WPF の BitmapImage で表示した画像ファイルが削除・上書きできなくなる問題の解決方法

BitmapImage で表示した画像ファイルがロックされ、削除・上書きできなくなる。原因である既定のキャッシュ動作と、BitmapCacheOption.OnLoad・StreamSource による解決方法を整理する。

概要

WPF の Image コントロールに BitmapImage を与えてローカルの画像ファイルを表示すると、アプリケーションの実行中はそのファイルを削除・上書きできなくなる。
本記事では、この現象が BitmapImage による明示的なロックではない点を説明する。
実際の原因は、既定のキャッシュ方針が画像ソースへのアクセスを保持し続けることにある。
そのうえで BitmapCacheOption.OnLoad による解決方法、UriSourceStreamSource の書き分け、BitmapCacheOption 各値の比較、Freeze とメモリ消費の注意点を整理する。


前提・対象環境


問題

ファイルパスから BitmapImage を生成して Image.Source に設定した後、同じファイルを削除または上書きしようとすると例外が発生する。
以下は、最も素直に書いた場合の再現コードである。

// 画像を表示する
PreviewImage.Source = new BitmapImage(new Uri(path, UriKind.Absolute));

// 同じファイルを削除・上書きしようとすると IOException になる
File.Delete(path);

Image の表示自体は成功するが、File.Delete は対象ファイルが使用中である旨の IOException を送出する。
上書き保存やリネームも同様に失敗する。
この失敗は確実に再現するとは限らない。
表示を止めて BitmapImage への参照を捨てた後にガベージコレクションが走ると、ファイルが解放されて削除に成功することがある。
このため「たまに削除できる」不安定な不具合として現れやすい。

XAML でパスを与えた場合も同じ結果になる。
次の記述は、Source にパス文字列を直接与えて画像を表示する最も短い書き方である。

<Image Source="{Binding ImagePath}" Stretch="Uniform" />

Source に文字列を直接与える書き方は ImageSourceConverter による型変換に委ねられており、CacheOption を指定する余地が無い。
そのため既定のキャッシュ動作のまま読み込まれ、コードから生成した場合と同じくファイルが保持される。


原因・背景

原因は BitmapImageキャッシュ方針にある。
BitmapImage.CacheOption の既定値は BitmapCacheOption.Default である。
CacheOption プロパティの公式ドキュメントは、既定の動作を次のように説明している。

既定の OnDemand キャッシュ オプションは、イメージが必要になるまでストリームへのアクセスを保持し、クリーンアップはガベージ コレクターによって処理されます。

出典: BitmapImage.CacheOption プロパティ

DefaultOnDemand が併記されるのは、BitmapCacheOption 列挙型で両者の値がいずれも 0 と定義されており、同一の値だからである。
ただし列挙型側の説明文は一致していない。
Default は「イメージ全体をメモリにキャッシュする」、OnDemand は「要求されたデータのみのメモリ ストアを作成する」と記述されており、公式ドキュメント内で食い違っている。
値が同一である以上、実際に適用される動作は一つである。
CacheOption の注釈は OnDemand を既定として名指ししたうえで、「イメージが必要になるまでストリームへのアクセスを保持し」と説明している。

OnDemand は、要求されたデータの分だけメモリストアを作る方式である。
最初の要求では画像が直接読み込まれ、以降の要求はキャッシュから満たされる。
後続の読み出しに備え、画像ソースへのアクセスが保持される。
オブジェクトの初期化そのものを必要になるまで遅らせるのは CacheOption ではなく BitmapCreateOptions.DelayCreation の役割であり、両者は独立した設定である。

UriSource にローカルファイルを指定した場合、保持されるのは WPF が内部で開いたファイルストリームだと考えられる。
これがファイルを開いたままにする実体であり、アプリケーション側から明示的に閉じる手段は無い。
解放を担うのはガベージコレクターであり、これが「削除できたりできなかったりする」挙動の背景である。

公式ドキュメントが OnLoad の効果として明示しているのは、「BitmapImage の作成に使ったストリームを閉じられる」という点である。
UriSource に渡したローカルファイルについて同じ文言があるわけではないが、保持されるのは同じく内部のストリームであるため、同じ仕組みが働く。

問題の本質は、BitmapImage がファイルを排他的に掴むことではなく、後続の読み出しに備えてソースを開いたまま保持する設計にある。
したがって解決策は「ロックを外す」ことではなく、「読み込み時点で画像全体をメモリへ取り込み、ソースを保持する必要をなくす」ことになる。


解決方法

CacheOptionBitmapCacheOption.OnLoad を指定する。
OnLoad は読み込み時に画像全体をメモリへキャッシュし、以降の画像データ要求はすべてメモリストアから満たされる。
ソースを読み続ける必要が無くなるため、初期化完了後にファイルやストリームを解放できる。

既定の動作と OnLoad の違いを図にすると次のようになる。

既定のキャッシュ動作と OnLoad の比較図。既定では BitmapImage が画像ファイルのストリームを開いたまま保持し、File.Delete が IOException となる。解放のタイミングは GC 任せである。OnLoad では EndInit の完了時点で画像全体がメモリへ取り込まれてストリームが閉じられ、File.Delete が成功する。
上段が既定(Default / OnDemand)、下段が OnLoad のときのファイルストリームの扱い。既定では後続の読み出しに備えてストリームが開いたまま残り、解放のタイミングはガベージコレクターに委ねられる。OnLoad では EndInit の完了時点で画像全体がメモリへ取り込まれるため、ストリームが閉じられて削除・上書きが可能になる。

