WPF サムネイル表示アプリ試作メモ2「サムネイル速度の改善」

コンピュータ

前回作成のアプリで気になる部分をアップデートしていきます。

気になるのは、多量の画像ファイルが保存されたディレクトリに移動した際、

表示まで待たされることがあります。

これはサムネイル画像のレンダリングまで時間が掛かることが原因です。

対策方法は色々考えられますが、まずは、キャッシュファイル機能を考えてみます。

身近な所のキャッシュファイルは、Webブラウザで表示する画像ファイルは、

ネット経由でダウンロードする場合、回線速度の関係で表示まで時間がかかりますが、

一度表示した、画像をストレージに保存することで、次回以降はストレージから読み込む

ことで、高速化を図る手法です。

画像のサムネイルの場合、同じPCストレージやNASなど比較的高速ですので、速くならない

ようにも思えますが、サムネイル画像は256x256pxなど比較的小さな解像度でファイルサイズが

小さく、また縮小処理にも処理時間が発生しますので、キャッシュファイル化することで、

レンダリング時間の短縮が望めます。

まずキャッシュファイルを保存するディレクトリに対して、保存するファイルサイズの上限を設定し、それを超えた場合古いファイルを削除します。

この処理を行わないと、キャッシュファイルが膨張してストレージ要領を圧迫してしまいます。

ThumbnailCacheCleaner.cs

using System.IO;

namespace WpfFileManager;
public static class ThumbnailCacheCleaner
{
    /// <summary>
    /// キャッシュサイズが上限を超えた場合、
    /// 指定日数経過したファイルを古い順に削除する
    /// </summary>
    public static void Cleanup(
        string cacheDir,
        long maxTotalBytes = 1000L * 1024 * 1024, // 1000MB
        int expireDays = 30)
    {
        if (!Directory.Exists(cacheDir))
            return;

        FileInfo[] files;

        try
        {
            files = new DirectoryInfo(cacheDir)
                .GetFiles("*", SearchOption.TopDirectoryOnly);
        }
        catch
        {
            return;
        }

        long totalSize = files.Sum(f => f.Length);

        // サイズ超過していなければ何もしない
        if (totalSize <= maxTotalBytes)
            return;

        DateTime expireBorder =
            DateTime.Now.AddDays(-expireDays);

        // 古い順
        var deleteTargets = files
            .Where(f => f.LastWriteTime < expireBorder)
            .OrderBy(f => f.LastWriteTime)
            .ToList();

        foreach (var file in deleteTargets)
        {
            try
            {
                file.Delete();
                totalSize -= file.Length;

                if (totalSize <= maxTotalBytes)
                    break;
            }
            catch
            {
                // 使用中・権限・競合は無視
            }
        }
    }
}

キャッシュのクリーナーの呼び出しはApp.xaml.csのコンストラクタで行うことで、アプリの初期化処理で行うようにします。

App.xaml.cs

public partial class App : Application
{
    public App()
    {
        ThumbnailCacheCleaner.Cleanup(AppPathHelper.CacheDir);
    }
}

次にキャッシュファイルのファイル名です。こちらは、

画像ファイルのフルパス-更新日時-ファイルサイズ

というような文字列を作り、それをMD5でハッシュ文字列へ変換し、拡張子の.PNGを加えてファイル名とします。

ファイルが更新されると更新日時及びファイルサイズが変更と成るため、新しいキャッシュファイルが出来上がります。

また、MD5によるハッシュは、キャッシュファイルが同一ディレクトリで重複しないようにするための対策です。

MD5は処理速度に優れ、重複する可能性も少ないので、採用しました。

