16 changed files with 4780 additions and 5474 deletions
@ -0,0 +1,410 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Concurrent; |
||||
|
using System.IO; |
||||
|
using System.Linq; |
||||
|
using System.Security.Cryptography; |
||||
|
using System.Text; |
||||
|
using System.Threading.Tasks; |
||||
|
using Avalonia.Media.Imaging; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using SixLabors.ImageSharp; |
||||
|
using SixLabors.ImageSharp.Processing; |
||||
|
|
||||
|
namespace AuroraDesk.Presentation.Services; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 缩略图缓存服务(类似 Windows 11 的 Thumbnail Cache)
|
||||
|
/// 核心策略:
|
||||
|
/// 1. 内存缓存:快速访问最近使用的缩略图
|
||||
|
/// 2. 磁盘缓存:持久化存储,避免重复生成
|
||||
|
/// 3. 按需生成:只在需要时生成缩略图
|
||||
|
/// 4. 异步加载:不阻塞 UI 线程
|
||||
|
/// </summary>
|
||||
|
public class ThumbnailCacheService |
||||
|
{ |
||||
|
private static ThumbnailCacheService? _instance; |
||||
|
private static readonly object _lockObject = new(); |
||||
|
|
||||
|
// 内存缓存(LRU 策略,最近使用的缩略图)
|
||||
|
private readonly ConcurrentDictionary<string, CachedThumbnail> _memoryCache = new(); |
||||
|
|
||||
|
// 正在加载的任务字典,避免重复加载
|
||||
|
private readonly ConcurrentDictionary<string, Task<Bitmap?>> _loadingTasks = new(); |
||||
|
|
||||
|
// 缩略图大小(与 UI 中的 184x184 一致)
|
||||
|
private const int ThumbnailSize = 184; |
||||
|
|
||||
|
// 内存缓存最大大小(最多缓存 1000 张缩略图)
|
||||
|
private const int MaxMemoryCacheSize = 1000; |
||||
|
|
||||
|
// 磁盘缓存目录
|
||||
|
private readonly string _cacheDirectory; |
||||
|
|
||||
|
// 缓存过期时间(7天)
|
||||
|
private static readonly TimeSpan CacheExpiration = TimeSpan.FromDays(7); |
||||
|
|
||||
|
private readonly ILogger<ThumbnailCacheService>? _logger; |
||||
|
|
||||
|
private ThumbnailCacheService(ILogger<ThumbnailCacheService>? logger = null) |
||||
|
{ |
||||
|
_logger = logger; |
||||
|
|
||||
|
// 使用系统临时目录 + 应用名称作为缓存目录
|
||||
|
var tempPath = Path.GetTempPath(); |
||||
|
_cacheDirectory = Path.Combine(tempPath, "AuroraDesk", "ThumbnailCache"); |
||||
|
|
||||
|
// 确保缓存目录存在
|
||||
|
if (!Directory.Exists(_cacheDirectory)) |
||||
|
{ |
||||
|
Directory.CreateDirectory(_cacheDirectory); |
||||
|
} |
||||
|
|
||||
|
// 启动后台清理任务(定期清理过期缓存)
|
||||
|
_ = Task.Run(CleanupExpiredCacheAsync); |
||||
|
} |
||||
|
|
||||
|
public static ThumbnailCacheService Instance |
||||
|
{ |
||||
|
get |
||||
|
{ |
||||
|
if (_instance == null) |
||||
|
{ |
||||
|
lock (_lockObject) |
||||
|
{ |
||||
|
if (_instance == null) |
||||
|
{ |
||||
|
_instance = new ThumbnailCacheService(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return _instance; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取缩略图(异步,按需加载)
|
||||
|
/// </summary>
|
||||
|
public async Task<Bitmap?> GetThumbnailAsync(string filePath) |
||||
|
{ |
||||
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
// 检查内存缓存
|
||||
|
if (_memoryCache.TryGetValue(filePath, out var cached) && cached.Thumbnail != null) |
||||
|
{ |
||||
|
cached.LastAccessed = DateTime.UtcNow; |
||||
|
return cached.Thumbnail; |
||||
|
} |
||||
|
|
||||
|
// 检查是否正在加载
|
||||
|
if (_loadingTasks.TryGetValue(filePath, out var loadingTask)) |
||||
|
{ |
||||
|
return await loadingTask; |
||||
|
} |
||||
|
|
||||
|
// 创建新的加载任务
|
||||
|
var task = LoadThumbnailInternalAsync(filePath); |
||||
|
_loadingTasks.TryAdd(filePath, task); |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var result = await task; |
||||
|
|
||||
|
// 添加到内存缓存
|
||||
|
if (result != null) |
||||
|
{ |
||||
|
AddToMemoryCache(filePath, result); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
_loadingTasks.TryRemove(filePath, out _); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 内部加载缩略图方法(检查磁盘缓存,如果没有则生成)
|
||||
|
/// </summary>
|
||||
|
private async Task<Bitmap?> LoadThumbnailInternalAsync(string filePath) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// 1. 检查磁盘缓存
|
||||
|
var cacheFilePath = GetCacheFilePath(filePath); |
||||
|
if (File.Exists(cacheFilePath)) |
||||
|
{ |
||||
|
var cacheFileInfo = new FileInfo(cacheFilePath); |
||||
|
var originalFileInfo = new FileInfo(filePath); |
||||
|
|
||||
|
// 检查缓存是否过期(原文件修改时间更新或缓存文件过期)
|
||||
|
if (cacheFileInfo.LastWriteTime >= originalFileInfo.LastWriteTime && |
||||
|
DateTime.UtcNow - cacheFileInfo.LastWriteTimeUtc < CacheExpiration) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// 从磁盘缓存加载
|
||||
|
await using var cacheStream = File.OpenRead(cacheFilePath); |
||||
|
var bitmap = new Bitmap(cacheStream); |
||||
|
_logger?.LogDebug("从磁盘缓存加载缩略图: {FilePath}", filePath); |
||||
|
return bitmap; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger?.LogWarning(ex, "从磁盘缓存加载缩略图失败: {FilePath}", filePath); |
||||
|
// 删除损坏的缓存文件
|
||||
|
try { File.Delete(cacheFilePath); } catch { } |
||||
|
} |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
// 缓存过期,删除
|
||||
|
try { File.Delete(cacheFilePath); } catch { } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 2. 生成新的缩略图
|
||||
|
return await GenerateThumbnailAsync(filePath); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger?.LogError(ex, "加载缩略图失败: {FilePath}", filePath); |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 生成缩略图并保存到磁盘缓存
|
||||
|
/// </summary>
|
||||
|
private async Task<Bitmap?> GenerateThumbnailAsync(string filePath) |
||||
|
{ |
||||
|
return await Task.Run(() => |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
// 使用 ImageSharp 加载并生成缩略图
|
||||
|
using var image = Image.Load(filePath); |
||||
|
|
||||
|
// 计算缩略图尺寸(保持宽高比)
|
||||
|
var sourceWidth = image.Width; |
||||
|
var sourceHeight = image.Height; |
||||
|
|
||||
|
int thumbnailWidth, thumbnailHeight; |
||||
|
|
||||
|
if (sourceWidth <= ThumbnailSize && sourceHeight <= ThumbnailSize) |
||||
|
{ |
||||
|
// 如果原图小于缩略图尺寸,直接使用原图尺寸
|
||||
|
thumbnailWidth = sourceWidth; |
||||
|
thumbnailHeight = sourceHeight; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
// 计算缩放比例,保持宽高比
|
||||
|
var scale = Math.Min( |
||||
|
(double)ThumbnailSize / sourceWidth, |
||||
|
(double)ThumbnailSize / sourceHeight); |
||||
|
|
||||
|
thumbnailWidth = (int)(sourceWidth * scale); |
||||
|
thumbnailHeight = (int)(sourceHeight * scale); |
||||
|
} |
||||
|
|
||||
|
// 生成缩略图
|
||||
|
image.Mutate(x => x.Resize(new ResizeOptions |
||||
|
{ |
||||
|
Size = new Size(thumbnailWidth, thumbnailHeight), |
||||
|
Mode = ResizeMode.Max, // 保持宽高比,确保不超过指定尺寸
|
||||
|
Sampler = KnownResamplers.Lanczos3 // 高质量缩放算法
|
||||
|
})); |
||||
|
|
||||
|
// 将 ImageSharp 图像转换为 Avalonia Bitmap
|
||||
|
using var memoryStream = new MemoryStream(); |
||||
|
image.SaveAsPng(memoryStream); |
||||
|
memoryStream.Position = 0; |
||||
|
|
||||
|
var bitmap = new Bitmap(memoryStream); |
||||
|
|
||||
|
// 保存到磁盘缓存(异步,不阻塞)
|
||||
|
_ = SaveToDiskCacheAsync(filePath, memoryStream); |
||||
|
|
||||
|
return bitmap; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger?.LogError(ex, "生成缩略图失败: {FilePath}", filePath); |
||||
|
return null; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 保存缩略图到磁盘缓存
|
||||
|
/// </summary>
|
||||
|
private async Task SaveToDiskCacheAsync(string filePath, MemoryStream thumbnailStream) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var cacheFilePath = GetCacheFilePath(filePath); |
||||
|
var cacheDir = Path.GetDirectoryName(cacheFilePath); |
||||
|
|
||||
|
if (cacheDir != null && !Directory.Exists(cacheDir)) |
||||
|
{ |
||||
|
Directory.CreateDirectory(cacheDir); |
||||
|
} |
||||
|
|
||||
|
// 复制流到文件
|
||||
|
await using var fileStream = File.Create(cacheFilePath); |
||||
|
thumbnailStream.Position = 0; |
||||
|
await thumbnailStream.CopyToAsync(fileStream); |
||||
|
|
||||
|
_logger?.LogDebug("缩略图已保存到磁盘缓存: {FilePath}", filePath); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger?.LogWarning(ex, "保存缩略图到磁盘缓存失败: {FilePath}", filePath); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取缓存文件路径(基于文件路径的哈希值)
|
||||
|
/// </summary>
|
||||
|
private string GetCacheFilePath(string filePath) |
||||
|
{ |
||||
|
// 使用文件路径的哈希值作为缓存文件名
|
||||
|
var hash = ComputeHash(filePath); |
||||
|
var extension = Path.GetExtension(filePath).ToLowerInvariant(); |
||||
|
var cacheFileName = $"{hash}{extension}"; |
||||
|
|
||||
|
// 使用子目录分散文件(避免单目录文件过多)
|
||||
|
var subDir = hash.Substring(0, 2); |
||||
|
return Path.Combine(_cacheDirectory, subDir, cacheFileName); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 计算文件路径的哈希值
|
||||
|
/// </summary>
|
||||
|
private string ComputeHash(string filePath) |
||||
|
{ |
||||
|
var bytes = Encoding.UTF8.GetBytes(filePath); |
||||
|
using var sha256 = SHA256.Create(); |
||||
|
var hashBytes = sha256.ComputeHash(bytes); |
||||
|
return BitConverter.ToString(hashBytes).Replace("-", ""); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 添加到内存缓存(LRU 策略)
|
||||
|
/// </summary>
|
||||
|
private void AddToMemoryCache(string filePath, Bitmap thumbnail) |
||||
|
{ |
||||
|
// 如果缓存已满,移除最久未使用的项
|
||||
|
if (_memoryCache.Count >= MaxMemoryCacheSize) |
||||
|
{ |
||||
|
var oldestKey = _memoryCache |
||||
|
.OrderBy(x => x.Value.LastAccessed) |
||||
|
.FirstOrDefault().Key; |
||||
|
|
||||
|
if (oldestKey != null && _memoryCache.TryRemove(oldestKey, out var oldest)) |
||||
|
{ |
||||
|
oldest.Thumbnail?.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
_memoryCache.TryAdd(filePath, new CachedThumbnail |
||||
|
{ |
||||
|
Thumbnail = thumbnail, |
||||
|
LastAccessed = DateTime.UtcNow |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 清理过期缓存
|
||||
|
/// </summary>
|
||||
|
private async Task CleanupExpiredCacheAsync() |
||||
|
{ |
||||
|
while (true) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
await Task.Delay(TimeSpan.FromHours(1)); // 每小时清理一次
|
||||
|
|
||||
|
// 清理内存缓存中的过期项
|
||||
|
var expiredKeys = _memoryCache |
||||
|
.Where(x => DateTime.UtcNow - x.Value.LastAccessed > TimeSpan.FromHours(1)) |
||||
|
.Select(x => x.Key) |
||||
|
.ToList(); |
||||
|
|
||||
|
foreach (var key in expiredKeys) |
||||
|
{ |
||||
|
if (_memoryCache.TryRemove(key, out var cached)) |
||||
|
{ |
||||
|
cached.Thumbnail?.Dispose(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 清理磁盘缓存中的过期文件
|
||||
|
if (Directory.Exists(_cacheDirectory)) |
||||
|
{ |
||||
|
var cutoffTime = DateTime.UtcNow - CacheExpiration; |
||||
|
foreach (var file in Directory.EnumerateFiles(_cacheDirectory, "*.*", SearchOption.AllDirectories)) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var fileInfo = new FileInfo(file); |
||||
|
if (fileInfo.LastWriteTimeUtc < cutoffTime) |
||||
|
{ |
||||
|
File.Delete(file); |
||||
|
} |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
// 忽略删除失败的文件
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger?.LogError(ex, "清理缓存时出错"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 清除所有缓存
|
||||
|
/// </summary>
|
||||
|
public void ClearCache() |
||||
|
{ |
||||
|
// 清除内存缓存
|
||||
|
foreach (var cached in _memoryCache.Values) |
||||
|
{ |
||||
|
cached.Thumbnail?.Dispose(); |
||||
|
} |
||||
|
_memoryCache.Clear(); |
||||
|
|
||||
|
// 清除磁盘缓存
|
||||
|
if (Directory.Exists(_cacheDirectory)) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
Directory.Delete(_cacheDirectory, true); |
||||
|
Directory.CreateDirectory(_cacheDirectory); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
_logger?.LogError(ex, "清除磁盘缓存失败"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 缓存的缩略图项
|
||||
|
/// </summary>
|
||||
|
private class CachedThumbnail |
||||
|
{ |
||||
|
public Bitmap? Thumbnail { get; set; } |
||||
|
public DateTime LastAccessed { get; set; } |
||||
|
} |
||||
|
} |
||||
|
|
||||
File diff suppressed because it is too large
@ -0,0 +1,62 @@ |
|||||
|
Avalonia 中实现目录选择加载10万张图片的思路(达到Win11级流畅效果) |
||||
|
概述 |
||||
|
本文档提供在Avalonia应用中实现“选择目录加载图片”功能的整体思路。Avalonia是一个跨平台.NET UI框架,支持Windows、macOS和Linux,目标是处理包含10万张图片的目录,实现类似Windows 11资源管理器(File Explorer)的流畅体验:快速目录选择、渐进式加载、虚拟化显示、无卡顿缩略图渲染。重点强调性能优化,避免全量加载导致的内存爆炸或UI阻塞。 |
||||
|
思路不涉及具体代码实现,而是分层描述设计原则、架构和优化策略。整个流程分为用户交互层、数据加载层、渲染层和优化层。利用Avalonia的Fluent主题实现Win11风格(圆角、Acrylic模糊、动画过渡)。 |
||||
|
1. 用户交互层:目录选择 |
||||
|
|
||||
|
核心目标:提供直观、原生化的目录选择对话框,确保用户感知到Win11的“现代感”(圆角窗口、流畅动画)。 |
||||
|
设计思路: |
||||
|
|
||||
|
使用Avalonia内置的OpenFolderDialog(来自Avalonia.Dialogs NuGet包)打开目录选择器,配置为文件夹模式,并可选预过滤图片扩展名(.jpg, .png 等)。 |
||||
|
在选择后,立即显示一个模态进度对话框(ProgressDialog),使用Win11风格的加载动画(IndeterminateProgressBar),告知用户“正在扫描目录...”(预计时间基于文件数预估,e.g., 10万文件需5-10秒)。 |
||||
|
选择后,更新应用状态栏(StatusBar)显示目录路径和文件总数,允许用户随时通过按钮重新选择或取消操作。集成Fluent主题的图标和按钮样式。 |
||||
|
|
||||
|
|
||||
|
|
||||
|
2. 数据加载层:异步目录扫描与索引 |
||||
|
|
||||
|
核心目标:高效处理10万文件,避免UI线程阻塞,实现渐进式加载。 |
||||
|
设计思路: |
||||
|
|
||||
|
异步扫描:在后台任务(Task.Run或IAsyncEnumerable)启动目录遍历,使用.NET的Directory.EnumerateFiles递归API收集图片路径列表。通过扩展名快速过滤非图片文件。 |
||||
|
分页/分块加载:不一次性加载全部10万文件,而是分批(e.g., 每批1000文件)扫描并入队。使用ConcurrentQueue或Channel管理批次,支持取消令牌(CancellationToken)。 |
||||
|
元数据索引:为每张图片预提取轻量元数据(文件名、大小、修改时间),存储在内存中的轻量数据结构(如List<ImageMetadata>或ObservableCollection)。对于10万文件,索引大小控制在10-50MB内,可选使用SQLite.NET for持久化。 |
||||
|
错误处理:扫描中遇到损坏文件时跳过并日志记录(使用Serilog);支持中断扫描(用户取消时通过CancellationTokenSource)。 |
||||
|
|
||||
|
|
||||
|
|
||||
|
3. 渲染层:虚拟化缩略图显示 |
||||
|
|
||||
|
核心目标:模拟Win11 File Explorer的网格视图,实现“无限滚动”效果,只有可见区域渲染缩略图。 |
||||
|
设计思路: |
||||
|
|
||||
|
UI布局:采用UniformGrid或WrapPanel作为ListBox或ItemsControl的容器,每项为固定大小卡片(e.g., 150x150px),包含Image控件、文本叠加(文件名)。支持排序(名称/日期)和过滤(通过CollectionViewSource)。 |
||||
|
虚拟化机制:启用VirtualizingPanel.IsVirtualizing="True"和VirtualizingPanel.VirtualizationMode="Recycling",只为当前视口(viewport)内可见项(e.g., 20-50张)生成控件。滚动时动态回收/复用项,使用ScrollViewer处理无限滚动。 |
||||
|
缩略图生成:后台线程池(Task Parallel Library)按需生成缩略图,使用Avalonia的Bitmap类从原图异步加载并采样缩放(e.g., RenderScale=1)。优先渲染可见项的缩略图,支持BitmapCache。 |
||||
|
懒加载动画:未加载项显示占位符(低分辨率模糊图或Skeleton加载动画),加载完成时使用Animation淡入效果(类似Win11的Acrylic过渡)。集成Fluent主题的圆角和阴影。 |
||||
|
|
||||
|
|
||||
|
|
||||
|
4. 优化层:性能与用户体验提升 |
||||
|
|
||||
|
核心目标:确保在低端硬件(e.g., 8GB RAM)下流畅运行,内存<2GB,加载延迟<2秒(首屏)。 |
||||
|
设计思路: |
||||
|
|
||||
|
缓存策略:实现多级缓存——L1: 内存LRU缓存(最近1000张缩略图,使用MemoryCache);L2: 磁盘缓存(目录下创建.cache文件夹,存储PNG缩略图)。缓存键为文件路径哈希,使用WeakReference避免泄漏。 |
||||
|
内存管理:限制同时生成的缩略图数(e.g., 并发10张,通过SemaphoreSlim),定期触发GC(GC.Collect for non-gen2)。监控性能使用Avalonia的诊断工具。 |
||||
|
预加载与预测:滚动事件(ScrollChanged)时预加载下一个视口(lookahead 2-3屏)的缩略图;使用用户行为预测(e.g., 向下滚动时优先下批)。 |
||||
|
Win11特效集成:应用Fluent主题的Mica/Acrylic背景模糊、圆角Border、阴影效果;添加手势支持(如MouseWheel缩放视图);进度指示器使用WinUI风格的spinner。 |
||||
|
边缘case处理:支持子目录递归(可选ToggleSwitch);大文件(>100MB)延迟加载;混合媒体(如果目录含视频,扩展为ThumbnailProvider)。 |
||||
|
测试指标:目标FPS>60,首屏加载<1s,全扫描<30s。使用Avalonia性能剖析工具验证。 |
||||
|
|
||||
|
|
||||
|
|
||||
|
潜在挑战与权衡 |
||||
|
|
||||
|
挑战1:10万文件扫描时间——解决方案:多线程并行扫描子目录(Parallel.ForEach)。 |
||||
|
挑战2:缩略图生成CPU密集——解决方案:限制分辨率(max 256x256),使用SkiaSharp硬件加速(Avalonia渲染引擎)。 |
||||
|
挑战3:跨平台一致性——优先Win11原生API(WinRT interop),fallback到通用实现;测试在Windows 11上优化Fluent主题。 |
||||
|
扩展性:未来可添加搜索(Lucene.NET索引)、标签系统,但当前聚焦核心加载。 |
||||
|
|
||||
|
总结 |
||||
|
此思路通过异步+虚拟化+缓存三剑客,在Avalonia中实现高效加载,达到Win11的丝滑交互。实施时,从MVP开始:先实现基本扫描+虚拟ListBox,再迭代优化。预计开发周期:1-2周(单人)。参考Avalonia文档的性能指南和虚拟化示例 |
||||
File diff suppressed because it is too large
Loading…
Reference in new issue