普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月20日首页

Swift Concurrency:彻底告别“线程思维”,拥抱 Task 的世界

作者 unravel2025
2025年8月19日 09:40

原文:Threads vs. Tasks in Swift Concurrency 链接:www.avanderlee.com/concurrency…

前言:别再问“它跑在哪个线程?”

在 GCD 时代,我们习惯用 DispatchQueue.global(qos: .background).async { ... }DispatchQueue.main.async { ... } 来显式地把任务丢到指定线程。久而久之,形成了一种“线程思维”:

“这段代码很重,我要放到子线程。”

“这行 UI 代码必须回到主线程。”

Swift Concurrency(async/await + Task)出现以后,这套思维需要升级——系统帮你决定“跑在哪个线程”。我们只需关心“任务(Task)”本身。

线程(Thread)到底是什么?

  • 系统级资源:由操作系统调度,创建、销毁、切换开销大。
  • 并发手段:多线程可以让多条指令流同时跑。
  • 痛点:数量一多,内存占用高、上下文切换频繁、优先级反转。

Swift Concurrency 的目标就是让我们 不再直接面对线程。

Task:比线程更高级的抽象

  • 一个 Task = 一段异步工作单元。
  • 不绑定线程:Task 被放进 合作线程池(cooperative thread pool),由运行时动态分配到“刚好够用”的线程上。
  • 运行机制:
    1. 线程数量 ≈ CPU 核心数。
    2. 遇到 await(挂起点)时,当前线程被释放,可立即执行其他 Task。
    3. 挂起的 Task 稍后可能在另一条线程恢复。

代码示范:Task 与线程的“若即若离”

struct ThreadingDemonstrator {
    private func firstTask() async throws {
        print("Task 1 started on thread: \(Thread.current)")
        try await Task.sleep(for: .seconds(2))   // 挂起点
        print("Task 1 resumed on thread: \(Thread.current)")
    }

    private func secondTask() async {
        print("Task 2 started on thread: \(Thread.current)")
    }

    func demonstrate() {
        Task {
            try await firstTask()
        }
        Task {
            await secondTask()
        }
    }
}

典型输出(每次都可能不同):

Task 1 started on thread: <NSThread: 0x600001752200>{number = 3, name = (null)}
Task 2 started on thread: <NSThread: 0x6000017b03c0>{number = 8, name = (null)}
Task 1 resumed on thread: <NSThread: 0x60000176ecc0>{number = 7, name = (null)}

解读:

  • Task 1 在 await 时释放了线程 3;
  • Task 2 趁机用到了线程 8;
  • Task 1 恢复时,被安排到线程 7——前后线程可以不同。

线程爆炸(Thread Explosion)还会发生吗?

场景 GCD Swift Concurrency
同时发起 1000 个网络请求 可能创建 1000 条线程 → 内存暴涨、调度爆炸 最多 CPU 核心数条线程,其余任务挂起 → 无爆炸
阻塞线程 线程真被 block,CPU 空转 用 continuation 挂起,线程立刻服务别的任务

因此,线程爆炸在 Swift Concurrency 中几乎不存在。

线程更少,性能反而更好?

  • GCD 误区:线程越多,并发越高。
  • 真相:线程 > CPU 核心时,上下文切换成本激增。
  • Swift Concurrency 做法
    • 线程数 = 核心数;
    • 用挂起/恢复代替阻塞;
    • CPU 始终在跑有效指令,切换开销极低。

实测常见场景(CPU-bound & I/O-bound)下,Swift Concurrency 往往优于 GCD。

三个常见误区

误区 正解
每个 Task 会新开一条线程 Task 与线程是多对一,由调度器动态复用
await会阻塞当前线程 await会挂起任务并释放线程
Task 一定按创建顺序执行 执行顺序不保证,取决于挂起点与调度策略

思维升级:从“线程思维”到“任务思维”

