让图片流不再卡:从瀑布流到大图预览——海量网络图片在鸿蒙 App 上的全链路性能工程
引子:一个不可能完成的任务
想象一下这个需求:
做一个图片流页面。数据量不封顶,用户往下滑永远滑不到底。快速滑动时不能卡,随手一停要能看清。点开大图要能看清细节、能放大、能左右翻。内存不能爆,流量要省。
把这个需求翻译成工程约束,它的本质是这样的:
每一帧只有 16.6 毫秒。 在这 16.6 毫秒里,你要完成:把网络图片的字节拿回来、解码成位图、上传到 GPU、算好布局、画出来。如果这一帧没做完,用户看到的就是"卡了一下"。
而 16.6 毫秒是什么概念?一次 4K 图片的纹理上传就能吃掉 20 毫秒——一帧的预算,还不够传一张错的图。
所以这个问题的关键,不是"哪个 API 更快",而是在一帧只给这么点时间的前提下,你如何分配每一帧的工作量。
这篇文章要讲的,就是这套分配逻辑。它不是一堆优化技巧的堆砌——所有技巧背后,其实只有一条主线。
第一章 先看清敌人在哪:五个抢预算的人
在动手优化之前,必须先建立一个坐标系:掉帧是一帧超时的结果,而超时可能发生在五个不同的环节。
① 下载字节 → ② 解码成位图 → ③ 纹理上传 → ④ 布局与创建 → ⑤ 回收释放
网络耗时 CPU 解码 RS 主线程 测量与渲染 GC
这五件事共享同一个 16.6ms 预算。任何一环超了,都表现为"卡"。但它们的解法完全不同——这就是为什么"调大缓存"这类通用手段常常无效:它可能根本没打在超时的那一环上。
先算清预算
| 刷新率 | 单帧预算 | 含义 |
|---|---|---|
| 60 Hz | 16.6 ms | 主流基线 |
| 90 Hz | 11.1 ms | |
| 120 Hz | 8.3 ms | 高刷机型,预算直接砍半 |
如果你只在 60Hz 机器上测,忽略的正是"预算只有一半"的那批用户。
一个决定性的定位动作
第一步永远是看 Profiler 的长帧落在哪一环,而不是先调参数。
DevEco Profiler 的 Frame 模板会告诉你长帧的成分。判读规则很关键:
- App 侧橙色占比高 → 问题在你的 ArkTS 逻辑(组件创建、状态更新、计算)
- RS 侧橙色占比高 → 布局绘制过复杂,或纹理上传过重
这条规则能立刻把"图片卡"这个模糊判断,拆成"是解码慢"还是"是上传慢"——而这两者的解法南辕北辙。
第二章 核心矛盾:一张图的尺寸,在三处被分别决定
这是全文最重要的一章。理解了它,后面的所有手段都会变得顺理成章。
一张图片的"尺寸",实际上在三个不同的地方、由三个不同的角色、在三个不同的时刻被决定:
| 决定方 | 决定什么 | 什么时候决定 | 怎么表达 |
|---|---|---|---|
| 布局系统 | 显示尺寸 | 运行时(布局阶段) | width/aspectRatio/layoutWeight |
| URL 参数 | 传输尺寸 | 请求前(要写进字符串) | ?w=200&h=200 |
| 解码参数 | 解码尺寸(决定内存) | 解码前 | desiredSize / autoResize |
这三个尺寸,谁都不知道对方是多少。
于是产生了三种病:
① 传输尺寸 >> 显示尺寸 → 流量白花、解码白做、内存白占 ← 最常见,也最浪费
② 显示尺寸 >> 传输尺寸 → 图糊(用户能直接看出来)
③ 解码尺寸 >> 显示尺寸 → 内存翻倍(用户看不出来,但会 OOM)
后文所有的优化手段,本质上都是在做同一件事:让这三个尺寸尽可能趋同。
这句话值得记住。因为它能解释几乎所有的"为什么":
- 为什么
autoResize重要?→ 它让解码尺寸跟上显示尺寸 - 为什么 CDN 参数重要?→ 它让传输尺寸跟上显示尺寸
- 为什么服务端要下发图片宽高?→ 让布局在图片到达前就知道显示尺寸
- 为什么大图预览要分档?→ 因为显示尺寸在用户双指缩放时是变的
但这里有个死结
看起来最简单的做法是:布局算完,拿到真实尺寸,再去请求对应的图片。
但做不到。
布局要算尺寸 → 需要知道图片高度 → 而图片高度要等图片加载完
图片要加载 → 需要知道请求多大 → 而请求大小要等布局算完
↑___________________________↓
这是一个循环依赖。 而"图片流"这个场景最麻烦的地方在于——瀑布流恰恰要求每张图高度不同。
所以真实的工程解法不是"打破循环",而是在循环之外引入一个信息源。
第三章 瀑布流:把高度问题在数据层解决
为什么瀑布流比普通列表难
List 是一维的——每项高度虽然可能不同,但下一项的起始位置是"前面所有高度之和",逻辑上是串行的。
WaterFlow 是二维的——它必须知道每项高度,才能决定这一项该放进哪一列(放进去之后哪一列最短就进哪一列)。这意味着:
在渲染任何东西之前,瀑布流需要先知道所有可见项的高度。
如果高度是等图片加载完才确定的,那么:图片加载 → 高度变化 → 重新分列 → 所有项位置重排 → 用户看到内容跳动。
破解方法:让服务端下发宽高
这是本场景投入产出比最高的一条改动,而且它不在客户端:
{
"id": "img_123",
"url": "https://cdn.example.com/photo.jpg",
"width": 1080,
"height": 1440
}
有了宽高比,就能在图片到达之前把位置算好:
WaterFlow() {
LazyForEach(this.dataSource, (item: ImageItem) => {
FlowItem() {
ReusableImageCard({ item: item })
}
.width('100%')
// ★ 用服务端下发的宽高比提前定高 —— 布局不再等图片
.aspectRatio(item.width / item.height)
}, (item: ImageItem) => item.id)
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
一句话:让图片去填充一个已知大小的容器,而不是让容器去适应一张未知大小的图片。
这句话是整个瀑布流方案的地基。它带来三个连锁收益:
- 布局阶段不需等图片 → 无跳动
- FlowItem 有确定尺寸 → 成为布局边界,重绘时可跳过测量
- 提前知道要显示多大 → 可以据此计算请求尺寸和显示尺寸的关系
如果服务端暂时给不了宽高
现实里很常见:老接口没存宽高。有三条退路,按推荐度排序:
| 方案 | 做法 | 代价 |
|---|---|---|
| 占位高度 | 按列宽给一个固定占位高度(如 1:1.5) | 图片到达后小跳动,但可控 |
| 客户端首图回填 | 首次加载后把宽高回写进数据模型,二次进入不再跳 | 首次仍有跳动 |
| 服务端异步补数 | 让后端空闲时扫描补齐历史数据 | 需要排期 |
最坏的选择是"让 FlowItem 高度自适应图片" —— 那等于把循环依赖留给用户去看。
布局模式:列数会变时,用另一个开关
WaterFlow 有一个容易被忽略的属性(API 12+):
| 模式 | 语义 | 何时用 |
|---|---|---|
ALWAYS_TOP_DOWN(默认) | 视窗内每一项都依赖上方所有项的布局信息 | 固定列数 |
SLIDING_WINDOW | 只考虑视窗内的布局,不依赖上方 | 动态列数、大数据量、屏幕旋转 |
ALWAYS_TOP_DOWN 的代价在"跳转"和"切列数"时才暴露:要算出视窗里每一项的位置,得先把上方所有项都算一遍。数据量越大,越慢。
SLIDING_WINDOW 换来了效率,但也有明确的代价,官方列了四条:
- 无动画跳转到较远位置后,回滑时布局可能与之前不一致(滑到顶部会自动校准)
- 不支持滚动条,设置了也不显示
- 不支持
scroller.scrollTo- 跳转或数据更新后
currentOffset返回的偏移量不准确
如果你的页面需要"记住位置"或"跳转到指定项",这个模式会带来麻烦。 只在明确需要频繁切列数时才用。
追加数据:一个会让整屏重算的坑
// ❌ 全量重载 —— 所有已有项都要重新计算布局
this.dataSource.notifyDataReload();
this.data = [...this.data, ...newItems];
// ✅ 增量追加 —— 只算新增项,已有项位置不变
newItems.forEach((item: ImageItem, i: number) => {
this.data.push(item);
this.dataSource.notifyDataAdd(startIndex + i);
});
数据越多,notifyDataReload 的代价越大 —— 它会触发瀑布流重新计算所有已有项的布局。而 notifyDataAdd 只影响新增项。
还有一条排查时容易被忽略的:数据源必须被状态装饰器持有(@State 或等价物),否则 LazyForEach 无法感知数据变化。
第四章 滑动流畅:让每一帧的工作量可预测
前面解决了"位置从哪来",现在解决"每帧干什么"。
4.1 懒加载是入场券,不是优化项
先看官方在 Swiper 场景下的实测数据(1000 页):
| 加载方式 | 耗时 | 丢帧率 | 内存 |
|---|---|---|---|
ForEach | 951 ms | 8.5% | 200 MB |
LazyForEach | 280 ms | 0% | 25 MB |
差了一个数量级。LazyForEach 在长列表里的内存表现更极端:
| 数据总量 | ForEach 内存 | LazyForEach 内存 |
|---|---|---|
| 100 条 | ~50 MB | ~9 MB |
| 500 条 | ~250 MB | ~9 MB |
| 1000 条 | ~500 MB(OOM) | ~9 MB |
| 10000 条 | 崩溃 | ~9 MB |
注意 LazyForEach 那列是不变的 —— 因为它只创建可视区 + 缓存区的组件,组件实例数与数据总量无关。
所以:如果你的图片流用了
ForEach,其他优化都不用做了,先换成LazyForEach。
4.2 组件复用:把"创建"变成"改数据"
LazyForEach 解决了"创建多少个",但没解决"创建一次有多贵"。
@Reusable 解决的是后者:滑出屏幕的组件不销毁,进缓存池等待复用。
官方实测:组件创建耗时 10.2 ms → 0.97 ms,帧率提升 15%+。
@Reusable
@Component
struct ReusableImageCard {
@Prop item: ImageItem;
@State private pixmap: image.PixelMap | undefined = undefined;
private token: number = 0;
aboutToReuse(params: Record<string, Object>): void {
this.item = params.item as ImageItem;
this.pixmap = undefined; // ★ 私有状态必须显式重置
this.token++; // ★ 作废上一轮在途请求
this.loadImage();
}
aboutToRecycle(): void {
this.token++; // ★ 这句最容易漏
}
}
aboutToRecycle 里那次 token++ 是最容易漏的一行。 漏掉的后果是:组件已经下了树,但网络请求还在飞,回来之后仍然会写状态——既浪费解码,又可能触发无效刷新。
4.3 异步加载的归属校验:图片串图的根因
这是图片流里第二个高频的诡异 bug:
图片 A 发起加载
↓
用户快速滑动,这个组件被复用给了 B
↓
A 的加载回调返回,把 A 的图画到了 B 上 ❌
滑动越快,在途请求越多,错配概率越高。
private async loadImage(): Promise<void> {
const myToken: number = ++this.token;
const expectId: string = this.item.id;
const pixmap = await ImageLoader.load(this.item);
// ★ 双重校验:代际没变 且 数据归属仍正确
if (myToken !== this.token || expectId !== this.item.id) {
return; // 已被复用或回收,丢弃结果
}
this.pixmap = pixmap;
}
这两个条件缺一不可 —— token 防"同一实例被复用",expectId 防"数据错位"。
4.4 cachedCount:一个必须实测的参数
cachedCount 决定可视区外缓存多少个。它的语义是"前后各 N 个"(Grid 场景是前后各 N × 列数 个)。
关键认知:它不是越大越好。
官方在 Swiper 场景的实测(20 页图库):
cachedCount | 丢帧率 | 内存 |
|---|---|---|
| 1 | 3.0% | 64 MB |
| 2 | 3.3% | 117 MB |
| 4 | 3.1% | 214 MB |
| 8 | 3.0% | 377 MB |
从 1 到 8,内存涨了近 6 倍,丢帧率纹丝不动(都在 3% 左右)。
这就是"调大缓存能解决卡顿"这个直觉的陷阱——它只在你确实需要更多预加载时才有效,而在不需要时纯粹是内存浪费。
调优方法比背数字有用:
从一个偏小的值开始
↓
快速滑到底部再回滚 → 若出现白屏,调大
↓
观察内存峰值 → 若偏高,调小
↓
回到上一个"不白屏且内存可接受"的值
参考区间:
| 场景 | 起步值 |
|---|---|
| 纯文字 | 0 |
| 简单卡片 | 8 ~ 16 |
| 中等复杂度 | 24 ~ 32 |
| 含图片的卡片 | 48 ~ 64(内存约 +50%) |
| 视频缩略图 | 96+ |
水瀑布流的默认值是 1 —— 这意味着如果你不设,几乎必然白屏。
4.5 两个"静默失效"的陷阱
这两个都不会报错,但会让前面的优化全部白做:
| 陷阱 | 后果 |
|---|---|
外层多包了一层 Scroll | 外层接管滚动,内层不再按需渲染 |
同时设置了 rowsTemplate 和 columnsTemplate | 变成固定网格,LazyForEach 懒加载失效 |
排查"为什么懒加载没生效"时,先看这两条。
4.6 一个很实用的策略:滑动降质,静止补清
这一条是性价比最高的交互优化,因为它把"流畅"和"清晰"从对立变成了分时复用:
滑动中:只加载低清缩略图 → 单帧工作量小 → 帧率稳
↓
滚动停止(onScrollStop):预加载后 2~3 屏的高清图
↓
用户停下来看到的,是已经变清晰的内容
.onScrollStop(() => {
// 用户停下来了,趁主线程空闲补高清
const targets = this.dataSource.getVisibleAndNext(3);
targets.forEach((item: ImageItem) => {
ImageLoader.prefetchHighRes(item);
});
})
两个必须注意的点:
- 预加载要限制方向。不要"前后都预加载"这种贪心策略——用户继续往下滑的概率远大于回滑。方向猜错,预加载就纯属浪费 CPU。
- 滑动过程中禁止触发状态更新。在滚动回调里弹 Toast、改状态、触发重排,会直接阻塞渲染。这类逻辑一律挪到
onScrollStop。
4.7 并发闸门:容易被忽略的一环
500 张图片如果不限并发,会同时发起大量请求。后果不是"更快",而是:
- 打满连接池
- 挤占可见区域图片的带宽
- 首屏反而更慢
export class ConcurrencyGate {
private static readonly LIMIT: number = 6;
private running: number = 0;
private queue: Array<() => void> = [];
async run<T>(task: () => Promise<T>): Promise<T> {
if (this.running >= ConcurrencyGate.LIMIT) {
await new Promise<void>((resolve: () => void) => this.queue.push(resolve));
}
this.running++;
try {
return await task();
} finally {
this.running--;
const next = this.queue.shift();
if (next) { next(); }
}
}
}
4~6 是经验值:太少浪费带宽,太多抢占可见图资源。
第五章 清晰度 vs 内存:三个字节级的决定
现在进入第三、四个抢预算的人:解码和纹理上传。
5.1 autoResize:被版本改掉的默认值
先给官方定义:
设置为
true时,组件会根据显示区域的尺寸决定用于绘制的图源尺寸。如原图 1920×1080,显示区域 200×200,则降采样解码到 200×200 的尺寸。
这句话正是第二章所说的"让解码尺寸跟上显示尺寸"。
但这里有一个必须知道的版本差异:
| 文档版本 | autoResize 默认值 |
|---|---|
| API V3 及更早 | true |
| API V5(较新) | false |
含义是:在新版本上,如果不显式设置,1920×1080 的原图会按原尺寸解码进内存,然后再缩放显示。
在 500 张图的网格里,这是内存杀手——而且它不报错、不报警,只是内存悄悄翻了几倍。
Image(this.item.url)
.autoResize(true) // ★ 必须显式写,不要依赖默认值
.interpolation(ImageInterpolation.Low) // 缩略图场景用低插值,省计算
官方也说明了权衡(这解释了为什么默认值会改):降采样会"部分信息丢失,可能导致锯齿";要最佳清晰度,官方建议 .autoResize(false) + .interpolation(.Medium)。
选择依据很简单:
| 场景 | 配置 |
|---|---|
| 网格/瀑布流缩略图 | autoResize(true) —— 内存优先 |
| 单张大图、需要细节 | autoResize(false) + interpolation(Medium) —— 画质优先 |
5.2 像素格式:最容易拿到的一笔内存收益
即使尺寸降下来了,每个像素占多少字节还是可以选的。官方实测 4K 图(3840×2160):
| 格式 | 内存 | 计算方式 |
|---|---|---|
RGBA_8888(默认) | 约 33.2 MB | × 4 |
RGB_565 | 约 16.6 MB | × 2 |
NV21(YUV) | 约 12.4 MB | × 1.5 |
官方明确:不指定 desiredPixelFormat 时默认使用 RGBA_8888 —— 也就是按 宽 × 高 × 4 估算。
缩略图/列表场景改用 RGB_565,内存直接减半。 缩略图本来就不需要透明度,也不需要 8888 的色彩精度。
const options: image.DecodingOptions = {
desiredSize: { width: reqW, height: reqH },
desiredPixelFormat: image.PixelMapFormat.RGB_565 // ★ 缩略图专供
};
四条约束要记牢:
| 约束 | 说明 |
|---|---|
| SVG / TIFF | 不支持 YUV |
| YUV 不含 Alpha | 需要透明度必须 RGBA_8888 |
| HDR 解码、硬件解码 | 只支持 DMA 分配 |
| SVG 解码 | 只支持共享内存分配 |
5.3 ⚠️ 一个反直觉的坑:WebP 不支持下采样
这一条几乎没人提到,但它直接影响你"要不要全面转 WebP"的决策。
官方在图片解码文档里列出了各格式对下采样解码的支持情况:
| 是否支持 | 格式 |
|---|---|
| ✅ 支持 | .jpg / .png / .heic |
| ❌ 不支持 | .gif / .bmp / .webp / .dng / .svg / .ico |
含义:对 WebP 图片,即使你设了 desiredSize,解码器也是先完整解码原图,再缩放到目标尺寸。
对比官方对支持格式的描述:
JPEG、PNG、HEIF 格式:解码时就使用降采样策略,以最优采样率解码,效率更高。
其他格式:先将原图完整解码,再缩放到期望尺寸。
所以"转 WebP"这件事,在流量上是赚的(体积小 25~35%),在解码内存上可能是亏的(峰值内存等于原图尺寸)。
务实结论:
| 你的图片 | 建议 |
|---|---|
| 原图很大(2000px+)、列表场景 | 用 JPEG/PNG,用 CDN 出小图 —— 别只靠 WebP 省体积 |
| 已经用 CDN 出了小尺寸图 | 此时图片本来就小,转 WebP 纯赚流量,无解码损失 |
这句话可以概括成:WebP 是"流量优化",不是"内存优化"。想要内存收益,必须从"请求的图本来就小"入手。
5.4 DMA:一个容易被忽略的环节
前面说过,瓶颈可能不在你的 ArkTS 代码里。官方原文揭示了这个反直觉的事实:
当 PixelMap 较大且使用共享内存时,RS 主线程将经历较长的纹理上传时间,导致卡顿现象。
这就是"代码看起来没问题但就是掉帧"的解释。
两种内存分配方式的差别:
| 分配方式 | 需要纹理上传? |
|---|---|
SHARE_MEMORY | 需要 |
DMA_ALLOC | 不需要(零拷贝) |
官方实测(4K 图单帧渲染):共享内存约 20 ms → DMA 约 4 ms,提速 5 倍。
在图片流这个场景里,DMA 是必要基础设施,不是锦上添花。 因为你无法靠"图本来就小"来完全规避上传成本——总有大图。
const allocator: image.AllocatorType = image.AllocatorType.DMA;
const pixmap = await source.createPixelMapUsingAllocator(options, allocator);
⚠️ 一个使用 DMA 时的注意点:官方提醒"必须注意
stride和图像宽度的差异,并在数据读取、解析、显示前进行对齐"。stride可能大于width(硬件对齐填充),如果你自己按width算像素偏移,会读到错位数据。只是交给Image组件显示则无需关心。
5.5 内存的硬边界
有两条硬限制值得知道:
① 单张图片解码有 2GB 上限 —— 官方明确"图片框架对单张图片的解码设置了 2GB 的内存限制"。超过会直接失败。
② 原始像素内存超 2GB,必须下采样:
对于原始像素内存超过 2GB 且支持下采样的图片,建议设置
desiredSize进行下采样解码。
而且从 API 21 起,下采样的粒度更细了:
解码器将以基准梯度为 1/8 的最优下采样率计算 PixelMap 像素内存,即按照 7/8、6/8、…、1/8 的采样率,逐次递减取一个清晰度最高的采样数。
这意味着"下采样"不是简单的二分之一、四分之一,而是一个更平滑的档位序列——清晰度和内存的平衡点更容易找到了。
第六章 流量:三个账户与一个零成本动作
云平台的图片方案大同小异,因为都被同一个约束塑造:参数必须能写进 URL(否则 CDN 无法缓存处理结果)。这一点上一篇文章详细讨论过,这里只说落地的部分。
6.1 参数怎么用
一次请求可以同时优化尺寸、质量、格式:
https://cdn.example.com/photo.jpg?w=400&h=400&fit=cover&q=85&format=webp
↑ 尺寸 ↑ 裁剪 ↑质量 ↑格式
关键:CDN 裁剪同时命中三个维度。 官方实测「预压缩到实际 UI 尺寸」的收益:
| 指标 | 未预压缩 | 预压缩到 UI 尺寸 |
|---|---|---|
| 解码耗时 | 93 ms | 740 μs |
| 内存占用 | 90224 KB | 21230 KB |
注意第一行:93ms → 0.74ms,快了约 125 倍。因为要解的图本来就小了。
这一条数据解释了为什么"CDN 出小图"永远应该排在所有优化之前 —— 它不只是省流量,它把解码、内存、上传三件事一起解决了。
6.2 流量的三个账户
| 账户 | 计费依据 | 优化手段 |
|---|---|---|
| 处理费 | 按原图大小(与操作数无关) | 10TB/月 免费额度,中小业务基本为 0 |
| 高级压缩费 | 按次(HEIF/AVIF 等) | 谨慎使用,先算账 |
| 外网流出流量费 | 按缩放后的大小 ← | ★ 这是重点 |
第三行是个容易被忽略的杠杆:官方明确外网流出流量费按缩放后的图片大小计费,而非原图。
也就是说:
20 MB 的原图 → 缩放成 2 MB → 按 2 MB 收费
于是"转 WebP + 压质量"的收益是双重的:处理费几乎不增加(按原图算、且在免费额度内),但 CDN 流量费直接下降。
6.3 零成本的那一笔:清掉 URL 里的脏参数
这是最省钱、却最常被忽略的一个动作。
?a.jpg?x-oss-process=image/resize,w_200&t=1730000000
↑ 时间戳
每次 t 不同,就是"新 URL",CDN 永远缓存不命中,每次都回源。
类似的脏参数还有:?sign=签名、?utm_source=...、?userid=...。
行业经验:回源流量费能占到总存储分发成本的 40% 以上。一个公开案例:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 缓存命中率 | 58% | 94% |
| 回源流量费 | 1260 元 | 192 元 |
| 月度总成本 | 2300 元 | 560 元(-75.6%) |
成本降了四分之三,代码一行没改。
6.4 一个真实的翻车:把"忽略参数"开错了地方
有个很典型的故障链条,值得单独说:
问题:URL 带无关参数 → 缓存碎片化 → 回源费高
↓
"优化":在 CDN 上开启「忽略参数」
↓
后果:CDN 把 ?x-oss-process=image/resize,w_200 也当成无关参数剥掉了
↓
回源请求变成 http://oss/a.jpg(无任何处理参数)
↓
用户拿到 2500×1875 的原图,而不是 200×200 的缩略图 ❌
"图片处理参数不生效"这个故障,八成是这么来的。 正解不是二选一,而是精确忽略——只忽略 utm_*、userid,保留处理参数。
或者更简单:在源头就别把脏参数写进 URL。
6.5 HTTP 缓存:需要服务端配合
鸿蒙的 RCP 从 6.0.0(20) 开始支持 HTTP 缓存,遵循 RFC 9111,可以配置内存 + 磁盘双重缓存。
const cache = new rcp.ResponseCache({
persistent: { kind: 'file-system', pathToFolder: 沙箱路径 },
defaultExpirationPolicy: {
kind: 'relative',
time: { units: 'seconds', value: 3600 }
}
});
const session = rcp.createSession({ requestConfiguration: { cache: cache } });
304 机制的价值:资源没变时,服务端只回响应头,不传图片体。
⚠️ 但这需要服务端返回
Cache-Control/ETag—— 端侧配了、服务端没配,等于没用。这是个前后端协作项,不是客户端能单独解决的。
6.6 两级缓存:职责不同,都要有
| 层 | 缓存内容 | 省什么 |
|---|---|---|
| L1 内存 | 解码后的位图 | 省解码 |
| L2 磁盘 | 原始压缩数据 | 省流量 |
只有 L1 会浪费流量;只有 L2 会浪费解码。 两者不是替代关系。
⚠️ 但这里有一条官方约束必须注意:“如果使用系统的 Image 组件进行图片显示,无需手动释放,Image 组件会自动管理传递给它的 pixelMap。”
这意味着 LRU 淘汰时不能无条件
release()—— 如果那张图正在被某个 item 展示,释放会导致显示异常。正解是引用计数:展示时retain,复用/回收时releaseRef,淘汰时只对引用计数为 0 的图真正释放。
第七章 大图预览:另一套完全不同的约束
点开缩略图看大图,是另一个战场。这里的约束和列表页几乎是反的:
| 列表页 | 大图预览 | |
|---|---|---|
| 同时可见 | 十几张 | 1 张 |
| 单图尺寸 | 小(200×200) | 大(全屏甚至原图) |
| 手势 | 垂直滑动 | 水平翻页 + 双指缩放 + 拖动 |
| 核心矛盾 | 帧率 + 内存 | 清晰度 + 手势跟手 |
7.1 左右滑动:Swiper 的预加载是刚需
官方明确说明了大图浏览场景为什么必须预加载:
如在图库大图浏览场景中,若不使用预加载机制,每次都将在滑动开始的首帧去加载下一张图片,会导致首帧耗时过长甚至掉帧,拖慢应用性能。
配置方式:
Swiper() {
LazyForEach(this.dataSource, (item: ImageItem) => {
ReusablePreviewItem({ item: item })
}, (item: ImageItem) => item.id)
}
.cachedCount(1) // ★ 前后各 1 张,官方推荐 1~2
.loop(false)
cachedCount 推荐 1 或 2,不要更大。 原因和列表页一样——内存涨得快,帧率不涨:
cachedCount | 丢帧率 | 内存 |
|---|---|---|
| 1 | 3.0% | 64 MB |
| 2 | 3.3% | 117 MB |
| 8 | 3.0% | 377 MB |
注意这是"一屏一页"的场景 —— 每张图都是全屏大图,缓存的每一份都很大。
抛滑时的抢跑:Swiper 有一个 onAnimationStart 回调,在离手后的切换动画开始时触发(跟手滑动阶段不触发)。此时动画逻辑在渲染线程执行,主线程是空闲的,正好用来加载目标页的资源。
官方实测效果:未预加载时组件构建耗时 50 ms,预加载后 2 ms。
还有一个更精细的控制(API 15+):
.cachedCount(2, false) // isShown = false:预加载节点不绘制、不进渲染树
含义:预加载范围内的节点只做准备工作,不参与绘制。这在"预加载但还是想省资源"时很有用。
7.2 清晰度:三个档位,按需切换
大图预览的"清晰度"不是二选一,而是分层的:
| 阶段 | 加载什么 | 目的 |
|---|---|---|
| 滑动中 | 中等清晰度(如屏幕宽 1~1.5 倍) | 快速出图,跟手 |
| 停下后 | 高清图(屏幕宽 2~3 倍) | 看清细节 |
| 双指放大后 | 按缩放倍率请求更高分辨率 / 原图 | 放大不糊 |
关键设计:不要一进预览页就加载原图。 用户可能只是快速滑过,不会放大。
// 进入预览:先给屏幕尺寸的图
ImageLoader.loadPreview(item, screenW)
// 用户放大超过阈值:补原图
.onGesture(PinchGesture()
.onActionUpdate((event: GestureEvent) => {
this.scale = event.scale;
if (this.scale > 1.5 && !this.highResLoaded) {
this.highResLoaded = true;
ImageLoader.loadHighRes(item).then((pixmap: image.PixelMap) => {
this.pixmap = pixmap; // 替换为高清
});
}
})
)
这就是第二章那条主线的又一次应用:显示尺寸变了(用户放大了),请求尺寸和解码尺寸都要跟着变。
7.3 手势:三个性能要点
① renderGroup(true)
Image(this.pixmap)
.scale({ x: this.scale, y: this.scale })
.offset({ x: this.ox, y: this.oy })
.renderGroup(true) // ★ 把 Image 及其变换合并为单一渲染层
作用:把图片和它的变换合并成一个渲染层,提升跟手性能。这是大图手势流畅度的关键开关之一。
② 变换用 GPU,不改布局
scale 和 offset 由 GPU 处理,不会触发重新布局。相反,如果用手势去改 width/height/margin,每一帧都会触发测量和布局——这是"缩放不跟手"的常见根因。
③ 手势回调里不要创建对象
矩阵变换对象不要在手势回调里反复 new,考虑对象池复用。手势回调每秒可能触发几十上百次,每次分配对象都会给 GC 添压力。
7.4 一个必须处理的边界:超长图
普通大图可以用下采样解决,但超长图(如长截图、地图)有特殊问题:
一张 1000 × 2000000 的图,按 4 字节/像素算,解码后是 约 7.6 GB —— 远超单张 2GB 的解码上限,直接失败。
官方给的方案是区域解码 + 分片展示:
超长原图 → ImageSource 区域解码 → 多个 PixelMap 片段 → List + LazyForEach 按需展示
例如可将 1000×2000000 的图,切分成 1000 个 1000×2000 的图片,按需处理。
// 只解码需要显示的那一段
const options: image.DecodingOptions = {
desiredRegion: {
size: { width: 1000, height: 2000 },
x: 0,
y: segmentIndex * 2000 // 按滚动位置决定解哪一段
}
};
这个思路很漂亮:把"一张巨大的图"重新定义成"一列很小的图"。 于是超长图复用了一整套 List + LazyForEach 的按需加载能力——包括内存、复用、预加载,全部继承。
7.5 区域解码 + 下采样的顺序
如果同时需要裁区域和缩放,官方明确推荐 CROP_FIRST:
当同时设置
desiredRegion和desiredSize时,建议设置cropAndScaleStrategy为CROP_FIRST以保证解码结果一致。
先裁剪后缩放能降低峰值解码内存。
const options: image.DecodingOptions = {
desiredRegion: { size: { width: 2000, height: 2000 }, x: 1000, y: 500 },
desiredSize: { width: 512, height: 512 },
cropAndScaleStrategy: image.CropAndScaleStrategy.CROP_FIRST // ★ 推荐
};
原因很直接:先裁掉不需要的部分,参与缩放的数据量就小了,峰值内存自然低。
第八章 把散落的结论收成一张表
8.1 全链路对照
| 环节 | 关键手段 | 官方实测收益 |
|---|---|---|
| ① 下载 | CDN 裁剪到 UI 尺寸 | 解码 93ms → 0.74ms;内存 -76% |
HTTP 缓存(ResponseCache + ETag) | 304 时零流量 | |
| 清掉 URL 脏参数 | 成本案例 -75.6% | |
| ② 解码 | autoResize(true)(新版默认 false) | 解码尺寸跟随显示尺寸 |
desiredSize 下采样 | 内存降 90%+ | |
RGB_565 | 内存减半 | |
| 并发闸门 4~6 | 保护可见区带宽 | |
| ③ 纹理上传 | AllocatorType.DMA | 4K 单帧 20ms → 4ms |
| ④ 布局渲染 | @Reusable + 两个复用回调 | 创建 10.2ms → 0.97ms |
FlowItem 提前定高(aspectRatio) | 无跳动 + 免测量 | |
cachedCount 从 48 起调优 | 避免白屏 / 避免浪费 | |
| ⑤ 回收 | LRU + 引用计数保护 | 避免误释放 |
onMemoryLevel 分级清理 | 内存紧张时主动降级 |
8.2 优先级:如果只能做三件事
第一件(不用写代码):让服务端/CDN 出缩略图。
它同时命中解码、内存、流量三个指标,且收益最大(125 倍解码提速)。
第二件(改一处代码):加 @Reusable + 正确实现 aboutToReuse / aboutToRecycle。
单点收益最大(创建耗时降到 1/10)。
第三件(改一处配置):显式声明 .autoResize(true)。
新版默认值是
false,不写就等于放弃了一个内建的内存优化。
8.3 一张"什么时候该怀疑什么"的表
| 现象 | 最可能的原因 | 先查 |
|---|---|---|
| 首次进入就卡 | 用了 ForEach 或懒加载失效 | 是否外层包了 Scroll;是否同时设了两个 template |
| 滚动中白块 | cachedCount 太小 | 从 48 往上调 |
| 滚动中掉帧但内存正常 | 布局/创建开销 | @Reusable 是否生效 |
| 内存阶梯上升不回落 | 泄漏或缓存无上限 | 滚 20 轮看曲线;查 PixelMap 释放 |
| 快速滑动图片串图 | 异步回调缺归属校验 | token + expectId 双校验 |
| 代码没问题但就是卡 | 纹理上传过重 | Profiler 看 RS 侧;上 DMA |
| 缩放不跟手 | 手势里改了布局属性 | 改用 scale/offset + renderGroup(true) |
| 超长图加载失败 | 超过单张解码上限 | 区域解码 + 分片 |
结语:优化的本质是"让三个尺寸趋同"
回到第二章那三个尺寸:显示尺寸、传输尺寸、解码尺寸。
这篇文章里的每一个手段,最终都能归到它让哪两个尺寸趋同:
| 手段 | 让谁趋同 |
|---|---|
| CDN 参数 | 传输 ← 显示 |
autoResize | 解码 ← 显示 |
desiredSize | 解码 ← 显示 |
| 服务端下发宽高 | 让显示尺寸提前可知 |
| 双指放大补高清 | 传输 + 解码 跟随变化的显示尺寸 |
| 区域解码 | 解码 ← 实际查看的区域 |
而掉帧,则是这个趋同过程的副产品——每一帧里,只要"取回来的图、解出来的图、要显示的图"三者尺寸对不上,多出来的那部分工作就是在白烧帧预算。
所以当你在优化图片流性能时,最值得问的问题不是"我该调哪个参数",而是:
这一帧里,有没有在做"用户最终看不到"的工作?
找出那部分工作,把它去掉。这比调任何参数都有效。
附:文中数据引自华为官方文档(WaterFlow / Swiper / Image 组件参考、图片解码内存优化、优化瀑布流加载慢丢帧问题、图片资源加载优化、实现Swiper高性能开发等)与公开技术资料。各类参数默认值与计费标准可能随版本调整,落地前建议以当前官方文档与本地 SDK 的 .d.ts 为准。
更多推荐



所有评论(0)