【YFMemoryLeakDetector】人人都能理解的 iOS 記憶體洩露檢測工具類

iOS122發表於2017-11-23

背景

即使到今天,iOS 應用的記憶體洩露檢測,仍然是一個很重要的主題。我在一年前,專案中隨手寫過一個簡單的工具類,當時的確解決了大問題。檢視和控制器相關的記憶體洩露,幾乎都不存在了。後來想著一直就那個工具,寫一篇文章,不過一直沒有寫。

時過境遷,今天在網上搜了下 “iOS 記憶體洩露檢測”,各種討論技術文章,有點頭大。我忍不住看了下自己當時的程式碼,突然感覺自己的思路好特別,好有創意。我真的就是在“建立”時把資料記錄到一個字典裡,在“釋放”時,從字典裡移出物件;所謂的檢測,其實就是列印那個字典,仍然在字典中的很有可能就是洩露嘍。

當然,還是有一些技術細節的。我把舊程式碼適度拆分整理為一個開源庫了,取名為 YFMemoryLeakDetector。本篇,將著重講述簡潔之下,可能不易察覺的一些考量。

注意:這個庫,相當程度上是為當時的專案量身定製的,你可能需要適當修改,才能在自己的專案中真正發揮出它的力量。

核心技術分析

AOP 機制,藉助 Aspects 庫實現

Aspects 這個庫的基本用法,我專門說過,大家可以參考 Aspects– iOS的AOP面向切面程式設計的庫。當然,用黑魔法直接操作執行時,也是很酷的。不過我當時的確是因為偷懶,才用的 Aspects。一直到現在,我依然覺得,它可能比黑魔法更可靠些。

在字典中直接儲存指標地址,而不是直接儲存物件自身

儲存指標地址的好處是,就是不會因為儲存本身影響物件的引用計數。當然,指標地址本身,在 OC 中,其實就是物件自身。而要想得到存地址,不存物件的效果,就要祭出整個工具庫的靈魂函式:

NSValue * key = [NSValue valueWithPointer: (__bridge const void * _Nullable)(info.instance)];

將物件轉換為 NSValue,直接以 NSValue 為鍵,來標記物件。這句程式碼,是整個機制的靈魂所在,也是比其他類似的記憶體洩露分析庫更簡潔的重要原因之一。我當時也是搜遍的整個網路,才知道自己要的究竟是什麼。

另外,還有一點必須提一下, NSValue 是可以在反向轉換為 oc 物件的,這有利於你在拿到工具庫提供的洩露資訊後,進一步定位和分析問題:

UIViewController * vc = (UIViewController *)[key pointerValue];

對控制器和檢視,採用不同的攔截策略

  • 物件銷燬,統一攔截的是 dealloc。現在網上的很多策略,基本也是這樣。

  • 物件建立,對於檢視,攔截的是 willMoveToSuperview: ;對於控制器攔截的是 viewDidLoad 。直到現在,我依然以為,沒有呼叫過這兩個方法的檢視或控制器物件,本身沒有多大的攔截價值。當然,這依然因專案而異。作為一個工具類,只要它能解決大多數場景下的問題,我覺得就可以了。

load 時,自動開啟監測

所以,你只要把工具庫原始碼拖拽到專案中,不需要任何修改,就可以自動監測記憶體洩露情況了。然後在需要的地方,在合適的時候,去讀取 YFMemoryLeakDetector 的單例屬性,分析結果即可。當然,這是我今天重構優化過的版本。原來是需要手動初始化的,好 Low,當時寫的!

+ (void)load
{
    [[YFMemoryLeakDetector sharedInstance] setup];
}

“見碼如晤”

YFMemoryLeakDetector.h 標頭檔案部分,主要簡化為暴露了儲存可能有記憶體洩露情況的檢視和控制器的字典屬性;同時提供了一個單例方法,以便於具體分析和操作記憶體分析情況。

#import <Foundation/Foundation.h>

/**
 *  分析頁面和頁面內檢視是否有記憶體洩露的情況.
 */
@interface  YFMemoryLeakDetector: NSObject

#pragma mark - 屬性.

/*
  已載入,但尚未正確釋放,有記憶體風險的控制器物件.
 
 以指標地址為key,以物件字串為值.所以不用擔心因為記錄本身而引起的記憶體洩露問題.
 
 必要時,可以使用類似 (UIViewController *)[key pointerValue] 的語法來獲取原始的 OC物件來進一步做些過濾操作.
 */
@property (strong, atomic) NSMutableDictionary * loadedViewControllers;

/*
 已載入,但尚未正確釋放,有記憶體風險的檢視物件.
 
 以指標地址為key,以物件字串為值.所以不用擔心因為記錄本身而引起的記憶體洩露問題.
 
 必要時,可以使用類似 (UIView *)[key pointerValue] 的語法來獲取原始的 OC物件來進一步做些過濾操作.
 */
