iOS 27 AsyncImage 升级:终于支持 URLRequest 和自定义 Session
iOS 15 时 AsyncImage 第一次给 SwiftUI 提供「一行代码加载网络图片」的便利。但凡用过真实项目的人都清楚,它有三条硬伤:要加 Authorization header?不行。要换 URLSession 缓存策略?不行。要单独控制某张图的超时?不行。
这五个「不行」不是小问题——任何带鉴权的 API、自定义 CDN 缓存、混合图片源的电商 App,都只能绕开 AsyncImage,自己写 ImageLoader 或用 Kingfisher。
iOS 27(WWDC 2026 发布)终于把这两个洞补上。Natalia Panferova(前 Apple SwiftUI 团队工程师,著有《The SwiftUI Way》)写了一篇简短但精准的介绍[1]。我把它展开成 6 个实战场景,对比改动前后的代码差异。
改动总结:2 个新东西
| 改动 | API | 解决什么 |
|---|---|---|
| 新初始化器 | AsyncImage(request:scale:) 等 3 个 | 接受 URLRequest 而非裸 URL |
| 新视图修饰符 | .asyncImageURLSession(_:) | 给视图层级注入自定义 URLSession |
单独看这两个 API 改动都不大。但它们把 AsyncImage 从「玩具级便利 API」拉到了「生产级可用 API」。
场景 1:加载需要 Bearer Token 的用户头像
这是最常见的痛点。改动前,你得完全绕开 AsyncImage:
// SwiftUI iOS 15~iOS 26:得自己写 ImageLoader
struct AuthenticatedAvatar: View {
let userID: String
@State private var image: UIImage?
@State private var failed = false
var body: some View {
Group {
if let image {
Image(uiImage: image).resizable()
} else if failed {
Image(systemName: "person.crop.circle.badge.exclamationmark")
} else {
ProgressView()
}
}
.task {
do {
image = try await ImageLoader.shared.load(
url: avatarURL(for: userID),
token: auth.token
)
} catch {
failed = true
}
}
}
}
// 配套的 ImageLoader 还得写一份…
final class ImageLoader {
static let shared = ImageLoader()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
cfg.requestCachePolicy = .returnCacheDataElseLoad
self.session = URLSession(configuration: cfg)
}
func load(url: URL, token: String) async throws -> UIImage {
var req = URLRequest(url: url)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, _) = try await session.data(for: req)
guard let img = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
return img
}
}
iOS 27 直接用 init(request:scale:):
struct AuthenticatedAvatar: View {
let userID: String
let accessToken: String
private var request: URLRequest {
var req = URLRequest(
url: avatarURL(for: userID),
cachePolicy: .returnCacheDataElseLoad,
timeoutInterval: 15
)
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
return req
}
var body: some View {
AsyncImage(request: request) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView()
}
.frame(width: 64, height: 64)
.clipShape(Circle())
}
}
少了一个 ImageLoader 类,少了 @State 管理、少了 task 修饰符、少了一堆状态机代码。SwiftUI 的声明式风格终于能贯穿到网络层。
场景 2:占位符 + 失败状态显式区分
改动前要 init(url:scale:content:placeholder:) 配合自己处理失败:
// iOS 15~iOS 26:用 phase 但要监听 URL 失败事件
struct ArticleHero: View {
let url: URL
@State private var phase: AsyncImagePhase = .empty
var body: some View {
Group {
switch phase {
case .empty:
Rectangle().fill(.gray.opacity(0.2))
case .success(let image):
image.resizable().scaledToFit()
case .failure:
Image(systemName: "photo.badge.exclamationmark")
.imageScale(.large)
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let img = UIImage(data: data) {
phase = .success(Image(uiImage: img))
} else {
phase = .failure(URLError(.cannotDecodeContentData))
}
} catch {
phase = .failure(error)
}
}
}
}
iOS 27 的 init(request:scale:transaction:content:) + AsyncImagePhase 一行解决:
struct ArticleHero: View {
let url: URL
var body: some View {
AsyncImage(
request: URLRequest(url: url),
transaction: Transaction(animation: .easeInOut)
) { phase in
switch phase {
case .empty:
Rectangle().fill(.gray.opacity(0.2))
case .success(let image):
image.resizable().scaledToFit()
.transition(.opacity)
case .failure:
ContentUnavailableView(
"图片加载失败",
systemImage: "photo.badge.exclamationmark"
)
@unknown default:
EmptyView()
}
}
}
}
ContentUnavailableView 是 iOS 17 引入的专用空状态组件,跟 SwiftUI 原生空状态完全一致。手写失败 UI 的日子过去了。
transaction 参数控制 phase 切换的动画——空状态淡入、成功图片淡入。@unknown default 是 Swift 编译器强制要求的模式匹配,因为 AsyncImagePhase 是 frozen 之前的 enum,未来可能加新 case。
场景 3:图片源不可用时不发起请求
init(request:scale:content:placeholder:) 里的 request 参数是 URLRequest?——传 nil 时不发起请求,直接显示占位符:
struct ConditionalImage: View {
let maybeURL: URL?
var body: some View {
// maybeURL 为 nil 时不发起任何网络请求
AsyncImage(request: maybeURL.map { URLRequest(url: $0) }) { image in
image.resizable().scaledToFit()
} placeholder: {
Image(systemName: "photo")
.imageScale(.large)
.foregroundStyle(.tertiary)
}
}
}
这看似边缘,但跟 SwiftUI 的「声明式」哲学一致:View 描述「应该显示什么」,不负责判断「什么时候去加载」。传 nil 就够了,不需要在 view 外面包一个 if let。
场景 4:图片瀑布流 + 自定义 URLSession
电商类 App 的图片瀑布流是另一个典型场景。普通请求和图片请求需要不同的缓存策略——图片占用空间大、应该激进缓存;普通 API 请求应该实时优先。
iOS 27 的 asyncImageURLSession(_:) 修饰符专门解决这个:
// 1. 定义专属图片 session
enum ImageSessions {
static let gallery: URLSession = {
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
memoryCapacity: 20 * 1024 * 1024, // 20MB 内存
diskCapacity: 100 * 1024 * 1024 // 100MB 磁盘
)
config.requestCachePolicy = .useProtocolCachePolicy
config.timeoutIntervalForRequest = 20
return URLSession(configuration: config)
}()
static let avatar: URLSession = {
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
memoryCapacity: 5 * 1024 * 1024, // 5MB
diskCapacity: 20 * 1024 * 1024
)
config.requestCachePolicy = .returnCacheDataElseLoad
return URLSession(configuration: config)
}()
}
// 2. 在 view 层级注入
struct ProductGallery: View {
let imageURLs: [URL]
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
ForEach(imageURLs, id: \.self) { url in
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView()
}
.frame(height: 120)
.clipShape(.rect(cornerRadius: 16))
}
}
.padding()
}
.asyncImageURLSession(ImageSessions.gallery) // 关键这一行
}
}
struct UserProfile: View {
let user: User
var body: some View {
VStack {
AsyncImage(url: user.avatarURL) { image in
image.resizable().scaledToFill()
} placeholder: {
Circle().fill(.gray.opacity(0.2))
}
.frame(width: 80, height: 80)
.clipShape(Circle())
Text(user.name)
}
.asyncImageURLSession(ImageSessions.avatar) // 用不同的 session
}
}
修饰符是「作用域式」传播——在哪个层级加,下面的 AsyncImage 都会用这个 session。这跟 SwiftUI 其他环境修饰符(.environment(_:)、.tint)一脉相承。
场景 5:不同尺寸的图源(srcset)
电商/新闻 App 的另一个常见需求:屏幕 1x 加载小图、3x 加载大图。iOS 27 可以用 URLRequest 的 url + 自定义 header 配合:
struct ResponsiveImage: View {
let baseURL: URL
@Environment(\.displayScale) private var scale
private var request: URLRequest {
// 根据屏幕 scale 选择不同尺寸
let size = Int(200 * scale) // 200pt 基础尺寸
var components = URLComponents(
url: baseURL, resolvingAgainstBaseURL: false
)!
components.queryItems = [
URLQueryItem(name: "w", value: "\(size)")
]
var req = URLRequest(url: components.url!)
req.setValue("\(Int(scale))x", forHTTPHeaderField: "X-Display-Scale")
return req
}
var body: some View {
AsyncImage(request: request) { image in
image.resizable().scaledToFit()
} placeholder: {
ProgressView()
}
}
}
这个能力在 iOS 26 之前完全不可能——AsyncImage 只能传裸 URL,没法带 query 参数。有了 URLRequest,srcset、CDN 优化、AB test 分流都能在 SwiftUI 原生层做。
场景 6:网络状态感知
iOS 26 之前要做「网络断开时不显示图片」,得在 ViewModel 里手动监听 NWPathMonitor。iOS 27 配合 asyncImageURLSession + 自定义 URLSessionConfiguration.waitsForConnectivity:
enum SmartImageSessions {
static let offlineAware: URLSession = {
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true // 网络恢复时自动重试
config.timeoutIntervalForRequest = 60
config.urlCache = URLCache(
memoryCapacity: 10 * 1024 * 1024,
diskCapacity: 50 * 1024 * 1024
)
return URLSession(configuration: config)
}()
}
struct OfflineAwareImage: View {
let url: URL
var body: some View {
AsyncImage(url: url) { image in
image.resizable()
} placeholder: {
// 离线时一直显示占位符,恢复后自动加载
Image(systemName: "photo")
.foregroundStyle(.tertiary)
}
.asyncImageURLSession(SmartImageSessions.offlineAware)
}
}
waitsForConnectivity = true 让 URLSession 在网络断开时排队请求,恢复后自动重试。配合缓存策略,离线 → 在线切换时 App 表现会顺滑很多。
实际迁移时要注意什么
iOS 27 才有的 API,但你的 App 可能要支持 iOS 18 / 19 / 20。@available(iOS 27, *) 是标准做法:
struct ModernImageView: View {
let url: URL
let token: String?
var body: some View {
if #available(iOS 27, *) {
modernAsyncImage
} else {
legacyAsyncImage
}
}
@available(iOS 27, *)
private var modernAsyncImage: some View {
AsyncImage(request: makeRequest()) { image in
image.resizable()
} placeholder: {
ProgressView()
}
}
private var legacyAsyncImage: some View {
AsyncImage(url: url) { image in
image.resizable()
} placeholder: {
ProgressView()
}
}
private func makeRequest() -> URLRequest {
var req = URLRequest(url: url)
if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
return req
}
}
或者用 SwiftUI 的 if #available 内联检查——SwiftUI 编译器对 @available 的处理已经比较成熟了。
跟 Kingfisher / SDWebImage 的对比
iOS 27 的改动不是要取代 Kingfisher。Kingfisher 仍然在以下场景有优势:
// Kingfisher 仍然更适合的场景:
// 1. 复杂的磁盘缓存淘汰策略(LRU + 大小限制 + 自定义)
// 2. 图片处理器链(圆角、模糊、滤镜 pipeline)
// 3. 内存压力响应(didReceiveMemoryWarning 主动清理)
// 4. 预加载队列(precache + cancel)
// 5. 多级 fallback 源(CDN → OSS → 本地)
但 80% 的 App 用 AsyncImage 就够了。iOS 27 之前,剩下 20% 必须用第三方库;现在这个数字可以缩小到 5%。
资源
→ 原文:AsyncImage improvements in iOS 27[2](Natalia Panferova,22 June 2026)
→ Apple 官方文档:AsyncImage init(request:scale:)[3]
→ Apple 官方文档:asyncImageURLSession(:)[4]
→ The SwiftUI Way[5](Natalia 的书,$35,前 20% off with nilcoalescing 优惠码)
引用链接
[1] 介绍: https://nilcoalescing.com/blog/AsyncImageImprovementsInSwiftUIOnIOS27/
[2] 原文:AsyncImage improvements in iOS 27: https://nilcoalescing.com/blog/AsyncImageImprovementsInSwiftUIOnIOS27/
[3] Apple 官方文档:AsyncImage init(request:scale:): https://developer.apple.com/documentation/swiftui/asyncimage/init%28request%3Ascale%3A%29
[4] Apple 官方文档:asyncImageURLSession(:): https://developer.apple.com/documentation/swiftui/view/asyncimageurlsession%28<em>%3A%29
[5] The SwiftUI Way: https://nilcoalescing.com/books/the-swiftui-way/
夜雨聆风