在iOS應用上進行記憶體監控

語言: CN / TW / HK

前言

最近在研究如何在iOS應用中進行一些簡單的記憶體監控,其中主要包括記憶體洩漏和記憶體佔用。開始記錄自己的踩坑歷程前,先推薦一篇文章:從 OOM 到 iOS 記憶體管理 | 創作者訓練營。文章裡面對於iOS的記憶體基礎知識介紹地比較全面。本文主要介紹如何除錯記憶體洩漏、程式碼檢測記憶體洩漏以及記憶體佔用量獲取等基礎內容,篇幅較長,可根據標題選擇性閱讀。

記憶體洩漏工具檢測

關於記憶體洩漏的檢測,主要還是在debug階段。Xcode也提供了一些工具用於檢測記憶體使用和記憶體洩漏。

毫無疑問,迴圈引用是造成記憶體洩漏的主要原因。下面是一個簡單的demo模擬迴圈引用。

class Server: NSObject {
    var clients: [Client] = []

    func add(client: Client) {
        self.clients.append(client)
    }
}

class Client: NSObject {
    var server: Server?

    override init () {
        super.init()
    }
}

class ViewController2: UIViewController {
    let client: Client
    let server: Server
    
    init() {
        self.client = Client.init()
        self.server = Server.init()
        super.init(nibName: nil, bundle: nil)
        self.client.server = server
        self.server.add(client: client)
    }
}
複製程式碼

通過程式碼可以推測出,Server和Client兩個物件的例項會造成迴圈引用,最終導致記憶體洩漏。以下介紹兩種Xcode自帶的記憶體洩漏檢測工具。(ps: 使用的Xcode版本為12.0)

  • Instruments的Allocations和Leaks可以檢測app執行過程中的記憶體使用情況以及記憶體洩漏的情況,這也是筆者日常開發最常使用檢測記憶體的方式。以下是使用Leaks檢測到記憶體洩漏的截圖:

這裡就能清晰的看到client和server兩個物件是沒有被釋放的,且形成了迴圈引用。(研究中發現,可能有部分洩漏的情況用Leaks無法檢測到,這個因為沒有深究,如果有了解的朋友可以下方留言討論一下。)

  • Memory Graph是檢視app的記憶體使用情況的功能,可清晰檢視物件的引用鏈。

在app執行過程中點選圖中按鈕即可開啟Memory Graph。接下來可以看看如果根據上述demo檢測出來的效果。 上圖中可得知,在理應釋放的client和server兩個物件因為迴圈引用的原因造成了記憶體洩漏。一般在如果是洩漏的物件,後面都會帶一個紫色的標記。

ps: 比較尷尬的是,筆者在自己的iPhone7上執行公司的專案無法開啟會提示如圖所示,目前沒有找到合適的解決方法:

還有一個方法是通過匯出.memgraph在命令列上檢視一些記憶體的佔用情況。這裡我們簡單介紹以下使用leaks命令檢視記憶體洩漏,還有一些高階玩法可以參考此文章:讓我們來除錯 iOS 記憶體 - Memory Graph

匯出.memgraph檔案:在上述的模式下點選File->Export Memory Graph 終端使用leaks命令即可檢視記憶體洩漏分析,譬如有迴圈引用發生時會打印出相關的引用:

也可開啟 malloc 日誌堆疊,獲取到根節點的回溯。

記憶體洩漏程式碼檢測

上文講到的檢測手段都是藉助Xcode工具實現的,比較依賴pc端。那是否有純程式碼的檢測手段呢?因為筆者的目的是希望可以在release環境下檢測洩漏的。而且如果可以程式碼自動檢測,那就可以在日常開發除錯階段自動發現一些記憶體洩漏的問題。答案是有的,接下來會介紹筆者在查閱資料過程中研究的3個開源庫。通過閱讀開源庫的原始碼和一些文章筆者得出了一個公式,後面的記憶體洩漏程式碼檢測也可通過該公式進行總結。

這裡我將記憶體洩漏的檢測總結成兩個方面觸發時機與校驗方法。iOS的記憶體洩漏程式碼檢測有一定的侷限性,對比了一些開源專案的實現都需要尋找一個合理的觸發時機(比如MLeaksFinder會通過hook ViewController的生命週期來作為起點檢測,這個會在後續講到)。這裡轉載一個簡單方案是採用Method Swizzling的方式hook了vc的方法進行觸發校驗。iOS自定記憶體監控

結合上述的示例套用公式:

  • 觸發時機:通過Method Swizzling的方式替換vc的dismiss和viewWillDisappear來間接判斷vc的銷燬。以此來觸發檢測。
  • 校驗方法:通過延時2s後再呼叫vc自身的一個擴充套件的例項方法。
  • 檢測結果:正常情況下,2s之後vc在銷燬後物件會被釋放,則無法呼叫自身的例項方法。若能呼叫成功,則說明該物件已洩漏。

上述簡單的思路基本可以貫穿大多數記憶體洩漏的程式碼檢測實現,後續介紹的開源庫也是大同小異。

