移动端图片加载
移动端图片加载把文件或网络中的图像数据转换成适合目标视图显示的资源。下载完成只是其中一步:解码、缩放、缓存、界面绑定和资源释放都可能影响内存、滚动流畅度与显示正确性。
准备这个主题时,要能解释一张图从 URL 到屏幕经历了什么,为什么几百 KB 的文件可能占用很多内存,以及列表复用后怎样阻止旧请求把图片放到错误的位置。框架源码只是在这些责任之上的具体实现,不能用某个库的方法名替代整条过程。
1. 面试核心
先记住这几条,再读正文- 压缩文件体积与解码内存是不同指标。像素尺寸、像素格式、行对齐、临时副本与缓存共同影响占用,不能只看下载字节数。卡 · 像素与解码内存
- 按显示需求控制解码尺寸,通常比先解出大图再缩小更有利于峰值内存。目标像素还要考虑屏幕比例、裁剪方式和图片方向。卡 · 目标尺寸要换算成像素
- 内存缓存、磁盘缓存和 HTTP 缓存的内容与规则不同。命中磁盘不等于已经有可显示的解码资源,缓存键也不能总是只有 URL。卡 · 缓存与请求合并
- 同一资源可以共享加载工作,但每个显示位置都有自己的订阅和生命周期。一个视图取消,不一定应该取消其他视图仍需要的请求。
- 取消减少无用工作,身份校验保证晚到结果不会显示错位。成功、失败和占位图更新都应属于当前绑定。卡 · 列表复用与晚到结果
- 解码、转换与磁盘访问需要控制执行位置和并发度。开启很多后台任务仍可能造成内存峰值与 CPU 争用。
- 图片库的缓存、请求管理和资源复用是不同机制。Glide 4 可以依次检查活动资源、内存缓存、转换后磁盘资源和原始数据,再回到源数据加载。卡 · 从请求到显示
- 完整方案需要覆盖更新、失败、取消和清理。联系人头像不仅要首次显示正确,还要能更新头像、快速滚动和切换账号。卡 · 综合题
2. 从请求到显示
一个加载器需要协调以下工作。缓存命中可能跳过部分阶段,但不能把“已经下载”当作“已经能直接显示”。
| 阶段 | 主要工作 | 可能的成本或失败 |
|---|---|---|
| 确定请求 | 资源身份、版本、目标尺寸、变换和权限范围 | 键冲突、头像更新后仍命中旧图 |
| 查找可复用资源 | 活动资源、内存或磁盘记录 | 资源已失效,磁盘文件损坏 |
| 获取编码数据 | 本地文件、HTTP 或其他数据源 | 超时、鉴权失败、不完整数据 |
| 解码与转换 | 得到像素、处理方向、裁剪或圆角等变换 | 大图内存峰值、格式不支持、计算成本 |
| 绑定显示 | 核对当前请求身份,更新目标视图 | 列表已复用,旧结果覆盖新内容 |
| 管理资源 | 缓存、取消、解绑、淘汰和复用 | 无界缓存、仍在使用的资源被错误回收 |
网络请求与磁盘读取可以是异步的,解码也可能被库延迟到后续阶段。具体线程和解码时机依赖 API、格式与配置,不能认为调用一个异步下载方法后,所有后续工作自然都离开了主线程。
3. 像素与解码内存
3.1 文件很小,解码后仍可能很大
解码把 JPEG、PNG 等编码数据转换成可供后续图像处理和显示使用的像素表示。压缩率取决于图像内容和格式,而展开后的像素数量主要取决于宽高。
假设一个 4000 × 3000 的图像采用每像素 4 字节表示,紧密排列的单份像素约为 4000 × 3000 × 4 = 48,000,000 字节,即约 45.8 MiB。它的 JPEG 文件可能远小于这个数字,但不会因此让所有解码像素也同样小。
这是估算,不是整个应用占用的精确值。实际还要考虑每行字节数、像素格式、解码临时空间、多个副本、图形资源和缓存;动画图片还可能保留多帧。Android 的 Bitmap 配置或 iOS 图像实际表示改变时,不能继续一律按 4 字节计算。
改变 JPEG 编码质量主要影响编码数据和画质;如果宽高与解码格式没变,就不能据此承诺单份解码像素内存按同样比例减少。图片文件压缩与解码降采样解决的是不同问题。
3.2 目标尺寸要换算成像素
视图的逻辑尺寸不等于图片的像素尺寸。一个宽 100 pt、显示比例为 3 的 iOS 头像,通常需要按约 300 像素宽评估资源;Android 则要结合 dp、密度与实际目标尺寸。保留过大图像会增加成本,过小又可能模糊。
裁剪方式也影响需求。等比完整显示与铺满后裁剪不同,不能只拿目标的最长边就保证所有宽高比都清晰。特别窄长的源图铺满方形区域时,应先按源图比例计算足够覆盖目标的尺寸,再选择是否接受裁剪与内存代价。
方向元数据可能改变显示后的宽高关系。读取尺寸和选择缩略图参数时,要考虑旋转方向,而不是始终把文件中记录的宽直接当成屏幕上的宽。
4. 降采样与解码位置
4.1 在解码阶段控制尺寸
降采样是在生成像素时采用较小分辨率,避免先创建完整大图,再额外创建一份缩小图。Android 可以先用 BitmapFactory.Options.inJustDecodeBounds 读取尺寸信息,再按目标设置整数 inSampleSize;它不是一个布尔开关,最终尺寸还要结合解码器与密度处理检查。
在 iOS 上,可以用 Image I/O 创建缩略图。下面的 Swift 5 示例接收本地文件 URL 和正整数像素上限,只处理第一个图像,不负责下载或完整的图片缓存:
import Foundation
import ImageIO
func thumbnail(at url: URL, maxPixelSize: Int) -> CGImage? {
guard maxPixelSize > 0 else { return nil }
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
return nil
}
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceShouldCacheImmediately: true
] as CFDictionary
return CGImageSourceCreateThumbnailAtIndex(source, 0, options)
}
maxPixelSize 限制生成缩略图的最大宽高,缩略图保持源图的宽高关系;方向变换由对应选项处理。比如 400 × 300 的普通横图用上限 200,可得到 200 × 150 的缩略图;它不会自动变成 200 × 200 的方形头像。
这个函数是同步的,应由调用方安排合适的执行队列,避免把文件访问和解码放进繁忙的主线程。kCGImageSourceShouldCache 为 false 不能被解释为“全流程不分配像素内存”;这里随后明确要求立即准备缩略图的图像数据。调用方仍要限制源尺寸、目标尺寸和并发数量,处理无效文件与 nil 结果。
4.2 后台执行仍需要限流
十张大图同时解码,可能同时保留十份输入、输出和临时资源。即使每个任务都在后台,也可能造成内存峰值、CPU 争用和能耗增加。
并发度应结合设备与任务成本设置。可见图片通常比很远的预取更紧急;快速滚动后取消或降低不再需要的任务优先级。过度预取会占用网络和缓存,甚至挤掉马上要显示的资源。
UI 绑定回到目标平台要求的主线程或主执行上下文。跨线程传递的对象还要满足其所有权与线程安全要求,不能一边把像素交给视图使用,一边把同一资源归还给可写复用池。
5. 缓存与请求合并
5.1 每层缓存保存什么
内存缓存常保存已解码或转换好的资源,适合快速复用,但占用运行时内存。磁盘缓存可能保存原始编码数据,也可能保存变换后的结果;具体层次和策略由库决定。
命中磁盘上的 JPEG 后,通常还要读取和解码。命中已解码内存资源时,可以省去更多工作,但资源是否仍可使用、是否与尺寸和变换匹配,也要检查。HTTP 缓存管理响应的新鲜度和验证,规则见 HTTP 缓存。
缓存按成本设上限比只限制图片张数更能反映压力:一张大图与一个小图标的占用不同。LRU 按最近访问情况选择淘汰对象,是一种策略,不是“缓存永远不增长”的保证;活动资源和其他强引用还可能继续持有被淘汰的对象。
5.2 缓存键与内容更新
缓存键标识可以复用的那份结果。原始数据和转换后资源未必用相同的键:后者通常还要区分尺寸、裁剪、圆角、解码配置或资源类型。
同一 URL 返回了新版头像,如果缓存键和新鲜度规则都没变化,就可能继续显示旧头像。可以使用内容版本或框架提供的签名机制,并遵循 HTTP 验证策略;不要为每次请求随机拼参数,否则会破坏本来有用的复用。
账号、权限或请求头会影响内容时,也要明确共享范围。图片 URL 相同不代表不同用户一定能共享解码结果;退出账号时如何处理受保护的缓存,应服从实际内容和产品规则。
5.3 合并工作与分别取消
两个头像位置请求同一份资源,可以共享尚未完成的下载或解码任务,避免重复工作。每个位置仍应有自己的订阅,取消其中一个订阅时,只移除它的回调;是否取消底层共享任务,要看还有没有其他使用者以及库的策略。
原始下载可以按原始资源身份合并,解码与转换则还需要考虑目标尺寸。不能把小头像的结果无条件复用为大图预览,也不能把“共享一个请求”误写成“共享一个视图”。
6. 列表复用与晚到结果
6.1 取消之外还要核对绑定身份
列表中的同一个视图先显示 A,滚动后复用来显示 B。A 的请求可能已经完成、回调正在排队,或者底层不支持立即取消。如果回调只保存视图引用,就可能把 A 的图覆盖到 B 上。
绑定标识表示某个显示位置当前等待的那次结果。开始新绑定时替换标识、重置旧内容,并取消不再需要的订阅;结果到达后先核对标识,再更新图片。失败占位图也应经过同样的校验,否则旧请求失败仍可能清掉新图片。
下面的 Swift 示例只表达这条规则。调用方负责实际请求、取消与 UI 更新,ImageBinding 本身不会下载图片:
import Foundation
import ImageIO
@MainActor
final class ImageBinding {
private var current: UUID?
private(set) var image: CGImage?
func begin() -> UUID {
let token = UUID()
current = token
image = nil
return token
}
func finish(_ result: CGImage?, for token: UUID) {
guard current == token else { return }
image = result
current = nil
}
func clear() {
current = nil
image = nil
}
}
begin() 返回的 token 交给对应任务;回调在主执行上下文调用 finish。先后开始 A 和 B,B 成功后再收到 A 的成功或失败,都不会改变 B 的图片。clear() 后的晚到回调同样失效。每次绑定都获得新标识,即使 URL 相同,也不会把上一次绑定当成本次结果。
这里只允许一次最终完成,完成后清空 current。渐进式图片或多阶段占位资源如果需要多次交付,应另行区分“中间结果”和“最终结束”,不能直接套用这份单次完成示例。
6.2 复用与资源释放
解绑时既要切断错误回调,也要释放不再需要的订阅和资源。仅仅不更新 UI,不代表网络、解码、闭包或图片已经被释放;反过来,释放一个视图的资源也不能破坏其他仍在显示它的使用者。
若库维护 Bitmap 池或其他可写资源池,资源何时可以返回池由库的引用计数或所有权协议决定。不要在视图仍持有资源时自行回收,或把仍在共享的像素交给下一次解码覆盖。
7. 图片库与场景验证
7.1 用 Glide 4 对照各层责任
Glide 4 的默认查找过程可以按四层理解:当前仍被使用的活动资源、内存缓存、已经解码变换后写入磁盘的资源、原始数据磁盘缓存。未找到可用结果,再从原始文件、URI 或 URL 等来源获取。
这些层并非始终全部启用,磁盘策略可以选择保存原始数据、转换结果或跳过相应缓存。各层键也有差别:资源尺寸、变换和解码选项可能影响结果,不能只用一个 URL 哈希解释所有层次。
Target 把请求和显示位置联系起来。复用同一个 Target 或 ImageView 启动新加载时,Glide 可以清理旧请求与关联资源;不再需要时应按库的约定 clear。自定义 Target、资源回收与视图复用仍需遵循接口契约,不能只复制默认 ImageView 的使用结论。
SDWebImage 等 iOS 图片库也需要处理获取、缓存、解码和目标生命周期,但类名、缓存策略与取消语义并不完全相同。解释框架时先说明使用版本和配置,再沿这几项责任核对源码,不把一个库的实现细节当成所有平台的统一标准。
7.2 联系人头像加载的完整路径
联系人列表先确定联系人 ID、头像资源版本、目标尺寸与占位状态。绑定时查询可用缓存,缺失时获取合适资源并受控解码;结果回到界面前核对当前联系人和绑定标识。相同资源可以合并工作,离屏或复用时移除不再需要的订阅。
头像更新时,让新版本能绕过旧结果,必要时重新验证源数据。失败要区分暂时网络问题、资源不存在与不支持的格式,设置合理的重试和错误占位,避免每次滚动都无限重试同一坏文件。
验证至少覆盖缓存命中和未命中、大图与损坏文件、请求乱序、复用后旧请求失败、同资源多个订阅、头像更新、账号切换与重复进入退出。内存和滚动表现需要目标设备测量;只证明图片最终显示,不能证明峰值内存和帧耗时已经合格。
8 · 面试题卡片
本页题目与参考回答
8.1 一张图片在内存中占多大?
考察重点:编码数据、像素表示与实际资源范围。
相关内容:像素与解码内存
参考回答
先区分压缩文件和解码后的像素资源,再根据尺寸与格式估算。 4000 × 3000、每像素 4 字节时,紧密排列的单份像素约为 45.8 MiB。实际占用还要看行对齐、临时副本、缓存和图形资源,不能把文件大小或这个估算当成应用总内存。
8.2 为什么显示小头像时,先降采样通常比解出大图再缩小更合适?
考察重点:解码分辨率、峰值内存与显示需求。
相关内容:目标尺寸要换算成像素、降采样与解码位置
参考回答
降采样在生成像素时控制尺寸,可以避免先保留完整大图再生成缩小副本。 目标要结合显示比例、裁剪方式和方向计算,不能只取视图逻辑宽高。后台执行仍需限制并发并处理失败。
8.3 为什么图片缓存键不能总是只使用 URL?
考察重点:资源身份、变换与内容更新。
相关内容:缓存与请求合并
参考回答
同一 URL 可能对应不同版本、权限范围或不同尺寸和变换结果。 原始数据缓存与解码资源缓存需要按各自结果设计键,并遵循新鲜度规则。版本更新要能区分旧结果,也不能通过每次随机键破坏全部复用。
8.4 列表复用时已经取消旧图片请求,为什么还要检查绑定标识?
考察重点:取消竞争、结果归属与失败回调。
相关内容:列表复用与晚到结果
参考回答
取消不保证已经完成或排队中的回调消失,绑定标识用来阻止旧结果更新当前视图。 开始新绑定时替换标识并重置内容,成功和失败都核对身份。还要独立释放订阅,不能把忽略 UI 更新当成底层任务已经结束。
8.5 Glide 4 的图片加载与缓存流程是什么?
考察重点:分层缓存、获取解码与目标生命周期。
相关内容:从请求到显示、Glide 4 的各层责任
参考回答
默认先检查活动资源、内存缓存、转换后资源磁盘缓存和原始数据磁盘缓存,再回到源数据获取。 获取后按目标尺寸解码与变换,将结果交给 Target,并根据策略缓存和管理资源。尺寸、变换与选项会影响资源键,具体层次是否使用取决于配置,复用或清理 Target 时还要处理旧请求。
8.6 怎样设计类似微信联系人列表的头像加载?
题型:综合题
考察重点:资源身份、尺寸、缓存、异步复用与生命周期。
相关内容:联系人头像加载的完整路径、缓存与请求合并
参考回答
先定义联系人、头像版本、目标像素尺寸和占位状态,再组织缓存查找、请求与解码。 同资源可以共享任务,每个显示位置保留独立订阅和绑定标识;复用时取消旧订阅,晚到成功或失败都不能覆盖新内容。还要处理头像更新、失败重试、账号范围和内存上限,并在快速滚动和目标设备上验证。
9. 参考资料
- Android Developers:Loading Large Bitmaps Efficiently
- Android Developers:BitmapFactory.Options
- Apple:CGImageSourceCreateThumbnailAtIndex
- Apple:kCGImageSourceThumbnailMaxPixelSize
- Apple:kCGImageSourceCreateThumbnailWithTransform
- Apple:kCGImageSourceShouldCacheImmediately
- Glide 4:Caching
- Glide 4:Targets
- Glide 4:Resource Reuse
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明