HarmonyOS 7 新特性8:闪控窗深潜——三种刷新策略、主窗帧率不动与CPU代价转移真机实测

1、引言

篇 1 里我把闪控窗做成了一张「轻量状态卡」——倒计时、进度、引擎态,悬浮在桌面上跟着专注走。写完回头看,「轻量」这两个字我一次都没量过。它到底多重?每秒重绘一次的那个小窗口,在渲染系统里花掉了什么?

我甚至提前写好了一个引子,打算在这篇开头用:「闪控窗一开,主窗口的录制预览掉了三帧。我没加任何界面,只是让那个小窗口每帧都刷新数字。」常驻窗口嘛,一直在画,画就得占合成预算,预算和主窗共享,所以主窗会卡——推理链看起来无懈可击。

真机数据出来时,这个引子被我整段删了。三档刷新策略下,主窗的卡顿计数增量全部是零。 一次都没有——而且后来我补了一组对照实验(让主窗持续滚动、悬浮窗每帧重绘),主窗照样一个卡顿都没有。这个「零」到底是系统做了隔离,还是负载太轻谁都掉不了,我给不出确定答案(第 7 节会老实交代);但有一件事是确定的:代价没有消失,它换了个地方。同一台 Mate 60 Pro,进程 CPU 占用从事件驱动的约 0% 抬到每帧刷新的约 33%,和实测出来的刷新率 1、11、60fps 对应。帧率的账主窗不背,整台设备的 CPU 背。

所以这篇的问题从「怎么节流防掉帧」变成了三个更实际的问题:

  1. 怎么量:常驻窗口的绘制开销,用什么工具量才有说服力?(顺手辟个谣:网上流传的 hiAppEvent FRAME_DROP 掉帧埋点,在 API 26 的 SDK 里根本不存在)
  2. 量出来是什么:每帧刷新、定时刷新、事件驱动三档,帧率、CPU、内存各是多少?信息密度(放一个数字还是放五个控件)影响大吗?
  3. 工程上怎么办:既然主窗不掉帧,是不是就可以随便每帧刷?当然不是——CPU 是电,是热,是和其他应用抢的资源。节流策略怎么按数据选?

环境声明:以下全部实测数据来自单台 Mate 60 Pro(ALN-AL80,麒麟 9000S),系统 HarmonyOS 7.0.0.107(SP8C00E105R4P3) / API 26 Release(正式版),IDE 为 DevEco Studio 26.0.0(内置 SDK 26.0.0.105 Release)。

可核查性:实验工程 LightLab(com.qingkouwei.lightlab),刷新层代码在 features/sparkperf/,证据(hilog 原文 + 三策略原始采样 JSON + 截图)随工程归档。DisplaySync API 见 @ohos.graphics.displaySync.d.ts(@since 11),hitchs/fpsCount 为 RenderService 的 hidumper 子命令(下文给原文输出)。

2、效果展示与项目结构
2.1 运行效果:一张可热切换的刷新实验台

诊断入口沿用篇 7 的「我的」Tab,新增绘制开销台:三档刷新策略(每帧 frame / 定时 100ms / 事件 event)× 三档信息密度(纯数字 / +进度条 / 全要素),加一行帧探测摘要和一行采样标准提示(pid、两个窗口名、清零命令直接印在面板上,实测时照抄):

HarmonyOS 7 新特性8:闪控窗深潜——三种刷新策略、主窗帧率不动与CPU代价转移真机实测-1.jpg

截图里能看到实验态一切正常:策略=frame、密度=3,诊断报「来源=displaySync 帧数=9480 样本=240 中位=16.7ms」——16.7ms 就是 60fps 的帧间隔,说明 DisplaySync 的每帧回调在悬浮窗这个独立 UIContext 里真的能收到帧信号(这是预检门槛 P1 要回答的问题,后文细说)。

悬浮卡在 frame 模式下分别渲染密度 1(只剩一个大数字)和密度 3(全要素)的样子——E5 实验的两个端点:

HarmonyOS 7 新特性8:闪控窗深潜——三种刷新策略、主窗帧率不动与CPU代价转移真机实测-2.jpg