RIBs-LeakDetector

RIBs是uber開源的一個app架構設計框架,筆者在庫中找到了一個檢測記憶體洩漏的工具LeakDetector.swift 比較巧妙的是,這裡使用了一個NSMapTable<AnyObject, AnyObject>.strongToWeakObjects()的物件trackingObjects去存放需要觀察的物件。文件的解釋是其key值為強引用而value值為弱引用。

比較不友好的是,該庫的觸發時機需要開發者自行尋找,譬如在vc的deinit上可以監控其viewModel是否發生記憶體洩漏:

deinit {
	LeakDetector.instance.expectDeallocate(object: viewModel)
}
複製程式碼

套用公式:

  • 觸發時機:需要開發者自行尋找觸發時機,譬如在vc的deinit
  • 校驗方法:事先將需要觀察的物件新增到NSMapTable,延時指定時間後根據對應的key獲取value
  • 檢測結果:若value為空說明記憶體已釋放(弱引用),反之發生了記憶體洩漏。

優點:值得借鑑的是,1、借用了NSMapTable的特性;2、計時部分的邏輯使用RxSwift實現,有助於檢測事件的取消還有程式碼的解耦。

缺點:1、依賴了RxSwift這種量級較大的第三方庫,不夠輕量;2、觸發時機需要自行尋找,可能對於邏輯的相容比較差。

LifetimeTracker

LifetimeTracker這是一個比較有意思的庫,其校驗是否洩漏的依據是根據開發者設定該物件最大數判斷的,以下是擷取其readme的用法:

實現LifetimeTrackable協議內有一個LifetimeConfiguration類物件完成maxCount的配置,在適當的時候譬如init方法呼叫trackLifetime即可觸發洩漏的檢查。其原理是在內部有一個集合存放每個物件的標示(這裡是將物件的資訊生成一個model),然後在每次觸發trackLifetime時進行數量的校驗。具體的原始碼實現可檢視LifetimeTracker.swift

ps: 值得注意的是,每次trackLifetime數量都會+1,而這裡還存在一個onDealloc時機會-1。這裡使用關聯屬性(如上圖),事先關聯一個物件到被觀察物件,若被觀察物件呼叫deinit時,此關聯屬性物件的deinit也會被跟著呼叫。這樣就能間接判斷被觀察物件的釋放了。有關關聯屬性的介紹可以看看這篇文章objc_setAssociatedObject 關聯詳解

套用公式:

  • 觸發時機:需要開發者在物件init時呼叫trackLifetime
  • 校驗方法:trackLifetime時會新增一次被觀察物件的計數並關聯一個物件,當關聯物件deinit呼叫時自動將計數-1
  • 檢測結果:若計數大於LifetimeConfiguration的maxCount則為洩漏。

ps: 該庫中還有分組(group)的設計,我的理解是可能某些型別會組合使用,分組可以更好的管理。這裡就不作過多的介紹了。

優點:1、比依賴vc等檢視;2、根據objc_setAssociatedObject被動檢測物件的釋放,無需通過延時主動檢測。

缺點:1、需要預先設定maxCount對於使用場景的比較限制;2、洩漏的檢查需要通過呼叫trackLifetime觸發,觸發時機比較靠後。

MLeaksFinder

MLeaksFinder是騰訊開源的一個記憶體洩漏檢測庫。原理大致與本節開頭提供的例子相似:通過Method Swizzling的方式替換vc的生命週期,繼而觸發檢查。

在物件load的時候自動替換,這樣可以實現無侵入的監控檢視級別的記憶體洩漏,接下來我們來看看整個庫的靈魂即觸發檢查的方法:

以上是庫中對於ViewController的擴充套件,靈魂就是willDealloc方法。這裡是觸發vc的dismiss時會觸發willDealloc方法,方法中會將vc的子view、子vc新增到觀察中,這樣就能最大限度地觀察檢視物件的洩漏。 我們再來看看willReleaseChildren、willReleaseChild方法做了什麼。

以上是庫中對於NSObject的擴充套件,實際上就是通過objc_setAssociatedObject關聯一個物件的地址集合(parentPtrs)及物件的引用棧(viewStack),這裡的操作是可以讓子物件也擁有其上級的物件地址資訊以及完整的引用棧。最終也是呼叫子物件的willDealloc。

ps: willDealloc的實現也是之前的老方法了,延遲2s呼叫物件的方法,從而判斷出是否存在洩漏。

比較遺憾的是,如果要觀察自定義型別的屬性,還是需要手動時機觸發,譬如觀察vc的viewModel是否存在洩漏:

    @objc dynamic public override func willDealloc() -> Bool {
        if !super.willDealloc() {
            return false
        }
        self.willReleaseChildren([
            self.viewModel
            ])
        return true
    }
複製程式碼

套用公式:

  • 觸發時機:以vc的銷燬作為起點逐層往下呼叫willDealloc
  • 校驗方法:通過延時2s後再呼叫NSObject的擴充套件方法assertNotDealloc。
  • 檢測結果:一般能呼叫成功,大概率就是發生了洩漏。

