[ WWDC2018 ] - 深入解析iOS内存 iOS Memory Deep Dive

Session 416 由三位苹果软件工程师 Kyle Howarth, James Snee, Kris Markel 为我们带来 iOS 内存相关的一些内容

  • Memory Usage Performance Guidelines 不再更新之后,这个 Session 简单介绍了一下 iOS 的虚拟内存机制的变化,如 Compressed memory 的使用等,分析了开发者应该减少哪部分内存占用。
  • Xcode 10 现在可以捕获内存超限的 EXC_RESOURCE_EXCEPTION 事件,其底层记录内存信息的 memgraph 文件与命令行工具的结合使用,使得内存相关的调试更加灵活高效。
  • 推荐开发者通过新的 API 让系统选择最佳的图片渲染格式来合理使用内存;相比于UIImage的绘制,图片下采样时使用ImageIO来减少损耗。
  • 减少应用处在后台时较大的内存占用,主要通过监听 App 生命周期的通知或利用VC的生命周期方法实现,使系统或其他进程获得更多的可用内存。

1. Why Reduce Memory?

开门见山,我们为什么要减少内存(占用)?

为了更好的用户体验

内存是有限且系统共享的资源,一个程序占用更多,系统和其他程序所能用的就更少。程序启动前都需要先加载到内存中,并且在程序运行过程中的数据操作也需要占用一定的内存资源。减少内存占用也能同时减少其对 CPU 时间维度上的消耗,从而使不仅你所开发的 App,其他 App 以及整个系统也都能表现的更好。

2. Memory Footprint

我们需要明确的是,这里的减少内存指减少 iOS App 的虚拟内存(Virtual Memory) 占用。

iOS 以及 macOS 都采用了虚拟内存技术来突破物理内存(RAM) 的大小限制,每个进程都拥有一段由多个大小相同的 page 所构成的逻辑地址空间。处理器和内存管理单元 MMU(Memory Management Unit) 维护着由逻辑地址空间到物理地址的 page 映射表,当程序访问逻辑内存地址时由 MMU 根据映射表将逻辑地址转换为真实的物理地址。在早期的苹果设备中,每个 page 的大小为 4KB;基于 A7 和 A8 处理器的系统为 64 位程序提供了 16KB 的虚拟内存分页和 4KB 的物理内存分页;而在A9之后,虚拟内存和物理内存的分页大小都达到了 16KB。

虚拟内存分页(Virtual Page, VP) 有两种类型:

1.Clean - Data that can be paged out of memory 指的是能够被系统清理出内存且在需要时能重新加载的数据,包括:

  • Memory mapped files
  • Frameworks 中的 __DATA_CONST 部分
  • 应用的二进制可执行文件

2.Dirty - Any memory that has been written to by your app 指的是不能被系统回收的内存占用,包括

  • 所有堆上的对象
  • 图片解码缓冲数据(Decoded image buffers)
  • Frameworks 中的 __DATA 和 __DATA_DIRTY部分

    Frameworks you link actually use clean memory and dirty memory

由于闪存容量和读写寿命的限制,iOS 上没有Disk swap机制,取而代之使用 Compressed memory。 Disk swap 是指在 macOS 以及一些其他桌面操作系统中,当内存可用资源紧张时,系统将内存中的内容写入磁盘中的backing store (Swapping out),并且在需要访问时从磁盘中再读入 RAM (Swapping in)。与大多数 UNIX 系统不同的是,macOS 没有预先分配磁盘中的一部分作为 backing store,而是利用引导分区所有可用的磁盘空间。

