How I export 1.2-gigapixel images on an iPhone without running out of memory
How I export 1.2-gigapixel images on an iPhone without running out of memory
Rendering a big image on iOS is one of those things that looks trivial until your app gets killed by the OS mid-export. CGContext, draw, makeImage(), done — except the moment the output gets large, that innocent-looking pipeline quietly asks for gigabytes of RAM and iOS terminates you.
在 iOS 上渲染大图看起来很简单,直到你的应用在导出过程中被系统强行终止。CGContext、draw、makeImage(),看起来一切就绪——但一旦输出尺寸变大,这个看似无害的流程就会悄悄占用数 GB 的内存,导致 iOS 将你的应用杀掉。
I hit this wall building Mozary, an iOS app that packs 100+ photos into a single giant picture (a photo mosaic). In v1.1.0 I finally killed the “high-resolution export crashes with out-of-memory” bug for good. The fix: stop putting the canvas in RAM at all. Put it in a memory-mapped file and let the OS page it to disk. RAM usage dropped from “4.8 GB, please die” to a few dozen MB, flat, regardless of output size.
我在开发 Mozary(一款将 100 多张照片拼成一张巨型照片的 iOS 应用)时遇到了这个瓶颈。在 v1.1.0 版本中,我终于彻底解决了“高分辨率导出导致内存溢出崩溃”的问题。解决方案是:完全不要把画布放在内存(RAM)中,而是将其放入内存映射文件(memory-mapped file),让操作系统将其分页写入磁盘。内存占用从“4.8 GB,请去死吧”降到了几十 MB,且无论输出尺寸多大,内存占用都保持平稳。
This post is the walkthrough — with the actual Swift. If you’ve ever seen Core Graphics blow up on a big image (mosaics, collages, stitched panoramas, high-res rendering — same trap), this is for you.
这篇文章将带你了解具体实现,并附带 Swift 代码。如果你曾遇到过 Core Graphics 在处理大图(如马赛克、拼贴画、拼接全景图、高分辨率渲染等)时崩溃的情况,那么这篇文章就是为你准备的。
TL;DR: A non-compressed bitmap costs 4 bytes/pixel. A 1.2-gigapixel image = ~4.8 GB of RAM just for the canvas. CGContext(data: nil, …) allocates that in RAM. context.makeImage() then copies it again. Double death. Back the canvas with a memory-mapped file (mmap). Writes transparently page out to disk and don’t count against your app’s memory footprint. Wrap that same mapping in a CGImage via CGDataProvider — zero copy — and stream it straight to a JPEG on disk. Never call makeImage().
简而言之:未压缩的位图每个像素占用 4 字节。一张 12 亿像素(1.2-gigapixel)的图像仅画布就需要约 4.8 GB 内存。CGContext(data: nil, …) 会在内存中分配这些空间,而 context.makeImage() 又会再次复制一份,导致双重崩溃。解决方法是使用内存映射文件 (mmap) 作为画布的后端。写入操作会透明地分页到磁盘,不会计入应用的内存占用。通过 CGDataProvider 将该映射包装成 CGImage(零拷贝),然后直接流式传输到磁盘上的 JPEG 文件。永远不要调用 makeImage()。
Decode source tiles at their draw size, not full size. Because you now spend disk instead of RAM: add a free-space pre-flight check and clean up temp files after a crash. Let’s dig in.
解码源图块时,请使用绘制尺寸而非原始尺寸。由于现在消耗的是磁盘空间而非内存,记得在导出前检查磁盘剩余空间,并在崩溃后清理临时文件。让我们深入探讨。
The problem: “compressed file size” is a lie about memory
问题所在:“压缩文件大小”是关于内存的谎言
Mozary lays photos out on a grid. A typical high-res export is a 200 × 267 grid with each tile drawn at 150px: width: 200 × 150 = 30,000 px height: 267 × 150 = 40,050 px That’s ~1.2 gigapixels.
Mozary 将照片排列在网格上。典型的高分辨率导出是 200 × 267 的网格,每个图块绘制为 150px: 宽度:200 × 150 = 30,000 px 高度:267 × 150 = 40,050 px 总计约 12 亿像素。
Here’s the part people underestimate: the final JPEG is only a few hundred MB to ~2 GB because JPEG is compressed. But while you’re drawing, the canvas is uncompressed — 4 bytes per pixel (RGBA): 30,000 × 40,050 × 4 bytes ≈ 4.8 GB. 4.8 GB of RAM for the canvas alone. iOS caps how much memory an app may use (varies by device, but you’ll get jetsammed somewhere in the few-hundred-MB-to-~2-GB range). 4.8 GB is never happening. That’s the crash.
这是人们容易低估的部分:最终的 JPEG 文件只有几百 MB 到 2 GB 左右,因为 JPEG 是压缩格式。但在绘制过程中,画布是未压缩的——每个像素 4 字节 (RGBA):30,000 × 40,050 × 4 字节 ≈ 4.8 GB。仅画布就需要 4.8 GB 内存。iOS 对应用的内存使用有限制(因设备而异,但通常在几百 MB 到 2 GB 之间就会被系统杀掉)。4.8 GB 是绝对无法实现的,这就是崩溃的原因。
The naive way (what I had before)
天真的做法(我之前的做法)
My original export looked like this — and honestly, it was brute force: 我最初的导出代码是这样的——老实说,这完全是暴力破解:
// ❌ OLD: allocate the giant bitmap in RAM
// "Pre-flight": compute how many bytes the bitmap needs
let bitmapBytes = UInt64(imgWidth) * UInt64(imgHeight) * 4
let availableMemory = os_proc_available_memory()
if UInt64(Double(bitmapBytes) * 1.3) > availableMemory {
// Not enough → just give up on high-res
throw NSError(domain: "MediaExporter", code: -10, ...)
}
// data: nil → CGContext allocates the bitmap in RAM
let context = CGContext(
data: nil,
width: imgWidth,
height: imgHeight,
bitsPerComponent: 8,
bytesPerRow: imgWidth * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
// ... draw all the tiles ...
// makeImage() duplicates the whole bitmap (another +4.8 GB)
let cgImage = context.makeImage()!
Two problems:
- CGContext(data: nil, …) allocates the canvas in RAM → asks for 4.8 GB.
- context.makeImage() copies the canvas again → +4.8 GB at peak.
有两个问题:
- CGContext(data: nil, …) 在内存中分配画布 → 需要 4.8 GB。
- context.makeImage() 再次复制画布 → 峰值额外增加 4.8 GB。
The os_proc_available_memory() check wasn’t a fix — it was a polite “sorry, give up.” High-res export only worked on the beefiest devices, and mid-tier sizes could sneak past the check and then crash on the makeImage() copy anyway. I needed to render the same pixels without ever holding them all in RAM.
os_proc_available_memory() 的检查并不是修复,而是一种礼貌的“抱歉,放弃吧”。高分辨率导出仅在性能最强的设备上有效,而中等尺寸的导出可能会侥幸通过检查,却在 makeImage() 复制时崩溃。我需要渲染同样的像素,但不能让它们全部驻留在内存中。
The fix: put the canvas on disk, not in RAM
解决方案:将画布放在磁盘上,而不是内存中
The reframe that unlocked everything: If 4.8 GB won’t fit in RAM, don’t put it in RAM. Enter the memory-mapped file (mmap).
解锁一切的关键思路是:如果 4.8 GB 放不进内存,那就别放进内存。引入内存映射文件 (mmap)。
What mmap actually buys you: mmap maps a file into your address space. You get a pointer you read and write like normal memory — but the backing store is a file on disk. The magic is that the OS manages that region in pages: Pages you write become “dirty” and the OS writes them back to disk as needed. Once written back (clean), those pages no longer count against your app’s memory footprint (the thing that gets you jetsammed). So even if the canvas is 4.8 GB, the only thing resident in RAM is the handful of pages you’re touching right now. Everything else lives on disk. You never trip the memory limit.
mmap 到底有什么用: mmap 将文件映射到你的地址空间。你会得到一个指针,可以像操作普通内存一样读写它——但其底层存储是磁盘上的文件。神奇之处在于操作系统以“页”为单位管理该区域:你写入的页面会变“脏”,操作系统会在需要时将其写回磁盘。一旦写回(变“干净”),这些页面就不再计入应用的内存占用(即导致你被杀掉的原因)。因此,即使画布有 4.8 GB,驻留在内存中的也只有你当前正在操作的那几页。其余部分都存在磁盘上。你永远不会触及内存限制。
In one sentence: I stopped trying to fit the work in the app’s own memory, and the crash disappeared. 一句话总结:我不再试图将工作内容塞进应用的内存里,崩溃也就消失了。
Step 1 — create the file and map it
第一步:创建文件并进行映射
let ppc = size.effectivePixelsPerCell(columns: columns, rows: rows)
let imgWidth = columns * ppc
let imgHeight = rows * ppc
let bytesPerRow = imgWidth * 4
let totalBytes = bytesPerRow * imgHeight // ← can be ~4.8 GB
// 1. Create a temp file
let canvasURL = FileManager.default.temporaryDirectory
.appendingPathComponent("mozary_canvas_\(UUID().uuidString).raw")
FileManager.default.createFile(atPath: canvasURL.path, contents: nil)
// 2. Open it and grow it to the needed size (ftruncate)
let fd = open(canvasURL.path, O_RDWR)
guard fd >= 0, ftruncate(fd, off_t(totalBytes)) == 0 else {
if fd >= 0 { close(fd) }
try? FileManager.default.removeItem(at: canvasURL)
throw NSError(domain: "MediaExporter", code: -11, ...)
}
// 3. Map the file into memory
let mapped = mmap(nil, totalBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)
close(fd) // The mapping keeps the file alive, so we can close the fd
guard let canvas = mapped, canvas != MAP_FAILED else {
try? FileManager.default.removeItem(at: canvasURL)
throw NSError(domain: "MediaExporter", code: -12, ...)
}
// 4. ALWAYS release the mapping, on every exit path
defer {
munmap(canvas, totalBytes)
try? FileManager.default.removeItem(at: canvasURL)
}
ftruncate grows the file to totalBytes, but it creates a sparse file — disk is only actually consumed for the bytes you write. ftruncate 将文件扩展到 totalBytes,但它创建的是稀疏文件——磁盘空间仅在你实际写入字节时才会被占用。
Step 2 — hand the mapping to CGContext
第二步:将映射交给 CGContext
CGContext lets you pass your own buffer pointer as data:. Just give it the mmap pointer: CGContext 允许你将自己的缓冲区指针作为 data: 参数传入。只需将 mmap 指针传给它即可:
guard let context = CGContext(
data: canvas, // <--- The mmap pointer
width: imgWidth,
height: imgHeight,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!