iOS開發實踐-OOM治理

KenshinCui發表於2020-06-17

概覽

說起iOS的OOM問題大家第一想到的應該更多的是記憶體洩漏(Memory Leak),因為無論是從早期的MRC還是2011年Apple推出的ARC記憶體洩漏問題一直是iOS開發者比較重視的問題,比如我們熟悉的 Instruments Leaks 分析工具,Xcode 8 推出的 Memory Graph 等都是官方提供的記憶體洩漏分析工具,除此之外還有類似於FBRetainCycleDetector的第三方工具。不過事實上記憶體洩漏僅僅是造成OOM問題的一個原因而已,實際開發過程中造成OOM的原因有很多,本文試圖從實踐的角度來分析造成OOM的諸多情況以及解決辦法。

造成OOM的原因

造成OOM的直接原因是iOS的 Jetsam 機制造成的,在Apple的 Low Memory Reports中解釋了具體的執行情況:當記憶體不足時,系統向當前執行中的App發起applicationDidReceiveMemoryWarning(_ application: UIApplication) 呼叫和 UIApplication.didReceiveMemoryWarningNotification 通知,如果記憶體仍然不夠用則會殺掉一些後臺程式,如果仍然吃緊就會殺掉當前App。

關於 Jetsam 實現機制其實蘋果已經開源了XNU程式碼,可以在這裡檢視,核心程式碼在 kern_memorystatus 感興趣可以閱讀,其中包含了很多系統呼叫函式,可以幫助開發者做一些OOM監控等。

一、記憶體洩漏

記憶體洩漏造成記憶體被持久佔用無法釋放,對OOM的影響可大可小,多數情況下並非洩漏的類直接造成大記憶體佔用而是無法釋放的類引用了比較大的資源造成連鎖反應最終形成OOM。一般分析記憶體洩漏的工具推薦使用Leaks,後來Apple提供了比較方便的Memory Graph。

Instruments Leaks

Leaks應該是被所有開發者推薦的工具,幾乎搜尋記憶體洩漏就會提到這個工具,但是很多朋友不清楚其實當前Leaks的作用沒有那麼大,多數時候記憶體洩漏使用Leaks是分析不出來的。不妨執行下面的一個再簡單不過的洩漏情況(在一個導航控制器Push到下面的控制器然後Pop出去進行驗證):

class Demo1ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.customView.block = {
            print(self.view.bounds)
        }
        self.view.addSubview(self.customView)
    }
    
    private lazy var customView:CustomView = {
        let temp = CustomView()
        
        return temp
    }()

    deinit {
        print("Demo1ViewController deinit")
    }
}


class CustomView:UIView {
    var block:(()->Void)?
}

上面這段程式碼有明顯的迴圈引用造成的記憶體洩漏,但是前面說的兩大工具幾乎都無能為力,首先Leaks是:

-w727

網路上有大量的文章去介紹Leaks如何使用等以至於讓有些同學以為Leaks是一個無所不能的記憶體洩漏分析工具,事實上Leaks在當前iOS開發環境下檢測出來的記憶體洩漏比較有限。之所以這樣需要先了解一個App的記憶體包括哪幾部分:

  1. Leaked memory: Memory unreferenced by your application that cannot be used again or freed (also detectable by using the Leaks instrument).

  2. Abandoned memory: Memory still referenced by your application that has no useful purpose.

  3. Cached memory: Memory still referenced by your application that might be used again for better performance.

Leaked memory正是Leaks工具所能發現的記憶體,這部分記憶體屬於沒有任何物件引用的記憶體,在記憶體活動圖中是是不可達記憶體。

Abandoned memory在應用記憶體活動圖中存在,但是因為應用程式邏輯問題而無法再次訪問的記憶體。和記憶體洩漏最主要的區別是它的引用(包括強引用和弱引用)是存在的,但是不會再用了。比如上面的迴圈引用問題,VC被Pop後這部分記憶體首先還是在記憶體活動圖中的,但是下次再push我們是建立一個新的VC而非使用原來的VC就造成上一次的VC成了廢棄的記憶體。

如果是早期MRC下建立的物件忘記release之類的使用Leaks是比較容易檢測的,但是 ARC 下就比較少了,實際驗證過程中發現更多的是引用的一些古老的OC庫有可能出現,純Swift幾乎沒有。

Abandoned memory事實上要比leak更難發現,關於如何使用Instruments幫助開發者進行廢棄的記憶體分析,參見官方Allocations工具的使用:Find abandoned memory

Memory Graph

