Skip to content

Commit e763028

Browse files
authored
perf(ui): load thumbnails asynchronously to unblock startup rendering (#825)
1 parent 7796cb6 commit e763028

5 files changed

Lines changed: 202 additions & 87 deletions

File tree

AvatarExplorer.UI/Services/AppInitializer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ public static void StartThumbnailCacheWarmup()
3232
{
3333
var thumbnailFileNames = InstanceRepository.Items.GetAll()
3434
.Select(i => i.ThumbnailFileName)
35-
.Where(p => !string.IsNullOrEmpty(p));
35+
.Where(p => !string.IsNullOrEmpty(p))
36+
.ToArray();
3637
ImageService.StartThumbnailCacheWarmupInBackground(thumbnailFileNames);
3738
}
3839

AvatarExplorer.UI/Services/Utilities/ImageService.cs

Lines changed: 84 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -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

AvatarExplorer.UI/ViewModels/Component/BulkImportItemViewModel.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Avalonia.Media.Imaging;
2+
using Avalonia.Threading;
23
using AvatarExplorer.Core.Extensions;
34
using AvatarExplorer.UI.Localization;
45
using AvatarExplorer.UI.Models.Items;
@@ -47,9 +48,23 @@ public class BulkImportItemViewModel : ViewModelBase
4748

4849
public string ItemId { get; set; } = string.Empty;
4950

51+
private CancellationTokenSource? _thumbnailLoadCts;
52+
5053
public BulkImportItemViewModel Update(int iconSize = 80, bool removeBrackets = false)
5154
{
52-
Thumbnail = ImageService.Get(ThumbnailSource.Primary);
55+
_thumbnailLoadCts?.Cancel();
56+
_thumbnailLoadCts?.Dispose();
57+
var cts = new CancellationTokenSource();
58+
_thumbnailLoadCts = cts;
59+
60+
// UIスレッドではファイルI/Oを行わず、キャッシュ済みの画像のみ即時表示する
61+
Thumbnail = ImageService.Peek(ThumbnailSource.Primary);
62+
63+
if (!string.IsNullOrEmpty(ThumbnailSource.Primary) && !ImageService.IsSystemIcon(ThumbnailSource.Primary))
64+
{
65+
_ = ApplyThumbnailAsync(ImageService.GetAsync(ThumbnailSource.Primary), iconSize, cts.Token);
66+
}
67+
5368
Title = TitleLocalizable ? Localizer.Instance[TitleRaw] : TitleRaw;
5469

5570
Description = DescriptionRaw.Args == null ? Localizer.Instance[DescriptionRaw.Key] : Localizer.Instance.Get(DescriptionRaw.Key, DescriptionRaw.Args);
@@ -74,6 +89,26 @@ public BulkImportItemViewModel Update(int iconSize = 80, bool removeBrackets = f
7489
return this;
7590
}
7691

92+
private async Task ApplyThumbnailAsync(Task<Bitmap?> loadTask, int iconSize, CancellationToken ct)
93+
{
94+
try
95+
{
96+
var bitmap = await loadTask.ConfigureAwait(false);
97+
if (bitmap == null || ct.IsCancellationRequested) return;
98+
99+
await Dispatcher.UIThread.InvokeAsync(() =>
100+
{
101+
if (ct.IsCancellationRequested) return;
102+
Thumbnail = bitmap;
103+
Width = Height = iconSize;
104+
}, DispatcherPriority.Normal, ct);
105+
}
106+
catch (OperationCanceledException)
107+
{
108+
// キャンセルされた場合は何もしない
109+
}
110+
}
111+
77112
public BulkImportItemViewModel Copy()
78113
{
79114
return new()

AvatarExplorer.UI/ViewModels/Component/ItemPathViewModel.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,20 @@ public ItemPathViewModel(string fileName, string path, ItemPathType type)
2727

2828
if (type == ItemPathType.Unknown)
2929
{
30-
IconImage = ImageService.Get(SystemIconKey.UnknownFileIcon);
30+
IconImage = ImageService.Peek(SystemIconKey.UnknownFileIcon);
3131
}
3232
else if (type == ItemPathType.URL)
3333
{
34-
IconImage = ImageService.Get(SystemIconKey.LinkIcon);
34+
IconImage = ImageService.Peek(SystemIconKey.LinkIcon);
3535
IsUrl = true;
3636
}
3737
else if (type == ItemPathType.File)
3838
{
39-
IconImage = ImageService.Get(SystemIconKey.FileIcon);
39+
IconImage = ImageService.Peek(SystemIconKey.FileIcon);
4040
}
4141
else if (type == ItemPathType.Folder)
4242
{
43-
IconImage = ImageService.Get(SystemIconKey.FolderIcon);
43+
IconImage = ImageService.Peek(SystemIconKey.FolderIcon);
4444
}
4545
}
4646
}

0 commit comments

Comments
 (0)