线程思维 任务思维
“这段代码要在子线程跑” “这段代码是异步任务,系统会调度”
“回到主线程刷新 UI” “用 @MainActor或 MainActor.run标记主界面任务”
“我怕线程太多” “线程数系统自动管理,我专注业务逻辑”

小结

  1. 线程是低层、昂贵的系统资源。
  2. Task 是高层、轻量的异步工作单元。
  3. Swift Concurrency 通过合作线程池 + 挂起/恢复机制,让线程数始终保持在“刚好够用”,既避免线程爆炸,又提升性能。
  4. 开发者应把注意力从“线程”转向“任务”与“挂起点”。

当你下次再想问“这段代码跑在哪个线程?”时,提醒自己:

“别管线程,写正确的 Task 就行。”

深入理解 Swift 中的 async/await:告别回调地狱,拥抱结构化并发

作者 unravel2025
2025年8月19日 09:32

原文:Async await in Swift explained with code examples

Swift 5.5 在 WWDC 2021 中引入了 async/await,随后在 Swift 6 中进一步完善,成为现代 iOS 开发中处理并发的核心工具。它不仅让异步代码更易读写,还彻底改变了我们组织并发任务的方式。

什么是 async?

async 是一个方法修饰符,表示该方法是异步执行的,即不会阻塞当前线程,而是挂起等待结果。

✅ 示例:定义一个 async 方法

func fetchImages() async throws -> [UIImage] {
    // 模拟网络请求
    let data = try await URLSession.shared.data(from: URL(string: "https://example.com/images")!).0
    return try JSONDecoder().decode([UIImage].self, from: data)
}
  • async 表示异步执行;
  • throws 表示可能抛出错误;
  • 返回值是 [UIImage]
  • 调用时需要用 await 等待结果。

什么是 await?

await 是调用 async 方法时必须使用的关键字,表示“等待异步结果”。

✅ 示例:使用 await 调用 async 方法

do {
    let images = try await fetchImages()
    print("成功获取 \(images.count) 张图片")
} catch {
    print("获取图片失败:\(error)")
}
  • 使用 try await 等待异步结果;
  • 错误用 catch 捕获;
  • 代码顺序执行,逻辑清晰。

async/await 如何替代回调地狱?

在 async/await 出现之前,异步操作通常使用回调闭包,这会导致回调地狱(Callback Hell):

❌ 旧写法:嵌套回调

fetchImages { result in
    switch result {
    case .success(let images):
        resizeImages(images) { result in
            switch result {
            case .success(let resized):
                print("处理完成:\(resized.count) 张图片")
            case .failure(let error):
                print("处理失败:\(error)")
            }
        }
    case .failure(let error):
        print("获取失败:\(error)")
    }
}

✅ 新写法:线性结构

do {
    let images = try await fetchImages()
    let resizedImages = try await resizeImages(images)
    print("处理完成:\(resizedImages.count) 张图片")
} catch {
    print("处理失败:\(error)")
}
  • 没有嵌套;
  • 顺序清晰;
  • 更易于维护和测试。

在非并发环境中调用 async 方法

如果你尝试在同步函数中直接调用 async 方法,会报错:

'async' call in a function that does not support concurrency

✅ 解决方案:使用 Task

final class ContentViewModel: ObservableObject {
    @Published var images: [UIImage] = []

    func fetchData() {
        Task { @MainActor in
            do {
                self.images = try await fetchImages()
            } catch {
                print("获取失败:\(error)")
            }
        }
    }
}
  • Task {} 创建一个新的异步上下文;
  • @MainActor 保证 UI 更新在主线程;
  • 适用于 SwiftUI 或 UIKit。

如何在旧项目中逐步迁移?

Xcode 提供了三种自动重构方式,帮助你从旧回调方式迁移到 async/await:

✅ 方式一:Convert Function to Async

直接替换旧方法,不保留旧实现:

// 旧
func fetchImages(completion: @escaping (Result<[UIImage], Error>) -> Void)

// 新
func fetchImages() async throws -> [UIImage]

✅ 方式二:Add Async Alternative

保留旧方法,并添加新 async 方法,使用 @available 标记:

@available(*, deprecated, renamed: "fetchImages()")
func fetchImages(completion: @escaping (Result<[UIImage], Error>) -> Void) {
    Task {
        do {
            let result = try await fetchImages()
            completion(.success(result))
        } catch {
            completion(.failure(error))
        }
    }
}

func fetchImages() async throws -> [UIImage] {
    // 新实现
}
  • 旧方法调用会提示警告;
  • 支持逐步迁移;
  • 不破坏现有代码。

✅ 方式三:Add Async Wrapper

使用 withCheckedThrowingContinuation 包装旧方法:

func fetchImages() async throws -> [UIImage] {
    try await withCheckedThrowingContinuation { continuation in
        fetchImages { result in
            continuation.resume(with: result)
        }
    }
}
  • 无需改动旧实现;
  • 适合第三方库或无法修改的代码。

async/await 会取代 Result 枚举吗?

虽然 async/await 让 Result 枚举看起来不再必要,但它不会立即消失。很多老代码和第三方库仍在使用 Result,但未来可能会逐步弃用。

迁移建议:先 async,再 Swift 6

  • Swift 6 引入了更强的并发安全检查;
  • 建议先迁移到 async/await,再升级到 Swift 6;
  • 使用 @preconcurrency@Sendable 等工具逐步迁移。

总结:async/await 带来的改变

特性 回调方式 async/await
可读性 差(嵌套) 好(线性)
错误处理 手动 Result try/catch
并发控制 手动管理 结构化
测试难度
与 SwiftUI 集成 复杂 自然

深入理解 SwiftUI 的 ViewBuilder:从隐式语法到自定义容器

作者 unravel2025
2025年8月19日 08:40

SwiftUI 的声明式语法之所以优雅,一大功臣是隐藏在幕后的 ViewBuilder。它让我们可以在 bodyHStackVStack 等容器的闭包里随意组合多个视图,而无需手动把它们包进 GroupTupleView

ViewBuilder 是什么?

ViewBuilder 是一个 结果构建器(Result Builder),负责把 DSL(领域特定语言)中的多条表达式“构建”成单个视图。它最常出现的场景:

VStack {
    Image(systemName: "star")
    Text("Hello, world!")
}

我们并没有显式写 ViewBuilder.buildBlock(...),却能在 VStack 的尾随闭包里放两个视图,这就是 @ViewBuilder 的魔力。

实际上,View 协议已经把 body 标记成了 @ViewBuilder

@ViewBuilder var body: Self.Body { get }

所以下面这样写也完全合法:

var body: some View {
    if user != nil {
        HomeView(user: user!)
    } else {
        LoginView(user: $user)
    }
}

即使 if 的两个分支返回不同类型,ViewBuilder 也能通过 buildEither 等内部方法把它们擦除为 AnyView_ConditionalContent,最终呈现出单一根视图。

给自己的 API 加上 @ViewBuilder:自定义容器

想让自定义容器也支持 DSL 语法?只需在属性或闭包参数前加 @ViewBuilder

基本用法:把属性变成视图构建闭包

struct Container<Header: View, Content: View>: View {
    @ViewBuilder var header: Header
    @ViewBuilder var content: Content

    var body: some View {
        VStack(spacing: 0) {
            header
                .frame(maxWidth: .infinity)
                .padding()
                .foregroundStyle(.white)
                .background(.blue)

            ScrollView { content.padding() }
        }
    }
}

调用方式立即变得“SwiftUI 味儿”:

Container(header: {
    Text("Welcome")
}, content: {
    if let user {
        HomeView(user: user)
    } else {
        LoginView(user: $user)
    }
})

让 header 可选:两种做法

做法 A:带约束的扩展

extension Container where Header == EmptyView {
    init(@ViewBuilder content: () -> Content) {
        self.init(header: EmptyView.init, content: content)
    }
}

现在可以这样写:

Container {
    LoginView(user: $user)
}

做法 B:默认参数 + 手动调用闭包

