iOS项目内存泄漏探究-使用Runtime检测内存泄漏

平安喜乐

前言

iOS自从引入ARC机制后,一般的内存管理就可以不用我们来负责了,但是一些操作如果不注意,还是会引起内存泄漏。

造成内存泄露的主要原因就是当我们不需要引用这个对象的时候,这个对象的引用计数器不为0。

一 、APP内存泄露排查:

背景: APP(直播类)上线后,测试使用wetest工具进行检测发现:

  1. 在房间内长时间进行挂机,iOS客户端内存会随着时间的增加一直增长;
  2. 当反复进出房间后内存也只是有一小部分进行了回收释放,还是有很大一部分内存没有释放。

上面的现象会造成在一些低端机型上长时间观看直播或者频繁进出房间客户端闪退;高端机型上虽然不会闪退,也会有卡顿的现象出现。这严重影响了用户体验,需要尽快找出内存泄露的点进行修复。

从内存泄露表现上进行分析,两种情况下可能的原因:

  • 内存随着时间一直增长:房间内会随着时间会一直变化的就是视频流、送礼信息、聊天信息这三个,第一时间考虑到应该就是这三个中一 个或多个造成的内存泄露。
  • 进出房间后内存没有回到原始值:房间控制器roomViewController没有释放或者包含的一些子模块没有释放,导致退出房间后对象没有释 放还在内存中。

1)房间内一直增长的问题

思路:先采用三种情况分别单一进行测试,大概确认是哪部分有内存泄露,再使用工具分析是哪个具体点造成的。

既然房间内视频流、送礼、聊天都有可能引起这个现象,那么就用三个用例进行测试:1、只加载视频流,不送礼聊天 2、只送礼 3、只聊天 使用上面的用例分别在房间内测试了一个小时以后得出结论:

  • 只加载视频流、不送礼和聊天内存基本没有变化,也就是说视频流没有内存泄露。
  • 只聊天一小时内存增长80M左右。
  • 只送礼一小时内存增长110M左右。

视频流没有内存泄露,和我们刚开始猜想差不多。视频流使用的其他的框架,应该是比较完善的。

聊天和送礼都有内存增长且送礼增长的较快,应该是聊天区、送礼底版都有内存泄露。送礼增加快猜测是因为送礼信息会同时展示聊天区和送
礼底版引起。

instrument

下面要具体排查出是哪些代码造成了内存泄露,再用上面的方法效率就很低了。这时候就要用到xcode自带的性能检测工具instrument。

注意:instrument在使用前需要把工程build settings中debug infomation Format设置为DWARF,不然不能根据内存片段找到对应代码。

使用instrument的Allocation和Leaks进行内存分析后发现:

  • 聊天区的图片资源占用内存一直在增加
  • 送礼底版每飘一次,展示送礼底版的GiftBottomView对象就会增加一个
1.1)聊天区的图片资源占用

查看聊天区代码,图片引用的方式并不会造成内存泄露。重写相关类的deinit方法,发现当对象从屏幕上移除的时候系统都进行的回收。

这就奇怪了,该释放的对象都进行了释放还为什么会出现这种问题呢?

考虑到是图片占用内存一直增加,把聊天区的图片逻辑先注释掉进行测试。测试后发现不显示图片后内存是比较平稳。

聊天区图片资源并没有单独引用,单条聊天信息对象ChatRecordCell释放的时候内部的图片资源应该是一起释放的呀?

经过本地的各种尝试后发现:

ChatRecordCell显示聊天信息时使用的是富文本的形式。当聊天区有图片时,图片资源也是转换成一个NSTextAttachment形式的富文本。

每次根据图片资源生成一个NSTextAttachment对象后,内存就会增长一个图片的大小,同一张图片也是。

NSTextAttachment每次会生成一个新的对象,因此没有进行复用之前图片资源,在内存紧张时图片资源系统默认是不进行释放的。

考虑到聊天区的图片都是固定的一些等级图片,当对应图片的NSTextAttachment创建以后,根据图片名称作为key把NSTextAttachment对象存在 一个字典中,当下次再需要显示这个图片资源时直接到字典中获取上次存放的对象。

