[WPF] BitmapImage の生成・初期化を非同期で行う際のメモリの問題について

この記事は、前回のエントリ WPFで「UriSouceプロパティに画像のURLを入れてBitmapImageを初期化する処理」を非同期で実行するとしぬ - pierre3のブログ
の続きになります。

目次

前回のおさらい

前回は、Web画像のURLからBitmapImageを、UIをブロックする事無く生成するには、以下の様に記述すると良さそう。
というところまで書きました。

public async Task<BitmapImage> DownloadImageAsync(string url)
{
    using (var web = new System.Net.Http.HttpClient())
    {
        //HttpClientでbyte配列に先読みしておいて
        var bytes = await web.GetByteArrayAsync(url).ConfigureAwait(false);
        // そのbyte配列でMemoryStream を作成し、StreamSourceプロパティに設定
        using (var stream = new MemoryStream(bytes))
        {
            var bitmap = new BitmapImage();
            bitmap.BeginInit();
            bitmap.StreamSource = stream;
            bitmap.CacheOption = BitmapCacheOption.OnLoad;
            bitmap.EndInit();
            bitmap.Freeze();
            return bitmap;
        }
    }
} 

しかし、このコードにはメモリリークを引き起こす問題が2つあります。
まずは、そのうちの1つから

MemoryStream はDisposeしても内部バッファを離さない

上のコードでは、EndInit()での初期化が終わった時点でStreamSourceプロパティに設定したMemoryStreamは不要になり、using のスコープを抜けるとDispose()されます。

しかし、MemoryStream では、コンストラクタに渡した byte配列の参照を内部バッファとして保持しているのですが、それをDispose()後も抱えたまま離さないようなのです。

f:id:pierre3:20151024135114p:plain

※ Dispose()後も_buffer が残ったまま!

画像データとしては、Bitmapにデコードされた表示用の画素データと、デコード前のbyte配列(内部バッファ)の両方を持つことになります。
この状態のBitmapImageは、単純計算で必要な量の2倍のメモリ空間を占有しているってことです。
特に大きな画像を読み込んだ場合は、なかなか無視できない容量になるのではないでしょうか。

Dispose時にMemoryStreamの参照を外すストリームクラスでラップする

対処法は、以下サイトにありました。

MemoryStreamをラップしたStreamクラスを作って Dispose時にMemoryStreamの参照を外すという方法でこの問題を回避するようです。

以下のようなStreamのラップクラスを用意して

/// <summary>
/// ストリームのWrapperクラス
/// </summary>
/// <remarks>
/// Dispose 時に、内部ストリームの参照を外します
/// </remarks>
public class WrappingStream : Stream
{
    Stream m_streamBase;

    public WrappingStream(Stream streamBase)
    {
        if (streamBase == null)
        {
            throw new ArgumentNullException("streamBase");
        }
        m_streamBase = streamBase; //渡したStreamを内部ストリームとして保持
    }

    //Streamクラスのメソッドをオーバーライドして、内部ストリームの同じメソッドをそのまま呼ぶだけ
    public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken)
    {
        ThrowIfDisposed();
        return m_streamBase.ReadAsync(buffer, offset, count, cancellationToken);
    }
    public new Task<int> ReadAsync(byte[] buffer, int offset, int count)
    {
        ThrowIfDisposed();
        return m_streamBase.ReadAsync(buffer, offset, count);
    }
    //...(中略)...

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            m_streamBase.Dispose();
            m_streamBase = null;  //disposeしたら内部ストリームをnullにして参照を外す
        }
        base.Dispose(disposing);
    }
    private void ThrowIfDisposed()
    {
        if (m_streamBase == null)
        {
            throw new ObjectDisposedException(GetType().Name);
        }
    }
}

MemoryStream をこれでラップしたうえで StreamSourceプロパティに設定します。

public async Task<BitmapImage> DownloadImageAsync(string url)
{
    using (var web = new HttpClient())
    {
        var bytes = await web.GetByteArrayAsync(url).ConfigureAwait(false);
        using (var stream = new WrappingStream(new MemoryStream(bytes)))
        {
            var bitmap = new BitmapImage();
            bitmap.BeginInit();
            bitmap.StreamSource = stream;
            bitmap.CacheOption = BitmapCacheOption.OnLoad;
            bitmap.EndInit();
            bitmap.Freeze();
            return bitmap;
        }
    }
} 

以下、サムネイル画像50枚分のBitmapImageを作成した状態のスナップショットでヒープを比較したものです。効果は一目瞭然だと思います。

