深入了解iOS内存管理
在开始之前,我们先要弄清一个问题,我们为什么要减少软件的内存占用?
简单的回答是,为了使用户获得更好的体验
不仅你的 App 会启动得更快,系统会表现得更好,你的 App 也会在内存中保留更长的时间
其他 App 也会在内存中保留更长的时间,几乎一切都变得更好
内存占用
首先我们要谈谈相对底层的部分,页
系统通过内存页来分布内存,一页的大小一般是16kB,它们可能是净页,也可能是脏页
一个页可以存储多个对象,一个对象也可能被分配到多个页上来存储
App的内存占用,实际上指的是页面数量乘以页面大小
关于脏页和净页,这里有一个例子
int* array = malloc(20000 * sizeof(int));
array[0] = 32;
array[19999] = 64;
假设我分配了一个含有20000个int的数组,系统可能会分配给我6个内存页面
此时,这六个页面是净页
但是当我开始写入数据的时候,例如我写入第0个数据,这个内存页就会变为脏页
类似的,如果我写入最后一个位置,最后一页也会变成脏页

注意!!!!
此时中间的四个页面仍然是净页,因为App还没有写入它们
所以判断页是否干净的依据是这页上是否有写入数据,而不是是否被占用
同时另一个有趣的东西是内存映射文件,它是一种在磁盘上的文件,但是加载到了内存中,如果你使用的是只读文件,这些页面会一直是净页

当我们讨论某个App时,它们的内存占用和分析文件,都会分为三部分
- 脏的部分
- 压缩部分
- 干净部分
净内存
净内存是可以被分页的内存,它们通常是
- 内存映射文件
- 框架(_DATA_CONST部分)
关于框架的部分,_DATA_CONST部分一般是净内存,但是如果你使用run time的一些方法,它就会变成脏内存
脏内存
脏内存是App写入的任何内存,它们可能是
- 对象
- 图像缓冲
- 框架(_DATA,_DATA_DIRTY)
压缩内存
压缩内存在iOS 7后被加入,内存压缩器接收未访问的内存页并压缩它们
这实际上可以创建更多的空间
但注意,在访问时,压缩器会对它们进行解压,以便读取内存
内存警告
关于内存警告部分,我们首先要知道,你的App实际上可能并不是引发内存警告的原因
如果在一个低内存的设备上接到一个电话,那也可能会触发一个内存警告
压缩器使得对内存警告的处理,即内存的释放变得复杂
例如:
我们在一个内存中有一个字典,它本身占了3页,但是在压缩后,它变成了一页

这本身是非常好的事,因为我们多了两页可以用的内存
但当我们收到一个内存警告,并决定从缓存中删除掉所有的对象的时候,问题就出现了

由于我们需要访问这个字典,这个字典被压缩器解压了!
这让本就不富裕的内存更加雪上加霜
内存占用限制
在App中,当我们讨论App的内存占用的时候,我们实际上在讨论的内存是脏内存和压缩内存,净内存在这里并不重要,这并不难理解
每个App都有一个内存占用限制
这个限制通常来说对于一个App来说是相当高的,但是请记住,根据设备的不同,这个限制也会改变,所以在优化App的内存占用的时候,还是尽可能做到少占用
当你的App超过了内存占用限制,就会出现异常,这种异常就是EXC_RESOURCE_EXCEPTION
图像的内存占用
关于图像的内存,最重要的就是内存的使用与图像的尺寸有关,而不与它的文件大小有关
举一个例子,我们有一个2048*1536的大小为590kB的图像,它如果被加载到内存中,需要占多少内存呢?
答案是10MB
把像素的宽度乘以高,每个像素再乘以4,就是占用的内存数量
图像在iOS上工作有加载,解码,渲染三个阶段
在加载阶段,这个被压缩的590KB的JPEG文件被接受并被加载到内存中
在解码阶段,JPEG文件将被转化为GPU可以读取的格式,图像需要被解压,这将使得文件大小增加至10MB
在这之后,就可以被渲染了
在SRGB格式中,每个像素有4个字节,这是图像中最常见的格式,红,绿,蓝各一个字节,Alpha通道一个字节
图像下采样
在之后的项目中,我们可能需要对图像进行下采样,即获取缩略图操作
我们在制作缩略图的时候,不应该使用UIImage来直接控制大小,如果有超清图片的存在,你的内存占用将飙升到一个恐怖的值
这里只简单介绍一个下采样方法,使用Image IO来进行下采样
先加一张图片

这张照片的信息为

130MB对吧,但是记得我们之前说过的吗
内存的使用与图像的尺寸有关,而不与它的文件大小有关
所以加入内存中的大小应该为14075 * 7010 * 4
我们把它加入程序显示

占用内存稳定为420MB

如果我们要直接缩小这个View来实现缩略图的效果,

太棒了!内存占用根本没有任何变化!

