XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

优化最近使用卡片图标缓存与加载机制

新增 RecentUsageIconCache 类,支持 WPF 图像冻结与 32 容量 FIFO 缓存,提升图标加载性能。优化 MainPageViewModel 刷新与缓存复用逻辑,RecentUsageCardViewModel 优先从缓存获取图标并异步加载。ToolBoxPage、DownloadPage 详情页写入缓存,主页可直接复用。新增 RecentUsageIconCacheTests 单元测试,验证缓存冻结、复用与淘汰。更新 XFEToolBox.Client.Wpf.Test.csproj 引用。

af8b779
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

7 个文件 +270 -40
Added XFEToolBox.Client.Wpf.Test/RecentUsageIconCacheTests.cs +89 -0
@@ -0,0 +1,89 @@
1 using System.Windows.Media;
2 using System.Windows.Media.Imaging;
3 using XFEToolBox.Client.Models;
4 using XFEToolBox.Client.Utilities;
5
6 namespace XFEToolBox.Client.Wpf.Test;
7
8 public static class RecentUsageIconCacheTests
9 {
10 [Test]
11 public static void RecentUsageIconsAreFrozenReusedAndBounded()
12 {
13 Exception? failure = null;
14 var thread = new Thread(() =>
15 {
16 try
17 {
18 RecentUsageIconCache.Clear();
19 var icon = CreateIcon(0x98, 0x98, 0xE7);
20 Ensure(!icon.IsFrozen, "测试图标在写入缓存前不应被冻结。");
21
22 RecentUsageIconCache.Remember(RecentUsageKind.Tool, "Code-Line-Counter", icon);
23 Ensure(icon.IsFrozen, "最近使用图标写入缓存时没有被冻结。");
24 Ensure(RecentUsageIconCache.TryGet(
25 RecentUsageKind.Tool,
26 "code-line-counter",
27 out var cachedIcon),
28 "主页没有按不区分大小写的工具 ID 找到会话图标。");
29 Ensure(ReferenceEquals(icon, cachedIcon),
30 "主页没有直接复用工具箱已经解码完成的图标实例。");
31
32 for (var index = 0; index < 40; index++)
33 RecentUsageIconCache.Remember(
34 RecentUsageKind.Software,
35 $"software-{index}",
36 CreateIcon((byte)index, 0x98, 0xE7));
37
38 Ensure(!RecentUsageIconCache.TryGet(
39 RecentUsageKind.Tool,
40 "code-line-counter",
41 out _),
42 "图标缓存超过容量后没有淘汰最早的条目。");
43 Ensure(RecentUsageIconCache.TryGet(
44 RecentUsageKind.Software,
45 "software-39",
46 out _),
47 "图标缓存错误淘汰了最新条目。");
48 }
49 catch (Exception exception)
50 {
51 failure = exception;
52 }
53 finally
54 {
55 RecentUsageIconCache.Clear();
56 }
57 })
58 {
59 IsBackground = true,
60 Name = "Recent usage icon cache test"
61 };
62
63 thread.SetApartmentState(ApartmentState.STA);
64 thread.Start();
65 Ensure(thread.Join(TimeSpan.FromSeconds(10)), "最近使用图标缓存测试超时。");
66 if (failure is not null)
67 throw new InvalidOperationException($"最近使用图标缓存测试失败:{failure.Message}", failure);
68 }
69
70 private static BitmapSource CreateIcon(byte red, byte green, byte blue)
71 {
72 const int size = 4;
73 var pixels = new byte[size * size * 4];
74 for (var index = 0; index < pixels.Length; index += 4)
75 {
76 pixels[index] = blue;
77 pixels[index + 1] = green;
78 pixels[index + 2] = red;
79 pixels[index + 3] = 0xFF;
80 }
81 return BitmapSource.Create(size, size, 96, 96, PixelFormats.Bgra32, null, pixels, size * 4);
82 }
83
84 private static void Ensure(bool condition, string message)
85 {
86 if (!condition)
87 throw new InvalidOperationException(message);
88 }
89 }
Modified XFEToolBox.Client.Wpf.Test/XFEToolBox.Client.Wpf.Test.csproj +2 -0
@@ -23,6 +23,8 @@
23 23 <Compile Include="..\XFEToolBox.Client.Installer\Utilities\InstallationService.cs" Link="Installer\InstallationService.cs" />
24 24 <Compile Include="..\XFEToolBox.Client.Installer\Utilities\ZipHelper.cs" Link="Installer\ZipHelper.cs" />
25 25 <Compile Include="..\XFEToolBox\Utilities\WebImageSourceLoader.cs" Link="Utilities\WebImageSourceLoader.cs" />
26 <Compile Include="..\XFEToolBox\Models\RecentUsageEntry.cs" Link="Models\RecentUsageEntry.cs" />
27 <Compile Include="..\XFEToolBox\Utilities\RecentUsageIconCache.cs" Link="Utilities\RecentUsageIconCache.cs" />
26 28 </ItemGroup>
27 29
28 30 <ItemGroup>
Added XFEToolBox/Utilities/RecentUsageIconCache.cs +62 -0
@@ -0,0 +1,62 @@
1 using System.Windows.Media;
2 using XFEToolBox.Client.Models;
3
4 namespace XFEToolBox.Client.Utilities;
5
6 /// <summary>
7 /// 保存工具箱和下载专区已经解码过的冻结图标,让主页最近使用卡片直接复用,
8 /// 避免页面切换时再次解析 Base64、SVG 或 GIF。
9 /// </summary>
10 public static class RecentUsageIconCache
11 {
12 private const int MaximumEntries = 32;
13 private static readonly object SyncRoot = new();
14 private static readonly Dictionary<string, ImageSource> Images = new(StringComparer.OrdinalIgnoreCase);
15 private static readonly Queue<string> InsertionOrder = new();
16
17 public static void Remember(RecentUsageKind kind, string targetId, ImageSource? image)
18 {
19 if (string.IsNullOrWhiteSpace(targetId) || image is null)
20 return;
21
22 if (!image.IsFrozen)
23 {
24 if (!image.CanFreeze)
25 return;
26 image.Freeze();
27 }
28
29 var key = CreateKey(kind, targetId);
30 lock (SyncRoot)
31 {
32 if (Images.ContainsKey(key))
33 {
34 Images[key] = image;
35 return;
36 }
37
38 Images.Add(key, image);
39 InsertionOrder.Enqueue(key);
40 while (Images.Count > MaximumEntries && InsertionOrder.TryDequeue(out var oldestKey))
41 Images.Remove(oldestKey);
42 }
43 }
44
45 public static bool TryGet(RecentUsageKind kind, string targetId, out ImageSource image)
46 {
47 lock (SyncRoot)
48 return Images.TryGetValue(CreateKey(kind, targetId), out image!);
49 }
50
51 internal static void Clear()
52 {
53 lock (SyncRoot)
54 {
55 Images.Clear();
56 InsertionOrder.Clear();
57 }
58 }
59
60 private static string CreateKey(RecentUsageKind kind, string targetId) =>
61 $"{kind}:{targetId.Trim()}";
62 }
Modified XFEToolBox/ViewModel/Pages/MainPageViewModel.cs +36 -6
@@ -31,6 +31,7 @@ public partial class MainPageViewModel : ObservableObject
31 31 private readonly DispatcherTimer adminRefreshTimer;
32 32 private bool isAdminOverviewLoading;
33 33 private bool hasAdminOverviewSnapshot;
34 private bool hasRecentUsageSnapshot;
34 35
35 36 public MainPage MainPage { get; }
36 37
@@ -70,7 +71,8 @@ public partial class MainPageViewModel : ObservableObject
70 71
71 72 private async void MainPage_Loaded(object sender, System.Windows.RoutedEventArgs e)
72 73 {
73 RefreshRecentUsage();
74 if (!hasRecentUsageSnapshot)
75 RefreshRecentUsage();
74 76 var tasks = new List<Task> { LoadAdminOverviewAsync() };
75 77 if (!MainPage.mainCarousel.HasItems) tasks.Add(ReloadAsync());
76 78 await Task.WhenAll(tasks);
@@ -83,7 +85,6 @@ public partial class MainPageViewModel : ObservableObject
83 85
84 86 private void ClientSession_SessionChanged(object? sender, EventArgs e) => MainPage.Dispatcher.InvokeAsync(async () =>
85 87 {
86 RefreshRecentUsage();
87 88 await LoadAdminOverviewAsync();
88 89 if (ClientSession.IsAdministrator && MainPage.IsVisible) adminRefreshTimer.Start();
89 90 else adminRefreshTimer.Stop();
@@ -126,20 +127,49 @@ public partial class MainPageViewModel : ObservableObject
126 127
127 128 private void RefreshRecentUsage()
128 129 {
129 var recent = RecentUsageService.GetRecent()
130 var entries = RecentUsageService.GetRecent()
130 131 .Where(entry => entry.Kind is RecentUsageKind.Tool or RecentUsageKind.Software)
131 132 .Take(MaximumVisibleRecentItems)
132 .Select(entry => new RecentUsageCardViewModel(entry))
133 133 .ToArray();
134 var existing = RecentItems.ToDictionary(
135 item => CreateRecentUsageKey(item.Entry),
136 StringComparer.OrdinalIgnoreCase);
137 var recent = entries.Select(entry =>
138 {
139 var key = CreateRecentUsageKey(entry);
140 return existing.TryGetValue(key, out var card) && EntriesEquivalent(card.Entry, entry)
141 ? card
142 : new RecentUsageCardViewModel(entry);
143 }).ToArray();
134 144
135 RecentItems.Clear();
136 foreach (var item in recent) RecentItems.Add(item);
145 for (var index = 0; index < recent.Length; index++)
146 {
147 if (index >= RecentItems.Count)
148 RecentItems.Add(recent[index]);
149 else if (!ReferenceEquals(RecentItems[index], recent[index]))
150 RecentItems[index] = recent[index];
151 }
152 while (RecentItems.Count > recent.Length)
153 RecentItems.RemoveAt(RecentItems.Count - 1);
137 154
155 hasRecentUsageSnapshot = true;
138 156 RecentItemsVisibility = recent.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
139 157 RecentEmptyVisibility = recent.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
140 158 RecentUsageCountText = $"{recent.Length} 项";
141 159 }
142 160
161 private static string CreateRecentUsageKey(RecentUsageEntry entry) =>
162 $"{entry.Kind}:{entry.TargetId}";
163
164 private static bool EntriesEquivalent(RecentUsageEntry left, RecentUsageEntry right) =>
165 left.Kind == right.Kind
166 && string.Equals(left.TargetId, right.TargetId, StringComparison.OrdinalIgnoreCase)
167 && string.Equals(left.Name, right.Name, StringComparison.Ordinal)
168 && string.Equals(left.Description, right.Description, StringComparison.Ordinal)
169 && string.Equals(left.Detail, right.Detail, StringComparison.Ordinal)
170 && string.Equals(left.IconReference, right.IconReference, StringComparison.Ordinal)
171 && left.LastUsedAtUtc == right.LastUsedAtUtc;
172
143 173 private async Task LoadAdminOverviewAsync()
144 174 {
145 175 AdminServerPanelVisibility = ClientSession.IsAdministrator ? Visibility.Visible : Visibility.Collapsed;
Modified XFEToolBox/ViewModel/Pages/RecentUsageCardViewModel.cs +72 -26
@@ -5,18 +5,31 @@ using CommunityToolkit.Mvvm.ComponentModel;
5 5 using XFEToolBox.Client.Models;
6 6 using XFEToolBox.Client.Profiles.CacheProfiles;
7 7 using XFEToolBox.Client.Utilities;
8 using XFEToolBox.Core.Downloads;
9 using XFEToolBox.Core.Tools;
10 8
11 9 namespace XFEToolBox.Client.ViewModel.Pages;
12 10
13 11 public partial class RecentUsageCardViewModel : ObservableObject
14 12 {
13 private static readonly object CatalogIconSyncRoot = new();
14 private static string? cachedToolCatalogJson;
15 private static string? cachedSoftwareCatalogJson;
16 private static IReadOnlyDictionary<string, string> cachedToolIcons =
17 new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
18 private static IReadOnlyDictionary<string, string> cachedSoftwareIcons =
19 new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
20
15 21 public RecentUsageCardViewModel(RecentUsageEntry entry)
16 22 {
17 23 Entry = entry;
18 IconSource = CreateFallbackIcon(entry);
19 _ = LoadConfiguredIconAsync(entry);
24 if (RecentUsageIconCache.TryGet(entry.Kind, entry.TargetId, out var cachedIcon))
25 {
26 IconSource = cachedIcon;
27 }
28 else
29 {
30 IconSource = CreateFallbackIcon(entry);
31 _ = LoadConfiguredIconAsync(entry);
32 }
20 33 }
21 34
22 35 public RecentUsageEntry Entry { get; }
@@ -39,17 +52,20 @@ public partial class RecentUsageCardViewModel : ObservableObject
39 52
40 53 private async Task LoadConfiguredIconAsync(RecentUsageEntry entry)
41 54 {
42 var reference = string.IsNullOrWhiteSpace(entry.IconReference)
43 ? ResolveCatalogIcon(entry)
44 : entry.IconReference;
45 if (string.IsNullOrWhiteSpace(reference) || reference.StartsWith('/'))
46 return;
47
48 55 try
49 56 {
57 var reference = string.IsNullOrWhiteSpace(entry.IconReference)
58 ? await Task.Run(() => ResolveCatalogIcon(entry))
59 : entry.IconReference;
60 if (string.IsNullOrWhiteSpace(reference) || reference.StartsWith('/'))
61 return;
62
50 63 var image = await WebImageSourceLoader.LoadAsync(reference);
51 64 if (image is not null)
65 {
66 RecentUsageIconCache.Remember(entry.Kind, entry.TargetId, image);
52 67 IconSource = image;
68 }
53 69 }
54 70 catch
55 71 {
@@ -72,32 +88,62 @@ public partial class RecentUsageCardViewModel : ObservableObject
72 88
73 89 private static string ResolveCatalogIcon(RecentUsageEntry entry)
74 90 {
75 try
91 var toolCatalogJson = AppCacheProfile.ToolCatalogJson;
92 var softwareCatalogJson = AppCacheProfile.SoftwareCatalogJson;
93 lock (CatalogIconSyncRoot)
76 94 {
77 if (entry.Kind == RecentUsageKind.Tool && !string.IsNullOrWhiteSpace(AppCacheProfile.ToolCatalogJson))
95 if (!ReferenceEquals(cachedToolCatalogJson, toolCatalogJson))
78 96 {
79 var tools = JsonSerializer.Deserialize<ToolPackageSummary[]>(
80 AppCacheProfile.ToolCatalogJson,
81 new JsonSerializerOptions(JsonSerializerDefaults.Web));
82 return tools?.FirstOrDefault(item =>
83 string.Equals(item.Id, entry.TargetId, StringComparison.OrdinalIgnoreCase))?.IconDataUrl ?? string.Empty;
97 cachedToolCatalogJson = toolCatalogJson;
98 cachedToolIcons = BuildIconIndex(toolCatalogJson, isToolCatalog: true);
84 99 }
85 100
86 if (entry.Kind == RecentUsageKind.Software && !string.IsNullOrWhiteSpace(AppCacheProfile.SoftwareCatalogJson))
101 if (!ReferenceEquals(cachedSoftwareCatalogJson, softwareCatalogJson))
87 102 {
88 var catalog = JsonSerializer.Deserialize<SoftwareCatalogResponse>(
89 AppCacheProfile.SoftwareCatalogJson,
90 new JsonSerializerOptions(JsonSerializerDefaults.Web));
91 return catalog?.Items.FirstOrDefault(item =>
92 string.Equals(item.Id, entry.TargetId, StringComparison.OrdinalIgnoreCase))?.IconUrl ?? string.Empty;
103 cachedSoftwareCatalogJson = softwareCatalogJson;
104 cachedSoftwareIcons = BuildIconIndex(softwareCatalogJson, isToolCatalog: false);
93 105 }
106
107 var icons = entry.Kind == RecentUsageKind.Tool ? cachedToolIcons : cachedSoftwareIcons;
108 return icons.TryGetValue(entry.TargetId, out var iconReference) ? iconReference : string.Empty;
109 }
110 }
111
112 private static IReadOnlyDictionary<string, string> BuildIconIndex(string? json, bool isToolCatalog)
113 {
114 if (string.IsNullOrWhiteSpace(json))
115 return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
116
117 try
118 {
119 using var document = JsonDocument.Parse(json);
120 var items = isToolCatalog
121 ? document.RootElement
122 : document.RootElement.TryGetProperty("items", out var softwareItems)
123 ? softwareItems
124 : default;
125 if (items.ValueKind != JsonValueKind.Array)
126 return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
127
128 var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
129 var iconPropertyName = isToolCatalog ? "iconDataUrl" : "iconUrl";
130 foreach (var item in items.EnumerateArray())
131 {
132 if (!item.TryGetProperty("id", out var idProperty) ||
133 !item.TryGetProperty(iconPropertyName, out var iconProperty))
134 continue;
135
136 var id = idProperty.GetString();
137 var icon = iconProperty.GetString();
138 if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(icon))
139 result[id] = icon;
140 }
141 return result;
94 142 }
95 143 catch (JsonException)
96 144 {
97 // 缓存失效时使用对应类型的内置图标。
145 return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
98 146 }
99
100 return string.Empty;
101 147 }
102 148
103 149 private static string GetBundledSoftwareIcon(string id) => id.ToLowerInvariant() switch
Modified XFEToolBox/Views/Pages/DownloadPage.xaml.cs +2 -0
@@ -6,6 +6,7 @@ using System.Windows.Input;
6 6 using System.Windows.Media;
7 7 using System.Windows.Media.Imaging;
8 8 using XFEToolBox.Client.Model;
9 using XFEToolBox.Client.Models;
9 10 using XFEToolBox.Client.Profiles.CacheProfiles;
10 11 using XFEToolBox.Client.Utilities;
11 12 using XFEToolBox.Client.Utilities.Server;
@@ -341,6 +342,7 @@ public partial class DownloadPage : Page
341 342
342 343 private static void ShowSoftwareDetails(SoftwareCardViewModel card)
343 344 {
345 RecentUsageIconCache.Remember(RecentUsageKind.Software, card.Id, card.IconSource);
344 346 RecentUsageService.RecordSoftware(card.Software);
345 347 PopupHelper.ShowDialog(new DownloadInfoPage(card.Software, card.IconSource), new PopupWindowOptions
346 348 {
Modified XFEToolBox/Views/Pages/ToolBoxPage.xaml.cs +7 -8
@@ -8,6 +8,7 @@ using System.Windows.Controls;
8 8 using System.Windows.Input;
9 9 using System.Windows.Media;
10 10 using System.Windows.Media.Imaging;
11 using XFEToolBox.Client.Models;
11 12 using XFEToolBox.Client.Utilities;
12 13 using XFEToolBox.Client.Utilities.Server;
13 14 using XFEToolBox.Client.ViewModel.Pages;
@@ -418,6 +419,7 @@ public partial class ToolBoxPage : Page
418 419
419 420 card.CacheState = "已打开";
420 421 StatusText.Text = $"{card.Name} {card.LatestVersion} 已在独立窗口中打开。";
422 RecentUsageIconCache.Remember(RecentUsageKind.Tool, card.Id, card.IconSource);
421 423 RecentUsageService.RecordTool(card.Package);
422 424 return true;
423 425 }
@@ -469,14 +471,11 @@ public partial class ToolBoxPage : Page
469 471 {
470 472 var separator = dataUrl.IndexOf(',');
471 473 if (separator < 0) return DefaultToolIcon;
472 using var stream = new MemoryStream(Convert.FromBase64String(dataUrl[(separator + 1)..]));
473 var image = new BitmapImage();
474 image.BeginInit();
475 image.CacheOption = BitmapCacheOption.OnLoad;
476 image.StreamSource = stream;
477 image.EndInit();
478 image.Freeze();
479 return image;
474 var header = dataUrl[5..separator];
475 var mediaType = header.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
476 .FirstOrDefault();
477 var bytes = Convert.FromBase64String(dataUrl[(separator + 1)..]);
478 return WebImageSourceLoader.Decode(bytes, mediaType, "tool-icon");
480 479 }
481 480 catch
482 481 {