ArkWeb 手记 12:Web 组件内存泄漏排查实录
去年冬天我们上线了一个电商详情页,上半截是原生的商品图和价格,下半截用 Web 组件嵌了一段 H5 的商品评价区。功能一切正常,测试同事却提了个很怪的单:同一个商品来回进出二十次,手机会发热,进场动画从顺滑变成一格一格地跳,最后干脆整页白屏。
我们第一反应都是往"Web 组件本身有毛病"上靠。升级 SDK、换内核参数、关掉预加载、加一层缓存清理,全都试了一遍,内存曲线该涨还是涨。
真正把问题按住,是在 DevEco Profiler 里看到那一条停不下来的引用链之后。今天这篇就把这次排查从头到尾复盘一遍:先看现象怎么确认,再拆清 Web 组件的引用关系,然后逐个过一遍真实踩到的泄漏场景,接着讲 Profiler 怎么抓、抓完怎么读,最后是修复代码和验证方式。跟着做完,你应该能自己排查自家工程里那个"越用越卡"的页面。
一、越用越卡,先别急着怪内核
排查内存问题的第一原则:不要凭手感猜,要拿到能对比的数字。症状描述得再详细,也不如两次快照的差值有说服力。
我做的第一件事是给页面加了一圈最朴素的生命周期日志,看看到底是"页面被创建了太多次",还是"页面只创建了一次但里面的东西没还回去"。
ts
// DetailPage.ets
import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct DetailPage {
private controller: webview.WebviewController = new webview.WebviewController();
@State url: string = 'https://shop.example.com/reviews';
aboutToAppear(): void {
console.info('[Mem] aboutToAppear 页面进入');
}
aboutToDisappear(): void {
console.info('[Mem] aboutToDisappear 页面离开');
}
build() {
Column() {
Web({ src: this.url, controller: this.controller })
.javaScriptAccess(true)
.onPageEnd(() => {
// 注意:内核还活着时,这个回调会被反复触发
console.info('[Mem] onPageEnd 收到页面加载完成');
})
}
.width('100%')
.height('100%')
}
}
aboutToAppear 和 aboutToDisappear 是 UI 组件自己的生命周期,写在里面只是为了确认页面结构确实走了"进入 → 离开"这一轮。真正有意思的是 onPageEnd。
执行结果是:日志里 aboutToAppear 出现二十次,aboutToDisappear 也出现二十次,数量和预期一致。但 onPageEnd 在页面已经离开之后还会零星冒出来,而且每次进出之后,日志里 [Mem] 这个标签出现的频率都在变高。
这里传递了一个很关键的信息:页面的 ArkTS 结构确实被销毁了,可是 Web 的内核线程、JS 引擎上下文还在后台跑,还在回调 ArkTS 侧的方法。既然它还能回调,说明它手里还握着 ArkTS 对象的引用。ArkTS 的垃圾回收只会回收"没人引用"的对象,只要引用链还在,那棵对象树连同它引用的图片、字符串、Bitmap 就都留在内存里。
这就引出了下一个问题:这条链到底长什么样。

二、Web 组件的引用关系其实有四层
很多人把 Web 当成一个普通组件,觉得它和 Text、Image 一样,页面销毁它就跟着没了。它确实会跟着没了,但中间有一串东西未必跟着没。
从 ArkTS 往内核方向数,大概是这么四层:
第一层是你的页面组件树。@Entry 页面 → 自定义组件 → Web 组件,这一层受 ArkTS 的组件生命周期管理,aboutToDisappear 之后就该退场。
第二层是 WebviewController。它是 ArkTS 侧的一个句柄,本身很轻,但它内部挂着一个指向原生内核的绑定。麻烦在于这个句柄经常被我们主动"藏"起来------塞进全局 Map、挂到单例上、传给工具类,于是它的存活时间就和页面对不上了。
第三层是原生内核,也就是 NWeb 那部分。它跑在独立线程上,负责渲染、网络、JS 执行。只要内核没有收到"停止"的信号,它就认为任务还在继续。
第四层是 JS 引擎上下文和桥接对象。javaScriptProxy 注册进去的对象会被挂到 H5 的 window 上,H5 里一句 window.nativeBridge.addToCart() 就能调到 ArkTS 的方法。这条链是双向的,反过来说,JS 上下文活着,nativeBridge 这个对象就必须活着,它内部持有的任何 ArkTS 引用也就都活着。
对照着看就清楚了:ArkTS 的 GC 只认第一层,后三层是原生世界和 JS 世界的地盘,需要我们用代码明确告诉它"可以收摊了"。

