应用冷启动那两秒、列表快滑时的一顿、视频转码时整机发烫——这些时刻系统调度器其实是最被动的那个。它不知道这波忙是三秒钟的紧要关头还是常态,只能按默认策略匀着给算力;等 CPU 频率真的提上来,最忙的那一下往往已经过去了。反过来,一个后台挂着的应用整天高频跑,调度器也分不清它是不是真有要紧事。

HarmonyOS 7 把这个问题摆到了台面上:FastKit,API 26 新增的算法加速套件,核心思路是让应用把自己的性能敏感场景主动报给系统——启动、页面切换、本地文件枚举、音视频编解码,什么时候开始、大概忙多久、牵扯哪些线程,一次讲清楚,调度器再决定怎么给算力。这套思路和 Android 侧 ADPF 的 PerformanceHintManager 是同一路数:不是替你优化代码,而是给调度器装上耳朵。

FastKit 一共就两个模块,schedulingOptimization 和 mathPrediction,都在 @kit.FASTKit 里。这篇文章把两个模块全部实测一遍:perfHint 从最小接入到踩满它的全部约束(有些约束文档写了但没人验证过是不是真的),predictIndex 用三种序列跟手写线性外推对垒——其中有个反直觉的实测结论,后面细说。

一、FastKit 全景:两种"加速",定位完全不同

先把两个模块的定位掰开,因为它们经常被混为一谈:

模块干什么输入输出什么时候用
schedulingOptimization场景上报,调度提示配置对象,Promise<void>干重活前后包一层
mathPrediction索引预测时序样本数组,返回预测索引抽帧/滑窗管线的预取决策

schedulingOptimization 是"喊一嗓子":不执行任何计算,只是告诉系统"我要进入某某场景了,大概忙多久"。它没有返回值、没有回调,收益完全取决于系统调度器听进去多少。

mathPrediction 是"算一下":给它一串按时间排序的索引样本,它吐出下一个索引的预测值。文档里写的适用场景是图像处理里的滑动帧序列——比如抽帧管线按固定节奏取帧,取到第 N 帧时预判第 N+1 帧在哪个位置,提前把资源备好。

两个模块的能力检测各占一个 syscap:

canIUse('SystemCapability.FAST.SchedulingOptimization')  // 上报模块
canIUse('SystemCapability.FAST.Core')                    // 预测模块

我这台设备上两个都返回 true,可以放心接入。FastKit 不需要申请任何权限,这在使用门槛上是个加分项——对比动辄要 user_grant 的能力,它只需要一次 canIUse 守卫。

二、perfHint 的四要素:一个配置对象说清楚

perfHint 唯一的入口是 schedulingOptimization.perfHint(config),配置对象四个字段,全部必填:

import { schedulingOptimization } from '@kit.FASTKit'

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

sceneType,场景类型,九选一。枚举值和语义的对照如下,覆盖了应用性能问题的高发区:

枚举值语义
APP_LAUNCH应用启动
PAGE_TRANSITION页面切换
PAGE_LOAD页面加载
NETWORK_FILE_PROCESSING网络媒体文件处理
LOCAL_FILE_PROCESSING本地文件处理(含枚举)
PAGE_DRAWING页面绘制
ANIMATION动效
MEDIA_PLAYBACK音视频播放
MEDIA_ENCODING_AND_DECODING音视频编解码

选哪个不是装饰——调度器对不同场景的策略是分化的,文档里"improve policy differentiation"说的就是这件事。本地批量枚举文件就报 LOCAL_FILE_PROCESSING,别看它只是个标签就随手填。

sceneState,BEGIN 还是 END。BEGIN 宣告场景开始,END 宣告结束,一开一合构成一个"实例"。

durationType,时长档,三选一。这是整个 API 里坑最密的地方,单独开一节讲。

tids,线程号数组,最长 16 个。空数组有明确含义(不指定线程),这个边界第八节细说。

三、最小接入:BEGIN → 干活 → END

完整的使用范式是三段式,把重活包在中间:

// 进入重活前:宣告场景开始
await schedulingOptimization.perfHint({
  sceneType: schedulingOptimization.SceneType.LOCAL_FILE_PROCESSING,
  sceneState: schedulingOptimization.SceneState.BEGIN,
  durationType: schedulingOptimization.DurationType.SHORT,
  tids: []
})
// …这里干重活:批量枚举、解码、排序…
// 干完收尾
await schedulingOptimization.perfHint({
  sceneType: schedulingOptimization.SceneType.LOCAL_FILE_PROCESSING,
  sceneState: schedulingOptimization.SceneState.END,
  durationType: schedulingOptimization.DurationType.SHORT,
  tids: []
})

第一次调用就成功返回了,没有任何前置条件(权限、初始化、会话都不存在,perfHint 是无状态的)。有一个实测细节值得记下来:END 不受间隔约束。BEGIN 之后 1 毫秒内调 END,成功——间隔约束只拦"新实例的开始",不拦收尾。也就是说忘不了 END 不会卡在节拍上,随时能收。

另一个前置约束是前后台:非前台调用会收到 1027700004。生命周期里 onBackground 之后就别报了,后台任务想用 perfHint,得掂量一下它本来就是给前台性能场景设计的。

四、时长档的隐藏约束:窗口上限与最小间隔

三档时长的约束是成对出现的,d.ts 里写得清楚但容易被略读:

档位单次实例最长相邻实例最小间隔
SHORT1 秒3 秒
MEDIUM10 秒30 秒
LONG60 秒180 秒

两个数字两种含义:窗口上限管的是单次 BEGIN 声明的场景持续时间——报了 SHORT,这个"性能预告"最多撑 1 秒,超了系统自动收窗口;最小间隔管的是两次 BEGIN 之间的冷却——同一个节拍上不允许连续打点,SHORT 档打完一次,3 秒内的下一次 BEGIN 会被拒绝。

"会被拒绝"是文档的说法,我实测验证了它是真的。写了个连报两次的用例:第一次 BEGIN 成功,紧接着(间隔 1 毫秒)再发一次同样的 BEGIN:

第一次 场景=本地文件处理 档位=SHORT 预判=放行
第一次 成功
立即第二次 场景=本地文件处理 档位=SHORT 预判=将拒报
立即第二次 失败 1027700005:间隔不满足

错误码 1027700005,消息原文 “The interval does not meet the requirement”。约束是真的、即时的、不带缓冲。
1.gif

这个约束直接影响接入代码的形状:任何会多次触发重活的入口,都要先判冷却再 BEGIN。我把三档间隔抽成了纯函数,页面在调用前先预判,调完和真实结果对照:

/** 距上次 BEGIN 是否已超过该档最小间隔(须严格大于);lastBeginAt<=0 视为从未上报 */
export function canReportNow(lastBeginAt: number, tier: FastTier, now: number): boolean {
  if (lastBeginAt <= 0) {
    return true
  }
  return now - lastBeginAt > intervalOf(tier)
}

注意"严格大于"这个边界:恰好间隔 3000ms 时仍是拒绝态,3001ms 才放行。这类边界我写了 26 条断言的 node 单测逐一锁死(tools/fastkit_logic_test.mjs),跟页面共用的同一份逻辑。

五、间隔是全局节拍:连自己的 benchmark 都要排队

上一个坑的延伸,也是实测里最意外的发现:间隔约束不认入口、不认档位,是全局节拍。

事情出在对照实验上。页面的对照实验分两个阶段:先裸跑四轮排序,再在 perfHint 窗口内跑四轮。我在点「跑一轮对照」之前手动点过一次 BEGIN(SHORT 档),心想 benchmark 内部用的是 MEDIUM 档、又是另一个入口,应该互不相干——结果 benchmark 的 BEGIN 被拒了,1027700005。也就是说,一次成功的 BEGIN 会占住整个节拍,后续任何 BEGIN(换档位、换入口)都得等冷却。

这个行为合理:三档最小间隔本来就是防滥用的节流阀,如果换个场景名就能绕过,约束就形同虚设。但对接入代码意味着:你的 perfHint 调用点要共享一个冷却状态,各管各的必然撞车。修正后的 benchmark 在 BEGIN 前先等冷却:

private async waitCooldown(): Promise<void> {
  let remain: number = waitRemainingMs(this.lastBeginAt, this.lastTier, Date.now())
  if (remain > 0) {
    this.log(`上次 BEGIN(${this.lastTier}) 还在冷却,还需约 ${Math.ceil(remain / 100) / 10}s,自动等待…`)
  }
  while (remain > 0) {
    const step: number = remain > 200 ? 200 : remain
    await new Promise<void>((resolve) => {
      setTimeout(resolve, step)
    })
    remain = waitRemainingMs(this.lastBeginAt, this.lastTier, Date.now())
  }
}

实测日志,冷却机制工作正常:

上次 BEGIN(SHORT) 还在冷却,还需约 0.8s,自动等待…
perfHint BEGIN OK(MEDIUM 窗口)
窗口内阶段完成,累计 1137ms
perfHint END OK

给页面上每个会 BEGIN 的入口都接上这份冷却跟踪后,再没有撞过 1027700005。

六、错误码六连:哪些能主动触发,哪些是环境说了算

perfHint 的六个错误码,按"能不能在自己的代码里稳定复现"分两类:

错误码含义主动性
1027700005间隔不满足可主动触发:冷却期内再 BEGIN,必现
1027700004非前台调用可主动触发:退后台后调用
1027700001高系统负载环境相关,系统忙时才有
1027700002省电模式环境相关,开关省电模式可控
1027700003低电量模式环境相关,电量低时才有
1027700006执行失败兜底错误

接入时值得为前两个写防御:间隔类靠冷却跟踪(第五节),前台类靠生命周期判断。后四个 catch 住打日志就行,别重试——它们都不是"再试一次能好"的错误。

处理时还有一个 ArkTS 的坑。错误回调里拿到的 be.code,运行时是字符串形态——拿它直接和数字字面量做全等,永远落空。第一版错误映射表就是这么失效的:日志里打出"失败 1027700005:未知错误 1027700005",映射函数里明明有这个码。调用点强转一下就好了:

catch (err) {
  const be = err as BusinessError
  // be.code 运行时可能是字符串形态,=== 数字全失配,必须强转
  this.log(`${tag} 失败 ${be.code}:${fastErrText(parseInt(`${be.code}`, 10))}`)
}

七、tids 的边界:API 没给线号,空数组是正解

tids 字段的设计意图很清楚:重活如果在子线程跑,把线程号报上去,调度器就能精确到线程地给资源,上限 16 个。但翻遍公开 API,ArkTS 侧拿不到自己的线程号——@ohos.process 没有 gettid 一类的接口,TaskPool 工作线程的 tid 也没有查询入口。

所以 ArkTS 应用只有两个选择:传空数组,或者在 native 侧拿 tid 后经混合开发通道传上来。空数组的语义是"不指定线程",调度器按调用上下文自行判断,对主线程上的重活(同步解码、批量枚举、大排序)够用了。真要把 TaskPool 池子里的线程精确报上去,得走 native。

这个边界值得写进技术方案:别在 ArkTS 里为了凑 tids 去猜线号,乱填不如空着。

八、对照实验:重活放进窗口里跑

理论说完,跑数据。实验设计:20 万元素的数组排序,四轮裸跑 vs 四轮窗口内跑(同一份种子数据,校验和一致证明两侧干的活完全相同),窗口用 MEDIUM 档包住整个第二阶段:

--- 对照实验:4 轮 x 排序 200000 元素 ---
裸跑阶段完成,累计 1268ms(校验和 10002718560)
上次 BEGIN(SHORT) 还在冷却,还需约 0.8s,自动等待…
perfHint BEGIN OK(MEDIUM 窗口)
窗口内阶段完成,累计 1137ms
perfHint END OK
对照结果:裸跑均值 296ms vs 窗口内均值 249ms(-16%)
轮次裸跑 ms窗口内 ms
1312262
2296251
3285246
4290238
均值296249