struct Container<Header: View, Content: View>: View {
    private let header: Header
    private let content: Content

    init(@ViewBuilder header: () -> Header = EmptyView.init,
         @ViewBuilder content: () -> Content) {
        self.header = header()
        self.content = content()
    }

    var body: some View { ... }
}

优点:

  • 不需要额外扩展;
  • 可以在未来继续添加默认参数;
  • 闭包在 init 就被执行,避免 body 反复求值带来的性能损耗。

多条表达式与隐式 Group

当闭包里出现多条顶层表达式时,ViewBuilder 会把它们当成 Group 的子视图。例如:

Container(header: {
    Text("Welcome")
    NavigationLink("Info") { InfoView() }
}, content: { ... })

实际上得到的是两个独立的 header 视图,各自撑满宽度,而不是一个整体。解决方式:

  1. 在容器内部用显式 VStack 再包一层:
VStack(spacing: 0) {
    VStack { header }   // 👈 统一布局
        .frame(maxWidth: .infinity)
        .padding()
        .background(.blue)

    ScrollView {
        VStack { content }.padding()
    }
}
  1. 或者在调用方显式组合:
private extension RootView {
    func header() -> some View {
        VStack(spacing: 20) {
            Text("Welcome")
            NavigationLink("Info") {
                InfoView()
            }
        }
    }
}

小建议:

如果函数/计算属性返回“一个整体”视图,最好显式用 VStackHStack 等包装,而不是依赖 @ViewBuilder 隐式 Group。语义更清晰,布局也更稳定。

把 ViewBuilder 当“代码组织工具”

body 越来越复杂时,可以把子区域拆成私有的 @ViewBuilder 方法:

struct RootView: View {
    @State private var user: User?

    var body: some View {
        Container(header: header, content: content)
    }
}

private extension RootView {
    @ViewBuilder
    func content() -> some View {
        if let user {
            HomeView(user: user)
        } else {
            LoginView(user: $user)
        }
    }
}

注意:如果 header() 需要返回多个兄弟视图,则推荐返回显式容器,而不是 @ViewBuilder

func header() -> some View {
    VStack(spacing: 20) { ... }
}

要点回顾

场景技巧 让属性支持 DSL在属性前加 @ViewBuilder 让参数支持 DSL在闭包参数前加 @ViewBuilder,并在 init 内手动执行 可选组件使用 EmptyView.init 作为默认值或约束扩展 多条表达式记住隐式 Group 行为,必要时显式包一层容器 代码组织用 @ViewBuilder 拆分 body,但根视图最好显式容器

结语

ViewBuilder 把 SwiftUI 的声明式语法推向了“像写普通 Swift 代码一样自然”的高度。当我们为自定义容器、可复用组件也加上 @ViewBuilder 时,API 就能与系统控件保持一致的体验,既易读又易维护。

下次写 SwiftUI 时,不妨问问自己:“这段代码能不能也让调用者用 ViewBuilder 的语法糖?” 如果答案是肯定的,就把 @ViewBuilder 加上去吧!

在 async/throwing 场景下优雅地使用 Swift 的 defer 关键字

作者 unravel2025
2025年8月19日 08:31

原文:Using Swift’s defer keyword within async and throwing contexts – Swift by Sundell

在日常 Swift 开发中,我们经常需要在多出口的函数里做清理工作:关闭文件句柄、归还数据库连接、把布尔值复原……如果每个出口都手写一遍,既啰嗦又容易遗漏。

Swift 提供了 defer 关键字,让我们可以把“善后逻辑”一次性声明在当前作用域顶部,却延迟到作用域退出时才执行。

本文将结合错误抛出(throwing)与并发(async/await)两个典型场景,带你彻底吃透 defer 的用法与注意点。

defer 基础回顾

defer { ... } 中的代码,会等到当前作用域(函数、闭包、do 块……)即将退出时执行,无论退出路径是 return、throw 还是 break。

最小示例:

func demo() {
    defer { print("最后才打印") }
    print("先打印")
    // 函数返回前,defer 里的内容一定执行
}