優點:1、無需侵入性的觀察檢視級別的物件;2、尋找觸發時機的相容性邏輯比較完善;3、記錄的引用棧比較完善,方便定位問題。

缺點:1、對於非檢視物件需要手動處理;2、依賴NSObject,對於swift上非繼承NSObject的型別不太友好;3、對於觀察非全域性屬性比如方法內的物件可能較難找到觸發時機。

記憶體洩漏程式碼檢測總結

綜上幾個開源庫的設計分析,其實都是非常符合本節開頭筆者提出的公式的。比較遺憾的是,它們對於程式碼都有或多或少的侵入性,而且對於流程上的(方法內)物件觀察對於程式碼的侵入性較大不太友好。綜合了以上分析的優缺點,最終筆者還是選擇了MLeaksFinder,後續會花少部分時間介紹筆者對於該庫的小改動。

MLeaksFinder記憶體洩漏記錄程式碼分析

如上圖所示,MLeaksFinder一旦檢查到記憶體洩漏:

  • 首先會將一個MLeakedObjectProxy物件關聯到洩漏物件中。主要的作用是觀察洩漏物件的生命週期一旦釋放後就會觸發MLeakedObjectProxy物件的釋放。這種做法類似上述提到的LifetimeTracker。
  • 在洩漏時和釋放時都會有彈窗提示開發者響應的資訊。

結合上述的分析,若想在洩漏之後進行一些自定義的記錄(如開發日誌記錄等),就可以在彈窗的地方加入或修改為自己的邏輯。這部分實現在DoraemonKit中的DoraemonKit-MLeaksFinder有很好的體現。

記憶體佔用量

應用效能檢測中,包含了當前記憶體佔用量的獲取,這裡有兩篇文章分享一下:從 OOM 到 iOS 記憶體管理 | 創作者訓練營iOS開發--APP效能檢測方案彙總(一)。裡面介紹得比較詳細,這裡就不再過多贅述了😁 。以下是通過資料總結的一些方法,有需要的可以拿去用。

  • 獲取當前app佔用記憶體量
#include <mach/mach.h>

+ (NSInteger)useMemoryForApp {
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
    if(kernelReturn == KERN_SUCCESS)
    {
        int64_t memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
        return (NSInteger)(memoryUsageInByte/1024/1024);
    }
    else
    {
        return -1;
    }
}
複製程式碼
  • 裝置總記憶體
#include <mach/mach.h>

+ (NSInteger)totalMemoryForDevice {
    return (NSInteger)([NSProcessInfo processInfo].physicalMemory/1024/1024);
}
複製程式碼
  • iOS13之後可以獲取app當前可用的記憶體
#import <os/proc.h>

+ (NSInteger)availableSizeOfMemory {
    if (@available(iOS 13.0, *)) {
        return (NSInteger)(os_proc_available_memory() / 1024.0 / 1024.0);
    }
    return 0;
}
複製程式碼
  • 當然也可以結合可用記憶體和已用記憶體計算出app總共可使用的記憶體,ps: 下述程式碼僅供參考。
#include <mach/mach.h>
#import <os/proc.h>

+ (NSInteger)limitSizeOfMemory {
    if (@available(iOS 13.0, *)) {
        task_vm_info_data_t taskInfo;
        mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
        kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

        if (kernReturn != KERN_SUCCESS) {
            return 0;
        }
        return (NSInteger)((taskInfo.phys_footprint + os_proc_available_memory()) / 1024.0 / 1024.0);
    } else {
        NSInteger totalMemory = [Utils totalMemoryForDevice];
        NSInteger limitMemory;
        if (totalMemory <= 1024) {
            limitMemory = totalMemory * 0.45;
        } else if (totalMemory >= 1024 && totalMemory <= 2048) {
            limitMemory = totalMemory * 0.45;
        } else if (totalMemory >= 2048 && totalMemory <= 3072) {
            limitMemory = totalMemory * 0.50;
        } else {
            limitMemory = totalMemory * 0.55;
        }
        return limitMemory;
    }
}
複製程式碼

最後關於效能監控的程式碼強烈建議大家去閱讀DoraemonKit的原始碼,裡面的DoraemonHealthManager.m囊括了許多常用的效能檢測程式碼。

MetricKit

順帶一提,在研究過程中發現iOS在13之後推出了一個性能監控的api,可以定期地返回一些效能資料給到開發者。官方文件是寫著每24小時回撥一次(坑爹的設計,開發階段不知道如何除錯)。目前不知如果未上架的應用能否使用,有興趣的可以看看iOS 效能優化:使用 MetricKit 2.0 收集資料

最後

本文主要介紹如何除錯記憶體洩漏、程式碼檢測記憶體洩漏以及記憶體佔用量獲取等基礎內容。總的來說,目前監控記憶體洩漏的做法侷限性還是有的,如果還有較好方案,歡迎在下方留言討論。