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