IOS APP 内存泄露优化
原理
https://juejin.cn/post/6864492188404088846
分析
我的APP主要的VC路径如下:
如果没有内存泄露的话,我们从一个VC_A开始push一个VC_B,无论在VC_B操作了什么,pop回到VC_A,这个时候的内存大小应该和VC_A在puhs VC_B的时候是一样大的。
如图:
页面结构:曲谱列表 push 曲谱详情 ->.... -> 曲谱详情 pop 曲谱列表
内存大小: x1 x2 x2 X1
下面我们用第一个工具开始大概检测一下是否存在问题:
Memory Report
运行程序,打开入口:
Memory Report 是可以实时查看整个应用当前应用内存使用情况的工具,但是它只能用于初略得定位哪些页面有可能有内存泄漏,或者哪个时间段有内存抖动问题。具体的定位还是需要Allocations工具。Memory Report工具不是很准,我们后面会提到。
开始测试
目前来看:在曲谱列表里面内存是40Mb
,我们打开曲谱编辑VC退出看下内存变化:
我们可以看出来,内存大小没有恢复push前的,说明可能存在内存泄露,不过我们前面说了,这个工具检测可能不准,我们用Allocations来确认。
Allocations
入口:
打开Instruments
创建一个空的。
添加Allocations
左上角选好设备和应用,开始运行
pop后的内存变化不大。说明编辑曲谱VC的内存没有得到释放。
这里简单介绍一下Allocations
的用法
All Heap & Anonymous VM: 所有堆内存和虚拟内存
All Heap Allocations: 所有堆内存,堆上malloc分配的内存,不包过虚拟内存区域
All Anonymous VM: 所有虚拟内存,就是Allocations不知道是你哪些代码创建的内存,也就是说这里的内存你无法直接控制。像memory mapped file,CALayer back store等都会出现在这里。这里的内存有些是你需要优化的,有些不是。
每行都包含如下几个重要的列:
Persistent :未释放的对象个数
Persistent Byte :未释放的字节数
Transient :已释放的临时对象个数
Total Byte :总使用字节数
Total :所有对象个数
Persistent/Total Bytes : 已经使用的内存对象占全部的百分比
不同的数据视图:
Statistics:显示程序运行期间的统计数据。包括分配的对象数量、总内存使用量、内存峰值等。
Call Trees:显示函数调用栈的层级结构,按线程或调用关系组织。包括每个函数的调用次数、占用的时间等。
Allocations List:列出程序中所有内存分配的对象详细信息。包括对象的类型、大小、分配时间、释放状态等。
Generations:通过“代”的概念,跟踪不同时间点内存分配的对象。显示每一代中创建的对象及其生命周期。
场景总结
Statistics:用于获取总体内存使用的概览。
Call Trees:用于分析代码的性能瓶颈。
Allocations List:用于详细检查内存分配的对象。
Generations:用于分析内存泄漏及对象生命周期。
我们这里如果想看在push的时候是代码是在什么地方申请了内存。
第1步,我们切换到call Tree视图。
第2步,我们框出这个push的时间段,可以明显看出有内存占用升高。
这个时候显示如下:
非常不好观察:(1)有系统调用, (2)函数是从栈底显示的就是从main开始一层一层到真正申请的内存的函数。
我们打开2个开关:
1. Separate by Category
功能:
按类别分离调用树。
将调用分组到不同的类别中,例如系统库、用户代码、动态库等。
用途:
帮助开发者快速区分调用源头(系统库或用户代码)。
对于大型项目,可以清晰地查看代码的调用类别。
2. Separate by Thread
功能:
按线程分离调用树。
将调用树分组到不同的线程上,显示每个线程的调用栈。
用途:
帮助分析多线程程序中不同线程的性能瓶颈。
定位哪一个线程占用最多的资源或导致了问题。
3. Invert Call Tree
功能:
颠倒调用树,将调用树的叶子节点(最底层函数)显示在顶层。
用途:
快速查看哪个底层函数耗时最多。
对于复杂的调用链,可以直接定位到性能瓶颈的根本原因,而不用从顶部逐层展开。
4. Hide System Libraries
功能:
隐藏系统库的调用栈。
仅显示用户代码相关的调用。
用途:
隐藏与用户代码无关的系统库调用栈。
减少干扰,更专注于优化用户代码。
5. Flatten Recursion
功能:
将递归调用压平,显示为一层。
即使某些函数递归调用了多次,也只显示一次。
用途:
简化递归调用的展示,避免递归调用链太深导致难以阅读。
快速了解递归调用的总体性能影响。
选项适用场景
时间和性能分析
如果你正在分析代码性能瓶颈,以下选项非常有用:
Invert Call Tree:快速找到最耗时的底层函数。
Hide System Libraries:只关注用户代码,忽略系统调用。
多线程优化
在多线程程序中,以下选项可帮助分析线程间问题:
Separate by Thread:区分不同线程的调用栈,发现某些线程可能占用过多资源。
代码复杂度管理
对于递归算法或复杂代码:
Flatten Recursion:清晰了解递归函数的整体影响。
Separate by Category:区分系统库和用户代码调用链。
推荐使用方式
初步分析:
开启 Hide System Libraries,专注于用户代码。
启用 Invert Call Tree,从底层函数开始找耗时热点
多线程项目:
开启 Separate by Thread,观察不同线程的资源占用。
递归代码:
启用 Flatten Recursion,避免调用树过于复杂。
通过调整这些选项,可以快速定位性能问题并优化代码。
现在我们再看,就可以知道是我的collectionView创建了大量collectionViewcell,collectionViewcell又创建了大量的ChordItemButtonMin,导致内存占用升高。
Leaks 内存泄漏检测工具
我们检查一下是否有内存泄露
运行操作刚才的步骤,发现没有检测到内存泄露。
感觉Leaks对野指针更有效,对于循环引用无法判断是否是程序员自己的逻辑,还是bug导致。
Debug Memory Graph 图形化内存表
Debug Memory Graph 是Xcode8中增加的调试技能,在App运行调试过程中,点击即可实时看到内存的分配情况以及引用情况,可用于发现部分循环引用问题,为了能看到内存详细信息,需要打开Edit Scheme–>Diagnostics, 勾选 Malloc Scribble 和 Malloc Stack。同时在 Malloc Stack 中选择 Live Allocations Only:
开始调试,我们运行APP,然后从
曲谱列表 push 曲谱详情 push 编辑曲谱,然后pop2次回到曲谱列表。
优化block循环引用
这个搜索(编辑曲谱VC)MusicViewController,发现还有一个实例没有释放。
我们看是谁强引用了它:
是MusicCollectionViewCell的block强引了。我们想看代码在什么地方进行了强引用:
这里MusicViewController被block强引用,形成循环引用,所以pop后MusicViewController无法释放。
修改代码:
@weakify(self); // 将 self 弱引用化,生成 weak_self
cell.buttonActionBlock = ^{
@strongify(self); // 将 weak_self 转为强引用,恢复为 self
// 将新的模型插入到数据数组的倒数第二个位置
[[VibrationManager sharedManager] vibrateWithCurrentSetting];
NSUInteger insertIndex = self.MusicModel.data.count >= 1 ? self.MusicModel.data.count - 1 : self.MusicModel.data.count;
[self addCellModel:insertIndex];
};
@weakify(self);和@strongify(self);是我自定义包装,可以用下面的代码,效果是一样的。
__weak typeof(self) weakSelf = self; // 将 self 转为弱引用,避免循环引用
cell.buttonActionBlock = ^{
__strong typeof(weakSelf) strongSelf = weakSelf; // 在 block 内部将弱引用提升为强引用
if (!strongSelf) return; // 如果 strongSelf 为 nil,直接返回
// 将新的模型插入到数据数组的倒数第二个位置
[[VibrationManager sharedManager] vibrateWithCurrentSetting];
NSUInteger insertIndex = strongSelf.MusicModel.data.count >= 1 ? strongSelf.MusicModel.data.count - 1 : strongSelf.MusicModel.data.count;
[strongSelf addCellModel:insertIndex];
};
现在的引用图:
我们再次运行,通过Allocations来检测:
每次回到曲谱列表页面,内存占用大小也恢复到之前的大小。说明MusicViewController在pop得到释放。我们可以在去Debug Memory Graph验证:
我们再执行一次,打开Debug Memory Graph,搜索MusicViewController:
发现在内存中没有了。
优化定时器循环引用
我们刚才确定了前3个VC没有内存泄露,现在我们在检测第4个VC:曲谱播放VC
还是一样的步骤,先用Allocations来检测。
我们从详情页push2次又pop2次,内存都回到原来的状态。
发现是正常的。
现在我为了模拟定时器循环引用,我改了一下代码
再次检测,我们从详情页push2次又pop2次。发现内存没有回到之前的状态。说明曲谱播放VC(PlayMusicViewController)也发生内存泄露。并且很可能是循环引用
我们打开Debug Memory Graph验证:
发现了是定时器强引用了PlayMusicViewController
方法1:
在退出的时候,显式调用[self.displayLink invalidate];
这个会主动让displayLink释放对Target的强引用。
优点:写法简单
缺点:必须在所有pop路径添加这个调用,不然就会循环引用。
方法2:
考虑添加一个中间对象作为Target,通过消息转发把定时器的回调转发到PlayMusicViewController
这样就算没有在pop的时候调用[self.displayLink invalidate];
也不会循环引用。
// MJProxy.h
#import <Foundation/Foundation.h>
@interface MJProxy : NSProxy
+ (instancetype)proxyWithTarget:(id)target;
@property (nonatomic, weak) id target; // 弱引用目标对象,避免循环引用
@end
// MJProxy.m
#import "MJProxy.h"
@implementation MJProxy
+ (instancetype)proxyWithTarget:(id)target {
MJProxy *proxy = [MJProxy alloc]; // NSProxy 没有 init 方法,直接 alloc
proxy.target = target;
return proxy;
}
// 消息转发目标
//- (id)forwardingTargetForSelector:(SEL)aSelector {
//return self.target; // 直接转发给目标对象
//}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
return [self.target methodSignatureForSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
// NSLog(@"拦截到未实现的方法:%@", NSStringFromSelector(invocation.selector));
[invocation invokeWithTarget:self.target];
}
@end
使用的地方修改:
// 第一次播放
- (void)playMusic {
// 滚动 tableView 使目标行可见
[self scrollToCellAtIndex:self.playbackProgress.cellIndex -1];
[self.currentCellView updateBeatBackView:self.playbackProgress.beatIndex];
// 使用 MJProxy 代理 self,避免 CADisplayLink 对 self 的强引用
self.displayLink = [CADisplayLink displayLinkWithTarget:[MJProxy proxyWithTarget:self]
selector:@selector(update:)];
self.displayLink.preferredFramesPerSecond = 60; // 设置刷新率
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
为什么用NSProxy子类作为中间对象?
答:NSProxy专门用来信息转发,objc_magSend
不用走信息发送
和方法解析
直接走消息转发
。
为什么MJProxy不通过forwardingTargetForSelector
快速转发?
答:有的方法不通过forwardingTargetForSelector
,例如:
isClass
和isMemberOfClass
。
- (BOOL)isMemberOfClass:(Class)aClass {
// 声明方法签名和调用对象
NSMethodSignature *sig;
NSInvocation *inv;
BOOL ret;
// 获取当前方法的签名
sig = [self methodSignatureForSelector:_cmd];
// 创建 NSInvocation 对象
inv = [NSInvocation invocationWithMethodSignature:sig];
// 设置选择器 (即 isMemberOfClass:)
[inv setSelector:_cmd];
// 设置参数
[inv setArgument:&aClass atIndex:2];
// 转发消息
[self forwardInvocation:inv];
// 获取返回值
[inv getReturnValue:&ret];
// 返回结果
return ret;
}
我的推荐:在项目中同时用方法1和方法2。
补充:我们前面说了Memory Report工具不是很准。
对比:
Memory Report一直没有回到原始水平,还是根据Debug Memory Graph就结果更可靠。
Analyze
使用 Xcode 自带的静态分析工具 Product -> Analyze(快捷键 command + shift + B)可以找出代码潜在错误,如内存泄露,未使用函数和变量等。
Analyze 主要分析以下四种问题:
1、逻辑错误:访问空指针或未初始化的变量等;
2、内存管理错误:如内存泄漏等,比如 ARC 下,内存管理不包括 core foundation;
3、声明错误:从未使用过的变量;
4、Api 调用错误:未包含使用的库和框架。
总结
Allocations:内存占用,当前堆栈。
Leaks 内存泄漏检测工具:内存泄漏(对循环引用无效),当前堆栈。
Debug Memory Graph 图形化内存表:循环引用。
Analyze:静态代码分析
上面介绍的工具虽然是官方推出的工具,但是实际上并不是十分好用,需要我们一个个场景去重复的操作,还有检测不及时,并且Instuments工具永久了不是一般的卡,在开发过程中上面几种工具用得比较多的就是Memory Report,Analyze 以及 Leaks,更多的是结合一些开源库来实时检测内存泄漏,这里推荐的是微信推出的MLeaksFinder,它能较为实时地检测内存泄漏问题,一旦有内存泄漏立刻弹窗提示,这种方式从很大角度上加快了我们发现问题解决问题的速度。
MLeaksFinder GitHub地址
MLeaksFinder 可以算得上是一个很好的检查内存泄漏的辅助工具,它有如下特点:
使用简单,不侵入业务逻辑代码,不用打开 Instrument
不需要额外的操作,你只需开发你的业务逻辑,在你运行调试时就能帮你检测
内存泄露发现及时,更改完代码后一运行即能发现(这点很重要,你马上就能意识到哪里写错了)
精准,能准确地告诉你哪个对象没被释放
MLeaksFinder 目前能自动检测 UIViewController 和 UIView 对象的内存泄露,而且也可以扩展以检测其它类型的对象
具体的实现细节可以看官方的博客,由于篇幅原因,这里只提炼一些重要的内容做介绍,后面会针对MLeaksFinder写一篇源码解析的文章来介绍它的实现:
MLeaksFinder 通过AOP技术 hook UIViewController 和 UINavigationController 的 pop 跟 dismiss 方法,这种做法的优点就是不会侵入项目工程。MLeaksFinder会在UIViewController被pop或dismiss一小段时间后,检测该 UIViewController的view,以及view 的 subviews 等等是否还存在,具体的方法是,为基类 NSObject 添加一个方法 -willDealloc 方法,该方法的作用是,先用一个弱指针指向 self,并在一小段时间(2秒)后,通过这个弱指针调用 -assertNotDealloc,而 -assertNotDealloc 主要作用是直接中断言。我们可以在一个 UIViewController被pop或dismiss时遍历该 UIViewController上的所有view依次调 -willDealloc 这样如果2秒后它们被释放成功,weakSelf 就指向 nil,不会调用到 -assertNotDealloc 方法,也就不会中断言,如果它没被释放,-assertNotDealloc 就会被调用中断言。通过这种方式可以找出具体是哪个地方发生了内存泄露。最新版本的MLeaksFinder 还结合了FBRetainCycleDetector通过MLeaksFinder查找可能存在内存泄漏的对象,然后通过FBRetainCycleDetector来查看是否存在循环引用。
- (BOOL)willDealloc {
// 当前的类是否再白名单中,如果是的话就不会进行检测是否泄漏
NSString *className = NSStringFromClass([self class]);
if ([[NSObject classNamesWhitelist] containsObject:className])
return NO;
NSNumber *senderPtr = objc_getAssociatedObject([UIApplication sharedApplication], kLatestSenderKey);
if ([senderPtr isEqualToNumber:@((uintptr_t)self)])
return NO;
//延迟2秒尝试调用assertNotDealloc
__weak id weakSelf = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__strong id strongSelf = weakSelf;
[strongSelf assertNotDealloc];
});
return YES;
}
- (void)assertNotDealloc {
if ([MLeakedObjectProxy isAnyObjectLeakedAtPtrs:[self parentPtrs]]) {
return;
}
[MLeakedObjectProxy addLeakedObject:self];
NSString *className = NSStringFromClass([self class]);
NSLog(@"Possibly Memory Leak.\nIn case that %@ should not be dealloced, override -willDealloc in %@ by returning NO.\nView-ViewController stack: %@", className, className, [self viewStack]);
}
需要注意的是这里的遍历需要遍历基于UIViewController的整棵View-ViewController树,对于某些 ViewController,如 UINavigationController,UISplitViewController 等,还需要遍历 viewControllers 属性。
MLeaksFinder在发现可能的内存泄漏对象并给出 alert 之后,还会进一步地追踪该对象的生命周期,并在该对象释放时给出 Object Deallocated 的 alert,所以有时候你会发现弹出一个内存泄漏的弹窗后,你以为内存泄漏了检查了好久发现没有,重复尝试后你会发现在这个弹窗之后还会出现Object Deallocated弹窗,这种其实是某个对象延迟释放了,并不是发生了内存泄漏。
所以在使用MLeaksFinder的时候一般会有如下几种情况:
- 在第一次pop的时候弹出Leak弹窗,在之后的重复push并pop同一个ViewController过程中,即不报 Object Deallocated,也不报 Memory Leak。这种情况下我们可以确定该对象被设计成单例或者缓存起来了。
- 在第一次pop的时候弹出Leak弹窗,在之后的重复push并pop同一个ViewController过程中,对于同一个类不断地报 Object Deallocated 和 Memory Leak。这种情况属于释放不及时的情况,不算内存泄漏。
- 在第一次pop的时候弹出Leak弹窗,在之后的重复push并pop同一个ViewController过程中,不报Object Deallocated,但每次 pop 之后又报 Memory Leak,这种才算是真正的内存泄漏。
*其他关于内存检测较好的开源库:*
- FBAllocationTracker FaceBook推出的用于检测对象分配的开源库
- FBRetainCycleDetector FaceBook推出的用于检测循环引用的开源库
- FBMemoryProfiler 结合FBAllocationTracker,FBRetainCycleDetector来提供内存问题检测的工具,支持插件开发。
- OOMDetector 腾讯开源的OOM检测器
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)