李峰峰博客

MLeaksFinder 实现分析

2019-10-06

一、概述

MLeaksFinder 是腾讯 WeRead 团队开源的 iOS 内存泄露检测工具。
MLeaksFinder 的使用非常简单,使用 pod 添加了 MLeaksFinder 依赖之后,如果页面出现了内存泄露,APP 进行弹窗提示:

接下来,通过 MLeaksFinder 源码,看下 MLeaksFinder 的实现原理。

二、实现原理

1、willDealloc 方法

MLeaksFinder 在 NSObject (MemoryLeak) 新增了一个 willDealloc 方法,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- (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;

__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]);
}

willDealloc 方法主要逻辑如下:

  • 白名单校验,过滤掉一些系统 UI 组件。
    • 某些系统的私有 View,由于系统机制(也可能是 BUG)原因不会被释放,所以增加白名单过滤掉这些 View
  • 判断 self 是否是 LatestSender,如果是则不检测。
    • 某些情况下,刚刚触发的事件(点击、操作)可能会暂时保留对象,可能会导致短期内无法释放。此处判断是为了防止出现误报。
    • UIApplication+MemoryLeak 中 hook 了 sendAction:to:from:forEvent: 方法,以 kLatestSenderKey 作为关联对象的 Key 将 LatestSender 保存到了 UIApplication 中。
  • 延迟 2 秒,如果 2 秒后 self 还没有销毁,则判定发生内存泄露。
    • 对象销毁后,当前的 GCD 延迟任务仍然会执行,但 self 变成了 nilassertNotDealloc 就执行不到了。
    • 对象被销毁后,dispatch_after 提交的任务仍会执行,这是因为任务的调度与对象的生命周期无关。调用 dispatch_after 时,GCD 会将 block 注册到一个定时器中。当指定时间到达时,定时器会将该 block 提交到目标队列,无论对象是否已经被销毁。

assertNotDealloc 方法主要逻辑如下:

  • 判断 self 的父指针(parentPtrs)是否有内存泄露,如果有,直接 return
    • 其父指针集合中有内存泄露,那么当前对象很有可能也会发生泄漏。父对象的泄漏弹窗提示已经包含了关于父子关系链的相关信息,子对象不再重复提示,避免过多弹窗。
  • 调用 MLeakedObjectProxyaddLeakedObject: 方法。

接下来看下 addLeakedObject: 方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
+ (void)addLeakedObject:(id)object {
NSAssert([NSThread isMainThread], @"Must be in main thread.");

MLeakedObjectProxy *proxy = [[MLeakedObjectProxy alloc] init];
proxy.object = object;
proxy.objectPtr = @((uintptr_t)object);
proxy.viewStack = [object viewStack];
static const void *const kLeakedObjectProxyKey = &kLeakedObjectProxyKey;
objc_setAssociatedObject(object, kLeakedObjectProxyKey, proxy, OBJC_ASSOCIATION_RETAIN);

[leakedObjectPtrs addObject:proxy.objectPtr];

#if _INTERNAL_MLF_RC_ENABLED
[MLeaksMessenger alertWithTitle:@"Memory Leak"
message:[NSString stringWithFormat:@"%@", proxy.viewStack]
delegate:proxy
additionalButtonTitle:@"Retain Cycle"];
#else
[MLeaksMessenger alertWithTitle:@"Memory Leak"
message:[NSString stringWithFormat:@"%@", proxy.viewStack]];
#endif
}

可以看到,该方法中主要逻辑就是将发生内存泄露的 self 保存到 leakedObjectPtrs 中,并 alert 弹窗提示开发者发生了内存泄露。

2、willDealloc 调用时机

既然通过 NSObject+MemoryLeak 新增的 willDealloc 方法中检测内存泄露并弹窗提示,那么 willDealloc 方法调用时机是什么呢?

(1)UINavigationController (MemoryLeak)

忽略其中 iPad 适配逻辑,精简后的 UINavigationController (MemoryLeak) 主要逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
static const void *const kPoppedDetailVCKey = &kPoppedDetailVCKey;

@implementation UINavigationController (MemoryLeak)

+ (void)load {
// 确保方法交换只执行一次
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self swizzleSEL:@selector(popViewControllerAnimated:) withSEL:@selector(swizzled_popViewControllerAnimated:)];
[self swizzleSEL:@selector(popToViewController:animated:) withSEL:@selector(swizzled_popToViewController:animated:)];
[self swizzleSEL:@selector(popToRootViewControllerAnimated:) withSEL:@selector(swizzled_popToRootViewControllerAnimated:)];
});
}

- (UIViewController *)swizzled_popViewControllerAnimated:(BOOL)animated {
UIViewController *poppedViewController = [self swizzled_popViewControllerAnimated:animated];

// 如果没有被弹出的视图控制器,则返回 nil
if (!poppedViewController) {
return nil;
}

// 为被弹出的视图控制器设置 kHasBeenPoppedKey 关联对象,用于标记该视图控制器已经被弹出
extern const void *const kHasBeenPoppedKey;
objc_setAssociatedObject(poppedViewController, kHasBeenPoppedKey, @(YES), OBJC_ASSOCIATION_RETAIN);

return poppedViewController;
}

- (NSArray<UIViewController *> *)swizzled_popToViewController:(UIViewController *)viewController animated:(BOOL)animated {
NSArray<UIViewController *> *poppedViewControllers = [self swizzled_popToViewController:viewController animated:animated];

// 对弹出的每个视图控制器进行内存泄漏检查
for (UIViewController *viewController in poppedViewControllers) {
[viewController willDealloc];
}

return poppedViewControllers;
}