HarmonyOS 7 新特性8:闪控窗深潜——三种刷新策略、主窗帧率不动与CPU代价转移真机实测-3.jpg

2.2 工程结构:刷新层与状态层解耦

篇 7 把引擎抽成了单例(features/sparkwindow/),这次的刷新实验一行都不动它——状态机、错误码、串行化逻辑全部保持已过审状态,新增的东西全在 features/sparkperf/:

features/sparkperf/
├── SparkRefreshDriver.ets   # 三策略驱动器:frame / timer / event,热切换
└── SparkFrameProbe.ets      # 帧间隔自测:中位/最大/长间隔,来源分账
features/sparkwindow/
└── FloatCardView.ets        # 悬浮卡改造:接策略 + 密度三档分支(E5 自变量)

解耦不只是洁癖:悬浮卡是 setUIContext 挂进独立窗口的内容页,它的刷新行为与主窗组件树是两个 UIContext 的事。刷新层(什么时候重绘)和状态层(重绘时读到什么值)各自有明确的变更理由,改动互不牵连——E1-E9 的每个实验变量都能单独拨动,这本身就是「刷新频率与数据源解耦」这条避坑的工程实现。

3、Kit 能力与核心 API 深度解析
3.1 先辟谣:hiAppEvent 没有 FRAME_DROP

写测量代码前我先查了 @ohos.hiviewdfx.hiAppEvent.d.ts(API 26),把事件常量逐个列出来核对:OS / USER_LOGIN / USER_LOGOUT / DISTRIBUTED_SERVICE_START / APP_CRASH / APP_FREEZE / APP_LAUNCH / SCROLL_JANK / CPU_USAGE_HIGH / BATTERY_USAGE / RESOURCE_OVERLIMIT / ADDRESS_SANITIZER / MAIN_THREAD_JANK / APP_KILLED / APP_HICOLLIE / AUDIO_JANK_FRAME / SCROLL_ARKWEB_FLING_JANK——17 个,没有 FRAME_DROP。

网上不少鸿蒙性能文章(包括一些二手教程)写的「用 hiAppEvent.write 上报 FRAME_DROP 事件统计掉帧」,在这个 SDK 上连常量都不存在。它可能是更早版本的遗留、可能是把别的系统的事件名张冠李戴,也可能干脆是编的——我没法考证来源,只能确认当下写不了。掉帧测量得换路。

3.2 帧率与卡顿的权威测量:RenderService 的两个子命令

真机上验证可用的路径是 hidumper 打 RenderService:

# 按窗口名取卡顿分档计数(累计值)
$ hdc shell hidumper -s RenderService -a "hitchs lightlab0"
 window [lightlab0] Id[6846177869826]:
more than 66 ms       0
more than 33 ms       0
more than 16.67 ms    0

# 全局刷新率分布计数(LTPO 屏 60/120Hz 各档)
$ hdc shell hidumper -s RenderService -a "fpsCount"
Refresh Rate:60, Count:22285;
Refresh Rate:120, Count:40606;

有三点要说。第一,hitchs 是按窗口记账的——主窗 lightlab0 和悬浮窗 float_view_window(窗口名从 hidumper -s WindowManagerService -a '-a' 拿)可以分别查,这正是核心数据(3.4 节)能被量出来的原因。第二,它是累计计数,取增量要用「前后两次读数相减」。RenderService 确实提供清零命令(帮助列表原文:fpsClear |[surface name]/composer fpsClear, clear the fps info、clearFpsCount |clear the refresh rate counts info),但清零的具体时序行为我没实测,所以本篇一律用差值法,不依赖清零。第三,三档阈值 16.67/33/66ms 对应「掉 1 帧 / 掉 2 帧 / 掉 4 帧」,比笼统的「帧率」更有诊断价值:掉帧的严重程度分布直接可见。

CPU 和内存用 top -n 1 -b(toybox 版支持批处理模式,%CPU 在 PID 后第 8 列,别数错位)和 hidumper --mem <pid>(Pss 分栏)。

3.3 DisplaySync:应用侧的帧驱动,不是掉帧事件

@ohos.graphics.displaySync(@since 11,从 @kit.ArkGraphics2D 导出)给了应用一个「每帧回调」:

// ArkTS:DisplaySync 每帧回调(节选,与工程一致,此处省略代次闸门判断)
const sync: displaySync.DisplaySync = displaySync.create();
const range: ExpectedFrameRateRange = { min: 30, max: 120, expected: 120 };
sync.setExpectedFrameRateRange(range);   // ExpectedFrameRateRange 是全局 ambient 类型
this.frameCb = (info: displaySync.IntervalInfo): void => {
  // info.timestamp / info.targetTimestamp,单位纳秒
  this.probe.onFrame(info.timestamp);    // 记帧间隔
  this.sink();                           // 驱动一次重绘
};
sync.on('frame', this.frameCb);
sync.start();

它的定位得先说清楚:它是主动帧驱动,不是被动掉帧通知。我用它做两件事——制造「每帧无条件重绘」的压力档(frame 策略),以及顺手把帧间隔记下来(E6)。另外 off('frame') 不传回调引用会退订该类型全部订阅,和篇 7 讲的 emitter.off 是同一个坑的又一次出现,回调必须存字段。

还有一个对实验设计很要紧的问题:DisplaySync 在悬浮窗这个独立 UIContext 里收得到帧吗?工程里我做了自动降级预案(起帧后两级 3 秒窗口都收不到回调,就切 8ms 高频定时逼近每帧,并把「降级」记进说法)。真机答案是:

15:27:39.492 refresh driver start mode=frame via DisplaySync
15:27:42.496 DisplaySync alive at stage1, keep frame mode

一级窗口内就有真帧回调,不降级。悬浮窗内容页拿帧信号没问题——P1 门槛通过,这条也顺带证明 @StorageProp + @Watch 在独立 UIContext 下能触发(切策略时悬浮窗侧日志 applyMode triggered in float window 每次都出现)。

3.4 主窗帧率不动,代价转移到 CPU——以及一个我必须承认的归因边界

有了 3.2 的尺子,三策略各跑一个采样窗口(真实窗长约 63s,含读数往返开销,见第 7 节边界说明),读主窗 lightlab0 的 hitchs 增量:

策略fpsCount 60Hz 桶增量折算刷新率主窗 hitchs 增量(>16.67/33/66ms)进程 CPU 中位
frame(DisplaySync 每帧)+3780≈60 fps(满帧)0 / 0 / 033.3%(29.6–37.0)
timer(100ms 定时)+672≈10.7 fps0 / 0 / 017.2%(14.8–18.5)
event(秒级数据驱动)+67≈1.1 fps0 / 0 / 00%(0–7.4)

三行数据三个结论,其中第一个我要先泼自己一盆冷水:

  1. 主窗一帧没掉——但「系统做了隔离」这个归因,我没办法验证。 悬浮窗从每秒 1 次重绘拉到每秒 60 次满帧,主窗卡顿计数纹丝不动。我最初的写法是「RenderService 把悬浮窗当独立合成层,重绘不进主窗帧预算」,回头看时被抓了个正着:hitchs 统计的是该窗口自己超阈值的帧,一个不绘制的主窗本来就记不到卡顿——我的三组实验里主窗全程静止,零结果可能只是负载不足,压根没测到「共享预算竞争」这回事。于是我补了一组对照:让主窗持续往复滑动(60 秒约 70 次),分「有 frame 悬浮窗」和「无悬浮窗」两组各跑一遍——两组 hitchs 都是 0。也就是说在这个负载强度下,主窗自己滚动都不掉帧,「零」是平凡结果。把话说准一点:「常驻窗拖垮主窗帧率」的叙事无法复现;「系统做了合成隔离」这一机制本实验既不能证实也不能证伪(缺一个「无悬浮窗时主窗本该掉帧」的阳性对照)。 能确定的只有 CPU 那条线。
  2. 代价在 CPU,且边际递减。刷新率 1→11→60fps,CPU 0%→17%→33%。注意形状:从 1fps 到 11fps 就付出 17 个点,从 11 到 60 只再加 16 个点——大头在第一档,越往上边际越便宜(两段斜率差约 5 倍,是凹形不是线性,我第一版写成「近似线性」,被自己的表格打脸)。帧率的账没人背?不,整台设备背。CPU 是电、是热、是和其他应用抢的调度片——这些都不体现在主窗帧率上,但用户拿手机的手会知道。
  3. fpsCount 增量是刷新率的独立佐证。+3780/63s≈60fps、+672/63s≈10.7fps、+67/63s≈1.1fps,和策略设计值(60/10/1)对得上,说明三档切换真实生效,不是面板自说自话。

