0%

iOS中的 OOM 问题

OOM 也就是 Out of Memory Crash,是由于内存不足的崩溃

iOS在内存不足时会给 App 发出警告,对应 App 进行适当处理,如果处理后还是不足,那么就会结束进程

OOM 可以分为 Foreground OOM / Background OOM 两类,其中 FOOM 是由于应用在前台占用过多的内存而被系统杀死,而 BOOM 则是 App 处于后台时,由于前台有别的活动占用大量内存,为了保证前台程序的正常运行,而且后台内存清理后仍然不足,会根据优先级去杀死一些后台进程。

1、iOS内存构成

内存的分配由系统管理,一般以页为单位,在 iOS 系统中,一页的大小是 16KB,所以在 iOS 中一个 App 所占用内存大小为 页数 * 16KB

这些页可以分为3类

净页 (净内存) Clean

这部分内存大概有

  • app 的二进制可执行文件
  • framework 中的 _DATA_CONST 段
  • 文件映射的内存
  • 未写入数据的内存

前三类都是一些只读的类型,在内存紧张时,这部分内容如果不处于使用中,那么随时可以回收,对应的资源需要使用时,再进行分配读取进内存

未写入数据的内存比如分配一个容量为 20000的 Int 类型数组 int * array = malloc(20000 * sizeof(int)),系统会分配几页内存给数组,但是没有写入数据,这时候这部分内存都属于净内存

如果给数组加入数据 array[0] = 10,那么第一页内存将会变成脏页,如果写入数据 array[19999] = 20 ,那么最后一页也会变成脏页,但是中间的页依然是净页

脏页(脏内存)Dirty

是指被 App写入数据的内存,包括 图片解码的缓冲区 大部分分配的堆内存 Frameworks 中使用runtime方法交换使用的内存

压缩内存 compression

当内存不足的时候,系统会按照一定策略来腾出更多空间供使用,比较常见的做法是将一部分低优先级的数据挪到磁盘上,之后当再次访问到这块数据的时候,系统会负责将它重新搬回内存空间中。然后对于移动设备而言,频繁对磁盘进行IO操作会降低存储设备的寿命。所以从iOS7开始,系统开始采用压缩内存的方式来释放内存空间。

在iOS中当内存紧张时能够将最近未使用过的脏内存占用压缩至原有大小的一半以下,并且能够在需要时解压复用。在节省内存的同时提高系统的响应速度,缺点就是增加 cpu 的运算,以时间换空间。

在开发中,我们只需要关心脏内存与压缩内存,净内存系统会自动回收与加载,不会影响 App 内存

2、OOM 常见原因

使用内存(缓存)过大

在平时的开发中,我们经常会把需要反复使用的数据进行缓存,避免 cpu 进行重复计算,包括各种计算结果的缓存以及单例、全局对象的使用。

还有一些不恰当的操作,比如有终止条件有漏洞的递归函数。

图像的不恰当使用是一个很大的原因,图像的解码与渲染会占用大量内存,在同时使用大量高清晰度图片的时候,累计内存使用会很高

例如

照片是2880 x 1750像素,色彩空间是 RGB,具有 DisplayP3 颜色配置文件(在这里的 MBP 屏幕上,彩色LCD默认就是 P3)。此颜色配置文件每像素占用 16 位。因此,使用这样的照片UIImage(named:) 大约需要2880 x 1750 x 8 bytes ≈ 38.45 MB

循环引用

循环引用的最终结果是造成内存泄漏,导致到程序结束之前都无法释放,随着运行时间的增加,泄漏累计会使 App 占用内存越来越大,最终崩溃

还有比如使用 UIWebView 的内存泄漏问题等

3、避免 OOM

谨慎使用缓存

如果不需要实现自定义缓存,Apple 建议使用NSCache,因为它内部实现了智能机制,可以在系统内存不足时清除缓存

如果应用程序需要更复杂的缓存实现,请注意为可以持久保存的数据量设置明确的限制,并实现一些逻辑来清除旧对象,以便永远不会超出此限制。

例如使用 Alamofire 我们可以给图片下载添加缓存限制:

