# 〖共创稿事节〗HarmonyOS 7 新特性实战(42):给 3D 地图接上泄漏检测,先让检测器抓到一个真的

地图页有个不太体面的特点:每挂载一次,就去解析一份 1.7MB 的 GLB;卸载一次,销毁一次。理论上这是一个闭环,实际上"理论上"三个字不值钱——Scene.load() 是异步的,aboutToDisappear() 是同步的,这两件事谁先谁后,代码说了不算。

所以要上仪器。系统能力里有一套 JS 泄漏检测(@ohos.hiviewdfx.jsLeakWatcher,API 12 起分步提供,24 起多了一个单调用版本),我把它的两种模式都接到了地图页上,然后跑了自检、跑了二十轮进出循环。过程中实测出来的几条行为,文档里没有或者写得含糊,记下来。

官方 API 参考:@ohos.hiviewdfx.jsLeakWatcher(核对日期 2026-10-28)

一、"没泄漏"这句话,先别急着说

check() 的返回值是个字符串,实际有三种形态:

返回含义
'[]'检测器在跑,登记过的对象都回收了
''(空串)检测器没在跑:没 enable,或设备不支持
其它 JSON有对象被判定泄漏

前两种在 if (!report) 这种判断里长得一模一样。这不是抬杠:一个没启动的检测器和一次干净的检查,报出来的东西一样,"没泄漏"和"我没在查"混在一起,整份报告就没有可信度了。

什么情况下会拿到空串?两种:忘了调 enable(true),或者设备压根不支持这套能力——它挂在 SystemCapability.HiviewDFX.HiChecker 下面,设备能力查询不过关时调用不会炸,就是安静地不干活。所以探针启动时先做一次能力判断,不支持就明确记录"这台设备上这套仪器不存在",而不是装作查过了。

探针里对这两种情况分开打日志,空串直接告警写明"检测器没在跑":

report = jsLeakWatcher.check();
if (report.length === 0) {
  hilog.warn(TAG, 'check returned EMPTY — detector is not running');
  return report;
}
const leaked: boolean = report !== '[]' && report !== '[ ]';
if (leaked) {
  hilog.warn(TAG, 'check LEAK: %{public}s', report);
} else {
  hilog.info(TAG, 'check clean');
}

二、两套接口,我都要

这个模块有两套用法。手动四步:enable 打开,watch 登记对象,check 取快照,dump 落盘。自动一次调用:enableLeakWatcher(API 24)配上一个配置对象,系统自己周期性跑 GC、比对、落盘。

手动四步自动一次调用
能盯什么任意 ArkTS 对象只有五类组件(CustomComponent / Window / NodeContainer / XComponent / Ability)
谁排 GC你自己系统按 checkInterval
结果形态check() 当场返回 JSON达到阈值落盘文件,回调给文件名
适合"跑完一轮就要一个结果"要一条泄漏时间线

Scene 不是组件,手动模式是唯一能盯住它的路子,所以它做主;自动模式也开一份,失败就降级:

private static enableAutoMode(): void {
  try {
    const config: jsLeakWatcher.LeakWatcherConfig = {
      monitorObjectTypes: jsLeakWatcher.MonitorObjectType.ALL,
      checkInterval: 3000,        // 默认 30 秒,验收等不到
      fgLeakCountThreshold: 1,    // 默认 5,抓单条泄漏永远不触发
      dumpHeapWaitTimeMs: 2000,   // GC 后等 2 秒再 dump,别把待回收算成泄漏
      maxStoredHeapDumps: 4       // 堆快照很大,只留最近几份
    };
    jsLeakWatcher.enableLeakWatcher(true, config, (files: Array<string>) => {
      hilog.warn(TAG, 'auto mode dumped: %{public}s', JSON.stringify(files));
    });
  } catch (err) {
    hilog.warn(TAG, 'enableLeakWatcher unavailable: %{public}s', JSON.stringify(err));
  }
}

