第一次感觉到“循环引用”这个词在不同语言里是两副面孔,是在一次跨技术栈评审上。C++的同学指着调用链说:“这里环了,会泄漏。”旁边写着JavaScript/TypeScript的同学一脸不解:“环就环呗,垃圾回收不是会自动处理吗?”两边都觉得自己是对的,谁也说服不了谁。
其实他们都没有错,只是背后的内存管理模型完全不同。C++默认没有垃圾回收,靠引用计数或手工释放来管理生命周期;而JS/TS跑在带GC引擎的运行时里,标记可达性比计数强引用要粗暴得多,但对“环”的处理逻辑也截然不同。再加上鸿蒙生态里的ArkTS,它在语法上贴近TypeScript,却要跑到ArkTS运行时上,还要经常通过NAPI和C++层打交道,于是循环引用这个问题被混在一起之后,就成了一个非常容易踩坑的概念泥潭。
这篇内容我想把“循环引用”放到C++、JavaScript/TypeScript、ArkTS三种技术栈里逐一拆开,讲清楚它什么时候是内存杀手,什么时候只是纸老虎,什么时候又需要靠工具一层层挖出来。适合听过很多次“循环引用”但始终没系统捋过的同学,也适合正在做跨语言开发、想在写代码阶段就避开这类问题的工程师。
1. C++程序员的循环引用噩梦:引用计数为什么归不了零
1.1 一个最简单的shared_ptr循环示例
C++里最典型的循环引用场景,就是两个对象互相用shared_ptr持有对方。代码长这样:
cpp复制#include <iostream>
#include <memory>
struct Node {
std::shared_ptr<Node> next;
~Node() {
std::cout << "destroy node" << std::endl;
}
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // 注意:这里形成了环
return 0;
}
运行这个程序,你会看到控制台上一行“destroy node”都不会输出。两个Node对象在main结束后并没有被销毁,那段内存就静静躺在堆里,直到进程退出才由操作系统回收。
为什么?因为shared_ptr的析构规则是:只有当引用计数降到0,它才会删除所管理的对象。初始化时a和b的计数都是1,把a->next指向b之后,b的计数变成2;同理,a的计数也变成2。当main函数结束,局部变量a和b被销毁,各自析构一次,两个对象的引用计数都从2降到1,仍然不是0,所以不会走析构逻辑。
这里的本质原因是:每个对象都持有一个对对方的强引用,形成互相等待的僵局。用生活的话说,就像两个人互相欠钱,谁都想等对方先还钱再还对方,结果谁都不会先动,账永远清不了。
1.2 破环的三种武器:weak_ptr、裸指针与所有权重构
C++社区解决循环引用的经典思路,不是“加强回收”,而是“从结构上打掉环”。最直接的办法是引入weak_ptr。它不参与对象的强引用计数,只提供一种“借用”访问能力:
cpp复制struct Node {
std::weak_ptr<Node> next;
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a;
auto sp = a->next.lock(); // 使用时必须 lock()
if (sp) {
// 对象还活着,安全访问
}
return 0;
}
weak_ptr的生命周期语义很明确:它不会让对象“活得更久”。使用前必须调用lock()尝试提升为shared_ptr,如果对象已经被释放,提升会失败,返回空指针。这个机制避免了悬垂引用,也让原本的双向强持有变成了单向强持有加单向弱引用,环自然就断了。
第二种方式是使用裸指针。很多人一听裸指针就皱眉,但在所有权边界清晰的情况下,它恰恰是开销最低的方案。比如父对象用unique_ptr独占持有子对象,子对象只需要保存一个指向父对象的原始指针用来回调。只要父对象的生命周期严格长于子对象,这种设计就是安全的。这里的关键是:裸指针不参与任何生命周期计数,它只是一个“观察者”,不能决定对象何时死亡。
第三种方式是最根本的:重新设计对象图,让所有权关系不再成环。比如一个树形结构,父节点持有子节点的unique_ptr,子节点需要反查父节点时,优先通过一个独立的中间对象或者接口来访问,而不是让子节点反过来强持有父节点。我的习惯是:尽量让代码里的所有权关系构成一个有向无环图,环只能出现在“调用关系”层面,不能出现在“所有权”层面。
1.3 C++里为什么循环引用是“头等大事”
在其他语言里循环引用可能只是一个运行时优化问题,但在C++里它被提升到了非常高的优先级,原因在于C++没有天然的追踪式GC,它的资源释放依赖RAII和确定性析构。RAII是好东西,它让文件、锁、网络连接都能在作用域退出时自动释放,但一旦对象因为引用计数无法归零而永不析构,那么RAII管理的就不只是内存,还有文件描述符、数据库连接、互斥锁等一切资源,这些全都会被一起“冻住”。
再加上C++里大量使用指针和引用,对象之间的图形结构往往比高级语言更复杂,一个环藏在某个回调里,可能在代码评审阶段根本发现不了。所以调试C++的循环引用,通常要借助工具。我常用的是AddressSanitizer的LeakSanitizer,在编译时加上-fsanitize=address,程序退出时它会报告“Leaked”的对象数量和调用栈,能比较快地定位到是谁持有了谁。
另外提醒一句,C++里的循环引用还有第二张面孔:头文件互相包含。两个头文件A和B互相#include对方,会导致编译期出现类型未定义的问题。这种情况一般用前置声明(class B;)加在头文件里声明、在源文件里包含完整定义的方式解决,和运行期的智能指针环不是一回事,但在团队沟通时经常被混在同一句话里说,需要先分清楚讨论的是编译期还是运行期。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JS/TS为何对此不慌:可达性标记如何“绕开”环
2.1 标记-清除的本质:不是数引用,而是找根
JavaScript程序员第一次接触C++的shared_ptr循环引用问题时,第一反应往往是:“你们没有垃圾回收吗?”这句话当然不全面,但也揭示了一个事实:JS/TS生态下的主流GC策略,根本不是靠引用计数来判断对象是否可以回收。
V8等现代JavaScript引擎使用的核心算法是标记-清除(Mark-Sweep)及其各种变体。它的工作方式是从一组称为“根”(GC Roots)的对象出发,沿着对象引用关系把所有能访问到的对象全部标记一遍。这个根集合包括全局对象、当前正在执行函数的局部变量、调用栈上的临时值、寄存器里被引用的对象等等。标记完成后,遍历整个堆,把没有被标记到的对象全部视为垃圾回收。
注意这个逻辑核心:GC并不关心“有没有人引用我”,它只关心“从根出发还能不能到达我”。所以两个对象互相引用甚至一整张引用网互相循环,只要这个环里没有任何一个节点能从根到达,整个环就会被判定为不可达对象,整块回收。
看一个非常直白的JS例子:
javascript复制let objA = {};
let objB = {};
objA.ref = objB;
objB.ref = objA; // 形成循环引用
objA = null;
objB = null; // 断开根的引用
// 此时 objA 和 objB 形成的环已经不可达,下次GC会自动回收它们
如果把这个代码翻译成C++的shared_ptr模型,objA和objB即便都被赋成null,互相之间计数仍然为1,内存是漏的;但在JavaScript引擎里,只要栈上不再有引用指向这个环,GC就能安全收走。这就是两个世界对同一问题的不同答案。
2.2 用实际验证替代口头争论
技术讨论永远要落到实测上。Node.js里可以通过--expose-gc参数把全局的gc函数暴露出来,手工触发一次垃圾回收,配合内存占用观察来验证循环引用是否真的会被回收:
javascript复制// 启动方式: node --expose-gc cycle-test.js
function createCycle() {
const a = {};
const b = {};
a.b = b;
b.a = a;
return a;
}
let keepAlive = createCycle(); // 这里先从函数里拿出来一个对象
keepAlive = null; // 断开根的引用
global.gc();
console.log(process.memoryUsage().heapUsed);
在实际开发中,我并不会用这种方式做精确判断,因为V8堆的占用还受JIT编译、字符串驻留、外部内存等多方面因素影响,单次测量容易误导,但它用来做“循环引用的对象是否还留在堆里”这种定性验证,足够了。你只要在断开引用前和断开后各打一次点,观察在强制GC之后内存是否出现明显回落,就能直观感受到标记-清除对这个环的处理结果。
在浏览器端,Chrome DevTools的Memory面板也支持直接手动触发GC。操作路径是:打开DevTools进入Memory标签,点击左上角的垃圾桶图标强制回收一次,再录制Heap Snapshot,然后在Snapshot里用类名或引用路径查找目标对象。如果在Detached DOM树或者Retainers面板里找不到它,说明引擎已经完全确认它不可达并做了回收。
2.3 TypeScript不改变运行时,但改变你的排雷方式
TypeScript经常被误以为有一套自己的内存管理,这是不对的。TS是纯静态层,编译后产出的就是JavaScript代码,运行时仍然由V8或JavaScriptCore等引擎的GC来管理。因此,TS代码里构造的循环引用对象图,在运行时的回收语义上和JS完全一致。
但TS确实给开发者在“静态层”增加了一道防线。类型标注可以直接暴露对象之间的依赖方向,比如一个IA接口依赖IB接口,而IB接口又反过来依赖IA接口,这种环在代码层面一眼就能扫出来。更重要的是,TS提供了import type这种纯类型导入语法,帮助你在编译期区分“我需要这个模块里的类型”和“我在运行时需要这个模块的对象”。很多所谓的“循环引用导致运行时报错”,其实根本不是内存泄漏,而是两个模块互相import,在模块初始化阶段访问了尚未初始化的绑定变量,出现了类似TDZ的异常。
举一个我在实际项目里踩过的例子。模块A导入模块B中的函数,模块B又导入模块A中的一个常量来初始化配置,结果A在加载过程中执行到某行时,需要访问B导出的对象,但B还没初始化完,于是拿到undefined或者直接抛错。表面看这是一次“循环引用”,本质是模块初始化时序问题,和内存回收没有任何关系。定位方法很简单:在模块顶层不要急于执行有副作用的初始化逻辑,把对对方的依赖延迟到函数调用内部,问题往往就消失了。
3. ArkTS的特殊位置:继承了TS的语法,却又面对C++式的边界
3.1 ArkTS到底算什么:一个带静态约束的TS变体
ArkTS是鸿蒙生态主推的应用开发语言,它的语法以TypeScript为基础做了一套静态约束,去掉了纯动态语言里容易失控的特性,比如any类型、对象字面量的动态扩展等在ArkTS里都受到严格限制。在大部分应用开发场景中,你写ArkTS的体验非常接近于写TS:类型检查严格,IDE补全及时,异步模型也是基于异步函数和Promise这些常见概念。
关键在于运行层面。ArkTS代码运行在ArkTS运行时上,这个运行时自带垃圾回收能力,和V8处理JavaScript一样,能够识别并回收互相引用但整体不可达的对象图。所以,如果你在ArkTS页面里创建了两个类对象,让它们互相持有,然后断开页面根级的引用,对象是会被运行时回收的。这一点上,ArkTS的表现和JS/TS处在同一个阵营。
有些刚接触鸿蒙开发的同学会有一个刻板印象,觉得“ArkTS靠近C++底层,所以内存管理应该像C++一样手动打点”,这是一个很大的误区。ArkTS的GC能力足以覆盖绝大多数单语言侧的循环引用场景。写ArkTS代码时,普通对象之间的环不需要像写C++那样天天想着用weak_ptr,你更应该考虑的是,让业务对象不要被过长的全局引用链意外保活。
3.2 ArkTS与C++通过NAPI握手时,环才是真正危险的
ArkTS真正的内存难点,出现在它和C++代码之间通过NAPI互操作的时候。NAPI是鸿蒙生态里连接ArkTS运行时与C/C++代码的桥,绕不开的核心问题是:两边的内存由各自的管理器负责。ArkTS对象归ArkTS运行时的GC管,C++对象归C++的shared_ptr/unique_ptr/手工删除管。GC能看到的是ArkTS对象之间的引用关系,而C++堆里谁持有谁,GC一无所知。
假设这样一个跨语言环:C++侧有一个shared_ptr强引用了某个ArkTS对象,把这个引用注册成了NAPI的强引用;同时ArkTS侧又通过napi_wrap把一个C++对象的指针包装到了同一个ArkTS对象上,使得ArkTS对象能调用C++对象的方法。两边一旦互相持有,就会出现一个尴尬局面:ArkTS的GC从根出发扫描时发现这个对象被C++侧强引用着,认为自己不能回收;而C++侧的shared_ptr又以为ArkTS对象还在,引用计数永远不为零。结果就是谁都不动手,资源一直被悬挂在内存里。
我在这种跨语言代码中养成了几个习惯,很少再遇到这类悬挂问题:
- 跨NAPI边界时,必须有非常明确的“持有关系”注释。到底哪一侧是owner,哪一侧只是borrower,在写NAPI封装之前就要定下来。
- C++侧持有ArkTS对象时,尽量使用NAPI的弱引用语义,绝对不要为一个生命周期由ArkTS业务控制的回调对象创建强引用,除非你确定有一个对称的时机主动释放它。
- ArkTS侧包装C++对象时,通过
napi_wrap提供的析构回调来管理C++对象的释放,保证ArkTS对象被回收时,C++对象也会被确切销毁,不要依赖另一个语言来帮你做清理。
3.3 ArkTS开发里要重点留意的保活场景
虽然ArkTS自身能处理循环引用,但有一种情况依然会让内存增长:对象确实构成了环,而且环上的某个节点挂在了一个长生命周期对象上,比如全局单例、应用级缓存、UIAbility实例的成员变量。这种情况下,环不会成为垃圾,因为它从根出发是可到达的。对象就不会被回收。
我在鸿蒙应用开发里处理页面跳转或弹窗回调时特别小心。比如一个网络请求回调闭包里捕获了页面级对象,而页面对象又持有这个请求实例,在网络请求较长时间未完成时,页面对象实际上会被一直保活。这本质上是“从根可达的环”导致的内存滞留,和C++里的循环引用有相近的现象描述,但从根因上说是另一码事——根没断,GC不会来收拾。
要避免这种问题,建议遵循一条简洁口径:回调闭包里不要长期捕获UI组件对象,凡是需要跨页面生命周期持久的监听者,都用生命周期感知接口去管理。ArkTS框架本身就提供了不少可感知生命周期的宿主,尽量把资源释放的时机绑定在这些宿主上,而不是自己写一套路径难追踪的公共单例里。
4. 即便在GC语言里,循环引用导致内存上升的几个真实案例
4.1 根对象与缓存:不是GC的错,是引用链没断
很多从纯C++转过来的工程师以为,只要用了JS/TS/ArkTS这套有GC的运行时,就可以完全无视循环引用。这个结论在单进程的局部对象里成立,但一旦涉及全局作用域、缓存、事件监听,循环引用引发的内存问题会换一副面孔重新出现。
举一个我自己排查过的例子。前端页面里曾经有一个模块缓存Map,结构大致如下:
javascript复制const cache = new Map();
function openDocument(doc) {
const wrapper = {
doc,
close() {
wrapper.doc = null;
}
};
cache.set(doc.id, wrapper);
return wrapper;
}
这里wrapper.close这个闭包通过wrapper变量引用自身,而cache又强引用着wrapper。如果业务侧打开文档后只调用了close()却没有从cache里删除这个键,那么被缓存的wrapper和doc对象就会一直驻留在内存里。close()置空内部的doc看似在释放,实际上闭包对wrapper的引用和cache对它的引用仍然存在,整个对象图从根可到达,GC不会认为它是垃圾。
排查后你会发现,根因不是“循环引用让垃圾回收失效”,而是“缓存项缺少漏出机制”,即业务代码没有在合适的时机把它从Map中删除。这也解释了一个很常见的误区:GC语言里循环引用很少造成直接泄漏,但循环引用加长生命周期容器,就很容易造成不易察觉的内存上升。
4.2 事件监听器与DOM节点:一条隐藏的强引用边
浏览器环境里有一类典型的循环引用保活场景:DOM节点上挂了一个事件监听器,而监听器闭包里又捕获了DOM节点或者它的父对象。旧版IE因为使用引用计数经常在这里泄漏,现代浏览器虽然用标记-清除避开了直接泄漏,但如果你把监听器挂在一个长期存在的对象上,比如全局事件总线或者某个单例状态管理器,那么被监听的目标对象就会被持续引用,永远不会进入回收名单。
实际开发中典型形态是:
javascript复制class Banner {
constructor() {
if (window.bannerInstance) {
window.bannerInstance.destroy();
}
window.bannerInstance = this;
this.handleResize = () => this.resize();
window.addEventListener("resize", this.handleResize);
}
destroy() {
window.removeEventListener("resize", this.handleResize);
window.bannerInstance = null;
}
}
这个类看起来有destroy方法做清理,但如果不调用destroy,每次new Banner都会在window上挂一个新的resize监听,旧Banner因为被自己的箭头函数捕获、又被window上的实例引用保活,导致同类实例越积越多。很多人定位出的现象是“反复创建组件后内存持续上涨”,最后会错怪循环引用,其实问题出在没有对称解绑。
4.3 由闭包形成的隐式环:最容易被误解的保活链
闭包是JavaScript里最隐蔽的隐式引用制造者。一个闭包能捕获其词法作用域内的变量,即使外部函数已经执行完毕,只要闭包还被某个根引用,那些被捕获的变量和对象就仍然存活。当闭包之间互相引用、同时又引用大对象树时,就可能形成一张从根上无法切断的网。
一个我遇到过很多次的最小化模型如下:
javascript复制function createBigDataFlow() {
const bigData = new Array(1024 * 1024).fill("x");
const handler1 = () => {
return handler2;
};
const handler2 = () => {
return bigData;
};
return handler1;
}
const leakedHandler = createBigDataFlow();
// leakedHandler 引用了 handler2,handler2 引用了 bigData 和 handler1 词法环境中的 bigData
// 整个大数据块被一个根引用保活
这里真正的问题是:业务代码只需要handler1的部分能力,但因为闭包作用域链的设计,“handler1 -> handler2 -> bigData”这条链路天然被闭包捕获并保留。如果把闭包返回给外部,或者塞进某个全局数组,bigData就无法被释放。解决方案通常是把大粒度的数据访问改成显式参数传递,或者拆分闭包的作用域,让内部函数不要捕获无关的大对象。
这类问题给人最深的教训是:在GC语言里,你以为你“没有引用它了”,其实一个看不见的闭包环境还在替你抓着它。排查时要借助开发工具查看对象的“Retainers”(持有链),而不是只看自己的代码里有没有显式赋值。
4.4 这些案例被误判为“循环引用泄漏”的原因
把上面这些场景放在一起复盘,会发现一个共性:它们确实都存在循环引用,但循环引用本身不是导致内存上升的最终原因,最终原因是整个对象环仍然挂在根对象上,没有断开出口。
这个区分非常重要,能帮你决定下一步怎么做。如果根因是找不到断点、所有代码路径都让它无路可退,那应该考虑加追踪式GC或引入更显式的资源释放协议;如果根因只是开发者在某处漏写了一行清理代码,那就该老老实实把根断开,而不是去改造GC或者逃避使用闭包。
我在帮团队排查时通常先问三句话:
- 这个对象的根引用在哪?是全局变量、事件系统、缓存容器还是某个单例?
- 它有没有提供销毁或释放的方法?有没有人调用过?
- 每次泄漏时的对象数量是否呈现线性增长?是否对应业务打开关闭次数?
把这三个问题理清,80%的保活问题都能定位到“链路断在何处”,很少需要上升到“语言机制缺陷”。
5. 一线排查工具的横向对比:从C++堆栈到JS堆快照的思维差异
5.1 C++侧:LeakSanitizer与valgrind
C++排查循环引用,第一推荐的工具是LeakSanitizer。它在Linux/macOS和主流编译工具链里都能启用,只需要在编译命令行加上:
bash复制g++ -fsanitize=address -g cycle_test.cpp -o cycle_test
./cycle_test
一旦程序退出时还有未释放的堆内存,它会直接打印类似“LeakSanitizer: detected memory leaks”的摘要,并带出分配点的调用栈。对于循环引用这种“对象一直活着所以不触发析构”的情况,LeakSanitizer的粒度最合适,因为它能检测出进程退出后仍然存活的对象是从哪段代码new出来的。
如果遇到跨线程或长期驻留的服务型进程,LeakSanitizer可能不太方便,因为服务进程不会正常退出,那就改用valgrind的massif或memcheck做采样分析。valgrind的缺点是慢,实测CPU开销会放大10到20倍,不适合跑大规模单元测试,但在稳定复现的疑难问题上,它给出的完整分配栈和引用关系是其他工具很难替代的。
5.2 JS/TS侧:Heap Snapshot里看Retainers链
JavaScript侧遇到疑似循环引用,我几乎不会去猜,直接抓Heap Snapshot。在Node.js里可以通过--inspect启动服务,然后在Chrome DevTools的Memory面板连接;在浏览器里直接原地录制就行。录完Snapshot后,在上方Class Filter搜索疑似泄漏的类名,点开对象实例就能看到完整的Retainers链,也就是“谁在持有它”。
这条链路能给的信息量很大。如果Retainers链里最后指向一个全局对象或者某个单例容器,问题就很清楚了:业务方的某个逻辑把对象注册进了全局区域,但没有对应的注销。如果Retainers链显示为两个对象互相持有,但链路顶部已经断开、没有进入根集合,GC其实已经能处理,这种对象一般不会出现在Snapshot里,出现了也多半会在强制GC后消失。
实际操作中最容易忽略的一步是:录制快照前先手动触发一次GC,把已经不可达的垃圾清空。否则Snapshot里堆满了各种待回收的空壳对象,干扰排查视线。
5.3 ArkTS/DevEco生态:用Profiler和代码审查双轨推进
ArkTS侧目前可以参考DevEco Studio自带的Profiler能力,它提供内存分配与内存占用趋势,可以观察对象数量变化和分配热点。不过相较于浏览器生态的Heap Snapshot,ArkTS的运行时工具链还在快速演进中,某些深度引用路径的UI不如Chrome DevTools那么直观。我自己的做法是双轨并行:
- 工具侧:用Profiler记录内存增长曲线,判断泄漏发生在哪个操作节点,再结合日志确认对象创建和释放是否成对出现。
- 代码侧:重点审查NAPI边界和全局单例,所有跨语言持有点必须写清楚生命周期规则,并安排定期的代码评审抽查。
对于ArkTS应用来说,我的总经验是:绝大多数“疑似循环引用”的内存爬坡,最终都回到两个不变量上——全局容器有没有及时清理,跨语言的强引用有没有显式释放。工具负责发现问题,代码审查负责把问题消灭在提交前。
5.4 什么时候该怀疑循环引用,什么时候不该
判断思路可以这样收束:
- 在纯C++的shared_ptr场景里,循环引用是优先怀疑对象,尤其是对象图复杂、释放日志缺失时。
- 在纯JS/TS/ArkTS的局部对象场景里,循环引用默认不是元凶,先看根引用链是否真的断开了。
- 在涉及缓存、事件监听、全局单例和跨语言持有的场景里,循环引用只是“中间剧情”,真正要盯住的是那些长生命周期入口。
这里我把三者的排查思路做成一个对照表,方便以后直接当checklist用:
| 场景 | 优先怀疑对象 | 首选排查工具 | 处理思路 |
|---|---|---|---|
| C++ shared_ptr无序互持 | 强引用环 | LeakSanitizer / valgrind | 引入weak_ptr或重构所有权 |
| C++头文件互相包含 | 编译期类型未定义 | 编译器报错信息 | 前置声明隔离 |
| JS/TS局部对象环 | 多为误报 | Heap Snapshot强制GC | 验证断根后是否回收 |
| JS/TS全局缓存/监听器 | 根引用未断 | Retainers链定位 | 补对称清理 |
| ArkTS纯应用对象 | 根引用或生命周期宿主 | DevEco Profiler | 修正全局单例持有方式 |
| ArkTS+NAPI跨语言对象 | 双向强引用悬挂 | 日志+代码审查 | 弱引用或显式释放 |
6. 我的编码纪律:让“所有权关系”永远无环
和循环引用斗了几年后,我最大的心得是:不要等出了内存问题再谈循环引用,而是要在设计阶段就定义一个简单可执行的口径。
我在三种语言里都遵守一条底层纪律:调用关系可以有环,所有权关系必须无环。 C++里,这也是现代C++用unique_ptr替代裸指针和shared_ptr的大方向之一。谁创建资源,谁就是那个资源的唯一owner,owner可以安全地把引用传递给别的模块,但如果其他模块需要长期持有这个对象,必须回到owner去申请,而不是自己复制一份所有权。JS/TS和ArkTS没有这么强的所有权限定,但你在架构层面仍然可以定义每个对象的“生命周期宿主”。比如这个对象属于页面级还是应用级,该由哪个模块负责释放,跨模块传出去的引用是否要回收,这些想清楚再动手写,通常能避免大量保活问题。
还有一个很实用的建议:给对象图分层。View层可以持有Presenter层,Presenter层可以持有数据模型,但反过来数据模型不要持有页面引用。如果确实需要通知页面刷新,通过事件总线或回调接口传递,让依赖方向永远朝着稳定的底层,而不是在业务对象之间编织出密密麻麻的网状互引。
踩过几次坑之后,我现在看到代码里出现两个类互相包含对方的实例字段,第一反应不是去查GC怎么处理环,而是先问一句:这里该不该用弱引用,或者这两个类是不是根本应该拆分?工具和GC都只是兜底手段,真正可靠的是把对象之间的依赖方向梳理清楚,让每一个引用都能找到一个合理的生命周期归属。这样一个简单的习惯,在纯C++、纯JS/TS、纯ArkTS,以及它们互相混合的工程里,都能省下后面大量的排查时间。
