原理
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 Y
下面我们用第一个工具开始大概检测一下是否存在问题:
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路径添加这个调用,不然就会循环引用。
我们看方法:
考虑添加一个中间对象作为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 方法,直接 allocproxy.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;
}