當然Xcode 8 的Memory Graph也是一大利器,不過如果你這麼想上面的問題很有可能會失望(如下圖),事實上Memory Graph我理解有幾個問題:第一是這個工具要想實際捕獲記憶體洩漏需要多執行幾次,往往一次執行過程是無法捕獲到記憶體洩漏的;第二比如上面的子檢視引起的記憶體洩漏是無法使用它捕獲記憶體洩漏資訊的,VC pop之後它會認為VC沒有釋放它的子檢視沒有釋放也是正確的,事實上VC就應該是被釋放的,不過調整一下上面的程式碼比如刪除self.view.addSubview(self.customView)後儘管還存在迴圈引用但是卻是可以檢測到的(不過實際上怎麼可能那麼做呢),關於這個玄學問題沒有找到相關的說明文件來解釋。但是事實上 Memory graph 從來也沒有宣告自己是在解決記憶體洩漏問題,而是記憶體活動圖分析工具,如果這麼去想這個問題似乎也不算是什麼bug。

-w1440

第三方工具

事實上看到上面的情況相信很多同學會想要使用第三方工具來解決問題,比如大家用的比較多的MLeaksFinderPLeakSniffer,兩者不同之處是後者除了可以預設查出 UIViewController 和 UIView 記憶體洩漏外還可以查出所有UIViewController屬性的記憶體洩漏算是對前者的一個補充。當然前者還配合了 Facebook 的FBRetainCycleDetector可以分析出迴圈引用出現的引用關係幫助開發者快速修復迴圈引用問題。

不過可惜的是這兩款工具,甚至包括 PLeakSniffer 的 Swift 版本都是不支援 Swift 的(準確的說是不支援Swift 4.2,原因是Swift 4.2繼承自 NSObject 的類不會預設新增 @objc 標記 class_copyPropertyList無法訪問其屬性列表,不僅如此Swift5.x中連新增 @objcMembers 也是沒用的),但是 Swift 不是到了5.x才ABI穩定的嗎??,再次檢視 Facebook 的 FBRetainCycleDetector 本身就不不支援Swift,具體可以檢視這個issue這是官方的回答,如果稍微熟悉這個庫原理的同學應該也不難發現具體的原因,從目前的情況來看當前 FBRetainCycleDetector 的原理在當前swift上是行不通的,畢竟要獲取物件佈局以及屬性在Swift 5.x上已經不可能,除非你將屬性標記為@objc,這顯然不現實,走 SWift 的Mirror當前又無法 setValue,所以研究了一下現在開源社群的情況幾乎沒有類似OC的完美解決方案。

Deubgger的LeakMonitorService

LeakMonitorService是我們自己實現的一個Swift記憶體洩漏分析工具,主要是為了解決上面兩個庫當前執行在Swift 5.x下的問題,首先明確的是當前 Swift 版本是無法訪問其非 @objc 屬性的,這就無法監控所有屬性,但是試想其實只要這個監控可以解決大部分問題它就是有價值的,而通常的記憶體洩漏也就存在於 UIViewController 和 UIView 中,因此出發點就是檢測 UIViewController 和其根檢視和子檢視的記憶體洩漏情況。

如果要檢測記憶體洩漏就要先知道是否被釋放,如果是OC只要Swizzle dealloc方法即可,但是顯然Swift中是無法Swizzle一個deinit方法的,因為這個方法本身就不是runtime method。最後我們確定的解決方案就是通過關聯屬性進行監控,具體的操作(具體實現後面開源出來):

  1. 使用一個集合Objects記錄要監控存在記憶體洩漏的物件
  2. 給NSObject新增一個關聯屬性:deinitDetector,型別為 Detector 作為NSObject的代理,Detector是一個class,裡面引用一個block,在 deinit 時呼叫這個 block 從Objects 中移除監控物件
  3. 在 UIViewController 初始化時給 deinitDetector 賦值進行監控,同時將自身新增到 Objects 陣列代表可能會發生記憶體洩漏,在 UIViewController 的將要釋放時檢測監控(一般稍微延遲一會)檢測Objects是否存在當前物件如果是被正確釋放因為其屬性deinitDetector 會將其從 Objects 移除所以就不會有問題,如果出現記憶體洩漏deinitDetector的內部block不會呼叫,此時當前控制器還在 Objects 中說明存在記憶體洩漏
  4. 使用同樣的方法監控UIViewController的根檢視和子檢視即可

