iOS开发实践-OOM治理

概览

说起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框架也并没有做其他修改,所以有类似问题的同学不妨试一下。

知识共享许可协议 作品采用知识共享署名 2.5 中国大陆许可协议进行许可,欢迎转载,演绎或用于商业目的。但转载请注明来自崔江涛(KenshinCui),并包含相关链接。
posted @ 2020-06-17 17:28  KenshinCui  阅读(3540)  评论(0编辑  收藏  举报