悬浮卡常驻的内存底价也量了:STARTED 态 Pss 64071 kB,结束专注销毁后 63377 kB,增量约 694 kB(单次对照,受 GC 时机抖动,当量级参考)。销毁后窗口树里 float_view_window 计数归零,teardown 链路干净。

4、逻辑流梳理
4.1 三策略刷新链路

渲染系统

悬浮窗独立UIContext

数据源

每帧

定时

事件

主窗hitchs无差异

FocusView tick 每秒
pushCard 写 AppStorage

@StorageProp 镜像
countdown/progress

策略开关
floatRefreshMode

frame:DisplaySync
on('frame') 每帧 bump

timer:setInterval 100ms
无条件 bump

@State frameTick++

build 重走
密度1/2/3 子树

RenderService
float_view_window 独立窗口

主窗 lightlab0
hitchs 实测恒0
(含主窗滚动对照仍为0)

进程 CPU
0% → 17% → 33%

event 档不装任何驱动器——数据源自己就是心跳(秒变 → AppStorage → @StorageProp → 局部刷新),这是篇 1 的现状基线。frame/timer 档的 bump 是无条件的:数字没变也强制走一遍 build,刻意制造「无谓重绘」,这样三档才构成干净的对照实验。

4.2 epoch 代次闸门:热切换的安全边界

策略是运行时随便切的,切早了会出事:DisplaySync 的回调从入队到执行有系统调度的时间窗,off 之后仍可能有最后一两个已入队的回调落地;定时器的 clearInterval 同理。这些「旧时代的遗孤」如果继续执行,就会 bump 新策略的 @State、污染新窗口的诊断统计。我的做法是给每次 start/stop 递增一个代次号,所有异步回调进来先对号:

// ArkTS:代次闸门(节选)。旧代次回调一律丢弃,不写诊断也不 bump
private epoch: number = 0;

private startDisplaySync(): void {
  const gen: number = this.epoch;
  const sync: displaySync.DisplaySync = displaySync.create();
  this.sync = sync;                    // create 成功即登记,异常路径才拆得掉
  this.frameCb = (info: displaySync.IntervalInfo): void => {
    if (gen !== this.epoch) {
      return;                          // 已过时代,静默丢弃
    }
    this.probe.onFrame(info.timestamp);
    this.sink();
  };
  sync.on('frame', this.frameCb);
  sync.start();
}

stop(): void {
  this.epoch++;                        // 闸门落下,此后一切旧回调 no-op
  // …clearTimeout/clearInterval/teardownSync 各自容错
}

真机上快速连切 frame→timer→frame→event 四次,日志每次恰好一条 refresh driver start、时间戳严格递增、最终收敛 event,诊断无残留增长——闸门生效。这段代码本身就是代码审查的产物:审查时两条独立意见都指出「已排队回调可跨切换继续写状态」,我最初想用「stop 时把 sink 置 null」补救,权衡后选了代次方案——置 null 只挡 sink,挡不住诊断写入,而且组件重挂载时还得重建闭包,代次闸门一并解决。

5、项目实战
5.1 诊断的统计标准:两个只有真机能暴露的坑

帧诊断(SparkFrameProbe)维护一个 240 样本的环形缓冲,出中位/最大/长间隔三个指标。听起来简单,代码审查加真机实测各逮出一个说法错误。

坑一(四条审查意见全命中):resetWindow() 最初只清环形缓冲,没清累计计数 count。而 P1 降级判定恰恰依赖 totalCount() === 0——只要之前跑过任何策略,切到 frame 后计数永远非零,降级机制永久静默失效。不修的话,DisplaySync 万一在悬浮窗里收不到帧,实验会无声跑零帧、不降级、不报警,整组数据作废。修复就是把 count 和 source 一起归零:

// ArkTS:全量清窗。count/source 不清,P1 判定就废(审查修订,4/4 一致确认)
resetWindow(): void {
  this.gaps = [];
  this.count = 0;
  this.source = 'none';
  this.lastNs = 0;
  this.lastMs = 0;
}

坑二(真机暴露):修完上线,面板打出「中位=17ms 最大=16.667ms」——中位数比最大值还大,自相矛盾。根因是中位数做了 Math.round 而最大值返回原始浮点,16.667 四舍五入成 17,两个指标的舍入标准打架。静态审查抓不到这个(代码逻辑都对,只是格式化不一致),是真机数据一跑就现形。修复统一交给 summary 做 toFixed(1):

// ArkTS:中位数不再 round,与最大值同说法,summary 统一格式化
medianGap(): number {
  if (this.gaps.length < 2) {
    return -1;
  }
  const s: number[] = this.gaps.slice();
  s.sort((a: number, b: number): number => a - b);
  const mid: number = Math.floor(s.length / 2);
  return s.length % 2 === 0 ? (s[mid - 1] + s[mid]) / 2 : s[mid];
}

同类问题还有一个:长间隔计数最初是单调累加、环形淘汰样本时不回退,导致「样本=240(当前窗口)」和「长间隔=全生命周期累计」并列展示。改成对当前环形窗口动态重算,三个指标才真正同窗口。

5.2 E5 信息密度实验:又一个反直觉

固定 frame 策略,切三档密度各跑一个采样窗口(名义 20s,真实约 23s):

密度内容CPU 中位fpsCount 60Hz 桶增量
1纯倒计时数字33.3%+1408
2数字 + 进度条33.3%+1413
3全要素(篇 1 现状)33.3%+1416

三档几乎无差异。 瓶颈是刷新频率本身,不是子树复杂度——DisplaySync 每帧回调固定来,ArkUI 对没有变化的子节点做 diff 复用,多几个 Text/Progress 在「每帧都 bump」面前不构成额外压力。这里得把归因标明白(这是基于当前数据的推断,不是实测结论):内容组织的代价应该只在「事件驱动 + 大子树频繁变化」时显现;frame 模式下这个变量被刷新频率压平了,所以测不出差异——测不出差异本身就是有效结论,它告诉你优化该往哪使劲:砍刷新率,别抠控件数量。

5.3 E6 帧间隔与 LTPO:两个说法别混着说

诊断侧的帧间隔数据:frame 策略稳定期中位 16.667ms(60fps),某几秒跳到 8.3ms(120fps)——DisplaySync 跟随了 LTPO 屏的升降档。但这里得把观察和归因分开说:E6(应用侧 DisplaySync 回调间隔)和 E9(RenderService 侧 fpsCount 合成计数)是两个说法,不能直接画等号。8.3ms 那几秒屏幕升到了 120Hz 档,而 frame 那个采样窗口的 fpsCount 增量全部落在 60Hz 档(120Hz 计数 +0)——不同采样窗口里屏幕档位不同,数据不能跨窗口比。单设备 + 采样窗口代表性,这就是这篇的边界之一。

5.4 E7/E8 尺寸与模板响应:顺手采的两个快数据
  • E7:setWindowSize 传超限值 2268×1702(limits.max 1134×851 的 2 倍)→ Promise ≈4ms resolve,未报错,getWindowProperties 核对实际值夹回 1134×851。附带一个新边界:夹回后的尺寸等于当前尺寸,所以 onRectChange 没有来——这个回调只在实际几何变化时触发,不能拿它当「调用成功」的信号,核对必须主动查属性。
  • E8:switchTemplate 圆角矩形→水平条 → Promise 到回调 ≈2ms,onLimitsChange(max=1134×104) 与 onRectChange(SIZE_CHANGE) 几乎同拍到达。水平条的 max 高只有 104px(圆角矩形 851),两种模板的布局差异巨大,切换前把内容按目标模板重排不是可选项。
5.5 三方案选型对比(用实测数据填)
维度event 事件驱动timer 定时frame 每帧
实测刷新率≈1.1 fps≈10.7 fps≈60 fps(满帧)
主窗 hitchs 代价000(含主窗滚动对照)
进程 CPU≈0%≈17%≈33%
数据实时性秒级(够用,倒计时本来就一秒一跳)100ms帧级
实现复杂度最低(@StorageProp 天然驱动)低(一个 setInterval)中(DisplaySync 生命周期 + 代次闸门)