同步 + throwing 场景:避免重复清理

想象一个 SearchService,它通过 Database API 查询条目,必须先 open、后 close:

❌ 传统写法:分支重复

actor SearchService {
    private let database: Database

    func loadItems(matching searchString: String) throws -> [Item] {
        let connection = database.connect()

        do {
            let items: [Item] = try connection.runQuery(
                .entries(matching: searchString)
            )
            connection.close()          // 成功路径
            return items
        } catch {
            connection.close()          // 失败路径
            throw error
        }
    }
}

问题:

  • 两处 close(),容易漏写。
  • 如果再加 returnguard,分支会更多。

✅ 利用 defer:把 close 写在 open 旁边

func loadItems(matching searchString: String) throws -> [Item] {
    let connection = database.connect()
    defer { connection.close() }          // 一次声明,处处生效

    return try connection.runQuery(.entries(matching: searchString))
}

优点:

  • 逻辑集中,一眼可见“成对动作”。
  • 任意新增提前退出(guardthrow)都不用再管 close()

⚠️ 注意执行顺序:

connect()runQuery() → 作用域结束 → close()

“延迟”并不代表“立刻”,代码阅读时需要适应这种跳跃。

async/await 场景:状态复原与去重

并发代码里,defer 更显价值——异步函数可能在任意 await 点挂起并抛错,手动追踪所有出口几乎不现实。

复原布尔 flag

场景:防止重复加载,用 isLoading 标记。

❌ 传统写法:catch 里回写 flag

actor ItemListService {
    private let networking: NetworkingService
    private var isLoading = false

    func loadItems(after lastItem: Item) async throws -> [Item] {
        guard !isLoading else { throw Error.alreadyLoading }
        isLoading = true

        do {
            let request  = requestForLoadingItems(after: lastItem)
            let response = try await networking.performRequest(request)
            let items    = try response.decoded() as [Item]

            isLoading = false        // 成功路径
            return items
        } catch {
            isLoading = false        // 失败路径
            throw error
        }
    }
}

✅ defer 写法:一行搞定

func loadItems(after lastItem: Item) async throws -> [Item] {
    guard !isLoading else { throw LoadingError.alreadyLoading }
    isLoading = true
    defer { isLoading = false }       // 无论成功/失败都会执行

    let request  = requestForLoadingItems(after: lastItem)
    let response = try await networking.performRequest(request)
    return try response.decoded()
}

利用 Task + defer 做“去重”

需求:如果同一 lastItem.id 的加载任务已在进行中,直接等待现有任务,而不是重新发起。

actor ItemListService {
    private let networking: NetworkingService
    private var activeTasks: [Item.ID: Task<[Item], Error>] = [:]

    func loadItems(after lastItem: Item) async throws -> [Item] {
        // 1. 已有任务则等待
        if let existing = activeTasks[lastItem.id] {
            return try await existing.value
        }

        // 2. 创建新任务
        let task = Task {
            // 任务结束前一定清理字典
            defer { activeTasks[lastItem.id] = nil }

            let request  = requestForLoadingItems(after: lastItem)
            let response = try await networking.performRequest(request)
            return try response.decoded() as [Item]
        }

        // 3. 登记
        activeTasks[lastItem.id] = task
        return try await task.value
    }
}

要点:

  • 一旦 Task 结束(无论正常返回还是抛错),defer 把字典条目删掉,防止内存泄漏。
  • 因为 actor 的重入性(reentrancy),await 期间仍可接受新调用;通过字典+Task 实现“幂等”效果。

何时不要使用 defer

  • 逻辑需要严格顺序时:defer 会在作用域最后执行,若必须与中间语句保持先后关系,则不适合。
  • 过度嵌套:多层 defer 会让执行顺序难以一眼看出,阅读负担大。
  • 性能极端敏感:defer 本质是隐藏的 try/finally,有微小开销,但通常可以忽略。

小结 checklist