这四个配置项都在跟"多久出结果"和"会不会漏抓"较劲。SDK 默认值是给常驻监控设计的,30 秒一轮、攒满 5 条才落盘,用在验收场景就是"跑完了什么都没等到"。

三、先种一个必漏的对象

接入完先别测业务。check() 返回 [] 的前提是检测器真的在跑,这件事得先证明。自检种两个对象:

const anchor: Object = new Object();
jsLeakWatcher.watch(anchor, 'probe-self-test');
selfTestAnchor.push(anchor);      // 静态数组锚住,GC 收不走
const ephemeral: Object = new Object();
jsLeakWatcher.watch(ephemeral, 'probe-ephemeral');   // 登记完就丢,不留引用

一个锚住(必然被判泄漏),一个不锚(如果检测器正常,它应该被回收、不被报)。冷启后隔 5 秒、10 秒、40 秒各查一轮——前两轮看"抓没抓到",第三轮拉长到 40 秒,是为了排除"只是 GC 还没跑到那个代"的侥幸:如果 40 秒里多次 GC 都收不走它,那才是板上钉钉的泄漏。

启动自检:锚住的对象三轮都在,不锚的从未出现

结果两条,都是实测:

1. 锚住的对象三轮全部报出,hash 稳定——链路是通的,check() 能抓到真的。2. 不锚的对象三轮都没出现——watch() 本身不钉对象,登记一个用完就丢的东西不会把它变成泄漏。

第二条很重要。如果 watch 持强引用,那登记过的对象永远活着,报告里全是自己制造的证据,这套 API 就没法用了。实测它用弱引用,干净。

四、清单只增不减

自检还带了一个后续实验:锚住的对象在第一轮被判泄漏之后,把静态数组清空(应用侧不再持有任何引用),再查两轮。

结果它还在,hash 原样,拆除 40 秒后依然在列。期间 check() 每次都会触发 GC,对象早就不被应用引用了。

也就是说这份清单是累积的:对象一旦在某轮被判过泄漏,之后每轮都带着它。这直接决定了验证"修完不漏"的操作方式——必须在干净的进程里跑,同一个进程里清单只增不减,第二轮的 clean 永远等不来。

对日常使用的影响也顺带清楚了:手动模式是一次性排查工具,不是常驻监控。排查时登记几个怀疑对象、跑一轮生命周期、读一次清单、完事;要是把生产对象长期 watch 着,清单会混进大量"曾经被判过、现在早没事了"的历史记录,真信号反而被淹没。常驻监控该走自动模式那套阈值和落盘。

五、dump 的代价:主线程 2147 毫秒

第一版实现里,check() 一发现泄漏就顺手 dump() 落盘。结果应用冷启 5 秒后卡死了一阵,XCollie 的记录是这样的:

一次 dump 卡住主线程 2147ms,第二次 2786ms

两次 dump 分别把主线程卡了 2147ms 和 2786ms(uv_timer_task 就是自检那个 5 秒定时器)。dump 要同步写泄漏清单和 VM 堆快照到磁盘,堆快照是几十 MB 级别的文件。另有一次跑得更慢,直接超过 3 秒,FreezeDetector 报了 THREAD_BLOCK_3S。

结论很简单:dump 不能挂在 check 里自动跑,只能留给"日志已经确认有泄漏、需要留档分析"的时刻,由人决定什么时候付这笔钱。改完之后 check() 只读不写,循环测试里再没出现过卡顿。

这也顺带回答了"探针能不能常开":GC 周期性触发加堆快照落盘,这些开销属于排查期,不属于运行时。探针由一个编译期常量控制,默认关,验收时临时打开。

六、真正要防的:孤儿 Scene

仪器就位,现在说仪器要盯的东西。修复前的地图组件是这样:

aboutToAppear(): void {
  this.loadScene();                    // 异步,不等待
}

private async loadScene(): Promise<void> {
  const loaded: Scene = await Scene.load($rawfile('myth_map_relief_v1.glb'));
  this.scene = loaded;
  // ...配置环境、找相机节点
}

