智绘鸿蒙 · 如 7 而至端侧 AI 能力实战手记

把滑动掉帧交给系统调度

智绘鸿蒙·如 7 而至

前言

上周产品在同一条消息里 @ 了我两次:「首页下滑有点糊,鸿蒙 7 不是有个性能提示的接口吗,加上试试。」

她指的是 schedulingOptimization.perfHint,鸿蒙 7(API 26.0.0)随 FAST Kit 放出来的那个。

这个需求我没法直接接。"糊"可能是掉帧,可能是高刷屏没抬到高档位,也可能是图片加载慢——三种原因对应三种改法,只有第一种和 perfHint 有关,而且关系还很弱。所以我没先加接口,先搭了一个和她说的首页长得一样的信息流,把帧率直接显示在页面上,量了一遍。

量完的结果比"加不加"更有意思:这个页面在滑动时本来就不掉帧;等我真的把掉帧造出来之后,perfHint 一点忙都没帮上。 下面按接口、怎么量、六组数据、结论的顺序写。

城读信息流首页,右上角是实时帧率徽标

perfHint 能做什么

它做的事情只有一件:在你预感到"接下来这一小段要抢资源"之前,把这段预期告诉系统,让调度器提前把 CPU、GPU 的频率和优先级准备好。

官方文档里有一句要放在前面读:perfHint 只是应用向系统发送的性能优化提示,系统会综合考量整机 CPU 负载、温度等因素决策,不保证一定进行性能提升。

需要注意的是,这句话不是免责模板,是实测结论。我们这次所有带提示的组,都没测出提升。

它适合的位置是那些"开始时刻明确、时长有限"的过程:应用启动、页面切换、首屏绘制、动效播放、编解码起播。不适合的位置,是把它当成"滑动不流畅"的通用开关——滑动本身是一个持续的、由你每帧干了什么决定的过程,提示改变不了你占住主线程的那几十毫秒。

  • 场景类型(sceneType):告诉系统这一段在干什么的枚举值,共 9 种,见下表。
  • BEGIN / END:一次提示的起止配对,只发 BEGIN 不发 END 会被系统按最大时长回收。

接口与约束

调用只有一个:

import { schedulingOptimization } from '@kit.FASTKit';

const cfg: schedulingOptimization.PerfHintConfig = {
  sceneType: schedulingOptimization.SceneType.ANIMATION,
  sceneState: schedulingOptimization.SceneState.BEGIN,
  durationType: schedulingOptimization.DurationType.MEDIUM,
  tids: []
};
await schedulingOptimization.perfHint(cfg);

PerfHintConfig 四个字段的取值域:

字段 取值
sceneType 1 APP_LAUNCH、2 PAGE_TRANSITION、3 PAGE_LOAD、4 NETWORK_FILE_PROCESSING、5 LOCAL_FILE_PROCESSING、6 PAGE_DRAWING、7 ANIMATION、8 MEDIA_PLAYBACK、9 MEDIA_ENCODING_AND_DECODING
sceneState 1 BEGIN、0 END
durationType 1 SHORT(单次 ≤1s,两次间隔 >3s)、2 MEDIUM(≤10s,>30s)、3 LONG(≤60s,>180s)
tids 线程 ID 数组,可同时提示多个线程,留空表示主线程

三个硬约束:仅前台生效;不要和 QoS API 混用;每一档都有最小间隔要求。

失败时的错误码是一份很好的诊断表:

错误码 含义
1027700001 系统负载高
1027700002 省电模式
1027700003 低电量模式
1027700004 非前台调用
1027700005 间隔不满足要求
1027700006 调度优化执行失败

也就是说"加了没生效"这件事,有一部分情况系统是明确告诉你的——前提是你把 Promise 的 reject 打出来,而不是 await 完就往下走。我们第一版就是没打,白跑了半天。

我们一步一步写

第 1 步:先把帧率挂到页面上

判断滑动有没有变好,得有帧数据。ArkUI 给的是 displaySync

import displaySync from '@ohos.graphics.displaySync';

private consumer: displaySync.DisplaySync = displaySync.create();

aboutToAppear(): void {
  this.consumer.on('frame', (info: displaySync.IntervalInfo) => {
    const now: number = info.timestamp / 1000000;   // 纳秒转毫秒
    if (this.prev > 0) {
      const gap: number = now - this.prev;
      // gap 就是一帧的间隔,采样期里累计均值 / 最大值 / 超阈值次数
    }
    this.prev = now;
  });
  this.consumer.start();      // ← 少了这一行,永远收不到回调
}

这里有一个足够坑到任何人的细节:on('frame') 之后必须调用 start()。我们第一版没调,跑出来 frames=0,看起来像"这台设备不支持",实际上只是没订阅成功,而且它不报错。

指标定义成三个,都能一眼看懂:

  • 均帧:采样窗口内收到的 vsync 帧数 ÷ 实际时长
  • 最差帧:单帧最大间隔(毫秒)
  • 掉帧次数:间隔 > 20ms 的帧数