场景是否推荐 defer 单一出口函数❌ 没必要 多出口、需清理资源✅ 强烈推荐 async/await 中状态复原✅ 强烈推荐 需要精确控制顺序❌ 慎用

一句话:defer 是“善后”利器,不是“流程”利器。

只要牢记“无论怎么退出,这段代码一定跑”,就能把它用得恰到好处。

昨天以前首页

当Swift Codable遇到缺失字段:优雅解决数据解码难题

作者 unravel2025
2025年8月18日 20:48

在Swift开发中,我们经常使用Codable协议轻松实现JSON数据与模型对象的自动转换。

但实际开发中常会遇到这种棘手问题:需要解码的模型中包含某些字段,但这些关键数据却不在当前接收到的JSON中。

本文将通过具体案例,深入探讨三种优雅解决方案及其适用场景。

问题的本质

假设我们有如下User模型:

struct User: Identifiable {
   let id: UUID
   var name: String
   var membershipPoints: Int
   var favorites: Favorites
}

struct Favorites: Codable { 
    var genre: String 
    var directorName: String 
    var movieIDs: [String] 
}

服务器返回的JSON数据只包含基础信息:

{
   "id": "7CBE0CC1-7779-42E9-AAF1-C4B145F3CAE9",
   "name": "John Appleseed",
   "membershipPoints": 192
}

而Favorites数据需要单独请求获取:

{
   "genre": "action",
   "directorName": "Christopher Nolan",
   "movieIDs": [
       "F028CAB5-74D7-4B86-8450-D0046C32DFA0",
       "D2657C95-1A35-446C-97D4-FAAA4783F2AA"
   ]
}

这时候直接使用Codable会出现什么问题?尝试解码时会因为缺少favorites字段导致崩溃。

方案一:可选属性(权宜之计)

最简单的解决办法是将favorites设为可选类型:

var favorites: Favorites?

优点

  • 实现简单,无需额外代码
  • 编译器不会报错

缺点

  • 模型变得脆弱,容易产生未初始化状态
  • 使用时必须频繁解包(user.favorites?.genre ?? "未知"
  • 无法保证数据完整性,可能导致后续逻辑错误

方案二:中间模型+数据合并(折中方案)

定义一个仅包含公共字段的Partial模型:

extension User {
   struct Partial: Decodable {
       let id: UUID
       var name: String
       var membershipPoints: Int
   }
}

网络请求时同时获取两部分数据:

func loadUser(id: UUID) async throws -> User {
   let (partialData, favoritesData) = try await Task.group {
       URLSession.shared.data(from: userURL(id))
       URLSession.shared.data(from: favoritesURL(id))
   }
   
   let partial = try JSONDecoder().decode(User.Partial.self, from: partialData)
   let favorites = try JSONDecoder().decode(Favorites.self, from: favoritesData)
   
   return User(
       id: partial.id,
       name: partial.name,
       membershipPoints: partial.membershipPoints,
       favorites: favorites
   )
}

优点

  • 保持原有模型完整性
  • 明确区分不同来源的数据

缺点

  • 需要维护额外的中间模型
  • 代码量增加约30%
  • 异步合并逻辑稍显复杂

方案三:CodableWithConfiguration(完美方案)

利用Swift 5.7引入的CodableWithConfiguration特性:

extension User: DecodableWithConfiguration {
    // 告诉编译器:我需要一个 Favorites 作为解码配置
    typealias DecodingConfiguration = Favorites
    
    enum CodingKeys: CodingKey {
        case id, name, membershipPoints
    }
    
    init(from decoder: Decoder, configuration: Favorites) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(UUID.self, forKey: .id)
        name = try container.decode(String.self, forKey: .name)
        membershipPoints = try container.decode(Int.self, forKey: .membershipPoints)
        favorites = configuration
    }
}

向下兼容:iOS 15/16 也能用 自定义JSONDecoder扩展以支持配置传递:

extension JSONDecoder {
    private struct Wrapper<T: DecodableWithConfiguration>: Decodable {
        let value: T
        init(from decoder: Decoder) throws {
            let config = decoder.userInfo[.configKey] as! T.DecodingConfiguration
            value = try T(from: decoder, configuration: config)
        }
    }

    func decode<T: DecodableWithConfiguration>(
        _ type: T.Type,
        from data: Data,
        configuration: T.DecodingConfiguration
    ) throws -> T {
        userInfo[.configKey] = configuration
        return try decode(Wrapper<T>.self, from: data).value
    }
}

private extension CodingUserInfoKey {
    static let configKey = CodingUserInfoKey(rawValue: "configuration")!
}

使用时只需一行代码即可完成配置注入:

func loadUser() throws -> User {
    let favoriteData = """
    {
      "genre": "action",
      "directorName": "Christopher Nolan",
      "movieIDs": ["7CBE0CC1-7779-42E9-AAF1-C4B145F3CAE9"]
    }
""".data(using: .utf8)!
    let favorites: Favorites = try JSONDecoder().decode(Favorites.self, from: favoriteData)
    // ↓ 直接把 favorites 当 configuration 传进去
    let userData = """
        {
          "id": "7CBE0CC1-7779-42E9-AAF1-C4B145F3CAE9",
          "name": "John Appleseed",
          "membershipPoints": 192
        }
""".data(using: .utf8)!
    return try JSONDecoder().decode(
        User.self,
        from: userData,
        configuration: favorites
    )
}

do {
    let u = try loadUser()
    print(u)
}

技术对比与选择建议

特性 可选属性 中间模型 CodableWithConfiguration
实现复杂度 ★☆☆☆☆ ★★☆☆☆ ★★★★☆
代码侵入性
运行时安全性 ⚠️潜在风险 ✅安全可靠 ✅绝对安全
类型系统支持 部分 完整
iOS版本要求 全平台支持 全平台支持 iOS 17+/Swift 5.7+

推荐使用场景

  • 紧急修复:可选属性适合快速验证原型
  • 团队协作:中间模型适合多人协作项目
  • 生产环境:CodableWithConfiguration适合追求代码质量的长期项目

通过合理选择技术方案,我们可以在保证代码质量的同时,优雅地解决这类数据解码难题。每种方案都有其适用场景,关键是根据项目实际情况做出最佳权衡。

用 SwiftUI 打造“会长大”的组件 —— 从一次性 Alert 到可扩展设计系统

作者 unravel2025
2025年8月12日 10:43

原文链接

为什么旧写法撑不过三次迭代?

先来看一个“经典”写法

Alert(
    title: "Title",
    message: "Description",
    type: .info,
    showBorder: true,
    isDisabled: false,
    primaryButtonTitle: "OK",
    secondaryButtonTitle: "Cancel",
    primaryAction: { /* ... */ },
    secondaryAction: { /* ... */ }
)

痛点一句话总结:初始化即地狱。

• 参数爆炸,阅读困难

• 布局/样式/行为耦合,一改全改

• 无法注入自定义内容,复用性 ≈ 0

目标:像原生一样的 SwiftUI 组件

我们想要的最终形态:

AlertView(title: "...", message: "...") {
    AnyViewBuilder Content
}
.showBorder(true)
.disabled(isLoading)

为此,需要遵循 4 个关键词:

  1. Familiar APIs – 看起来像 SwiftUI 自带的
  2. Composability – 任意组合内容
  3. Scalability – 业务扩张不炸窝
  4. Accessibility – 无障碍不打补丁

三步重构法

Step 1:只保留「必须参数」

public struct AlertView: View {
    private let title: String
    private let message: String
    
    public init(title: String, message: String) {
        self.title = title
        self.message = message
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
        }
        .padding()
    }
}

经验:先把最常用、不可省略的参数放进 init,其余全部踢出去。这一步就能干掉 70% 的参数。

Step 2:用 @ViewBuilder 把“内容”交出去

public struct AlertView<Footer: View>: View {
    private let title: String
    private let message: String
    private let footer: Footer
    