结论一句话就能说完:倒计时这种秒级数据源,event 档就是正确答案——实时性匹配、CPU 几乎为零、代码最少。timer 和 frame 存在的意义是当对照,量出「多花的每一档刷新率值不值」。如果内容真需要高频动效(进度环补间之类),优先让 ArkUI 动画系统自己驱动属性动画,而不是把应用侧 bump 拉满——动画属性走渲染线程,比每帧回 JS 里改 @State 便宜得多。

6、避坑指南
6.1 hiAppEvent FRAME_DROP 不存在,掉帧测量别照抄网文
  • 现象:按网文写 hiAppEvent 的 FRAME_DROP 事件订阅,常量根本导入不了。
  • 根因:API 26 的 hiAppEvent 事件常量共 17 个,无此事件;网文来源无法考证,可能是旧版遗留或张冠李戴。
  • 方案对比:hiAppEvent(写不了)vs RenderService hitchs/fpsCount(本篇,按窗口分档计数)vs 应用侧 DisplaySync 自测帧间隔(互补)。
  • 最佳实践:掉帧/帧率测量走 hidumper -s RenderService -a 'hitchs <窗口名>',按窗口取、三档阈值;hitchs 是累计值,一律用前后两次读数相减——RenderService 虽有 fpsClear/clearFpsCount 清零命令,但其清零时序行为未实测,不依赖它。
  • 验证:E1/E9 全部数据出自这条路径,原始输出见 docs/evidence/dp8-raw-*.json。
6.2 主窗 hitchs 恒 0 不等于「系统做了隔离」——零结果要先做阳性对照
  • 现象:悬浮窗每帧重绘 60 次每秒,主窗 hitchs 增量为 0,第一反应是「系统做了合成隔离,代价不进主窗账」。
  • 根因(审查抓出):hitchs 统计的是该窗口自己超阈值的帧;一个不绘制的主窗本来就记不到卡顿——三组实验里主窗全程静止,零结果可能只是负载不足,压根没测到「共享预算竞争」。
  • 方案对比:把零结果直接归因隔离(本篇第一版的错)vs 补「主窗持续滚动」的对照组再下结论(本篇修正版)。
  • 最佳实践:零结果(null result)只能说明「在这个负载下没差异」,要归因机制必须先做阳性对照——先证明「无干预时本应掉帧」,否则零结果只是实验不够狠;评估常驻窗口开销,hitchs 之外必须配 top 采 CPU、hidumper --mem 采内存——代价不会消失,只会转移。
  • 验证:补测两组(有 frame 悬浮窗 + 主窗滚动 76 次 / 无悬浮窗 + 主窗滚动 67 次),hitchs 均 0/0/0——「零」确认是平凡结果,隔离归因降级为「无法证实也无法证伪」,CPU 0→17→33% 才是确定证据。
6.3 统计标准混入:清窗不清计数,降级判定永久失效
  • 现象:P1 自动降级(DisplaySync 静默 3s 即切高频定时)在实验室永远不触发。
  • 根因:resetWindow() 只清了环形缓冲,没清累计计数 count;降级判定 totalCount() === 0 读到的是跨策略累计值,热切换后必然大于零。
  • 方案对比:判定改用独立会话计数(多一个状态要维护)vs 清窗全量归零(本篇)。
  • 最佳实践:凡是「窗口 A 的统计要喂给窗口 B 的判定」的结构,清窗必须清全——计数、来源、时间戳基线一个不落;统计函数与判定条件共用的字段,写代码时先画清生命周期。
  • 验证:修复后真机快速连切四次,诊断计数随切换归零重计,无跨窗口污染。
6.4 中位数 round 与最大值不 round,面板自相矛盾
  • 现象:诊断摘要「中位=17ms 最大=16.667ms」,中位大于最大,数学上不可能。
  • 根因:medianGap() 出口做了 Math.round,maxGap() 返回原始浮点,16.667→17 后两指标舍入标准不一致。
  • 方案对比:所有出口统一保留原始值、只在展示层格式化(本篇)vs 每个 getter 各自 round(漏一个就再犯)。
  • 最佳实践:一组要并列展示的统计量,舍入策略必须统一且放在最后一层;单元测试测不出这种错(每个函数单看都对),真机数据跑出来才现形——这也是我坚持每篇必须上真机采证的原因。
  • 验证:修复后复测「中位=8.3ms 最大=8.3ms」,矛盾消失。
