iOS開發實踐-OOM治理

概覽

提及iOS的OOM問題你們第一想到的應該更多的是內存泄漏(Memory Leak),由於不管是從早期的MRC仍是2011年Apple推出的ARC內存泄漏問題一直是iOS開發者比較重視的問題,好比咱們熟悉的 Instruments Leaks 分析工具,Xcode 8 推出的 Memory Graph 等都是官方提供的內存泄漏分析工具,除此以外還有相似於FBRetainCycleDetector的第三方工具。不過事實上內存泄漏僅僅是形成OOM問題的一個緣由而已,實際開發過程當中形成OOM的緣由有不少,本文試圖從實踐的角度來分析形成OOM的諸多狀況以及解決辦法。html

形成OOM的緣由

形成OOM的直接緣由是iOS的 Jetsam 機制形成的,在Apple的 Low Memory Reports中解釋了具體的運行狀況:當內存不足時,系統向當前運行中的App發起applicationDidReceiveMemoryWarning(_ application: UIApplication) 調用和 UIApplication.didReceiveMemoryWarningNotification 通知,若是內存仍然不夠用則會殺掉一些後臺進程,若是仍然吃緊就會殺掉當前App。git

關於 Jetsam 實現機制其實蘋果已經開源了XNU代碼,能夠在這裏查看,核心代碼在 kern_memorystatus 感興趣能夠閱讀,其中包含了不少系統調用函數,能夠幫助開發者作一些OOM監控等。github

1、內存泄漏

內存泄漏形成內存被持久佔用沒法釋放,對OOM的影響可大可小,多數狀況下並不是泄漏的類直接形成大內存佔用而是沒法釋放的類引用了比較大的資源形成連鎖反應最終造成OOM。通常分析內存泄漏的工具推薦使用Leaks,後來Apple提供了比較方便的Memory Graph。web

Instruments Leaks

Leaks應該是被全部開發者推薦的工具,幾乎搜索內存泄漏就會提到這個工具,可是不少朋友不清楚其實當前Leaks的做用沒有那麼大,多數時候內存泄漏使用Leaks是分析不出來的。不妨運行下面的一個再簡單不過的泄漏狀況(在一個導航控制器Push到下面的控制器而後Pop出去進行驗證):swift

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的內存包括哪幾部分:api

  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。

2、持久化對象

不少時候內存泄漏確實能夠很大程度上解決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框架也並無作其餘修改,因此有相似問題的同窗不妨試一下。

相關文章
相關標籤/搜索