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-ControlETagLast-Modified,让配置正确的 URLCache 处理验证。只有在业务确实需要转换结果缓存时,再增加带成本限制的内存缓存。

还要限制解码尺寸。下载一张超大原图再缩到列表缩略图,会同时浪费网络、内存和解码时间。服务端缩略图通常最有效;否则使用 Image I/O 按目标像素下采样,并把屏幕 scale 纳入缓存键。

列表预取需要和可见加载共享同一仓库,而不是建立第二套缓存。预取任务可以使用较低优先级,但进入屏幕后新的高优先级调用应能等待同一个进行中请求。设计“进行中任务表”时要明确多个等待者的取消语义:一个 cell 离开屏幕,不一定应该取消另一个可见 cell 仍需要的共享请求。通常由仓库拥有底层任务,调用者取消只停止自己的等待;当没有等待者时是否取消,则由计数或业务成本决定。

隐私与认证图片不能盲目进入共享磁盘缓存。头像、票据或带授权 URL 的内容需要检查缓存目录保护、退出登录清理和 URL 中的敏感查询参数。缓存键可以使用规范化资源标识,而不是把短期令牌作为永久文件名。日志也只记录域名、状态类别和匿名请求标识,避免打印完整 URL。

测试时注入自定义 URLSessionConfiguration 或协议处理器,覆盖成功、404、错误 MIME、慢响应与取消。缓存测试应验证第二次读取不重复请求,也要验证失败响应没有进入成功缓存。

一条可靠图片管线的核心不是更多 View modifier,而是明确职责:HTTP 层验证响应,缓存层管理生命周期,主执行器管理展示状态,SwiftUI 管理任务与界面生命周期。边界清楚后,占位、重试和预取都能在不破坏数据流的情况下逐步加入。