- (NSArray<UIViewController *> *)swizzled_popToRootViewControllerAnimated:(BOOL)animated {
NSArray<UIViewController *> *poppedViewControllers = [self swizzled_popToRootViewControllerAnimated:animated];

// 对弹出的每个视图控制器进行内存泄漏检查
for (UIViewController *viewController in poppedViewControllers) {
[viewController willDealloc];
}

return poppedViewControllers;
}

- (BOOL)willDealloc {
// 调用父类的 willDealloc 方法,如果返回 NO,则当前对象无需检测
if (![super willDealloc]) {
return NO;
}

// 对导航控制器的所有子视图控制器进行内存泄漏检查
[self willReleaseChildren:self.viewControllers];

return YES;
}

@end

可以看到,上面主要 hook 了 UINavigationController 的下面这几个方法:

  • popViewControllerAnimated:
    • 当前 UIViewController 被 pop 后,将该 UIViewController 标记成 BeenPopped
  • popToViewController:animated:
    • 该方法的返回值是包含所有被 pop 的 UIViewController 数组。
    • 遍历该数组,逐个调用内部 UIViewControllerwillDealloc
  • popToRootViewControllerAnimated:
    • 该方法的返回值也是包含所有被 pop 的 UIViewController 数组。
    • 遍历该数组,逐个调用内部 UIViewControllerwillDealloc

可以看到,在 UIViewController 被 pop 后,该 UIViewControllerwillDealloc 方法会被调用。

(2)UIViewController (MemoryLeak)

UIViewController (MemoryLeak) 中逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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:)];
[self swizzleSEL:@selector(dismissViewControllerAnimated:completion:) withSEL:@selector(swizzled_dismissViewControllerAnimated:completion:)];
});
}

- (void)swizzled_viewDidDisappear:(BOOL)animated {
[self swizzled_viewDidDisappear:animated];

// 如果视图控制器被标记为已弹出,则进行内存泄漏检测
if ([objc_getAssociatedObject(self, kHasBeenPoppedKey) boolValue]) {
[self willDealloc];
}
}

- (void)swizzled_viewWillAppear:(BOOL)animated {
[self swizzled_viewWillAppear:animated];

// 设置已弹出标记为 NO,表示视图控制器将要显示
objc_setAssociatedObject(self, kHasBeenPoppedKey, @(NO), OBJC_ASSOCIATION_RETAIN);
}

- (void)swizzled_dismissViewControllerAnimated:(BOOL)flag completion:(void (^)(void))completion {
[self swizzled_dismissViewControllerAnimated:flag completion:completion];

// 获取被 dismiss 的视图控制器
UIViewController *dismissedViewController = self.presentedViewController;
if (!dismissedViewController && self.presentingViewController) {
dismissedViewController = self;
}

// 如果没有被 dismiss 的视图控制器,则返回
if (!dismissedViewController) return;

// 对被 dismiss 的视图控制器进行内存泄漏检测
[dismissedViewController willDealloc];
}

- (BOOL)willDealloc {
// 调用父类的 willDealloc 方法,如果返回 NO,则当前对象无需检测
if (![super willDealloc]) {
return NO;
}

// 对视图控制器的所有子视图控制器进行内存泄漏检测
[self willReleaseChildren:self.childViewControllers];
// 对当前展示的视图控制器进行内存泄漏检测
[self willReleaseChild:self.presentedViewController];

// 如果视图已加载,对视图本身进行内存泄漏检测
if (self.isViewLoaded) {
[self willReleaseChild:self.view];
}

return YES;
}

@end

上面主要 hook 了 UIViewController 的下面这几个方法:

  • viewDidDisappear:
    • 如果当前 UIViewController 被标记为已弹出(kHasBeenPoppedKey),则会调用 willDealloc 方法进行内存泄漏检测
  • viewWillAppear:
    • UIViewController 的已弹出标记重置为 NO。
  • dismissViewControllerAnimated:completion:
    • 获取被 dismiss 的 UIViewController 并调用 willDealloc 方法进行内存泄漏检测。

由于 UIViewController 的这个 category 中也实现了 willDealloc 方法,所以上一步 UINavigationController (MemoryLeak) 中,调用的 willDealloc 实际上这里的 willDealloc 方法。

UIViewController(MemoryLeak)willDealloc 中又调用了 [super willDealloc],此处会走进前面的 NSObject (MemoryLeak) 中的 willDealloc 中,执行真正的内存泄露检测逻辑。

(3)总结

总结前述主要逻辑如下:

  • NSObject (MemoryLeak)willDealloc 是内存泄露检测核心逻辑。
    • 使用 GCD 创建 2s 的延迟任务,如果 2s 后对象还没释放,则弹窗提示发生内存泄露。
    • 对象销毁后,当前的 GCD 延迟任务仍然会执行,但 self 变成了 nil,所以后续弹窗提示的方法就执行不到了。
  • willDealloc 执行时机如下:
    • 调用了 UINavigationController 如下 pop 方法后,遍历方法返回的被 pop 的 UIViewController 数组,逐个调用内部 UIViewControllerwillDealloc:
      • popToViewController:animated:
      • popToRootViewControllerAnimated:
    • UIViewControllerviewDidDisappear 方法被调用,且 UIViewController 已经被标记成 HasBeenPopped 时,调用 willDealloc
      • UINavigationController (MemoryLeak) hook popViewControllerAnimated: 方法,当 UIViewController 被 pop 时将其标记成 HasBeenPopped
    • UIViewController 被 dismiss 时,调用 willDealloc
Tags: 源码