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框架也并无作其余修改,因此有相似问题的同窗不妨试一下。

相关文章
相关标签/搜索