克魔助手
全部文章

那是内存泄漏,还是缓存?

4 分钟阅读内存性能

内存曲线是最容易被读错的一条曲线。往上走不等于泄漏,泄漏也不等于 App 就是因此死的。这两个误判各自都能把人送进一段很长、很错的排查。

看形状,不看数值

绝对数值远不如时间上的形状重要。

锯齿形。 涨、落、涨、落,而且每次回落到大致同一条底线。那是缓存在正常工作,或者自动释放池在排空。健康。

台阶形。 涨、平、涨、平,从不回来。每一级台阶对应你做过的一件事 —— 打开一个页面、加载一个列表 —— 而返回时什么都没被释放。这是泄漏,至少是无上限的持有。

斜坡形。 匀速上升,没有台阶。通常是某个反复发生的分配:定时器、观察者,或者一个没人裁剪的数组。

悬崖形。 快速上升,然后进程消失。见下文。

判断泄漏的测法

做往返,做很多次。

  1. 记下主页面上的内存数值。
  2. 进到详情页,做那件事,返回。
  3. 再记一次。
  4. 重复五次。

如果每次返回后的数值都大致回到第一步的底线,没问题。如果每次都更高,那个页面上有东西还活着。五次足以把泄漏和噪声分开,而这是移动端成本最低的一项诊断。

为什么 iOS 上这件事更紧迫

iOS 不做交换分区。没有页面文件来兜底你的失误。取而代之的是:内存吃紧时 jetsam 会终止进程 —— 先杀后台的,如果涨上去的是前台 App,就杀前台的。

内存配额是按设备给的,而且远低于装机内存。一台 3 GB 的手机给前台 App 的远不到 3 GB,也明显少于 8 GB 旗舰给的。这就是为什么内存问题常常表现为“只有便宜那台手机会崩”。

Jetsam 从外面看长什么样

这是最耗团队时间的一点。内存终止:

  • 会在崩溃列表里留下一条记录
  • 有调用栈,但那只是进程当时恰好在哪,不是哪里出了错
  • 没有异常信息,或者有一个放在上下文里说不通的
  • 在新设备上完全无法复现

于是有人读了调用栈,看到栈顶一个无辜的函数,回去把它重写了一遍。调用栈不是 bug,bug 是前两分钟里那条趋势。

判据永远一样:去看在被杀那一刻结束的那条内存曲线。台阶接悬崖是 jetsam;平线接悬崖才是真崩溃。

常见成因,按出现频率

按原图尺寸解码

一张 4000 像素的照片,为了一个 200 像素的缩略图被完整持有。它既是性能问题也是内存问题,在列表密集的 App 里通常是第一个也是最大的一个发现。

没有上限的缓存

NSCache 会在内存压力下清理,普通字典不会。被当作缓存用的字典,就是一个起了好听名字的无上限分配。

闭包造成的循环引用

经典款。闭包捕获了 self,self 又持有闭包,两边都不释放。每一个这么写的页面都会泄漏整棵视图树。

活得比视图久的观察者和代理

在 appear 时注册,从来不注销。控制器一直活着,继续接收一个用户十分钟前就离开了的页面的通知。

只增不减的数组

日志缓冲、消息列表、没有裁剪的“最近使用”。一小时内没问题,一整天的会话就是致命的。

一套能找到这些的流程

  1. 在正常使用中采集,不要用合成的循环。真实的导航、真实的内容。
  2. 看底线,不看峰值。 回到根页面之后的数值才是关键。
  3. 按页面逐个隔离。 对每个主要页面各做一轮往返,抬高底线的那个就是要查的。
  4. 在你支持的最小设备上测。 在旗舰上永远不会被杀的泄漏,在低配机型上会被杀,而你的用户就在那里。
  5. 修完再跑一遍。 同样的往返、同样的次数,对比底线。

趋势不够用的时候

趋势分析告诉你哪个页面在漏,不会告诉你是哪个对象。要到那一步,最终还是需要堆分析工具和一台 Mac。但大部分价值在于把“这个 App 太吃内存”收窄成“这个页面每进一次多 40 MB”—— 到这一步,那个循环引用通常读一遍文件就能看见。