那我们如何高效的实现缩略图呢?
思路实际上很简单,我们想要描绘大象的身形,我们可以不把它抬进屋,只在门口把它的影子画出来就好了
即避免完全解码,直接根据原始数据来生成缩略图
我们的函数需要原始的未解码的原始数据,和最后图片的精确度,所以函数接口应该长这样
- (UIImage *)downsampleImageWithData:(NSData *)imageData toMaxDimension:(CGFloat)maxDimension
我们要使用的部分为Image IO框架,这个框架是使用c语言来编写和构建的,更为底层
当我说这个框架是c语言构建的时候,就说明,这个框架的内存管理是非常自由的(完全没有)
它们不使用ARC,而是遵循传统的c语言内存管理规则,需要手动调用来释放资源
这同时意味着,NSData,NSDictionary不能直接被使用,你必须使用c语言中与之对应的数据类型CFDataRef, CGImageSourceRef, CFDictionaryRef
同时,你还需要使用桥接来连接这两个完全不同的世界,以便你可以将你的高级对象传递给底层的c
数据内容
CGImageSourceRef代表图像的来源,它是ImageIO框架的核心,用于从各种来源(NSData,NSURL等)读取图像数据和元数据
我们的第一步就应该是将参数传入的数据转化为CGImageSourceRef类型,方便识别
CGImageSourceCreateWithData 是一个非常基础和重要的函数,它的作用是建立一个读取图像数据的通道,它返回一个c类型的指针,并且需要手动释放
__bridge 是一种桥接,它把一个oc对象的指针安全地转化为一个c语言类型的指针,注意,它只会进行指针的转换,而不改变对象的引用计数或所有权
CFDataRef 是 Core Foundation 框架中表示原始二进制数据的 C 类型引用。它与 Cocoa 框架中的 NSData 是功能上等价的
知道了上述这四个东西,我们就可以迈出第一步:
CGImageSourceRef imageSource = CGImageSourceCreateWithData((__bridge CFDataRef)imageData, NULL);
成功的将图片转化为了一段数据的引用
配置下采样选项
我们曾经在AFN的第三方库里学过使用字典来配置请求连接
同样的这里的下采样也适用字典来配置采样的选项
这里简单介绍我们需要用到的四个选项
-
kCGImageSourceShouldCache告诉ImageIO不要在内部缓存完整解压后的原始图像数据,这也是我们的目的,高效实现下采样,确保我们只为缩略图分配内存
在这里我们设置为 @NO
-
kCGImageSourceCreateThumbnailWithTransform方向修正,告诉ImageIO在生成缩略图的时候,是否自动旋转图片,让图片方向正确
这里我们设置为 @YES
-
kCGImageSourceCreateThumbnailFromImageIfAbsent当图像不包含嵌入的缩略图,也要从主图像数据生成一个
这个选项保证我们无论如何都可以得到一个缩略图
这里我们设置为 @YES
你知道吗?
很多原始图片文件是包含一个或多个嵌入的缩略图的
许多现代的图像文件格式(最常见的是 JPEG 和一些 RAW 格式)在文件主体像素数据之外,还包含额外的元数据(Metadata)。这些元数据中,往往包含了一个或多个预先生成好的缩略图
这样可以提高用户体验和文件预览速度 -
kCGImageSourceThumbnailMaxPixelSize告诉ImageIO最终生成缩略图的最大边长(宽或高),不能超过这个参数,ImageIO 会根据这个值在读取数据时直接执行高效缩放
这个值即为我们传入的参数
设置为 @(maxDimension)
最后我们的字典应该是
NSDictionary *options = @{(__bridge NSString *)kCGImageSourceShouldCache: @NO,(__bridge NSString *)kCGImageSourceCreateThumbnailWithTransform: @YES,(__bridge NSString *)kCGImageSourceCreateThumbnailFromImageIfAbsent: @YES,(__bridge NSString *)kCGImageSourceThumbnailMaxPixelSize: @(maxDimension)};
生成缩略图
所有准备工作都完成了,我们就可以开始生成缩略图了
CGImageRef 结果是一个指向位图图像数据的引用。这个数据就是已经按 maxDimension 缩放好的小图的像素数据
CFDictionaryRef 其实就是oc的字典对象在c里的投射,不过多讲述
CGImageSourceCreateThumbnailAtIndex 函数,接收一段数据,和一个字典作为选项
ImageIO 在接收到这个指令后,它不会读取和解码所有像素。它会利用图像文件格式(如 JPEG)的特性,在读取数据流的同时,进行缩放和下采样,只解码生成目标尺寸 (X×Y) 所需的像素
这样CGImageRef就是我们需要的缩略图数据了
CGImageRef downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, (__bridge CFDictionaryRef)options);
转化为UIImage
在转化之前,我们要先善后
在一开始我们就提到过,ImageIO框架由c语言撰写,所以内存管理相当的自由
在我们已经拿到了我们需要的数据之后,我们要先把之前不需要的数据释放掉
CFRelease(imageSource); // 释放之前的数据
之后,我们就可以将之前的数据转化为UIImage对象
UIImage *finalImage = [UIImage imageWithCGImage:downsampledImage];
最后释放我们的CGImageRef数据
CGImageRelease(downsampledImage);
以及返回最终的缩略图像
整个过程到这里就结束了
效果是这样的


下采样就到这里就算完成了
当然iOS内存管理部分要学习的还有很多,包括方法交换造成的内存占用都是将来要学习的部分内容
关于CF和CG框架,也只是在这里简单介绍了一下而已,之后还需要多学习
引用资料
- https://developer.apple.com/cn/videos/play/wwdc2018/416
- https://zhuanlan.zhihu.com/p/579702765
- https://blog.csdn.net/q923714892/article/details/118343736