@property (strong, atomic) NSMutableDictionary * loadedViews; //!< 已載入的檢視.



#pragma mark - 單例方法.
+(YFMemoryLeakDetector *) sharedInstance;
@end

YFMemoryLeakDetector.m 實現,藉助於 AspectsvalueWithPointer: 程式碼大大簡化。

#import <objc/runtime.h>
#import <UIKit/UIKit.h>

#import "YFMemoryLeakDetector.h"
#import "Aspects.h"

@interface  YFMemoryLeakDetector()
@end

@implementation  YFMemoryLeakDetector

static YFMemoryLeakDetector * sharedLocalSession = nil;

+ (void)load
{
    [[YFMemoryLeakDetector sharedInstance] setup];
}

+(YFMemoryLeakDetector *) sharedInstance{
    @synchronized(self){
        if (sharedLocalSession == nil) {
            sharedLocalSession = [[self alloc] init];
        }
    }
    return  sharedLocalSession;
}


- (void)setup
{
    self.loadedViewControllers = [NSMutableDictionary dictionaryWithCapacity: 42];
    self.loadedViews = [NSMutableDictionary dictionaryWithCapacity:42];
    
    /* 控制器迴圈引用的檢測. */
    [UIViewController aspect_hookSelector:@selector(viewDidLoad) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> info) {
        NSValue * key = [NSValue valueWithPointer: (__bridge const void * _Nullable)(info.instance)];

        [self.loadedViewControllers setObject:[NSString stringWithFormat:@"%@", info.instance] forKey:key];
    }error:NULL];
    
    [UIViewController aspect_hookSelector:NSSelectorFromString(@"dealloc") withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info) {
        NSValue * key = [NSValue valueWithPointer: (__bridge const void * _Nullable)(info.instance)];

        [self.loadedViewControllers removeObjectForKey: key];
    }error:NULL];
    
    /* 檢視迴圈引用的檢測. */
    /* 只捕捉已經從父檢視移除,卻未釋放的檢視.以指標區分. */
    [UIView aspect_hookSelector:@selector(willMoveToSuperview:) withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info, UIView * superview){
        /* 過濾以 _ 開頭的私有類. */
        NSString * viewClassname = NSStringFromClass(object_getClass(info.instance));
        if ([viewClassname hasPrefix:@"_"]) {
            return;
        }
        
        /* 相容處理使用了KVO機制監測 delloc 方法的庫,如 RAC. */
        if ([viewClassname hasPrefix:@"NSKVONotifying_"]) {
            return;
        }
        
        NSValue * key = [NSValue valueWithPointer: (__bridge const void * _Nullable)(info.instance)];
        
        /* 從父檢視移除時,就直接判定為已釋放.
         這樣做的合理性在於:當檢視從父檢視移除後,一般是很難再出發迴圈引用的條件了,所以可適度忽略.
         */
        if (!superview) {
            [self.loadedViews removeObjectForKey: key];
        }
        
        NSMutableDictionary * obj = [self.loadedViews objectForKey: key];
        
        if (obj) { /* 一個 UIView 檢視,只記錄一次即可.因為一個UIView,最多隻被 delloc 一次. */
            return;
        }
        
        [self.loadedViews setObject: [NSString stringWithFormat:@"%@", info.instance] forKey:key];
        
        /* 僅對有效例項進行捕捉.直接捕捉類物件,會引起未知崩潰,尤其涉及到和其他有KVO機制的類庫配合使用時. */
        [info.instance aspect_hookSelector:NSSelectorFromString(@"dealloc") withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info){
            [self.loadedViews removeObjectForKey: key];
        }error:NULL];
    }error:NULL];
}
@end

使用示例:

這裡展示一個基於工具類,二次分析的示例:

YFMemoryLeakDetector * memoryLeakDetector = [YFMemoryLeakDetector sharedInstance];
        
/* 控制器檢測結果的輸出. */
[memoryLeakDetector.loadedViewControllers enumerateKeysAndObjectsUsingBlock:^(NSValue *  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
    UIViewController * vc = (UIViewController *)[key pointerValue];
    if (!vc.parentViewController) { /* 進一步過濾掉有父控制器的控制器. */
        NSLog(@"有記憶體洩露風險的控制器: %@", obj);
    }
}];
    
/* 檢視檢測結果的輸出. */
[memoryLeakDetector.loadedViews enumerateKeysAndObjectsUsingBlock:^(NSValue *  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
    UIView * view = (UIView *)[key pointerValue];
    if (!view.superview) { /* 進一步過濾掉有父檢視的檢視,即只輸出一組檢視的根節點,這樣便於更進一步定位問題. */
        NSLog(@"有記憶體洩露風險的檢視: %@", obj);
    }
}];

參考文章

相關文章