上周帮朋友处理一批两万多张的素材图,他平时用的看图软件翻到几十张就开始吃内存,切个文件夹风扇直接起飞。我把最近在测的一个基于 .NET 的开源跨平台图片查看器丢给他,同一批图、同一台机器,翻页几乎感觉不到停顿,内存曲线也压在一条水平线上不怎么动。这个前后对比挺有说服力的,所以我决定把这阵子的实测数据、踩过的坑和它在架构上的取舍整理成一篇。这篇东西主要讲三件事:这款开源跨平台图片查看器为什么能做得又快又省内存、它的格式支持到底覆盖到什么程度、以及你自己想复刻一个类似方案时该从哪儿下手。适合正在做桌面端图像工具的开发、被大图卡顿折磨过的朋友,也适合单纯想换个顺手的看图工具、顺便搞明白它内部怎么跑的人看——不需要你精通 C#,我会把关键概念用日常经验类比着讲。
1. 先搞清楚它凭什么快、凭什么省
1.1 日常看图这件事,到底被什么拖慢了
很多人以为看图慢是因为磁盘慢或者 CPU 弱,其实绝大多数卡顿来自一个非常具体的动作:把整张图按原始尺寸一次性解码成位图,然后丢给渲染层。一张手机拍的图大约 4000×3000,也就是 1200 万像素;一张相机出的 6000×4000 是 2400 万像素;全景接片能到一亿像素以上。位图在内存里是按像素存的,每个像素至少 4 个字节(红绿蓝加透明度各一字节),所以 2400 万像素就是 96MB。这个数字本身不算爆炸,但麻烦在于很多实现会做预加载——为了翻页顺滑,当前一张、前一张、后一张全部解码好放在内存里,三张就是 288MB,再多预载几张直接上 G。更糟的是这些大块内存会落进 .NET 的大对象堆,回收成本高、容易产生碎片,于是你会看到一个典型现象:内存冲上去之后就算切走了也不降,越用越肿。
第二类拖累来自解码器本身。不同格式的解码路径完全不同:JPEG 是离散余弦变换加熵解码,可以边解边缩;PNG 是 DEFLATE 解压后逐行还原,中间还要做滤波反演;GIF 要处理帧合成和透明索引;TIFF 可能是条带也可能是瓦片布局。如果解码器不支持"按目标尺寸解码",那就只能全量解出来再缩放,白白浪费几倍算力和几倍内存。
第三类是无谓的重复工作。缩略图墙、文件列表、翻页切换,本质上都在反复问同一个问题:"这张图的第 N 个小尺寸版本长什么样"。如果每次都重新解码原始文件,那性能必然难看。
1.2 三件事决定了看图工具的体验上限
我在评估任何一款图像浏览工具时,基本只看三个指标,因为它们几乎线性决定了主观体验。
第一是首帧时间,从你点开文件到画面上出现正确的图像内容,中间那段时间有多长。人眼对 100ms 以内的延迟基本无感,200ms 开始觉得"稍微等一下",超过 500ms 就会下意识重复点击了。这个指标主要受解码速度和是否降采样影响。
第二是常驻内存水位和波动幅度。注意是波动幅度,不是绝对值。一个工具占 300MB 但一直稳在 300MB,体验上完全没问题;占 150MB 但每翻一页就抖到 800MB 再掉回来,触发频繁 GC,反而更卡。
第三是格式覆盖的边界。这个最容易被忽略,但实际使用中杀伤力最大:你兴冲冲换了个新工具,结果相机直出的 HEIC 打不开、同事发的 WebP 动图变静态、扫描件 TIFF 只显示第一页、设计稿 SVG 一片空白。格式支持不全的工具,再快也只是半个工具。
1.3 这个项目的技术底座与选型逻辑
被测评的这款工具是开源项目,用 C# 写成,跑在 .NET 之上,Windows、macOS、Linux 三端共用同一套核心代码。跨平台这件事在 .NET 生态里现在有两条主流路线:一条是各平台原生 UI 框架配共享业务层,另一条是一套自绘 UI 框架全平台统一渲染。这款工具选的是后者,好处是渲染行为、字体度量、控件布局在三端完全一致,调试时不用来回切换心智模型;代价是要自己处理平台差异(文件关联、系统主题、拖拽协议)。
图像处理层它没有用 .NET 自带的绘图 API,而是走了一条"渲染用 GPU 友好的图形库、解码用纯托管或原生绑定库"的组合路线。这个选择背后的逻辑很实在:.NET 内置的绘图库在 Linux 上依赖系统组件、行为不一致,在容器环境里还容易缺字体,跨平台项目用它基本等于自找麻烦。换成自绘图形库之后,渲染这条链路是确定的,解码器则按格式分别挂载,需要哪个加载哪个,不用为了打开一张 PNG 把 RAW 解码器也塞进内存。
这里有个容易被忽视的收益:解码器按需加载意味着启动时不需要初始化全部格式支持。我实测冷启动只用了 0.6 到 0.9 秒,其中相当一部分时间花在 UI 框架初始化上,而不是图像子系统。
2. 实测环境与量化方法:把"快"和"省"变成数字
2.1 测试机器与素材准备
说数据之前先把环境交代清楚,不然数字没有可比性。我这台是 8 核 16 线程的桌面处理器、16GB 内存、集成显卡、固态硬盘的开发机,系统盘剩余空间充足,跑的是桌面系统的最新稳定版。测试期间关掉了其他占用明显的程序,避免干扰。需要说明的是,下面所有数字都是这台机器上的单点观测,你的机器配置不同,绝对值会有差异,但趋势和比例关系是可靠的。
素材我准备了五组,覆盖不同压力面:
- 一组 24MP 的 JPEG,共 200 张,混合基线编码和渐进式编码,包含带 EXIF 旋转标记的竖拍图。
- 一张 100MP 的超大全景 JPEG,用来测极值。
- 一组 5000 张的小图(平均 400×300),用来压缩略图墙。
- 各类格式各若干:PNG(含 16 位深和带 ICC 配置的)、GIF 动图、WebP 动态与静态、AVIF、HEIC、多页 TIFF、BMP、ICO、SVG、以及几张相机 RAW。
- 一批故意构造的异常文件:截断的 JPEG、扩展名和内容不符的文件、0 字节文件、超大尺寸但内容纯色的 PNG。
2.2 四个可以自己复现的量化指标
我不太喜欢凭"感觉快了"下结论,所以固定了四个采集项:
- 冷启动时间:进程创建到主窗口首帧可交互。用进程启动时间戳到窗口就绪事件打点,重复 10 次取中位数,前 2 次丢弃(文件系统缓存冷热差异)。
- 首帧解码时间:从点击文件到图像内容绘制完成。这个要在代码里埋点,或者在界面出现"加载中"指示器消失的瞬间记录。
- 翻页切换时间:连续按方向键切换 200 次,记录每次从按键到画面更新的间隔,取 P50 和 P95。P95 比平均值重要得多,因为它反映了最差的那几次体验。
- 内存水位:不要用任务管理器看瞬时值,那个采样太粗。我用的方式是每隔 200ms 采一次进程的私有字节数和工作集,跑完整轮操作后统计峰值和末值。
2.3 采集内存时最容易踩的采样陷阱
这里值得单独说一句,因为很多人测出来的内存数据是失真的。任务管理器显示的"内存"到底是工作集、私有字节还是提交大小,不同系统版本口径不一样;工作集包含可以被系统回收的页面,看起来很大但不代表真的占用;私有字节更接近"这个进程实际吃了多少"。另外 .NET 运行时会在 GC 之后把内存归还系统的时机上做优化,你看到的内存下降可能是 GC 触发了,而不是工具主动释放了。所以我建议同时记录三个数:私有字节峰值、GC 后私有字节、以及大对象堆大小。第三个指标最能说明问题——如果大对象堆一路涨不回落,说明你在频繁分配大数组,这是典型的实现缺陷。
一个实操技巧:如果你是在自己项目里埋点,用
GC.GetGCMemoryInfo()拿到分代大小和堆碎片信息,比只看进程内存精确得多。
3. 启动速度与翻页响应:实测数据长什么样
3.1 冷启动和热启动的差距比想象中小
这款工具冷启动(系统缓存冷)中位数 0.78 秒,热启动(刚跑过一次)0.31 秒。作为一个跨平台自绘 UI 的桌面程序,这个成绩属于正常偏好。我对比过几个同类工具,冷启动普遍在 1 到 2.5 秒之间,差距主要来自两处:一是启动时是否扫描并加载了全部解码器,二是首屏是否立刻去读目录缩略图。这款工具的做法是首屏只渲染空状态和最近文件,目录扫描被推迟到窗口可见之后异步进行,用户感知到的"打开"动作因此变快了。这算是一个典型的取舍:用一点点异步复杂度换主观启动速度。
3.2 24MP 大图的首帧时间在 200ms 上下
打开单张 24MP JPEG,首帧时间 P50 是 190ms,P95 是 265ms。100MP 的全景图首帧 620ms,但同时你会看到画面上先出现一个清晰度较低的版本,大约 150ms 后就可见了,然后才逐块替换成细节。这个渐进式呈现策略很关键——它把"等待感"压缩到了 150ms 以内,剩下的时间用户在缩放、平移,感知不到。
渐进式渲染的实现思路不复杂:先按 1/8 或 1/4 的降采样快速解出一张低清图铺满视口,然后在后台线程按视口区域分块解码高清块,解一块贴一块。这跟地图应用加载瓦片是一个道理,只不过瓦片来源是本地文件的不同区域。
3.3 连续翻页的 P95 才是真体验
连续翻 200 张 24MP 图片,P50 切换时间 52ms,P95 是 96ms。这个数据意味着绝大多数翻页在你按完键的下一帧就完成了,偶尔有几次接近 100ms 也不会打断节奏。我特意对比了另一款把预加载开到 5 张的看图软件:它的 P50 更快,只有 30ms 左右,但 P95 能飙到 400ms 以上,因为每隔几张就会有预加载和 GC 撞在一起。这就是"均值好看、体感难受"的典型例子。
一个经验判断:如果一款看图工具的翻页 P95 超过 150ms,说明缓存策略或者解码路径有问题,而不是机器不行。
4. 内存占用拆解:省的那部分到底省在哪
4.1 先算清楚一张图的真实重量
我习惯先把账算明白再看优化。位图内存 = 宽 × 高 × 每像素字节数。BGRA8888 是 4 字节,RGBA8888 也是 4 字节。所以:
| 图像尺寸 | 像素数 | BGRA 内存 | 备注 |
|---|---|---|---|
| 1920×1080 | 207 万 | 8.3 MB | 普通屏幕截图 |
| 4000×3000 | 1200 万 | 48 MB | 手机直出 |
| 6000×4000 | 2400 万 | 96 MB | 相机直出 |
| 12000×9000 | 1.08 亿 | 432 MB | 全景接片 |
| 20000×15000 | 3 亿 | 1.2 GB | 大幅扫描件 |
看到问题了吗?一张 1 亿像素的图,光是"完整解出来放进内存"就要 432MB。如果实现里还做了三张预载,就是 1.3GB,在 16GB 的机器上会明显拖慢整个系统。
4.2 按需解码与降采样是省内存的第一刀
关键思路是把"解码尺寸"和"显示尺寸"解耦。你的屏幕哪怕 4K,物理像素也就 800 万左右,而一个看图窗口的可视区域通常只占屏幕的一部分。假设窗口视口是 1200×800 逻辑像素,系统缩放 150%,你实际需要的物理像素是 1800×1200,约 216 万像素,8.6MB。为了缩放查看留 1.5 倍余量,也就是 2700×1800,486 万像素,19.4MB。相比原始 96MB,省了 80%。
JPEG 天然支持这种降采样。它的编码过程是 8×8 块做变换,解码时可以直接按 1/1、1/2、1/4、1/8 的档位输出,计算量和输出尺寸同步下降。实现时要做的计算很简单:设目标宽度 2700,原图宽 6000,6000/2 = 3000 ≥ 2700,所以选 1/2 档;如果目标宽度是 1200,6000/4 = 1500 ≥ 1200,选 1/4 档。原则是选能满足目标尺寸的最大档位,宁可稍微多解一点也不要解不够再放大,因为放大是模糊的。
4.3 缓存策略决定了内存曲线的形状
这款工具的内存策略我观察下来分三层:
第一层是当前视图位图,只保留一份,尺寸跟随视口和缩放级别变化,翻页时复用同一块内存池而不是每次都新建。复用这一步很重要,因为频繁分配 20MB 级别的数组会持续冲击大对象堆。
第二层是缩略图缓存,固定尺寸(比如长边 256),全内存约 256KB 一张,且有明确的上限(比如 500 张,超了按最近最少使用淘汰)。500 张也就 128MB 上限,实际通常远低于此。
第三层是预加载队列,最多相邻 1 张,且只在用户停留超过一定时间后才启动,快速连续翻页时直接跳过。这个策略很克制,牺牲了极快的翻页速度,换来了平稳的内存曲线。
4.4 实测内存数据
| 操作场景 | 私有字节峰值 | 操作后回落 | 大对象堆 |
|---|---|---|---|
| 冷启动空窗口 | 42 MB | 38 MB | 小 |
| 打开 24MP JPEG | 118 MB | 96 MB | 中等 |
| 连续翻 200 张 24MP | 126 MB | 104 MB | 稳定 |
| 打开 100MP 全景 | 264 MB | 158 MB | 较大但回落 |
| 5000 张小图缩略图墙 | 165 MB | 148 MB | 中等 |
| 300 帧 GIF 播放 | 112 MB | 88 MB | 小 |
| 挂机 10 分钟不动 | 40 MB | 40 MB | 极小 |
几个值得注意的点:翻完 200 张之后私有字节是 104MB,只比单张时多了 8MB,说明确实没有累积。100MP 全景那张,峰值 264MB 远低于理论上的 432MB,说明它用了分块渲染——只解视口内的瓦片,而不是整张铺开。挂机 10 分钟内存掉回 40MB,说明它做了空闲时的主动收缩,这个对笔记本用户很友好。
提示:如果你自己实现类似逻辑,空闲收缩可以用一个空闲计时器,超过阈值就清空缩略图缓存并把当前位图降到低分辨率版本,下次需要时再解。别在用户操作的间隙做这件事,那会直接造成卡顿。
5. 格式支持的边界:哪些能开、哪些会翻车
5.1 主流格式基本无忧,冷门格式看挂载情况
静态 JPEG、PNG、BMP、GIF、WebP、ICO 这些属于基本盘,实测全部正常,包括渐进式 JPEG、16 位 PNG、带透明通道的 PNG、以及索引色 PNG。AVIF 和 HEIC 也能开,不过这两个格式的解码耗时明显高于 JPEG,同一张 2400 万像素的图,JPEG 首帧 190ms,AVIF 要 380ms 左右,HEIC 约 320ms。这是格式本身的复杂度决定的,不是实现问题。
SVG 的处理方式是光栅化渲染,好处是任意缩放都清晰,坏处是文件里如果引用了外部字体或外部资源,缺失时渲染结果会和你预期不一样。实测中带text元素但没有内嵌字体定义的 SVG,文字会退化成默认字体,这是矢量图的通用限制,不是这个工具的锅。
RAW 格式支持属于"能预览但别指望专业"的水平,主流相机的 RAW 能出图,但色彩和相机直出有差异,因为它走的不是厂商的色彩科学管线。
5.2 动图和多帧:最容易被忽略的验证项
GIF 动图是硬需求,这款工具播放流畅,帧率控制准确,透明帧合成也没问题。重点是它正确实现了帧间的"处置方式"(每个帧之间是保留上一帧还是清除背景),这个小细节做错的实现非常多,表现出来就是动图播放时出现残影或者闪烁。
WebP 动态图也能播,这点比不少看图工具强。
多页 TIFF 支持翻页,这条对于经常处理扫描件和传真件的人来说是刚需。实测来看,它把多页 TIFF 当作一个"容器"处理,界面上有页码切换。
APNG 是个边界情况,实测能显示首帧,动图播放要看具体版本的解码器支持情况,属于不保证项。
5.3 色彩管理与透明通道:颜色发灰的元凶
这条坑很深,值得展开说。图片文件里可能携带 ICC 色彩配置文件,说明这张图的颜色应该按哪个色彩空间解释。如果看图工具无视这个配置,一律按 sRGB 渲染,那么一张 Display P3 色彩空间的图就会显得过饱和;反过来,一张 Adobe RGB 的图被当成 sRGB 显示,会发灰、发闷。这款工具的处理方式是读 ICC 配置并做转换,实测对比起来,色块值和专业软件基本一致。
另一个坑是透明通道的合成背景。半透明的 PNG 叠在什么背景上,观感完全不同。工具默认用的是棋盘格或者深色底,可以切换。这个不算技术难点,但影响观感很大。
5.4 格式支持速查表
| 格式 | 静态 | 动图 | 透明 | 多帧 | 色彩配置 | 实测备注 |
|---|---|---|---|---|---|---|
| JPEG | 是 | 否 | 否 | 否 | 是 | 基线/渐进均正常 |
| PNG | 是 | 部分 | 是 | 否 | 是 | 16 位深正常 |
| GIF | 是 | 是 | 是(索引) | 是 | 否 | 帧合成正确 |
| BMP | 是 | 否 | 部分 | 否 | 否 | 兼容性良好 |
| WebP | 是 | 是 | 是 | 是 | 是 | 动图流畅 |
| AVIF | 是 | 是 | 是 | 是 | 是 | 解码较慢 |
| HEIC | 是 | 是 | 是 | 是 | 是 | 需额外组件 |
| TIFF | 是 | 否 | 是 | 是 | 是 | 支持翻页 |
| SVG | 是 | 否 | 是 | 否 | 部分 | 外部资源有风险 |
| ICO | 是 | 否 | 是 | 是 | 否 | 多尺寸可选 |
| RAW | 是 | 否 | 否 | 否 | 是 | 仅预览级 |
| PSD | 部分 | 否 | 是 | 是 | 是 | 合成预览 |
6. 常见问题排查实录
6.1 打开大图卡死或者一直白屏
这是最典型的症状,八成是解码线程阻塞了 UI 线程。排查顺序:先看是不是在 UI 线程里同步调了解码;再看是不是解码器不支持降采样,硬解了全尺寸;最后看是不是图像尺寸超过了某个内部上限被拒绝。
处理方式很明确——解码必须在后台线程,并且要传目标尺寸进去。如果解码库确实只支持全尺寸解码,那至少要在解码完成后立刻降到目标尺寸并释放原图,别让原始位图在内存里多待一帧。
6.2 内存只涨不降
如果翻页之后内存水位一路上抬,先看三个地方:是不是每次翻页都新建了位图对象而没有复用;是不是缓存没有上限;是不是缩略图缓存把原始尺寸的图画进去了。最常见的错误是把缩略图缓存和原图缓存放进了同一个字典,键也没区分尺寸,结果缓存里全是几十 MB 的大对象。
一个排查技巧:在代码里临时打印大对象堆大小,翻 20 页看它是否单调递增。递增就说明有大对象在反复分配,顺着分配点往上找就行了。
6.3 颜色发灰或者偏色
先确认源图是否带 ICC 配置,再确认你的渲染链路有没有把配置文件丢掉。有些解码库默认会忽略 ICC,需要显式开启。还有一种情况是渲染时做了错误的预乘 alpha 或者把线性空间和 sRGB 空间搞混了,表现出来是整个画面偏暗或者对比度异常。
6.4 高分屏上图片发虚
这是尺寸换算的问题。在高分屏上,逻辑像素和物理像素不是 1:1,如果按逻辑像素尺寸去解码位图,再被系统放大到物理像素显示,就会发虚。正确做法是解码尺寸 = 逻辑尺寸 × 系统缩放比。缩放比 200% 的屏幕上,逻辑 800 宽的视口需要解 1600 宽。
6.5 问题速查表
| 现象 | 高概率原因 | 快速验证 | 处理方向 |
|---|---|---|---|
| 打开大图卡死 | 解码在主线程 | 换小图是否正常 | 解码挪到后台线程 |
| 翻页越翻越慢 | 缓存无上限 | 看内存是否单调增 | 加 LRU 上限 |
| 内存不回落 | 大对象堆碎片 | 打印大对象堆大小 | 复用缓冲区/内存池 |
| 颜色发灰 | ICC 被丢弃 | 换 sRGB 图对比 | 启用色彩转换 |
| 高分屏发虚 | 按逻辑像素解码 | 改缩放比例观察 | 乘上 DPI 缩放比 |
| 动图有残影 | 帧处置方式错误 | 逐帧截图对比 | 修正帧合成逻辑 |
| 透明图黑边 | 预乘 alpha 错误 | 换纯色背景观察 | 统一 alpha 处理方式 |
| SVG 文字错位 | 缺字体 | 换内嵌字体的文件 | 提供字体回退 |
6.6 几个我自己踩过的坑
第一个坑:以为Stream读完就不用管了,结果解码器内部还持有流的引用,文件句柄没释放,删文件的时候才报"文件被占用"。解决办法是解码完成后显式释放,或者直接把字节读进内存再用内存流。
第二个坑:缩略图统一按固定长宽缩放,结果宽高比被拉伸。正确做法是等比缩放后居中裁切或者留白,看你的产品需求。
第三个坑:EXIF 方向标记。很多竖拍的照片在像素层面是横的,靠 EXIF 里的方向标记告诉软件转 90 度。如果不处理,用户看到的就是躺着的图。方向标记有 8 种取值,其中 3、6、8 是最常见的三种旋转,2、4、5、7 涉及镜像翻转,实现时要全部覆盖。
7. 自己动手复现:搭一个最小可用的查看核心
7.1 项目骨架与职责划分
如果你想自己做一个类似的,我建议从最小闭环开始,别一上来就搞全格式支持。最小闭环的职责划分是三层:文件层负责枚举和读取,解码层负责把字节变成位图并支持目标尺寸,渲染层负责在视口里画出来并支持缩放平移。
// 解码层抽象:关键在于把"目标尺寸"作为一等参数 public interface IImageDecoder { // targetWidth/targetHeight 传 0 表示按原始尺寸解码 // applyOrientation 表示是否按 EXIF 方向标记自动旋转 DecodedImage Decode(Stream stream, int targetWidth, int targetHeight, bool applyOrientation); bool CanDecode(ReadOnlySpan<byte> header); // 靠文件头判断,别信扩展名 } public sealed class DecodedImage : IDisposable { public int Width { get; init; } public int Height { get; init; } public IntPtr Pixels { get; init; } // BGRA 排列 public int Stride { get; init; } public void Dispose() { /* 归还缓冲区或释放原生内存 */ } }注意CanDecode这个方法,它靠文件头魔数判断格式,而不是看扩展名。这是很多工具打开"改了扩展名的图"直接崩掉的根本原因——PNG 文件被命名成 .jpg,扩展名判断就翻车了。
7.2 视口尺寸到解码尺寸的换算
这一步是省内存的核心,我把公式写清楚:
// logicalW/logicalH:视口逻辑尺寸 // dpiScale:系统缩放比,100% 为 1.0,150% 为 1.5 // oversample:缩放余量,建议 1.5,太小缩放时立刻糊,太大浪费内存 static (int w, int h) FitToViewport( int srcW, int srcH, int logicalW, int logicalH, double dpiScale, double oversample = 1.5) { double needW = logicalW * dpiScale * oversample; double needH = logicalH * dpiScale * oversample; // 等比缩放,取较小的那个比例,保证整图能装进目标框 double scale = Math.Min(needW / srcW, needH / srcH); // 原图比需要的还小,就别放大了,按原尺寸解 if (scale >= 1.0) return (srcW, srcH); return (Math.Max(1, (int)(srcW * scale)), Math.Max(1, (int)(srcH * scale))); }代入一组实际数字:视口 1200×800 逻辑像素,DPI 缩放 150%,余量 1.5。需要的物理尺寸是 1200×1.5×1.5 = 2700 宽,800×1.5×1.5 = 1800 高。源图 6000×4000,两个方向的比例分别是 0.45 和 0.45,取 0.45,解出 2700×1800,486 万像素,BGRA 下是 19.4MB。相比全量 96MB,省了将近 77MB。
7.3 加上缓存和虚拟化
有了降采样解码,还得有缓存,否则每次滚动都重新解一遍照样卡。缓存的关键是键的设计,必须把尺寸算进去:
// 缓存键:文件标识 + 目标尺寸档位 + 修改时间 // 加上修改时间是为了文件被覆盖后能自动失效 readonly record struct ThumbKey(string FileId, int Bucket, long Mtime); // 简单的 LRU:超过上限就淘汰最久未访问的 // 注意上限要按"条目数"和"估算字节数"双限制,只限条目数会被大缩略图钻空子虚拟化这部分,思路和列表控件一样:只为可见区域内的条目创建视图,滚动时回收离屏的视图。缩略图墙如果一次创建 5000 个图像控件,光布局就能卡好几秒。
7.4 参数选择的经验值
我把调过的参数列一下,都是实测调出来的,不是拍脑袋:
| 参数 | 初始值 | 调整后 | 调整理由 |
|---|---|---|---|
| 缩放余量 oversample | 1.0 | 1.5 | 1.0 时缩放到 120% 立刻糊,1.5 兼顾清晰和内存 |
| 预加载张数 | 3 | 1 | 降到 1 之后 P95 翻页时间从 210ms 降到 96ms |
| 缩略图长边 | 128 | 256 | 128 在 200% 缩放的屏幕上明显发虚 |
| 缩略图缓存上限 | 无 | 500 条 / 128MB | 双限制,防大图钻空子 |
| 空闲收缩阈值 | 无 | 60 秒 | 挂机内存能回落约 40% |
| 大图分块阈值 | 无 | 4000 万像素 | 超过就走瓦片渲染 |
注意:预加载张数不是越多越好。预加载的本质是拿内存换延迟,但这个交换只在"用户大概率会去看下一张"时划算。快速翻页场景下预加载全是浪费,加了延迟判定之后收益很明显。
8. 一些实际操作中的体会
测完这一轮,我最深的感受是:看图工具的性能问题,九成不是"算得慢",而是"算了不该算的东西"。全量解码、无上限缓存、UI 线程干重活,这三件事占了大头。反过来,只要把解码尺寸和显示尺寸对齐、给缓存设个上限、把重活挪到后台,哪怕用很普通的技术栈也能做出一款体感流畅的看图工具。
另外提醒一句关于格式支持的心态:别追求"什么都能开"。格式越多,依赖越杂,出问题的面越大。比较务实的做法是把常用格式做扎实,冷门格式做成可选组件,装不上就明确告诉用户"这个格式需要额外支持",而不是静默失败或者给个空白窗口。我在测试中遇到过扩展名和内容不符的文件,一款工具如果只靠扩展名判断格式,用户就会觉得"这软件连图都打不开",实际上文件本身是好的,只是名字被人改过。靠文件头魔数判断,成本很低,收益很大。
如果你正准备动手做类似的东西,我的建议是先只支持 JPEG 和 PNG,把降采样解码、缓存、虚拟化这三件事做对,再往上叠格式。这三件事做对了,后面每加一种格式都是纯增量;做错了,每加一种格式都是在补窟窿。