    public init(
        title: String,
        message: String,
        @ViewBuilder footer: () -> Footer
    ) {
        self.title = title
        self.message = message
        self.footer = footer()
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
            footer.padding(.top, 25)
        }
        .padding()
    }
}

使用:

AlertView(title: "提示", message: "确定删除吗?") {
    HStack {
        Button("取消", role: .cancel) {}
        Button("删除", role: .destructive) {}
    }
}

Step 3:样式/行为用 环境值 + 自定义修饰符

我们想让边框可开关,但又不想回到“参数爆炸”。

struct ShowBorderKey: EnvironmentKey {
    static let defaultValue = false
}

extension EnvironmentValues {
    var showBorder: Bool {
        get { self[ShowBorderKey.self] }
        set { self[ShowBorderKey.self] = newValue }
    }
}

extension View {
    public func showBorder(_ value: Bool) -> some View {
        environment(\.showBorder, value)
    }
}

在 AlertView 内部读取

@Environment(\.showBorder) private var showBorder

// …
.overlay(
    RoundedRectangle(cornerRadius: 12)
        .stroke(Color.accentColor, lineWidth: showBorder ? 1 : 0)
)

至此,API 回归简洁:

AlertView(...) { ... }
    .showBorder(true)

进阶:用 @resultBuilder 做「有约束的自由」

当设计规范新增“免责声明 + 倒计时”组合时,与其疯狂加 init,不如定义一个 InfoSectionBuilder:

@resultBuilder
public struct InfoSectionBuilder {
    public static func buildBlock(_ disclaimer: Text) -> some View {
        disclaimer.disclaimerStyle()
    }
    public static func buildBlock(_ timer: TimerView) -> some View {
        timer
    }
    public static func buildBlock(
        _ disclaimer: Text,
        _ timer: TimerView
    ) -> some View {
        VStack(alignment: .leading, spacing: 12) {
            disclaimer.disclaimerStyle()
            timer
        }
    }
}

把 AlertView 再升级一次:

public struct AlertView<Info: View, Footer: View>: View {
    private let title, message: String
    private let infoSection: Info
    private let footer: Footer
    
    public init(
        title: String,
        message: String,
        @InfoSectionBuilder infoSection: () -> Info,
        @ViewBuilder footer: () -> Footer
    ) {
        self.title = title
        self.message = message
        self.infoSection = infoSection()
        self.footer = footer()
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(title).font(.headline)
            Text(message).font(.subheadline)
            infoSection.padding(.top, 16)
            footer.padding(.top, 25)
        }
        .padding()
    }
}

用法:

AlertView(
    title: "删除账户",
    message: "此操作不可撤销",
    infoSection: {
        Text("余额将在 24 小时内退回")
        TimerView(targetDate: .now + 100)
    },
    footer: {
        Button("确认删除", role: .destructive) {}
    }
)

无障碍:组件方 + 使用方 共同责任

组件内部负责结构级:

.accessibilityElement(children: .combine)
.accessibilityLabel("\(type.rawValue) alert: \(title). \(message)")
.accessibilityAddTraits(.isModal)

使用方负责内容级:

Button("延长会话") {}
    .accessibilityHint("延长 30 分钟")
    .accessibilityAction(named: "延长会话") { // 实际逻辑 }

写在最后的 checklist

维度 ✅ 自检问题
初始化 是否只有“最少必要参数”?
可组合 是否使用 @ViewBuilder / @resultBuilder
样式扩展 是否通过 EnvironmentKey + 自定义修饰符?
无障碍 结构 + 内容 是否都提供了 label / hint / action?
向后兼容 新增需求是否只“加 Builder 方法”而不是“改 init”?

源码仓库

所有示例已整理到 GitHub(非官方镜像,可直接跑 playground): github.com/muhammadosa…

当你用 .disabled(true) 把一整块区域关掉,子组件自动变灰、按钮自动失效 —— 这种「像原生」的体验,正是可扩展设计系统给人的最大安全感。

❌
❌