安卓第 4 周写 ImageView 时,解码这一层我基本没碰过:Glide 接进来,into(imageView) 一挂,按控件尺寸取图、后台解码、内存磁盘二级缓存、滚出屏幕自动取消,全是框架包办的。脏活被包干的结果就是,我对"脏活"本身几乎没有概念。
Stack Overflow 上有个流传很广的对照实测:一张 28KB 的 PNG,尺寸 1080×1920,加载后 Instruments 里 ImageIO_PNG_Data 显示 7.92MB。28KB 到 7.92MB 之间隔着一个解码。PNG 和 JPEG 都是压缩格式,屏幕不能直接显示压缩数据,系统得先把它解成每像素 4 字节的位图,1080 × 1920 × 4 ≈ 8.3MB。文件大小决定网络传输和磁盘占用,尺寸才决定内存。
这周的内容基本围着这个事实转:怎么让进内存的位图尽量小(下采样),怎么让解码不卡主线程(后台解码),怎么让解码结果可复用(缓存),加上圆角和 GIF 这两个内存大户的处理。
contentMode:13 个值,先分两类再记
iOS 的 UIView.ContentMode 官方枚举一共 13 个 case,安卓 ScaleType 是 8 个。硬背容易混,按"缩放类"和"摆放类"分组会好记很多:
| 分组 | 值 | 行为 | 安卓对应 |
|---|---|---|---|
| 缩放 | .scaleToFill |
拉伸填满,不保比例(默认值) | fitXY |
| 缩放 | .scaleAspectFit |
等比缩放至完整显示,周围留空 | fitCenter / centerInside |
| 缩放 | .scaleAspectFill |
等比缩放至填满,超出裁剪 | centerCrop |
| 重绘 | .redraw |
bounds 变化时调 setNeedsDisplay() 整块重画 |
无对应(自定义 onDraw 的思路) |
| 摆放 | .center / .top / .bottom / .left / .right / .topLeft / .topRight / .bottomLeft / .bottomRight |
不缩放,原图按方位摆放,越界裁掉 | center;其余无直接对应 |
两边默认值是反的,这是迁移的第一坑。Apple 文档 contentMode 属性页原话:"The default value of this property is UIView.ContentMode.scaleToFill",允许变形拉满;安卓 ImageView 的默认 scaleType 是 fitCenter(Android 官方 ScaleType 枚举页 + 多家中文资料口径一致),等比缩放完整显示。从安卓过来什么都不设的话,头图会被拉变形,而安卓什么都不设最多边上留白。
.redraw 是给自定义 draw(_:) 的视图用的。UIImageView 用不上它:contentMode 改变的只是 GPU 合成时对 layer 缓存位图的变换方式,位图内容本身不变,也就不需要重绘。这句话反过来理解更值钱:contentMode 的所有取值都不触发解码,同一张图换十种 contentMode,内存里始终只有一份位图。
圆形头像的最常见写法:
ini
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.layer.cornerRadius = avatarImageView.bounds.width / 2
avatarImageView.layer.masksToBounds = true
cornerRadius 只作用于背景色和边框,对 layer.contents(图片位图)不生效,所以必须配 masksToBounds = true 才看得到裁剪效果。这对组合的渲染代价是下一节的事。
圆角与离屏渲染:那句流传很广的性能警告,边界比它写的细
"cornerRadius + masksToBounds 触发离屏渲染,列表里会卡"。它不完全对。细化后的口径:
- iOS 9 之前,
UIImageView这样设圆角确实触发离屏渲染 - iOS 9 之后,单层 contents 的场合被系统优化了:图片直接参与圆角裁剪,不再离屏。一旦同时设了背景色、边框,或者叠了子图层,又回到离屏渲染
UIButton没吃到这个优化,设圆角照样离屏
列表头像这种大量圆角的场合,iOS 的传统替代方案是把裁剪挪到 CPU 一次做掉:后台线程画一个圆角路径裁好的位图,图层直接显示结果,滚动时 GPU 只做普通合成。
arduino
func roundedImage(from image: UIImage, cornerRadius: CGFloat) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: image.size)
return renderer.image { _ in
let rect = CGRect(origin: .zero, size: image.size)
UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip()
image.draw(in: rect)
}
}
// 在后台队列执行,裁好之后回主线程直接 imageView.image = 结果
裁剪发生在解码后的位图上,做完之后 imageView 不再需要 masksToBounds。代价是每张图多一次 CPU 绘制,换来列表滚动时 GPU 零额外负担。选 UIGraphicsImageRenderer 而不是老的 UIGraphicsBeginImageContextWithOptions,依据是 WWDC18 同一场 session:老 API 固定用 4 字节/像素的 SRGB 格式,新 API 让系统按内容挑格式(比如纯蒙版图形用 1 字节/像素的 Alpha 8),这是文档级的内存优化点。
顺带一个量化的例子:安卓那边的圆角如果走 ShapeableImageView,也是 Material 组件在视图层做的裁剪。两端殊途同归,都是在"让 GPU 少开离屏缓冲"上打转。
手写最小加载器:下载、错位、解码,三个环节各有一个坑
先给整体骨架。下载走 URLSession,内存缓存用 NSCache,同一 URL 的在途请求合并:
swift
final class ImageLoader {
static let shared = ImageLoader()
private let cache = NSCache<NSURL, UIImage>()
func loadImage(from url: URL, completion: @escaping @MainActor (UIImage?) -> Void) {
if let hit = cache.object(forKey: url as NSURL) {
completion(hit)
return
}
let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let data, let image = UIImage(data: data) else {
DispatchQueue.main.async { completion(nil) }
return
}
self?.cache.setObject(image, forKey: url as NSURL,
cost: Int(image.size.width * image.size.height
* image.scale * image.scale * 4))
DispatchQueue.main.async { completion(image) }
}
task.resume()
}
func cancelAll() { cache.removeAllObjects() }
}
cost 的算法是宽 × 高 × scale 的平方 × 4,因为 image.size 是点,内存要按物理像素算,这一点跟 kCGImageSourceThumbnailMaxPixelSize 的坑同源,后面还会撞到。
坑一:cell 复用图片错位。下载是异步的,cell 被复用去显示别的数据后,前一个请求回来会把旧图盖在新数据上。Glide 的 into() 自带请求取消,安卓时代感知不到这个坑的存在;手写就得自己处理,标准做法是给 imageView 挂一个 token,回调时校验:
swift
extension UIImageView {
private static var tokenKey: UInt8 = 0
func loadImage(from url: URL) {
let token = UUID()
objc_setAssociatedObject(self, &Self.tokenKey, token, .OBJC_ASSOCIATION_RETAIN)
ImageLoader.shared.loadImage(from: url) { [weak self] image in
guard let self,
objc_getAssociatedObject(self, &Self.tokenKey) as? UUID == token
else { return }
self.image = image
}
}
}
token 不匹配说明这个 imageView 已经被复用去加载别的图了,旧回调直接丢弃。列表场景还要在 prepareForReuse 里调 URLSessionDataTask.cancel() 把在途请求真正断掉,不然校验只是防了错位,流量和 CPU 照样在烧。
坑二:解码时机。UIImage(data:) 生成对象时位图还没解码,真正的解码发生在第一次绘制到屏幕的瞬间,而那下在主线程。也就是说,就算下载全程在后台线程,直接把 UIImage(data:) 扔给 imageView,解码那一下还是会卡 UI。SDWebImage 默认在后台预解码,就是冲着这个来的。手写版有个一步到位的做法:走下面的下采样函数拿图,它生成时就把解码做完了。
下采样:WWDC 官方指定的做法,比"先解码再缩小"快一半
控件只有 100×100,就没必要把 4000×3000 的原图完整解码进内存。先看反面写法:
ini
// 反面写法:先整图解码,再缩小
let image = UIImage(contentsOfFile: path) // 此刻还没解码
let renderer = UIGraphicsImageRenderer(size: smallSize)
let small = renderer.image { _ in image.draw(in: targetRect) }
这个写法的内存曲线是:先冲到原图全尺寸位图的峰值(4000 × 3000 × 4 ≈ 45MB),再回落到小图。峰值那一下就够让列表多滑两屏的 cell 被 jetsam 杀掉。Apple 在 WWDC18 Session 416 给的官方替代是 ImageIO 下采样:
swift
func downsampledImage(from url: URL, pointSize: CGSize, scale: CGFloat) -> UIImage? {
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil }
let maxPixel = max(pointSize.width, pointSize.height) * scale // 单位是像素,不是点
let thumbnailOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel,
kCGImageSourceCreateThumbnailWithTransform: true, // 带上 EXIF 方向信息
kCGImageSourceShouldCacheImmediately: true // 生成时就完成解码
]
guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary),
let downsampled = UIImage(cgImage: cg) as UIImage?
else { return nil }
return UIImage(cgImage: downsampled.cgImage!) // 保留解码后的位图
}
准确说最后两行的强制解包不该出现在生产代码里,简化写法直接 return UIImage(cgImage: cg)。写成一长串是想让参数逐个被看见。CGImageSourceCreateThumbnailAtIndex 是流式读取,解码只解到目标尺寸就停,Apple 在 session 里的量化结论是:比"UIImage 先解码再缩小"快约 50%,且没有大图位图的内存峰值,因为脏内存只为最终结果付账。
两个参数级的细节。kCGImageSourceThumbnailMaxPixelSize 的单位是像素 ,而 imageView.bounds 是点,两边差一个 traitCollection.displayScale(3 倍屏传 1 就糊了)。Apple 文档还有一句容易被漏的话:不传这个参数时,返回的"缩略图"就是原图尺寸。
还有一条画质边界:Apple 开发者论坛上有人(2021 年的帖子)报告 ImageIO 下采样的结果比 UIKit 渲染的略糊一点,方向上是"省内存换一点画质",头像缩略图无感,大图浏览页需要自己权衡。
NSCache 与三级缓存:把 NSDictionary 的直觉先扔掉
三级缓存的骨架是内存 NSCache → 磁盘文件 → 网络。内存这一层,Apple 官方文档给了 NSCache 四条与 NSDictionary 不同的特性,每条都对应一个工程决策:
- 内置自动驱逐策略 。系统内存紧张时自己删对象,不需要手写
didReceiveMemoryWarning逐个清理。Apple 归档文档《Caching and Purgeable Memory》的原话是它是"reactive cache":有内存就激进缓存,内存吃紧就自动丢弃。 - 线程安全,多线程读写不需要自己加锁。
- 不拷贝 key (NSDictionary 会拷贝),所以 key 用
NSURL、UUID这类对象没有额外拷贝开销。 countLimit/totalCostLimit控制上限,两者默认 0,即无限制。不设上限的 NSCache 缓存多少看系统脸色,驱逐时机 Apple 只说"may purge at any time"。
自动驱逐不等于万事不管。驱逐的触发时机 Apple 没有承诺,模拟器上模拟内存警告经常不清理(社区多人反馈),所以图片缓存通常还是注册 didReceiveMemoryWarningNotification 主动 removeAllObjects(),把兜底行为握在自己手里。另外驱逐的先后顺序(LRU 还是按时间)Apple 从未文档化,社区实验显示近似按更新时间淘汰,不保证严格 LRU。想精确控制淘汰策略的人,就到了自己写缓存或用框架的时候。
imageNamed: 和 contentsOfFile: 的选择是同一层认知的另一面。Apple 文档原话:imageNamed: 会查系统缓存,系统"may purge cached image data at any time",且只清理不再使用的;文档的 Special Considerations 明确建议"只显示一次的图,用 imageWithContentsOfFile: 避免进系统缓存,改善内存表现"。翻译成工程语言:常驻小图标用 imageNamed:,详情页那种一次性大图用 contentsOfFile: 加下采样。
磁盘层的设计点就两个:按 URL 哈希做文件名;清理策略按"总大小上限 + 最旧访问时间"淘汰。这层逻辑手写大概 50 行,SDWebImage 和 Kingfisher 都内置了同款,也是它们值得用的一部分。
GIF:animatedImage 把所有帧都解到内存里
UIImage.animatedImage(with:duration:) 是最省事的多帧方案,但它有一个必须先算的账。4032×3024 的单帧解码后约 48MB(4032 × 3024 × 4),30 帧的 GIF 全帧预载就是 1.4GB 量级,这条路对大图直接判死刑。它适用的场合是小尺寸 loading 动画、聊天表情,帧少图小。
GIF 文件本身带每帧的时长信息,用 ImageIO 逐帧提取:
swift
static func animatedGIF(from data: Data) -> UIImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
let count = CGImageSourceGetCount(source)
var frames: [UIImage] = []
var total: TimeInterval = 0
for i in 0..<count {
guard let cg = CGImageSourceCreateImageAtIndex(source, i, nil) else { continue }
frames.append(UIImage(cgImage: cg))
total += frameDuration(at: i, source: source)
}
return UIImage.animatedImage(with: frames, duration: total)
}
static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
guard
let props = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
let gif = props[kCGImagePropertyGIFDictionary] as? [CFString: Any]
else { return 0.1 }
let unclamped = gif[kCGImagePropertyGIFUnclampedDelayTime] as? Double
let clamped = gif[kCGImagePropertyGIFDelayTime] as? Double
let delay = unclamped ?? clamped ?? 0.1
return delay < 0.02 ? 0.1 : delay // 浏览器传统的老坑:0 时长帧按 0.1 处理
}
UnclampedDelayTime 优先于 DelayTime,小于 0.02 秒按 0.1 处理,这是社区多年流传下来的惯例(继承自浏览器对 GIF87a/89a 的兼容行为),Apple 文档没有写。不处理它,动画会快得抽搐。
列表里的 GIF 走另一条路:宫格里只解码首帧当封面,点开预览再完整解码;或者换 SDAnimatedImageView / FLAnimatedImage 这类逐帧渐进解码的控件,同屏几百张 GIF 的社交 Feed 才能活。公开的相册选择器实战文章里,单张 4032×3024 的 Live Photo 缩略图就按"首帧封面 + 点开全解"来省内存,同一个思路。
SF Symbols:按点尺寸渲染的矢量图标
ini
let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
let icon = UIImage(systemName: "heart.fill", withConfiguration: config)
安卓的矢量方案是 Vector Drawable + ImageView,iOS 这边是 SF Symbols:Apple 官方维护的符号库,按 point size 渲染,SymbolConfiguration 可以直接改字重和尺寸,同一符号出 regular / semibold / bold 多种形态,不需要切多套图。对比之下安卓要改矢量图标的视觉重量,得动 XML 或者引第三方方案。
一个容易忽略的文档细节:UIImage(named:) 在 asset catalog 里查找时,同名资产里 symbol 优先于位图(iOS 13+ 行为)。如果项目里既有叫 "heart" 的位图又误用了个同名 symbol,加载结果会跟你预期的不一样。
映射
下采样与后台解码 ,ImageIO 按显示尺寸下采样、解码放后台、UIGraphicsImageRenderer 挑像素格式、不可见大资源在进后台和 viewWillDisappear 时卸载。SDWebImage 的 SDWebImageContextImageThumbnailPixelSize 和 Kingfisher 的 DownsamplingImageProcessor,内核都是这套 ImageIO 调用。有一篇公开技术文章(腾讯云开发者社区,iOS 性能优化系列)给了 Feeds 流的量化数据:6 张大图按控件 100×100 尺寸下采样后,内存从 25MB 降到 5MB;首页 Feed 在只加载不到三屏的场景里省了约 40MB。这是"按控件尺寸解码"最直观的收益数据。
列表 GIF 的分级解码,公开的相册选择器实战文章的做法是宫格首帧封面、预览页全解,配合下采样控制单帧尺寸。社交 Feed 类产品(微博、快手这类重度 GIF 场景)通用方案是逐帧渐进解码控件加按需解码,SDWebImage / FLAnimatedImage 内置了这套逻辑。
手写与框架的分工判断:这一周手写加载器的意义是把框架黑盒里的这层拆开看。真业务的结论是列表图片直接上成熟框架,自写版只适合学习原理和极简场景。框架的完整对比(Kingfisher 的 Swift 原生与 processor 管线、SDWebImage 的 coder 体系和存量优势、Nuke 的性能导向)进专题任务池,编号 SP-I001。
技术清零表
| 技术 | 它是什么 | 工程价值 | 常见坑 |
|---|---|---|---|
UIView.ContentMode |
视图内容随 bounds 变化的 13 种处理方式 | 控制图片显示形态 | 默认 .scaleToFill 会变形,安卓默认 fitCenter,直觉相反 |
cornerRadius + masksToBounds |
图层圆角与裁剪 | 圆角头像标准写法 | 多层内容时仍离屏渲染;UIButton 未吃到 iOS 9 优化 |
UIGraphicsImageRenderer 预裁剪 |
CPU 侧一次性画出圆角位图 | 列表滚动 GPU 零额外负担 | 每张图多一次 CPU 绘制,需放后台队列 |
CGImageSourceCreateThumbnailAtIndex |
ImageIO 流式下采样 API | 按显示尺寸解码,省内存峰值 | 不传 MaxPixelSize 返回原图尺寸 |
kCGImageSourceThumbnailMaxPixelSize |
下采样的目标像素上限 | 峰值内存的直接控制项 | 单位是像素,控件尺寸是点,要乘 scale |
NSCache |
带自动驱逐的线程安全缓存 | 图片内存缓存的标准选型 | 两限默认 0 无限制;驱逐时机与顺序无承诺 |
imageNamed: vs contentsOfFile: |
走/不走系统缓存的两种加载 | 常驻小图与一次性大图的分流 | 全部用 imageNamed: 会把大图也压进系统缓存 |
URLSessionDataTask.cancel() |
断开在途下载 | 列表滚出屏停止烧流量 | 只配 token 校验不取消任务,浪费还在继续 |
| 回调 token 校验 | 关联对象挂 UUID 防错位 | cell 复用图片不错乱 | Glide 时代 into() 包办,迁移后要手写 |
UIImage.animatedImage(with:) |
多帧 UIImage 动画 | 小图动画(loading/表情) | 全帧预载入内存,大 GIF 直接判死 |
kCGImagePropertyGIFDelayTime |
GIF 每帧时长元数据 | 还原原始播放节奏 | 0 时长帧按 0.1 处理是社区惯例 |
SF Symbols + SymbolConfiguration |
按点尺寸渲染的官方矢量图标 | 一套符号多字重多尺寸 | 同名资产 symbol 优先于位图 |