需要說明的是監控UIViewController的時機,通常建議新增監控的時機放到viewDidAppear(),檢測監控的時機放到viewDidDisappear()中。原因是此時子檢視相對來說已經完成佈局(避免存在動態新增的檢視沒有被監控到),而檢測監控的時機放到viewDidDisappear()中自然也不是所有呼叫了viewDidDisappear()的控制器就一定釋放了,可以在viewDidDisappear()中配合isMovingFromParentisBeingDismissed屬性進行比較精準的判斷。

常見的記憶體洩漏

經過 LeakMonitorService 檢測確實在產品中發現了少量的記憶體洩漏情況,但是很有代表性,這裡簡單的說一下,當然普通的block迴圈引用、NSTimer、NotificationCenter.default.addObserver()等這裡就不在介紹了,產品檢測中幾乎也沒有發現。

1.block的雙重引用問題

先來看一段程式碼:

class LeakDemo2ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let customView = CustomView()
        customView.block1 = {
            [weak self] () -> CustomSubView? in
            guard let weakSelf = self else { return nil }
            let customSubview = CustomSubView()
            customSubview.block2 = {
                 // 儘管這個 self 已經是 weak 了但是這裡也會出現迴圈引用
                print(weakSelf)
            }
            return customSubview
        }
        
        self.view.addSubview(customView)
    }
    
    deinit {
        print("LeakDemo2ViewController deinit")
    }

}

private class CustomView:UIView {
    var block1:(()->CustomSubView?)?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        if let subview = block1?() {
            self.addSubview(subview)
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

private class CustomSubView:UIView {
    var block2:(()->Void)?
}

上面的程式碼邏輯並不複雜,customView 的 block 內部已經考慮了迴圈引用將 self 宣告為 weak 是沒有問題的,出問題的是它的子檢視又巢狀了一個 block2 從而造成了 block2 的巢狀引用關係,而第二個 block2 又引用了 weakSelf 從而造成迴圈引用(儘管此時的self是第一個 block 內已經宣告成 weakSelf)解決的辦法很簡單隻要內部的 block2 引用的 self 宣告成weak就好了(此時形成的是[weak weakSelf]的關係)。那麼為什麼會這樣的,內部 block2 訪問的也不是當前VC的self物件,而是弱引用怎麼會出問題呢?

原因是當前控制器 self 首先強引用了customView,而customView又通過 addSubview() 強引用了customSubView,這樣依賴其實 self 已經對 customSubView形成了強引用關係。但是 customSubview 本身引用的弱引用weakSelf嗎?(注意是弱引用的weakSelf,不是weakSelf的弱引用),但是需要清楚一點就是外部的弱引用是block1對self的弱引用,也就是在weak table(Swift最新實現在Side table)裡面會記錄block1的弱引用關係,但是block2是不會在這個表中的,所以這裡還是一個強引用,最終造成迴圈引用關係。

Swift中的weakSelf和strongSelf

補充一下OC中的weakSelf和strongSelf的內容,通常情況下常見的做法:

__weak __typeof__(self) weakSelf = self;
[self.block = ^{
    __strong __typeof(weakSelf)strongSelf = weakSelf;
    if (strongSelf) {
        strongSelf.title = @"xxx";
    }
}];

當然你可以用兩個巨集簡化上面的操作:

@weakify(self);
[self.block = ^{
	 @strongify(self);
    if (strongSelf) {
        self = @"xxx";
    }
}];

上面 strongSelf 的主要目的是為了避免block中引用self的方法在執行過程中被釋放掉造成邏輯無法執行完畢,swfit中怎麼做呢,其實很簡單(method1和method2要麼都執行,要麼一個也不執行):

self.block = {
    [weak self] in
    if let strongSelf = self {
        strongSelf.method1()
        strongSelf.method2()
    }
}

但是下面的程式碼是不可以的(有可能會出現method2不執行,但是method1會執行的情況):

self.block = {
    [weak self] in
    self?.method1()
    self?.method2()
}

2.delay操作

通常大家都很清楚 NStimer 會造成迴圈引用(儘管在新的api已經提供了block形式,不必引用target了),但是很少注意 DispatchQueue.main.asyncAfter() 所實現的delay操作,而它的返回值是 DispatchWorkItem 型別通常可以用它來取消一個延遲操作,不過一旦物件引用了 DispatchWorkItem 而在block中又引用了當前物件就形成了迴圈引用關係,比如:

class LeakDemo3ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.delayItem = DispatchWorkItem {
            print("asyncAfter invoke...\(self)")
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3), execute: self.delayItem!)
    }
    
    deinit {
        print("LeakDemo3ViewController deinit")
    }
    
    private var delayItem:DispatchWorkItem?

}