阈值取 20ms 是刻意宽松的——144Hz 一帧 6.9ms,20ms 意味着这一帧已经慢到肉眼可见。

第 2 步:造一个像样的信息流

这一步的目的不是测接口,是先有一个能替产品回答"糊在哪"的页面。48 张卡片(8 篇内容循环 6 遍),每张是 640×480 的封面图 + 标题 + 来源 + 时间 + 点赞数 + 热度条,ListcachedCount(2)。页面上浮两个东西:右上角一个实时帧率徽标,绿色 ≥100、橙色 ≥60、红色更低;右下角一个「调」字圆点,点开是调试面板。

徽标直接用上面的 gap 换算,Text 的底色由 @State fps 决定,所以它是活的,不是截图时凑数的。

第 3 步:给"掉帧"找一个真实来源

空跑的结果是满帧,perfHint 无事可做。要判断它到底有没有用,得先有一个真实存在的掉帧原因,而不是随手写个死循环。

信息流里最常见的一种真实反模式是:卡片进入可视区时,顺手在主线程上把这张卡片的高清原图解码出来,准备用户点开就能看。后台原图直出、没接缩略图 CDN 的项目,代码形态基本就是这样。

复现它只要三行,挂在 onScrollIndex 上:

List({ scroller: this.sc }) { /* ... */ }
  .onScrollIndex((first: number, last: number) => {
    this.onVisible(first, last);      // 每个新进入可视区的下标解码一次
  })

// 同步版:读文件 + 解码,全在主线程
const buf: Uint8Array = cxt.resourceManager.getRawFileContentSync(CARDS[idx].big);
const src: image.ImageSource = image.createImageSource(copy);
const pm: image.PixelMap = src.createPixelMapSync();

调试面板里做成三个胶囊:不解原图 / 同步解原图 / 异步解原图,外加一个 perfHint 开关和一个「自动下滑 2.6 秒」按钮。自动下滑是 scrollBy(0, 30)sleep(8) 的循环——顺带说一句,sc.fling(9000) 在这个 List 上完全不生效,试了两轮才换掉。

第 4 步:跑对照

同一份代码、同一台设备、每次跑之前都从第 0 张重新下滑,六组结果:

卡片进入可视区时 perfHint 均帧 最差帧 掉帧次数 解码
不解原图 132 FPS 9 ms 0
同步解原图 91 FPS 199 ms 23 27 张 / 1043 ms
同步解原图 94 FPS 190 ms 23 27 张 / 1035 ms
同步解原图 关(复测) 92 FPS 191 ms 23 27 张 / 1045 ms
异步解原图 132 FPS 9 ms 0 35 张 / 1945 ms
异步解原图 131 FPS 17 ms 0 35 张 / 1891 ms

同步解原图那三行是复现出来的"糊":单张解码 30–45ms,正好一帧半到六帧没了,最差帧 199ms 意味着手指还在动、画面已经停了五分之一秒。

开不开 perfHint,91 / 94 / 92 三行落在同一个噪声带里,掉帧次数三次都是 23 次,一次不差。

第 5 步:把重活挪出主线程

同一批图、同一个解码量,只把同步 API 换成 Promise:

cxt.resourceManager.getRawFileContent(CARDS[idx].big).then((buf: Uint8Array) => {
  const src: image.ImageSource = image.createImageSource(copy);
  return src.createPixelMap().then((pm: image.PixelMap) => {
    pm.release();
    src.release();
  });
});

均帧回到 132,最差帧 9ms,掉帧 0 次。

这里有一处反直觉,值得单独指出:异步版的累计解码耗时是 1945ms,比同步版的 1043ms 更高。因为 35 张排在别的线程上排队,每张从发起到拿到 PixelMap 的墙钟时间被拉长了。但滑动帧率是满的——总耗时变差、体验变好,这正是"主线程每帧干了什么"和"总共花了多少 CPU"是两件事的意思。

同步解原图 + perfHint 关闭:均帧 91、最差帧 199ms、掉帧 23 次

同一负载下打开 perfHint:均帧 94、最差帧 190ms、掉帧仍然 23 次

改成异步解码后:均帧 132、最差帧 9ms、掉帧 0 次

中途差点得出的错误结论

第一轮实验不是这么设计的。当时是另一个写法:400 行列表,每行构建时做一次约 90 万轮位运算,然后先连跑三次不带提示、再连跑三次带提示。

帧数 平均间隔 卡顿数
不带提示 #1/#2/#3 97 / 132 / 157 17.53 / 12.59 / 10.58 ms 18 / 11 / 7
带提示 #1/#2/#3 175 / 185 / 194 9.77 / 9.15 / 8.85 ms 5 / 3 / 2

卡顿从 18 降到 2,看起来"效果显著"。但第二列自己就在从 97 涨到 157——让曲线下降的变量可能不是提示,是"跑得越来越多"。

改成交错跑(off、on、off、on……)之后:

次序 帧数 平均间隔 卡顿数
1 不带 97 17.71 ms 18
2 135 12.50 ms 11
3 不带 162 10.51 ms 7
4 180 9.40 ms 4
5 不带 187 8.87 ms 2
6 194 8.59 ms 1

第 3 次(不带提示,162 帧)比第 2 次(带提示,135 帧)更好。用"第几次跑"预测结果,比用"哪一组"准得多。

交错 A/B 六轮,帧数严格单调上升

实验一的"效果"是预热:JIT、布局缓存、图片解码缓存这些一次性成本摊薄之后,任何指标都会自己变好。

真机数据

项目 实测值
设备 HUAWEI MatePad Pro(型号 MRDI-W00,序列号略)
系统 OpenHarmony-7.0.0.105 / API 26
屏幕 2800×1840,hidumper -s RenderService -a screen 显示支持 60 / 120 / 144Hz 三档,空闲时 activeMode 为 60
滑动时 均帧 132 FPS,最差帧 9 ms,掉帧 0 次(不解原图)
同步解码 单张 30–45 ms,27 张累计 1043 ms,最差帧 199 ms
perfHint 收益 91 → 94 FPS,掉帧 23 → 23 次,无收益
异步解码 35 张累计 1945 ms,最差帧 9 ms,掉帧 0 次
BEGIN 连发 第二次即 1027700005,END 之后立刻 BEGIN 同样被拒
displaySync 未调 start() frames=0,无任何报错

六条实测结论

一、先确认掉帧存在,再讨论要不要提频。 现象:一个正常写的 48 卡信息流,滑动时 132 FPS、最差帧 9ms、掉帧 0。原因:主线程每帧只做布局和一次 scrollBy,本来就够快。对策:任何"卡顿优化"之前,先把帧间隔打印出来,别拿体感当依据。

二、perfHint 救不了主线程被占住的情况。 现象:同一份同步解码负载,开关前后 91 / 94 / 92 FPS,掉帧次数三次都是 23。原因:提频能改变的是资源分配优先级,解码那 40ms 是主线程上一件串行做完的事,频率再高也得占着这一帧。对策:先定位并拆掉主线程的活,再考虑提示。

三、同步改异步的收益是量级上的。 现象:132 FPS / 0 掉帧,且代码只改了四行。原因:解码被挪到框架的工作线程,主线程每帧只剩布局。对策:getRawFileContentSync + createPixelMapSync 这一对同步 API 不要出现在任何滚动路径上。

四、累计耗时和体验可以反向。 现象:异步版总耗时 1945ms,比同步版 1043ms 高 86%,帧率却从 91 涨到 132。原因:并行排队拉长了单张的墙钟时间,但把它从关键路径上摘了下来。对策:评估优化时同时看"总开销"和"每帧开销",只看一个会得出反的结论。

五、BEGIN 的间隔是从 BEGIN 起算的,END 不解锁。 现象:BEGIN#1 ok · BEGIN#2 code=1027700005 · 中间发过 END,再 BEGIN 仍是 1027700005。原因:最小间隔按档位(SHORT >3s、MEDIUM >30s)从上一次 BEGIN 计时。对策:高频触发点(比如每次 onScrollIndex)不适合直接调 perfHint,要么降频,要么选对档位并真的等够。

六、displaySync 不调 start() 是静默失败。 现象:回调一次都不进,frames=0,不报错不抛异常。原因:on('frame') 只是注册消费者,start() 才真正订阅 vsync。对策:帧统计代码写完,第一件事是确认空跑也能拿到非零帧数。

顺带记一个 ArkUI 坑:统计表格用 ForEach(this.stats, ..., (s: Stat) => s.name),而 name 只有"带提示/不带提示"两种值,结果六次实验只显示两行。key 重复时后面的项不会新增。改成 (s, idx) => ${s.name}-${idx}`` 就正常了。跟性能无关,但足以让人误判一整轮实验。

连发 BEGIN 被 1027700005 拒绝

总结

这篇测的接口,结论是"在我们造出来的那个掉帧场景里,它没有可测量的收益"。如果一定要说什么时候值得接:应用启动、页面切换、首屏绘制这类有明确起点和终点、且瓶颈确实在调度而不在你自己代码里的过程,可以试,试之前先按第 1 步把帧率挂到页面上。

不适合的位置很明确——把它当"滑动优化开关"往滚动回调里塞。一是主线程被自己的活占住时提频无效,二是 onScrollIndex 这种频率根本过不了 BEGIN 的最小间隔,只会拿到一串 1027700005。

给产品的那句回复是:首页不糊,糊的是新卡片进屏时在主线程解原图,这个已经改掉了;perfHint 这次没加。

💡 一句话:perfHint 是"提前借资源",不是"把活变少"——主线程上那几十毫秒,只有你自己能还。

参考文献

  1. 使用 perfHint 系统性能优化(ArkTS):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/fast-scheduling-optimization_arkts
  2. schedulingOptimization(系统性能优化)API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/fast-kit-scheduling-optimization
  3. FAST Kit 简介:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/fast-introduction
  4. FAST Kit 目录:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/fast-kit-guide