不止 AsyncImage:构建可取消、可缓存的 SwiftUI 图片管线
AsyncImage 很适合原型和简单远程图片,但产品列表通常还需要明确缓存策略、HTTP 校验、取消、占位图和错误重试。与其把所有逻辑塞进自定义 View,最好把管线分成三个边界:加载器取得并缓存字节,UI 模型把结果转换成展示状态,SwiftUI 只渲染状态。
Actor 缓存原始数据
缓存 Data 而不是 UIKit 图片,可以避免把平台图像对象跨 actor 传递。这个最小缓存只演示进程内去重;生产环境通常还应利用 URLCache 的 HTTP 缓存语义,而不是永久保存所有响应。
import Foundation
actor ImageDataLoader {
private var memory: [URL: Data] = [:]
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func data(for url: URL) async throws -> Data {
if let cached = memory[url] {
return cached
}
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode),
http.mimeType?.hasPrefix("image/") == true else {
throw URLError(.badServerResponse)
}
try Task.checkCancellation()
memory[url] = data
return data
}
}
检查状态码和 MIME 类型能避免把错误页面当成图片保存。取消检查放在写缓存前,防止一个已经不再需要的任务继续改变内存状态。
主执行器拥有展示状态
import Observation
import SwiftUI
@Observable
@MainActor
final class RemoteImageModel {
enum State {
case idle, loading, loaded(Image), failed
}
private(set) var state: State = .idle
func load(_ url: URL, using loader: ImageDataLoader) async {
state = .loading
do {
let data = try await loader.data(for: url)
try Task.checkCancellation()
guard let image = UIImage(data: data) else {
state = .failed
return
}
state = .loaded(Image(uiImage: image))
} catch is CancellationError {
state = .idle
} catch {
state = .failed
}
}
}
模型区分首次空闲、加载、成功和失败。失败不是永久缓存的图片;用户可以通过新的 task 或按钮重试。真实产品还可以保存可展示错误类型,但不要把完整底层错误直接显示给用户。
视图用 .task(id:) 把任务生命周期绑定到 URL 和当前 View。URL 改变或 View 消失时,SwiftUI 会取消旧任务;加载器和模型都必须配合检查取消。
缓存策略不是一个字典
进程内字典缺少容量、淘汰和磁盘策略。优先尊重服务器的 Cache-Control、ETag 与 Last-Modified,让配置正确的 URLCache 处理验证。只有在业务确实需要转换结果缓存时,再增加带成本限制的内存缓存。
还要限制解码尺寸。下载一张超大原图再缩到列表缩略图,会同时浪费网络、内存和解码时间。服务端缩略图通常最有效;否则使用 Image I/O 按目标像素下采样,并把屏幕 scale 纳入缓存键。
列表预取需要和可见加载共享同一仓库,而不是建立第二套缓存。预取任务可以使用较低优先级,但进入屏幕后新的高优先级调用应能等待同一个进行中请求。设计“进行中任务表”时要明确多个等待者的取消语义:一个 cell 离开屏幕,不一定应该取消另一个可见 cell 仍需要的共享请求。通常由仓库拥有底层任务,调用者取消只停止自己的等待;当没有等待者时是否取消,则由计数或业务成本决定。
隐私与认证图片不能盲目进入共享磁盘缓存。头像、票据或带授权 URL 的内容需要检查缓存目录保护、退出登录清理和 URL 中的敏感查询参数。缓存键可以使用规范化资源标识,而不是把短期令牌作为永久文件名。日志也只记录域名、状态类别和匿名请求标识,避免打印完整 URL。
测试时注入自定义 URLSessionConfiguration 或协议处理器,覆盖成功、404、错误 MIME、慢响应与取消。缓存测试应验证第二次读取不重复请求,也要验证失败响应没有进入成功缓存。
一条可靠图片管线的核心不是更多 View modifier,而是明确职责:HTTP 层验证响应,缓存层管理生命周期,主执行器管理展示状态,SwiftUI 管理任务与界面生命周期。边界清楚后,占位、重试和预取都能在不破坏数据流的情况下逐步加入。