窗口内一组稳定地快了约 16%。但必须把另一半数据也摆出来:同一台机器另一轮测试里,裸跑自身就从 206ms 波动到 296ms——单机、小样本下的百分比只能看趋势。perfHint 的定位是提示不是保证,调度器听多少、当时的系统负载怎么样,决定了每台设备每分钟的收益都不同。把接入姿势做对(场景报准、窗口包对、冷却守好),收益交给系统;反过来,接入姿势错(比如窗口没真开起来还以为开了),测出来的只能是噪声。
2.gif

九、predictIndex:实测它偏保守,别拿它做趋势外推

mathPrediction.predictIndex 的输入是按时间升序的样本数组,每个样本是"索引 + 毫秒时间戳",至少两个,少了抛 1023100001(实测触发过)。返回预测的下一个索引,取整。

设计三组序列让它和手写线性外推对垒——线性外推就是"取首尾算平均步长,外推一步":

序列样本实际下一帧线性外推FAST
匀速100→105,每 33ms 一帧106106106
加速20,22,25,29,34,40(步长 2→6 递增)474442
抖动50,52,51,54,53,56575756

三个结果都值得说。匀速序列全中:每 33ms 一帧的均匀节奏,FAST 预测分毫不差——这正是文档说的滑动帧序列场景,抽帧管线按固定间隔取帧时,预取决策可以直接用。抖动序列差 1:带 ±1 噪声时它比线性外推略保守,抗噪表现正常。

加速序列是反直觉的那个:步长递增的序列,下一帧实际在 47,线性外推到 44,FAST 只给到 42——它比手写线性还保守,完全没有追加速趋势的意思。这说明内置算法走的是平滑/滞后一路,不是多项式外推。对照它"滑动帧序列"的定位就通了:帧管线里索引节奏基本均匀(偶尔抖动),要的是稳,不是预测爆发。

所以选型结论:均匀节奏的下一帧预取,用它,准;趋势预测,别用它,它不追。真要预判加速场景,自己写外推逻辑反而更激进。

调用代码很短,注意样本数组要显式标类型:

import { mathPrediction } from '@kit.FASTKit'

const samples: mathPrediction.IndexSample[] = [
  { index: 10, timestamp: 1000 },
  { index: 12, timestamp: 1020 },
  { index: 14, timestamp: 1040 }
]
const next: number = mathPrediction.predictIndex(samples)  // → 16

3.gif

十、接入清单与误区

把全文的实测结论收拢成一张接入清单:

  1. 入口先 canIUse,两个 syscap 各查各的,不通过就降级为不接,perfHint 调用点全部可摘除(无状态、无副作用是它的优点)。
  2. 场景报准。九个 SceneType 覆盖了启动/切页/加载/文件/绘制/动效/媒体,是什么场景报什么,调度策略是按场景分化的。
  3. 档位按实际时长选。一秒内的瞬时重活 SHORT,几秒的批量任务 MEDIUM,长任务 LONG;报 LONG 就要接受 180 秒的节拍占用。
  4. BEGIN/END 配对,END 随时可收(实测不受间隔约束),但窗口到上限系统也会自动收,别依赖这个兜底。
  5. 冷却跟踪做成全局单例。任何一次成功的 BEGIN 都占节拍,跨档位跨入口共享冷却状态,否则撞 1027700005。
  6. 退后台别报(1027700004),生命周期里收口。
  7. tids 传 [],ArkTS 没有线号来源,乱填不如空着。
  8. 收益别打包票。perfHint 是给调度器的预告,实测窗口内重活快了 16%,但裸跑自身波动就有四成多,每台设备、每个时刻的收益都不同。

顺带一个 ArkTS 工程坑:配置常量别用 Record<string, number> 接对象字面量——它不算显式声明的 class/interface,编译直接报 10605038(Record<string, Object> 同罪,规则一样)。三档常量写成 if 链或 switch 最省事。

总结

FastKit 两件事:perfHint 把性能敏感场景报给调度器,predictIndex 预判均匀序列的下一帧。API 面小,但约束密:三档窗口上限与最小间隔成对出现、间隔是全局节拍(跨档位跨入口)、END 不占节拍、非前台拒报、tids 在 ArkTS 侧只能空着。predictIndex 均匀序列全中、加速序列保守——它是给抽帧管线做预取的,不是趋势预测器。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