3.內部函式

其實,如果是閉包大家平時寫程式碼都會比較在意避免迴圈引用,但是如果是內部函式很多同學就沒有那麼在意了,比如下面的程式碼:

class LeakDemo4ViewController: UIViewController {

    var block:(()->Void)?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        func innerFunc() {
            print(self)
        }
        
        self.block = {
            [weak self] in
            guard let weakSelf = self else { return }
            innerFunc()
            print(weakSelf)
        }
    }
    
    deinit {
        print("LeakDemo4ViewController deinit")
    }

}

innerfunc() 中強引用了self,而 innerFunc 執行上下文是在block內進行的,所以理論上在block內直接訪問了self,最終造成迴圈引用。內部函式在swift中是作為閉包來執行的,上面的程式碼等價於:

let innerFunc =  {
    print(self)
}

說起block的迴圈引用這裡可以補充一些情況不會造成迴圈引用或者是延遲釋放的情況。特別是對於延遲的情況此次在產品中也做了優化,儘可能快速釋放記憶體避免記憶體峰值過高。

a.首先pushViewController()和presentViewController()本身是不會引用當前控制器的,比如說下面程式碼不會迴圈引用:

let vc = CustomViewController()
vc.block = {
    print(self)
}
self.present(vc, animated: true) {
    print(self)
}

b.UIView.animation不會造成迴圈引用

UIView.animate(withDuration: 10.0) {
    self.view.backgroundColor = UIColor.yellow
}

c.UIAlertAction的handler不會引起迴圈引用(iOS 8 剛出來的時候有問題)

let alertController = UIAlertController(title: "title", message: "message", preferredStyle: UIAlertController.Style.alert)
let action1 = UIAlertAction(title: "OK", style: UIAlertAction.Style.default) { (alertAction) in
    print(self)
}
let action2 = UIAlertAction(title: "Cancel", style: UIAlertAction.Style.cancel) { (alertAction) in
    print(self)
}
alertController.addAction(action1)
alertController.addAction(action2)
self.present(alertController, animated: true) {
    print(self)
}

d.DispatchQueue asyncAfter會讓引用延遲,這裡的引用也是強引用,但是當asynAfter執行結束會得到釋放,但是不及時

DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) {
    print(self)
}

e.網路請求會延遲釋放

如下在請求回來之前self無法釋放:

guard let url = URL(string:"http://slowwly.robertomurray.co.uk/delay/3000/url/http://www.google.co.uk
") else { return }
let dataTask = URLSession.shared.dataTask(with: url) { (data, response, error) in
    print(self,data)
}
dataTask.resume()

f.其他單例物件有可能延遲釋放,因為單例本身對外部物件強引用,儘管外部物件不會強引用單例,不過釋放是延遲的

class SingletonManager {
    static let shared = SingletonManager()
    
    func invoke(_ block: @escaping (()->Void)) {
        DispatchQueue.global().async {
            sleep(10)
            block()
        }
    }
}

SingletonManager.shared.invoke {
    print(self)
}

Instruments Allocation

前面說過Leaks和Memory Graph的限制,使用監控UIViewController或者UIView的工具對多數記憶體進行監控,但是畢竟這是多數情況,有些情況下是無法監控到的,那麼此時配合Instruments Allocation就是一個比較好的選擇,首先它可以通過快照的方式快速查對比記憶體的增長點也就可以幫助分析記憶體不釋放的原因,另外可以通過它檢視當前記憶體被誰佔用也就有利於幫助我們分析記憶體佔用有針對性行的進行優化。

首先要了解,當我們向作業系統申請記憶體時系統分配的記憶體並不是實體記憶體地址而是虛擬記憶體 VM Regions 的地址。每個程式擁有的虛擬記憶體的空間大小是一樣的,32位的程式可以擁有4GB的虛擬記憶體,64位程式則更多。當真正使用記憶體時,作業系統才會將虛擬記憶體對映到實體記憶體。所以理論上當兩個程式A和B預設擁有相同的虛擬記憶體大小,當B使用記憶體時發現實體記憶體已經不夠用在OSX上會將不活躍記憶體寫入硬碟,叫做 swapping out。但是在iOS上面會直接發出記憶體警告 Memory warning 通知App清理無用記憶體(事實上也會引入 Compressed memory 壓縮一部分記憶體,需要的時候解壓)。

當然要使用這個工具之前建議先了解這個工具對記憶體類別劃分:

  • All Heap Allocations :程式執行過程中堆上分配的記憶體,簡單理解就是實際分配的記憶體,包括所有的類例項,比如UIViewController、UIView、Foundation資料結構等。比如:
    • Malloc 512.00KiB: 分配的512k堆記憶體,類似還有 Malloc 80.00KiB
    • CTRun: Core Text物件記憶體
  • All Anonymous VM :主要包含一些系統模組的記憶體佔用,以 VM: 開頭
    • VM:CG raster data:(光柵化資料,也就是畫素資料。注意不一定是圖片,一塊顯示快取裡也可能是文字或者其他內容。通常每畫素消耗 4 個位元組)
    • VM:Statck:棧記憶體(比如每個執行緒都會需要500KB)
    • VM:Image IO:(圖片編解碼快取)
    • VM:IOSurface:用於儲存FBO、RBO等渲染資料的底層資料結構,是跨程式的,通常在CoreGraphics、OpenGLES、Metal之間傳遞紋理資料。
    • CoreAnimation: 動畫資源佔用記憶體
    • VM:IOAccelerator:圖片的CVPixelBuffer

需要注意,Allocations統計的 Heap Allocations & Anonymous VM(包括:All Heap AllocationsAll Anonymous VM) 並不包括非動態的記憶體,以及部分其他動態庫建立的VM Region(比如:WebKit,ImageIO,CoreAnimation等虛擬記憶體區域),相對來說是低於實際執行記憶體的。

為了進一步瞭解記憶體實際分配情況,這裡不妨藉助一下 Instruments VM Tracker 這個工具,對於前面說過虛擬記憶體,這個工具是可以對虛擬記憶體實際分配情況有直觀展示的。

Virtual memory(虛擬記憶體) = Dirty Memory(已經寫入資料的記憶體) + Clean Memory(可以寫入資料的乾淨的記憶體) + Compressed Memory(對應OSX上的swapped memory)

Dirty Memory : 包括所有 Heap 中的物件、以上All Anonymous VM以及每個framework的 _DATA 段和 _Dirty_Data 段

Clean Memory:可以寫資料的乾淨的記憶體,不過對於開發者是read-only,作業系統負責寫入和移除,比如:System Framework、Binary Executable佔用的記憶體,framework都有_DATA_CONST段(不過當使用framework時會變成 Dirty memory )

Compressed Memory:由於iOS系統是沒有 swapped memory 的,取而代之的是 Compressed Memory ,通過壓縮記憶體可以降低大概一半的記憶體。不過遇到記憶體警告釋放記憶體的時候情況就複雜了些,比如遇到記憶體警告後通常可以試圖壓縮記憶體,而這時開發者會在收到警告後釋放一部分記憶體,遇到釋放記憶體的時候記憶體很可能會從壓縮記憶體再解壓去釋放反而峰值會增加。

前面提到過 Jetsam 對於記憶體的控制機制,這裡需要明確它做出記憶體警告的依據是 phys_footprint,而發生記憶體警告後系統預設清理的記憶體是 Clean Memory 而不會清理 Dirty Memory,畢竟有資料的記憶體系統也不知道是否還有用,無法自動清理。

Resident Memory = Dirty Memory + Clean Memory that loaded in physical memory

Resident Memory:已經被對映到虛擬記憶體中的實體記憶體,但是注意只有 phys_footprint 才是真正消耗的實體記憶體,也正是 Jetsam 判斷記憶體警告的依據。

Memory Footprint:App 實際消耗的實體記憶體,Jetsam 判斷記憶體警告的依據,包括:Dirty Memory 、Compressed Memory、NSCache, Purgeable、IOKit used
和部分載入到實體記憶體的Clean memory。

如果簡單總結:
Instruments AllocationsHeap Allocations & Anonymous VM 是整個App佔用的一部分,它又分為 Heap Allocations 為開發者申請的記憶體,而 Anonymous VM 是系統分配記憶體(但是並不是不需要優化)。這部分儘管不是 App 的所有消耗記憶體但卻是開發者最關注的。

Instruments VM TrackerDirty MemorySwapped(對應iOS中的 Compressed Memory) 應該是開發者關注的主要記憶體佔用,比較接近於實際佔用記憶體,類似的是Xcode Navigator的記憶體也接近於最終的 Memory Footprint (多了除錯佔用的記憶體而已一般可以認為是 App 實際佔用記憶體)

關於圖片的記憶體佔用有必要解釋一下:CGImage 持有原始壓縮格式DataBuffer(DataBuffer佔用本身比較小),通過類似引用計數管理真正的Image Bitmap Buffer,需要渲染時通過 RetainBytePtr 拿到 Bitmap Buffer 塞給VRAM(IOSurface),不渲染時 ReleaseBytePtr 釋 放Bitmap Buffer。通常在使用UIImageView時,系統會自動處理解碼過程,在主執行緒上解碼和渲染,會佔用CPU,容易引起卡頓。推薦使用ImageIO在後臺執行緒執行圖片的解碼操作(可參考SDWebImageCoder)。但是ImageIO不支援webp。

二、持久化物件

很多時候記憶體洩漏確實可以很大程度上解決OOM問題,因為類似於UIViewController或者UIView中包含大量UIImageView的情況下,兩者不釋放很可能會有很大一塊關聯的記憶體得不到釋放造成記憶體洩漏。但是另一個問題是持久化物件,即使解決了所有記憶體洩漏的情況也並不代表就真正解決了記憶體洩漏問題,其中一個重要的因素就是持久化物件。

關於持久化物件這裡主要指的是類似於App進入後在主介面永遠不會釋放的物件,以及某些單例物件。象基本上基本上不kill整個app是無法釋放的,但是如果因為設計原因又在首頁有大量這樣的持久物件那麼OOM的問題理論上更加難以解決,因為此時要修改整個App結構幾乎是不可能的。

這裡簡單對非洩漏OOM情況進行分類:

  1. 首頁及其關聯頁面:比如首頁是UITabbarController相應的tab點選之後也成為了持久化物件無法釋放
  2. 單例物件:特別是會載入一些大模型的單例,比如說單例中封裝了人臉檢測,如果人臉檢測模型比較大,首次使用人臉識別時載入的模型也會永遠得不到釋放
  3. 複雜的介面層級:Push、Pop是iOS常用的導航操作,但是如果介面設計過於複雜(甚至可以無限Push)那麼層級深了以後前面UINavigationController棧中的物件一直堆疊也會OOM
  4. 耗資源的物件:比如說播放器這種消耗資源的物件,理論上不會在同一個app內播放兩個音視訊,設計成單例反而是比較好的方案
  5. 圖片資源:圖片資源是app內最佔用記憶體的資源,一個不合適的圖片尺寸就可以導致OOM,比如一張邊長10000px的正方形圖片解碼後的大小是10000 * 10000 * 4 = 381M左右

首先說一下第一種情況,其實在早期iOS中(5.0及其之前的版本)針對以上情況有記憶體警lunload機制,通常在viewDidUnload()中釋放當前view,同時也是給開發者提供資源解除安裝的一個比較合適的時機,當UIViewController再次展示時會重新loadView(),而從iOS 6.0之後Apple建議相關操作放到didReceiveMemoryWarning()方法中,主要的原因是因為僅僅釋放當前根檢視並不會帶來大的記憶體釋放同時又造成了體驗問題,原本一個UITableView已經翻了幾頁了現在又要重新載入一遍。所以結論是在didReceiveMemoryWarning()放一些大的物件釋放操作,而不建議直接釋放view,但是不管怎麼樣一定要做恢復機制。實際的實踐是在我們的MV播放器中做了解除安裝操作,因為MV的預覽要經過A->B->C的push過程,A、B均包含了MV預覽播放器,而實際測試兩個播放器的記憶體佔用大概110M上下這是一部分很大的開銷,特別是對於iPhone 6等1g記憶體的手機。另外針對某個頁面有多個子控制器的情況避免一次載入所有的自控制器的情況,理想的情況是切換到對應的控制器時才會載入對應的控制器。

單例物件是另一種大記憶體持久物件,通常情況下物件本身佔用記憶體很有限,做成單例沒有什麼問題,但是這個物件引用的資源才是關注的重點,比如說我們產品中中有個主體識別模組,依賴於一個AI模型,本身這個模組也並非App操作的必經路徑,首次使用時載入,但是之後就不會釋放了,這樣一來對於使用過一次的使用者很有可能不再使用就沒必要一直佔用,解決的辦法自然是不用單例。

關於複雜的介面層級則完全是設計上的問題,只能通過介面互動設計進行控制,而對於耗資源物件上面也提到了儘量複用同一個物件即可,這裡不再贅述。

此外,前面說到FBO相關的記憶體,其實這部分記憶體也是需要手動釋放的,比如在產品中使用的播放器在用完之後並沒有及時釋放,呼叫 CVOpenGLESTextureCacheFlush() 及時清理(類似的還有使用基於OpenGL的濾鏡)。

記憶體峰值飆升

除了持久的記憶體佔用意外,有時會不恰當的操作會造成記憶體的飆升出現OOM,儘管這部分記憶體可能一會會被釋放掉不會長久的佔用記憶體但是記憶體的峰值本身就是很危險的操作。

圖片壓縮

首先重點關注一下圖片的記憶體佔用,圖片應該是最佔用記憶體的物件資源,理論上UILayer最終展示也會繪製一個bitmap,不過這裡主要說的是UIImage資源。一張圖片要最終展示出來要經過解碼、渲染的步驟,解碼操作的過程就是就是從data到bitmap的過程,這個過程中會佔用大量記憶體,因為data是壓縮物件,而解碼出來的是實實在在的畫素資訊。自然在開發中重用一些控制元件、做圖片資源優化是必要的,不過這些事實上在我們的產品中都是現成的內容,如何進一步優化是我們最關注的的。理論上這個問題可以歸結到第一種情況的範疇,就是如何讓首頁的圖片資源儘可能的小,答案也是顯而易見的:第一解碼過程中儘可能控制峰值,第二能用小圖片的絕不解碼一張大圖片。

比如一個圖片壓縮需求一張巨大的圖片要判斷圖片大小做壓縮處理,假設這張圖片是1280 * 30000的長圖,本來的目的是要判斷圖片大小進行適當的壓縮,比如說超過50M就進行80%壓縮,如果100M就進行50%壓縮,但是遇到的情況是這樣的:本來為了判斷圖片的大小以及保留新的圖片,原圖片A記憶體佔用大約146M,宣告瞭一個新物件B保留壓縮後的圖片,但是預設值是A原圖,根據情況給B賦值,實際情況是原圖146M+146M+中間壓縮結果30M左右,當前記憶體322M直接崩潰。優化這個操作的過程自然是儘量少建立中間變數,也不要賦值預設值,避免峰值崩潰。

關於產品中使用合適的圖片應該是多數app都會遇到的情況,比如首頁預設有10張圖,本來尺寸是比較小的UIImageView也沒有必要使用過大的圖片,不過實際情況很可能是通過後端請求的url來載入圖片。比如說一個64pt * 64pt的UIImageView要展示一個1080 * 1920 pixal的圖片記憶體佔用達在2x情況下多了126倍之多是完全沒必要的,不過後端的配置自然是不可信的,即使剛開始沒有問題說不準後面運營維護的時候上一張超大的圖片也是很有可能的。解決方式自然是向下取樣,不過這裡建議不要直接使用Core Graphics繪製,避免記憶體峰值過高,Apple也給了推薦的做法。

常見的壓縮方法:

func compressImage(_ image:UIImage, size:CGSize) -> UIImage? {
        let targetSize = CGSize(width: size.width*UIScreen.main.scale, height: size.height*UIScreen.main.scale)
        UIGraphicsBeginImageContext(targetSize)
        image.draw(in: CGRect(origin: CGPoint.zero, size: targetSize))
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return newImage
    }

推薦的做法:

func downsamplingImage(url:URL, size:CGSize) -> UIImage? {
        let imageSourceOptions = [kCGImageSourceShouldCache:false] as CFDictionary
        guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else { return nil }
        let maxDimension = max(size.width, size.height) * UIScreen.main.scale
        let downsamplingOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways : true,
            kCGImageSourceShouldCacheImmediately : true ,
            kCGImageSourceCreateThumbnailWithTransform:true,
            kCGImageSourceThumbnailMaxPixelSize : maxDimension
        ] as CFDictionary
        guard let downsampleImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsamplingOptions) else { return nil }
        let newImage = UIImage(cgImage: downsampleImage)
        return newImage
    }