CacheOptionBitmapImage の初期化中にしか設定できない。
BitmapImageISupportInitialize を実装しており、プロパティの設定は BeginInitEndInit の間で行う必要がある。
初期化完了後のプロパティ変更は無視される。

アプローチは 2 つある。


実装例

UriSource に OnLoad を組み合わせる

BeginInit / EndInit ブロック内で CacheOptionUriSource を設定する。
EndInit の時点で画像全体がメモリへ取り込まれるため、戻り値を受け取った後はファイルを削除・上書きできる。

private static BitmapImage LoadWithoutLocking(string path)
{
    var bitmap = new BitmapImage();
    bitmap.BeginInit();
    bitmap.CacheOption = BitmapCacheOption.OnLoad;
    bitmap.UriSource = new Uri(path, UriKind.Absolute);
    bitmap.EndInit();
    bitmap.Freeze();
    return bitmap;
}

Freeze の呼び出しは必須ではないが、BitmapImageFreezable の派生クラスであり、凍結すると変更通知のコストが無くなるうえ、スレッド間で共有できるようになる。
非同期に画像を読み込んで UI スレッドへ渡す構成では、凍結が事実上の前提となる。
ローカルファイルを OnLoad で読み込んだ直後は凍結可能だが、条件が読めない場面では後述のとおり CanFreeze で判定してから呼ぶ。

ここで注意が必要なのは、BitmapImage(Uri) コンストラクタとの違いである。
このコンストラクタで生成した BitmapImage自動的に初期化済みとなり、以降のプロパティ変更は無視される。
そのため、次のコードは OnLoad が反映されずファイルが保持されたままになる。

// 生成時点で初期化が完了しているため、CacheOption の変更は無視される
var bitmap = new BitmapImage(new Uri(path, UriKind.Absolute));
bitmap.CacheOption = BitmapCacheOption.OnLoad;

OnLoad を効かせるには、引数なしコンストラクタと BeginInit / EndInit の組み合わせを使う必要がある。

StreamSource に自前のストリームを与える

ファイルの開き方を制御したい場合は、FileStream を自分で開いて StreamSource に渡す。
OnLoad を指定していれば EndInit の完了時点で画像全体がメモリへ取り込まれているため、using ブロックを抜けてストリームを破棄しても画像は表示できる。

private static BitmapImage LoadFromStream(string path)
{
    var bitmap = new BitmapImage();
    using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        bitmap.BeginInit();
        bitmap.CacheOption = BitmapCacheOption.OnLoad;
        bitmap.StreamSource = stream;
        bitmap.EndInit();
    }

    bitmap.Freeze();
    return bitmap;
}

FileShare は、ファイルを開いている間に、同一プロセス・他プロセスを問わず後続のオープンへ許可するアクセス種別を指定する。
FileShare.ReadWrite を指定しているため、この FileStream を開いている間も他のプロセスが同じファイルを読み書き用に開ける。
ただし ReadWrite が許可するのは読み取りと書き込みだけであり、削除・リネームは含まれない。
読み込み中の削除まで許可する必要がある場合は、FileShare.ReadWrite | FileShare.Delete のように Delete を併せて指定する。

StreamSourceUriSource の両方を設定した場合、StreamSource は無視される。
この方式では UriSource を設定しない。

XAML での指定と制約

表示する画像が固定パスで決まっている場合は、XAML だけで完結する。
Source に文字列を書いて型コンバーターに任せるのではなく、BitmapImage をオブジェクト要素として記述し、CacheOption を指定する。
オブジェクト要素に書いたプロパティ設定は初期化の一部として反映されるため、この記述で OnLoad が有効になる。

<Image Stretch="Uniform">
    <Image.Source>
        <BitmapImage UriSource="C:\work\preview.png" CacheOption="OnLoad" />
    </Image.Source>
</Image>

この BitmapImage は XAML の解析時に一度だけ生成される。
初期化後のプロパティ変更は無視されるため、UriSource にバインドを設定してもパスの切り替えは反映されない。
表示する画像を実行時に差し替える場合は、パス文字列から BitmapImage を生成する IValueConverter を挟むか、ViewModel 側で ImageSource 型のプロパティを公開して Image.Source にバインドする。
後者では値が変わるたびに新しい ImageSource が渡されるため、前掲の LoadWithoutLocking で生成した BitmapImage をそのまま代入する。


注意点


代替案・比較

方法 ファイルの解放 メモリストア 適するケース
UriSource + 既定(Default / OnDemand 解放されない(GC 任せ) 要求されたデータ分のみ作成 実行中に差し替えの発生しない固定的な画像
UriSource + OnLoad 初期化完了時に解放 読み込み時に画像全体を作成 実行時に削除・上書きし得るローカルファイル
StreamSource + OnLoad using で明示的に解放 読み込み時に画像全体を作成 共有モードの指定やメモリ上のデータからの生成が必要な場合
BitmapCacheOption.None 解放されない 作成しない 要求のたびにファイルから読み直してよい場合

UriSourceStreamSource の選択基準は明確である。
パスから読むだけなら UriSource で足りる。
FileShare の指定、ネットワーク越しや暗号化されたデータの復号結果など、ストリームの取得方法を自分で決める必要がある場合に StreamSource を選ぶ。


まとめ

BitmapImage による画像ファイルのロックは、既定のキャッシュ方針が後続の読み出しに備えてソースを開いたまま保持することに起因する。
解決策の選択基準は次のとおりである。

いずれの方法にも共通する前提として、BitmapImage(Uri) コンストラクタは初期化を自動的に完了させ、以降のプロパティ変更を無視する。
OnLoad を使う実装では、必ず引数なしコンストラクタと BeginInit / EndInit の組み合わせを用いる。