三、场景一:桥接对象反向把 UI 组件焊死
这是我们那次真正的主因,也是最容易犯的错。
写桥接对象的时候,为了让 H5 能回调 ArkTS 侧刷新 UI,很自然地就把页面实例传进去:
ts
// ReviewBridge.ets
import { DetailPage } from './DetailPage';
export class ReviewBridge {
private host: DetailPage; // ← 反向持有 UI 组件,泄漏的种子
constructor(host: DetailPage) {
this.host = host;
}
addToCart(goodsId: string): void {
this.host.cartCount = this.host.cartCount + 1;
}
getHostName(): string {
return this.host.constructor.name;
}
}
然后注册:
ts
@Entry
@Component
struct DetailPage {
private controller: webview.WebviewController = new webview.WebviewController();
@State cartCount: number = 0;
build() {
Column() {
Web({ src: 'https://shop.example.com/reviews', controller: this.controller })
.javaScriptProxy({
object: new ReviewBridge(this),
name: 'reviewBridge',
methodList: ['addToCart', 'getHostName'],
controller: this.controller,
})
}
.width('100%')
.height('100%')
}
}
javaScriptProxy 的 object 会被序列化成 JS 侧的代理对象,methodList 里的方法名在 H5 侧直接可调。执行起来完全正常,H5 点一下"加入购物车",原生角标就加一。
危险就藏在那句 private host: DetailPage 里。引用链变成了:JS 上下文 → reviewBridge → ReviewBridge 实例 → DetailPage 实例 → 整棵组件树。页面离开以后,第一层被销毁了,但 ReviewBridge 还活着,它手里那个 host 就是最后一根钉子,整棵子树被钉在内存里。
常见错误有两个。一个是把 methodList 传成空数组,心想"以后再加方法",结果方法一个都调不到,白排查半天。另一个是在 aboutToAppear 里注册、在 aboutToDisappear 里忘了注销,第二次进页面时同名注册会报错,错误信息往往只说"注册失败",看不出根因。
判断方法很简单:只要桥接对象里出现了 this.host = host 这种写法,就要打起精神,它必须配一段明确的注销逻辑。
四、场景二:控制器被单例和缓存攥住
第二个坑更隐蔽,因为它看起来像"优化"。
详情页的 Web 加载有点慢,有人提议做一层控制器池,第二次进页面直接复用,省掉初始化时间:
ts
// WebPool.ets
import { webview } from '@kit.ArkWeb';
export class WebPool {
private static cache: Map<string, webview.WebviewController> = new Map();
static put(key: string, controller: webview.WebviewController): void {
WebPool.cache.set(key, controller);
}
static take(key: string): webview.WebviewController | undefined {
return WebPool.cache.get(key);
}
// 少写这个方法,池子就变成了泄漏池
static release(key: string): void {
const controller = WebPool.cache.get(key);
if (controller !== undefined) {
controller.stop();
WebPool.cache.delete(key);
}
}
static size(): number {
return WebPool.cache.size;
}
}
Map 是静态的,生命周期等于进程。里面的 key 是页面路由名,早晚会重复,controller 就一直被 cache 这个 Map 持有。
执行结果很明显:进出页面二十次,WebPool.size() 返回 20,一个都没少。每个 controller 背后是一个完整的内核实例,内存不涨才怪。
同类问题还有很多变体:把 controller 存进 AppStorage、挂到 AppStorage.setOrCreate 之外的自定义单例上、塞进 @State 之外的模块级变量、甚至传给一个长生命周期的网络工具类当"回调上下文"。判断标准只有一条------这个引用的宿主是不是比页面活得久。是的话,就得配一条对等的释放路径。
修复思路不是"不要缓存",而是"缓存的每一项都要有明确的失效时机"。页面 aboutToDisappear 里调一次 WebPool.release(routeName),池子的存在才有意义。
五、场景三:定时器和事件监听没人收尾
这类泄漏不专属 Web 组件,但只要页面里有 Web,就会被放大。
ts
@Entry
@Component
struct DetailPage {
private controller: webview.WebviewController = new webview.WebviewController();
private timerId: number = -1;
aboutToAppear(): void {
// 每秒让 H5 回到顶部,产品要的"动态感"
this.timerId = setInterval(() => {
this.controller.runJavaScript('window.scrollTo(0, 0)');
}, 1000);
}
aboutToDisappear(): void {
clearInterval(this.timerId); // 少了这一句,定时器会一直持有 this
this.timerId = -1;
}
build() {
Column() {
Web({ src: 'https://shop.example.com/reviews', controller: this.controller })
}
.width('100%')
.height('100%')
}
}
setInterval 返回的是个数字 id,它被 ArkTS 的定时器模块统一管理,回调闭包会捕获 this。页面离开后如果定时器还在跑,那个闭包就一直活着,this 指向的页面实例也就一直活着。更糟的是它每秒还会执行一次 runJavaScript,而控制器已经和组件解绑,日志里会持续刷 17100001 这类"控制器未绑定组件"的错误。
很多同学看到 17100001 就去查绑定顺序,其实根因是定时器没清。修复就是在 aboutToDisappear 里 clearInterval,并且顺手把 id 复位,避免重复注册。
事件监听同理。controller.on('xxx', cb) 这类注册,配对的 controller.off('xxx', cb) 必须在同一处写死,别指望框架帮你收。我的习惯是:凡是出现 on、setInterval、register、subscribe 的地方,同一个函数里必须能看到对应的 off、clearInterval、unregister、unsubscribe,看不到就说明这里欠了一笔账。
六、Profiler 抓取:把"猜"换成"看到"
前面三个场景都是我在代码里读出来的,但真实项目里代码几十万行,靠读是读不完的。这时候要靠 DevEco Profiler 把"怀疑对象"变成"证据"。
抓内存快照有一套固定流程,照着做结果才可比。
第一步,用真机跑,不用模拟器。模拟器的内存模型和真机差别太大,快照里的数字没参考价值。
第二步,DevEco Studio 底部打开 Profiler,选 Memory 面板,再选 ArkTS Heap 或者 Native Heap。查桥接对象和页面实例用 ArkTS Heap,查内核和渲染缓存用 Native Heap,这次两个都要看。
第三步,建立基线。连续进出页面三次,回到首页,静置五秒,点一下面板上的手动回收按钮,然后抓第一份快照,命名为 A。
第四步,制造差异。再连续进出页面二十次,回到首页,同样静置五秒、手动回收、抓第二份快照,命名为 B。
第五步,用面板上的对比功能把 B 和 A 做 Diff,按类名排序,看每个类的实例数和 retain size 增长了多少。
第六步,点开增长最猛的那个类,看它的引用路径,从叶子一路往上读,直到读到一个"本来不该还活着"的对象。
那次我的 Diff 结果里,ReviewBridge 的实例数从 3 涨到了 23,DetailPage 也是同样的增量,retain size 加起来接近 40MB。点开引用路径,顶端就是 NWeb 的 JS 上下文,往下是 reviewBridge,再往下就是 host。到这里,前面的推测就变成结论了。

