## 1. 标准图片加载管线 Flutter 图片加载可以分为三个阶段:**获取编码数据、解码生成图像、提交绘制**。JPEG、PNG、WebP 的文件体积反映存储成本,解码后的像素尺寸决定另一部分内存与渲染成本。 | 层次 | 核心对象 | 职责 | | ---------- | -------------------------------------------------- | -------------------------------------- | | Widget | `Image` | 布局、订阅图片流、处理加载状态 | | 加载与缓存 | `ImageProvider`、`ImageCache` | 生成 key,复用任务与结果,未命中时加载 | | 解码与出帧 | `ImageDescriptor`、`Codec`、`ImageStreamCompleter` | 确定尺寸、解码、调度并分发帧 | | 绘制 | `ui.Image`、`RenderImage`、`paintImage` | 持有图像资源,按目标区域绘制 | ```graphviz digraph ImagePipeline { graph [rankdir=TB, bgcolor="white", nodesep=0.3, ranksep=0.35]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [color="#617080", fontname="sans-serif"]; widget [label="Image Widget\n布局、图片订阅与状态更新"]; provider [label="ImageProvider + ImageCache\n生成 key,复用加载任务与结果"]; bytes [label="编码数据\nNetwork / File / Asset / Memory"]; codec [label="ImageDescriptor → Codec\n识别格式、确定尺寸、解码出帧"]; image [label="ui.Image\n可绘制的图像资源"]; paint [label="RawImage → RenderImage → paintImage\n裁剪、采样与绘制"]; widget -> provider; provider -> bytes [label="需要重新加载"]; bytes -> codec -> image -> paint; provider -> image [label="复用已有加载结果", style=dashed]; } ``` ### 1.1 调用链与职责边界 ```text _ImageState._resolveImage → ImageProvider.resolve / obtainKey → resolveStreamForKey → ImageCache.putIfAbsent → 未命中:provider.loadImage(key, decode) → NetworkImage / FileImage 准备 ImmutableBuffer → PaintingBinding.instantiateImageCodecWithSize → ui.ImageDescriptor.encoded → descriptor.instantiateCodec(targetWidth, targetHeight) → MultiFrameImageStreamCompleter 调用 codec.getNextFrame() → ImageStream 发布 ImageInfo → RawImage → RenderImage.paint → paintImage ``` 关键分工是:**provider 获取数据,decode 回调创建 codec,completer 取得并分发帧**。因此,加载来源和解码尺寸可以分别扩展;ResizeImage 就利用了这个边界。[ImageProvider 源码][provider] `getNextFrame()` 返回前,引擎还可能执行颜色转换、缩放、纹理分配与上传。该调用的总耗时不能直接等同于某一压缩算法的解码耗时。 ### 1.2 NetworkImage:先接收完整数据,再解码 [NetworkImage._loadAsync][network]的主流程如下,省略请求头、响应校验、进度与错误处理: ```dart final request = await _httpClient.getUrl(resolved); final response = await request.close(); final bytes = await consolidateHttpClientResponseBytes(response); return await decode(await ui.ImmutableBuffer.fromUint8List(bytes)); ``` 下载前先查 ImageCache。未命中时通过共享 `HttpClient` 发起 GET,接收完整响应后才调用 decode。 ```plantuml @startuml hide footbox skinparam backgroundColor #FFFFFF skinparam sequenceMessageAlign center participant "Image Widget" as W participant "确定图片\nImageProvider" as P participant "内存缓存\nImageCache" as C participant "网络下载\nNetworkImage" as N participant "解码器与引擎" as E W -> P : resolve(configuration) P -> P : obtainKey() P -> C : putIfAbsent(key, loader) alt 已在加载,或已有可复用的结果 C --> P : 复用 ImageStreamCompleter else 没有可复用的结果 C -> N : 启动加载 N -> N : 发送请求,检查响应 loop 接收响应数据 N --> W : 通知下载进度 end N -> N : 收齐图片文件 N -> E : 识别图片,确定解码尺寸 E -> E : getNextFrame() E --> W : 通过 completer 发布 ImageInfo end W -> W : 更新显示 @enduml ``` 因此,`loadingBuilder` 可以展示下载进度,但默认路径不会逐个网络 chunk 解码并更新图片。即使源文件是 progressive JPEG,也不会自动获得边下载边清晰的效果。 动画图片由 completer 按 codec 的帧时长调度。**下载进度、渐进式解码、动画播放和视口分片是不同机制。** ### 1.3 FileImage:不同的数据入口,相同的解码阶段 普通 `FileImage` 使用 `ImmutableBuffer.fromFilePath`。3.47.2 的引擎实现先映射文件,再将完整编码数据复制到 `SkData`:避免先在 Dart 侧读取整份 `Uint8List`,但仍有整份编码数据的成本。[ImmutableBuffer 源码][immutable-buffer] 随后进入相同的 descriptor、codec 与出帧流程。以文件作为测试输入可以隔离网络因素,独立测量解码、图像创建和交互成本。 ## 2. ImageCache 与图片复用 ### 2.1 缓存对象与生命周期 `ImageCache` 以 provider key 为索引,跟踪 **`ImageStreamCompleter`**。复用可以发生在首帧解码完成之前,因此它同时承担加载任务去重与结果保留。 | 状态 | 对应结构 | 作用 | | --------- | ---------------- | -------------------------------------- | | pending | `_pendingImages` | 首个结果未完成,后续请求共享 completer | | keepAlive | `_cache` | LRU 保留,受条目数与字节预算约束 | | live | `_liveImages` | 自跟踪起,尚未触发最后一个监听者移除 | `putIfAbsent` 按 **pending → keepAlive → live** 查找,全部未命中才调用 loader。状态可以重叠,例如图片同时处于 live 和 keepAlive。[ImageCache 源码][cache] 默认 keepAlive 预算为 **1000 项 / 100 MiB**。超过预算的单图不进入 LRU,但仍可能被使用者持有;`currentSizeBytes` 也只反映缓存记账值,不能代表进程 RSS 或 GPU 内存。清缓存不保证所有图像资源立即释放。 默认 `NetworkImage` 没有实现持久化文件缓存或 HTTP 过期、ETag、304 管理。**磁盘缓存减少下载,ImageCache 减少重复加载与解码,两者职责不同。** ### 2.2 key 决定复用范围 3.47.2 的 `NetworkImage` 相等条件包含 URL、scale 和 headers 内容;省略类型检查后为: ```dart other.url == url && other.scale == scale && mapEquals(other.headers, headers) ``` | 场景 | 缓存行为 | | ------------------------------------------- | ------------------------------------- | | provider 实例不同,URL、scale、headers 相同 | 可以复用 | | 只改变 Widget 的宽高或`fit` | 不因此改变 provider key | | 相同 URL,headers 不同 | 不同 key | | 分别设置`cacheWidth: 360` 和 `720` | 不同 ResizeImageKey | | 同一路径文件被覆盖 | FileImage 的 key 不会自动感知内容变化 | `ResizeImageKey` 包含底层 key、policy、width、height、allowUpscaling。不同尺寸版本分别缓存,也可能分别下载:默认流程没有额外的 URL 级编码数据缓存。业务上应使用稳定的尺寸档位,避免生成大量相近版本。 加载失败方面,默认 NetworkImage 已安排 `evict`,ResizeImage 也配置错误清理;自定义 provider 需处理自己的失败路径。 ### 2.3 图像资源共享 `ImageStreamCompleter` 通过 `ImageInfo.clone()` 分发结果,进一步调用 `ui.Image.clone()`。这里复制的是**可独立 dispose 的句柄**,底层图像资源共享。[ImageInfo][stream]、[ui.Image.clone][ui-image]因此,多个相同头像不必各持有一份像素。Widget 重建也不直接意味着重新下载或解码,关键仍是 key 和缓存生命周期。这种共享不等于可改写像素的通用缓冲池。 ## 3. ResizeImage:控制解码尺寸 Widget 的 `width`、`height` 控制布局,`cacheWidth`、`cacheHeight` 通过 ResizeImage 控制解码目标。两者是独立输入。 ```dart final dpr = MediaQuery.devicePixelRatioOf(context); Image.network( imageUrl, width: 120, height: 90, cacheWidth: (120 * dpr).ceil(), fit: BoxFit.cover, ) ``` 对于 4000 × 3000 原图,DPR 为 3 时请求宽度 360,高度按比例得到 270。按 RGBA8888 的每像素 4 字节估算,输出像素内存从 **45.8 MiB 降至 0.37 MiB**。实际目标需结合宽高比与 `BoxFit.cover`,避免裁剪后像素不足。 ```graphviz digraph ResizePipeline { graph [rankdir=TB, bgcolor="white", ranksep=0.3]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [fontname="sans-serif", color="#617080"]; encoded [label="同一张原始图片文件"]; original [label="原始尺寸\n4000 × 3000"]; target [label="ResizeImage 指定目标\n360 × 270"]; full [label="大尺寸 ui.Image\n像素约 45.8 MiB"]; small [label="小尺寸 ui.Image\n像素约 0.37 MiB"]; display [label="同样显示在 120 × 90 逻辑区域"]; encoded -> original -> full -> display; encoded -> target -> small -> display; } ``` ### 3.1 原理:包装 decode 回调 `Image.network` 通过 `ResizeImage.resizeIfNeeded` 包装 provider;[ResizeImage.loadImage][resize] 再包装 decode 回调。以下为结构示意,省略尺寸策略与错误处理: ```dart ImageStreamCompleter loadImage(ResizeImageKey key, ImageDecoderCallback decode) { Future decodeResize( ui.ImmutableBuffer buffer, { ui.TargetImageSizeCallback? getTargetSize, }) { return decode( buffer, getTargetSize: (intrinsicWidth, intrinsicHeight) { // 实际源码按 policy、原始尺寸及 allowUpscaling 计算。 return ui.TargetImageSize(width: width, height: height); }, ); } return imageProvider.loadImage(key._providerCacheKey, decodeResize); } ``` 底层 provider 仍负责取得完整编码数据,包装回调只改变 codec 的目标尺寸: - 默认 `exact`:两边都指定时可能改变宽高比;只指定一边时保持比例。 - `fit`:按比例装入指定范围。 - `allowUpscaling`:默认 false。 ### 3.2 收益与边界 Impeller 根据目标尺寸查询解码器支持的缩放尺寸,解码后必要时追加 CPU 或 GPU 缩放。JPEG 的 DCT 缩放能减少部分计算和输出,但顺序路径仍需处理相关熵编码数据。[Impeller 解码源码][impeller] 因此,较小输出不保证中间缓冲同样小,也不保证耗时按像素量同比下降。Flutter 会查询解码器支持的尺寸,并未把所有请求固定为 1/2、1/4、1/8。 **ResizeImage 表达的是整图输出尺寸,没有区域矩形参数,也不会随手势缩放自动补充原始细节。** 它适合固定尺寸展示;超大图浏览需要把视口引入加载决策。 ## 4. 超大图的结构性约束 基准图为 **24000 × 24000,共 5.76 亿像素**,JPEG 文件约 299.1 MiB。整幅 RGBA8888 像素需要: $$ 24000 \times 24000 \times 4 = 2{,}304{,}000{,}000\ \text{bytes} \approx 2.15\ \text{GiB} $$ | 约束 | 影响 | | ------------------ | --------------------------------------------------------------- | | 整幅像素与临时缓冲 | 文件数据、解码工作区、缩放结果和纹理共同形成内存峰值 | | GPU 最大纹理尺寸 | 超大尺寸无法直接按原始规格创建单张纹理 | | 整图降采样 | 24000 缩到 4096,宽度分辨率降低约 5.86 倍;再次放大无法恢复细节 | | 加载与视口分离 | 绘制时只显示局部,不会反向减少已经决定的整图解码范围 | 需要区分“加载失败”与“无法按原始清晰度浏览”。3.47.2 的 Impeller 会钳制目标尺寸,源图过大时还有 CPU resize 分支。因此,实际可能分配失败,也可能返回缩小后的图像,不能用固定边长概括所有平台行为。 `paintImage` / `drawImageRect` 支持源矩形裁剪,但那是**绘制范围**,不是区域解码请求。问题在于标准路径仍以整幅输出作为解码和缓存单位。[paintImage 源码][paint] 优化目标由此确定:**以视口需要的分辨率加载局部,避免长期持有整幅原分辨率像素。** ## 5. fast_image 分片架构 fast_image 采用 Dart 调度与 Rust 解码协作的方式,通过 flutter_rust_bridge 连接。分片浏览需要以下几层能力: | 模块 | 职责 | | -------------------------- | ----------------------------------------------------- | | 概览缓存 | 生成、持久化并复用 2048 宽的概览图 | | `FastTilePlanner` | 根据可见矩形与物理缩放选择瓦片 | | 瓦片调度器 | 请求去重、批次、图像创建、缓存淘汰与重绘 | | `FastRegionDecoder` / Rust | 格式分发、区域解码、中间缓存与方向处理 | | `FastImage` provider | 选择派生图、限制整图尺寸,接入标准 ImageProvider 体系 | `FastImage` provider 负责标准图片加载入口;完整的分片浏览还需要瓦片规划、调度、区域解码与绘制层配合。 ```graphviz digraph TilePipeline { graph [rankdir=TB, bgcolor="white", ranksep=0.35]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [fontname="sans-serif", color="#617080"]; view [label="视口变化\nvisibleRect + physicalScale"]; enough [shape=diamond, label="概览是否足够清晰?", fillcolor="#FFF4DA"]; overview [label="显示概览\n保留已有瓦片以便复用"]; plan [label="FastTilePlanner\n选档、中心优先、缓存去重"]; decode [label="FastRegionDecoder\n同档批量区域解码"]; upload [label="分批创建 ui.Image\n缓存、淘汰与增量重绘"]; draw [label="清晰瓦片覆盖在概览上\n尚未加载的地方仍能看到全貌"]; view -> enough; enough -> overview [label="是"]; enough -> plan [label="否"]; plan -> decode [label="仅请求缺失块"]; decode -> upload -> draw; plan -> draw [label="复用已缓存块", style=dashed]; overview -> draw; } ``` ### 5.1 概览与分辨率选档 概览满足当前显示分辨率时,不请求瓦片;放大后按需覆盖清晰瓦片,未完成区域继续显示概览。首次生成概览有成本,已有派生文件时才走复用路径。 以下采用 512 像素的瓦片目标边长,sample 表示源图缩小倍数: | sample | 典型源区域 | 典型输出 | | ------ | ------------ | ---------- | | 8 | 4096 × 4096 | 512 × 512 | | 4 | 2048 × 2048 | 512 × 512 | | 2 | 1024 × 1024 | 512 × 512 | | 1 | 512 × 512 | 512 × 512 | `FastTilePlanner` 根据 `physicalScale = 逻辑缩放 × DPR` 选档: ```dart int _chooseSample(double physicalScale) { final scale = math.max(physicalScale, 1e-6); var sample = 1; while (sample < _maxSample && scale * sample * 2 <= 1.15) { sample *= 2; } return sample; } ``` `physicalScale = 0.25` 时,sample 4 的输出接近屏幕所需;放大至 1 时使用 sample 1。1.15 是选档容差,最大 sample 为 8。可见块中心优先,边缘按源图边界裁切。 ### 5.2 批量区域解码与缓存 相邻瓦片分别从头解码会重复处理前置数据。Rust 侧优先合并同档请求,缓存中间区域后再切出瓦片。核心逻辑如下: ```rust if let Some(union) = wants.iter().copied().reduce(union_rect) { self.ensure_band(union, denom, scaled_w, scaled_h)?; } ``` 并集过大时尝试带状缓存,再按需逐块处理。Dart 侧按 `sample / row / col` 复用瓦片,每张源图维护独立的调度与缓存状态。 | 策略 | 一组实现参数 | | ------------- | ---------------------------------------- | | 可见瓦片 | 尝试降档控制规模,最多选取 96 块 | | 预取 | 周围半屏;超过 5000 万像素的图不执行预取 | | 解码批次 | 同档最多 48 块 | | 图像创建 | 每 12 块让出一次事件循环 | | Rust 区域缓存 | 96 MiB 预算,单次候选区域门槛 48 MiB | | Dart 瓦片缓存 | 64 MiB 淘汰目标,保留当前需要的块 | 手势期间采用约 100 ms 节流、70 ms 尾部补齐,避免纯防抖导致连续缩放时不加载。异步结果返回后检查是否仍需要,过期结果可释放;这不等于取消已经执行的底层解码。 ### 5.3 内存模型 单块 512² RGBA8888 约 1 MiB。分片把主要显示像素限制在视口附近,但 **64 MiB 是缓存淘汰目标,不是进程硬上限**:当前所需块与新批次可能超额,概览、区域缓存、索引、返回中的像素和临时缓冲也占内存。 `mmap` 避免主动复制整份源文件,但访问到的文件页仍占物理内存;`DontNeed` 仅提供回收建议。首次扫描、概览生成与解码峰值都需要单独计量。 ## 6. 图片格式与区域解码可行性 评估分片收益,需要区分三个层次: 1. **分片绘制**:将已有像素分块显示。 2. **区域输出**:只返回目标矩形,减少输出缓冲。 3. **随机访问压缩数据**:从可独立恢复的位置开始,跳过无关数据。 crop API 只能证明支持区域输出。能否同时降低 CPU 与 I/O 成本,取决于压缩依赖、索引结构及解码器实现。 ### 6.1 JPEG:区域裁剪与 restart 索引 JPEG 使用块状变换,但熵编码存在前后依赖,例如 DC 系数差分预测。普通顺序 JPEG 即使裁剪输出,仍可能处理目标区域之前的数据。 restart marker 为解码状态提供重新开始的位置。fast_image 的 `RestartIndex` 扫描标记,校验重启间隔与 MCU 行的对应关系,建立行段到字节偏移的索引。MCU 是 JPEG 的最小编码单元。 满足条件后,可以提取所需行段,保留必要头信息、改写高度、重新编号标记,构造独立的小 JPEG,再按工作量并行解码,最多六个线程。 ```graphviz digraph RestartJPEG { graph [rankdir=TB, bgcolor="white", ranksep=0.35]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [fontname="sans-serif", color="#617080"]; file [label="单个 JPEG 文件\n图片信息 + 带重启标记的压缩数据"]; index [label="RestartIndex\n建立 MCU 行范围到字节偏移的索引"]; wanted [label="定位视口覆盖的行段"]; a [label="构造行段 A 的小 JPEG"]; b [label="构造行段 B 的小 JPEG"]; da [label="任务 A:缩放与裁剪解码"]; db [label="任务 B:缩放与裁剪解码"]; result [label="合并解码结果\n切出瓦片并缓存"]; file -> index -> wanted; wanted -> a -> da -> result; wanted -> b -> db -> result; } ``` JPEG 解码入口区分索引路径与普通区域路径: ```rust let region = match self.index() { Some(index) => index.decode( &self.bytes, turbo::numerator(denom)?, rect, true, restart::threads(), ), None => turbo::decode_region(&self.bytes, denom, rect), }?; ``` `regionDecodable` 表示可区域输出,`randomAccess` 表示索引可用。后者还有三个条件:首次需扫描文件;标记布局必须符合实现要求;访问粒度主要是行段,不是任意二维瓦片。 **在保持单个 JPEG 文件的前提下,符合条件且带可用 restart 标记的顺序 JPEG,更适合这种基于行段索引的局部浏览方案。** ### 6.2 Progressive JPEG、PNG 与 WebP **Progressive JPEG** 通过多个 scan 逐步补全整图信息,不直接对应当前的独立行段路径。fast_image 不直接对其区域解码,但提供系数域转顺序 JPEG 的入口。“无损”指避免重新量化;转换需要较大系数缓冲并限制像素数,24000² 图片超过转换范围。 **PNG** 的 IDAT 共同组成压缩流,行滤波也可能依赖前置行,IDAT 边界不等于空间瓦片边界。fast_image 的 PNG 解码路径 从头 `next_row()` 至目标底部,只收集目标区域,降采样时对像素取平均。主要收益是减小结果缓冲,仍有顺序处理成本。 **WebP** 使用 libwebp 的 crop / scale 与外部输出缓冲,处理有损裁剪起点的偶数对齐。但裁剪不保证内部只处理目标区域,也不保证临时内存与输出等大;fast_image 的 WebP 解码路径未实现类似 JPEG restart 的随机访问索引。 | 格式或操作 | fast_image 的准入条件 | 主要限制 | | ----------------------- | --------------------------------------------------------------------------- | ---------------------------------- | | JPEG 区域解码 | 8 位、非 progressive / lossless / arithmetic / CMYK;底层像素保护值为 10 亿 | 无可用索引时仍可能处理大量前置数据 | | 渐进式 JPEG 转顺序 JPEG | ≤ 64 × 1024² 像素,满足编码条件 | 系数缓冲与一次性转换成本 | | PNG 区域解码 | 非交错,≤ 1 亿像素 | 从头按行处理 | | 有损 WebP 区域解码 | 非动画,≤ 64 × 1024² 像素 | crop 不等于随机访问 | | 无损 WebP 区域解码 | 非动画,≤ 16 × 1024² 像素 | 内部工作区成本需单独评估 | 这些是该实现的保护策略,不是格式本身的通用上限;静态瓦片查看器也未实现动画时间轴。 ### 6.3 独立瓦片与多分辨率资源 若可控制资源生产,可预先生成多分辨率金字塔,每层保存独立瓦片。缩小时读低分辨率层,放大后只获取高分辨率层的局部:下载与解码都能按块进行。 独立瓦片内部可选 JPEG、PNG 或 WebP。**先用资源组织解决独立访问,再按照片、透明度、无损等需求选择单块编码。** Tiled TIFF 也提供按位置读取块的基础,但不是所有 TIFF 都采用瓦片或金字塔组织。 上述 fast_image 实现以文件为输入,区域后端覆盖 JPEG、PNG 和 WebP。TIFF、AVIF、HEIF 需要额外的区域解码支持;多分辨率网络瓦片与远程单文件的 HTTP Range 加载也需要独立设计字节索引、请求管理和网络缓存。 资料:[TurboJPEG][jpeg-docs]、[PNG 压缩结构][png-spec]、[WebP API][webp-docs]、[LibTIFF TIFFReadTile][tiff-docs]。 ## 7. 性能分析 ### 7.1 测试环境与加载结果 以下为 **2026-09-08** 的一次实测记录。环境:macOS 26.5.2、14 核、DPR 2、profile 模式、Flutter 3.47.2 / Dart 3.13.2。 源图 24000²,约 299.1 MiB,非 progressive JPEG,`randomAccess = true`。流程预热源文件页缓存,不代表冷磁盘或网络场景。 | 标准侧输出 | 取得 ui.Image | 峰值 RSS 增量 | RGBA8888 估算 | | ---------- | ------------- | ------------- | ------------- | | 2048² | 1776.7 ms | 299.5 MiB | 16 MiB | | 4096² | 1826.4 ms | 299.2 MiB | 64 MiB | | 8192² | 2308.2 ms | 565.1 MiB | 256 MiB | 输出像素明显减少,但耗时未同比下降,仍需处理同一份大编码文件。标准侧绕过 Widget、ImageProvider、ImageCache,测量文件到 codec 的路径;不能据此推断真实页面每次都要重新解码。已有数据不含原尺寸运行,8192 只是最高测试档。 | fast_image 阶段 | 记录 | | ------------------------------- | --------------------------- | | 首次生成并加载概览 | 372.2 ms | | 已有概览读盘加载 | 44.9 ms | | probe / open / restart 索引探测 | 51.8 / 25.9 / 16.5 ms | | 1:1 视口解码并创建图像 | 62.2 ms,35 块,35 MiB 像素 | | 显示阶段峰值 RSS 增量 | 359.7 MiB | | 概览准备阶段峰值 RSS 增量 | 496.1 MiB | 将图片探测、解码器打开和已有概览加载相加,准备时间约为 122.6 ms;需要首次生成概览时,合计约 449.9 ms。二者均未包含单独记录的索引探测,也不是点击到屏幕呈现的直接测量。 ### 7.2 交互与结果解释 两侧分别独占视口执行约 6 秒的相同运镜。标准侧使用 8192 档,分片侧按需补充原始细节,因此比较的是两种浏览方案,输出清晰度并不相同。 | FrameTiming.totalSpan | 标准侧 | fast_image | | --------------------- | -------- | ---------- | | 平均 | 2.96 ms | 1.61 ms | | p95 | 4.86 ms | 2.24 ms | | p99 | 6.33 ms | 2.84 ms | | 最差样本 | 32.40 ms | 4.62 ms | 分片侧重规划 **60 次**,创建 **258 块瓦片,累计约 226 MiB 像素**。该工作量证明交互阶段包含细节加载;累计处理量不等于常驻内存。 测量边界: - `FrameTiming.totalSpan` 不是显示器呈现延迟;帧数除以采样时长得到的帧率估计受回调批次与窗口边界影响,不能当成精确的屏幕呈现帧率。 - 此次统计的 jank 阈值为 16.7 ms,不对应 120 Hz 的 8.33 ms 预算。 - RSS 为进程驻留内存,采样可能漏过瞬时峰值;RGBA 估算也不等于精确 GPU 占用。 - 单次汇总只能支持本样本的帧时与工作量结论。长帧归因需要运行时间线,不能推定“大纹理每帧被完整采样”。 ## 8. 工程结论 - **常规图片**:稳定 provider key,按显示需求控制解码尺寸,分开设计内存缓存与磁盘缓存。 - **超大图浏览**:概览提供全貌,按视口选档、区域解码、缓存和调度,控制常驻像素规模。 - **格式选择**:区域输出不等于高效随机访问;单文件优先评估编码依赖与索引条件,可预处理时考虑独立瓦片和多分辨率组织。 [flutter-image]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/widgets/image.dart#L471 [provider]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_provider.dart#L376 [network]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/_network_image_io.dart#L110 [ui-codec]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting.dart#L2572 [immutable-buffer]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting/immutable_buffer.cc [cache]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_cache.dart#L320 [resize]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_provider.dart#L1353 [stream]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_stream.dart#L63 [ui-image]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting.dart#L2180 [impeller]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting/image_decoder_impeller.cc#L340 [paint]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/decoration_image.dart#L718 [jpeg-docs]: https://libjpeg-turbo.org/Documentation/Documentation [png-spec]: https://www.w3.org/TR/png-3/#10Compression [webp-docs]: https://developers.google.com/speed/webp/docs/api [tiff-docs]: https://libtiff.gitlab.io/libtiff/functions/TIFFReadTile.html Loading... ## 1. 标准图片加载管线 Flutter 图片加载可以分为三个阶段:**获取编码数据、解码生成图像、提交绘制**。JPEG、PNG、WebP 的文件体积反映存储成本,解码后的像素尺寸决定另一部分内存与渲染成本。 | 层次 | 核心对象 | 职责 | | ---------- | -------------------------------------------------- | -------------------------------------- | | Widget | `Image` | 布局、订阅图片流、处理加载状态 | | 加载与缓存 | `ImageProvider`、`ImageCache` | 生成 key,复用任务与结果,未命中时加载 | | 解码与出帧 | `ImageDescriptor`、`Codec`、`ImageStreamCompleter` | 确定尺寸、解码、调度并分发帧 | | 绘制 | `ui.Image`、`RenderImage`、`paintImage` | 持有图像资源,按目标区域绘制 | ```graphviz digraph ImagePipeline { graph [rankdir=TB, bgcolor="white", nodesep=0.3, ranksep=0.35]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [color="#617080", fontname="sans-serif"]; widget [label="Image Widget\n布局、图片订阅与状态更新"]; provider [label="ImageProvider + ImageCache\n生成 key,复用加载任务与结果"]; bytes [label="编码数据\nNetwork / File / Asset / Memory"]; codec [label="ImageDescriptor → Codec\n识别格式、确定尺寸、解码出帧"]; image [label="ui.Image\n可绘制的图像资源"]; paint [label="RawImage → RenderImage → paintImage\n裁剪、采样与绘制"]; widget -> provider; provider -> bytes [label="需要重新加载"]; bytes -> codec -> image -> paint; provider -> image [label="复用已有加载结果", style=dashed]; } ``` ### 1.1 调用链与职责边界 ```text _ImageState._resolveImage → ImageProvider.resolve / obtainKey → resolveStreamForKey → ImageCache.putIfAbsent → 未命中:provider.loadImage(key, decode) → NetworkImage / FileImage 准备 ImmutableBuffer → PaintingBinding.instantiateImageCodecWithSize → ui.ImageDescriptor.encoded → descriptor.instantiateCodec(targetWidth, targetHeight) → MultiFrameImageStreamCompleter 调用 codec.getNextFrame() → ImageStream 发布 ImageInfo → RawImage → RenderImage.paint → paintImage ``` 关键分工是:**provider 获取数据,decode 回调创建 codec,completer 取得并分发帧**。因此,加载来源和解码尺寸可以分别扩展;ResizeImage 就利用了这个边界。[ImageProvider 源码][provider] `getNextFrame()` 返回前,引擎还可能执行颜色转换、缩放、纹理分配与上传。该调用的总耗时不能直接等同于某一压缩算法的解码耗时。 ### 1.2 NetworkImage:先接收完整数据,再解码 [NetworkImage._loadAsync][network]的主流程如下,省略请求头、响应校验、进度与错误处理: ```dart final request = await _httpClient.getUrl(resolved); final response = await request.close(); final bytes = await consolidateHttpClientResponseBytes(response); return await decode(await ui.ImmutableBuffer.fromUint8List(bytes)); ``` 下载前先查 ImageCache。未命中时通过共享 `HttpClient` 发起 GET,接收完整响应后才调用 decode。 ```plantuml @startuml hide footbox skinparam backgroundColor #FFFFFF skinparam sequenceMessageAlign center participant "Image Widget" as W participant "确定图片\nImageProvider" as P participant "内存缓存\nImageCache" as C participant "网络下载\nNetworkImage" as N participant "解码器与引擎" as E W -> P : resolve(configuration) P -> P : obtainKey() P -> C : putIfAbsent(key, loader) alt 已在加载,或已有可复用的结果 C --> P : 复用 ImageStreamCompleter else 没有可复用的结果 C -> N : 启动加载 N -> N : 发送请求,检查响应 loop 接收响应数据 N --> W : 通知下载进度 end N -> N : 收齐图片文件 N -> E : 识别图片,确定解码尺寸 E -> E : getNextFrame() E --> W : 通过 completer 发布 ImageInfo end W -> W : 更新显示 @enduml ``` 因此,`loadingBuilder` 可以展示下载进度,但默认路径不会逐个网络 chunk 解码并更新图片。即使源文件是 progressive JPEG,也不会自动获得边下载边清晰的效果。 动画图片由 completer 按 codec 的帧时长调度。**下载进度、渐进式解码、动画播放和视口分片是不同机制。** ### 1.3 FileImage:不同的数据入口,相同的解码阶段 普通 `FileImage` 使用 `ImmutableBuffer.fromFilePath`。3.47.2 的引擎实现先映射文件,再将完整编码数据复制到 `SkData`:避免先在 Dart 侧读取整份 `Uint8List`,但仍有整份编码数据的成本。[ImmutableBuffer 源码][immutable-buffer] 随后进入相同的 descriptor、codec 与出帧流程。以文件作为测试输入可以隔离网络因素,独立测量解码、图像创建和交互成本。 ## 2. ImageCache 与图片复用 ### 2.1 缓存对象与生命周期 `ImageCache` 以 provider key 为索引,跟踪 **`ImageStreamCompleter`**。复用可以发生在首帧解码完成之前,因此它同时承担加载任务去重与结果保留。 | 状态 | 对应结构 | 作用 | | --------- | ---------------- | -------------------------------------- | | pending | `_pendingImages` | 首个结果未完成,后续请求共享 completer | | keepAlive | `_cache` | LRU 保留,受条目数与字节预算约束 | | live | `_liveImages` | 自跟踪起,尚未触发最后一个监听者移除 | `putIfAbsent` 按 **pending → keepAlive → live** 查找,全部未命中才调用 loader。状态可以重叠,例如图片同时处于 live 和 keepAlive。[ImageCache 源码][cache] 默认 keepAlive 预算为 **1000 项 / 100 MiB**。超过预算的单图不进入 LRU,但仍可能被使用者持有;`currentSizeBytes` 也只反映缓存记账值,不能代表进程 RSS 或 GPU 内存。清缓存不保证所有图像资源立即释放。 默认 `NetworkImage` 没有实现持久化文件缓存或 HTTP 过期、ETag、304 管理。**磁盘缓存减少下载,ImageCache 减少重复加载与解码,两者职责不同。** ### 2.2 key 决定复用范围 3.47.2 的 `NetworkImage` 相等条件包含 URL、scale 和 headers 内容;省略类型检查后为: ```dart other.url == url && other.scale == scale && mapEquals(other.headers, headers) ``` | 场景 | 缓存行为 | | ------------------------------------------- | ------------------------------------- | | provider 实例不同,URL、scale、headers 相同 | 可以复用 | | 只改变 Widget 的宽高或`fit` | 不因此改变 provider key | | 相同 URL,headers 不同 | 不同 key | | 分别设置`cacheWidth: 360` 和 `720` | 不同 ResizeImageKey | | 同一路径文件被覆盖 | FileImage 的 key 不会自动感知内容变化 | `ResizeImageKey` 包含底层 key、policy、width、height、allowUpscaling。不同尺寸版本分别缓存,也可能分别下载:默认流程没有额外的 URL 级编码数据缓存。业务上应使用稳定的尺寸档位,避免生成大量相近版本。 加载失败方面,默认 NetworkImage 已安排 `evict`,ResizeImage 也配置错误清理;自定义 provider 需处理自己的失败路径。 ### 2.3 图像资源共享 `ImageStreamCompleter` 通过 `ImageInfo.clone()` 分发结果,进一步调用 `ui.Image.clone()`。这里复制的是**可独立 dispose 的句柄**,底层图像资源共享。[ImageInfo][stream]、[ui.Image.clone][ui-image]因此,多个相同头像不必各持有一份像素。Widget 重建也不直接意味着重新下载或解码,关键仍是 key 和缓存生命周期。这种共享不等于可改写像素的通用缓冲池。 ## 3. ResizeImage:控制解码尺寸 Widget 的 `width`、`height` 控制布局,`cacheWidth`、`cacheHeight` 通过 ResizeImage 控制解码目标。两者是独立输入。 ```dart final dpr = MediaQuery.devicePixelRatioOf(context); Image.network( imageUrl, width: 120, height: 90, cacheWidth: (120 * dpr).ceil(), fit: BoxFit.cover, ) ``` 对于 4000 × 3000 原图,DPR 为 3 时请求宽度 360,高度按比例得到 270。按 RGBA8888 的每像素 4 字节估算,输出像素内存从 **45.8 MiB 降至 0.37 MiB**。实际目标需结合宽高比与 `BoxFit.cover`,避免裁剪后像素不足。 ```graphviz digraph ResizePipeline { graph [rankdir=TB, bgcolor="white", ranksep=0.3]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [fontname="sans-serif", color="#617080"]; encoded [label="同一张原始图片文件"]; original [label="原始尺寸\n4000 × 3000"]; target [label="ResizeImage 指定目标\n360 × 270"]; full [label="大尺寸 ui.Image\n像素约 45.8 MiB"]; small [label="小尺寸 ui.Image\n像素约 0.37 MiB"]; display [label="同样显示在 120 × 90 逻辑区域"]; encoded -> original -> full -> display; encoded -> target -> small -> display; } ``` ### 3.1 原理:包装 decode 回调 `Image.network` 通过 `ResizeImage.resizeIfNeeded` 包装 provider;[ResizeImage.loadImage][resize] 再包装 decode 回调。以下为结构示意,省略尺寸策略与错误处理: ```dart ImageStreamCompleter loadImage(ResizeImageKey key, ImageDecoderCallback decode) { Future<ui.Codec> decodeResize( ui.ImmutableBuffer buffer, { ui.TargetImageSizeCallback? getTargetSize, }) { return decode( buffer, getTargetSize: (intrinsicWidth, intrinsicHeight) { // 实际源码按 policy、原始尺寸及 allowUpscaling 计算。 return ui.TargetImageSize(width: width, height: height); }, ); } return imageProvider.loadImage(key._providerCacheKey, decodeResize); } ``` 底层 provider 仍负责取得完整编码数据,包装回调只改变 codec 的目标尺寸: - 默认 `exact`:两边都指定时可能改变宽高比;只指定一边时保持比例。 - `fit`:按比例装入指定范围。 - `allowUpscaling`:默认 false。 ### 3.2 收益与边界 Impeller 根据目标尺寸查询解码器支持的缩放尺寸,解码后必要时追加 CPU 或 GPU 缩放。JPEG 的 DCT 缩放能减少部分计算和输出,但顺序路径仍需处理相关熵编码数据。[Impeller 解码源码][impeller] 因此,较小输出不保证中间缓冲同样小,也不保证耗时按像素量同比下降。Flutter 会查询解码器支持的尺寸,并未把所有请求固定为 1/2、1/4、1/8。 **ResizeImage 表达的是整图输出尺寸,没有区域矩形参数,也不会随手势缩放自动补充原始细节。** 它适合固定尺寸展示;超大图浏览需要把视口引入加载决策。 ## 4. 超大图的结构性约束 基准图为 **24000 × 24000,共 5.76 亿像素**,JPEG 文件约 299.1 MiB。整幅 RGBA8888 像素需要: $$ 24000 \times 24000 \times 4 = 2{,}304{,}000{,}000\ \text{bytes} \approx 2.15\ \text{GiB} $$ | 约束 | 影响 | | ------------------ | --------------------------------------------------------------- | | 整幅像素与临时缓冲 | 文件数据、解码工作区、缩放结果和纹理共同形成内存峰值 | | GPU 最大纹理尺寸 | 超大尺寸无法直接按原始规格创建单张纹理 | | 整图降采样 | 24000 缩到 4096,宽度分辨率降低约 5.86 倍;再次放大无法恢复细节 | | 加载与视口分离 | 绘制时只显示局部,不会反向减少已经决定的整图解码范围 | 需要区分“加载失败”与“无法按原始清晰度浏览”。3.47.2 的 Impeller 会钳制目标尺寸,源图过大时还有 CPU resize 分支。因此,实际可能分配失败,也可能返回缩小后的图像,不能用固定边长概括所有平台行为。 `paintImage` / `drawImageRect` 支持源矩形裁剪,但那是**绘制范围**,不是区域解码请求。问题在于标准路径仍以整幅输出作为解码和缓存单位。[paintImage 源码][paint] 优化目标由此确定:**以视口需要的分辨率加载局部,避免长期持有整幅原分辨率像素。** ## 5. fast_image 分片架构 fast_image 采用 Dart 调度与 Rust 解码协作的方式,通过 flutter_rust_bridge 连接。分片浏览需要以下几层能力: | 模块 | 职责 | | -------------------------- | ----------------------------------------------------- | | 概览缓存 | 生成、持久化并复用 2048 宽的概览图 | | `FastTilePlanner` | 根据可见矩形与物理缩放选择瓦片 | | 瓦片调度器 | 请求去重、批次、图像创建、缓存淘汰与重绘 | | `FastRegionDecoder` / Rust | 格式分发、区域解码、中间缓存与方向处理 | | `FastImage` provider | 选择派生图、限制整图尺寸,接入标准 ImageProvider 体系 | `FastImage` provider 负责标准图片加载入口;完整的分片浏览还需要瓦片规划、调度、区域解码与绘制层配合。 ```graphviz digraph TilePipeline { graph [rankdir=TB, bgcolor="white", ranksep=0.35]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [fontname="sans-serif", color="#617080"]; view [label="视口变化\nvisibleRect + physicalScale"]; enough [shape=diamond, label="概览是否足够清晰?", fillcolor="#FFF4DA"]; overview [label="显示概览\n保留已有瓦片以便复用"]; plan [label="FastTilePlanner\n选档、中心优先、缓存去重"]; decode [label="FastRegionDecoder\n同档批量区域解码"]; upload [label="分批创建 ui.Image\n缓存、淘汰与增量重绘"]; draw [label="清晰瓦片覆盖在概览上\n尚未加载的地方仍能看到全貌"]; view -> enough; enough -> overview [label="是"]; enough -> plan [label="否"]; plan -> decode [label="仅请求缺失块"]; decode -> upload -> draw; plan -> draw [label="复用已缓存块", style=dashed]; overview -> draw; } ``` ### 5.1 概览与分辨率选档 概览满足当前显示分辨率时,不请求瓦片;放大后按需覆盖清晰瓦片,未完成区域继续显示概览。首次生成概览有成本,已有派生文件时才走复用路径。 以下采用 512 像素的瓦片目标边长,sample 表示源图缩小倍数: | sample | 典型源区域 | 典型输出 | | ------ | ------------ | ---------- | | 8 | 4096 × 4096 | 512 × 512 | | 4 | 2048 × 2048 | 512 × 512 | | 2 | 1024 × 1024 | 512 × 512 | | 1 | 512 × 512 | 512 × 512 | `FastTilePlanner` 根据 `physicalScale = 逻辑缩放 × DPR` 选档: ```dart int _chooseSample(double physicalScale) { final scale = math.max(physicalScale, 1e-6); var sample = 1; while (sample < _maxSample && scale * sample * 2 <= 1.15) { sample *= 2; } return sample; } ``` `physicalScale = 0.25` 时,sample 4 的输出接近屏幕所需;放大至 1 时使用 sample 1。1.15 是选档容差,最大 sample 为 8。可见块中心优先,边缘按源图边界裁切。 ### 5.2 批量区域解码与缓存 相邻瓦片分别从头解码会重复处理前置数据。Rust 侧优先合并同档请求,缓存中间区域后再切出瓦片。核心逻辑如下: ```rust if let Some(union) = wants.iter().copied().reduce(union_rect) { self.ensure_band(union, denom, scaled_w, scaled_h)?; } ``` 并集过大时尝试带状缓存,再按需逐块处理。Dart 侧按 `sample / row / col` 复用瓦片,每张源图维护独立的调度与缓存状态。 | 策略 | 一组实现参数 | | ------------- | ---------------------------------------- | | 可见瓦片 | 尝试降档控制规模,最多选取 96 块 | | 预取 | 周围半屏;超过 5000 万像素的图不执行预取 | | 解码批次 | 同档最多 48 块 | | 图像创建 | 每 12 块让出一次事件循环 | | Rust 区域缓存 | 96 MiB 预算,单次候选区域门槛 48 MiB | | Dart 瓦片缓存 | 64 MiB 淘汰目标,保留当前需要的块 | 手势期间采用约 100 ms 节流、70 ms 尾部补齐,避免纯防抖导致连续缩放时不加载。异步结果返回后检查是否仍需要,过期结果可释放;这不等于取消已经执行的底层解码。 ### 5.3 内存模型 单块 512² RGBA8888 约 1 MiB。分片把主要显示像素限制在视口附近,但 **64 MiB 是缓存淘汰目标,不是进程硬上限**:当前所需块与新批次可能超额,概览、区域缓存、索引、返回中的像素和临时缓冲也占内存。 `mmap` 避免主动复制整份源文件,但访问到的文件页仍占物理内存;`DontNeed` 仅提供回收建议。首次扫描、概览生成与解码峰值都需要单独计量。 ## 6. 图片格式与区域解码可行性 评估分片收益,需要区分三个层次: 1. **分片绘制**:将已有像素分块显示。 2. **区域输出**:只返回目标矩形,减少输出缓冲。 3. **随机访问压缩数据**:从可独立恢复的位置开始,跳过无关数据。 crop API 只能证明支持区域输出。能否同时降低 CPU 与 I/O 成本,取决于压缩依赖、索引结构及解码器实现。 ### 6.1 JPEG:区域裁剪与 restart 索引 JPEG 使用块状变换,但熵编码存在前后依赖,例如 DC 系数差分预测。普通顺序 JPEG 即使裁剪输出,仍可能处理目标区域之前的数据。 restart marker 为解码状态提供重新开始的位置。fast_image 的 `RestartIndex` 扫描标记,校验重启间隔与 MCU 行的对应关系,建立行段到字节偏移的索引。MCU 是 JPEG 的最小编码单元。 满足条件后,可以提取所需行段,保留必要头信息、改写高度、重新编号标记,构造独立的小 JPEG,再按工作量并行解码,最多六个线程。 ```graphviz digraph RestartJPEG { graph [rankdir=TB, bgcolor="white", ranksep=0.35]; node [shape=box, style="rounded,filled", fillcolor="#EDF4FF", color="#7697BF", fontname="sans-serif"]; edge [fontname="sans-serif", color="#617080"]; file [label="单个 JPEG 文件\n图片信息 + 带重启标记的压缩数据"]; index [label="RestartIndex\n建立 MCU 行范围到字节偏移的索引"]; wanted [label="定位视口覆盖的行段"]; a [label="构造行段 A 的小 JPEG"]; b [label="构造行段 B 的小 JPEG"]; da [label="任务 A:缩放与裁剪解码"]; db [label="任务 B:缩放与裁剪解码"]; result [label="合并解码结果\n切出瓦片并缓存"]; file -> index -> wanted; wanted -> a -> da -> result; wanted -> b -> db -> result; } ``` JPEG 解码入口区分索引路径与普通区域路径: ```rust let region = match self.index() { Some(index) => index.decode( &self.bytes, turbo::numerator(denom)?, rect, true, restart::threads(), ), None => turbo::decode_region(&self.bytes, denom, rect), }?; ``` `regionDecodable` 表示可区域输出,`randomAccess` 表示索引可用。后者还有三个条件:首次需扫描文件;标记布局必须符合实现要求;访问粒度主要是行段,不是任意二维瓦片。 **在保持单个 JPEG 文件的前提下,符合条件且带可用 restart 标记的顺序 JPEG,更适合这种基于行段索引的局部浏览方案。** ### 6.2 Progressive JPEG、PNG 与 WebP **Progressive JPEG** 通过多个 scan 逐步补全整图信息,不直接对应当前的独立行段路径。fast_image 不直接对其区域解码,但提供系数域转顺序 JPEG 的入口。“无损”指避免重新量化;转换需要较大系数缓冲并限制像素数,24000² 图片超过转换范围。 **PNG** 的 IDAT 共同组成压缩流,行滤波也可能依赖前置行,IDAT 边界不等于空间瓦片边界。fast_image 的 PNG 解码路径 从头 `next_row()` 至目标底部,只收集目标区域,降采样时对像素取平均。主要收益是减小结果缓冲,仍有顺序处理成本。 **WebP** 使用 libwebp 的 crop / scale 与外部输出缓冲,处理有损裁剪起点的偶数对齐。但裁剪不保证内部只处理目标区域,也不保证临时内存与输出等大;fast_image 的 WebP 解码路径未实现类似 JPEG restart 的随机访问索引。 | 格式或操作 | fast_image 的准入条件 | 主要限制 | | ----------------------- | --------------------------------------------------------------------------- | ---------------------------------- | | JPEG 区域解码 | 8 位、非 progressive / lossless / arithmetic / CMYK;底层像素保护值为 10 亿 | 无可用索引时仍可能处理大量前置数据 | | 渐进式 JPEG 转顺序 JPEG | ≤ 64 × 1024² 像素,满足编码条件 | 系数缓冲与一次性转换成本 | | PNG 区域解码 | 非交错,≤ 1 亿像素 | 从头按行处理 | | 有损 WebP 区域解码 | 非动画,≤ 64 × 1024² 像素 | crop 不等于随机访问 | | 无损 WebP 区域解码 | 非动画,≤ 16 × 1024² 像素 | 内部工作区成本需单独评估 | 这些是该实现的保护策略,不是格式本身的通用上限;静态瓦片查看器也未实现动画时间轴。 ### 6.3 独立瓦片与多分辨率资源 若可控制资源生产,可预先生成多分辨率金字塔,每层保存独立瓦片。缩小时读低分辨率层,放大后只获取高分辨率层的局部:下载与解码都能按块进行。 独立瓦片内部可选 JPEG、PNG 或 WebP。**先用资源组织解决独立访问,再按照片、透明度、无损等需求选择单块编码。** Tiled TIFF 也提供按位置读取块的基础,但不是所有 TIFF 都采用瓦片或金字塔组织。 上述 fast_image 实现以文件为输入,区域后端覆盖 JPEG、PNG 和 WebP。TIFF、AVIF、HEIF 需要额外的区域解码支持;多分辨率网络瓦片与远程单文件的 HTTP Range 加载也需要独立设计字节索引、请求管理和网络缓存。 资料:[TurboJPEG][jpeg-docs]、[PNG 压缩结构][png-spec]、[WebP API][webp-docs]、[LibTIFF TIFFReadTile][tiff-docs]。 ## 7. 性能分析 ### 7.1 测试环境与加载结果 以下为 **2026-09-08** 的一次实测记录。环境:macOS 26.5.2、14 核、DPR 2、profile 模式、Flutter 3.47.2 / Dart 3.13.2。 源图 24000²,约 299.1 MiB,非 progressive JPEG,`randomAccess = true`。流程预热源文件页缓存,不代表冷磁盘或网络场景。 | 标准侧输出 | 取得 ui.Image | 峰值 RSS 增量 | RGBA8888 估算 | | ---------- | ------------- | ------------- | ------------- | | 2048² | 1776.7 ms | 299.5 MiB | 16 MiB | | 4096² | 1826.4 ms | 299.2 MiB | 64 MiB | | 8192² | 2308.2 ms | 565.1 MiB | 256 MiB | 输出像素明显减少,但耗时未同比下降,仍需处理同一份大编码文件。标准侧绕过 Widget、ImageProvider、ImageCache,测量文件到 codec 的路径;不能据此推断真实页面每次都要重新解码。已有数据不含原尺寸运行,8192 只是最高测试档。 | fast_image 阶段 | 记录 | | ------------------------------- | --------------------------- | | 首次生成并加载概览 | 372.2 ms | | 已有概览读盘加载 | 44.9 ms | | probe / open / restart 索引探测 | 51.8 / 25.9 / 16.5 ms | | 1:1 视口解码并创建图像 | 62.2 ms,35 块,35 MiB 像素 | | 显示阶段峰值 RSS 增量 | 359.7 MiB | | 概览准备阶段峰值 RSS 增量 | 496.1 MiB | 将图片探测、解码器打开和已有概览加载相加,准备时间约为 122.6 ms;需要首次生成概览时,合计约 449.9 ms。二者均未包含单独记录的索引探测,也不是点击到屏幕呈现的直接测量。 ### 7.2 交互与结果解释 两侧分别独占视口执行约 6 秒的相同运镜。标准侧使用 8192 档,分片侧按需补充原始细节,因此比较的是两种浏览方案,输出清晰度并不相同。 | FrameTiming.totalSpan | 标准侧 | fast_image | | --------------------- | -------- | ---------- | | 平均 | 2.96 ms | 1.61 ms | | p95 | 4.86 ms | 2.24 ms | | p99 | 6.33 ms | 2.84 ms | | 最差样本 | 32.40 ms | 4.62 ms | 分片侧重规划 **60 次**,创建 **258 块瓦片,累计约 226 MiB 像素**。该工作量证明交互阶段包含细节加载;累计处理量不等于常驻内存。 测量边界: - `FrameTiming.totalSpan` 不是显示器呈现延迟;帧数除以采样时长得到的帧率估计受回调批次与窗口边界影响,不能当成精确的屏幕呈现帧率。 - 此次统计的 jank 阈值为 16.7 ms,不对应 120 Hz 的 8.33 ms 预算。 - RSS 为进程驻留内存,采样可能漏过瞬时峰值;RGBA 估算也不等于精确 GPU 占用。 - 单次汇总只能支持本样本的帧时与工作量结论。长帧归因需要运行时间线,不能推定“大纹理每帧被完整采样”。 ## 8. 工程结论 - **常规图片**:稳定 provider key,按显示需求控制解码尺寸,分开设计内存缓存与磁盘缓存。 - **超大图浏览**:概览提供全貌,按视口选档、区域解码、缓存和调度,控制常驻像素规模。 - **格式选择**:区域输出不等于高效随机访问;单文件优先评估编码依赖与索引条件,可预处理时考虑独立瓦片和多分辨率组织。 [flutter-image]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/widgets/image.dart#L471 [provider]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_provider.dart#L376 [network]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/_network_image_io.dart#L110 [ui-codec]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting.dart#L2572 [immutable-buffer]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting/immutable_buffer.cc [cache]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_cache.dart#L320 [resize]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_provider.dart#L1353 [stream]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/image_stream.dart#L63 [ui-image]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting.dart#L2180 [impeller]: https://github.com/flutter/flutter/blob/3.47.2/engine/src/flutter/lib/ui/painting/image_decoder_impeller.cc#L340 [paint]: https://github.com/flutter/flutter/blob/3.47.2/packages/flutter/lib/src/painting/decoration_image.dart#L718 [jpeg-docs]: https://libjpeg-turbo.org/Documentation/Documentation [png-spec]: https://www.w3.org/TR/png-3/#10Compression [webp-docs]: https://developers.google.com/speed/webp/docs/api [tiff-docs]: https://libtiff.gitlab.io/libtiff/functions/TIFFReadTile.html 最后修改:2026 年 09 月 09 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 祝你今天愉快