895 lines
36 KiB
C#
895 lines
36 KiB
C#
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<Task>),列表快速滚动时不会重复解码同一张图。
|
||
/// 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<Task> 模式的落点(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<double>?),见 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<Task> 保证同一个键只会解码一次,其余调用方 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<Task>,但让"本次调用"的取消能立刻返回 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<T> 本身:同一键的并发调用方会拿到同一个实例,
|
||
/// 因此 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;
|
||
}
|
||
}
|
||
}
|
||
}
|