@@ -23,13 +23,14 @@ private sealed class CacheEntry
2323 ".png" , ".jpg" , ".jpeg" , ".bmp" , ".gif" , ".webp" , ".tiff" , ".tif"
2424 } ;
2525
26+ // UIスレッドからは Peek のみを呼び出すこと (Peek はファイルI/Oを行わない)。
27+ // 読み込みは必ず GetAsync / GetFromFileSystem をバックグラウンドで行い、結果を BitmapCache に反映する。
2628 private static readonly Dictionary < string , CacheEntry > BitmapCache = [ ] ;
29+ private static readonly Dictionary < string , Task < Bitmap ? > > InFlightLoads = [ ] ;
2730 private static readonly Lock BitmapCacheLock = new ( ) ;
2831 private static int ThumbnailWarmupStarted = 0 ;
2932 private static int _compressedThumbnailMaxEdge = DefaultCompressedThumbnailMaxEdge ;
3033
31- internal static event Action < bool > ? ThumbnailCacheWarmupStateChanged ;
32-
3334 private const string ResourceRootPath = "avares://AvatarExplorer/Assets/Internal/" ;
3435 private static Uri GetAssetUri ( string fileName ) => new ( ResourceRootPath + fileName ) ;
3536 internal static readonly Dictionary < string , Bitmap ? > SystemIconsDictionary = new ( )
@@ -52,32 +53,58 @@ internal static bool IsImageFile(string filePath)
5253 return ! string . IsNullOrEmpty ( ext ) && SupportedImageExtensions . Contains ( ext ) ;
5354 }
5455
55- internal static Bitmap ? GetFromFileSystem ( string filePath )
56+ /// <summary>
57+ /// キャッシュ (またはシステムアイコン) から即時に取得する。ファイルI/Oを行わないためUIスレッドから呼び出せる。
58+ /// キャッシュに無い場合は null を返すので、呼び出し側はフォールバック表示 + GetAsync での後読みを行う。
59+ /// </summary>
60+ internal static Bitmap ? Peek ( string fileName )
61+ {
62+ if ( IsSystemIcon ( fileName ) ) return SystemIconsDictionary . GetValueOrDefault ( fileName ) ;
63+
64+ var filePath = Path . Join ( SystemPath . ItemThumbnailsFolderPath , fileName ) ;
65+ lock ( BitmapCacheLock )
66+ {
67+ return BitmapCache . TryGetValue ( filePath , out var entry ) ? entry . Bitmap : null ;
68+ }
69+ }
70+
71+ /// <summary>
72+ /// サムネイルをバックグラウンドで取得する。読み込みはファイル単位で重複排除され、結果はキャッシュされる。
73+ /// 返される Bitmap はキャッシュ所有 (共有) のため、呼び出し側で Dispose してはいけない。
74+ /// </summary>
75+ internal static Task < Bitmap ? > GetAsync ( string fileName )
5676 {
5777 try
5878 {
59- if ( ! File . Exists ( filePath ) ) return null ;
60- return LoadBitmap ( filePath , compressThumbnail : true ) ;
79+ if ( IsSystemIcon ( fileName ) ) return Task . FromResult ( SystemIconsDictionary . GetValueOrDefault ( fileName ) ) ;
80+
81+ var filePath = Path . Join ( SystemPath . ItemThumbnailsFolderPath , fileName ) ;
82+ lock ( BitmapCacheLock )
83+ {
84+ if ( InFlightLoads . TryGetValue ( filePath , out var inFlight ) ) return inFlight ;
85+
86+ var loadTask = Task . Run ( ( ) => LoadItemThumbnailAsync ( filePath ) ) ;
87+ InFlightLoads [ filePath ] = loadTask ;
88+ return loadTask ;
89+ }
6190 }
6291 catch ( Exception ex )
6392 {
64- ErrorManager . Instance . PostInternalError ( $ "Failed to get image from file system : { filePath } ", ex ) ;
65- return null ;
93+ ErrorManager . Instance . PostInternalError ( $ "Failed to get image for file: { fileName } ", ex ) ;
94+ return Task . FromResult < Bitmap ? > ( null ) ;
6695 }
6796 }
6897
69- internal static Bitmap ? Get ( string fileName )
98+ internal static Bitmap ? GetFromFileSystem ( string filePath )
7099 {
71100 try
72101 {
73- if ( IsSystemIcon ( fileName ) ) return SystemIconsDictionary [ fileName ] ;
74-
75- var filePath = Path . Join ( SystemPath . ItemThumbnailsFolderPath , fileName ) ;
76- return GetFromFileCache ( filePath , compressThumbnail : true ) ;
102+ if ( ! File . Exists ( filePath ) ) return null ;
103+ return LoadBitmap ( filePath , compressThumbnail : true ) ;
77104 }
78105 catch ( Exception ex )
79106 {
80- ErrorManager . Instance . PostInternalError ( $ "Failed to get image for file: { fileName } ", ex ) ;
107+ ErrorManager . Instance . PostInternalError ( $ "Failed to get image from file system : { filePath } ", ex ) ;
81108 return null ;
82109 }
83110 }
@@ -98,73 +125,81 @@ internal static bool IsImageFile(string filePath)
98125 }
99126 }
100127
128+ /// <summary>
129+ /// 全サムネイルのキャッシュをバックグラウンドで順次構築する。
130+ /// GetAsync と同じ経路を通るため、表示中アイテムの読み込みと重複することはない。
131+ /// </summary>
101132 internal static void StartThumbnailCacheWarmupInBackground ( IEnumerable < string > imageFileNames )
102133 {
103134 if ( Interlocked . Exchange ( ref ThumbnailWarmupStarted , 1 ) != 0 ) return ;
104135
105- ThumbnailCacheWarmupStateChanged ? . Invoke ( true ) ;
106-
107- _ = Task . Run ( ( ) =>
136+ _ = Task . Run ( async ( ) =>
108137 {
109138 try
110139 {
111140 if ( ! Directory . Exists ( SystemPath . ItemThumbnailsFolderPath ) ) return ;
112141
113- foreach ( var filePath in imageFileNames )
142+ foreach ( var fileName in imageFileNames . Where ( n => ! string . IsNullOrEmpty ( n ) && ! IsSystemIcon ( n ) ) )
114143 {
115- _ = GetFromFileCache ( Path . Join ( SystemPath . ItemThumbnailsFolderPath , filePath ) , compressThumbnail : true ) ;
144+ await GetAsync ( fileName ) . ConfigureAwait ( false ) ;
116145 }
117146 }
118147 catch ( Exception ex )
119148 {
120149 ErrorManager . Instance . PostInternalError ( "Failed to warmup thumbnail cache in background." , ex ) ;
121150 }
122- finally
123- {
124- ThumbnailCacheWarmupStateChanged ? . Invoke ( false ) ;
125- }
126151 } ) ;
127152 }
128153
129- private static Bitmap ? GetFromFileCache ( string filePath , bool compressThumbnail )
154+ private static async Task < Bitmap ? > LoadItemThumbnailAsync ( string filePath )
130155 {
131- var exists = File . Exists ( filePath ) ;
132- var lastWriteTimeUtc = DateTime . MinValue ;
133- if ( exists )
156+ try
134157 {
135- try
136- {
137- lastWriteTimeUtc = File . GetLastWriteTimeUtc ( filePath ) ;
138- }
139- catch ( Exception ex )
140- {
141- ErrorManager . Instance . PostInternalError ( $ "Failed to get last write time for file: { filePath } ", ex ) ;
142- return null ;
143- }
144- }
158+ var ( exists , lastWriteTimeUtc ) = GetFileState ( filePath ) ;
145159
146- lock ( BitmapCacheLock )
147- {
148- if ( BitmapCache . TryGetValue ( filePath , out var cacheEntry ) && cacheEntry . Exists == exists && cacheEntry . LastWriteTimeUtc == lastWriteTimeUtc )
160+ lock ( BitmapCacheLock )
149161 {
150- return cacheEntry . Bitmap ;
162+ if ( BitmapCache . TryGetValue ( filePath , out var entry ) && entry . Exists == exists && entry . LastWriteTimeUtc == lastWriteTimeUtc )
163+ {
164+ return entry . Bitmap ;
165+ }
151166 }
152167
153- var bitmap = exists ? LoadBitmap ( filePath , compressThumbnail ) : null ;
168+ var bitmap = exists ? LoadBitmap ( filePath , compressThumbnail : true ) : null ;
154169
155- if ( cacheEntry ? . Bitmap != null && ! ReferenceEquals ( cacheEntry . Bitmap , bitmap ) )
170+ lock ( BitmapCacheLock )
156171 {
157- cacheEntry . Bitmap . Dispose ( ) ;
172+ // 差し替え前の Bitmap は描画中のViewModelから参照されている可能性があるため Dispose しない (GCに回収を委ねる)
173+ BitmapCache [ filePath ] = new ( )
174+ {
175+ Bitmap = bitmap ,
176+ LastWriteTimeUtc = lastWriteTimeUtc ,
177+ Exists = exists ,
178+ } ;
158179 }
159180
160- BitmapCache [ filePath ] = new ( )
181+ return bitmap ;
182+ }
183+ finally
184+ {
185+ lock ( BitmapCacheLock )
161186 {
162- Bitmap = bitmap ,
163- LastWriteTimeUtc = lastWriteTimeUtc ,
164- Exists = exists ,
165- } ;
187+ InFlightLoads . Remove ( filePath ) ;
188+ }
189+ }
190+ }
166191
167- return bitmap ;
192+ private static ( bool Exists , DateTime LastWriteTimeUtc ) GetFileState ( string filePath )
193+ {
194+ try
195+ {
196+ if ( ! File . Exists ( filePath ) ) return ( false , DateTime . MinValue ) ;
197+ return ( true , File . GetLastWriteTimeUtc ( filePath ) ) ;
198+ }
199+ catch ( Exception ex )
200+ {
201+ ErrorManager . Instance . PostInternalError ( $ "Failed to get last write time for file: { filePath } ", ex ) ;
202+ return ( false , DateTime . MinValue ) ;
168203 }
169204 }
170205
0 commit comments