七、修复:onDestroy 里到底该放什么
定位到主因,修复反而简单。难点在顺序。
ts
@Entry
@Component
struct DetailPage {
private controller: webview.WebviewController = new webview.WebviewController();
private bridge: ReviewBridge | undefined = undefined;
@State cartCount: number = 0;
aboutToAppear(): void {
this.bridge = new ReviewBridge(this);
}
aboutToDisappear(): void {
// 1. 先拆桥:注销 JS 侧挂载的对象
this.controller.deleteJavaScriptRegister('reviewBridge');
// 2. 再停内核:停止加载、停止渲染
this.controller.onInactive();
this.controller.stop();
// 3. 最后松手:断开 ArkTS 侧的反向引用
this.bridge = undefined;
WebPool.release('/detail');
}
build() {
Column() {
Web({ src: 'https://shop.example.com/reviews', controller: this.controller })
.javaScriptProxy({
object: this.bridge,
name: 'reviewBridge',
methodList: ['addToCart', 'getHostName'],
controller: this.controller,
})
}
.width('100%')
.height('100%')
}
}
deleteJavaScriptRegister 的参数是注册时用的名字,不是对象本身。它把 JS 侧的挂载点摘掉,nativeBridge 这个全局对象就不再引用 ArkTS 实例了。
onInactive 配合 stop 一起用,前者让 Web 组件进入非活跃状态、停止音视频和渲染回调,后者中断当前加载任务。只 stop 不 onInactive 时,某些场景下内核线程还会挂着渲染循环。
顺序很重要。如果先 stop 再 deleteJavaScriptRegister,控制器已经不再绑定组件,注销调用会抛 17100001,等于没注销。这个错误信息很误导人,我第一次就是把两句写反了,白白多查了一轮。
还有一个常见误区:把释放逻辑写进 aboutToRecycle。那个回调只在组件被列表复用、或者开了 NodeContainer 回收的场景下触发,普通页面栈里根本不会调用。看起来写了释放,实际上一次都没执行。
如果是用 Navigation 或 NavDestination 做页面栈,还要注意 onPageHide 不等于销毁。页面进入后台只是隐藏,组件和你持有的所有引用都还在,这时候做释放会出问题;释放仍然要等 aboutToDisappear 或者路由栈真正 pop 掉。