6.5 DisplaySync 的 off 与孤儿会话:create 成功就要登记
  • 现象:策略热切换或组件销毁后,偶尔还能看到旧回调在 bump;极端情况下(on/start 抛错的异常路径)帧会话彻底失去引用。
  • 根因:两个结构缺陷——this.sync = sync 写在 start() 成功之后,中间抛错则局部对象无人持有、永远拆不掉;stop() 里 off 和 stop 共用一个 try,off 抛错会跳过 stop 还把引用置 null。
  • 方案对比:try/finally 包一切(粒度粗)vs create 即登记 + teardownSync() 两步独立容错(本篇,off 失败也保证 stop 执行)。
  • 最佳实践:系统资源的句柄,拿到手的瞬间就登记进可清理的位置,别等「初始化全部成功」——异常路径才是泄漏的温床;另外 off('frame') 不带回调引用会退订全部,与 emitter 同坑。
  • 验证:epoch 闸门 + 登记式清理落地后,连切/关窗压测无残留 tick。
6.6 onRectChange 不是「调用成功」的信号
  • 现象:setWindowSize 传超限值,Promise resolve 了,等 onRectChange 核对夹回结果——左等右等回调不来。
  • 根因:E7 实测——超限值 2268×1702 被静默夹回 1134×851,而当前尺寸恰好就是 1134×851,几何没变,系统不发变更回调。onRectChange 的语义是「变了才说」,不是「办完了说」。
  • 方案对比:等回调核对(会死等)vs 主动 getWindowProperties 查询(本篇)。
  • 最佳实践:所有「静默调整」类接口(尺寸夹回、篇 7 的超限探测),核对结果一律用属性查询接口,回调只当增量通知用。
  • 验证:E7 原始日志(dp8-hilog-e7e8.txt):resolve 后无 RECT_CHANGE,getWindowProperties 返回夹回值。
7、总结

回到开头的三个问题。怎么量:hiAppEvent 没有 FRAME_DROP,掉帧走 RenderService hitchs(按窗口、三档阈值、取差值),刷新率走 fpsCount,CPU/内存走 top 和 hidumper --mem。量出来是什么:主窗帧率在这个测试强度下纹丝不动(三策略 hitchs 恒 0,补了主窗滚动对照还是 0),代价转移到 CPU(0/17/33%,边际递减),内存底价约 694kB,信息密度在高频刷新下不构成差异。工程上怎么办:秒级数据源就用事件驱动(event 档),把每帧刷新的冲动留给真正需要帧级动效、且优先交给 ArkUI 属性动画的场景。

这篇给我自己最大的收获有两层。第一层是预设结论被打掉的过程:引子写好了、故事线想好了、连「掉三帧」的数字都准备引用了——数据出来是零。如果当时选择「再测一轮肯定能测出来」,那就是在给自己编素材。第二层更疼:零结果本身也差点被我写成结论。我最初把「主窗不掉帧」直接归因成「系统做了合成隔离」,回头看时一针见血——不绘制的主窗本来就记不到卡顿,我的零结果可能只是负载不足。补了对照实验(主窗持续滚动,有无悬浮窗各跑一遍)之后发现:这个强度下主窗自己滚动都不掉帧,「零」是平凡结果。测不出差异,比测出差异值钱——但前提是你知道自己测不出的是什么:是「没有代价」,还是「实验不够狠」。这两者的分界线,就是阳性对照。

下一篇(深潜篇 9)把闪控窗三部曲收尾:冲突治理与降级——闪控球/画中画的互斥矩阵、1300034 冲突实录、三级降级路径,以及 FLOAT_VIEW 与 SYSTEM_FLOAT_WINDOW 两种悬浮权限的边界(后者误声明会直接导致安装失败,是个高频误区)。在这里插入图片描述

Logo

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

更多推荐