ImageGlass v10 开发者架构指南:AOT 安全、跨平台服务与高性能渲染工程实践

原创2026-10-01 22:15:22117 阅读
文章标签:桌面应用图像处理

ImageGlass v10 开发者架构指南:AOT 安全、跨平台服务与高性能渲染工程实践

导读

本文基于 ImageGlass v10 的开发者指南 source/CLAUDE.md 编写,系统梳理这个完全重写的开源图像查看器在 .NET 10 + Avalonia 12 + SkiaSharp 3.119.x 技术栈下的工程架构。你将掌握 v10 的模块划分与关键类定位、五大开发红线(AOT/Trim 安全、线程安全、内存泄漏、内存控制、跨平台设计)、Photo/Codec/Viewer 渲染链路、缓存与色彩管理/HDR 色调映射的实现原理,以及构建、调试、打包和疑难排查的完整方法。文中所有结论均可在 source/ 目录源码中逐一印证,可直接作为在 v10 代码库上开发、移植或贡献的作战地图。


一、项目概览:为什么 v10 是一次"从零重写"

ImageGlass v10 与 v9 的"渐进式迭代"不同,它是一次完整重写:

  • 技术栈:C# 搭配 .NET 10,UI 采用 Avalonia 12(跨平台 XAML),渲染基于 SkiaSharp 3.119.x;
  • 目标平台:Windows、macOS、Linux 的 x64 与 ARM64,且全部启用 AOT(Ahead-of-Time)发布;
  • 仓库布局:/source 是 v10 的活跃代码库(本文档所在),/v9 是已归档的旧版 WinForms/.NET 代码。除非用户明确要求处理 v9,否则一切开发默认面向 v10。

四大核心工程(Key Projects)

工程 目标框架 职责
ImageGlass.Lib net10.0 核心库:UI 控件、渲染、编解码器、主题、本地化、设置
ImageGlass.Win32 net10.0-windows10.0.19041.0 Windows 桌面入口:Win32 API 与平台服务实现
ImageGlass.Linux net10.0 Linux 桌面入口:Linux 专属服务
ImageGlass.Mac net10.0 macOS 桌面入口:macOS 专属服务

版本号只在 source/Directory.Build.props 一处维护(当前 IgVersion=10.0.6.906),由 source/Directory.Build.targets 生成 AppBuildInfo.g.cs 把版本以常量字符串烘焙进可执行工程——完全 AOT 安全、无需反射。


二、五大关键关注领域(开发红线)

这是 v10 开发中"永远优先考虑"的五件事,也是项目大量架构决策的根因。

1. AOT / Trim 安全

  • 所有平台工程(Win32、Linux、Mac)均启用 PublishAot=true 与 PublishTrimmed=true,并开启 trim analyzer;
  • Win32 工程额外启用 DisableRuntimeMarshalling=true 并采用 CsWin32 代码生成(见 source/ImageGlass.Win32/NativeMethods.txt);
  • 禁止基于反射的序列化——JSON 序列化必须使用 source/ImageGlass.Lib/Common/Types/JsonTypeConverters/ 下的自定义 AOT 安全转换器;
  • 验证方式:dotnet publish ImageGlass.Win32 -c Release 产出自包含(self-contained)、裁剪(trimmed)的可执行文件。