八、弱引用与回调清理的两种写法
前面把桥接对象直接置空,能解决"对象拿着页面"的问题。但如果页面里注册了大量回调,靠一个个手动置空太容易漏。这时候有两种更稳的写法。
第一种是引用托管。所有回调统一进一个中心,注册发号,用完删号:
ts
// CallbackHub.ets
export type H5Callback = (payload: string) => void;
export class CallbackHub {
private static seq: number = 0;
private static map: Map<number, H5Callback> = new Map();
static add(cb: H5Callback): number {
const id = ++CallbackHub.seq;
CallbackHub.map.set(id, cb);
return id;
}
static remove(id: number): void {
CallbackHub.map.delete(id);
}
static fire(payload: string): void {
CallbackHub.map.forEach((cb: H5Callback) => cb(payload));
}
static aliveCount(): number {
return CallbackHub.map.size;
}
}
页面离开时把这一页注册过的 id 全部 remove,map 里的闭包自然被回收。aliveCount() 还能直接当回归指标用,进出二十次以后它应该回到 0。
第二种是弱引用。ArkTS 提供了 WeakRef,它持有的引用不阻止垃圾回收:
ts
export class WeakHolder {
private ref: WeakRef<DetailPage>;
constructor(page: DetailPage) {
this.ref = new WeakRef<DetailPage>(page);
}
touch(): void {
const page = this.ref.deref(); // 已被回收时返回 undefined
if (page === undefined) {
return;
}
page.cartDirty = true;
}
}
deref() 每次都可能是 undefined,所以拿到之后必须判空,这是用弱引用最容易忽略的一点。很多人写了 this.ref.deref().cartDirty = true,平时不报错,一旦对象被回收就崩在空指针上。
要强调一点:WeakRef 只解决"我这边不拦着回收",不解决"JS 那边还挂着代理"。桥接注册必须单独注销,两件事不能互相替代。我见过只把持有关系改成弱引用、注册照旧不注销的写法,内存一样往上涨,因为真正的钉子还在 JS 上下文里。

九、验证:怎么证明它真的不漏了
改完代码不验证,等于没改。内存问题尤其如此,因为改错的代价要到线上才暴露。
我用的验证办法是埋一个极轻的探针,把对象存活数量打出来:
ts
// PageProbe.ets
export class PageProbe {
static created: number = 0;
static released: number = 0;
static onEnter(tag: string): void {
PageProbe.created = PageProbe.created + 1;
console.info(`[Probe] ${tag} enter, alive=${PageProbe.created - PageProbe.released}`);
}
static onExit(tag: string): void {
PageProbe.released = PageProbe.released + 1;
console.info(`[Probe] ${tag} exit, alive=${PageProbe.created - PageProbe.released}`);
}
static summary(): string {
return `created=${PageProbe.created}, released=${PageProbe.released}, alive=${PageProbe.created - PageProbe.released}`;
}
}
onEnter 放在 aboutToAppear,onExit 放在 aboutToDisappear,在首页加个隐藏按钮打印 summary()。
判据不是"alive 必须等于 0",而是"alive 必须收敛"。路由栈里可能还有一个页面挂着,缓存策略也可能刻意留一两个实例,所以进三十次、退三十次之后,alive 应该稳定在一个小数上,比如 1 或 2,而不是随着进出次数线性增长。改之前我的数字是 20 往上,改之后稳定在 1。
再补一轮 Profiler 快照对比:同样的三进三出抓 A,二十进二十出抓 B,ReviewBridge 和 DetailPage 的 Diff 应该是 0 或者个位数。如果还有增长,回到第六步的方法读引用路径,重复一遍就能收敛。
有个细节值得说一下:做好这些释放之后,第二次进页面的首屏会稍微慢一点点,因为内核不再复用了。这个取舍是划算的------用几百毫秒的加载换一个不会 OOM 的页面,线上表现要稳得多。真的想省时间,用 Web 组件自己的缓存模式去省网络请求,别用常驻内核去省初始化。

十、小结与一个马上能动手的任务
这次排查的收获,与其说是修好了三个 bug,不如说是换了一套思路。
内存泄漏在 ArkWeb 里几乎都是一个模式:某个 ArkTS 对象被一个"活得更久"的宿主引用住了。桥接对象被 JS 上下文引用、控制器被静态 Map 引用、闭包被定时器引用,形式不同,本质一样。
所以排查顺序永远是:先用 Profiler 拿到两份可比快照,Diff 出实例数增长的类,再顺着引用路径往上读,找到那个不该活着的宿主。找到之后,修复方法往往只有几行,难的是找对地方和写对顺序------先拆桥、再停内核、最后松手。
三条经验可以直接抄走。桥接对象里不要出现 this.host = 页面实例,改成回调表或者弱引用。凡是缓存,必须配一条对等的释放路径,缓存的每一项都要有失效时机。凡是 on、set、register,同一个文件里必须能看到配对的 off、clear、unregister。
留个小任务给你:挑自家工程里一个带 Web 组件的页面,把 PageProbe 那段代码加进去,连续进出三十次,记录 alive 的最终值。如果它大于三,就开 Profiler 抓两份快照做 Diff,把增长最多的三个类名和它们的引用路径记下来。这三个类名,基本就是你下一次的优化清单。