第 4 周:28KB 的 PNG 占了 8MB 内存,因为图片内存看尺寸不看文件大小

安卓第 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 不同的特性,每条都对应一个工程决策:

  1. 内置自动驱逐策略 。系统内存紧张时自己删对象,不需要手写 didReceiveMemoryWarning 逐个清理。Apple 归档文档《Caching and Purgeable Memory》的原话是它是"reactive cache":有内存就激进缓存,内存吃紧就自动丢弃。
  2. 线程安全,多线程读写不需要自己加锁。
  3. 不拷贝 key (NSDictionary 会拷贝),所以 key 用 NSURL、UUID 这类对象没有额外拷贝开销。
  4. 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 优先于位图
相关推荐
leizhen198408092 小时前
Vibe Coding:门槛降了,但判断力成了新的门槛
人工智能·安全·ios
我就是不信7 小时前
Swift 方法派发机制详解
开发语言·ios·swift
AIsoft_86899 小时前
科会通录音工具倍速回放怎么切换速度
ios·智能手机
老李IT笔记10 小时前
设备显示在线但指令没回执:三类静默失效的排查顺序
ios·iphone
芯械研究所10 小时前
她宁、益博思、倍思、绿联哪款ipad电容笔好?四款电容笔款式实测解读
ios·ipad·电容笔·电容笔推荐·电容笔测评
Yannik1 天前
iOS CocoaPods私有库完整搭建+踩坑实战总结
ios·cocoapods
终端安全笔记1 天前
绕过工具把 iOS 27 列进支持范围:从它的更新节奏能读出什么
安全·ios
AIsoft_86891 天前
线上培训录屏怎么转文字?科会通操作指南
ios·智能手机