前言
最近在研究如何在iOS应用中进行一些简单的内存监控,其中主要包括内存泄漏和内存占用。开始记录自己的踩坑历程前,先推荐一篇文章:从 OOM 到 iOS 内存管理 | 创作者训练营。文章里面对于iOS的内存基础知识介绍地比较全面。本文主要介绍如何调试内存泄漏、代码检测内存泄漏以及内存占用量获取等基础内容,篇幅较长,可根据标题选择性阅读。
内存泄漏工具检测
关于内存泄漏的检测,主要还是在debug阶段。Xcode也提供了一些工具用于检测内存使用和内存泄漏。
毫无疑问,循环引用是造成内存泄漏的主要原因。下面是一个简单的demo模拟循环引用。
- class Server: NSObject {
- var clients: [Client] = []
- func add(client: Client) {
- self.clients.append(client)
- }
- }
- class Client: NSObject {
- var server: Server?
- override init () {
- super.init()
- }
- }
- class ViewController2: UIViewController {
- let client: Client
- let server: Server
- init() {
- self.client = Client.init()
- self.server = Server.init()
- super.init(nibName: nil, bundle: nil)
- self.client.server = server
- self.server.add(client: client)
- }
- }
通过代码可以推测出,Server和Client两个对象的实例会造成循环引用,最终导致内存泄漏。以下介绍两种Xcode自带的内存泄漏检测工具。(ps: 使用的Xcode版本为12.0)
Instruments的Allocations和Leaks可以检测app运行过程中的内存使用情况以及内存泄漏的情况,这也是笔者日常开发最常使用检测内存的方式。以下是使用Leaks检测到内存泄漏的截图:
这里就能清晰的看到client和server两个对象是没有被释放的,且形成了循环引用。(研究中发现,可能有部分泄漏的情况用Leaks无法检测到,这个因为没有深究,如果有了解的朋友可以下方留言讨论一下。)
Memory Graph是查看app的内存使用情况的功能,可清晰查看对象的引用链。
在app运行过程中点击图中按钮即可打开Memory Graph。接下来可以看看如果根据上述demo检测出来的效果。
上图中可得知,在理应释放的client和server两个对象因为循环引用的原因造成了内存泄漏。一般在如果是泄漏的对象,后面都会带一个紫色的标记。
ps: 比较尴尬的是,笔者在自己的iPhone7上运行公司的项目无法打开会提示如图所示,目前没有找到合适的解决方法:
还有一个方法是通过导出.memgraph在命令行上查看一些内存的占用情况。这里我们简单介绍以下使用leaks命令查看内存泄漏,还有一些高阶玩法可以参考此文章:让我们来调试 iOS 内存 - Memory Graph
导出.memgraph文件:在上述的模式下点击File->Export Memory Graph
终端使用leaks命令即可查看内存泄漏分析,譬如有循环引用发生时会打印出相关的引用:
也可开启 malloc 日志堆栈,获取到根节点的回溯。
内存泄漏代码检测
上文讲到的检测手段都是借助Xcode工具实现的,比较依赖pc端。那是否有纯代码的检测手段呢?因为笔者的目的是希望可以在release环境下检测泄漏的。而且如果可以代码自动检测,那就可以在日常开发调试阶段自动发现一些内存泄漏的问题。答案是有的,接下来会介绍笔者在查阅资料过程中研究的3个开源库。通过阅读开源库的源码和一些文章笔者得出了一个公式,后面的内存泄漏代码检测也可通过该公式进行总结。
这里我将内存泄漏的检测总结成两个方面触发时机与校验方法。iOS的内存泄漏代码检测有一定的局限性,对比了一些开源项目的实现都需要寻找一个合理的触发时机(比如MLeaksFinder会通过hook ViewController的生命周期来作为起点检测,这个会在后续讲到)。这里转载一个简单方案是采用Method Swizzling的方式hook了vc的方法进行触发校验。iOS自定内存监控
结合上述的示例套用公式:
- 触发时机:通过Method Swizzling的方式替换vc的dismiss和viewWillDisappear来间接判断vc的销毁。以此来触发检测。
- 校验方法:通过延时2s后再调用vc自身的一个扩展的实例方法。
- 检测结果:正常情况下,2s之后vc在销毁后对象会被释放,则无法调用自身的实例方法。若能调用成功,则说明该对象已泄漏。
上述简单的思路基本可以贯穿大多数内存泄漏的代码检测实现,后续介绍的开源库也是大同小异。
RIBs-LeakDetector
RIBs是uber开源的一个app架构设计框架,笔者在库中找到了一个检测内存泄漏的工具LeakDetector.swift
比较巧妙的是,这里使用了一个NSMapTable.strongToWeakObjects()的对象trackingObjects去存放需要观察的对象。文档的解释是其key值为强引用而value值为弱引用。
比较不友好的是,该库的触发时机需要开发者自行寻找,譬如在vc的deinit上可以监控其viewModel是否发生内存泄漏:
- deinit {
- LeakDetector.instance.expectDeallocate(object: viewModel)
- }
套用公式:
- 触发时机:需要开发者自行寻找触发时机,譬如在vc的deinit
- 校验方法:事先将需要观察的对象添加到NSMapTable,延时指定时间后根据对应的key获取value
- 检测结果:若value为空说明内存已释放(弱引用),反之发生了内存泄漏。
优点:值得借鉴的是,1、借用了NSMapTable的特性;2、计时部分的逻辑使用RxSwift实现,有助于检测事件的取消还有代码的解耦。
缺点:1、依赖了RxSwift这种量级较大的第三方库,不够轻量;2、触发时机需要自行寻找,可能对于逻辑的兼容比较差。
LifetimeTracker
LifetimeTracker这是一个比较有意思的库,其校验是否泄漏的依据是根据开发者设定该对象最大数判断的,以下是截取其readme的用法:
实现LifetimeTrackable协议内有一个LifetimeConfiguration类对象完成maxCount的配置,在适当的时候譬如init方法调用trackLifetime即可触发泄漏的检查。其原理是在内部有一个集合存放每个对象的标示(这里是将对象的信息生成一个model),然后在每次触发trackLifetime时进行数量的校验。具体的源码实现可查看LifetimeTracker.swift。
ps: 值得注意的是,每次trackLifetime数量都会+1,而这里还存在一个onDealloc时机会-1。这里使用关联属性(如上图),事先关联一个对象到被观察对象,若被观察对象调用deinit时,此关联属性对象的deinit也会被跟着调用。这样就能间接判断被观察对象的释放了。有关关联属性的介绍可以看看这篇文章objc_setAssociatedObject 关联详解。
套用公式:
- 触发时机:需要开发者在对象init时调用trackLifetime
- 校验方法:trackLifetime时会新增一次被观察对象的计数并关联一个对象,当关联对象deinit调用时自动将计数-1
- 检测结果:若计数大于LifetimeConfiguration的maxCount则为泄漏。
ps: 该库中还有分组(group)的设计,我的理解是可能某些类型会组合使用,分组可以更好的管理。这里就不作过多的介绍了。
优点:1、比依赖vc等视图;2、根据objc_setAssociatedObject被动检测对象的释放,无需通过延时主动检测。
缺点:1、需要预先设定maxCount对于使用场景的比较限制;2、泄漏的检查需要通过调用trackLifetime触发,触发时机比较靠后。
MLeaksFinder
MLeaksFinder是腾讯开源的一个内存泄漏检测库。原理大致与本节开头提供的例子相似:通过Method Swizzling的方式替换vc的生命周期,继而触发检查。
在对象load的时候自动替换,这样可以实现无侵入的监控视图级别的内存泄漏,接下来我们来看看整个库的灵魂即触发检查的方法:
以上是库中对于ViewController的扩展,灵魂就是willDealloc方法。这里是触发vc的dismiss时会触发willDealloc方法,方法中会将vc的子view、子vc添加到观察中,这样就能最大限度地观察视图对象的泄漏。 我们再来看看willReleaseChildren、willReleaseChild方法做了什么。
以上是库中对于NSObject的扩展,实际上就是通过objc_setAssociatedObject关联一个对象的地址集合(parentPtrs)及对象的引用栈(viewStack),这里的操作是可以让子对象也拥有其上级的对象地址信息以及完整的引用栈。最终也是调用子对象的willDealloc。
ps: willDealloc的实现也是之前的老方法了,延迟2s调用对象的方法,从而判断出是否存在泄漏。
比较遗憾的是,如果要观察自定义类型的属性,还是需要手动时机触发,譬如观察vc的viewModel是否存在泄漏:
- @objc dynamic public override func willDealloc() -> Bool {
- if !super.willDealloc() {
- return false
- }
- self.willReleaseChildren([
- self.viewModel
- ])
- return true
- }
套用公式:
- 触发时机:以vc的销毁作为起点逐层往下调用willDealloc
- 校验方法:通过延时2s后再调用NSObject的扩展方法assertNotDealloc。
- 检测结果:一般能调用成功,大概率就是发生了泄漏。
优点:1、无需侵入性的观察视图级别的对象;2、寻找触发时机的兼容性逻辑比较完善;3、记录的引用栈比较完善,方便定位问题。
缺点:1、对于非视图对象需要手动处理;2、依赖NSObject,对于swift上非继承NSObject的类型不太友好;3、对于观察非全局属性比如方法内的对象可能较难找到触发时机。
内存泄漏代码检测总结
综上几个开源库的设计分析,其实都是非常符合本节开头笔者提出的公式的。比较遗憾的是,它们对于代码都有或多或少的侵入性,而且对于流程上的(方法内)对象观察对于代码的侵入性较大不太友好。综合了以上分析的优缺点,最终笔者还是选择了MLeaksFinder,后续会花少部分时间介绍笔者对于该库的小改动。
MLeaksFinder内存泄漏记录代码分析
如上图所示,MLeaksFinder一旦检查到内存泄漏:
- 首先会将一个MLeakedObjectProxy对象关联到泄漏对象中。主要的作用是观察泄漏对象的生命周期一旦释放后就会触发MLeakedObjectProxy对象的释放。这种做法类似上述提到的LifetimeTracker。
- 在泄漏时和释放时都会有弹窗提示开发者响应的信息。
结合上述的分析,若想在泄漏之后进行一些自定义的记录(如开发日志记录等),就可以在弹窗的地方加入或修改为自己的逻辑。这部分实现在DoraemonKit中的DoraemonKit-MLeaksFinder有很好的体现。
内存占用量
应用性能检测中,包含了当前内存占用量的获取,这里有两篇文章分享一下:从 OOM 到 iOS 内存管理 | 创作者训练营、iOS开发--APP性能检测方案汇总(一)。里面介绍得比较详细,这里就不再过多赘述了 。以下是通过资料总结的一些方法,有需要的可以拿去用。
获取当前app占用内存量
- #include <mach/mach.h>
- + (NSInteger)useMemoryForApp {
- task_vm_info_data_t vmInfo;
- mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
- kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
- if(kernelReturn == KERN_SUCCESS)
- {
- int64_t memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
- return (NSInteger)(memoryUsageInByte/1024/1024);
- }
- else
- {
- return -1;
- }
- }
设备总内存
- #include <mach/mach.h>
- + (NSInteger)totalMemoryForDevice {
- return (NSInteger)([NSProcessInfo processInfo].physicalMemory/1024/1024);
- }
iOS13之后可以获取app当前可用的内存
- #import <os/proc.h>
- + (NSInteger)availableSizeOfMemory {
- if (@available(iOS 13.0, *)) {
- return (NSInteger)(os_proc_available_memory() / 1024.0 / 1024.0);
- }
- return 0;
- }
当然也可以结合可用内存和已用内存计算出app总共可使用的内存,ps: 下述代码仅供参考。
- #include <mach/mach.h>
- #import <os/proc.h>
- + (NSInteger)limitSizeOfMemory {
- if (@available(iOS 13.0, *)) {
- task_vm_info_data_t taskInfo;
- mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
- kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);
- if (kernReturn != KERN_SUCCESS) {
- return 0;
- }
- return (NSInteger)((taskInfo.phys_footprint + os_proc_available_memory()) / 1024.0 / 1024.0);
- } else {
- NSInteger totalMemory = [Utils totalMemoryForDevice];
- NSInteger limitMemory;
- if (totalMemory <= 1024) {
- limitMemory = totalMemory * 0.45;
- } else if (totalMemory >= 1024 && totalMemory <= 2048) {
- limitMemory = totalMemory * 0.45;
- } else if (totalMemory >= 2048 && totalMemory <= 3072) {
- limitMemory = totalMemory * 0.50;
- } else {
- limitMemory = totalMemory * 0.55;
- }
- return limitMemory;
- }
- }
最后关于性能监控的代码强烈建议大家去阅读DoraemonKit的源码,里面的DoraemonHealthManager.m囊括了许多常用的性能检测代码。
MetricKit
顺带一提,在研究过程中发现iOS在13之后推出了一个性能监控的api,可以定期地返回一些性能数据给到开发者。官方文档是写着每24小时回调一次(坑爹的设计,开发阶段不知道如何调试)。目前不知如果未上架的应用能否使用,有兴趣的可以看看iOS 性能优化:使用 MetricKit 2.0 收集数据
最后
本文主要介绍如何调试内存泄漏、代码检测内存泄漏以及内存占用量获取等基础内容。总的来说,目前监控内存泄漏的做法局限性还是有的,如果还有较好方案,欢迎在下方留言讨论。
原文地址:https://juejin.cn/post/6922330892295733256