引子:一个不可能完成的任务

想象一下这个需求:

做一个图片流页面。数据量不封顶,用户往下滑永远滑不到底。快速滑动时不能卡,随手一停要能看清。点开大图要能看清细节、能放大、能左右翻。内存不能爆,流量要省。

把这个需求翻译成工程约束,它的本质是这样的:

每一帧只有 16.6 毫秒。 在这 16.6 毫秒里,你要完成:把网络图片的字节拿回来、解码成位图、上传到 GPU、算好布局、画出来。如果这一帧没做完,用户看到的就是"卡了一下"。

而 16.6 毫秒是什么概念?一次 4K 图片的纹理上传就能吃掉 20 毫秒——一帧的预算,还不够传一张错的图。

所以这个问题的关键,不是"哪个 API 更快",而是在一帧只给这么点时间的前提下,你如何分配每一帧的工作量。

这篇文章要讲的,就是这套分配逻辑。它不是一堆优化技巧的堆砌——所有技巧背后,其实只有一条主线。


第一章 先看清敌人在哪:五个抢预算的人

在动手优化之前,必须先建立一个坐标系:掉帧是一帧超时的结果,而超时可能发生在五个不同的环节。

① 下载字节  →  ② 解码成位图  →  ③ 纹理上传  →  ④ 布局与创建  →  ⑤ 回收释放
   网络耗时       CPU 解码        RS 主线程      测量与渲染        GC

这五件事共享同一个 16.6ms 预算。任何一环超了,都表现为"卡"。但它们的解法完全不同——这就是为什么"调大缓存"这类通用手段常常无效:它可能根本没打在超时的那一环上。

先算清预算

刷新率单帧预算含义
60 Hz16.6 ms主流基线
90 Hz11.1 ms
120 Hz8.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)

一句话:让图片去填充一个已知大小的容器,而不是让容器去适应一张未知大小的图片。

这句话是整个瀑布流方案的地基。它带来三个连锁收益:

  1. 布局阶段不需等图片 → 无跳动
  2. FlowItem 有确定尺寸 → 成为布局边界,重绘时可跳过测量
  3. 提前知道要显示多大 → 可以据此计算请求尺寸和显示尺寸的关系

如果服务端暂时给不了宽高

现实里很常见:老接口没存宽高。有三条退路,按推荐度排序:

方案做法代价
占位高度按列宽给一个固定占位高度(如 1:1.5)图片到达后小跳动,但可控
客户端首图回填首次加载后把宽高回写进数据模型,二次进入不再跳首次仍有跳动
服务端异步补数让后端空闲时扫描补齐历史数据需要排期

最坏的选择是"让 FlowItem 高度自适应图片" —— 那等于把循环依赖留给用户去看。

布局模式:列数会变时,用另一个开关

WaterFlow 有一个容易被忽略的属性(API 12+):

模式语义何时用
ALWAYS_TOP_DOWN(默认)视窗内每一项都依赖上方所有项的布局信息固定列数
SLIDING_WINDOW只考虑视窗内的布局,不依赖上方动态列数、大数据量、屏幕旋转

ALWAYS_TOP_DOWN 的代价在"跳转"和"切列数"时才暴露:要算出视窗里每一项的位置,得先把上方所有项都算一遍。数据量越大,越慢。

SLIDING_WINDOW 换来了效率,但也有明确的代价,官方列了四条:

  1. 无动画跳转到较远位置后,回滑时布局可能与之前不一致(滑到顶部会自动校准)
  2. 不支持滚动条,设置了也不显示
  3. 不支持 scroller.scrollTo
  4. 跳转或数据更新后 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 页):

加载方式耗时丢帧率内存
ForEach951 ms8.5%200 MB
LazyForEach280 ms0%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丢帧率内存
13.0%64 MB
23.3%117 MB
43.1%214 MB
83.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);
  });
})

两个必须注意的点:

  1. 预加载要限制方向。不要"前后都预加载"这种贪心策略——用户继续往下滑的概率远大于回滑。方向猜错,预加载就纯属浪费 CPU。
  2. 滑动过程中禁止触发状态更新。在滚动回调里弹 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 ms740 μs
内存占用90224 KB21230 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丢帧率内存
13.0%64 MB
23.3%117 MB
83.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.DMA4K 单帧 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 为准。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