aboutToDisappear(): void {
  this.scene?.destroy();               // 组件销毁时 Scene 还没回来
  this.scene = null;
}

时序拆开看:Scene.load 还在解析时用户退出了地图,aboutToDisappear() 先执行——此刻 this.scene 还是 null,那句 destroy() 什么都没做。然后 GLB 解析完成,this.scene = loaded 把一个 Scene 挂到已经销毁的组件上。没有任何人再会销毁它:native 侧一份 GLB 的贴图和网格,ArkTS 侧一个 GC 能看到的引用。进出一次,漏一份。

修复是两个字段的纪律:

aboutToAppear(): void {
  this.disposed = false;
  this.loadScene();
}

private async loadScene(): Promise<void> {
  const loaded: Scene = await Scene.load($rawfile('myth_map_relief_v1.glb'));
  if (this.disposed) {
    loaded.destroy();                  // 组件已经走了,当场销毁孤儿
    return;
  }
  this.scene = loaded;
  jsLeakWatcher.watch(loaded, 'myth-map-scene');   // 一造出来就登记
  // ...
}

aboutToDisappear(): void {
  this.disposed = true;                // 回调靠这个判定
  this.scene?.destroy();
  this.scene = null;
}

watch 必须在对象刚造出来的时候登记——等它漏了再补,检测器不认识它。

顺带一提,同一个文件里另一条异步路径早就带着同款守卫:投影标定那趟流程也是先取一个修订号、await 完成后核对"组件期间有没有被销毁或重标定"。那条路守了,这条路漏了。同一类问题在同一段代码里只修一半,大概是最常见的泄漏来源。

七、模拟器上连 3D 都没有

修复写完,到模拟器上复现。第一轮就撞上一个更基本的问题:地图一直是平面形态,日志里每次都是 scene load failed。

查系统日志,根因在更下面一层:模拟器镜像里 /system/lib64/module/graphics/libscene_napi.z.so 不存在,@ohos.graphics.scene 原生模块加载失败,Scene.load() 直接 reject,地图组件按设计回落到平面图。也就是说这台模拟器从来就没渲染过 3D 地图。

地图覆盖层打开后的 2D 兜底形态,提示条写着立体渲染暂不可用

先排除自己的嫌疑:把探针开关关掉重装,场景照样加载失败——不是检测器把引擎搞坏的。这个降级路径本身就是有价值的一手资料:地图组件的 catch 分支真实工作过,界面回落到平面,没有白屏。

代价是竞态没法在模拟器上自然撞上。做法是加了一个默认为 0 的常量,在 Scene.load 之前垫一段人为延迟:

if (SHANHAI_DEV_SCENE_LOAD_DELAY_MS > 0) {
  await new Promise<void>((resolve: () => void) => {
    setTimeout(resolve, SHANHAI_DEV_SCENE_LOAD_DELAY_MS);
  });
}

设成 8000 之后,"挂载 → 用户能收起"这段窗口稳稳罩住一次未完成的加载——云幕散开在两秒出头,场景加载回来要到八秒多,中间六秒都够按一次返回,不用掐秒。正常设备上 GLB 解析远快于用户操作的速度,这个竞态撞上的概率低——但低概率不等于不存在,挂载和销毁谁先谁后,得让代码保证,而不是让用户手速保证。

八、二十轮进出:clean

模拟器上能跑的验证是这一条:探针开着,走完完整的开关流程——点"在地图上选",云幕过渡后覆盖层挂载(加载失败,回落平面),点选区域,停留后自动收起,云幕合拢后组件卸载。卸载后隔 3 秒取一次快照。

探索页与地图入口