大量迴圈操作

此外關於一些迴圈操作,如果操作本身比較耗記憶體,通常的做法就是使用 autoreleasepool 確保一個操作完成後記憶體及時釋放,但是在PHImageManager獲取圖片時這種方法並不是太湊效。比如說下面的一段程式碼獲取相簿中30張照片儲存到沙盒:

guard let cachePath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).first else { return }
let assets = getAssets() // top 30
for i in 0..<assets.count {
    let option = PHImageRequestOptions()
    option.isSynchronous = false
    option.isNetworkAccessAllowed = true
    PHImageManager.default().requestImage(for: assets[i], targetSize: CGSize(width: 1080, height: 1920), contentMode: PHImageContentMode.aspectFit, options: option) { (image, info) in
        if info?[PHImageResultIsDegradedKey] as? Bool == true {
            return
        }
        if let image = image {
            do {
                let savePath = cachePath + "/\(i).png"
                if FileManager.default.fileExists(atPath: savePath) {
                    try FileManager.default.removeItem(atPath: savePath)
                }
                try image.pngData()?.write(to: URL(fileURLWithPath: savePath))
            } catch {
                print("Error:\(error.localizedDescription)")
            }
        }
    }
}

實測在iOS 13下面記憶體峰值85M左右,執行後記憶體65M,比執行前多了52M而且這個記憶體應該是會一直常駐,這也是網上很多文章中提到的增加autoreleasepool來及時釋放記憶體的原因。改造之後程式碼:

guard let cachePath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).first else { return }
let assets = getAssets()
for i in 0..<assets.count {
    autoreleasepool(invoking: {
        let option = PHImageRequestOptions()
        option.isSynchronous = false
        option.isNetworkAccessAllowed = true
        PHImageManager.default().requestImage(for: assets[i], targetSize: CGSize(width: 1080, height: 1920), contentMode: PHImageContentMode.aspectFit, options: option) { (image, info) in
            if info?[PHImageResultIsDegradedKey] as? Bool == true {
                return
            }
            if let image = image {
                do {
                    let savePath = cachePath + "/\(i).png"
                    if FileManager.default.fileExists(atPath: savePath) {
                        try FileManager.default.removeItem(atPath: savePath)
                    }
                    try image.pngData()?.write(to: URL(fileURLWithPath: savePath))
                } catch {
                    print("Error:\(error.localizedDescription)")
                }
            }
        }
    })
}

實測之後發現記憶體峰值降低到了65M左右,執行之後記憶體在50M左右,也就是峰值和之後常駐記憶體都有所降低,autoreleasepool有一定作用,但是作用不大,但是理論上這個常駐記憶體應該恢復到之前的10M左右的水平才對為什麼多了那麼多呢?原因是Photos獲取照片是有快取的(注意在iPhone 6及以下裝置不會快取),這部分快取如果進入後臺會釋放(主要是IOSurface)。其實這個過程中記憶體主要包括兩部分 IOSurface 和 CG raster data ,那麼想要降低這兩部分記憶體其實針對上述場景最好的辦法是使用 PHImageManager.default().requestImageDataAndOrientation() 而不是 PHImageManager.default().requestImage() 實測上述情況記憶體峰值 18M 左右並且瞬間可降下來。那麼如果需求場景非要使用 PHImageManager.default().requestImage() 怎麼辦呢?答案是使用序列操作降低峰值。