FileItem.cs

    // 文字列からMD5ハッシュ文字列へ変換
    public static string ToMd5(string text)
    {
        using var md5 = MD5.Create();

        byte[] bytes = Encoding.UTF8.GetBytes(text);
        byte[] hash = md5.ComputeHash(bytes);

        // 32文字の16進文字列へ
        return Convert.ToHexString(hash).ToLowerInvariant();
    }
    // キャッシュファイルのパスを生成
    static string CachePath(string path)
    {
        string dir = AppPathHelper.CacheDir;
        var info = new FileInfo(path);
        string modified =
            info.LastWriteTime.ToString("yyyyMMddHHmmss");
        string size = info.Length.ToString();
        string filename = ToMd5($"{path}-{modified}-{size}") + ".png";
        string cacheFile = System.IO.Path.Combine(dir, filename);

        return cacheFile;
    }
    // サムネイル用に画像サイズの変更ルーチン
    public static Rect CalcFitRect(
        BitmapSource source,
        double targetWidth,
        double targetHeight)
    {
        double sw = source.PixelWidth;
        double sh = source.PixelHeight;

        if (sw <= 0 || sh <= 0)
            return new Rect(0, 0, targetWidth, targetHeight);

        // 縦横比を維持したスケールを計算
        double scale = Math.Min(
            targetWidth / sw,
            targetHeight / sh);

        double w = sw * scale;
        double h = sh * scale;

        // 中央寄せ
        double x = (targetWidth - w) / 2;
        double y = (targetHeight - h) / 2;

        return new Rect(x, y, w, h);
    }

    // サムネイル画像保存処理
    static void SaveThumbnail(
        BitmapSource source,
        string savePath)
    {

        // 縮小処理
        RenderTargetBitmap rtb =
            new RenderTargetBitmap(
                256,
                256,
                96,
                96,
                PixelFormats.Pbgra32);

        DrawingVisual dv = new();
        using (var dc = dv.RenderOpen())
        {
            dc.DrawRectangle(
                Brushes.Transparent,
                null,
                new Rect(0, 0, 256, 256));

            Rect rect = CalcFitRect(source, 256, 256);
            dc.DrawImage(source, rect);
        }

        rtb.Render(dv);
        rtb.Freeze();


        // BitmapSource => PNGエンコード
        var encoder = new PngBitmapEncoder();
        encoder.Frames.Add(BitmapFrame.Create(source));

        // ストレージへ保存
        using var fs = new FileStream(savePath, FileMode.Create);
        encoder.Save(fs);
    }    

実際試した速度は以下の通り

キャッシュ組み込み前: 1725 ms
キャッシュ初回:       1896 ms
キャッシュ2回目:       212 ms

組み込み前よりキャッシュの初回のほうが時間がかかっているのは、キャッシュの保存に掛かるルーチンが増えたことが要因と考えられます。
2回目以降は、かなり高速化していることが確認出来ます。

とはいえ、ディレクトリの移動のたびに1秒(1000ms)待たせられると、少し使い勝手が悪い。

対策として、サムネイル処理を遅延ロード(Lazy Loading)します。

これは、ファイルの一覧(ListView)を構成するFileItemの作成時にはサムネイル画像を作成せず、非同期処理でサムネイル作成を別スレッドで実行します。

その際、サムネイル画像の作成の終了を待たずに、FileItemを作成する事により、ディレクトリの切り替えを素早く行うことが出来ます。

サムネイル画像の表示は非同期処理側から、完了次第、ListViewのUIを書き換える用にすることで、後から少しづつUI側が書き換えられるような振る舞いになります。

FileItem.cs

    // ------------------------------
    // factory
    // ------------------------------

    public static FileItem FromPath(string path)
    {
        var name = System.IO.Path.GetFileName(path);

        // 先に表示する軽量サムネ
        var icon = GetIconCached(path);
        var jumbo = GetJumboIconCached(path) as BitmapSource;

        var item = new FileItem(path, name, icon, jumbo);

        // バックグラウンドで本物のサムネ生成
        Task.Run(() =>
        {
            var thumb = LoadImage(path);
            if (thumb == null) return;

            thumb.Freeze();

            Application.Current.Dispatcher.Invoke(() =>
            {
                // 後から上書き
                item.Thumb = thumb;
            });
        });

        return item;
    }

こちらの方は、速度の計測はしていませんが、概ねどのディレクトリ表示も待ちを感じることは無く、

1秒以下(多分500ms以下)で処理されていると思われます。

また、実行中であることを利用者に通知する方法として、プログレスバーを組み込んでは見ましたが、

こちらは、キャッシュ及び遅延ロードで速度が改善しため、

一瞬で処理されるのでプログレスバーの動きを目視することが出来ませんでした。

ウェイトを入れるとプログレスバーの動きが確認出来たので、コード的には問題が無さそうです。

せっかく作成しましたが、プログレスバーは、そのうちオミットしたいと思います。

コメント