是内存泄漏,还是缓存?
内存曲线是最容易被误读的一条曲线。曲线上升不等于泄漏,存在泄漏也不等于 App 因此被终止。这两种误判都可能让排查走上很长的弯路。
看形状,不看数值
时间上的形状远比绝对数值重要。
锯齿形。 上升、回落、再上升、再回落,且每次回落到大致相同的底线。这是缓存在正常工作,或自动释放池在排空,属于健康状态。
台阶形。 上升、持平、再上升、再持平,始终不回落。每一级台阶对应一次操作,例如打开一个页面或加载一个列表,而返回后没有任何内存被释放。这是泄漏,至少是无上限的持有。
斜坡形。 匀速上升,没有台阶。通常来自反复发生的分配:定时器、观察者,或一个从不裁剪的数组。
悬崖形。 快速上升,随后进程消失。详见下文。
判断泄漏的方法
反复做页面往返。
- 记下主页面的内存数值。
- 进入详情页,执行相关操作,然后返回。
- 再记录一次。
- 重复五次。
如果每次返回后的数值都大致回到第一步的底线,说明没有问题;如果每次都更高,说明该页面上有对象未被释放。五次往返足以区分泄漏与噪声,这也是移动端成本最低的一项诊断。
为什么在 iOS 上更紧迫
iOS 没有交换分区,也没有页面文件来兜底。内存吃紧时,jetsam 会终止进程:先终止后台进程;如果内存增长来自前台 App,则终止前台 App。
内存配额按设备分配,且远低于物理内存。一台 3 GB 内存的手机分给前台 App 的远不到 3 GB,也明显少于 8 GB 旗舰机的配额。因此,内存问题常常表现为「只有低配手机会崩溃」。
Jetsam 从外部看的特征
这一点最耗费团队时间。因内存被终止时:
- 崩溃列表中会留下一条记录
- 有调用栈,但它只反映进程当时所处的位置,而非出错位置
- 没有异常信息,或异常信息与上下文不符
- 在新设备上完全无法复现
于是有人阅读调用栈,看到栈顶一个无关的函数,便将其重写。调用栈并不是 bug,bug 在于此前两分钟的内存趋势。
判断标准始终如一:查看在进程被终止时结束的那条内存曲线。台阶之后接悬崖,是 jetsam;平线之后接悬崖,才是真正的崩溃。
常见原因(按出现频率排序)
按原图尺寸解码
为显示一张 200 像素的缩略图,完整持有一张 4000 像素的照片。这既是性能问题也是内存问题,在列表密集的 App 中通常是最先发现、影响最大的一项。
没有上限的缓存
NSCache 会在内存压力下自动清理,普通字典不会。被当作缓存使用的字典,本质上是一个无上限的分配。
闭包造成的循环引用
最典型的情况:闭包捕获了 self,self 又持有该闭包,双方都无法释放。每个这样编写的页面都会泄漏整棵视图树。
生命周期长于视图的观察者和代理
在页面出现时注册,却从不注销。控制器因此一直存活,持续接收用户十分钟前就已离开的页面的通知。
只增不减的数组
日志缓冲、消息列表、未做裁剪的「最近使用」列表。运行一小时尚无问题,持续一整天的会话就会导致严重后果。
定位这些问题的流程
- 在正常使用中采集,不要使用合成的循环操作,应采用真实的导航和真实的内容。
- 关注底线,而非峰值。 回到根页面后的数值才是关键。
- 逐个页面隔离。 对每个主要页面分别做一轮往返,导致底线抬高的页面就是排查对象。
- 在支持的最低配设备上测试。 在旗舰机上永远不会被终止的泄漏,在低配机型上会被终止,而您的用户正在使用这些设备。
- 修复后重新测试。 使用相同的往返操作和次数,对比底线。
趋势分析的局限
趋势分析能告诉您哪个页面在泄漏,但无法指出具体是哪个对象。要定位到对象,最终仍需要堆分析工具和一台 Mac。不过,大部分价值在于把「这个 App 太占内存」收窄为「这个页面每进入一次增加 40 MB」。到这一步,循环引用通常通读一遍相关文件就能发现。