成效:加入NSTextAttachment缓存后使用机器人进行聊天房间内内存相对平稳,一小时的内存波动基本在10M以内。

1.2)送礼底版问题

重写送礼相关类的deinit方法,发现当对象移除的时候GiftBottomView的deinit方法没走。说明GiftBottomView存在强引用导致移除屏幕后系
统没有进行内存回收。

排查代码发现是控制GiftBottomView中显示时间、特效播放的timer没有主动销毁导致。

因为GiftBottomView中mTimer变量对timer对象进行强引用,而timer在创建时添加target时对GiftBottomView也进行了强引用。

导致了mTimer和GiftBottomView两个对象之间互相进行了强引用,造成了循环应用。当从屏幕中移除后mTimer和GiftBottomView引用计数器都 不为0,因此也都不会进行内存回收。

结论:当一个类中包含有timer时,在销毁对象的时候需要手动的调用timer的销毁方法进行销毁,不然当前类是不会进行销毁的。

成效:进行对应的修改后再次使用wetest进行测试,在房间内挂机一小时内存增长在10M左右。

2)退出房间内存没有回到原始值

没有回到原始值要考虑两个方面:

  1. 系统缓存
  2. 最定义对象没有释放

系统缓存:当图片资源被第一次后,虽然有时候对图片的引用已经移除但系统不会立即进行回收;在内存不紧张的情况下会存在内存中一段时间,如果再用到这个图片资源后会直接从内存中进行获取。

因为这块并不是真正的泄露泄露,所以我们要把这部分进行排除。

自定义对象没有释放:这部分是我们重点研究的部分。 再次使用instrument的Allocation和Leaks进行内存分析发现:房间控制器roomViewController没有释放。

造成roomViewController的原因主要分为两种:roomViewController内有循环引用;roomViewController有对象没有释放都会造成 roomViewController不释放。

当退出房间后分析内存片段发现: PlayerListCollectionView在线列表、ContributionRankViewController贡献榜等会导致roomViewController没有进行释放。

在线列表排查代码发现和GiftBottomView的原因是一样的,定时刷新在线列表时起了一个定时器,在退出房间时没有进行手动释放造成了循环 引用。
ContributionRankViewController是因为roomViewController中有个变量对其进行了强引用,ContributionRankViewController又对 roomViewController进行了强应用造成了循环引用。

解决:抽取退出房间方法leaveRoomAndClearUI,清除一些需要手动进行释放的类。

成效:解决房间的内存泄露后,退出roomViewController时调用了deinit方法进行了控件的销毁,当非第一次进出房间内存变化在2M以内。

3)思考

经过上面的优化,使用wetest进行性能测试时没有发现很明显的内存泄露。

后续每个月功能开发提测后都安排对应的人进行内存检测:第一发现新增功能是否有新增的内存泄露,第二排查历史功能是否有未发现的内存泄露。

这样每个月都需要对功能进行内存泄露检测。通过instrument进行运行、截取片段、分析内存、找到对应代码。

虽然这样也可以找到内存泄露的点,但是第一影响较小的内存泄露也不容易被发现;第二工作效率非常低,每个版本都需要花费不少的人力进行分析。

实在受不了这种低效率、繁琐的工作。那么有没有一劳永逸或者高效的办法呢?

二 、内存泄露自动检测:

1)调研

上网查询资料发现,有很多人利用runtime的method swzzling技术可以一定程度的进行自动化的内存检测,开发过程中也可以实时的在进行检 测。

主要思路:

  • 通过method swzzlinghook的方式,监听整个类的创建与销毁,在销毁的时候进行一定的处理,判断是否进行了释放。
  • 使用method swzzlinghook采用无侵入的方式,解决常见的内存泄露。

2)method swzzling

iOSRuntime:

Objective-C 是一个动态语言,这意味着它不仅需要一个编译器,也需要一个运行时系统(runtime system)来动态得创建类和对象、进行消息 传递和转发。

平时编写的OC代码,在程序运行过程中,其实最终会转换成Runtime的C语言代码,Runtime是Object-C的幕后工作者。这就是 Objective-C Runtime 系统存在的意义,它是整个Objc运行框架的一块基石。

