俺が遭遇したWPFイメージコントロールのメモリーリークと回避法(?)の1つ

俺が遭遇したWPFイメージコントロールのメモリーリークと回避法(?)の1つ

仕事でオンメモリで画像のサムネイルを表示するアプリケーションを作ってた時に、無邪気にWPFやーDatabindingでMVVMやーって
喜んでたら地獄に叩き落された。
実はこの話題は結構FAQらしくってgoogleで「wpf image source memory leak」で検索したら結構 StackOverflow もヒットする。
大体書いてる解決策は

    var bmpImage = new BitmapImage();
    bmpImage.BeginInit();
    bmpImage.CacheOption = BitmapCacheOption.OnLoad;    //ココ
    bmpImage.CreateOptions = BitmapCreateOptions.None;  //ココ
    bmpImage.UriSource = new Uri(fileName);
    bmpImage.EndInit();
    bmpImage.Freeze();                                  //ココ

キャッシュさすな、Freezeしろでファイナルアンサー
それで解決できるって書いてる。ほんまか?

検証コードは↓こんな感じ。

<Window x:Class="ImageMemoryDisposeTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="44"/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <Button Content="Button" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="75" Height="24" Click="AddClick"/>
        <Button Content="Button" HorizontalAlignment="Left" Margin="432,10,0,0" VerticalAlignment="Top" Width="75" Click="ClearClick"/>
        <ScrollViewer  Grid.Row="1">
            <WrapPanel x:Name="wp"/>
        </ScrollViewer>
    </Grid>
</Window>
using System;
using System.Diagnostics;
using System.Drawing;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Image = System.Windows.Controls.Image;

namespace ImageMemoryDisposeTest
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        [System.Runtime.InteropServices.DllImport("gdi32.dll")]
        public static extern bool DeleteObject(IntPtr hObject);

        public MainWindow()
        {
            InitializeComponent();
        }

        private ImageSource LoadImagesMemoryLeaks(string fileName)
        {
            var bmpImage = new BitmapImage();
            bmpImage.BeginInit();
            bmpImage.CacheOption = BitmapCacheOption.OnLoad;
            bmpImage.CreateOptions = BitmapCreateOptions.None;
            bmpImage.DecodePixelWidth = 512;
            bmpImage.UriSource = new Uri(fileName);
            bmpImage.EndInit();
            bmpImage.Freeze();

            return bmpImage;
        }

        private ImageSource LoadImagesMemoryNoLeaks(string fileName)
        {
            BitmapSource source = null;
            using (var bmp = new Bitmap(fileName))
            {
                var dest = new Bitmap(512, 512);
                var g = Graphics.FromImage(dest);
                g.DrawImage(bmp, 0, 0, 512, 512);

                var hBitmap = dest.GetHbitmap();
                try
                {
                    source = Imaging.CreateBitmapSourceFromHBitmap(
                        hBitmap, IntPtr.Zero,
                        Int32Rect.Empty,
                        BitmapSizeOptions.FromWidthAndHeight(32, 32));
//                        BitmapSizeOptions.FromEmptyOptions());
                }
                finally
                {
                    DeleteObject(hBitmap);
                }
            }

            return source;
        }

        private void AddClick(object sender, RoutedEventArgs e)
        {
            var file = @"C:\images\test.jpg";
            for (int i = 0; i < 100; i++)
            {
                var imageSource = LoadImagesMemoryNoLeaks(file);

                var image = new Image();
                image.Width = 32;
                image.Height = 32;
                image.Source = imageSource;

                wp.Children.Add(image);
            }
        }

        private void ClearClick(object sender, RoutedEventArgs e)
        {
            foreach (var ch in wp.Children)
            {
                var img = ch as Image;
                Debug.Assert(ch != null);

                img.Source = null;
                img.Tag = null;
            }
            wp.Children.Clear();
        }
    }
}

ウィンドウ1つに、ボタンが2つと WrapPanel が1つあるだけ。
最初の画像読み込み前のメモリの使用状況を見張ったのち、安定したらサムネイルを読み込んで、解放する。
Imageコントロールを作ってWrapPanelに追加するコードと解放するコードはリークする方もしない方も共通。
Imageコントロールのソースを作るところだけが違う。

で、実際の実行結果を見ると
モリーリークする方の結果
開始時点(画像読み込む前)のメモリー使用状況。

そして読み込み後のメモリー使用状況。

その後で解放ボタンを押して表示が消えてもメモリの使用状況はほとんど変化しない。


一方メモリーリークしない方。
開始時点(画像読み込む前)のメモリー使用状況。

そして読み込み後のメモリー使用状況。

解放ボタンをクリックして(しばらく待って)ガベージコレクションが終わった状態が

メモリの使用量の多寡だけでメモリーリークうんぬんというのもエンジニアらしくないんで(まあ、帰納的検証ではあるんやろけど)
より明確な違いを捕まえとく。

windbgを起動して、解放ボタンを押した後のそれぞれのプロセスがどんな感じのリソース参照を持ってるか調べる。

コマンドラインwindbg

windbg 上でメニューからプロセスにアタッチ

windbg のコマンドで sos を読み込む .loadby sos clr

最後に !dumpheap -stat

プロセス 10672(リークする方)は Image の参照が大量に残っている

プロセス 8012(リークしない方)は Image の参照が消えている。

ほんで、試してはないんやけど今の自分の知識で検索してみると、↓こういうやり方でも回避できるんかもって外人さんの記事がある。(2エントリ連続)

“Memory leak” with BitmapImage and MemoryStream — Logos Bible Software Code Blog
http://code.logos.com/blog/2008/04/memory_leak_with_bitmapimage_and_memorystream.html

WrappingStream Implementation — Logos Bible Software Code Blog
http://code.logos.com/blog/2009/05/wrappingstream_implementation.html

ちゃんと読んでないけど、Streamのラッパクラス作ってオブジェクトの参照を弱めてるかバグを回避してるんじゃないかと推測する。
で、これを回避するために使った CreateBitmapSourceFromHBitmap の情報があまりにも少なすぎて、知り合いのブログにぶち当たったって次第。

まとまってないけど、ぱぱっと書いた記録として。