Initial commit: FluidExplorer:从零实现的 WinUI 3 文件资源管理器替代品(Mica、命令栏、面包屑)

This commit is contained in:
WpyQwq
2026-09-19 11:54:03 +08:00
commit 5780fde61a
60 changed files with 15035 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
namespace FluidExplorer.Services.Icons;
/// <summary>
/// 图标/缩略图服务:全部来自 Windows 外壳(imageres.dll 等系统图标库、IShellItemImageFactory),
/// 也就是资源管理器本身显示的那批原版图标,不做自制图标。
/// </summary>
public interface IIconService
{
/// <summary>系统小图标(16/32/48px 的多尺寸 HICON),用于列表与侧边栏。目录、驱动器、特殊文件夹同样走外壳。</summary>
Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken);
/// <summary>大缩略图(SIIGBF_THUMBNAILONLY + 图标回退),用于网格/磁贴视图。失败返回 null。</summary>
Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken);
/// <summary>按扩展名取通用图标(同类型文件共用一个位图,命中率极高)。</summary>
Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken);
/// <summary>取某个已知外壳文件夹(如 "shell:RecycleBinFolder"、"::{20D04FE0-3AEA-1069-A2D8-08002B30309D}")的图标。</summary>
ImageSource? GetSpecialFolderIcon(string parsingName, int size);
void ClearCache();
}
/// <summary>图标服务需要在 UI 线程创建 ImageSource,构造时注入 DispatcherQueue。</summary>
public interface IIconServiceHost
{
DispatcherQueue DispatcherQueue { get; }
}
+894
View File
@@ -0,0 +1,894 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
using BgraBuffer = FluidExplorer.Services.Icons.ShellNative.BgraBuffer;
namespace FluidExplorer.Services.Icons;
/// <summary>
/// 系统原版图标 / 缩略图服务。
///
/// 设计要点:
/// 1) 取图全部在外壳与 GDI 侧完成(可后台线程),只有 <see cref="SoftwareBitmapSource"/> 的创建被派回 UI 线程;
/// SoftwareBitmapSource 是 UI 线程亲和对象,后台线程碰它会直接崩。
/// 2) 结果带 LRU 缓存 + 同键请求合并(Lazy&lt;Task&gt;),列表快速滚动时不会重复解码同一张图。
/// 3) 任何未预期异常都会被吞成 null 并写 Debug 输出,服务层绝不把异常抛进 UI 线程。
/// </summary>
public sealed class ShellIconService : IIconService
{
private const int IconCacheCapacity = 512;
private const int ThumbnailCacheCapacity = 256;
private const int MaxConcurrentNativeWork = 4;
/// <summary>缩略图 E_PENDING 重试参数。</summary>
private const int ThumbnailRetryCount = 3;
/// <summary>缩略图 E_PENDING 重试间隔(毫秒)。</summary>
private const int ThumbnailRetryDelayMs = 200;
/// <summary>UI 调度器;极端情况下可能为 null(此时退化为在调用线程创建 ImageSource)。</summary>
private readonly DispatcherQueue? _uiDispatcher;
private readonly Func<double> _scaleProvider;
/// <summary>
/// 图标缓存:值类型是 <see cref="Task{TResult}"/>。
/// 缓存"共享的 Task"而不是最终 ImageSource,才能在解码未完成时就完成同键请求合并;
/// 这也是 Lazy&lt;Task&gt; 模式的落点(LruCache 内层用 Lazy 保证工厂只跑一次)。
/// </summary>
private readonly LruCache<Task<ImageSource?>> _iconCache = new(IconCacheCapacity);
/// <summary>缩略图缓存,容量更小(缩略图位图大得多,全部是 256px 级别的位图)。</summary>
private readonly LruCache<Task<ImageSource?>> _thumbnailCache = new(ThumbnailCacheCapacity);
/// <summary>把纯 GDI 取图/解码节流在 4 路,避免整屏滚动时把 CPU 打满。</summary>
private readonly SemaphoreSlim _nativeThrottle = new(MaxConcurrentNativeWork, MaxConcurrentNativeWork);
/// <summary>默认的 DPI 缩放(rasterizationScale 为 null 时按 1.0 处理)。</summary>
private static readonly Func<double> DefaultScale = static () => 1.0;
/// <summary>
/// 构造。签名固定为 (DispatcherQueue, Func&lt;double&gt;?),见 Services/AppServices.cs 的调用。
/// </summary>
/// <remarks>
/// 构造函数**不抛异常**:uiDispatcher 为 null 时回退到当前线程的 DispatcherQueue,
/// 再拿不到就退化为"直接在调用线程创建 ImageSource"。
/// 宁可降级也不要让 App 启动阶段直接崩掉。
/// </remarks>
public ShellIconService(DispatcherQueue uiDispatcher, Func<double>? rasterizationScale = null)
{
_uiDispatcher = uiDispatcher ?? TryGetCurrentDispatcher();
_scaleProvider = rasterizationScale ?? DefaultScale;
}
/// <summary>尽力拿到当前线程的 DispatcherQueue;拿不到返回 null(构造期不抛异常)。</summary>
private static DispatcherQueue? TryGetCurrentDispatcher()
{
try
{
return DispatcherQueue.GetForCurrentThread();
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] GetForCurrentThread 失败: {ex.Message}");
return null;
}
}
/// <summary>当前构造时注入的 UI 调度器(ViewModel 可用它确认自己在哪个线程上调用)。</summary>
public DispatcherQueue? UiDispatcher => _uiDispatcher;
// ─────────────────────────────────────────────────────────────
// IIconService
// ─────────────────────────────────────────────────────────────
/// <inheritdoc />
public Task<ImageSource?> GetIconAsync(string path, bool isDirectory, int size, CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrWhiteSpace(path)) return Task.FromResult<ImageSource?>(null);
int logical = NormalizeSize(size);
// 缓存键规则(关键,不要随意改动):
// - 文件 / 目录 / 驱动器一律用完整路径。因为 .exe 会带自身嵌入图标,
// .lnk 指向不同目标,文件夹可能带 OneDrive / 共享 / 快捷方式角标,
// 只有按路径缓存才不会串图。
// - 扩展名走 ".ext" 键(见 GetExtensionIconAsync),SHGFI_USEFILEATTRIBUTES 不碰磁盘。
string key = BuildIconKey(path, isDirectory, logical);
return GetOrAddAsync(_iconCache, key, ct => LoadIconCoreAsync(path, isDirectory, logical, ct), cancellationToken);
}
catch (Exception ex)
{
// 公开方法绝不抛异常给调用方(列表滚动会崩主程序)
Debug.WriteLine($"[ShellIconService] GetIconAsync 失败 {path}: {ex}");
return Task.FromResult<ImageSource?>(null);
}
}
/// <inheritdoc />
public Task<ImageSource?> GetThumbnailAsync(string path, int size, CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrWhiteSpace(path)) return Task.FromResult<ImageSource?>(null);
int logical = NormalizeSize(size);
string key = "thumb|" + logical.ToString() + "|" + path;
return GetOrAddAsync(_thumbnailCache, key, ct => LoadThumbnailCoreAsync(path, logical, ct), cancellationToken);
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] GetThumbnailAsync 失败 {path}: {ex}");
return Task.FromResult<ImageSource?>(null);
}
}
/// <inheritdoc />
public Task<ImageSource?> GetExtensionIconAsync(string extension, int size, CancellationToken cancellationToken)
{
try
{
string ext = NormalizeExtension(extension);
if (ext.Length == 0) return Task.FromResult<ImageSource?>(null);
int logical = NormalizeSize(size);
// ".ext" 键:同类型文件共用一个位图,命中率极高,且不需要访问磁盘
string key = "ext|" + logical.ToString() + "|." + ext;
return GetOrAddAsync(_iconCache, key, ct => LoadExtensionIconCoreAsync(ext, logical, ct), cancellationToken);
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] GetExtensionIconAsync 失败 {extension}: {ex}");
return Task.FromResult<ImageSource?>(null);
}
}
/// <inheritdoc />
/// <remarks>
/// 注意:本方法是同步签名(见 IIconService)。它只应在 UI 线程调用,内部会同步等待取图完成,
/// 因此仅适合启动阶段取少量侧边栏图标,不要在滚动/渲染路径里按行调用。
/// </remarks>
public ImageSource? GetSpecialFolderIcon(string parsingName, int size)
{
try
{
if (string.IsNullOrWhiteSpace(parsingName)) return null;
// 该重载不会死锁:内部从不依赖调用方线程继续泵消息(软件位图那一段走 TryEnqueue)
return GetIconAsync(parsingName, isDirectory: true, size, CancellationToken.None)
.WaitAsync(TimeSpan.FromSeconds(5))
.GetAwaiter()
.GetResult();
}
catch (TimeoutException ex)
{
Debug.WriteLine($"[ShellIconService] 特殊文件夹图标超时 {parsingName}: {ex.Message}");
return null;
}
catch (Exception ex)
{
// 含 OperationCanceledException:公开方法一律不外抛
Debug.WriteLine($"[ShellIconService] 特殊文件夹图标失败 {parsingName}: {ex}");
return null;
}
}
/// <inheritdoc />
public void ClearCache()
{
try
{
_iconCache.Clear();
_thumbnailCache.Clear();
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] ClearCache 失败: {ex}");
}
}
// ─────────────────────────────────────────────────────────────
// 取图主流程(全部在后台线程执行)
// ─────────────────────────────────────────────────────────────
/// <summary>文件 / 目录 / 驱动器 / 已知外壳对象的图标。</summary>
private Task<ImageSource?> LoadIconCoreAsync(string path, bool isDirectory, int logicalSize, CancellationToken ct)
{
return RunSafeAsync(async () =>
{
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
try
{
BgraBuffer? pixels = null;
if (IsSpecialParsingName(path))
{
// shell:RecycleBinFolder、::{20D04FE0-...}(此电脑)之类:
// 必须先解析成 PIDL,再交给 SHGetFileInfoW 的 PIDL 重载
pixels = ShellParsingName.IsShellPrefix(path)
? LoadIconFromShellParsingName(path, logicalSize)
: LoadIconFromParsingName(path, logicalSize);
}
else if (!isDirectory && logicalSize >= 48 && File.Exists(path))
{
// 大尺寸文件图标优先走 IShellItemImageFactory + SIIGBF_ICONONLY:
// 它返回的是外壳为该文件类型选定的高清图标(含 .exe/.lnk 的个性化图标),
// 比把 32x32 拉伸到 48 清晰得多。
// File.Exists 先挡一道:SHCreateItemFromParsingName 对不存在的路径会抛/失败,避免无谓开销。
pixels = LoadIconViaShellItemImageFactory(path, logicalSize);
}
// 回退 / 小尺寸路径:SHGetFileInfoW。
// 小尺寸用这条更贴近资源管理器的列表视图(16/32 就是系统图像列表原生尺寸,不经缩放)。
pixels ??= LoadIconViaShellFileInfo(path, isDirectory, logicalSize);
ct.ThrowIfCancellationRequested();
if (pixels is null) return null;
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
}
finally
{
_nativeThrottle.Release();
}
});
}
/// <summary>扩展名通用图标:SHGFI_USEFILEATTRIBUTES + FILE_ATTRIBUTE_NORMAL,不访问磁盘。</summary>
private Task<ImageSource?> LoadExtensionIconCoreAsync(string extension, int logicalSize, CancellationToken ct)
{
return RunSafeAsync(async () =>
{
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
try
{
// 路径传 "x.ext" 纯粹是为了让外壳从扩展名推断类型;
// SHGFI_USEFILEATTRIBUTES 保证它不会去访问磁盘(这也是它比按路径取快一个数量级的原因)。
string fakePath = "x." + extension;
BgraBuffer? pixels = LoadIconViaShellFileInfoCore(
fakePath,
ShellNative.FILE_ATTRIBUTE_NORMAL,
ShellNative.SHGFI_USEFILEATTRIBUTES,
logicalSize);
ct.ThrowIfCancellationRequested();
if (pixels is null) return null;
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
}
finally
{
_nativeThrottle.Release();
}
});
}
/// <summary>
/// 缩略图:SHCreateItemFromParsingName + IShellItemImageFactory.GetImage。
/// 失败一律返回 null(不抛异常);E_PENDING 表示外壳正在后台解码,等 200ms 重试,最多 3 次。
/// </summary>
private Task<ImageSource?> LoadThumbnailCoreAsync(string path, int logicalSize, CancellationToken ct)
{
return RunSafeAsync(async () =>
{
await _nativeThrottle.WaitAsync(ct).ConfigureAwait(false);
try
{
int pixelSize = ToPixelSize(logicalSize);
Guid iid = typeof(ShellNative.IShellItemImageFactory).GUID;
ShellNative.IShellItemImageFactory? factory = null;
try
{
int hr = ShellNative.SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out factory);
if (hr < 0 || factory is null)
{
Debug.WriteLine($"[ShellIconService] SHCreateItemFromParsingName 失败 0x{hr:X8}: {path}");
return null;
}
// THUMBNAILONLY:没有真实缩略图就直接失败(交给上层显示图标);
// BIGGERSIZEOK:允许外壳返回更大的缓存图,由 XAML 侧缩放,反而更清晰。
const int flags = ShellNative.SIIGBF_THUMBNAILONLY | ShellNative.SIIGBF_BIGGERSIZEOK;
BgraBuffer? pixels = null;
for (int attempt = 0; attempt <= ThumbnailRetryCount; attempt++)
{
ct.ThrowIfCancellationRequested();
var size = new ShellNative.SIZE(pixelSize, pixelSize);
int hrImage = factory.GetImage(size, flags, out IntPtr hBitmap);
if (hrImage == ShellNative.E_PENDING)
{
// 外壳正在生成缩略图:等一下再来
await Task.Delay(ThumbnailRetryDelayMs, ct).ConfigureAwait(false);
continue;
}
if (hrImage < 0 || hBitmap == IntPtr.Zero)
{
// 没有缩略图(例如 .txt / 未知类型)——这是正常情况,不记错误
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
return null;
}
try
{
pixels = ShellNative.BitmapToBgra(hBitmap, pixelSize, pixelSize);
}
finally
{
// IShellItemImageFactory 返回的 HBITMAP 归调用方所有,必须释放
ShellNative.DeleteObject(hBitmap);
}
break;
}
ct.ThrowIfCancellationRequested();
if (pixels is null) return null;
// 空位图保护:尺寸为 0 或无像素时不产出 ImageSource
if (pixels.Width <= 0 || pixels.Height <= 0 || pixels.Pixels.Length == 0) return null;
return await CreateImageSourceAsync(pixels).ConfigureAwait(false);
}
finally
{
if (factory is not null)
{
try
{
Marshal.FinalReleaseComObject(factory);
}
catch (ArgumentException)
{
// 已被释放:忽略
}
}
}
}
finally
{
_nativeThrottle.Release();
}
});
}
// ─────────────────────────────────────────────────────────────
// 各条取图路径的 Win32 细节
// ─────────────────────────────────────────────────────────────
/// <summary>SHGetFileInfoW 路径:目录走真实路径(保角标/个性化图标),文件走属性推断。</summary>
private BgraBuffer? LoadIconViaShellFileInfo(string path, bool isDirectory, int logicalSize)
{
if (isDirectory)
{
// 目录必须用真实路径:这样 OneDrive / 共享 / 快捷方式角标才会被带上
return LoadIconViaShellFileInfoCore(path, ShellNative.FILE_ATTRIBUTE_DIRECTORY, 0, logicalSize);
}
// 文件用 FILE_ATTRIBUTE_NORMAL 推断:不访问磁盘,速度极快。
// 注意路径仍然参与取值,所以 .exe 的嵌入图标、.lnk 的目标图标依然正确。
return LoadIconViaShellFileInfoCore(path, ShellNative.FILE_ATTRIBUTE_NORMAL, ShellNative.SHGFI_USEFILEATTRIBUTES, logicalSize);
}
/// <summary>
/// SHGetFileInfoW 核心:先拿系统图像列表索引 → 按 size 选 SHIL 取对应尺寸 HICON;
/// 拿不到索引就退回 SHGetFileInfoW 直接给的 HICON(32x32)。
/// </summary>
private BgraBuffer? LoadIconViaShellFileInfoCore(string path, uint attributes, uint extraFlags, int logicalSize)
{
int pixelSize = ToPixelSize(logicalSize);
int imageListKind = SelectImageList(logicalSize);
IntPtr hIcon = IntPtr.Zero;
try
{
int index = ShellNative.GetSystemIconIndexByPath(path, attributes, extraFlags);
if (index >= 0)
{
hIcon = ShellNative.GetHIconFromSystemImageList(imageListKind, index);
}
if (hIcon == IntPtr.Zero)
{
// 回退:直接取 HICON(32x32 或 16x16),大尺寸就走高质量缩放
uint iconFlags = logicalSize <= 16 ? ShellNative.SHGFI_SMALLICON : ShellNative.SHGFI_LARGEICON;
hIcon = ShellNative.GetHIconByPath(path, attributes, extraFlags | iconFlags);
}
if (hIcon == IntPtr.Zero) return null;
return ShellNative.IconToBgra(hIcon, pixelSize, pixelSize);
}
finally
{
// 每一次取到的 HICON 都必须销毁,否则 GDI 句柄会持续增长
if (hIcon != IntPtr.Zero) ShellNative.DestroyIcon(hIcon);
}
}
/// <summary>shell: / ::{CLSID} 解析名的图标:SHParseDisplayName → PIDL → 系统图像列表。</summary>
private BgraBuffer? LoadIconFromShellParsingName(string parsingName, int logicalSize)
{
IntPtr pidl = ShellNative.ParseDisplayNameToPidl(parsingName);
if (pidl == IntPtr.Zero) return null;
try
{
int pixelSize = ToPixelSize(logicalSize);
int imageListKind = SelectImageList(logicalSize);
IntPtr hIcon = IntPtr.Zero;
try
{
int index = ShellNative.GetSystemIconIndexByPidl(pidl, 0);
if (index >= 0)
{
hIcon = ShellNative.GetHIconFromSystemImageList(imageListKind, index);
}
if (hIcon == IntPtr.Zero)
{
uint iconFlags = logicalSize <= 16 ? ShellNative.SHGFI_SMALLICON : ShellNative.SHGFI_LARGEICON;
hIcon = ShellNative.GetHIconByPidl(pidl, iconFlags);
}
if (hIcon == IntPtr.Zero) return null;
return ShellNative.IconToBgra(hIcon, pixelSize, pixelSize);
}
finally
{
if (hIcon != IntPtr.Zero) ShellNative.DestroyIcon(hIcon);
}
}
finally
{
// PIDL 由 SHParseDisplayName 用 CoTaskMemAlloc 分配,必须 CoTaskMemFree
ShellNative.CoTaskMemFree(pidl);
}
}
/// <summary>带 "::{CLSID}" 前缀但不带 shell: 前缀的解析名。</summary>
private BgraBuffer? LoadIconFromParsingName(string parsingName, int logicalSize)
=> LoadIconFromShellParsingName(parsingName, logicalSize);
/// <summary>IShellItemImageFactory + SIIGBF_ICONONLY:大尺寸文件/扩展名的高清原版图标。</summary>
private BgraBuffer? LoadIconViaShellItemImageFactory(string path, int logicalSize)
{
int pixelSize = ToPixelSize(logicalSize);
Guid iid = typeof(ShellNative.IShellItemImageFactory).GUID;
ShellNative.IShellItemImageFactory? factory = null;
IntPtr hBitmap = IntPtr.Zero;
try
{
int hr = ShellNative.SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out factory);
if (hr < 0 || factory is null) return null;
// ICONONLY:只要图标不要缩略图;BIGGERSIZEOK:允许外壳给更大的原版图标
const int flags = ShellNative.SIIGBF_ICONONLY | ShellNative.SIIGBF_BIGGERSIZEOK;
hr = factory.GetImage(new ShellNative.SIZE(pixelSize, pixelSize), flags, out hBitmap);
if (hr < 0 || hBitmap == IntPtr.Zero)
{
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
return null;
}
return ShellNative.BitmapToBgra(hBitmap, pixelSize, pixelSize);
}
catch (COMException ex)
{
Debug.WriteLine($"[ShellIconService] IShellItemImageFactory(icon) 0x{ex.HResult:X8}: {path}");
return null;
}
finally
{
if (hBitmap != IntPtr.Zero) ShellNative.DeleteObject(hBitmap);
if (factory is not null)
{
try
{
Marshal.FinalReleaseComObject(factory);
}
catch (ArgumentException)
{
// 已被释放:忽略
}
}
}
}
// ─────────────────────────────────────────────────────────────
// 像素 → WinUI ImageSource(唯一需要 UI 线程的一段)
// ─────────────────────────────────────────────────────────────
/// <summary>
/// BGRA 像素 → <see cref="SoftwareBitmapSource"/>。
/// SoftwareBitmap 可在任意线程构造,但 SoftwareBitmapSource 必须在 UI 线程创建并 SetBitmapAsync,
/// 因此这里统一通过 <see cref="_uiDispatcher"/> 派回 UI 线程。
/// </summary>
private Task<ImageSource?> CreateImageSourceAsync(BgraBuffer buffer)
{
// SoftwareBitmap 是自由线程的,先在当前(后台)线程建好。
// 用 DataWriter 把 BGRA 字节装进 WinRT IBuffer(比 byte[].AsBuffer() 更省一次拷贝,
// 而且后者依赖 System.Runtime.InteropServices.WindowsRuntime 扩展,在部分 TFM 下不可用)。
var writer = new DataWriter();
writer.WriteBytes(buffer.Pixels);
IBuffer winrtBuffer = writer.DetachBuffer();
var bitmap = SoftwareBitmap.CreateCopyFromBuffer(
winrtBuffer,
BitmapPixelFormat.Bgra8,
buffer.Width,
buffer.Height,
// 图标/缩略图带 alpha,必须用预乘格式,否则半透明边缘会出现黑边
BitmapAlphaMode.Premultiplied);
return RunOnUiThreadAsync(async () =>
{
var source = new SoftwareBitmapSource();
try
{
await source.SetBitmapAsync(bitmap);
}
finally
{
// SetBitmapAsync 会拷贝像素,之后即可释放 SoftwareBitmap
bitmap.Dispose();
}
return (ImageSource)source;
});
}
/// <summary>
/// 把一段"必须跑在 UI 线程"的工作派回去执行,用 TaskCompletionSource 桥接。
/// 如果调用方已经在 UI 线程上,则直接同步执行,省掉一次调度往返。
/// </summary>
private Task<T?> RunOnUiThreadAsync<T>(Func<Task<T?>> work) where T : class
{
var dispatcher = _uiDispatcher;
if (dispatcher is null)
{
// 没有调度器(构造时未注入且当前线程也没有):退化为直接在调用线程创建,
// 总比直接失败好 —— 调用方若本来就在 UI 线程,这里完全正确。
return work();
}
if (dispatcher.HasThreadAccess)
{
// 已在 UI 线程:直接执行(SoftwareBitmapSource 亲和 UI 线程,此处满足条件)
return work();
}
var tcs = new TaskCompletionSource<T?>(TaskCreationOptions.RunContinuationsAsynchronously);
bool enqueued;
try
{
enqueued = dispatcher.TryEnqueue(async void () =>
{
try
{
tcs.TrySetResult(await work().ConfigureAwait(true));
}
catch (Exception ex)
{
// 派回 UI 线程的工作失败:以异常结束 Task,由上层 RunSafeAsync 统一吞掉
tcs.TrySetException(ex);
}
});
}
catch (Exception ex) when (ex is COMException or InvalidOperationException)
{
// DispatcherQueue 正在关闭(应用退出中)
Debug.WriteLine($"[ShellIconService] TryEnqueue 失败: {ex.Message}");
return Task.FromResult<T?>(null);
}
if (!enqueued)
{
Debug.WriteLine("[ShellIconService] TryEnqueue 返回 false(消息循环已停止)");
return Task.FromResult<T?>(null);
}
return tcs.Task;
}
// ─────────────────────────────────────────────────────────────
// 缓存 / 并发 / 异常兜底
// ─────────────────────────────────────────────────────────────
/// <summary>
/// 同键请求合并 + LRU 缓存。
/// Lazy&lt;Task&gt; 保证同一个键只会解码一次,其余调用方 await 同一个 Task(快速滚动不会重复解码)。
/// </summary>
private static Task<ImageSource?> GetOrAddAsync(
LruCache<Task<ImageSource?>> cache,
string key,
Func<CancellationToken, Task<ImageSource?>> factory,
CancellationToken ct)
{
// 缓存命中时必须尊重取消:已取消就直接返回 null
if (cache.TryGet(key, out var cachedTask))
{
return ct.IsCancellationRequested ? Task.FromResult<ImageSource?>(null) : cachedTask;
}
// 注意 T 是 Task<ImageSource?>:Lazy 的工厂返回的就是这个共享 Task
var lazy = cache.GetOrCreate(
key,
_ => new Lazy<Task<ImageSource?>>(
() => factory(CancellationToken.None),
LazyThreadSafetyMode.ExecutionAndPublication));
return AwaitLazyAsync(lazy, ct);
}
/// <summary>等待共享的 Lazy&lt;Task&gt;,但让"本次调用"的取消能立刻返回 null(不打断别人共享的那次解码)。</summary>
private static async Task<ImageSource?> AwaitLazyAsync(Lazy<Task<ImageSource?>> lazy, CancellationToken ct)
{
Task<ImageSource?> task;
try
{
task = lazy.Value;
}
catch (Exception ex)
{
// Lazy 的工厂本身抛异常(极少见,主要来自 factory 构造阶段)
Debug.WriteLine($"[ShellIconService] 缓存工厂异常: {ex}");
return null;
}
if (task.IsCompleted)
{
if (ct.IsCancellationRequested) return null;
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
}
if (!ct.CanBeCanceled)
{
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
}
using var cts = new CancellationTokenSource();
using (ct.Register(static state => ((CancellationTokenSource)state!).Cancel(), cts))
{
var cancelTask = Task.Delay(Timeout.Infinite, cts.Token);
var finished = await Task.WhenAny(task, cancelTask).ConfigureAwait(false);
if (finished != task)
{
// 本调用被取消:吞掉 OperationCanceledException 语义,返回 null。
// 注意共享的那次解码仍在后台继续,其它调用方仍能拿到结果。
return null;
}
}
return await AwaitSharedTaskAsync(task).ConfigureAwait(false);
}
/// <summary>
/// await 共享 Task 的最后一层兜底。
/// 正常情况这里不会抛(各 Load*CoreAsync 都套了 RunSafeAsync),
/// 但共享 Task 一旦带异常,所有同键调用方都会中招,所以在出口再兜一次,确保公开 API 永不外抛。
/// </summary>
private static async Task<ImageSource?> AwaitSharedTaskAsync(Task<ImageSource?> task)
{
try
{
return await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] 共享取图任务异常: {ex}");
return null;
}
}
/// <summary>
/// 服务层统一兜底:取消 → null;其它任何异常(含 COMException / SEHException / 未知)记 Debug 后返回 null。
/// 这里绝不让异常逃出去把 UI 线程打崩。
/// </summary>
private static async Task<ImageSource?> RunSafeAsync(Func<Task<ImageSource?>> work)
{
try
{
return await work().ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// 取消是正常路径(列表滚过去了),静默返回 null
return null;
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] 取图失败: {ex}");
return null;
}
}
/// <summary>逻辑尺寸归一到合法范围(16..1024)。</summary>
private static int NormalizeSize(int size)
{
if (size <= 0) return 32;
if (size < 16) return 16;
if (size > 1024) return 1024;
return size;
}
/// <summary>归一化扩展名:去掉前导点、转小写;空则返回空串。</summary>
private static string NormalizeExtension(string? extension)
{
if (string.IsNullOrWhiteSpace(extension)) return string.Empty;
string ext = extension.Trim();
if (ext.StartsWith('.')) ext = ext[1..];
return ext.ToLowerInvariant();
}
/// <summary>按显示尺寸 × DPI 缩放算出实际渲染像素尺寸(保证高 DPI 不糊)。</summary>
private int ToPixelSize(int logicalSize)
{
double scale = 1.0;
try
{
scale = _scaleProvider();
}
catch (Exception ex)
{
Debug.WriteLine($"[ShellIconService] rasterizationScale 取值失败: {ex.Message}");
}
if (double.IsNaN(scale) || double.IsInfinity(scale) || scale <= 0) scale = 1.0;
if (scale > 4.0) scale = 4.0;
int pixels = (int)Math.Round(logicalSize * scale, MidpointRounding.AwayFromZero);
if (pixels < 1) pixels = 1;
if (pixels > 2048) pixels = 2048;
return pixels;
}
/// <summary>
/// 按显示尺寸挑系统图像列表,保证拿到的是「原生该尺寸」的图标,而不是放大出来的。
/// 实测各列表的 HICON 原生尺寸:SHIL_LARGE(0)=32、SHIL_SMALL(1)=16、SHIL_EXTRALARGE(2)=48、SHIL_JUMBO(4)=256。
/// </summary>
private static int SelectImageList(int logicalSize) => logicalSize switch
{
>= 256 => ShellNative.SHIL_JUMBO,
>= 48 => ShellNative.SHIL_EXTRALARGE,
>= 32 => ShellNative.SHIL_LARGE,
_ => ShellNative.SHIL_SYSSMALL,
};
private static string BuildIconKey(string path, bool isDirectory, int logicalSize)
=> (isDirectory ? "dir|" : "file|") + logicalSize.ToString() + "|" + path;
/// <summary>判断是否为已知外壳解析名(shell: 或 ::{CLSID})。</summary>
private static bool IsSpecialParsingName(string path)
=> ShellParsingName.IsShellPrefix(path) || path.StartsWith("::", StringComparison.Ordinal);
}
/// <summary>
/// 极简 LRU 缓存:ConcurrentDictionary 负责高并发读取,LinkedList 记录使用顺序,
/// 超出容量后从链表尾部淘汰最少使用的项。
/// </summary>
/// <typeparam name="T">缓存值类型(本工程里是 ImageSource?)。</typeparam>
internal sealed class LruCache<T>
{
private readonly int _capacity;
private readonly ConcurrentDictionary<string, Entry> _map = new(StringComparer.OrdinalIgnoreCase);
private readonly LinkedList<string> _order = new();
private readonly object _orderGate = new();
internal LruCache(int capacity)
{
_capacity = capacity > 0 ? capacity : 1;
}
private sealed class Entry
{
internal Entry(Lazy<T> value) => Value = value;
internal Lazy<T> Value { get; }
internal LinkedListNode<string>? Node { get; set; }
}
/// <summary>取缓存值(命中即刷新使用顺序)。</summary>
internal bool TryGet(string key, out T value)
{
if (_map.TryGetValue(key, out var entry))
{
Touch(entry);
value = entry.Value.Value;
return true;
}
value = default!;
return false;
}
/// <summary>
/// 取或创建。注意这里返回的是 Lazy&lt;T&gt; 本身:同一键的并发调用方会拿到同一个实例,
/// 因此 T 为 Task 时天然实现了"同键请求合并"。
/// </summary>
internal Lazy<T> GetOrCreate(string key, Func<string, Lazy<T>> factory)
{
var entry = _map.GetOrAdd(key, k => new Entry(factory(k)));
if (entry.Node is null)
{
lock (_orderGate)
{
if (entry.Node is null)
{
entry.Node = _order.AddFirst(key);
Trim();
}
}
}
return entry.Value;
}
internal void Clear()
{
lock (_orderGate)
{
_map.Clear();
_order.Clear();
}
}
/// <summary>把命中的键移到链表头部(最近使用)。</summary>
private void Touch(Entry entry)
{
var node = entry.Node;
if (node is null) return;
lock (_orderGate)
{
if (node.List is null) return;
_order.Remove(node);
_order.AddFirst(node);
}
}
/// <summary>超出容量时从尾部淘汰(调用方已持有 _orderGate)。</summary>
private void Trim()
{
while (_order.Count > _capacity)
{
var last = _order.Last;
if (last is null) return;
_order.RemoveLast();
if (_map.TryRemove(last.Value, out var removed))
{
// 置空节点引用,防止被淘汰的 Entry 仍被 Remove 时的 node.List 判空逻辑误解
removed.Node = null;
}
}
}
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
namespace FluidExplorer.Services.Icons;
/// <summary>
/// 外壳解析名(parsing name)的小工具。
/// 资源管理器/地址栏里出现的 "shell:RecycleBinFolder"、"{20D04FE0-3AEA-1069-A2D8-08002B30309D}"(此电脑)
/// 都不是文件系统路径,必须先经 SHParseDisplayName 解析成 PIDL 才能取图标。
/// </summary>
internal static class ShellParsingName
{
/// <summary>"shell:" 前缀(大小写不敏感)。</summary>
internal const string ShellPrefix = "shell:";
/// <summary>是不是 "shell:xxx" 形式的解析名。</summary>
internal static bool IsShellPrefix(string? parsingName)
=> !string.IsNullOrEmpty(parsingName)
&& parsingName.StartsWith(ShellPrefix, StringComparison.OrdinalIgnoreCase);
/// <summary>是不是 "::{CLSID}" 形式的解析名(此电脑、回收站等已知外壳对象的经典写法)。</summary>
internal static bool IsGuidPidl(string? parsingName)
=> !string.IsNullOrEmpty(parsingName)
&& parsingName.StartsWith("::", StringComparison.Ordinal);
/// <summary>本工程支持的外壳解析名(取图标时可以走 PIDL 路径)。</summary>
internal static bool IsParsingName(string? parsingName)
=> IsShellPrefix(parsingName) || IsGuidPidl(parsingName);
/// <summary>补全 "shell:" 前缀:传入 "RecycleBinFolder" 也能用。</summary>
internal static string Normalize(string parsingName)
{
if (string.IsNullOrWhiteSpace(parsingName)) return string.Empty;
string trimmed = parsingName.Trim();
return IsParsingName(trimmed) ? trimmed : ShellPrefix + trimmed;
}
// 常用已知外壳文件夹的解析名(全部是 Windows 自带原版对象)
internal const string RecycleBin = "shell:RecycleBinFolder";
internal const string ThisPcClassic = "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}";
internal const string ThisPc = "shell:MyComputerFolder";
internal const string Network = "shell:NetworkPlacesFolder";
internal const string UserProfile = "shell:UserProfile";
internal const string Desktop = "shell:Desktop";
internal const string Downloads = "shell:Downloads";
internal const string Documents = "shell:Personal";
internal const string Pictures = "shell:MyPictures";
internal const string Music = "shell:MyMusic";
internal const string Videos = "shell:MyVideos";
internal const string ControlPanel = "shell:ControlPanelFolder";
}