Objective-C对象收到消息之后,会调用objc_msdSend方法需要在运行期才能解析出来。

发给某对象的全部消息都要有“动态消息派发系统”来处理,该系统会查出对应的方法,并执行其代码。

还有就是与给定的选择子名称相对应的方法是可以在运行期改变的。

可以采用此特性,发挥出巨大优势,因为我们几不需要源代码,也不需要通过集成子类来覆写方法就能改变这个类本身的功能。

这样新功能将在类的所有实例中生效,而不仅限于覆写了相应方法的那些子类实例。此方案经常称为“方法调配”(method swzzling)

类的方法列表会把选择子的名称映射到相应的方法实现之上,使得“动态消息派发系统”能够据此找到应该调用的方法。这些方法均已函数指 针的形式来表示,这种指针叫做IMP。

从最常用的NSString类来解释:NSString类可以相应lowercaseString、uppercaseString、capitalizedString等选择子。

下面映射表中的每个选择子都映射到了不同的IMP上


image.png

Objective-C运行期系统提供的方法能够用来操作这张表。我们可以向其中新增选择子,也可以改变某选择子所对应的方法实现,还可以交换两个选择子所映射的指针。

上面图可以经过操作变成下图的样子


image.png

在上图新的映射表中,多了一个名为newSelector的选择子,lowercaseString、uppercaseString的实现则互换了。上面你的修改都无须编写子类,只要修改了“方法表”的布局,就会反映到程序中所有的NSString的实例之上,这就是次特性的强大之处。

3)UIViewController自动检测的实现

先找一个开发中最常用的类UIViewController进行实现。

UIViewController是一个界面的基础;一般情况下一个界面就是一个UIViewController;当从一个界面返回到上一级界面时,则这个界面应该是被释放的。

也就是当一个 UIViewController被dismiss 后,该 UIViewController 包括它的 view、view 的 subviews 等等将很快被释放。

于是,我们只需在一个 ViewController 被 dismiss 一小段时间后,看看该 UIViewController、它的 view、view 的 subviews 等等是否还存在,就可以判断出当前UIViewController有没有被释放,如果没有被释放那就很大可能是有内存泄露。

具体的方法是:

为ViewController 添加一个方法 -willDealloc 方法。

该方法的作用是,先用一个弱指针指向 self,并延迟一小段时间(3秒)后,通过这个弱指针调用一个方法-alertNotDealloc,而 -alertNotDealloc 主要作用是提供程序当前类有内存泄露。

这样,当我们认为某个对象应该要被释放了,在释放前调用这个方法,如果3秒后它被释放成功,weakSelf 就指向 nil,不会调用到 -alertNotDealloc 方法,也就没有任何响应;

如果它没被释放(则泄露了)则-alertNotDealloc 就会被调用进行弹框。

- (BOOL)willDealloc {
    // 弱引用的self
    __weak id weakSelf = self;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        __strong id strongSelf = weakSelf;
        // 对当前类进行一个weak的引用,如果没有其他引用的时候,会直接事nil
        [strongSelf alerttNotDealloc];
    });
    return YES;
}
- (void)alerttNotDealloc {
    NSString *className = NSStringFromClass([self class]);
    // 打印当前类的名称等信息
    NSLog(@"Possibly Memory Leak.\nIn case that %@ should not be dealloced, override -willDealloc in %@ by returning NO.", className, className);
}

什么时候去调用willdealloc方法呢?也就是一个ViewController什么时候应该被销毁:当dismiss被调用的时候。

但是现有的接口没有办法在每个实例中dismiss被调用时同时调用willDealloc。

这时候就要用到runtime的“方法调配”技术对系统方法和自定义方法的实现进行交换。

重写ViewController的load方法(load方法只有当前类在项目中第一次被加载时调用,也就是只调用一次且比实例中任何方法调用时机都早),在load方法中把自定的swizzled_dismiss方法和系统的实现IMP进行交换。

在swizzled_dismiss方法中获取到退出界面的viewController,调用willDealloc方法。

这样如果3秒后alertNotDealloc方法被调用了则当前viewController有内存泄露,需要进行进一步分析具体泄露点。