guard let cachePath = NSSearchPathForDirectoriesInDomains(.cachesDirectory, FileManager.SearchPathDomainMask.userDomainMask, true).first else { return }
let semaphore = DispatchSemaphore(value: 0)
self.semaphore = semaphore
DispatchQueue.global().async {
    let assets = self.getAssets()
    for i in 0..<assets.count {
        print(1)
        autoreleasepool(invoking: {
            let option = PHImageRequestOptions()
            option.isSynchronous = false
            option.isNetworkAccessAllowed = true
            PHImageManager.default().requestImageDataAndOrientation(for: assets[i], options: option) { (data, _, orientation, info) in
                if info?[PHImageResultIsDegradedKey] as? Bool == true {
                    return
                }
                defer {
                    semaphore.signal()
                    print(4)
                }
                do {
                    print(3)
                    let savePath = cachePath + "/\(i).png"
                    if FileManager.default.fileExists(atPath: savePath) {
                        try FileManager.default.removeItem(atPath: savePath)
                    }
                    try data?.write(to: URL(fileURLWithPath: savePath))
                } catch {
                    print("Error:\(error.localizedDescription)")
                }
            }
        })
        print(2)
        _ = semaphore.wait(timeout: .now() + .seconds(10))
        print(5)
        
    }
}

通過序列控制以後記憶體峰值穩定在16M左右,並且執行之後記憶體沒有明顯增長,但是相應的操作效率自然是下降了,整體時長增高。

總結

本文從記憶體洩漏和記憶體佔用兩個角度分析瞭解決OOM的問題,也是產品中實際遇到問題的一次徹查結果,列舉了常見引起OOM的原因,也對持久記憶體佔用給了一些實踐的建議,對於比較難發現的leak情況做了示例演示,也是產品實際遇到的,事實上在我們的產品中通過上面的手段OOM降低了80%以上,整體的App框架也並沒有做其他修改,所以有類似問題的同學不妨試一下。

相關文章