・MemoryStreamのみの場合 f:id:pierre3:20151024214006p:plain

・WrappingStreamを使用した場合 f:id:pierre3:20151024214020p:plain

これで問題の1つは解決できました。

そして、もう1つの問題が以下になります。

DispatcherObjectをバックグラウンドスレッドで作成するとメモリリークする

この問題については、以下のエントリに詳しいので、そちらをご参照ください。

上記エントリで記述されている通り、BeginInvokeShutdown()Dispatcher.Run を使って回避する方法もあるようなのですが、
今回はバックグラウンドスレッド上でBItmapImageを生成しないようにすることで、対処したいと思います。

ダウンロード処理とBitmapImage生成処理を分離する

最も時間のかかるダウンロード部分のみバックグラウンドで非同期実行して、ダウンロードした結果を基にBitmapImageを生成・初期化する部分はUIスレッド上で行う様にします。

画像データを非同期ダウンロードしてbyte配列で返す

まずは、画像データのダウンロード部分。

(2015/10/26 訂正)
System.Net.Http.HttpClient の GetByteArrayAsync() の結果をそのまま返すだけです。
System.Net.Http.HttpClient の GetByteArrayAsync() を await してその結果を返します。

// このままではDownload完了前に HttpClient がDispose()されてしまう
//public static Task<byte[]> DownLoadImageBytesAsync(string url)
//{
//    using (var web = new HttpClient())
//    {
//        return web.GetByteArrayAsync(url);
//    }
//}

public static async Task<byte[]> DownLoadImageBytesAsync(string url)
{
    using (var web = new HttpClient())
    {
        return await web.GetByteArrayAsync(url);
    }
}

受け取ったbyte配列からBitmapImageを作成する

上記メソッドで取得したbyte配列を受け取って、MemoryStream を作ります。
さらに、WrappingStreamにラップしたものをStreamSourceプロパティに設定してBitmapImageを初期化します。

public static BitmapImage CreateBitmap(byte[] bytes, bool freezing = true)
{
    using (var stream = new WrappingStream(new MemoryStream(bytes)))
    {
        var bitmap = new BitmapImage();
        bitmap.BeginInit();
        bitmap.StreamSource = stream;
        bitmap.CacheOption = BitmapCacheOption.OnLoad;
        bitmap.EndInit();
        if (freezing && bitmap.CanFreeze)
        { bitmap.Freeze(); }
        return bitmap;
    }
}

DownLoadImageBytesAsync()をバックグラウンドで実行後、指定したディスパッチャ上でCreateBitmap()を実行する

上の2つのメソッドを組み合わせて呼び出します。
CreateBitmap() を実行するDispatcher を引数で指定できるようにしています。
今回はUIスレッド上で処理を行いたいので、App.Current.Dispatcher を指定します。

public static async Task<BitmapImage> DownloadImageAsync(string url, 
    Dispatcher dispatcherForBitmapCreation)
{
    var bytes = await DownLoadImageBytesAsync(url).ConfigureAwait(false);
    return await dispatcherForBitmapCreation.InvokeAsync(() => CreateBitmap(bytes));
}

// 使用例
// var bitmap = await DownloadImageAsync(downloadUrl, App.Current.Dispatcher);

おまけ

Rxを使って一度にダウンロードする場合は、DownloadImageAsync() にまとめず、個別に実行する方が分かりやすいかもしれません。

private ObservableCollection imageSource;
public void DownloadSearchResult(IEnumerable<Uri> imageUrlList)
{
    //IEnumerable<T>を、スレッドプール上で動作するIObservable<T> に変換
    disposable = imageUrlList.ToObservable(ThreadPoolScheduler.Instance)
        //画像データをまとめてダウンロード
        .SelectMany(async uri => await DownLoadImageBytesAsync(uri))
        //UIスレッドに処理を移す
        .ObserveOn(Reactive.Bindings.UIDispatcherScheduler.Default)
        //BitmapImageを作成してコレクションに追加
        .Subscribe( bytes => imagesSource.Add(CreateBitmap(bytes));   
}

//ここで使用しているReactive.Bindings.UIDispatcherScheduler は
//ReactiveProperty が用意するクラスです。App.xaml.cs でAppクラスのOnStartup()あたりで
// Reactive.Bindings.UIDispatcherScheduler.Initialize();
//としておくと、UIDispatcherScheduler.Default で UIスレッドのDispatcherが取得できます