在 source/ImageGlass.Lib/ImageGlass.Lib.csproj 中可以看到库工程的对应配置:PublishTrimmed=true、EnableTrimAnalyzer=true、LangVersion=Preview(可用 C# 14 特性)、AllowUnsafeBlocks=true(P/Invoke 与 SkiaSharp 互操作需要)、AvaloniaUseCompiledBindingsByDefault=true。

2. 线程安全(禁止数据竞争)

  • 保护共享状态一律使用 Lock 类型(而非 object 锁);
  • 并发标志位使用 InterlockedBool 而非普通 bool;
  • 线程安全集合使用 ConcurrentDictionary;
  • 多线程上下文中访问资源前必须先检查 IsDisposed。

典型例证:source/ImageGlass.Lib/Common/Photoing/Manager/PhotoManager.cs 中 PhotoManager 用 Lock _lock 保护 _items、_dict(ConcurrentDictionary<string, int>,采用 OrdinalIgnoreCase 精确字符串匹配)与 _cachedIndexes。

3. 内存泄漏预防

  • 所有 SKObject 获取一律用 try/finally 或 using;
  • 在 PhDisposable 子类中重写 OnDisposing() 清理非托管资源;
  • 例:MipmapTileCache 驱逐时用 RequestDispose()(而非 Dispose()),保证正在渲染的租赁(lease)仍然有效;
  • SKImageRef 租赁用完必须释放,MipmapTileCache 中使用 KeepAlive() 模式。

4. 内存使用控制

  • 遵守缓存上限:MipmapTileCache 上限 100 个瓦片;PhotoManager_Caching 遵守 Config.MaxMemoryCacheInMb、Config.MaxFileSizeCacheInMb、Config.MaxDimensionCache;
  • 瓦片缓存与照片缓存强制 LRU 驱逐,防止无界增长;
  • 硬解码上限:任何 SKBitmap 或栅格 SKImage 的字节数不得超过 int.MaxValue——无论空闲内存多大。约等于:4bpp 下 5.36 亿像素、RgbaF16 下 2.68 亿像素、RgbaF32 下 1.34 亿像素。没有绕过途径:SKImage.FromPixels 从 SKData、裸指针或 SKPixmap 构造都会在同一边界失败,宿主自有缓冲技巧无效。两个解码器采用"缩小以适应"而非报错(见下文"超大图像解码");
  • 位图完成后在 SKImage.FromBitmap 前调用 SetImmutable()(即 SkiaCodec.ToSKImageNoCopy),否则 Skia 会复制整个像素缓冲,峰值内存翻倍;
  • 用大图和 GIF 动画做内存剖析,确保缓存不泄漏。

5. 跨平台设计(接口先行)

以 source/ImageGlass.Win32/Program.cs 为证:Main 中先注册 Core.StoreEntitlementProvider(必须在 InitializeAppInstance 之前——许可证解析发生在其中),随后在 App.InitializeAppInstance 的回调里安装 FileSearchProvider、PreviewProvider、ShellProvider、PrintProvider、PipeSecurityProvider。


三、架构亮点:Core 静态枢纽、插件式服务与解码渲染链路

1. Core 静态枢纽(Core.cs / Core_Events.cs)

source/ImageGlass.Lib/Core.cs 与 source/ImageGlass.Lib/Core_Events.cs 构成全局事件分发器与单例注册表:

  • Core.AppInstance:应用生命周期与单实例强制;
  • Core.Config:全局配置状态;
  • Core.Photos:照片集合与文件搜索;
  • 事件:LanguageChanged、ThemeChanged、PhotoUnloaded、PhotoSaved、ColorProfileChanged;
  • 服务插槽:ShellProvider、PreviewProvider、FileSearchProvider、PrintProvider、ColorProfileProvider、PipeSecurityProvider、StoreEntitlementProvider、API。

2. Service Providers(插件模式)

平台无关接口 + 平台实现 + 库内共享实现(PhotoPreviewProvider、SlideshowProvider、UpdateProvider)。在 Program.cs 注册、App.xaml.cs 初始化。

3. Photo 与 Codec 抽象

  • PhotoManager:AvaloniaList<Photo> + ConcurrentDictionary 索引的线程安全集合;
  • 双解码器:SkiaCodec(快速 SkiaSharp 流水线)+ MagickCodec(Magick.NET 兜底,处理冷门格式);
  • 异步加载:PhotoLoadingOptions + 帧动画支持(SkiaAnimator / AnimatorImpl);
  • 缓存:source/ImageGlass.Lib/Common/Photoing/Manager/ 下的 PhotoManager_Caching.cs 采用螺旋模式、LRU 驱逐、内存预算。

4. Viewer 与渲染(ViewerControl)

  • 按关注点拆分的分部类:source/ImageGlass.Lib/UI/Viewer/ 下的 ViewerControl_Render.cs、ViewerControl_ZoomAndPan.cs、ViewerControl_Events.cs、ViewerControl_Animation.cs、ViewerControl_NavButtons.cs 等;
  • 高性能 mipmap 瓦片缓存:source/ImageGlass.Lib/UI/Viewer/Renderer/MipmapTileCache.cs,对 8192×8192 以上的大图启用 LRU 驱逐;
  • 手势识别器:PhPanGestureRecognizer、PhPinchGestureRecognizer 累积事件点实现平滑交互;
  • 导航按钮:NavButtonsOverlay 是独立的 PhControl 覆盖层,悬停时渲染左右箭头;用 RequestAnimationFrame 实现帧率无关的滑动+淡入动画;指针释放时才判定点击(不干扰平移);通过 EnableNavButtonsProperty 附加属性绑定配置。

四、关键基础类与模式

1. 响应式编程 — PhReactive

  • 所有 ViewModel 继承 PhReactive : INotifyPropertyChanged;
  • 用 Lock(而非 object)做线程安全的属性变更通知;
  • 与 Avalonia 编译绑定配合:{CompiledBinding PropertyName}。

示例(ViewModel 属性定义):

public class MainWindowViewModel : PhReactive
{
    public string Title
    {
        get; set
        {
            if (field.Equals(value)) return;
            field = value;
            OnPropertyChanged();
        }
    } = BHelper.AppName;
}

2. 资源管理 — PhDisposable

  • 管理非托管资源(SKImage、文件句柄等)时继承之;
  • 重写 OnDisposing()(在 Dispose 时调用,而非析构函数);
  • 内部用 InterlockedBool _isDisposed 做线程安全的释放检查;
  • 异步上下文中配合 await using 使用。

其实现见 source/ImageGlass.Lib/Common/Types/PhDisposable.cs:Dispose(true) 触发 OnDisposing() 与事件清理,_isDisposed.SetTrue() 置位,并 GC.SuppressFinalize。

public sealed class MipmapTileCache : PhDisposable
{
    protected override void OnDisposing()
    {
        lock (_lock)
        {
            foreach (var bitmap in _tiles.Values)
            {
                bitmap.Dispose();
            }
            _tiles.Clear();
        }
    }
}

3. 线程安全标志 — InterlockedBool

  • 并发状态标志用其替代 bool;
  • 基于 Volatile 与 Interlocked 的原子读写。

其实现见 source/ImageGlass.Lib/Common/Types/InterlockedBool.cs:Value 属性用 Volatile.Read 读取,SetTrue()/SetFalse() 用 Interlocked.Exchange 原子翻转,并返回"是否发生变更",支持隐式转换为 bool。ViewerControl 中的 _isPreviewing、_isFirstDraw、_isDisposed 均是实例。

4. SkiaSharp 安全 — IsDisposed() 扩展

  • 使用 SkiaSharp 对象前总是调用 SKObject.IsDisposed();
  • 定义在 ImageGlass.Common.Extensions.SKObject_Exts;
  • 对象为 null 或 Handle == IntPtr.Zero 时返回 true。
if (_skImage.IsDisposed()) { _skImage = null; }

五、构建、调试与测试工作流

构建与运行

# 还原并构建
dotnet build

# 发布 AOT 二进制(ImageGlass.Win32)
dotnet publish ImageGlass.Win32 -c Release -o ./bin/publish

# 从源码运行,可携带图片路径与附加参数
dotnet run --project ImageGlass.Win32 -- [image_path] [additional_args]

注意:调试构建时需先把 source/__assets/__app/ 的内容复制到构建输出目录、放在 ImageGlass 可执行文件旁边,再启动(见 README.md 的 Development 说明)。

调试

  • 在 Visual Studio 2026 中打开解决方案,将 ImageGlass.Win32 设为启动项目;
  • C# 代码断点正常工作;
  • Avalonia Hot Reload:带 --debug 标志可用(仅限 XAML/样式);
  • 性能剖析:用 VS 的内存剖析器检测缓存泄漏。

测试

  • v10 没有独立测试工程(侧重功能验证);
  • 通过 dotnet run 手动测试;
  • 用样例图片测试命令行解析;
  • 用大图和 GIF 动画剖析内存。

关键 C# 与构建开关速查

项 值
LangVersion Preview(可用 C# 14 特性)
Nullable 各工程 enable
AllowUnsafeBlocks true(P/Invoke、SkiaSharp 互操作)
PublishAot 所有平台工程 true
PublishTrimmed 所有工程(库 + 平台)true
PublishSingleFile / SelfContained / PublishReadyToRun 所有平台工程 true
DisableRuntimeMarshalling 仅 Win32 true(AOT 安全封送)

六、关键集成点(源码级原理)

1. 照片加载流水线(Photo Loading Pipeline)

App.Initialize()
  → Core.InitializeAsync()
    → PhotoManager.Add/SearchAsync()
    → ViewerControl.LoadPhotoAsync(photoPath)
      → Codec.DecodeAsync() (SkiaCodec or MagickCodec with fallback)
      → shrink to fit the pixel ceiling if needed → Photo.DecodeScale
      → PhotoRenderer.RenderAsync()
      → AnimatorImpl for GIF/WEBP animation
    → PhotoLoading/PhotoAnimatorFrameChanged events
    → MipmapTileCache.Create() if image > 8192×8192

2. 超大图像解码(Oversized image decode)

超过 int.MaxValue 字节上限的图像缩小解码而非拒绝:

  • Skia 路径(SkiaCodec.GetDecodableImageInfo):沿编解码器原生缩放逐级下调,JPEG 从 1/8 起按最大优先。非 JPEG 编解码器无法缩放,会抛出可读的 NotSupportedException;
  • Magick 路径(SkiaCodec.FromMagick):原地缩放以适应,因此能比 Skia 更贴近上限;调用方必须保证传入的图片即将被丢弃;
  • 缩档链路:SkiaDecoderOutput/MagickCodecAdapter → CodecDecodeResult.DecodeScale → Photo.DecodeScale,并在 AppStatusInfo.Dimension 中展示;
  • Photo.Size 与 ViewerControl.BitmapSize 跟随解码后尺寸,保证缩放、选区、裁剪、取色一致;只有 Photo.Metadata.Width/Height 保留文件真实尺寸。

3. 主题与本地化变更

  • 换语言:Core.LanguageChanged?.Invoke();
  • 换主题:Core.ThemeChanged?.Invoke(new ThemePackChangedEventArgs(...));
  • UI 通过绑定 + 事件订阅自动刷新。

4. Win32 服务注册

发生在 source/ImageGlass.Win32/Program.cs 的 Avalonia 启动之前(Linux/Mac 有等价注册):

  • FileSearchProvider:原生 shell 枚举,支持 Explorer 排序顺序;
  • ShellProvider:右键菜单、文件属性、Windows Share API;
  • PrintProvider:Windows 打印 API;
  • ColorProfileProvider:通过 Win32 API 获取显示器色彩配置文件;
  • PreviewProvider:通过 Windows shell 缩略图缓存。

5. 配置持久化

6. 并发文件写(BHelper.WriteAllTextSharedAsync)

多实例共享同一个 igconfig.json,普通 File.WriteAllTextAsync 会因共享冲突失败。因此:

  • 所有 JSON 写入先落 <path>.<pid>.tmp,再重命名覆盖目标,并用按路径的 SemaphoreSlim(同进程两个窗口)加有限重试保护;
  • File.Move(overwrite: true) 只在无写者持有目标时才是原子的;被占用时退化为就地拷贝,并发读者会读到截断文件。因此 OpenReadShared 请求 FileShare.Read | FileShare.Delete:Delete 允许在句柄打开状态下重命名,而拒绝 Write 才是保持"重命名"的关键。约 16.6 万次读测量:ReadWrite 出现 878 次截断读,Read 为 0 次;
  • File.Replace 不是解药:会遗留 ~RF*.TMP 垃圾,且调用期间目标名短暂不存在,并发读写都会抛 FileNotFoundException;
  • Config.SaveAsync 永不抛出:失败时返回 false 并记录 Config.SavingException,因为它在 async void 关闭处理器和 SettingsWindow.OnClosing 的 fire-and-forget 保存中被调用;用户显式触发的路径仍会报告失败(ModalWindow.ShowSettingsSaveErrorAsync,或从 IG_ApplySettings 抛出);
  • 最后写入者获胜(last writer wins)是既有设计:每个实例持久化自己的完整 Config。

7. 便携模式(ConfigMode)

  • 启动目录存在 .igportable 标记文件(Const.PORTABLE_MARKER_FILE)时,整个配置目录迁移到启动目录:BHelper.ConfigPath 返回 BasePath 而非 %LocalAppData%\ImageGlass,于是 igconfig.json、_cache、_logs、_plugins、_lang、_themes 全部与 exe 同居,文件夹可整体搬迁而不丢设置;
  • ConfigMode 只回答"处于哪种模式"(IsPortable 每次进程首次访问时解析一次);BHelper.ConfigPath 是唯一的路径访问点;
  • 标记必须在任何代码触碰配置目录之前解析,所以 App.InitializeAppInstance 强制先行(trace 标记 InitInstance:portableMode),早于 LicenseService.LoadActive 与 Config.Load;
  • 不可写的启动目录中的标记是致命错误,绝非回退。ConfigMode.PortableError 保存来自单次创建探测(每个进程一个临时文件,绝非标记本身)的原始异常,App.OnFrameworkInitializationCompleted 展示后直接退出,Config.SaveAsync 拒绝写入;
  • 仅由 ZIP 打包脚本写入(source/__assets/win/script-pack-win-zip.ps1,-NoPortable 省略);绝不能放进 __assets/__app(MSIX 载荷目录只读,打包版将拒绝启动)。

ConfigMode 的实现见 source/ImageGlass.Lib/Common/Types/ConfigMode.cs:Resolve() 检查标记文件存在性,GetStartupDirAccessError() 用 FileOptions.DeleteOnClose 探测一个 <marker>.<pid>.tmp 临时文件验证可写性。

8. 全屏与窗口化布局

  • 全屏是"破坏性"操作:会把隐藏的工具栏/图库和 frameless/window fit 直接写进 Config。因此进入全屏前必须用 AppAPIProvider._preFullScreenLayout 保存一份 WindowLayoutSnapshot,由 MainWindow.CaptureWindowLayout() 在 enter 分支任何其他逻辑之前捕获;
  • 不变量:Config.ShowToolbar / ShowGallery / EnableFrameless / EnableWindowFit / EnableMainWindowMaximized / MainWindowBounds 永远描述窗口化布局;EnableFullScreen 是唯一正交标志。SaveConfigOnClosingAsync 在全屏结束会话时持久化 Core.API.PreFullScreenLayout 而非实时值;
  • 启动必须经 IG_ToggleFullScreen(true) 进入全屏,绝不直接赋 WindowState = FullScreen(裸赋值不捕获任何快照、跳过工具栏/图库隐藏策略);
  • 关闭处理器读取 RestorableWindowState 而非 WindowState:否则最小化状态下关闭会把 EnableFullScreen/EnableMainWindowMaximized 误记为 false;
  • 退出全屏时通过 Dispatcher.UIThread.Post 延迟重算 window fit,因为可见性变更与 ApplyWindowFitMode 测量 Toolbar.Bounds/Gallery.Bounds 存在时序依赖。

9. 照片缓存策略

  • Tier 1:查看器当前照片(始终加载);
  • Tier 2:通过 PhotoManager.RequestCacheAround() 预加载邻近照片;
  • Tier 3:内存预算耗尽时的 LRU 驱逐;
  • 螺旋模式:右-1、左-1、右-2、左-2……平衡浏览方向;
  • 已解码照片绝不能只通过 _cachedIndexes 可达:RunCacheAroundAsync 在 LoadAsync 返回的瞬间就登记索引(先于取消检查),否则"已解码但未被追踪"的照片既逃过 EstimateCachedMemory 又逃过驱逐,进程生命周期内永不释放。快速导航每一步都会取消一轮,因此这是常见路径而非罕见路径——它曾让整个 80 张照片的文件夹(3.6 GB)对抗 1000 MB 预算而不被释放;
  • 驱逐对全列表权威:UnloadPhotosOutside(keepIndexes, excludePhoto) 遍历每一项,卸载 keep 集外的所有 PhotoState.Loaded 照片;centerIndex 与 CurrentIndex 始终保留;ClearCache(excludePhoto) 是空 keep 集的同一清扫,这正是插件卸载安全的前提(孤照片持有插件缓冲将比 NativeLibrary.Free 活得更久);
  • 预算从螺旋累积,而非从 _cachedIndexes:pass 只用当前照片播种 usedMemory,螺旋再次访问已解码照片时累加;从追踪集播种会重复计数并过早中断循环;
  • LoadAsync 返回却不触发 Loaded 是"黑屏查看器"而非"空操作":SetPhotoAsync 提前释放旧图,静默返回会让照片黑屏直到用户导航离开再回来。因此:Photo 内按 _loadGate 串行化加载;缓存命中经 DispatchLoadedAsync 分发 Loaded;_cancelEpoch 丢弃排队期间被取消的加载;ViewerControl.RecoverIfNothingRenderedAsync 兜底其余路径;
  • UnloadPhotosOutside(..., keepCurrent: true) 逐项重读 CurrentIndex(清扫期间用户可能导航到 keep 集之外的索引);ClearCache 有意关闭它,插件卸载前必须能释放一切。

10. 文件搜索与元数据捕获

  • FileSearchProvider.EnumerateFileEntries 在枚举时把 FileInfo(大小、时间戳、属性)捕获进 FileSearchEntry,PhotoManager.Add(IEnumerable<FileSearchEntry>) 由此构建 Photo/PhotoMetadata,免去逐文件 re-stat。Windows 上这些字段来自 WIN32_FIND_DATA 免费获得;Unix 上每次 stat 一次;
  • PhotoMetadata(FileSearchEntry) 有意跳过 SetFilePath__(会 re-stat),是部分填充:IsOutdated() 保持 true 直到 codec 载入真实图像元数据;
  • 枚举输出绝对路径(FileInfo.FullName),PhotoManager.IndexOf 是精确字符串 ConcurrentDictionary 查找。任何进入 StartLoadingFiles 的路径都必须先经 BHelper.ResolvePath,否则初始照片永远解析不到列表索引(图库无选中、导航从 -1 开始);
  • FileSearchingEventArgs.Results 是物化的 IReadOnlyList,过滤/排序在后台线程完成而非在 Add 时惰性跑在 UI 线程;
  • Win32 覆写通过 Dispatcher.UIThread.Post 发布进度,并在 lambda 内重新检查 token,从而把陈旧批次与 PhotoManager.Clear() 串行化;Linux/macOS 基础提供者仍在工作线程直接调用 progressFn,该竞争在那边仍然开放。

11. 画廊缩略图流水线(Photo.LoadThumbnailAsync)

  • 层级依次为(useCache=false 时全部跳过):内存 GalleryThumbnail → ThumbnailDiskCache → IPhotoPreviewProvider.TryGetCachedThumbnailAsync(平台缓存)→ GetThumbnailAsync(解码);
  • 两个缓存层都先于 LoadMetadataAsync 运行——这是关键设计,因为两者都不许读源文件;ThumbnailDiskCache.TryGetAsync 用枚举时捕获的 Metadata.FileLastWriteTimeUtc 校验,而非重新 stat;
  • _galleryThumbnailRequestSize 把内存缩略图与其生产尺寸绑定,DPI 或 Config.ThumbnailSize 变化会重载而非复用旧尺寸;
  • 把缓存返回的任何图都缩到 thumbSize(SkiaCodec.ScaleDown 后再 ToWritableBitmap):Windows Shell 会交还整层缓存(96/256/768/1920),每张照片多留 MB 级图库内存;
  • 禁止在此调用内再嵌套进程级门闩:调用方已持有 _thumbnailThrottleLock 槽位,第二个信号量会让线程占着槽位挂起,图库塌缩为一次一张;
  • 节流按缓存预算而非核数:clamp(CacheMaxMemoryInMb / MB_PER_THUMBNAIL_SLOT, 4, clamp(ProcessorCount-1, 4, 16)),惰性求值以读取已加载配置。缓存未命中会先解码全图再缩小,每个槽位瞬时可持有一张全分辨率位图;纯 CPU 定价曾让 16 路并发 4K 解码压过整个照片缓存预算;
  • TryGetCachedThumbnailAsync 不许解码:Win32 用 SIIGBF_INCACHEONLY 实现,基础实现返回 null,因此 Linux/macOS 只有磁盘层。调用方已探测过时必须把 skipPlatformCache 传给 GetThumbnailAsync/GetPreviewAsync,否则每张照片同一 shell 查询跑两遍;
  • GalleryThumbnail 在 UI 线程赋值(ShowGalleryThumbnailAsync),因为编译绑定在触发 PropertyChanged 的线程上写 Image.Source。

12. 色彩管理(Config.ColorProfile)

  • 显示配置文件在两个点应用,两者绝不能同时打到同一像素:
    • 解码时(仅 Magick):MagickCodec.ProcessMagickImage__() 通过 TransformColorSpace 烘焙;仅在 MagickCodecAdapter 解码时运行(Skia 解码不应用配置文件);
    • 查看器时(Skia):ViewerControl.TryApplySkiaColorSpace()(HDR 色调映射 + SDR ICC),在 HandlePhotoLoadedAsync() 中应用;
  • CMYK 配置文件:SKColorSpace.CreateIcc() 对 CMYK 返回 null → Core.IsDestColorProfileSupported=false → SkiaCodecAdapter.CanDecode 拒绝 → 由 Magick 解码。ProcessMagickImage__ 绝不能输出 CMYK 像素(会反向渲染):把 refImgM.ColorSpace = ColorSpace.sRGB(保留印刷色调;不要 ICC 往返,会过度提亮);
  • 解码器选择缓存:Core.UpdateDestColorProfile() 调用 CodecRegistry.InvalidateSelectionCaches() 并总是触发 ColorProfileChanged。没有失效,CMYK 选择会让 .jpg 卡在 Magick("粘性降级"),之后每个配置文件双重应用(直到重启都过亮/过饱和);
  • 变更时:Core_ColorProfileChanged() 通过 SetPhotoAsync(photo, { ResetZoom = false, UseCache = false }) 重新解码当前照片——干净重应用同时保留缩放/平移;绝不就地变换 _imgSource。ColorProfile 刻意不进 SettingsViewModel.reloadPhoto(通用重载会重置缩放);
  • 显示器变更 vs 设置变更:UpdateDestColorProfile(requiresPhotoReload) 通过 DestColorProfileChangedEventArgs 传递意图。显示器配置文件提供者传 false(跨显示器拖动窗口不得闪烁查看器);设置页用默认 true。两种情况下后续加载都会应用新配置文件。

13. HDR 色调映射(HdrToneMapper、Core.HdrToneMappingConfig)

  • 每个传输函数对 1.0 的含义都不同,而 ComputeNormScale() 是唯一知道这点的地方:PQ = 10000 nits 绝对;ScRgb = 80 nits(IEC 61966-2-2,显示参考);Linear/None = 漫反射白(场景参考 EXR / Radiance HDR)。缩放后 v 恒为 nits / ReferenceWhiteNits,这就是交叉校验:同一场景以 PQ 和 scRGB 呈现必须落在同一 v;
  • WIC 对每种浮点与定点像素格式都推断 scRGB,所以 WIC 编解码插件返回的 RgbaFloat16 都是 HdrTransferFunction.ScRgb,绝非 Linear。标为 Linear 会把图像读亮 203/80 = 2.54 倍,表现为发白、黑位抬高。IGHdrTransferFn.ScRgb = 5 在插件 ABI 1.1.1 引入;插件按 ig_plugin_get_api 传入的 hostAbiVersion 门控,低于 1_001_000 回退 Linear(旧宿主把未知值映射为 None 而完全跳过色调映射);
  • ReferenceWhiteToneMap(BT2408)是全局滚降而非膝点,这是有意为之。参考白落在线性 0.5 附近(188/255),曲线只在自身白电平(文件的声明内容峰值,见下)或文件未声明时的 6 倍参考白处达到 1.0。BT.2408 规范确实说参考白映射到 SDR 白,但照做会裁掉真实调色的上四分之一:1164-nit 样本有 29% 像素高于参考白,膝点 0.9 会把 p75 渲染到 252/255。低位是余量,不是 bug。Reinhard(膝点 0.7)与 ACES(膝点 0.5)在膝点以下是恒等映射,会更早裁掉这些内容;
  • 色调曲线的白电平是文件声明的峰值,而非猜测。MagickCodec.DetectHdrInfo 第 10 步从容器 HDR10 元数据填充 PhotoMetadata.ContentPeakNits(ISOBMFF clli MaxCLL,否则 mdcv mastering max;PNG 拼写为块 cLLi 而非规范的 cLLI,且用 0.0001-nit 单位而 ISOBMFF 用整 nits),ComputeToneCurveWhiteLevel 把它变成 peak / ReferenceWhiteNits 并夹到 1..24。声明的 10000 会被拒绝:它是编码器不知道真实峰值时发出的 PQ 容器上限,信任它会让 24 倍余量困在没有的内容上。只有 BT2408 消费它;Reinhard/ACES 保持膝点驱动。HighlightCompression 现在表示越过声明峰值的额外余量(x (1 + 3c)),用于比自身元数据更亮的调色;
  • ReferenceWhiteNits 升高会使图像变暗,这是正确的而非反转。它是归一化除数(ComputeNormScale),即源侧 BT.2408 HDR 参考白——声明更亮的白必然让其下一切变暗。混淆点在于显示端的"纸白"(Windows SDR 内容亮度)升高会变亮。不要"修正"这个方向;
  • Avalonia 只呈现 SDR,所以 HDR 模式桌面会让微软照片结构性更好看,任何色调映射设置都追不上。追求之前先用 DisplayConfigGetDeviceInfo 检查 advancedColorEnabled;切勿用 GDI 屏幕截取 HDR 桌面作为另一应用的基准(它只忠实于 ImageGlass 自身的 SDR 输出)。

HdrToneMapper 源码(source/ImageGlass.Lib/Common/Photoing/Codecs/SkiaCodecs/HdrToneMapper.cs)中可印证:PqPeakNits = 10_000f、ScRgbWhiteNits = 80f、Bt2408ReferenceWhiteNits = 203f、FallbackToneCurveWhiteLevel = 6f、白电平夹取范围 1f..24f,以及两种策略:线性 sRGB 基色内容(EXR、Radiance、scRGB)用逐通道避免溢出伪影;宽色域 PQ/HLG(JXL、AVIF、HEIF)用基于亮度以保留 Rec.2020→sRGB 色域矩阵所需的通道比。


七、代码风格与最佳实践

通用规则

  • 注释只解释 why 而非 what,一行以内;两行仅在确实不可避免时;三行永远不允许——该上限同样约束 XML 文档标签(<param>、<summary>)。放不下的理由应进 commit message 或 PR;
  • 基础/协调类(插件、工具、宿主桥、进程管理器、IPC 处理器等)的类、方法、公共属性保持 XML 文档存在且最新;
  • XML 摘要绝不用单行 /// <summary>Text</summary>,一律多行形式;
  • 长/复杂方法用简短行内注释标注主要实现阶段;
  • 库代码用 ConfigureAwait(false),UI 入口省略;
  • 超时用 CancellationTokenSource.CancelAfter();
  • 循环中总是检查 token.IsCancellationRequested,取消时抛出。

Avalonia 专属

  • 复用 source/ImageGlass.Lib/UI/BaseControls/ 的 Ph* 控件而非裸 Avalonia 控件;
  • 平台条件可见性用 XAML OnPlatform 标记扩展写在 IsVisible 上,而非代码里的 OperatingSystem.IsWindows()(参见 source/ImageGlass.Lib/UI/Toolbar/ToolbarControl.axaml);
  • 编译绑定:AvaloniaUseCompiledBindingsByDefault = true,XAML 中一律写 {CompiledBinding ...} 而非 {Binding ...}(含样式、ControlTheme、DataTemplate);
  • 事件处理优先使用附加行为(attached behaviors)而非 UI 触发器;
  • 跨线程更新用 Dispatcher.UIThread.Post() / Dispatcher.UIThread.InvokeAsync();
  • 动画用 TopLevel.GetTopLevel(this)?.RequestAnimationFrame(callback) 实现帧率无关动画:从 TimeSpan 时间戳参数计算 delta,回调末尾请求下一帧以继续循环(见 source/ImageGlass.Lib/UI/Viewer/ViewerControl_Animation.cs、NavButtonsOverlay.cs)。

SkiaSharp 专属

  • 总是释放:SKImage、SKPaint、SKCanvas、SKMatrix(用 try/finally 或 using);
  • 热路径不做对象池:分配很快,过早池化伤害可读性;
  • 像素格式:sRGB 用 SKColorType.Rgba8888;ICC 场景考虑 ColorSpace;
  • 位图转图像:位图完成后用 SkiaCodec.ToSKImageNoCopy()(共享像素);普通 ToSKImage() 会复制整个缓冲;
  • 大小上限:分配任何全图缓冲前检查 width * height * bytesPerPixel 对 int.MaxValue;
  • 过滤:缩略图 SKFilterQuality.High;交互缩放 Medium;
  • Mipmap 策略:加载时预计算;按需缓存瓦片;保持缩放感知的 LRU。

Magick.NET 专属(原生堆安全)

Magick.NET 的取消竞态曾两次造成应用崩溃(0xc0000374 双重释放、0xc0000005 访问违规,均在 Magick.Native-*.dll):

  • 绝不 Ping 后再向同一个 MagickImage Read:Ping 分配原生图像,再读入同一包装器会留下第二个被双重释放的对象。用一次性 using var 探测,或先 Dispose() 再读(MagickCodec.DecodeImageAsync 是后者);
  • 每条失败路径都释放 MagickImage,含提前返回与 catch。被遗弃的半读图像交给终结器,会把上述错误变成在无关分配处浮现的延迟崩溃;
  • 取消 Magick ReadAsync 在 14.15.0–14.17.0 是硬性进程击杀,所以底线是 14.17.1。14.15.0 把文件重载重写为经反向 P/Invoke 回调流式化;token 触发会杀死泵,回调返回 -1,libjpeg 把它读作无符号 bytes_in_buffer 而越界。同一竞态也可能把解码线程困在 _readDone.Wait() 永不返回。14.17.0 只修复了访问违规(原生侧),14.17.1 修复了死锁。不得降低此底线去引入更老的 Magick.NET;
  • 适用于任何快速图库滚动可取消的路径:QuickDecodeAsync 由 PhotoPreviewProvider.GetThumbnailAsync 触达,其 token 在每次容器回收时被取消。

性能关键代码

  • Mipmap 缓存:8192×8192 以上的图用 MipmapTileCache,跨帧复用实例;
  • 手势识别器:PhPanGestureRecognizer、PhPinchGestureRecognizer 在昂贵计算前累积事件点;
  • 文件搜索:PhotoManager_FileWatcher.cs 中异步 + 防抖,IFileSearchProvider 做平台优化;
  • 照片预载:PhotoManager_Caching.RunCacheAroundAsync() 用螺旋模式平衡内存与响应性。

八、重要文件速查表

文件/目录 职责
source/ImageGlass.Lib/Core.cs、source/ImageGlass.Lib/Core_Events.cs 全局事件枢纽与单例访问;服务提供者插槽
source/ImageGlass.Lib/App.axaml.cs Avalonia 应用生命周期、主题/语言初始化
source/ImageGlass.Win32/Program.cs 入口、服务注册、AOT 引导
PhotoManager.cs + PhotoManager_*.cs 照片集合、文件搜索、LRU 缓存、文件监视
ViewerControl.cs + ViewerControl_*.cs 图像渲染、缩放/平移、选区、动画、导航按钮
source/ImageGlass.Lib/UI/Viewer/Renderer/MipmapTileCache.cs 带 LRU 驱逐的瓦片 mipmap 缓存
Common/Photoing/Codecs/SkiaCodecs/HdrToneMapper.cs HDR→SDR 色调映射;ComputeNormScale 中按传输函数锚定 1.0(scRGB = 80 nits)
source/ImageGlass.Lib/Common/ServiceProviders/ 跨平台特性的接口契约与共享提供者
Common/ServiceProviders/AppAPIs/FeatureManager.cs 功能锁(静态 Config.LockedFeatures API 名,仅管理员):IsLocked、IsZoomLocked/IsPanLocked、HideLockedMenuItems
source/ImageGlass.Lib/Common/Types/ConfigMode.cs 便携模式检测(.igportable 标记),由 BHelper.ConfigPath 消费;不可写目录是致命启动错误
Settings/Config_Static.cs(AdminLockedConfigs/IsConfigLocked)+ Windows/Settings/ 管理员设置锁:禁用并拒存 igconfig.admin.json 定义的设置(仅 Pro)
Common/AppThemes/ 主题加载、色彩管理(AppThemeColors、IgThemeColors)
Common/Localization/Lang.cs 翻译键注册表
source/ImageGlass.Lib/Common/Types/Resx.cs 主题资源注册表(ResxId);另承载图标助手(ResxIconId + GetIcon)与平台库存图标(StockIconId)
Common/Types/Const.cs 应用常量:图标尺寸、FONT_CODE、FONT_SIZE_*
source/ImageGlass.Lib/Common/Extensions/、source/ImageGlass.Lib/Common/BHelper/、source/ImageGlass.Lib/Common/Types/JsonTypeConverters/ 共享扩展、助手、AOT 安全 JSON 转换器(写新代码前先查)
source/ImageGlass.Lib/UI/BaseControls/ 可复用 Ph* 控件
source/ImageGlass.Lib/UI/Styles/ 全局 XAML 样式与资源字典(含 IconResources.axaml)
source/Directory.Packages.props 中央包版本管理(Avalonia 12.1.x、SkiaSharp 3.119.x、Magick.NET 14.x 等)

九、调试技巧速查(按症状定位根因)

  • PhotoLoading 挂起? 检查 ViewerControl 的 _cancelPreview;可能是 codec 超时;
  • 快速导航时照片黑屏、下次访问却正常? 加载在未触发 Loaded 的情况下结束(见上文"LoadAsync 返回却不触发 Loaded");用 --ig-photo-trace 追踪,查找没有跟随 render:first-draw 的 viewer:set-photo;
  • 内存泄漏? 验证 SKImage、SKBitmap、SKSurface 与 PhDisposable 子类的释放;用大图剖析;
  • 主题不更新? 确认 Core.ThemeChanged 被触发且订阅者在监听;
  • 手势失灵? 检查 ZoomAndPan/ 下的 PhPanGestureRecognizer、PhPinchGestureRecognizer 与点累积;
  • 导航按钮不显示? 检查 Config.EnableNavigationButtons 绑定;NavButtonsOverlay.Background 必须是 Brushes.Transparent 才能命中测试;EnableSelection 会禁用导航按钮;
  • 切换配置文件后颜色错误(过亮/过饱和、直到重启)? codec 选择缓存卡在 Magick(双重配置应用);确保 Core.UpdateDestColorProfile() 调用 CodecRegistry.InvalidateSelectionCaches()。CMYK 反转/负像?ProcessMagickImage__ 必须把 CMYK 结果转成 ColorSpace.sRGB;
  • HDR 图发白/过曝,或两个查看器亮度不一致? 是归一化 bug 而非颜色问题:ComputeNormScale() 按传输函数锚定 1.0,scRGB 是 80 nits 而非 203-nit 参考白。判读前先转储源亮度百分位,并检查 Config.ToolSettings["Tool_HdrToneMapper"].Mode——手测构建可能跑着 ACES(膝点 0.5,裁掉 p75 以上)而非默认 BT2408;
  • 管理员锁定项仍可编辑/保存? 管理员设置锁在三点全部强制:SettingsViewModel.CommitAsync(跳过锁定 id)、Config.ApplyCliOverrides(跳过锁定 id)、设置 UI(SettingsRegistry.DisableLockedControls + SettingsPageView.DisableIfLocked);
  • igconfig.default.json 被忽略? 两个模板层都是 Pro 专属,由 ReadDefaultConfigDocument() 门控,与管理员文件相同的"先许可证后加载"顺序与"启动目录优先、配置目录兜底"查找;
  • LockedFeatures 设在 igconfig.json 里无效? 预期行为:它不是 ConfigId 也不是 Config 属性,用户可写层全部忽略它;Config.LoadAdminPolicies() 只从 igconfig.admin.json 读取;
  • 被锁的功能仍可触发? 只有 AppAPIProvider.RunApiAsync(两个重载)与热键处理器经 FeatureManager.IsLocked 自动门控。其他路径必须自守:上下文菜单项经 LockAwareApiCommand 包装;查看器滚轮/拖拽/触摸缩放平移自查 FeatureManager.IsZoomLocked()/IsPanLocked();
  • 巨大图报 "Unable to allocate pixels for the bitmap"? 不是内存不足,别追 RAM 或碎片。Skia 拒绝任何超过 int.MaxValue 字节的像素缓冲;解码必须先缩小(Skia 路径 GetDecodableImageInfo,Magick 路径 FromMagick 原地缩放)。任何绕过此检查分配全图缓冲的新代码路径都会重演崩溃;
  • 巨大图打开后比文件小? 预期:Photo.DecodeScale < 1,状态栏显示缩减。Photo.Metadata.Width/Height 保留真实尺寸,其余都跟随解码后尺寸;
  • 启用 codec 插件后内存暴涨? 插件像素缓冲是 GC 看不见的原生内存;NativeCodecProxy.WrapPluginBufferAsImage 必须把 PluginPixelBufferRelease.TrackNativeMemory 与释放配对,缓存预算必须读 Photo.BytesPerPixel(插件帧可为 8 字节/像素而非 4);
  • 插件格式显示垃圾图库缩略图(细线、空白)而查看器正常? 内容嗅探的内置解码器用荒谬尺寸成功而不是失败,满足了 IsPreviewLargeEnough(只看最长边),插件因此不被调用。每个预览/缩略图来源都必须用 CodecRegistry.IsDecodingExtensionOwnedByPlugin 门控;
  • AppImage 立即退出(GLIBC_2.xx not found / libgomp.so.1 cannot open)? AppImage 链接宿主 glibc,与自带运行时的 Flatpak 不同;libgomp 是 Magick.Native-*.so 的 DT_NEEDED,打包在 usr/lib/fallback/,仅当宿主缺失时由 AppRun 加进 LD_LIBRARY_PATH。绝不打包 libGL/libX11/libfontconfig——它们与宿主驱动和字体配置耦合;
  • AppImage 打不开相对路径文件,或 Save As 默认进 /tmp/.mount_*? 都依赖 cwd(BHelper.ResolvePath 以 Path.GetFullPath 结尾),AppRun 把它固定到 $OWD。AppRun 还绝不能导出 TMPDIR(AppInstance 的锁在那里,不可写路径会被吞掉,每次启动都谎称"首个实例")或 XDG_DATA_HOME;
  • AppImage 重启后不回来(Quick Setup、Pro 激活)? Environment.ProcessPath 是挂载内的二进制,进程退出瞬间卸载。必须经 BHelper.AppRelaunchPath(设置 $APPIMAGE 时用之,否则 AppExePath)重启;
  • 设置不跟随应用文件夹(或启动即退"Cannot use portable mode")? 便携模式由启动目录 .igportable 标记驱动;标记必须在 exe 旁且文件夹可写,不可写是硬停止;
  • 显示的应用目录打不开("Unable to find /app/imageglass")或应用目录下文件突然找不到? Flatpak 把安装目录挂到 /app,MSIX 可能把 %LocalAppData% 重定向进包容器。BHelper.GetRealPlatformPath() 是唯一转换器,用于任何展示给 OS/用户或在进程外传递的路径;BHelper.BasePath 恒为二进制的完整真实目录,沙箱内该路径不可读,一切 I/O 读经 BHelper.BaseDir()(映射回挂载)——Path.Combine(BasePath, x) 做 I/O 是 bug;
  • 应用无声消失(无错误对话框、无日志)? 原生崩溃,App.UIThread_UnhandledException 不会运行。用 Get-WinEvent -FilterHashtable @{LogName='Application'} 过滤 Application Error 取异常码(0xc0000374 = 堆损坏,0xc0000005 = 访问违规),再用 WinDbg cdb.exe -z <dump> -c ".lastevent; !analyze -v; q" 定位模块;
  • macOS 上 EXC_CRASH (SIGABRT) / abort() called on a .NET TP Worker? 是 NativeAOT 从后台线程逃逸的托管异常的 FailFast——通常是 async void。用 atos 反符号化 ImageGlass.Mac/bin/<Platform>/Release/net10.0/osx-arm64/native/ImageGlass.dSYM;
  • UI 事件处理器之外的 async void 是崩溃而非 bug:UI 线程上 Dispatcher.UIThread.UnhandledException 能接住;任何工作线程上它静默杀进程。可从工作线程触达的每个 async void(Photo.OnDisposing/Unload、GalleryItem.LoadThumbnail、文件搜索回调)必须用 try/catch (Exception) 包住整个方法体;
  • 集成外部工具(ExifGlass)打开却不显示当前照片、不随导航更新? IPC 套接字不可达。Linux 上工具常见为 Flatpak 应用,每个 Flatpak 都有私有 /tmp,默认 NamedPipeServerStream 套接字对它不可见。ToolProcessManager.CreatePipeName() 因此按 BHelper.OS == Linux 判定,不是 IsFlatpakSandbox——工具的连通性才是关键,而不是 ImageGlass 自身是否沙箱化;
  • Windows 正常但 macOS/Linux 文件搜索路径崩溃? 只有 Win32FileSearchProvider 覆写 SearchAsync 经 Dispatcher.UIThread.Post 发布;基础提供者直接从 Task.Run 工作线程调用 progressFn。macOS/Linux 上触碰 Core.Photos.Items(绑定图库)或任何控件的搜索回调跑在非 UI 线程,必须封送整个回调体而不仅是 Add;
  • Linux 上应用启动但窗口从未出现——无错误、无崩溃、无日志? 主窗口由 _taskUi 门控,_taskUi.TrySetResult() 是 ApplyThemePackAsync 的最后一行;任何跳过它的路径都会挂起显示。用 xwininfo -root -tree 与 xwininfo -id <id> 查 Map State 诊断;
  • Linux 深色模式:启动时 PlatformSettings.GetColorValues() 是临时的 Light,ColorValuesChanged 只在解析值不同时触发。Avalonia 从 org.freedesktop.portal.Settings 异步解析主题,t=0 时无论桌面如何都返回 Light;事件在深色桌面约 20–34ms 后到达,浅色桌面永不触发。LINUX_SYSTEM_THEME_GRACE_MS 约束等待上限;
  • 图库缩略图每项加载两次,或错误尺寸缩略图闪现后被替换? PhVirtualizingUniformPanel.GetOrCreateElement 在 AddInternalChild 之前设置 DataContext,新 GalleryItem 仍处于分离状态时 TopLevel.GetTopLevel(this) 为 null,Dpi 静默回退为 1。用 IsLoaded 门控该处理器;
  • 打开文件后图库无选中(导航从 -1 开始)? 初始照片路径未匹配枚举形式。枚举输出绝对路径,IndexOf 是精确字符串查找,相对启动路径必须经 BHelper.ResolvePath;
  • 没有幻灯片通知音? BHelper.PlayNotificationSoundAsync() 播放内置 Assets/Sounds/notification.wav,绝非系统蜂鸣。Windows/macOS 用 NetCoreAudio(winmm MCI / afplay);Linux 有意不用(NetCoreAudio 硬编码 aplay),PlayLinuxSoundAsync 改为探测 paplay/pw-play/aplay/ffplay,保留首个退出码 0 者。Flatpak 还需 --socket=pulseaudio;
  • 退出全屏后窗口尺寸错误(或图库/工具栏重现)? 全屏前的 WindowLayoutSnapshot 未被捕获,或关闭处理器用实时全屏值覆盖了它;
  • igconfig.json 中的热键自己变,或整个配置加载失败? Hotkey 有两个文本且不可互换:KeyString 是平台显示文本(KeyGesture.ToString("p")),InvariantKeyString 才是唯一可持久化的。Hotkey.ParseFrom 是 ToInvariantString 的唯一逆向,接受显示拼写且绝不抛出;JsonHotkeyArrayConverter 丢弃解析不了的条目,单个坏串不会让 Config.Load 失败;
  • 多实例同时关闭时报 "The process cannot access the file 'igconfig.json'"? 写入必须走 BHelper.WriteAllTextSharedAsync(暂存+重命名+重试),读取走 BHelper.OpenReadShared;
  • 序列化失败? 确认 source/ImageGlass.Lib/Common/Types/JsonTypeConverters/ 中有对应转换器;
  • 缓存不驱逐? 检查 MipmapTileCache.MAX_CACHED_TILES(100)与 LRU 提升逻辑;
  • AOT 裁剪错误? 审查裁剪器警告;添加 [DynamicallyAccessedMembers] 注解或自定义转换器。

十、添加新功能的八步检查清单

  1. 跨平台? 在 Common/ServiceProviders/ 定义接口,在各平台 Common/ServiceProviders/(Win32、Linux、Mac)实现,经 Core.API 或服务插槽注册;
  2. 面向用户的字符串? 加到 Lang.cs 枚举,再用 Lang.GetString(LangId.KeyName) 引用;
  3. 新 codec? 继承 codec 基类(SkiaCodec 或 MagickCodec),在 PhotoManager 注册;
  4. UI 控件? 继承 PhControl 或 PhWindow;ViewModel 用 PhReactive;
  5. 设置项? 在 Config.cs 添加属性;复杂 JSON 在 JsonTypeConverters/ 创建转换器;
  6. 带取消的异步操作? 接受 CancellationToken ct,传给子任务,调用 ct.ThrowIfCancellationRequested();
  7. 内存关键? 用大图(8192×8192+)测试并剖析缓存行为;
  8. 线程工作? 共享状态用 Lock;避免阻塞 UI 线程;优先 async/await 而非 Thread。

结语

ImageGlass v10 的工程架构可以用一句话概括:以 AOT 安全为约束、以接口先行为骨架、以缓存预算为底线的高性能跨平台渲染系统。无论是继续探索 source/ImageGlass.Lib/Common/Photoing/ 的解码器实现,还是深入 source/ImageGlass.Lib/UI/Viewer/ 的渲染细节,本文给出的文件定位与设计红线都能帮助你快速定位代码、理解决策动机,并避免踩中线程、内存与 AOT 上的已知陷阱。

登录后查看全文
ImageGlass