// ViewController在load进行方法交换
@implementation UIViewController (MemoryLeak)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self swizzleSEL:@selector(viewDidDisappear:) withSEL:@selector(swizzled_viewDidDisappear:)];
        [self swizzleSEL:@selector(viewWillAppear:) withSEL:@selector(swizzled_viewWillAppear:)];
        [self swizzleSEL:@selector(dismissViewControllerAnimated:completion:) withSEL:@selector(swizzled_dismissViewControllerAnimated:completion:)];
    });
}
- (void)swizzled_dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion {
    // 当前控制器被移除堆栈,也就是控制器被销毁了
    [self swizzled_dismissViewControllerAnimated:flag completion:completion];
    // 调用应该被释放的控制器willDealloc方法
    [self.presentedViewController willDealloc];
}
@end

4)常见类自动检测拓展

经过上面的处理以后,UIViewControoler内有内存泄露时可以及时的进行提醒。需要把自动检测的功能拓展到我们常用的一些类UINavigationController、UITouch等。

考虑到常用的类都是继承自NSObject,给NSObjet添加一个分类增加willDealloc方法,这样在UIViewControoler、UINavigationController等中都可以直接调用willDealloc。

新建需要拓展类的分类,在对应分类的load方法中使用method swzzling重写被移除方法(就是当前对象应该被释放了)。
UIApplication:sendAction:to:from:forEvent:

UITouch-setView:
UINavigationController:popViewControllerAnimated:

在自定义的方法中调用移除对象的willDealloc方法,检测当前对象是否被正常进行了释放。

5)method swzzling的问题

ViewController

完成上面的实现以后确实可以自动进行ViewController的内存检测,但是当ViewController被UINavigationController包裹以后时不生效。

发现被UINavigationController包裹以后ViewController被销毁前不走dismiss方法,而是走UINavigationController的pop方法,因此也就没有调用willDealloc。

这时候能不能通过监听ViewController视图隐藏的时间进行调用呢?但是视图隐藏不代表是被销毁,也可能是跳转到了其他的界面。

这时候需要使用runtime的另外一个技术"关联对象(AssociatedObject)",使用AssociatedObject动态的给当前ViewController添加一个属性kPoppedKey:Bool。

再通过method swzzling监听ViewController的视图出现willAppear、视图隐藏willDisappear方法和UINavigationController的pop方法。

在willAppear把kPoppedKey设置为false,在UINavigationController的pop方法中把kPoppedKey设置为true。

在willDisappear中判断kPoppedKey为true时则调用willDealloc方法进行内存检测。

// ViewController在load进行方法交换
const void *const kHasBeenPoppedKey = &kHasBeenPoppedKey;
@implementation UIViewController (MemoryLeak)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self swizzleSEL:@selector(viewDidDisappear:) withSEL:@selector(swizzled_viewDidDisappear:)];
        [self swizzleSEL:@selector(viewWillAppear:) withSEL:@selector(swizzled_viewWillAppear:)];
    });
}
- (void)swizzled_viewDidDisappear:(BOOL)animated {
    [self swizzled_viewDidDisappear:animated];
    // 当前视图被隐藏,有两种情况:1、跳转到其他控制器(此时不处理)2、被包裹的NAV控制器pop出了堆栈,此时也需要进行销毁处理
    if ([objc_getAssociatedObject(self, kHasBeenPoppedKey) boolValue]) {
        [self willDealloc];
    }
}
- (void)swizzled_viewWillAppear:(BOOL)animated {
    [self swizzled_viewWillAppear:animated];
    // 设置关联对象,判断隐藏的时候是否是进行销毁,还是跳转到其他控制器
    objc_setAssociatedObject(self, kHasBeenPoppedKey, @(NO), OBJC_ASSOCIATION_RETAIN);
}
@end
 
 
// UINavigationController在load进行方法交换
@implementation UINavigationController (MemoryLeak)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self swizzleSEL:@selector(popViewControllerAnimated:) withSEL:@selector(swizzled_popViewControllerAnimated:)];
    });
}
- (UIViewController *)swizzled_popViewControllerAnimated:(BOOL)animated {
    UIViewController *poppedViewController = [self swizzled_popViewControllerAnimated:animated];
    if (!poppedViewController) {
        return nil;
    }
    extern const void *const kHasBeenPoppedKey;
    // 设置关联对象,当前控制器要被销毁了
    objc_setAssociatedObject(poppedViewController, kHasBeenPoppedKey, @(YES), OBJC_ASSOCIATION_RETAIN);
    return poppedViewController;
}
@end