苹果最初只是公开了从 OS X Mavericks 开始使用 Compressed memory 技术,但 iOS 系统也从 iOS 7 开始悄悄地使用。从 [OS X Mavericks Core Technology Overview](https://images.apple.com/media/us/osx/2013/docs/OSX_Mavericks_Core_Technology_Overview.pdf) 文档中可以了解到该技术在内存紧张时能够将最近使用过的内存占用压缩至原有大小的一半以下,并且能够在需要时解压复用。它在节省内存的同时提高了系统的响应速度,其特点可以归结为:
  • Shrinks memory usage 减少了不活跃内存占用
  • Improves power efficiency 改善电源效率,通过压缩减少磁盘IO带来的损耗
  • Minimizes CPU usage 压缩/解压十分迅速,能够尽可能减少 CPU 的时间开销
  • Is multicore aware 支持多核操作

本质上,Compressed memory 也是 Dirty memory

因此, memory footprint = dirty size + compressed size ,这也就是我们需要并且能够尝试去减少的内存占用。

当 memory footprint 超过一定值时(这里给出了不同机型的测试结果),就会收到内存警告(Memory Warnings)。对于Extension来说,限制值更小,因此使用也需要更加谨慎。⚠️一些情况下,如果内存使用增长过快,App 有可能在尚未响应内存警告的情况下就已经被系统杀掉进程了。

Kyle 在这一部分给出了几点关于内存警告的看法:

(1).你的 App 不一定是真正的“凶手”

在一些 RAM 容量较低的机型上,App 使用过程中接到一个电话,也有可能触发内存警告。

(2).内存压缩技术的存在使得释放内存变得复杂

假设一个 App 的 Dirty memory 中有一个 NSDictionary 对象占用了3个 page 的内存空间,当 App 处于非活跃状态时系统将其压缩至1个 page 的压缩大小,系统获得了2个 page 大小的可用内存。

但是,如果这时因为一些原因收到内存警告,我们可能会决定将 NSDictionary 中的一些数据移除,这时我们重新访问了压缩后的page,它被解压 - 释放对象 - 然后内存占用又回到了1个page大小。也就是说,我们努力释放了一些对象却没有增加可用内存空间,甚至可能会加剧内存紧张的态势,也增加了 CPU 的时间开销。

(3).缓存策略

缓存选择实际上是 CPU 和内存性能开销的博弈,相比于使用字典缓存,Kyle 更推荐使用NSCache。NSCache 分配的内存实际上是 Purgeable Memory,可以由系统自动释放。这点在 Effective Objective 2.0 一书中也有推荐,NSCache 与 NSPureableData 的结合使用既能让系统根据情况回收内存,也可以在内存清理的同时移除相关对象。

3. Tools for profiling footprint

(1).为了更好寻找能够减少的内存占用,Xcode 和 Instruments 一直以来提供了一系列工具帮助我们进行 Debug:

  • Xcode memory gauge 在 Xcode 的 Debug navigator 中可以通过 Xcode memory gauge 直接看到正在 debug 程序的内存占用情况,以及其他程序占用内存和系统总内存。为了查看更为详细的内存占用变化,可以使用 Instruments 相关工具。

  • Allocations 追踪程序的虚拟内存占用和堆信息,提供对象的类名、大小以及调用栈等信息。

  • Leaks 用于检测程序运行过程中的内存泄露,并记录对象的历史信息。

    在检测内存泄露方面,三方库 MLeaksFinder 较为流行,能够不入侵代码且不用打开 Instruments,自动检测 UIViewController 和 UIView 对象的内存泄露,而且也可以扩展以检测其它类型的对象。

  • VM Tracker 能够区分程序运行时前文所述的各种内存类型占用情况,Instruments User Guide 中给出了各个参数的具体定义。

  • Virtual Memory Trace 隐藏在 System Trace 中的 Virtual Memory Trace 工具能够从 page 层面更深层次剖析应用程序的虚拟内存操作。 Syetem Trace in Depth-WWDC 2016 中给出了详细的介绍。

(2).在 Xcode 10 中,内存占用触发限制时,会有 EXC_RESOURCE_EXCEPTION 事件被捕捉到,继而可以利用各种手段分析研究内存占用情况,更有助于寻找问题根源。此外,从 Xcode 8 开始引入的 Debug memory graph 也更新了更好的布局方式。

(3).Xcode 使用 memgraph 的文件格式来储存应用程序的占用信息,因此导出 memgraph 文件可以结合命令行工具进行分析。能够虽然可视化工具已经能够直观的表现我们想要了解的内存占用信息,但是在终端中不仅可以灵活地利用各种命令和 flag 突出我们想要的内容,更可以快速的实现信息查找和文本化交互。这一部分苹果工程师为我们介绍了4个常用命令:

  • vmmap
vmmap App.memgraph  
vmmap --summary App.memgraph  

vmmap 能够打印出进程信息,所有分配给该进程的 VMRegions 以及 VMRegion 的种类、内存占用信息等内容。利用 --summary 则能够根据不同的 region type 打印出详细的内存占用类型和信息。这里需要注意的是 SWAPPED SIZE 在 iOS 上指的是 Compressed memory size 且其值表示压缩前的占用大小。

系统中将一系列连续的内存页关联到一个 VMObject 进行管理,VMRegion 即 VMObject 所管理IDE区域。 Finding iOS Memory 中对每种 VMRegion 作出了详细的解释。

此外,结合 grep 和 awk 命令,还可以进行制定 VMRegion 的信息查找。例如,以下命令以 page 而非字节为单位打印 App 中所有动态库所占内存大小。

vmmap -pages App.memgraph | grep .dylib | awk '{ sum += $6} END { print "Total Dirty Pages:" sum}'  
output:Total Dirty Pages:1387  
  • leaks
 leaks App.memgraph
 leaks --traceTree [address] App.memgraph

leaks 追踪堆中的对象,打印出进程中内存泄露情况、调用堆栈以及循环引用信息。利用 --traceTree 和指定对象的地址,leaks还能以树形结构打印出对象的相关引用。

  • heap
heap App.memgraph  
heap App.memgraph -sortBySize  
heap App.memgraph -address all | <classes-pattern>  

heap 会打印出所有在堆上的对象信息,默认按类数量排序,也可以通过 -sortBySize 按大小排序,对于追踪堆中较大的对象十分有帮助。找到目标对象后,通过 -address 获得所有/指定类的地址,继而可以利用 malloc_history 寻找其调用堆栈信息。

  • malloc_history
malloc_history App.memgraph --fullStacks [address]  

使用上述命令能够获得我们知道地址的对象的调用堆栈信息,它能够得到的比 memory inspector 中 Backtrace 更加详细。但是需要开启 Dignostics 中的 Malloc Stack 选项,才能通过 malloc_history 获得 memgraph 记录的调用堆栈信息。

上述命令都有着不同的适用场景,与可视化工具的结合能够更大的发挥它们的作用。比如当进入 Debug memory graph 模式时,可以直接通过点击下方导航栏的叹号查看内存泄露,可视化的内存泄露更为直观。发现泄露对象后,可以再使用命令行查看相对复杂的引用关系和调用堆栈。或者当程序运行后,用 vmmap/heap 查看详细的内存占用情况,然后进一步查看具体占用的 region type/class name 并得到对象地址,用 malloc_history 获取调用堆栈发现问题。

  • To see object creation: malloc_history
  • To see what references an object in memory: leaks
  • To see how large a region or an instanceSize: heap & vmmap

接下来两部分,苹果工程师针对内存的使用给出了一些建议。

4. Images

图片在内存使用上很容易产生较大的占用,如下图所示,一个图片文件从硬盘到展示需要经历加载-解码-渲染三步。以一个590KB大小、2048 * 1536 像素的图片为例,在3x设备上解码后的内存占用能够达到10MB(2048 * 3 * 1536 * 3 * 4 Bytes/pixel)之多。更深层次的图像相关实践在 Image and Graphics Best Practices 中介绍,这里我们需要知道:

Memory use of an image is related to the dimensions, not the file size in disk

因此,在解码图片时要注意所选择的图片分辨率大小,对于一些分辨率过大的图片,可以先进行下采样降低分辨率再进行解码渲染等。在 iOS 设备上支持四种图片渲染格式,每种格式有着不同的 bitsPerComponent 和适用场景:

  • SRGB format :每个像素占用 4 字节,分别表示红、绿、蓝通道以及 alpha 通道
  • Wide format:iOS 硬件设备支持的更生动的色域的渲染格式,每个通道占用 2 字节,每像素占用 8 字节。iOS 7 以上的设备可以拍摄这类照片,他们可以栩栩如生地还原美好。但是因为其较大的内存开销需要谨慎使用
  • Luminance and alpha 8 format:每像素占用 2 字节,分别表示灰度和透明度,适用于 Metal Apps 中的阴影等
  • Alpha 8 format:每像素只占用 1 字节,单色,适用于如阴影、无emoji文字等 那么我们该如何选择合适的渲染格式呢?

    Don’t pick the format, let the format pick you

相比于总是使用默认的 SRGB 格式的 UIGraphicsBeginImageContextWithOptions 方法,Kyle 建议我们使用在 iOS 10 引入的 UIGraphicsImageRenderer 类完成绘制任务,它在 iOS 12 中会根据场景自动选择最合适的渲染格式,更加合理地使用内存。UIGraphicsImageRenderer 可以创建 UIImage 对象或者进行 JPEG/PNG 格式的编码。

此外,关于下采样(downsampling),虽然上述 API 能够合理使用渲染方案,但 UIImage 在修改图片尺寸时的性能逊于 ImageIO。
  • UIImage 会首先把图片解码加载到内存,内部空间坐标转换也会带来巨大损耗

  • ImageIO 能够在不产生 dirty memory 的情况下读取到图片尺寸和元信息,其内存损耗等于缩减后的图片尺寸产生的内存占用

5. Optimizing when in background

最后Kyle给出了一点建议就是优化 App 的后台相关行为,即在 App 进入后台时释放内存占用较大的资源,进入前台时重新加载。这里的实现有两种方式:

(1)App life cycle - 对于一些正在显示的view对象,可以监听 UIApplicationDidEnterBackground 和 UIApplicationDidEnterForeground 系统通知

(2)UIViewController appearance cycle - 利用 VC 的生命周期方法,更适用于UITabBarController、UINavigationController 等有多个子vc的场景,因为你可能会有多个同一层级的 vc,但同一时间内又只有一个页面在展示

两种方式都可以在用户没有感知的情况下减少后台行为下的内存占用,让系统能够获得更多可用内存。除了苹果工程师为我们提供的建议外,内存占用也还有更多的优化可能。在对进行现有问题的追踪优化基础上,开发应用的过程中,我们更要注意对对象和文件的使用方式,避免引入显而易见的内存问题。

参考

更新日期:
本文总阅读量