1
2
3
4
5
6
7
8
9
 private func setupAlamofireCache() {
let imageCache = AutoPurgingImageCache(
memoryCapacity: 30_000_000,
preferredMemoryUsageAfterPurge: 15_000_000
)

UIImageView.af_sharedImageDownloader = AlamofireImage.ImageDownloader(maximumActiveDownloads: 12,
imageCache: imageCache)
}

加载图片时要小心

如果应用需要显示大量大型高分辨率图像,用于在屏幕上渲染它们的图像缓冲区可能会产生较高的内存消耗。

除了让服务器在数据中提供多种尺寸的图片以外,在程序中合适使用工具也可以显著降低内存占用

图片的颜色配置文件,会影响用于表示内存中图像的每个像素的位数。sRGB 使用 8 位,显示 p3 使用 16 位。这可能会产生接近一倍的差距

在进行图片处理时,最好使用 ImageIO 库,抛弃老旧的 UIGraphicsBeginImageContextWithOptionsUIGraphicsRenderer 自动识别需要使用的色彩空间,并在必要时利用 Display P3 配置文件

我们拿到图像的 URL 后,通过UIGraphicsRenderer进行图像的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public extension UIGraphicsRenderer {

static func renderImagesAt(url: URL, size: CGSize, scale: CGFloat = 1) throws -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)

let options: [NSString: Any] = [
kCGImageSourceThumbnailMaxPixelSize: max(size.width * scale, size.height * scale),
kCGImageSourceCreateThumbnailFromImageAlways: true
]

let thumbnail = try {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) else { throw RenderingError.unableToCreateImageSource }
guard let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else { throw RenderingError.unableToCreateThumbnail }
return scaledImage
}()

let rect = CGRect(x: 0,
y: -size.height,
width: size.width,
height: size.height)

let resizedImage = renderer.image { ctx in

let context = ctx.cgContext
context.scaleBy(x: 1, y: -1)
context.draw(thumbnail, in: rect)
}

return resizedImage
}
}

在业务中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
let size = CGSize(width: 100, height: 100)

let iv = UIImageView(frame: .init(x: 100, y: 400, width: 100, height: 100))
view.addSubview(iv)

let imagePath = Bundle.main.path(forResource: "image", ofType: "jpg")!
let imageUrl = URL(fileURLWithPath: imagePath)

//iv.image = UIImage(contentsOfFile: imagePath)

if let image = try? UIGraphicsRenderer.renderImagesAt(url: imageUrl, size: size, scale: 4) {
iv.image = image
}

这里 scale 使用4会更接近我们原先看到的图片,但是内存占用大幅下降

当然这样的处理会增加 CPU 的工作量,这个方法在使用 UIImage(contentsOfFile:)进行图片加载时,执行消耗大概在 0.03s,而使用 ImageIO对图片增加了下采样处理,导致此方法耗时 0.09s

对没有使用中的资源进行释放

尤其是在App进入后台后,如果尽量降低App内存,那么会增加不被系统杀死的概率。

消除内存泄漏

在开发阶段注意循环引用并使用 Xcode 和 Instruments 来定位内存泄漏。

一些循环引用很隐蔽,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TestLeak {
deinit {
print("\(self) deinit")
}
var fun: (() -> Void)? = nil

func setFun() {
fun = fun2
}

func setFun1() {
fun = TestLeak.fun3
}

func fun2() {
print("fun2")
}

class func fun3() {
print("fun3")
}
}

在调用 setFunfun 赋值后,因为 fun2 是实例方法,所以fun会隐式的去捕获 self,以防止 fun2 被释放,而给 fun 赋值 fun3 并不会出现循环引用的问题。

使用 XcodeMemory Graph 可以监测到内存泄漏

在出现内存警告时候,进行清理

内存警告会通过 applicationDidReceiveMemoryWarningdidReceiveMemoryWarningUIApplicationDidReceiveMemoryWarningNotification 通知来告诉 App,所以在需要处理的位置来实现这些回调

释放尽可能多的内存,让应用程序有机会继续运行,卸载不可见的视图控制器,释放图像,模型文件,任何你能想到的东西

但是不要去轻易去释放基于 Dictionary Array 缓存的数据,因为内存压缩的存在,在收到内存警告之前,这些内存会被压缩,再使用的时候再解压,所以如果去释放内部的数据,会在使用前被解压缩,这会与想做的事情相违背,可以看下这个2018 WWDC