通过上面的操作可以动态监听UIViewController的创建和销毁,当应该被销毁时如果延迟3秒后对象还没有被销毁,则通过弹框通知程序,最好可以把堆栈信息进行打印。

通过类似的操作给UIView、UITouch、UITabBarController、UIPageViewController等添加监听,这样就可以把一些常用的类都进行内存监听,可以在开发过程中发现问题

单例、全局变量

项目中有些类是全局变量或者是以单例的形式存在的,这时候开发过程中只要使用到这样类时就会进行提示,有点影响正常功能开发了。

这时候就需要增加一个白名单机制,当发现当前对象是白名单中的时候就不进行下一步的内存泄露检测。

使用一个全局变量NoCheckMemoryArray记录不需要进行检测的一些类,在willDealloc中进行判断是否在NoCheckMemoryArray,当不在NoCheckMemoryArray再进行内存检测。

6)method swzzling动态检测优势

使用method swzzling的方法可以在实时的进行检测,开发过程中如果代码有问题会直接进行报告,这样可以在提测前就把内存泄露的风险降到最低。

method swzzling动态检测内存泄露的好处:

  • 使用简单,不侵入业务逻辑代码,因为每个类在第一次加载的时候直接就把对应的方法动态添加成功了,而且可以设置只在debug环境下才进行添加
  • 不需要额外的操作,只需开发自己的业务逻辑,在运行调试时就能帮你检测
  • 内存泄露发现及时,更改完代码后一运行即能发现(这点很重要,可以马上就能意识到哪里写错了)
  • 精准,能准确地告诉你哪个对象没被释放
  • 可以添加白名单机制,有些类可能是单例或者就是会有引用,这时候可以通过设置白名单进行屏蔽

三 、APP内存泄露示例:

通过instrument和method swzzling动态检测发现了项目中的一些内存泄露。

按照产生的原因大概有一下几种类型:

1)ViewController中存在NSTimer


image.png

2)ViewController中的代理delegate


image.png

3)ViewController中Block


image.png

4)CoreFoundation对象没有释放


image.png

5)WKWebview设置配置没有移除


image.png

6)当前控制器强引用目标控制器,退出目标控制器时,目标控制器没有销毁

7)当前控制器内view强引用当前控制器,在移除view时要把引用当前控制器这个引用消除

四、思考与总结:

虽然现在iOS使用了ARC的进行内存管理,但是如果不注意的情况还是会引起内存泄露造成严重后果。

内存泄露不止会影响用户体验,个别情况下还会影响功能的正常使用。在平时开发中要熟记引起内存泄露的常见形式,开发时直接进行避免。

ARC模式下内存泄露的情况只有上面列举的一些情况,主要大家写代码的时候都注意点一般情况下不会出现内存泄露的。

Runtime技术提供的便利是iOS中其他方式无法给与的。检测检测内存泄露只是其中的一个应用,具体的其他应用还有很多,这里暂时就不进行列举了。

检测内存泄露使用Runtime进行检测,相交与系统提供的的instrument进行检测效率提升了很多。

instrument需要每次版本的时候把本地修改、新增的功能都进行一定时间的检测,然后根据检测的结果分析、整理是否有内存泄露。发现有内存泄露后还需要具体的堆栈信息,分析多个时间点内存中对象的数量、对象销毁是否正常,然后在找到对应有问题的代码进行修改再进行检测。这种办法和上面提供的自动检测内存的办法比较耗时耗力,还可能存在遗漏的地方。

还有最重要的一点在runtime实际应用中大部分都是采用无侵入的方式进行的,不会和业务代码有任何的耦合更不会影响业务代码的执行。

五、参考:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容