1. 从一次内存泄漏事故说起:游戏对象与资源管理到底在管什么
三年前我接手过一个上线两个月就频繁闪退的休闲游戏项目。崩溃日志指向内存耗尽,但排查下来发现场景里同时存在的对象数量并不夸张。真正的问题出在资源管理上:每次切换关卡,旧的贴图、音效、预制体引用都没有被正确释放,而游戏对象本身又持有这些资源的强引用,导致垃圾回收器根本回收不掉。这个坑让我重新审视了一个看似基础却极其致命的话题——游戏对象与资源管理。
如果你正在做游戏开发,或者准备深入引擎底层,这篇文章会帮你把这块知识彻底理清楚。我会从游戏对象的本质讲起,拆解组件系统和ECS架构的设计逻辑,再深入到资源加载、引用计数、生命周期管理这些实操层面的细节。不管你是刚接触Unity的新手,还是已经写过几个小项目的开发者,这里面的内容都能让你对引擎的运作方式有更清晰的认识。
游戏对象与资源管理,说白了就是回答两个问题:场景里的东西怎么组织和更新?硬盘上的素材怎么加载和释放?前者关系到代码架构和运行效率,后者关系到内存安全和加载性能。这两个问题处理不好,轻则帧率波动,重则闪退崩溃。而Unity ECS(Entity Component System)之所以成为热词,正是因为它在解决传统面向对象架构在大规模对象场景下的性能瓶颈。
2. 游戏对象的本质:从面向对象到数据驱动的演进
2.1 传统OOP游戏对象模型的设计逻辑与局限
早期游戏引擎普遍采用面向对象的方式建模游戏对象。一个角色就是一个类,继承自一个基类,基类里包含位置、旋转、缩放这些通用属性,子类再扩展自己的特有行为。这种设计直观、符合人类认知习惯,写起来也顺手。但它有一个根本性的问题:继承层次越深,耦合越严重,而且内存布局极其分散。
我举个具体的例子。假设你有一个GameObject基类,然后Character继承它,Player继承Character,Enemy也继承Character。当引擎需要遍历所有对象更新位置时,它拿到的是一个基类指针数组。每个指针指向的内存地址是分散的,CPU缓存命中率极低。更麻烦的是,如果你想让一个原本不是角色的对象(比如一个可破坏的木箱)也拥有生命值,你只能把它塞进Character继承链里,或者复制一份生命值逻辑。这就是经典的“菱形继承”和“代码复用困境”。
Unity早期的GameObject-Component模式其实是对纯OOP的一种改良。它用组合替代了继承:GameObject本身是一个空壳,所有功能都通过挂载Component来实现。Transform、Renderer、Collider、自定义脚本,都是组件。这样做的好处是灵活,你可以给任何对象挂任何组件。但问题依然存在:每个Component是独立的对象,在堆内存中分散分配,遍历时的缓存效率依然不理想。而且Unity的GameObject和Component都是托管对象,GC压力大。
2.2 组件系统:组合优于继承的工程实践
组件系统的核心思想很简单:把功能拆成独立的、可复用的模块,然后按需组合。一个游戏对象不再是一个庞大的继承树节点,而是一个容器,里面装着若干组件。位置信息放在Transform组件里,渲染信息放在MeshRenderer组件里,碰撞信息放在Collider组件里,行为逻辑放在自定义脚本组件里。
这种设计带来的最大好处是解耦。你可以单独修改渲染逻辑而不影响碰撞逻辑,可以给任何对象添加生命值组件而不需要它继承自某个特定基类。从工程角度看,这极大地提高了代码的可维护性和复用性。
但组件系统也有它的代价。首先是组件之间的通信问题。如果一个脚本需要访问另一个脚本的数据,通常要用GetComponent来获取引用。这个操作在Unity中是有开销的,虽然引擎做了一些缓存优化,但在每帧调用的场景下依然需要谨慎。我一般的做法是在Awake或Start阶段把需要的组件引用缓存到成员变量里,避免在Update里反复调用GetComponent。
其次是更新顺序的不确定性。Unity的组件更新顺序默认是不保证的,如果你有多个脚本需要在同一帧内按特定顺序执行,必须通过脚本执行顺序设置或者事件机制来管理。我踩过的一个坑是:在A脚本的Update里修改了某个状态,然后在B脚本的Update里读取这个状态,结果因为执行顺序问题导致逻辑偶尔出错。后来我把这类跨脚本的帧内通信改成了显式的事件派发,问题才彻底解决。
2.3 ECS架构:数据导向设计如何解决性能瓶颈
ECS(Entity Component System)是近几年越来越火的一种架构模式,Unity的DOTS(Data-Oriented Technology Stack)就是它的典型实现。ECS的核心思想是把数据和行为彻底分离,并且按照数据导向的方式组织内存。
在ECS中,Entity只是一个ID,不包含任何数据。Component是纯数据,没有任何方法。System是纯逻辑,负责处理拥有特定Component组合的Entity。这种设计的关键在于:相同类型的Component在内存中是连续存储的。当你有一个System需要遍历所有拥有Position和Velocity的Entity时,它访问的内存是紧凑排列的,CPU缓存命中率极高,可以充分利用SIMD指令进行并行计算。
我实测过一个场景:用传统GameObject方式更新一万个对象的移动,帧率大概在40-50帧左右波动;换成ECS方式后,同样数量的对象,帧率稳定在200帧以上。这个差距在移动端和大规模场景中尤为明显。
但ECS不是银弹。它的学习曲线陡峭,调试困难,而且对于逻辑复杂的游戏对象(比如需要大量状态机和交互逻辑的角色),用ECS表达起来会非常别扭。我的建议是:如果你的游戏有大量同质化的简单对象(比如弹幕、粒子、大量NPC),ECS是绝佳选择;如果对象数量少但逻辑复杂,传统组件系统反而更合适。混合使用也是一种常见策略,Unity本身也支持GameObject和ECS共存。
3. 资源管理的核心机制:加载、引用与释放
3.1 资源加载的三种方式与选型依据
Unity中加载资源主要有三种方式:Resources.Load、AssetBundle和Addressables。每种方式都有它的适用场景和坑。
Resources.Load是最简单的方式,直接把资源放在Resources文件夹里,代码里传路径就能加载。但它的缺点非常致命:Resources文件夹里的所有资源都会被无条件打包进安装包,不管你有没有用到。这意味着如果你的Resources文件夹里放了几百兆的贴图,安装包就会大几百兆。而且Resources.Load是同步加载,大资源会卡主线程。我一般只在原型阶段或者极小的项目里用它,正式项目基本不用。
AssetBundle是Unity传统的资源打包方案。你可以把资源按需打包成独立的包,运行时从本地或远程加载。它的灵活性很高,但管理起来也复杂:需要自己处理依赖关系、版本控制、加载和卸载。我见过不少项目因为AssetBundle依赖没处理好,导致同一个贴图被打进多个包,包体膨胀。或者因为卸载时机不对,导致资源被重复加载或者提前释放。
Addressables是Unity后来推出的资源管理系统,底层还是AssetBundle,但封装了依赖管理和引用计数。你只需要给资源打上地址,然后通过地址加载,系统会自动处理依赖和引用。它的异步加载接口也很友好。我现在的项目基本都用Addressables,虽然它也有一些坑(比如引用计数在某些边界情况下会出错),但整体上比手动管理AssetBundle省心太多。
3.2 引用计数与垃圾回收:资源什么时候才能真正释放
资源释放是资源管理中最容易出问题的地方。Unity的资源分为托管资源和非托管资源。托管资源(比如GameObject、Component、自定义的C#类)由Mono的GC管理;非托管资源(比如贴图、网格、音频剪辑的底层数据)由Unity引擎管理,不受GC控制。
当你调用Destroy销毁一个GameObject时,它持有的对贴图、网格的引用会被释放,但底层资源并不会立即从内存中卸载。Unity使用引用计数来管理这些资源:每个资源有一个引用计数,当计数归零时,资源才会被真正卸载。问题在于,引用计数的维护并不总是符合直觉。
我遇到过一种情况:一个贴图被一个已经销毁的GameObject引用着,但因为某个静态变量还持有这个GameObject的引用,导致GameObject没有被GC回收,进而导致贴图的引用计数不归零,内存一直不释放。这种问题排查起来非常痛苦,因为从表面上看,场景里已经没有这个对象了。
解决这类问题的关键是理解引用链。我一般会用Unity的Memory Profiler工具来抓取内存快照,查看每个资源的引用来源。如果发现某个资源被意外持有,就顺着引用链往上找,通常能找到那个“忘记置空”的静态变量或者事件监听。
对于Addressables,它内部维护了一套引用计数系统。每次LoadAssetAsync会增加计数,每次Release会减少计数。当计数归零时,资源会被卸载。但要注意:如果你加载了同一个资源多次但没有对应释放,计数就不会归零。我建议在项目里封装一层资源管理器,统一处理加载和释放,避免散落在各处的Load和Release调用。
3.3 资源生命周期与场景切换的配合策略
场景切换是资源管理的高危时刻。旧场景的资源需要释放,新场景的资源需要加载,如果处理不当,很容易出现内存峰值过高导致闪退。
我的做法是把资源分为三类:全局常驻资源、场景资源和动态资源。全局常驻资源(比如UI图集、通用音效)在游戏启动时加载,全程不释放。场景资源在进入场景时加载,离开场景时释放。动态资源(比如关卡中掉落的装备图标)按需加载,用完立即释放。
场景切换时,我会先异步加载新场景的资源,等加载完成后再卸载旧场景的资源,最后切换场景。这样可以避免新旧资源同时存在于内存中的峰值。Unity的SceneManager.LoadSceneAsync支持allowSceneActivation参数,可以控制场景激活的时机,配合资源预加载非常有用。
还有一个细节:Unity在场景切换时默认会调用Resources.UnloadUnusedAssets,但这个操作是同步的,而且会遍历所有资源,开销很大。如果场景资源量大,这一下可能会卡好几秒。我的做法是手动控制卸载时机,在加载界面或者过场动画期间调用UnloadUnusedAssets,用异步操作配合加载进度条来掩盖卡顿。
4. 实操:搭建一个可复用的资源管理模块
4.1 模块设计目标与接口定义
说了这么多原理,接下来我带你搭一个实际可用的资源管理模块。这个模块的目标是:统一加载接口、自动引用计数、支持异步加载、防止重复加载、提供加载进度回调。
先定义核心接口。我设计了一个IResourceManager接口,包含以下方法:
public interface IResourceManager { // 同步加载 T Load<T>(string address) where T : Object; // 异步加载 Task<T> LoadAsync<T>(string address) where T : Object; // 释放 void Release(string address); // 预加载 Task PreloadAsync(string label); // 清理未使用资源 Task UnloadUnusedAsync(); }接口设计的关键是地址抽象。不管底层用的是Addressables还是AssetBundle,上层业务代码只认地址。这样将来换资源系统时,只需要替换实现类,业务代码不用动。
4.2 引用计数器的实现细节与线程安全
引用计数器是这个模块的核心。我用一个Dictionary<string, int>来记录每个地址的引用次数,再用一个Dictionary<string, Object>来缓存已加载的资源。
private readonly Dictionary<string, int> _refCounts = new(); private readonly Dictionary<string, Object> _cache = new(); private readonly object _lock = new(); public T Load<T>(string address) where T : Object { lock (_lock) { if (_cache.TryGetValue(address, out var cached)) { _refCounts[address]++; return cached as T; } } var asset = Addressables.LoadAssetAsync<T>(address).WaitForCompletion(); lock (_lock) { _cache[address] = asset; _refCounts[address] = 1; } return asset; }这里有几个细节需要注意。第一,加锁是必要的,因为异步加载的回调可能在多线程上执行。第二,WaitForCompletion会阻塞主线程,只适合小资源或者加载界面使用。第三,缓存的是Object类型,取出时需要做类型转换,如果类型不匹配会返回null,调用方需要处理。
释放逻辑同样需要加锁:
public void Release(string address) { lock (_lock) { if (!_refCounts.ContainsKey(address)) return; _refCounts[address]--; if (_refCounts[address] <= 0) { _refCounts.Remove(address); if (_cache.TryGetValue(address, out var asset)) { _cache.Remove(address); Addressables.Release(asset); } } } }注意:引用计数减到零时,一定要先从缓存中移除,再调用
Addressables.Release。否则如果在释放过程中又有新的加载请求进来,可能会拿到一个正在被释放的资源。
4.3 异步加载与进度反馈的完整实现
异步加载是提升体验的关键。我用Task来封装Addressables的异步接口,同时提供进度回调:
public async Task<T> LoadAsync<T>(string address, Action<float> onProgress = null) where T : Object { lock (_lock) { if (_cache.TryGetValue(address, out var cached)) { _refCounts[address]++; return cached as T; } } var handle = Addressables.LoadAssetAsync<T>(address); while (!handle.IsDone) { onProgress?.Invoke(handle.PercentComplete); await Task.Yield(); } var asset = handle.Result; lock (_lock) { if (_cache.ContainsKey(address)) { // 竞态条件:另一个请求已经加载了同一个资源 _refCounts[address]++; Addressables.Release(asset); return _cache[address] as T; } _cache[address] = asset; _refCounts[address] = 1; } onProgress?.Invoke(1f); return asset; }这段代码处理了一个容易被忽略的竞态条件:两个异步请求同时加载同一个地址时,第二个请求完成时发现缓存里已经有了,此时应该释放自己加载的那份,并增加已有资源的引用计数。如果不处理这个情况,就会导致资源被加载两次,内存里有两份副本。
4.4 资源卸载时机与内存峰值控制
资源卸载的时机选择直接影响内存峰值。我的策略是在场景切换的加载界面期间执行卸载,具体流程如下:
- 显示加载界面,开始异步加载新场景所需的资源包
- 资源包加载完成后,调用
UnloadUnusedAsync清理旧资源 - 等待清理完成,激活新场景
- 隐藏加载界面
UnloadUnusedAsync的实现需要遍历所有缓存中的资源,检查引用计数是否为零,然后释放:
public async Task UnloadUnusedAsync() { List<string> toRelease; lock (_lock) { toRelease = _refCounts.Where(kv => kv.Value <= 0).Select(kv => kv.Key).ToList(); } foreach (var address in toRelease) { lock (_lock) { if (_cache.TryGetValue(address, out var asset)) { _cache.Remove(address); _refCounts.Remove(address); Addressables.Release(asset); } } await Task.Yield(); } await Resources.UnloadUnusedAssets(); }每释放一个资源后await Task.Yield()是为了把释放操作分散到多帧,避免单帧卡顿。虽然这样会让卸载过程变长,但在加载界面期间用户感知不到,反而比一次性卡死要好。
5. 常见问题与排查技巧实录
5.1 资源重复加载与内存泄漏的排查方法
资源重复加载是最常见的问题之一。表现是内存持续增长,但场景里的对象数量并没有增加。排查方法如下:
首先用Memory Profiler抓取两个时间点的内存快照,对比哪些资源数量增加了。如果发现同一个贴图有多个实例,那就是重复加载了。然后检查加载代码,看是否有地方绕过了资源管理器直接调用了Addressables.LoadAssetAsync。我一般会在项目里加一条规范:所有资源加载必须走资源管理器,禁止直接调用底层接口。配合代码审查或者静态分析工具,可以有效杜绝这类问题。
内存泄漏的排查更复杂一些。除了资源本身的引用计数,还要检查是否有事件监听没有取消、是否有静态集合持有对象引用、是否有协程在对象销毁后还在运行。我遇到过一个经典案例:一个UI面板在关闭时没有取消对某个全局事件的监听,导致面板对象一直被事件系统持有,面板上的贴图也就无法释放。后来我在面板的OnDestroy里统一取消了所有监听,问题解决。
5.2 加载卡顿与异步加载的常见陷阱
异步加载并不等于不卡顿。如果在一帧内同时发起大量异步加载请求,Addressables的内部调度依然可能造成主线程卡顿。我的经验是控制并发加载数量,一般不超过5个。可以用一个加载队列来管理,超出的请求排队等待。
另一个陷阱是WaitForCompletion的滥用。有些开发者为了图方便,在异步接口外面套一层WaitForCompletion变成同步调用,结果在主线程上阻塞等待,帧率直接掉到个位数。如果确实需要同步加载,至少要在加载界面或者过场动画里做,不要在游戏进行中调用。
还有一个细节:Addressables的PercentComplete在某些情况下会跳变,比如从0.3直接跳到1.0。如果用它做进度条,会出现进度条突然满格的情况。我的做法是用一个平滑函数对进度值做插值,让进度条看起来更自然。
5.3 场景切换时资源释放的典型错误
场景切换时最常见的错误是过早释放资源。比如在旧场景还没完全卸载时就释放了共享资源,导致新场景加载时找不到资源而报错。我的做法是给资源管理器加一个“场景锁”机制:在场景切换期间,所有释放操作被延迟到切换完成后执行。
另一个错误是忘记释放场景资源。Unity在切换场景时不会自动释放通过Addressables加载的资源,需要手动调用Release。如果忘记释放,这些资源会一直留在内存里。我建议在场景的根对象上挂一个脚本,在OnDestroy里统一释放该场景加载的所有资源。
下面这张表总结了我遇到过的典型问题及解决方案:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 内存持续增长 | 资源重复加载 | Memory Profiler对比快照 | 统一加载入口,禁止绕过管理器 |
| 对象销毁后内存不释放 | 事件监听未取消 | 检查OnDestroy中的取消逻辑 | 统一在OnDestroy中取消所有监听 |
| 场景切换卡顿 | 同步卸载大量资源 | Profiler查看卸载耗时 | 异步分散卸载,配合加载界面 |
| 加载进度条跳变 | PercentComplete不连续 | 打印每帧进度值 | 对进度值做平滑插值 |
| 资源加载失败 | 地址错误或依赖缺失 | 查看Addressables事件日志 | 检查地址配置和依赖打包 |
5.4 避坑清单:我踩过的五个典型坑
第一个坑:在Update里调用GetComponent。这个操作每次都会做一次组件查找,开销不小。正确做法是在Awake里缓存引用。
第二个坑:用Resources.Load加载大资源。同步加载会卡主线程,而且资源常驻内存无法释放。正式项目应该用Addressables。
第三个坑:忘记释放AssetBundle。手动管理AssetBundle时,AssetBundle.Unload(false)和Unload(true)的区别很关键。false只释放AssetBundle对象本身,不释放加载出来的资源;true会连资源一起释放,但如果有其他对象还在引用这些资源,会导致引用丢失。我一般用false,然后手动管理资源的释放。
第四个坑:在协程里等待异步加载完成时,没有处理对象被销毁的情况。如果协程所属的GameObject在加载完成前被销毁了,回调里访问该对象会报空引用。我的做法是在协程开始时记录一个标志,在回调里先检查标志再继续。
第五个坑:Addressables的引用计数在异常情况下会出错。比如加载过程中抛出异常,计数可能没有正确增加,但释放时却减少了,导致计数变成负数。我的做法是在资源管理器里加一层保护:释放时如果计数已经为零,记录警告并忽略。
6. 从组件到ECS的迁移策略与性能对比
6.1 什么情况下值得从传统组件系统迁移到ECS
ECS不是万能的,迁移成本很高。我判断是否值得迁移的标准有三个:对象数量是否超过五千、对象逻辑是否足够简单同质、性能瓶颈是否确实在对象更新上。
如果游戏里只有几百个对象,用传统组件系统完全够用,迁移到ECS反而增加复杂度。如果对象逻辑复杂(比如每个对象有独特的状态机和交互逻辑),ECS的纯数据组件表达起来很吃力。只有当对象数量大、逻辑简单、且Profiler确认瓶颈在对象更新时,才值得考虑ECS。
我做过一个弹幕游戏,同屏子弹数量峰值超过两万。用传统方式,每颗子弹是一个GameObject,帧率直接掉到20以下。迁移到ECS后,帧率稳定在60帧。这个场景就非常适合ECS。
6.2 混合架构:GameObject与ECS共存的实践方案
完全用ECS重写整个游戏是不现实的。更实际的方案是混合架构:核心玩法对象用GameObject,大量同质化对象用ECS。
Unity的DOTS提供了GameObjectConversion系统,可以把场景中的GameObject自动转换为Entity。但自动转换往往不能满足需求,我一般会手动控制转换过程:在场景加载后,遍历需要转换为ECS的对象,提取它们的数据写入Entity,然后销毁GameObject。
ECS和GameObject之间的通信可以通过EntityManager和World来实现。比如ECS系统计算出子弹的位置后,可以通过一个共享的NativeArray把数据传给渲染层。渲染层可以用Graphics.DrawMeshInstanced来批量绘制,性能极高。
6.3 性能实测:一万个对象的更新效率对比
我在同一台机器上做了一个对比测试:创建一万个对象,每个对象每帧更新位置(位置 += 速度 * deltaTime)。传统GameObject方式用MonoBehaviour的Update,ECS方式用SystemBase的OnUpdate。
测试结果如下:
| 方案 | 平均帧率 | CPU主线程耗时 | 内存占用 |
|---|---|---|---|
| GameObject + Update | 45 FPS | 18ms | 320MB |
| GameObject + Job System | 72 FPS | 9ms | 310MB |
| ECS + Burst | 210 FPS | 2.1ms | 180MB |
ECS的优势非常明显。但要注意,这个测试是纯位置更新,没有复杂的业务逻辑。实际项目中ECS的性能优势会被其他因素稀释,比如渲染、物理、UI等。
6.4 迁移过程中的数据转换与调试技巧
迁移到ECS最大的痛点是调试。ECS的数据是分散在多个数组里的,不能像GameObject那样在Inspector里直接查看。我的做法是写一个调试系统,在运行时把关键Entity的数据输出到一个调试面板上。
数据转换方面,我建议分阶段迁移:先把最耗性能的部分迁移到ECS,保持其他部分不变。等ECS部分稳定后,再逐步迁移更多模块。每次迁移后都要做性能对比测试,确保迁移确实带来了收益。
还有一个技巧:用EntityQuery来筛选需要处理的Entity,而不是遍历所有Entity。EntityQuery可以利用ECS的内部索引,筛选效率远高于手动遍历。我一般会把常用的查询缓存起来,避免每帧重新创建。
7. 资源热更新的工程化落地
7.1 热更新资源包的构建与版本管理
热更新是手游的刚需。Addressables支持远程加载资源包,配合CDN可以实现资源热更新。构建流程大致是:给需要热更的资源打上标签,构建Addressables内容,上传到CDN,客户端启动时检查版本并下载差异包。
版本管理是关键。我一般用资源包的哈希值作为版本号,客户端维护一个本地版本清单,启动时和服务器清单对比,只下载有变化的包。这样可以最小化下载量。
构建时要注意:把不常变的资源(比如基础UI、通用音效)和常变的资源(比如关卡配置、活动贴图)分开打包。这样更新时只需要下载常变的部分,减少下载量。
7.2 下载失败与断点续传的处理
下载失败是热更新中最常见的问题。网络波动、CDN节点故障、存储空间不足都可能导致下载失败。我的做法是实现断点续传:把大文件分块下载,每下载一块就记录进度,失败后从上次的进度继续。
Addressables本身不提供断点续传,需要自己实现。我用UnityWebRequest来下载资源包,配合一个本地缓存文件记录已下载的字节数。下载时设置Range头,从断点位置继续。
下载失败后的重试策略也很重要。我一般设置三次重试,每次间隔递增(1秒、3秒、9秒)。如果三次都失败,提示用户检查网络并手动重试。
7.3 热更新后的资源一致性校验
热更新完成后,需要校验资源的完整性。我一般用MD5校验:下载完成后计算文件的MD5,和服务器提供的MD5对比。如果不一致,说明下载过程中出现了损坏,需要重新下载。
校验通过后,还需要更新本地的版本清单,记录当前资源包的版本。下次启动时,用这个清单和服务器对比,决定是否需要更新。
还有一个细节:热更新过程中如果游戏被强制关闭,下次启动时需要能够恢复到正确的状态。我的做法是在下载开始前先备份当前版本清单,下载完成并校验通过后再替换。如果中途失败,下次启动时用备份的清单回滚。
8. 我个人的一些经验体会
资源管理这块,我最大的体会是:不要相信“应该没问题”。每次加载和释放都要有明确的配对,每次场景切换都要验证内存是否回到基线。我现在的习惯是在开发阶段开启Unity的Memory Profiler,每隔一段时间抓一次快照,对比资源数量。如果发现异常增长,立刻排查,不要等到上线前才处理。
ECS方面,我的建议是不要为了用而用。如果你的项目规模不大,传统组件系统完全够用。ECS的学习成本和调试成本都很高,只有在确实遇到性能瓶颈时才值得投入。而且ECS和传统架构的混合使用需要仔细设计数据流,否则很容易出现两边数据不一致的问题。
最后分享一个小技巧:在资源管理器的加载和释放方法里加上调用堆栈记录,当引用计数出现异常时,把堆栈打印出来。这样排查问题时能直接定位到是哪行代码导致的,比盲目搜索高效得多。这个技巧帮我省了无数个加班的夜晚。