自动化跑了 20 轮,check(map-unmount) 报了 19 条 clean,零泄漏、零空串(第 20 条的 3 秒延迟落在抓取日志之后)。每轮的流程是:滚动到地图入口、重新定位按钮坐标(选完区域面板会重排,写死坐标第三轮就飘了)、点击、等云幕过渡完成、点选一个区域、等 900 毫秒停留加 700 多毫秒的合拢闸门。整个循环没有任何快捷方式,走的和用户手点完全同一条路。

这轮验证的口径要说明白:模拟器上场景加载走的是 catch 分支,没有 Scene 可漏,所以它证明的是检测器在跑、且不误报——没有登记过的对象不会出现在清单里。把本轮所有实验汇总:

实验结果说明
自检(锚住对象)三轮全报,hash 稳定链路通,能抓到真的
自检(不锚对象)三轮均不报watch() 弱引用,不钉对象
锚点拆除后复查+5s / +40s 仍在列清单累积,验证要开新进程
check() 顺带 dump()主线程卡 2147 / 2786 ms已改成手动触发
20 轮地图进出19 条 clean,零误报检测口径有效(无 Scene 场景)
探针关闭对照场景照样加载失败模拟器缺 3D 模块,排除探针嫌疑

"Scene 正确回收"这个命题本身,要等真机。

九、真机上跑完了

验证在手机上做(HBN-AL80,API 26,3D 可用),还是三个包:修复前加延迟的复现包、修复后加延迟的验证包、常量状态的回归包。模拟器上 Scene.load 直接 reject,竞态复现只能靠真机。

先交一次学费。复现包里我给加载加了 8 秒延迟,原计划「云幕散开后按返回」,落在 6 秒的窗口里。第一轮跑完,日志里六次 scene ready 一次不少——每轮都等到了加载完成才退出,竞态根本没撞上。原因很直接:延迟不光推迟加载,也把云幕揭幕推迟到了 8.8 秒,"散开后按返回"永远晚于加载完成。正确的时机是数 3 秒、云幕还盖着就按返回。

改时机的过程中又撞出一个应用层的 bug:云幕过渡期间按返回没反应。返回键链路是通的,onBackPress 收到了、showWorldMap 也翻成 false 了,但云幕过渡的守卫一看"过渡进行中"就把关闭意图丢了。正常包云幕只盖一秒多,没人会在这十几秒里按返回;延迟 8000 之后云幕要盖九秒,一按就撞。修法是关闭意图撞上过渡时不再丢弃:作废旧过渡的全部定时器,覆盖层当场卸载,直接揭幕退场。

三包实测结果:

包轮次scene readycheck 清单内存
A:守卫摘除8 轮,全部撞上竞态卸载后仍打 ×87 个 hash 逐轮累积,观测窗口内不释放+39.2MB,约 4.9MB/轮
B:守卫在6 轮,全部撞上竞态0 条(孤儿当场 destroy)6 轮全 clean+18.2MB
C:常量回归24 轮正常进出24 次全走通前 9 轮涨到 9 条,之后清零,0~3 条波动+17.7MB,约 0.74MB/轮

A 包的日志里有一条值得单独看:组件卸载之后,scene ready=true camera=true 还是打出来了——加载回来时组件早死了,没人拦它。第六节的守卫防的就是这件事,防住了就是 B 包:零条 ready,清单零新增。

「check 报出」和「真泄漏」的关系,真机数据给了更完整的说法。C 包走的是正常路径,每次 destroy 的 Scene 也被 check 暂时报成 LEAK,清单一度涨到 9 条——但一两分钟内 GC 清零,之后反复出现 clean。A 包孤儿的清单在同样窗口内一条都不放。所以单看一条 LEAK 说明不了什么,「清单只增不减」加上「内存阶梯上涨」,两个都占才能定性。

内存还有个诚实的备注:B 包每轮剩 3MB 上下,比 C 包的 0.74 高——加载中途 destroy,native 侧未必能全释放,这部分在 jsLeakWatcher 的观测范围之外。守卫管的是它防得住的那部分:孤儿不再挂到死组件上,加载流程当场掐断,JS 侧清单零新增。

Logo

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

更多推荐