阅读视图

发现新文章,点击刷新页面。

Homebrew 5.0:并行加速、MCP 加持,与 Intel 的最后倒计时 -- 肘子的 Swift 周报 #0111

issue111.webp

🚀 《肘子的 Swift 周报》

每周为你精选最值得关注的 Swift、SwiftUI 技术动态

Homebrew 5.0:并行加速、MCP 加持,与 Intel 的最后倒计时

几天前,我像往常一样在输入 brew update 后顺手执行了 brew upgrade。出乎意料的是,终端里突然出现了从未见过的画面——大量组件与工具并行下载、整齐排列、同时推进。短暂的惊讶之后,我才从新闻中得知:Homebrew 已经发布了 5.0 版本

此次更新内容相当丰富。除了默认启用并行下载外,还正式将 Raspberry Pi、ARM 迷你 PC、Windows ARM 上的 WSL2 等 ARM64/AArch64 设备纳入 Tier 1 支持,并新增多项指令与能力。其中,官方提供的本地 MCP 服务尤为引人注目。通过 brew mcp-server,开发者可以让 AI Agent 自动操作 Homebrew,意味着 brew 也顺利接入了正在兴起的 AI 工作流。这是一项颇具时代感的更新。

不过,并非所有消息都同样令人愉快。随着 macOS Tahoe 26 大概率成为最后一个支持 Intel x86_64 的版本,Homebrew 也相应调整了自身的支持策略:从 2026 年 9 月起,Intel Mac 将被降级为 Tier 3;到 2027 年 9 月(或更晚),对 Intel 的支持则可能完全终止。

不可否认,在过去十余年里,Intel 架构为苹果带来了庞大的“潜在用户”:它能原生运行 Windows,让许多本不属于 Mac 生态的用户因兼容性而选择苹果设备,为苹果的市场份额提供了关键支撑。如今,随着 Apple Silicon 的成熟,Intel Mac 注定会与 MOS 6502、PowerPC 等一同成为苹果硬件发展史上的重要篇章,在不久的将来缓缓落幕。

回顾历史,每次 CPU 架构转换都激发了苹果在产品设计上的创新灵感,催生出许多具有时代印记的经典产品——从 68k 时代的 Lisa 开创图形界面先河,到 PowerPC 时代的 iMac G3 半透明美学和彩色贝壳本,再到 Intel 时代的小白/小黑 MacBook 以及重新定义轻薄的 MacBook Air。在 M 系列芯片时代,Apple 在性能、能效与系统集成上实现了跨越式提升,但在硬件外观与工业设计语言上,尚未出现能够留下强烈时代烙印的革新之作。

期待尽早看到可以计入史册的新设计,别让我们等太久。

本期内容 | 前一期内容 | 全部周报列表

近期推荐

Grow on iOS 26:UIKit + SwiftUI 混合架构下的 Liquid Glass 适配实战

Grow 是一款在 173 个国家和地区获得 App Store 编辑推荐、拥有超过 18 万五星评价的健康管理应用。在适配 iOS 26 的 Liquid Glass 设计语言时,团队遇到了不少挑战:如何在 UIKit + SwiftUI 混合架构下实现原生的 morph 效果?如何精确控制 Scroll Edge Effect?如何处理自定义导航栏元素的动态尺寸?Grow 的开发者之一 Shuhari,分享了团队在这次适配过程中的实战经验。文章涵盖 Sheet、Navigation、Popover 等场景的改造方案,深入探讨 UIBarButtonItem 尺寸计算、CABackdropLayer 副作用处理等底层细节,还展示了如何利用 Core Text 创造“玻璃文字”效果。所有核心概念都配有完整的 Demo 工程


警惕参数化测试中的陷阱 (Pitfalls of Parameterized Tests)

参数化测试 (Parameterized Tests)是 Swift Testing 中颇具代表性的新特性,它让开发者能够在最小化重复代码的同时扩大测试覆盖范围,为同一逻辑轻松验证多组输入。然而 Alex Ozun 在大规模迁移实践中发现,这项功能虽然便捷,却也暗藏不少容易忽略的陷阱,甚至可能悄悄降低测试的有效性。文章结合多个示例展示了一些常见陷阱,并提出了如避免在 #expect 两侧重复使用测试参数、明确区分示例测试与属性测试等多项实践建议。


为任务显式指定“身份” (Task Identity)

在 SwiftUI 中,task / onAppear 会在视图“出现”时执行一次,但它们并不会像视图那样自动跟踪依赖——如果任务闭包依赖了某个状态,该状态变化后任务本身不会自动重新触发。Chris Eidhof 以加载远程图片为例,展示了这一容易被忽略的问题,并建议为任务显式指定“身份”(identity),例如使用 .task(id: url),让相关依赖(如 URL 或由多个值组合而成的复合标识)参与任务的重新执行条件,使 SwiftUI 能在依赖更新时取消旧任务并启动新任务。作者提醒,凡是在视图中使用 task / onAppear 时,都应确保相关的依赖已经体现在任务的身份(identity)中。


Objective-C API 引发的 Unicode 错误 (One Swift mistake everyone should stop making today)

Swift 已经诞生十年了,但在日常开发中开发者使用的很多 Swift API 仍只是对 Objective-C API 的简单包装,这可能会引发一些容易忽视的严重问题。Paul Hudson 在本文中就通过 replacingOccurrences(of:with:) 展示了这种情况:在处理由多个 Unicode 标量组成的字符(如国旗表情)时,该方法可能会“误拆”字符、匹配不存在的序列,从而生成完全错误的结果。Paul 的建议非常简单:在 Swift 中应优先使用原生的 replacing(_:with:),它能够正确地按字符语义处理 Unicode,避免这些诡异且难以排查的字符串错误。

随着 Foundation 在 Swift 社区重构完成,在 macOS 等平台上,对于具备类似功能的 API,通常应优先选择新 Foundation 中提供的 Swift 原生版本。这样不仅可以避免上述问题,而且也提前为跨平台做好准备。


和 Christian 一起学习 Swift 并发 (Learning About Swift Concurrency (from Matt Massicotte’s Blog) with a Zettelkasten)

Swift 的并发演进并非一帆风顺,引入 Approachable Concurrency 概念后,不同编译选项组合甚至可能得到完全不同的编译结果,理解成本也随之水涨船高。Christian Tietze 原本只打算做一个简短演示:展示如何使用卡片盒笔记法(Zettelkasten)来消化 Matt Massicotte 关于 Swift 并发的博客文章,结果在实作过程中不断撞见更深层的复杂性——例如:actor 无法直接满足带有 nonisolated 要求的 Sendable 协议,除非显式将成员标记为 nonisolatednonisolated(unsafe)。等他回过神来,视频已经录到了 80 分钟。

视频很好地呈现了“深入学技术”的真实面貌:不是线性的知识堆叠,而是充满困惑、假设以及有待日后用代码与文档验证的开放问题。同时也侧面证明,卡片盒笔记法非常适合应对 Swift 并发这类复杂且持续演进的主题,通过构建可搜索、可链接的笔记网络,承载理解在时间维度上的逐步收敛。


Claude Code Skills 功能介绍以及使用经验

Ein Verne 在本文中介绍了 Claude 新推出的 Skills 机制 —— 一种用于扩展 Claude 能力的模块化体系。相比 MCP、Slash Commands 和传统插件,Skills 更强调可组合性、可移植性以及对上下文窗口的友好使用方式。每个 Skill 都以独立文件夹的形式存在,包含名称、描述、操作指令(SKILL.md)、可执行脚本、参考文档与资源文件等。Claude 会在执行任务时自动扫描并匹配合适的技能,并通过“渐进式披露(Progressive Disclosure)”按需加载细节,从而显著降低上下文消耗。作者认为,Skills 本质上将“提示词工程”演进为“工作流工程”,让 Claude 从通用智能助手进一步迈向可维护的智能基础设施形态。


在 iOS 中集成 Rust:基于 UniFFI 的多平台工作流 (Multiplatform with Rust on iOS)

就像许多 Swift 开发者希望把代码带出苹果生态一样,iOS 本身也对其他开发语言保持着相当开放的态度。Tjeerd in 't Veen 在这篇文章中分享了一份详实的 Rust + iOS 集成指南,展示如何通过 Mozilla 的 UniFFI 将 Rust 代码优雅地接入到 iOS 项目中。UniFFI 能将 Rust 的 enum 自动映射为 Swift enum,并把函数名从 snake_case 转为 camelCase,让 Rust 模块在 Swift 侧看起来就像原生 API。

文章给出了一整套可落地的工作流:从创建 Rust 库、为多种 iOS 架构构建静态库、打包 XCFramework,到最终封装成 Swift Package,每一步都有详细说明与常见陷阱提示。这套方案不仅让 iOS 工程可以像使用普通 Swift 包一样消费 Rust 逻辑,也为后续在 Android 等平台复用同一份 Rust 代码打下了良好基础。

工具

VisualDiffer 2:从 Objective-C 到 Swift 的重生

Davide Ficano 将其经营多年的 macOS 文件对比工具 VisualDiffer 完全开源,并从 Objective-C 彻底重写为 Swift。这不是简单的语言迁移或 AI 辅助转换,而是一次从零开始的手工重构。

核心功能保持不变:

  • 🟩 直观对比 - 并排展示目录差异,用颜色标识新增、修改或缺失的文件
  • 🧩 深入分析 - 支持文件级别的逐行对比(基于 UNIX diff)
  • 🧹 智能过滤 - 自动排除版本控制文件(.git、.svn)和系统文件(.DS_Store)
  • 性能优化 - 支持多种对比策略,从快速的日期/大小对比到精确的逐字节对比

Reddit 上,作者坦言自己依旧非常欣赏 Objective-C,但 Swift 的潜力让他愿意承受迁移的巨大成本。UI 层(特别是 NSTableView 与 delegate 模式)的重写过程尤为艰难,早期充满了并发属性标注,但随着理解加深,Swift 的优势逐渐显现。


FSWatcher:高性能的 Swift 原生文件系统监控库

十里 在开发图片压缩工具 Zipic 时,需要实时感知图片文件变化以便进行及时处理,为此开发了 FSWatcher。这是一个基于 macOS/iOS 底层 kqueue 机制的文件系统监控库,采用事件驱动而非轮询方式,资源消耗极低。

核心特性:

  • 🎯 智能过滤:支持按文件类型、大小、修改时间等多维度过滤,并可链式组合
  • 🔍 预测性忽略:自动识别并跳过自身生成的输出文件(如 *_compressed.jpg),避免循环触发
  • 📁 递归监控:可监控整棵目录树,支持深度限制与排除规则
  • 现代 API:完整支持 Combine、async/await 以及传统闭包回调模式

该库非常适合作为图片处理流程的监听器、开发工具的热重载组件,或构建轻量化自动备份系统等需要实时文件变动感知的场景。


SFSymbolKit:零维护的类型安全 SF Symbols 库

市面上已有不少用于改进 SF Symbols 使用体验的库,但 LiYanan 的 SFSymbolKit 仍然颇具特色:所有符号与可用性信息都由工具直接从系统框架自动生成,一键即可完成更新,真正做到无需人工维护。

核心优势:

  • 数据源可靠:直接读取 /System/Library/PrivateFrameworks/SFSymbols.framework/,与系统 100% 同步
  • 完全自动化:运行 ./update_symbols.sh 即可更新,无需手动添加新符号
  • 版本感知:自动生成 @available 属性,编译时检查符号兼容性
  • 用户自助:任何人都可以在本地更新,不依赖作者发版

往期内容

💝 支持与反馈

如果本期周报对你有帮助,请:

  • 👍 点赞 - 让更多开发者看到
  • 💬 评论 - 分享你的看法或问题
  • 🔄 转发 - 帮助同行共同成长

🚀 拓展 Swift 视野

Swift 6 迁移常见 crash: _dispatch_assert_queue_fail

我的 Github:github.com/RickeyBoy/R…

大量 iOS 内容欢迎大家关注~

最近在将公司项目迁移到 Swift 6 的过程中,解决了好几个相似的 crash。关键字如下

    _dispatch_assert_queue_fail
    
    "%sBlock was %sexpected to execute on queue [%s (%p)]
    
    Task 208: EXC_BREAKPOINT (code=1, subcode=0x103546f18)

在这里记录和分享,希望遇到相似的问题之后能够更快的解决。

Crash 1 信息

image1.png

image2.png

原因与解决 1

首先根据 crash 的提示,可以清楚地知道:Block 预期在一个线程上执行,而实际在另一个线程上执行。第一反应:大概率是主线程 vs 后台线程之间的不一致导致的。

如果经常熟练处理 Swift 6 升级的小伙伴就知道,一定是有地方标记了 @MainActor,也就意味着相对应的方法一定是要在主线程上执行的;而实际阴差阳错,在后台线程执行了。

所以接下来可能需要找,到底哪里线程不一致呢?我们根据代码来寻找即可。

image3.png

不难找到,根据 Combine 调用链,可以发现其中一处对于 userPublisher 的监听时,选择了在 global 上去执行后面的操作,所以这里需要将这一个逻辑去掉。

于此同时,对于 userPublisher 的发布,我们也最好将其默认放在主线程上,因为他是和 UI 相关的,所以需要做这样的改动:

image4.png

坑点

目前是不是觉得好像这个类型的 crash 不算很难解决?没错,这个 crash 的提示相对清楚,知道大概原因后去找就相对容易了。

不过需要注意的是,当使用 Combine 框架遇上这类型的 crash 时,crash 断点只会发生在 Publisher 而不是 Observer 处,所以我们需要自己去寻找调用方,看下在哪里出现了线程使用不一致的问题。

Crash 2 信息

好的,那么同类型的一个 crash 再来看看:

image5.png

报错信息就不贴了,和上一个 crash 是一样的,都是:"%sBlock was %sexpected to execute on queue [%s (%p)]

这里可以看到,crash 断点处在子线程,也是在 AsyncStream 发布处断点。那么根据经验推断,可以大概知道原因:

  1. 此处发布的时候,处在子线程
  2. 下游调用方,一定有某个地方要求在主线程
  3. 实际线程与要求线程不一致,所以导致 crash

原因与解决 2

这里寻找过程就不赘述了。原来的发布者是处在子线程,而后面的监听者处在主线程,因此需要改在主线程发布。

image6.png

Swift 一个小型游戏对象模型渐进式设计(五)——Swift 并发世界:把 Attackable 搬进 actor

为什么“并发”突然成了刚需

真实场景里:

  • 游戏服务器:32 条网络线程并发处理玩家技能;
  • 客户端:主线程发动画,后台线程算伤害,Timer 触发 dot;
  • 单机多核:SceneKit 物理回调、Vision 识别、Swift Concurrency Task 同时读写同一 BOSS 的血量。

如果还用传统锁:

objc_sync_enter(self)
hp -= damage
objc_sync_exit(self)

轻则性能抖动,重则死锁;而 Swift 5.5 起的 Actor 模型 把“互斥”升级为消息队列,编译期即可检查“跨 actor 引用是否安全”,让“数据竞争”成为编译错误。

Actor 101:30 秒速览

  1. 定义
actor Boss {
    var hp: Double = 100
    func takeDamage(_ amount: Double) {
        hp = max(0, hp - amount)
    }
}
  1. 调用规则
  • 内部:同步函数,直接访问 hp
  • 外部:必须通过 await 异步消息,编译器自动加队列。
let boss = Boss()
await boss.takeDamage(10)   // 编译通过
boss.hp                     // ❌ 编译错误:actor-isolated
  1. 关键保证

Actor 隔离域(isolation domain):同一时间只有一条消息在执行,天然“可线性化”(Serializability)。

把协议能力搬进 actor

目标:

  • 不破坏前两篇的泛型协议架构;
  • 让任何实体既能以“值语义”跑在单线程,也能以“ actor 引用”跑在多线程;
  • 客户端/服务器共用同一套算法。
  1. 定义并发版协议
/// 可并发受伤
protocol ConcurrentWoundable: AnyObject {
    associatedtype Value: NumericValue
    func takeDamage(_ amount: Value) async
    var currentHp: Value { get }
}

注意:

  • AnyObject 限制只让 class/actor 符合,因为需要共享引用;
  • 方法标记 async,调用方必须 await
  1. 让 actor 直接符合
actor ConcurrentBoss<Value: NumericValue>: ConcurrentWoundable {
    private(set) var hp: Value
    let maxHp: Value
    
    init(hp: Value, maxHp: Value) {
        self.hp = hp; self.maxHp = maxHp
    }
    
    func takeDamage(_ amount: Value) async {
        hp = max(Value(0), hp - amount)
    }
    
    nonisolated var currentHp: Value { hp }   // 只读快照,无需 await
}

nonisolated 关键字:编译器允许外部同步读取,但不能写。

  1. 并发安全暴击算法

把上篇的 DamageCalculator 泛型算法保持值语义,计算过程无锁;只有最后 takeDamage 进 actor 才排队。

let calc = AnyDamageCalculator(Double.self) { base in base * 1.5 }
let damage = calc.calculate(base: 50)          // 无锁计算
await boss.takeDamage(damage)                  // 一次消息

分离“计算”与“状态变更”:计算无锁、变更串行,兼顾性能与安全。

分布式 Actor:跨进程也能 “await boss.takeDamage”

Swift 5.9 起引入 distributed actor,同一语法即可跨进程/跨机器:

distributed actor RemoteBoss: ConcurrentWoundable {
    distributed func takeDamage(_ amount: Value) async {
        hp = max(Value(0), hp - amount)
    }
}

调用方:

let boss = try await RemoteBoss.resolve(id: bossID, using: .init())
await boss.takeDamage(30)

底层由 Swift gRPC 传输消息,开发者零成本获得分布式对象模型。

实战:并发 Boss 战模拟器

场景:

  • 4 个玩家并发放技能,伤害随机;
  • 1 个后台线程每 0.5 s 触发 dot;
  • 1 个渲染线程每帧读血量更新 UI;

代码:

protocol NumericValue: Comparable & Sendable {
    static func + (lhs: Self, rhs: Self) -> Self
    static func - (lhs: Self, rhs: Self) -> Self
    static func * (lhs: Self, rhs: Self) -> Self
    static func / (lhs: Self, rhs: Self) -> Self
    static func > (lhs: Self, rhs: Self) -> Bool   // 与标量乘
    init(_ value: Int)                               // 能从整数字面量初始化
}
extension Double: NumericValue {}

/// 可并发受伤
protocol ConcurrentWoundable: AnyObject {
    associatedtype Value: NumericValue
    func takeDamage(_ amount: Value) async
    var currentHp: Value { get }
}

// 1. 并发 BOSS
actor BossBattle: @preconcurrency ConcurrentWoundable {
    private(set) var hp: Double
    let maxHp: Double
    init(hp: Double) {
        self.hp = hp;
        self.maxHp = hp
    }
    
    func takeDamage(_ amount: Double) async {
        hp = max(0, hp - amount)
        if hp == 0 { print("BOSS 被击败!") }
    }
    
    var currentHp: Double { hp }
}

// 2. 玩家技能
func playerTask(id: Int, boss: BossBattle) async {
    for _ in 0..<5 {
        let damage = Double.random(in: 5...15)
        await boss.takeDamage(damage)
        print("Player\(id) 造成 \(damage)")
        try? await Task.sleep(for: .milliseconds(.random(in: 100...300)))
    }
}

// 3. dot 后台
func dotTask(boss: BossBattle) async {
    while await boss.currentHp > 0 {
        await boss.takeDamage(3)
        print("dot 3 点")
        try? await Task.sleep(for: .milliseconds(500))
    }
}

// 4. 渲染线程(只读)
func renderTask(boss: BossBattle) async {
    while await boss.currentHp > 0 {
        let hp = await boss.currentHp
        print("UI 血量:\(Int(hp))")
        try? await Task.sleep(for: .seconds(1/60))
    }
}

// 5. 启动

Task {
    let boss = BossBattle(hp: 100)
    let _ = await withDiscardingTaskGroup { group in
        for i in 1...4 {
            group.addTask {
                await playerTask(id: i, boss: boss)
            }
        }
        
        group.addTask {
            await dotTask(boss: boss)
        }
        
        group.addTask {
            await renderTask(boss: boss)
        }
    }
}

运行结果(节选):

Player3 造成 11.0
Player1 造成 8.0
dot 3 点
UI 血量:78
...
BOSS 被击败!

全程无需手动加锁,编译器保证任何时刻只有一条消息在修改 hp

与 SwiftUI 无缝衔接

@MainActor
final class BossModel: ObservableObject {
    private let boss = BossBattle(hp: 100)
    
    @Published private(set) var hpText = ""
    
    func start() async {
        await renderLoop()
    }
    
    @MainActor
    private func renderLoop() async {
        while await boss.currentHp > 0 {
            hpText = "血量 \(Int(await boss.currentHp))"
            try? await Task.sleep(for: .seconds(1))
        }
        hpText = "BOSS 被击败"
    }
    
    func attack() async {
        await boss.takeDamage(Double.random(in: 10...20))
    }
}

@MainActor 保证所有 SwiftUI 状态更新跑在主线程;业务逻辑在后台 actor 串行执行,零数据竞争。

常见坑与最佳实践

  1. 在 actor 里访问全局可变状态

    同样要 await,否则编译报错。

  2. nonisolated 只能读,不能写;写必须走消息。

  3. 不要把长时间阻塞代码(sleep、sync I/O)直接放进 actor,会卡住消息队列;应拆到 Task.detachedAsyncSequence

  4. 跨 actor 调用时,值类型会被拷贝,不要传递大型数组;可改用 AsyncSequence 流式输出。

  5. 分布式 actor 的方法参数/返回值必须遵循 Codable,否则无法序列化

Swift 一个小型游戏对象模型渐进式设计(四)——类型擦除与 Existential:当泛型遇见动态派发

为什么“泛型”还不够

上一篇我们写出了这样的代码:

let calc: any DamageCalculator<Double> = CritCalculator(rate: 1.5)

它编译得快、跑得也快,但当你想把它存进数组、或者作为属性逃逸到运行时,就会遇到三个灵魂问题:

  1. 编译器不知道具体类型有多大,如何分配内存?
  2. 协议里有 associatedtype,为什么不能用 DamageCalculator 直接当做类型?
  3. 同样一句 calculate(base:),为什么有时走内联、有时走虚表?

答案都指向同一个机制:Existential Container(存在性容器),社区俗称“类型擦除盒”。

Existential 是什么

Swift 把“符合某个协议的值”打包成一种统一大小的盒子,这个盒子就叫 existential。

语法层面:

  • any Protocol // Swift 5.6+ 显式 existential
  • 老代码里的 Protocol // 隐式 existential,即将被逐步废弃

盒子内部到底长什么样?继续看。

Existential Container 的内存布局

以 64 bit 为例,标准布局 5 个字(40 byte):

+-------- 0:  value buffer (3 ptr = 24 byte)  
+--------24:  value witness table (VWT)  
+--------32:  protocol witness table (PWT)  
  1. value buffer

    • 小值(Int、Double、CGPoint…)直接内联;
    • 大值(String、Array、自定义 class)堆分配,buffer 存指针;
  2. VWT

    管理“值语义”生命周期:拷贝、销毁、搬移。

  3. PWT

    管理“协议方法”派发地址,相当于 C++ 的 vtable。

结论:哪怕只是一个 Double,装进 any NumericValue 后也会膨胀到 40 字节;如果频繁在数组里拷贝,就会带来隐式堆分配和缓存抖动。

关联类型协议的“额外”盒子

当协议带 associatedtype 时,existential 还需要一份通用签名(generic environment),用于在运行时保存类型元数据。

因此:

let x: any Attackable        // ❌ 编译错误:associatedtype Value 未定
let y: any Attackable<Int>   // ✅ Swift 5.9 新语法:parameterized existential

后者内部比“无关联类型”再多 8 byte,总计 48 byte。

苹果在 WWDC23 给出的性能警告:< 3 个 witness 方法且 value ≤ 24 byte 时,existential 才基本无额外开销;否则请考虑“手写类型擦除”或“泛型特化”。

实战:手写 AnyDamageCalculator

目标:

  • 对外暴露固定大小(无动态盒子);
  • 对内保存任意具体计算器;
  • 仍保持 Value 泛型参数。
  1. 定义抽象基类(引用语义)
class AnyDamageCalculatorBox<Value: NumericValue>: DamageCalculator {
    func calculate(base: Value) -> Value { fatalError("abstract") }
}
  1. 定义具体盒子(泛型类)
final class ConcreteBox<T: DamageCalculator>: AnyDamageCalculatorBox<T.Value> {
    private let concrete: T
    init(_ concrete: T) { self.concrete = concrete }
    override func calculate(base: Value) -> Value {
        concrete.calculate(base: base)
    }
}
  1. 定义值包装(对外类型)
struct AnyDamageCalculator<Value: NumericValue>: DamageCalculator {
    private let box: AnyDamageCalculatorBox<Value>
    
    init<C: DamageCalculator>(_ concrete: C) where C.Value == Value {
        self.box = ConcreteBox(concrete)
    }
    
    func calculate(base: Value) -> Value {
        box.calculate(base: base)
    }
}
  1. 使用:
let crit = CritCalculator(rate: 1.5)
let erased: AnyDamageCalculator<Double> = AnyDamageCalculator(crit)
array.append(erased)   // 数组元素大小 = 1 ptr,无 existential 盒子
  • 内存大小:8 byte(一个 class 指针);
  • 拷贝成本:一次 ARC retain;
  • 方法派发:虚表一次,但不再额外带 VWT/PWT。

Swift 5.9 新武器:Parameterized Existential

let list: [any DamageCalculator<Double>] = [
    CritCalculator(rate: 1.5),
    MultiplierCalculator(upstream: CritCalculator(rate: 2), multiplier: 1.2)
]

编译器会自动生成“隐藏盒子”,但仍带 48 byte 拷贝成本。

适合场景:

  • 原型阶段、快速迭代;
  • 对性能不敏感的工具代码;

高性能路径(渲染、音频、网络解析)继续用手写擦除或泛型特化。

类型擦除的通用套路(模板)

任何带 associatedtype 的协议,都可以套下面 4 步:

  1. 创建 AnyProtocolBase<AssociatedType> 抽象类;
  2. 创建 ConcreteBox<T: Protocol> 具体类,持有 T
  3. 创建 AnyProtocol<AssociatedType> 值类型,内部存 AnyProtocolBase 指针;
  4. 对外 API 全部 override / forward 到抽象类。

什么时候用哪种形态?

需求 \ 方案        泛型特化   any Protocol   手写擦除
------------------------------------------------------------
编译期已知类型       ✅          ❌             ❌
需要进数组/逃逸      ❌          ✅             ✅
对性能极度敏感       ✅          ❌             ✅
不想写样板代码       ✅          ✅             ❌(可用宏)

一句话:编译期能定类型就用泛型;运行时再决定就用擦除;原型阶段先 any 再说。

Swift 一个小型游戏对象模型渐进式设计(三)——把能力再抽象一层,写一套“伤害计算器”框架

为什么要“再抽象一层”

上两篇我们已经用协议把“攻击”拆成了能力插件,但遗留了一个硬核问题:

  • 游戏前期用 Int 足够,后期为了避免除法误差想换成 Double,甚至金融级精度要用 Decimal
  • 如果给每种数值类型都复制一份协议,就会出现 AttackableIntAttackableDouble…爆炸式增长。

Swift 的泛型(Generic)+ 关联类型(associatedtype)可以“一次性”写出算法,然后让编译器在调用点自动生成对应版本的代码,既保证类型安全,又保持运行时零成本。

把 Attackable 升级成泛型协议

  1. 定义“数值”契约

先约定一个“可运算、可比较”的基本协议,把 +*/> 等运算符包进去:

protocol NumericValue: Comparable {
    static func + (lhs: Self, rhs: Self) -> Self
    static func * (lhs: Self, rhs: Self) -> Self
    static func / (lhs: Self, rhs: Self) -> Self
    static func > (lhs: Self, rhs: Self) -> Bool   // 与标量乘
    init(_ value: Int)                               // 能从整数字面量初始化
}
  1. 让标准库类型自动符合

Swift 5.7 之后可以用 extension 给标准库类型“批量”实现:

extension Int: NumericValue {}
extension Double: NumericValue {}
extension Decimal: NumericValue {
    static func *(lhs: Decimal, rhs: Double) -> Decimal {
        lhs * Decimal(rhs)
    }
}

FloatCGFloat 同理)

  1. 泛型版 Attackable
protocol Attackable {
    associatedtype Value: NumericValue   // ① 关联类型
    func attack() -> Value
}

注意:

① 这里不能再给 attack() 提供默认实现,因为返回类型是泛型,不同数值的“默认伤害”语义不同;

② 如果确实想提供默认,可以再包一层泛型扩展

给“默认伤害”一个泛型实现

利用协议扩展的“where 子句”只对特定数值生效:

extension Attackable where Value == Double {
    func attack() -> Value { 10.0 }
}
extension Attackable where Value == Int {
    func attack() -> Value { 10 }
}
extension Attackable where Value == Decimal {
    func attack() -> Value { Decimal(10) }
}

这样任何符合者只要 Value 是上述三种之一,不实现 attack() 也能编译通过;想定制就再写一遍覆盖即可。

把“伤害计算器”也做成泛型组件

需求:

  • 支持“暴击”、“易伤”、“免伤”多层修正;
  • 算法写一次,对 Int / Double / Decimal 全部生效;
  • 编译期决定类型,无运行时派发。
  1. 定义计算器协议
protocol DamageCalculator<Value> {
    associatedtype Value: NumericValue
    /// 传入基础伤害,返回最终伤害
    func calculate(base: Value) -> Value
}
  1. 默认实现:暴击 * 1.5
struct CritCalculator<Value: NumericValue>: DamageCalculator {
    let rate: Value   // 暴击倍率
    
    func calculate(base: Value) -> Value {
        base * rate
    }
}
  1. 链式组合:装饰器模式
struct MultiplierCalculator<Value: NumericValue>: DamageCalculator {
    let upstream: any DamageCalculator<Value>  // 上游计算器
    let multiplier: Double
    
    func calculate(base: Value) -> Value {
        let upstreamDamage = upstream.calculate(base: base)
        return upstreamDamage * multiplier
    }
}

使用:

let crit: any DamageCalculator<Double> = CritCalculator(rate: 1.5)
let vulnerable = MultiplierCalculator(upstream: crit, multiplier: 1.2)  // 易伤 +20%
let final = vulnerable.calculate(base: 100)   // 100 * 1.5 * 1.2 = 180.0

把计算器塞进实体——“能力注入”

我们不再让实体“继承”伤害逻辑,而是把计算器当成属性注入:

struct Warrior<Value: NumericValue>: Attackable {
    let calculator: any DamageCalculator<Value>
    
    func attack() -> Value {
        let base: Value = Value(50)        // 自己定基础值
        return calculator.calculate(base: base)
    }
}

使用:

let warriorD = Warrior<Double>(calculator: vulnerable)
print(warriorD.attack())   // 90.0

一个文件里同时玩三种精度

let wInt    = Warrior<Int>(calculator: CritCalculator(rate: 2))
let wDouble = Warrior<Double>(calculator: CritCalculator(rate: 2))
let wDec    = Warrior<Decimal>(calculator: CritCalculator(rate: 2))

print(wInt.attack())     // 100
print(wDouble.attack())  // 100.0
print(wDec.attack())     // 100

同一套算法,编译器自动生成三份特化(specialization)代码,运行时无盒子、无动态派发。

性能实测:零开销承诺是否兑现?

测试环境:M1 Mac / Swift 5.9 / -O 优化

let p = Warrior<Double>(calculator: CritCalculator(rate: 1.8))
measure {
    for _ in 0..<1_000_000 { _ = p.attack() }
}

结果:

  • 泛型特化版本:0.047 s
  • 手写 Double 专用版本:0.046 s

差距在 2% 以内,属于测量误差;汇编层面已无线程堆分配、无 protocol witness 调用。

什么时候回到引用语义?

  1. 计算器需要状态缓存(如随机种子、CD 计时)且要共享;
  2. 需要继承 NSObjec 以兼容 KVO / Core Data;
  3. 需要互斥锁、原子引用计数。

其余场景继续 struct + 泛型协议

最终决策清单(速查表)

需求场景 首选方案 备选方案
只是多态 protocol 默认实现 class + override
多精度算法 泛型 protocol + associatedtype 宏/模板代码生成
共享可变状态 class actor
值语义 + 组合 struct + protocol
运行时动态替换 class + objc SwiftUI 的 AnyView 类型擦除

Swift 一个小型游戏对象模型渐进式设计(二)——协议与默认实现:如何写出不用继承的多态

用 protocol + extension 把上一篇的 BOSS 战例彻底重构,让代码轻量、可测试、易扩展

为什么“不用继承”

上一篇我们用 class Entity → Monster / Boss 的经典继承树完成了需求,但留下几个隐痛:

  1. 值类型无法参与:Swift 的 struct 不能继承 class。
  2. 多继承死路:一个 BOSS 既要“可攻击”又要“可飞行”还要“可分裂”,Swift 不支持多类继承。
  3. 隐式共享状态:父类新增的存储属性,所有子类被迫买单,造成“胖基类”。
  4. 单元测试困难:想单独测“狂暴逻辑”必须把整个 Boss 实例 new 出来,还要喂血量。

协议(protocol)是什么

一句话:协议只定义“契约”,不关心“怎么存”。

protocol Attackable {
    func attack() -> Double
}

任何类型(class / struct / enum / actor)只要实现 attack(),就自动“符合” Attackable,从而获得多态能力。

协议本身不能存属性,但可以通过“关联属性”或“协议扩展”给出默认实现,达到“代码复用”而“不强制继承”。

协议扩展:给协议加“默认实现”

语法:

extension Attackable {
    func attack() -> Double { 10.0 }   // 默认伤害
}

现在任何符合者如果不自己写 attack(),就自动拿到 10 点伤害。

想定制?在自己的类型里重新实现即可,不需要 override 关键字——因为协议不涉继承链。

拆成“能力插件”

  1. 可攻击
protocol Attackable {
    func attack() -> Double
}
extension Attackable {
    func attack() -> Double { 10.0 }
}
  1. 可定位
protocol Locatable {
    var x: Double { get set }
    var y: Double { get set }
}
  1. 可受伤
protocol Woundable {
    var hp: Double { get set }
    var maxHp: Double { get }
}
extension Woundable {
    var isRage: Bool { hp < maxHp * 0.2 }   // 狂暴判断
}
  1. 可随机伤害
protocol RandomDamage {
    func randomDamage(base: Int, range: Int) -> Double
}
extension RandomDamage {
    func randomDamage(base: Int, range: Int) -> Double {
        Double.random(in: 0.0..<Double(range)) + Double(base)
    }
}

用 struct 组装各种实体

Swift 的 struct 可以符合多个协议,享受所有默认实现,零继承。

  1. 普通小怪
struct Monster: Attackable, Locatable, Woundable, RandomDamage {
    var hp: Double
    let maxHp: Double
    var x: Double
    var y: Double
    
    // 自己定制伤害
    func attack() -> Double {
        randomDamage(base: 5, range: 6)
    }
}
  1. BOSS
struct Boss: Attackable, Locatable, Woundable {
    var hp: Double
    let maxHp: Double
    var x: Double
    var y: Double
    
    // 狂暴机制
    func attack() -> Double {
        let base: Double = 10
        return isRage ? base * 2 : base
    }
}
  1. 飞行小怪(新增能力,无需改旧代码)
protocol Flyable {
    var altitude: Double { get set }
}
struct FlyingMonster: Attackable, Locatable, Woundable, Flyable, RandomDamage {
    var hp: Double
    let maxHp: Double
    var x: Double
    var y: Double
    var altitude: Double
    
    func attack() -> Double {
        randomDamage(base: 4, range: 5) + 2   // 空对地加 2
    }
}

多态依旧可用:协议作为类型

let army: [any Attackable & Woundable] = [
    Monster(hp: 30, maxHp: 30, x: 0, y: 0),
    Boss(hp: 100, maxHp: 100, x: 1, y: 1),
    FlyingMonster(hp: 20, maxHp: 20, x: 2, y: 2, altitude: 10)
]

for unit in army {
    print("伤害=\(unit.attack()), 狂暴=\(unit.isRage)")
}

打印示例:

伤害=8.857546603881572, 狂暴=false
伤害=10.0, 狂暴=false
伤害=9.333377580674401, 狂暴=false

把 Boss 的血量打到 19 再跑一次,就能看到伤害翻倍,逻辑与继承版完全一致。

单元测试变得多简单?

想测“狂暴判断”只要 new 一个符合 Woundable 的伪对象即可,完全不用构造整个 Boss:

struct Mock: Woundable {
    var hp: Double
    let maxHp: Double = 100
}

let mock = Mock(hp: 19)
XCTAssertTrue(mock.isRage)

协议组合(Protocol Composition)的语法糖

typealias GameUnit = Attackable & Woundable & Locatable
func move(_ unit: inout GameUnit, toX x: Double, y: Double) {
    unit.x = x
    unit.y = y
}

一个类型别名即可把“能力包”当成一个整体使用,比继承树清爽得多。

什么时候仍需要 class 继承

  1. 需要 Objective-C 运行时动态替换(KVO、Swizzle)。
  2. 需要析构器 deinit 做资源清理。
  3. 需要共享引用语义(多个指针指向同一对象)。
  4. 需要互斥锁、原子操作等“引用计数”场景。

其余场景,优先 struct + 协议。

小结:一条决策流程图

image.png

Swift 一个小型游戏对象模型渐进式设计(一)——继承机制解读:从基础类到防止重写

为什么必须有“继承”

在真实世界里,我们习惯把事物归类:车 → 自行车 → 双人自行车。

Swift 的 class 类型允许我们用同样的层级方式建模,把公共的代码放在“上层”,把差异化的代码放在“下层”,这就是继承(Inheritance)。

它带来的三大价值:

  1. 代码复用:公共逻辑写一次。
  2. 统一接口:上层可用“父类指针”操作一切子类。
  3. 多态:运行时才决定到底执行哪段代码。

基础概念速览

  1. 基类(Base Class):不继承任何类的类。
  2. 子类(Subclass):写在冒号后面的类,它自动拥有父类所有成员。
  3. 重写(Override):子类对继承来的成员重新实现,需加关键字 override。
  4. super:在子类内部访问“父类实现”的前缀。
  5. final:阻止后面的人再继续重写或继承。

基类长什么样

/// 基类:最普通的“车”
class Vehicle {
    var currentSpeed = 0.0          // 存储属性,默认 0
    
    /// 计算属性:只读,返回人类可读的描述
    var description: String {
        return "traveling at \(currentSpeed) mph"
    }
    
    /// 实例方法:基类里什么都不做,留给子类去“填坑”
    func makeNoise() {
        // 空实现
    }
}

// 使用
let someVehicle = Vehicle()
print("Vehicle: \(someVehicle.description)")
// 打印:Vehicle: traveling at 0.0 mph

子类化(Subclassing)

语法:

class 子类: 父类 { /* 新增或覆盖 */ }

示例 1:自行车

class Bicycle: Vehicle {
    var hasBasket = false   // 新增属性
}

let bike = Bicycle()
bike.hasBasket = true
bike.currentSpeed = 15
print("Bicycle: \(bike.description)")
// 打印:Bicycle: traveling at 15.0 mph

示例 2:双人自行车(子类还能再被继承)

class Tandem: Bicycle {
    var currentNumberOfPassengers = 0
}

let tandem = Tandem()
tandem.hasBasket = true
tandem.currentNumberOfPassengers = 2
tandem.currentSpeed = 22
print("Tandem: \(tandem.description)")
// 打印:Tandem: traveling at 22.0 mph

重写(Override)全规则

  1. 方法重写:必须写 override;否则编译器报错。
class Train: Vehicle {
    override func makeNoise() {
        print("Choo Choo")
    }
}
Train().makeNoise()   // Choo Choo
  1. 属性重写

    子类“看不到”父类属性到底是存储型还是计算型,只能按“名字 + 类型”去匹配。

    a) 只读变读写:可以补充 setter。

    b) 读写变只读:❌ 不允许。

示例:给车加“档位”描述

class Car: Vehicle {
    var gear = 1
    
    override var description: String {
        // 先拿父类描述,再拼接自己的
        return super.description + " in gear \(gear)"
    }
}

let car = Car()
car.currentSpeed = 25
car.gear = 3
print("Car: \(car.description)")
// 打印:Car: traveling at 25.0 mph in gear 3
  1. 属性观察器重写

    可以为任何继承来的属性(无论存储/计算)添加 willSet/didSet。

    典型场景:自动档根据速度换挡。

class AutomaticCar: Car {
    override var currentSpeed: Double {
        didSet {
            gear = Int(currentSpeed / 10) + 1   // 自己算档位
        }
    }
}

let auto = AutomaticCar()
auto.currentSpeed = 35
print("AutomaticCar: \(auto.description)")
// 打印:AutomaticCar: traveling at 35.0 mph in gear 4

防止继承与重写——final

  1. 防重写
class Parent {
    final var id = 1          // 子类不能 override
    final func foo() {}       // 子类不能 override
}
  1. 防继承
final class Tool {}         // 任何人写 class MyTool: Tool {} 都会编译失败

super 的使用场景小结

  1. 在 override 方法里:super.someMethod()
  2. 在 override 属性 getter/setter 里:super.someProperty
  3. 在 override 下标里:super[index]

容易踩的坑

  1. 忘记写 override → 编译期报错。
  2. 重写时把只读属性改成只写 setter, Swift 不允许。
  3. 在 init 里访问 super 属性前,必须保证本类存储属性已初始化(初始化器规则)。
  4. 把 struct 拿去继承 → Swift 只有 class 支持继承,struct/enum 不行。

继承的边界与替代方案

继承是“白盒复用”,子类会依赖父类实现细节,容易造成“脆弱基类”问题。Swift 提供了更轻量的组合手段:

  1. 协议(protocol)+ 默认实现 → 无需继承即可获得多态。
  2. 值类型(struct)+ 组合 → 把“能力”做成属性,而非父类。
  3. 面向协议编程(POP)→ 把“is-a”转成“can-do”,降低耦合。

实战扩展:一个小型游戏对象模型

需求:

  • 所有游戏实体都有血量 hp 与坐标 (x,y)。
  • 玩家可以攻击,造成随机伤害。
  • BOSS 血量低于 20% 时进入狂暴模式,攻击力翻倍。

代码:

// 1. 基类
class Entity {
    var hp: Double
    var x: Double, y: Double
    
    init(hp: Double, x: Double, y: Double) {
        self.hp = hp; self.x = x; self.y = y
    }
    
    func attack() -> Double {
        return 10.0   // 默认伤害
    }
}

// 2. 普通小怪
class Monster: Entity {
    override func attack() -> Double {
        let damage = Double.random(in: 0..<6) + 5
        return damage
    }
}

// 3. BOSS
class Boss: Entity {
    override func attack() -> Double {
        let base = super.attack()
        // 狂暴判断
        let rage = hp < 20   // 假设 maxHp = 100
        return rage ? base * 2 : base
    }
}

// 4. 使用
let boss = Boss(hp: 15, x: 0, y: 0)
print("BOSS 伤害:\(boss.attack())")  // 血量<20,伤害翻倍

总结与思考

  1. 继承是 Swift 面向对象体系的基石,但“能用”不等于“该用”。
  2. 优先把“共性”做成协议或工具函数,再考虑是否抽象出基类。
  3. 重写时始终加 override,既安全又自文档化。
  4. 用 final 明确“设计边界”,让后来者少踩坑。
  5. 与值类型、协议、组合搭配,才能发挥 Swift 真正的威力。

Swift 中的迭代机制:Sequence、Collection 与 Iterator 完全拆解

前言

日常开发里,我们写 for item in list 像呼吸一样自然。

但 Swift 编译器在背后悄悄做了三件事:

  1. 调用 list.makeIterator() 拿到一个迭代器
  2. 反复调用 iterator.next()
  3. 把返回的可选值解包后赋给 item

一旦理解这三步,你就能

  • 自己写“能 for-in 的数据结构”
  • 避免“遍历同时修改”导致的崩溃
  • 把回调式 API 优雅地转成 AsyncSequence

Sequence:最小迭代单元

协议定义(核心部分,Swift 5.9 仍不变)

public protocol Sequence {
    associatedtype Element
    associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
    
    func makeIterator() -> Iterator
}

public protocol IteratorProtocol {
    associatedtype Element
    mutating func next() -> Element?
}

关键知识点

  1. Sequence 只承诺“能生成迭代器”,不保证能反复遍历,也不保证有 count
  2. 迭代器几乎总是 struct:值语义保证“复制一份就从头开始”,不会意外共享状态。
  3. 单趟序列(例如网络流)完全合法;第二次 makeIterator() 可以返回空迭代器。

代码示例:自定义一个“从 n 倒数到 0”的序列

struct Countdown: Sequence {
    let start: Int
    
    // 每次 for-in 都会调用一次,生成新的迭代器
    func makeIterator() -> Iterator {
        Iterator(current: start)
    }
    
    struct Iterator: IteratorProtocol {
        var current: Int
        
        // 返回 nil 时代表迭代结束
        mutating func next() -> Int? {
            guard current >= 0 else { return nil }
            defer { current -= 1 }          // 先返回,再减
            return current
        }
    }
}

// 使用
for number in Countdown(start: 3) {
    print(number)   // 3 2 1 0
}

Collection:在 Sequence 上加了三把锁

Collection 额外保证

  • 可多次遍历且顺序稳定(除非自己把文档写错)
  • 提供 countendIndex、下标访问
  • 支持切片、前缀、后缀等默认实现

协议片段

public protocol Collection: Sequence {
    associatedtype Index: Comparable
    var startIndex: Index { get }
    var endIndex: Index { get }
    subscript(position: Index) -> Element { get }
    func index(after i: Index) -> Index
}

因为多趟安全,mapfilter 可以提前分配内存;

因为下标存在,ArrayDictionarySet 都直接 conform。

for-in 的糖衣剥开长这样

编译器把

for element in container {
    print(element)
}

翻译成

var iterator = container.makeIterator()
while let element = iterator.next() {
    print(element)
}

理解这段模板代码,你就能:

  • 在 Playground 里手动模拟 for 循环
  • 把“遍历同时修改”的崩溃场景复现出来

遍历同时修改:崩溃现场与三种安全写法

现场:遍历数组时删除元素

var todoItems = ["A", "B", "C"]

// 目前倒是没有崩溃,但是也不是很符合逻辑
for (index, item) in todoItems.enumerated() {
    if item == "B" {
        todoItems.remove(at: index)   // ❌ Fatal error: Collection modified while enumerating
    }
}

原因:数组缓冲区搬迁,迭代器指针失效。

三种安全写法:

  1. 官方一次性 API
todoItems.removeAll { $0 == "B" }
  1. 先记下索引,后删除
let indexesToRemove = todoItems.indices.filter { todoItems[$0] == "B" }
for i in indexesToRemove.reversed() {
    todoItems.remove(at: i)
}
  1. 过滤后整体替换
todoItems = todoItems.filter { $0 != "B" }

AsyncSequence:把“迭代”搬到异步世界

协议定义

public protocol AsyncSequence {
    associatedtype Element
    associatedtype AsyncIterator: AsyncIteratorProtocol where AsyncIterator.Element == Element
    func makeAsyncIterator() -> AsyncIterator
}

public protocol AsyncIteratorProtocol {
    associatedtype Element
    mutating func next() async throws -> Element?
}

消费方式

for await element in stream {
    print(element)          // 会在每次 next() 挂起时让出线程
}

桥接回调式 API 的模板:进度条场景

func makeProgressStream() -> AsyncStream<Double> {
    AsyncStream { continuation in
        let token = ProgressCenter.onUpdate { value in
            continuation.yield(value)
            if value >= 1.0 { continuation.finish() }
        }
        continuation.onTermination = { _ in
            ProgressCenter.removeObserver(token)
        }
    }
}

// 使用
Task {
    for await p in makeProgressStream() {
        progressView.progress = Float(p)
    }
}

自己动手:一个固定容量的 RingBuffer

需求:保持最新 N 条日志,支持 for-in 打印。

struct RingBuffer<Element>: Collection {
    private var storage: [Element?]   // 用 Optional 占位
    private var head = 0
    private var tail = 0
    private(set) var count = 0
    private let capacity: Int
    
    init(capacity: Int) {
        self.capacity = capacity
        storage = Array(repeating: nil, count: capacity)
    }
    
    // 写入新元素,覆盖最旧数据
    mutating func append(_ newElement: Element) {
        storage[tail] = newElement
        tail = (tail + 1) % capacity
        if count == capacity {
            head = (head + 1) % capacity   // 丢弃最旧
        } else {
            count += 1
        }
    }
    
    // MARK: Collection 必备
    typealias Index = Int
    
    var startIndex: Int { 0 }
    var endIndex: Int { count }
    
    func index(after i: Int) -> Int {
        precondition(i < endIndex, "Index out of bounds")
        return i + 1
    }
    
    subscript(position: Int) -> Element {
        precondition((0..<count).contains(position), "Index out of bounds")
        let offset = (head + position) % capacity
        return storage[offset]!
    }
}

// 使用
var buffer = RingBuffer<Int>(capacity: 3)
for i in 1...5 {
    buffer.append(i)   // 1,2,3 → 2,3,4 → 3,4,5
}

for value in buffer {
    print(value)   // 3 4 5
}

总结与扩展场景

  1. 协议层次

IteratorProtocolSequenceCollectionBidirectionalCollectionRandomAccessCollection

每一层只加必要约束,绝不多要一颗糖。

  1. 值语义是 Swift 迭代的灵魂

结构体迭代器复制即“新游标”,避免共享状态,这点与 Objective-C 的 NSEnumerator 形成鲜明对比。

  1. 遍历同时修改的崩溃本质是“迭代器失效”

所有带指针/索引的集合都存在,掌握“先记录后改”或“一次性 API”即可。

  1. AsyncSequence 让“事件流”变成普通 for-in

网络下载、蓝牙数据、用户点击序列都能用同一套思维建模;配合 AsyncStream 几乎零成本桥接老代码。

  1. 自定义 Collection 是架构试金石

RingBuffer 这类小容器写一遍,你会深刻理解“下标换算”、“容量与 count 区别”、“前置条件断言”这些日常被标准库隐藏的细节。

学习资料

  1. www.donnywals.com/a-deep-dive…

告别并发警告:Swift 6 线程安全通知 MainActorMessage & AsyncMessage 实战指南

为什么旧的 NotificationCenter 会“踩坑”

在 Swift Concurrency 时代,即使你把 addObserverqueue 设成 .main,只要闭包里调用了 @MainActor 隔离的函数,编译器依旧会甩出警告:

⚠️ Main actor-isolated property 'xxx' can not be referenced from a non-isolated context

根因:

Notification 默认被标记为 nonisolated,它与 @MainActor 之间没有建立任何“隔离约定”,编译器无法证明线程安全。

新 API 的基石:两个协议

协议 作用 适用场景
MainActorMessage 保证观察回调一定在主线程执行 更新 UI、访问 @MainActor 属性
AsyncMessage 允许在任意隔离域异步投递 后台处理、跨 actor 通信

系统版本要求:iOS / macOS 26+

MainActorMessage 深入拆解

协议定义

public protocol MainActorMessage: SendableMetatype {
    associatedtype Subject
    static var name: Notification.Name { get }

    // 把 Notification 转成当前消息类型
    @MainActor static func makeMessage(_ notification: Notification) -> Self?

    // 把消息转回 Notification(供 post 时使用)
    @MainActor static func makeNotification(_ message: Self) -> Notification
}

关键注解

  • 所有协议要求都在 @MainActor 隔离域内完成,编译期即保证线程安全
  • makeMessage 允许你从 userInfoobject 里取出强类型数据,告别 Any? 强转

系统帮你做好的“现成的”消息类型

UIApplication.didBecomeActiveNotification 为例,Swift 26 已内置:

@available(iOS 26.0, *)
extension NotificationCenter.MessageIdentifier where Self ==
    NotificationCenter.BaseMessageIdentifier<UIApplication.DidBecomeActiveMessage> {

    public static var didBecomeActive: NotificationCenter.BaseMessageIdentifier<UIApplication.DidBecomeActiveMessage> { get }
}

DidBecomeActiveMessage 内部实现:

public struct DidBecomeActiveMessage: NotificationCenter.MainActorMessage {
    public static var name: Notification.Name { UIApplication.didBecomeActiveNotification }

    public typealias Subject = UIApplication

    @MainActor
    public static func makeMessage(_ notification: Notification) -> DidBecomeActiveMessage? {
        // 系统通知不需要额外参数,直接返回空实例即可
        return DidBecomeActiveMessage()
    }

    @MainActor
    public static func makeNotification(_ message: DidBecomeActiveMessage) -> Notification {
        Notification(name: name)
    }
}

观察方式对比

旧写法(仍有并发警告) 新写法(零警告)
NotificationCenter.default.addObserver(forName: .didBecomeActiveNotification, object: nil, queue: .main, using: { ... }) NotificationCenter.default.addObserver(of: UIApplication.self, for: .didBecomeActive) { message in ... }

完整新代码:

final class AppActiveMonitor {
    private var token: NSObjectProtocol?

    func startObserving() {
        // ✅ 闭包自动在 @MainActor 执行
        token = NotificationCenter.default.addObserver(
            of: UIApplication.self,
            for: .didBecomeActive
        ) { [weak self] _ in
            self?.handleDidBecomeActive()
        }
    }

    @MainActor
    private func handleDidBecomeActive() {
        print("✅ 已切换到前台,线程:\(Thread.isMainThread)")
    }
}

投递端(post)也受 @MainActor 限制

@MainActor
func postDidBecomeActive() {
    let message = DidBecomeActiveMessage()
    NotificationCenter.default.post(message, subject: UIApplication.shared)
}

由于 post 方法本身被标记为 @MainActor,系统保证同步投递,即观察闭包会立即在当前主线程执行,与旧 API 的“异步队列投递”行为不同。

迁移时需评估是否会对现有时序产生副作用。

AsyncMessage:脱离主线程的灵活投递

协议定义

public protocol AsyncMessage: SendableMetatype {
    associatedtype Subject
    static var name: Notification.Name { get }

    // 可在任意隔离域调用,支持异步上下文
    static func makeMessage(_ notification: Notification) async -> Self?

    static func makeNotification(_ message: Self) async -> Notification
}

与 MainActorMessage 的核心差异

  1. 没有 @MainActor 限制
  2. 观察闭包为 @Sendable async 形式,可并发执行
  3. 投递方 post 不要求主线程,异步分发

自定义 AsyncMessage 实战

假设 RocketSim 插件需要广播“最近构建列表已更新”:

步骤 1:定义强类型消息

struct RecentBuild {
    let appName: String
}

struct RecentBuildsChangedMessage: NotificationCenter.AsyncMessage {
    typealias Subject = [RecentBuild]   // 把数组本身当 Subject

    let recentBuilds: [RecentBuild]

    // 从旧的 notification.object 取出数据
    static func makeMessage(_ notification: Notification) async -> RecentBuildsChangedMessage? {
        guard let builds = notification.object as? [RecentBuild] else { return nil }
        return RecentBuildsChangedMessage(recentBuilds: builds)
    }

    static func makeNotification(_ message: RecentBuildsChangedMessage) async -> Notification {
        Notification(name: .recentBuildsChanged, object: message.recentBuilds)
    }
}

步骤 2:添加静态成员,提升可读性

extension NotificationCenter.MessageIdentifier where Self ==
    NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> {

    static var recentBuildsChanged: NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> {
        .init()
    }
}

步骤 3:发送端(可在后台线程)

func fetchLatestBuilds() async {
    let builds = await ServerAPI.latestBuilds()
    let message = RecentBuildsChangedMessage(recentBuilds: builds)
    await NotificationCenter.default.post(message)   // 异步投递
}

步骤 4:观察端(支持任意隔离域)

final class BuildListViewModel {
    private var token: NSObjectProtocol?

    func startObserving() {
        token = NotificationCenter.default.addObserver(
            of: [RecentBuild].self,
            for: .recentBuildsChanged
        ) { [weak self] message in
            // ✅ 闭包为 async @Sendable,可并发执行
            await self?.handleNewBuilds(message.recentBuilds)
        }
    }

    @MainActor
    private func handleNewBuilds(_ builds: [RecentBuild]) async {
        // 回到主线程刷新 UI
        self.builds = builds
    }
}

知识速查表

特性 MainActorMessage AsyncMessage
回调线程 主线程(同步) 任意(异步)
发送方限制 @MainActor 任意隔离域
观察闭包 同步 async @Sendable
是否强类型
是否需要 async 上下文

总结与迁移建议

  1. 优先使用 MainActorMessage

    只要最终需要刷新 UI,就直接选它,编译期强制主线程,再也不用手写 DispatchQueue.main.async

  2. AsyncMessage 适合“纯后台”链路

    例如数据库落地、网络日志上报、跨 actor 通信,不会阻塞主线程。

  3. 逐步替换,而非一刀切

    旧通知可以先封装成新消息类型,双轨并行;观察到无异常后再删除旧代码。

  4. 单元测试更友好

    强类型消息让测试断言不再依赖 userInfo 魔法字符串,可读性↑ 维护性↑。

《Flutter全栈开发实战指南:从零到高级》- 15 -本地数据存储

Flutter本地存储

当我们关闭App再重新打开,为什么有些数据(比如登录状态、用户设置、文章草稿)还在,而有些数据(比如临时弹窗状态)却消失了?这背后就是 “本地存储” 在发挥作用。可以说,一个不懂得如何管理本地数据的开发者,很难做出用户体验好的应用。

今天,我们就来彻底搞懂Flutter中的本地存储。


一、 为什么需要本地存储?

举个例子:如果你每天醒来都会失忆,不记得自己的名字、家在哪里、昨天做了什么……这简直是一场灾难。对于App而言,本地存储就是它的 “记忆系统”

主要应用场景:

  1. 用户偏好设置:比如主题颜色、语言选择、消息提醒开关。
  2. 登录状态保持:用户登录后,App“记住”他,下次打开无需重新登录。
  3. 缓存网络数据:将首屏数据缓存下来,下次启动秒开,提升用户体验。
  4. 离线数据持久化:如笔记草稿、阅读进度、购物车商品,即使断网也不丢失。
  5. 大数据量结构化存储:比如聊天记录、交易明细等。

Flutter拥有多种本地数据存储方案,下面我们先看下用张图来了解下存储方案脉络:

image.png

二、 shared_preferences

2.1 它是什么?能干什么?

shared_preferences 这个名字听起来有点拗口,但其实很简单。你可以把它理解成 Flutter 为我们在本地提供的一个 “小本子”,专门用来记录一些简单的、键值对形式的数据。

  • shared(共享):指这些数据在你的App内是共享的,任何页面都能读写。
  • preferences(偏好):顾名思义,最适合存储用户的偏好设置。

它的本质是什么? 在 Android 上,它背后是通过 SharedPreferences API 将数据以 XML 文件形式存储;在 iOS 上,则使用的是 NSUserDefaults。Flutter 插件帮我们统一了这两端的接口,让我们可以用一套代码搞定双端存储。

2.2 工作原理图解

让我们看看当你调用 setString('name', '一一') 时,背后发生了什么:

sequenceDiagram
    participant A as Flutter App
    participant SP as shared_preferences插件
    participant M as Method Channel
    participant AOS as Android (SharedPreferences)
    participant IOS as iOS (NSUserDefaults)

    A->>SP: 调用 setString('name', '一一')
    SP->>M: 通过Method Channel调用原生代码
    M->>AOS: (在Android上) 写入XML文件
    M->>IOS: (在iOS上) 写入plist文件
    AOS-->>M: 写入成功
    IOS-->>M: 写入成功
    M-->>SP: 返回结果
    SP-->>A: 返回 Future<bool> (true)

关键点:

  • 异步操作:所有读写操作都是 Future,意味着不会阻塞你的UI线程。
  • 持久化:数据被写入设备文件系统,App重启后依然存在。
  • 平台差异被屏蔽:你不需要关心底层是XML还是plist,插件帮你处理了。

2.3 下面用一段代码来详细介绍下

第一步:引入依赖pubspec.yaml 文件中添加:

dependencies:
  shared_preferences: ^2.2.2 # 请使用最新版本

然后运行 flutter pub get

第二步:基础CRUD操作

import 'package:shared_preferences/shared_preferences.dart';

class SPManager {
  // 单例
  static final SPManager _instance = SPManager._internal();
  factory SPManager() => _instance;
  SPManager._internal();

  late SharedPreferences _prefs;

  // 初始化
  Future<void> init() async {
    _prefs = await SharedPreferences.getInstance();
    print('SharedPreferences 初始化完成!');
  }

  // 1. 写入数据
  Future<bool> saveUserInfo() async {
    try {
      // 字符串
      await _prefs.setString('username', 'Flutter本地存储');
      // 整型
      await _prefs.setInt('userAge', 28);
      // 布尔值
      await _prefs.setBool('isVip', true);
      // 字符串列表
      await _prefs.setStringList('hobbies', ['编程', '读书', '健身']);
      // 双精度浮点数
      await _prefs.setDouble('walletBalance', 99.99);

      print('用户信息保存成功!');
      return true;
    } catch (e) {
      print('保存失败: $e');
      return false;
    }
  }

  // 2. 读取数据
  void readUserInfo() {
    // 读取字符串,提供默认值
    String username = _prefs.getString('username') ?? '未知用户';
    int age = _prefs.getInt('userAge') ?? 0;
    bool isVip = _prefs.getBool('isVip') ?? false;
    double balance = _prefs.getDouble('walletBalance') ?? 0.0;
    List<String> hobbies = _prefs.getStringList('hobbies') ?? [];

    print('''
      用户信息:
      用户名:$username
      年龄:$age
      VIP:$isVip
      余额:$balance
      爱好:$hobbies
    ''');
  }

  // 3. 删除数据
  Future<bool> deleteUserInfo() async {
    try {
      // 删除指定键
      await _prefs.remove('username');
      // 清空所有数据
      // await _prefs.clear();
      print('用户信息已删除');
      return true;
    } catch (e) {
      print('删除失败: $e');
      return false;
    }
  }

  // 4. 检查键是否存在
  bool containsKey(String key) {
    return _prefs.containsKey(key);
  }

  // 5. 获取所有键
  Set<String> getAllKeys() {
    return _prefs.getKeys();
  }
}

第三步:在App中使用

void main() async {
  // 确保WidgetsBinding初始化
  WidgetsFlutterBinding.ensureInitialized();
  
  // 初始化SPManager
  await SPManager().init();
  
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final SPManager _spManager = SPManager();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('SP演示')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => _spManager.saveUserInfo(),
              child: Text('保存用户信息'),
            ),
            ElevatedButton(
              onPressed: () => _spManager.readUserInfo(),
              child: Text('读取用户信息'),
            ),
            ElevatedButton(
              onPressed: () => _spManager.deleteUserInfo(),
              child: Text('删除用户信息'),
            ),
          ],
        ),
      ),
    );
  }
}

2.4 使用介绍与注意点

  1. 一定要先初始化:在使用前必须调用 getInstance() 并等待其完成。
  2. 处理空值:读取时一定要提供默认值,因为键可能不存在。
  3. 不要存大数据:它不适合存储大型对象或列表,性能会变差。
  4. 键名管理:建议使用常量来管理键名,避免拼写错误。
    class SPKeys {
      static const String username = 'username';
      static const String userAge = 'user_age';
      static const String isVip = 'is_vip';
    }
    
  5. 异步错误处理:使用 try-catch 包裹可能出错的操作。

三、 文件存储

3.1 适用场景

当你的数据不适合用键值对存储时,文件存储就派上用场了:

  • App的配置文件(JSON, XML)
  • 用户下载的图片、文档
  • 应用日志文件
  • 需要自定义格式的数据

3.2 文件系统路径详解

在Flutter中,我们使用 path_provider 插件来获取各种路径:

import 'package:path_provider/path_provider.dart';

class FilePathManager {
  // 获取临时目录
  static Future<String> get tempPath async {
    final dir = await getTemporaryDirectory();
    return dir.path;
  }

  // 获取文档目录(Android对应App专用目录,iOS对应Documents)
  static Future<String> get documentsPath async {
    final dir = await getApplicationDocumentsDirectory();
    return dir.path;
  }

  // 获取外部存储目录
  static Future<String?> get externalStoragePath async {
    final dir = await getExternalStorageDirectory();
    return dir?.path;
  }

  // 获取支持目录
  static Future<String> get supportPath async {
    final dir = await getApplicationSupportDirectory();
    return dir.path;
  }
}

路径选择:

  • 临时文件getTemporaryDirectory() - 缓存,系统可清理
  • 用户数据getApplicationDocumentsDirectory() - 用户生成的内容
  • App内部文件getApplicationSupportDirectory() - App运行所需文件

3.3 文件存储实战演示

一个完整的文件管理类如下代码所示:

import 'dart:io';
import 'dart:convert';
import 'package:path_provider/path_provider.dart';

class FileManager {
  // 单例
  static final FileManager _instance = FileManager._internal();
  factory FileManager() => _instance;
  FileManager._internal();

  // 获取文件路径
  Future<String> _getLocalFilePath(String filename) async {
    final dir = await getApplicationDocumentsDirectory();
    return '${dir.path}/$filename';
  }

  // 1. 写入字符串到文件
  Future<File> writeStringToFile(String content, String filename) async {
    try {
      final file = File(await _getLocalFilePath(filename));
      return await file.writeAsString(content);
    } catch (e) {
      print('写入文件失败: $e');
      rethrow;
    }
  }

  // 2. 从文件读取字符串
  Future<String> readStringFromFile(String filename) async {
    try {
      final file = File(await _getLocalFilePath(filename));
      if (await file.exists()) {
        return await file.readAsString();
      } else {
        throw Exception('文件不存在');
      }
    } catch (e) {
      print('读取文件失败: $e');
      rethrow;
    }
  }

  // 3. 写入JSON对象
  Future<File> writeJsonToFile(Map<String, dynamic> json, String filename) async {
    final jsonString = jsonEncode(json);
    return await writeStringToFile(jsonString, filename);
  }

  // 4. 从文件读取JSON对象
  Future<Map<String, dynamic>> readJsonFromFile(String filename) async {
    try {
      final jsonString = await readStringFromFile(filename);
      return jsonDecode(jsonString);
    } catch (e) {
      print('读取JSON失败: $e');
      rethrow;
    }
  }

  // 5. 增加内容到文件
  Future<File> appendToFile(String content, String filename) async {
    try {
      final file = File(await _getLocalFilePath(filename));
      return await file.writeAsString(content, mode: FileMode.append);
    } catch (e) {
      print('追加文件失败: $e');
      rethrow;
    }
  }

  // 6. 检查文件是否存在
  Future<bool> fileExists(String filename) async {
    final file = File(await _getLocalFilePath(filename));
    return await file.exists();
  }

  // 7. 删除文件
  Future<void> deleteFile(String filename) async {
    try {
      final file = File(await _getLocalFilePath(filename));
      if (await file.exists()) {
        await file.delete();
        print('文件删除成功: $filename');
      }
    } catch (e) {
      print('删除文件失败: $e');
      rethrow;
    }
  }

  // 8. 获取文件信息
  Future<FileStat> getFileInfo(String filename) async {
    try {
      final file = File(await _getLocalFilePath(filename));
      if (await file.exists()) {
        return await file.stat();
      } else {
        throw Exception('文件不存在');
      }
    } catch (e) {
      print('获取文件信息失败: $e');
      rethrow;
    }
  }
}

3.4 以用户配置管理为例:

class UserConfigManager {
  static const String _configFileName = 'user_config.json';
  final FileManager _fileManager = FileManager();

  // 保存用户配置
  Future<void> saveUserConfig({
    required String theme,
    required String language,
    required bool darkMode,
    required List<String> recentSearches,
  }) async {
    final config = {
      'theme': theme,
      'language': language,
      'darkMode': darkMode,
      'recentSearches': recentSearches,
      'lastUpdated': DateTime.now().toIso8601String(),
    };

    await _fileManager.writeJsonToFile(config, _configFileName);
    print('用户配置已保存');
  }

  // 读取用户配置
  Future<Map<String, dynamic>> loadUserConfig() async {
    try {
      if (await _fileManager.fileExists(_configFileName)) {
        return await _fileManager.readJsonFromFile(_configFileName);
      } else {
        // 返回默认配置
        return _getDefaultConfig();
      }
    } catch (e) {
      print('加载用户配置失败,使用默认配置: $e');
      return _getDefaultConfig();
    }
  }

  Map<String, dynamic> _getDefaultConfig() {
    return {
      'theme': 'light',
      'language': 'zh-CN',
      'darkMode': false,
      'recentSearches': [],
      'lastUpdated': DateTime.now().toIso8601String(),
    };
  }

  // 清空配置
  Future<void> clearConfig() async {
    await _fileManager.deleteFile(_configFileName);
  }
}

四、 SQLite

4.1 什么是SQLite?为什么需要它?

SQLite是一个轻量级的、文件式的关系型数据库。它不需要单独的服务器进程,整个数据库就是一个文件,非常适合移动端应用。

使用场景:

  • 用户关系管理(联系人、好友)
  • 商品目录、订单管理
  • 聊天消息记录
  • 任何需要复杂查询和关系的数据

4.2 Flutter中的SQLite架构

在Flutter中,我们通常使用 sqflite 插件来操作SQLite:

graph TB
    A[Flutter App] --> B[sqflite插件]
    B --> C[Method Channel]
    C --> D[Android: SQLiteDatabase]
    C --> E[iOS: SQLite3 Library]
    D --> F[.db文件]
    E --> F
    F --> G[数据持久化]

4.3 构建一个任务管理App

第一步:添加依赖

dependencies:
  sqflite: ^2.3.0
  path: ^1.8.3

第二步:创建数据库工具类

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static final DatabaseHelper _instance = DatabaseHelper._internal();
  factory DatabaseHelper() => _instance;
  DatabaseHelper._internal();

  static Database? _database;

  // 数据库名称和版本
  static const String _dbName = 'task_manager.db';
  static const int _dbVersion = 1;

  // 表名和列名
  static const String tableTasks = 'tasks';
  static const String columnId = 'id';
  static const String columnTitle = 'title';
  static const String columnDescription = 'description';
  static const String columnIsCompleted = 'is_completed';
  static const String columnCreatedAt = 'created_at';
  static const String columnUpdatedAt = 'updated_at';

  // 获取数据库实例
  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }

  // 初始化数据库
  Future<Database> _initDatabase() async {
    // 获取数据库路径
    String path = join(await getDatabasesPath(), _dbName);
    
    // 创建/打开数据库
    return await openDatabase(
      path,
      version: _dbVersion,
      onCreate: _createTables,
      onUpgrade: _upgradeDatabase,
    );
  }

  // 创建表
  Future<void> _createTables(Database db, int version) async {
    await db.execute('''
      CREATE TABLE $tableTasks (
        $columnId INTEGER PRIMARY KEY AUTOINCREMENT,
        $columnTitle TEXT NOT NULL,
        $columnDescription TEXT,
        $columnIsCompleted INTEGER NOT NULL DEFAULT 0,
        $columnCreatedAt INTEGER NOT NULL,
        $columnUpdatedAt INTEGER NOT NULL
      )
    ''');
    print('任务表创建成功!');
  }

  // 数据库升级
  Future<void> _upgradeDatabase(Database db, int oldVersion, int newVersion) async {
    if (oldVersion < 2) {
      // await db.execute('ALTER TABLE $tableTasks ADD COLUMN new_column TEXT');
    }
    print('数据库从版本 $oldVersion 升级到 $newVersion');
  }

  // 关闭数据库
  Future<void> close() async {
    if (_database != null) {
      await _database!.close();
      _database = null;
    }
  }
}

第三步:创建数据模型

class Task {
  int? id;
  String title;
  String? description;
  bool isCompleted;
  DateTime createdAt;
  DateTime updatedAt;

  Task({
    this.id,
    required this.title,
    this.description,
    this.isCompleted = false,
    DateTime? createdAt,
    DateTime? updatedAt,
  })  : createdAt = createdAt ?? DateTime.now(),
        updatedAt = updatedAt ?? DateTime.now();

  // 将Task对象转换为Map,便于存入数据库
  Map<String, dynamic> toMap() {
    return {
      DatabaseHelper.columnId: id,
      DatabaseHelper.columnTitle: title,
      DatabaseHelper.columnDescription: description,
      DatabaseHelper.columnIsCompleted: isCompleted ? 1 : 0,
      DatabaseHelper.columnCreatedAt: createdAt.millisecondsSinceEpoch,
      DatabaseHelper.columnUpdatedAt: updatedAt.millisecondsSinceEpoch,
    };
  }

  // 从Map创建Task对象
  factory Task.fromMap(Map<String, dynamic> map) {
    return Task(
      id: map[DatabaseHelper.columnId],
      title: map[DatabaseHelper.columnTitle],
      description: map[DatabaseHelper.columnDescription],
      isCompleted: map[DatabaseHelper.columnIsCompleted] == 1,
      createdAt: DateTime.fromMillisecondsSinceEpoch(
          map[DatabaseHelper.columnCreatedAt]),
      updatedAt: DateTime.fromMillisecondsSinceEpoch(
          map[DatabaseHelper.columnUpdatedAt]),
    );
  }

  @override
  String toString() {
    return 'Task{id: $id, title: $title, completed: $isCompleted}';
  }
}

第四步:创建数据访问对象

class TaskDao {
  final DatabaseHelper _dbHelper = DatabaseHelper();

  // 1. 插入新任务
  Future<int> insertTask(Task task) async {
    final db = await _dbHelper.database;
    
    // 更新时间戳
    task.updatedAt = DateTime.now();
    
    final id = await db.insert(
      DatabaseHelper.tableTasks,
      task.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
    
    print('任务创建成功,ID: $id');
    return id;
  }

  // 2. 根据ID查询任务
  Future<Task?> getTaskById(int id) async {
    final db = await _dbHelper.database;
    
    final maps = await db.query(
      DatabaseHelper.tableTasks,
      where: '${DatabaseHelper.columnId} = ?',
      whereArgs: [id],
    );
    
    if (maps.isNotEmpty) {
      return Task.fromMap(maps.first);
    }
    return null;
  }

  // 3. 查询所有任务
  Future<List<Task>> getAllTasks() async {
    final db = await _dbHelper.database;
    
    final maps = await db.query(
      DatabaseHelper.tableTasks,
      orderBy: '${DatabaseHelper.columnCreatedAt} DESC',
    );
    
    return maps.map((map) => Task.fromMap(map)).toList();
  }

  // 4. 查询未完成的任务
  Future<List<Task>> getIncompleteTasks() async {
    final db = await _dbHelper.database;
    
    final maps = await db.query(
      DatabaseHelper.tableTasks,
      where: '${DatabaseHelper.columnIsCompleted} = ?',
      whereArgs: [0],
      orderBy: '${DatabaseHelper.columnCreatedAt} DESC',
    );
    
    return maps.map((map) => Task.fromMap(map)).toList();
  }

  // 5. 更新任务
  Future<int> updateTask(Task task) async {
    final db = await _dbHelper.database;
    
    // 更新修改时间
    task.updatedAt = DateTime.now();
    
    final count = await db.update(
      DatabaseHelper.tableTasks,
      task.toMap(),
      where: '${DatabaseHelper.columnId} = ?',
      whereArgs: [task.id],
    );
    
    if (count > 0) {
      print('任务更新成功: ${task.title}');
    }
    
    return count;
  }

  // 6. 删除任务
  Future<int> deleteTask(int id) async {
    final db = await _dbHelper.database;
    
    final count = await db.delete(
      DatabaseHelper.tableTasks,
      where: '${DatabaseHelper.columnId} = ?',
      whereArgs: [id],
    );
    
    if (count > 0) {
      print('任务删除成功, ID: $id');
    }
    
    return count;
  }

  // 7. 批量操作
  Future<void> batchInsertTasks(List<Task> tasks) async {
    final db = await _dbHelper.database;
    
    final batch = db.batch();
    
    for (final task in tasks) {
      batch.insert(DatabaseHelper.tableTasks, task.toMap());
    }
    
    await batch.commit();
    print('批量插入 ${tasks.length} 个任务成功');
  }

  // 8. 复杂查询:搜索任务
  Future<List<Task>> searchTasks(String keyword) async {
    final db = await _dbHelper.database;
    
    final maps = await db.query(
      DatabaseHelper.tableTasks,
      where: '''
        ${DatabaseHelper.columnTitle} LIKE ? OR 
        ${DatabaseHelper.columnDescription} LIKE ?
      ''',
      whereArgs: ['%$keyword%', '%$keyword%'],
      orderBy: '${DatabaseHelper.columnCreatedAt} DESC',
    );
    
    return maps.map((map) => Task.fromMap(map)).toList();
  }

  // 9. 事务操作
  Future<void> markAllAsCompleted() async {
    final db = await _dbHelper.database;
    
    await db.transaction((txn) async {
      await txn.update(
        DatabaseHelper.tableTasks,
        {
          DatabaseHelper.columnIsCompleted: 1,
          DatabaseHelper.columnUpdatedAt: DateTime.now().millisecondsSinceEpoch,
        },
      );
    });
    
    print('所有任务标记为完成');
  }
}

第五步:在UI中使用

class TaskListPage extends StatefulWidget {
  @override
  _TaskListPageState createState() => _TaskListPageState();
}

class _TaskListPageState extends State<TaskListPage> {
  final TaskDao _taskDao = TaskDao();
  List<Task> _tasks = [];
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadTasks();
  }

  Future<void> _loadTasks() async {
    setState(() => _isLoading = true);
    
    try {
      final tasks = await _taskDao.getAllTasks();
      setState(() => _tasks = tasks);
    } catch (e) {
      print('加载任务失败: $e');
      // 错误提示
    } finally {
      setState(() => _isLoading = false);
    }
  }

  Future<void> _addTask() async {
    final newTask = Task(
      title: '新任务 ${DateTime.now().second}',
      description: '这是一个新任务的描述',
    );
    
    await _taskDao.insertTask(newTask);
    await _loadTasks(); // 重新加载列表
  }

  Future<void> _toggleTaskCompletion(Task task) async {
    task.isCompleted = !task.isCompleted;
    await _taskDao.updateTask(task);
    await _loadTasks();
  }

  Future<void> _deleteTask(Task task) async {
    if (task.id != null) {
      await _taskDao.deleteTask(task.id!);
      await _loadTasks();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('任务管理器 (${_tasks.length})'),
        actions: [
          IconButton(
            icon: Icon(Icons.add),
            onPressed: _addTask,
          ),
        ],
      ),
      body: _isLoading
          ? Center(child: CircularProgressIndicator())
          : _tasks.isEmpty
              ? Center(child: Text('还没有任务,点击+号添加吧!'))
              : ListView.builder(
                  itemCount: _tasks.length,
                  itemBuilder: (context, index) {
                    final task = _tasks[index];
                    return Dismissible(
                      key: Key(task.id.toString()),
                      background: Container(color: Colors.red),
                      onDismissed: (_) => _deleteTask(task),
                      child: ListTile(
                        leading: Checkbox(
                          value: task.isCompleted,
                          onChanged: (_) => _toggleTaskCompletion(task),
                        ),
                        title: Text(
                          task.title,
                          style: TextStyle(
                            decoration: task.isCompleted
                                ? TextDecoration.lineThrough
                                : TextDecoration.none,
                          ),
                        ),
                        subtitle: Text(
                          task.description ?? '暂无描述',
                          maxLines: 1,
                          overflow: TextOverflow.ellipsis,
                        ),
                        trailing: Text(
                          DateFormat('MM-dd HH:mm').format(task.createdAt),
                          style: TextStyle(fontSize: 12, color: Colors.grey),
                        ),
                      ),
                    );
                  },
                ),
    );
  }
}

五、 性能优化

5.1 数据库迁移

当你的数据结构需要变更时,就需要数据库迁移:

class DatabaseHelper {
  static const int _dbVersion = 2; // 版本升级
  
  Future<void> _upgradeDatabase(Database db, int oldVersion, int newVersion) async {
    for (int version = oldVersion + 1; version <= newVersion; version++) {
      switch (version) {
        case 2:
          await _migrateToV2(db);
          break;
        case 3:
          await _migrateToV3(db);
          break;
      }
    }
  }

  Future<void> _migrateToV2(Database db) async {
    // 添加优先级字段
    await db.execute('''
      ALTER TABLE ${DatabaseHelper.tableTasks} 
      ADD COLUMN priority INTEGER NOT NULL DEFAULT 0
    ''');
    print('数据库迁移到版本2成功');
  }

  Future<void> _migrateToV3(Database db) async {
    // 创建新表或更复杂的迁移
    await db.execute('''
      CREATE TABLE categories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        color TEXT NOT NULL
      )
    ''');
    print('数据库迁移到版本3成功');
  }
}

5.2 使用ORM简化操作

虽然直接使用SQL很强大,但ORM可以让代码更简洁。推荐 floormoor

dependencies:
  floor: ^1.4.0
  sqflite: ^2.0.0

5.3 优化技巧

  1. 使用索引:对经常查询的字段创建索引
  2. 批量操作:使用 batch() 进行批量插入/更新
  3. 连接池:保持数据库连接,避免频繁开关
  4. 分页查询:大数据集使用 LIMITOFFSET
  5. 避免N+1查询:使用 JOIN 一次性获取关联数据
// 分页查询
Future<List<Task>> getTasksPaginated(int page, int pageSize) async {
  final db = await _dbHelper.database;
  final offset = (page - 1) * pageSize;
  
  final maps = await db.query(
    DatabaseHelper.tableTasks,
    limit: pageSize,
    offset: offset,
    orderBy: '${DatabaseHelper.columnCreatedAt} DESC',
  );
  
  return maps.map((map) => Task.fromMap(map)).toList();
}

六、 方案对比

下面我们通过多维度对以上几种本地存储方案进行一个详细对比:

维度 shared_preferences 文件存储 SQLite Hive
数据类型 基本类型 任意数据 结构化数据 任意对象
查询能力 键值查询 顺序读取 复杂SQL查询 键值+条件查询
性能 中等 快(有索引) 非常快
复杂度 简单 中等 复杂 简单
数据量 小(<1MB) 中等
是否需要序列化 需要 需要 需要 不需要

实际项目开发中,我们如何选择本地存储?可按下面策略进行存储方案选型:

graph TD
    A[开始选型] --> B{数据量大小};
    B -->|很小 < 1MB| C{数据类型};
    B -->|中等| D[文件存储];
    B -->|很大| E{是否需要复杂查询};
    
    C -->|简单键值对| F[shared_preferences];
    C -->|复杂对象| G[Hive];
    
    E -->|是| H[SQLite];
    E -->|否| G;
    
    F --> I[完成];
    D --> I;
    G --> I;
    H --> I;

以上选型策略概述以下:

  1. 用户设置、登录令牌shared_preferences
  2. App配置、日志文件 → 文件存储
  3. 聊天记录、商品目录 → SQLite
  4. 缓存数据、临时状态 → Hive
  5. 需要极致性能 → Hive
  6. 需要复杂关系查询 → SQLite

七、 综合应用代码实战

下面我们构建一个完整的用户数据管理方案,综合运用多种存储方式:

class UserDataManager {
  final SPManager _spManager = SPManager();
  final FileManager _fileManager = FileManager();
  final TaskDao _taskDao = TaskDao();
  
  // 1. 用户登录状态 - 使用shared_preferences
  Future<void> saveLoginState(User user) async {
    await _spManager.init();
    await _spManager._prefs.setString('user_id', user.id);
    await _spManager._prefs.setString('user_token', user.token);
    await _spManager._prefs.setBool('is_logged_in', true);
    
    // 同时保存用户信息到SQLite
    // await _userDao.insertUser(user);
  }
  
  // 2. 用户偏好设置 - 使用文件存储
  Future<void> saveUserPreferences(UserPreferences prefs) async {
    await _fileManager.writeJsonToFile(
      prefs.toJson(), 
      'user_preferences.json'
    );
  }
  
  // 3. 用户任务数据 - 使用SQLite
  Future<void> syncUserTasks(List<Task> tasks) async {
    await _taskDao.batchInsertTasks(tasks);
  }
  
  // 4. 清理所有用户数据
  Future<void> clearAllUserData() async {
    // 清理SP
    await _spManager._prefs.clear();
    
    // 清理配置文件
    await _fileManager.deleteFile('user_preferences.json');
    
    // 清理数据库
    // await _taskDao.deleteAllTasks();
  }
}

写在最后

至此本地数据存储的知识就全学完了,记住这几个核心要点:

  1. 性能意识:大数据量时考虑索引、分页、批量操作
  2. 错误处理:存储操作可能失败,一定要有完善的错误处理
  3. 数据安全:敏感信息考虑加密存储
  4. 测试验证:数据库迁移等复杂操作要充分测试

本地存储是App开发的基础,掌握好它,就能开发出体验流畅、数据可靠的应用。希望这篇文章能帮助到你! 我们下期再见!

【SwiftUI 任务身份】task(id:) 如何正确响应依赖变化

为什么 .task 默认不会“跟着变量跑”

在 UIKit 时代,我们手动 addObserverremoveObserver,一不小心就忘记移除。

SwiftUI 带来了“自动依赖追踪”:只要 body 里读到的 @State@ObservedObject 发生变化,body 会被重新求值。

然而,这个“自动”仅限于 body 的求值本身,并不包括你在 .task(或 .onAppear)里写的异步闭包。

换句话说:.task 闭包里用到的任何变量,都不会自动成为“重新触发”的触发器。

它只会在“视图第一次出现”时跑一次,之后即使变量变了,闭包也不会再执行。

一个“看似正常”却永远不会刷新的例子

// 图片加载器
struct ImageLoader: View {
    var url: URL                                    // 外部传进来的 URL
    @State private var loaded: NSImage? = nil       // 下载好的图片
    
    var body: some View {
        ZStack {
            if let loaded {
                Image(nsImage: loaded)               // 显示图片
            } else {
                Text("Loading…")                     // 加载中占位
            }
        }
        // ⚠️ 只会在第一次出现时执行一次
        .task {
            // 即使 url 后来变了,这里也不会再跑
            guard let data = try? await URLSession.shared.data(from: url).0 else { return }
            loaded = NSImage(data: data)
        }
    }
}

// 在 ContentView 里随机切换高度
struct ContentView: View {
    @State private var height = 300
    
    var body: some View {
        ImageLoader(url: URL(string: "https://picsum.photos/200/\(height)")!)
            .onTapGesture {
                height = height == 300 ? 200 : 300   // 点击后 URL 已变
            }
    }
}

运行结果:

点击后 body 确实重新求值,但图片不会重新加载,因为 .task 闭包没再跑。

给任务一个“身份证”——task(id:)

把“依赖”显式地告诉 SwiftUI:只要 id 的值发生变化,旧任务会被自动取消,新任务会被重新创建。

.task(id: url) {   // url 就是身份证
    guard let data = try? await URLSession.shared.data(from: url).0 else { return }
    loaded = NSImage(data: data)
}

就这么简单,却解决了“变量变而任务不变”的痛点。

多依赖怎么办?——构造“复合身份证”

当任务同时依赖多个值时,需要把它们打包成一个可比较(Equatable) 的整体。

方法 1:用数组 [AnyHashable]

struct ImageLoader: View {
    @Environment(\.baseURL) private var baseURL   // 环境值
    var path: String                              // 外部属性
    
    @State private var loaded: NSImage? = nil
    
    var body: some View {
        ZStack {
            if let loaded { Image(nsImage: loaded) }
            else { Text("Loading…") }
        }
        // 把两个依赖一起放进数组,作为复合 id
        .task(id: [baseURL, path] as [AnyHashable]) {
            let url = baseURL.appendingPathComponent(path)
            guard let data = try? await URLSession.shared.data(from: url).0 else { return }
            loaded = NSImage(data: data)
        }
    }
}

方法 2:抽成一个计算属性,只要属性本身 Equatable

var fullURL: URL {
    baseURL.appendingPathComponent(path)
}

.task(id: fullURL) { /* ... */ }

如果你更喜欢 .onAppear 风格

.task 本质是“带生命周期的异步闭包”。

如果你只想用 .onAppear + .onChange,也可以手动模拟:

.onAppear {
    load()          // 首次加载
}
.onChange(of: url) { _ in
    load()          // url 变化时再加载
}

func load() {
    Task {
        guard let data = try? await URLSession.shared.data(from: url).0 else { return }
        loaded = NSImage(data: data)
    }
}

但官方更推荐统一用 .task(id:),因为:

  1. 自动取消旧任务,防止网络回调“串台”;
  2. 代码更短,逻辑集中。

Apple 为何不做“全自动”追踪?

SwiftUI 工程经理 Matt Ricketson 在 Mastodon 的回应:

  1. 自动追踪副作用闭包,极易产生依赖环,且难以调试;
  2. 有些任务本就只想跑一次,例如 for await 持续监听;
  3. 有时读写的是缓存值,不应参与追踪;

因此,把“决定权”交给开发者,是最安全、最灵活的做法。

总结与实战建议

  1. 看到 .task { ... } 先问自己:闭包里用到了哪些外部变量?

    只要有一个变量可能变化且希望任务重新执行,就给它一个 id

  2. 多依赖就打包成一个 Equatable 值,别写多个 .task,否则顺序与取消策略会变得不可控。

  3. 如果任务里需要持续监听(如 AsyncSequence),记得在 task 内部手动 for await,此时 id 只用于“配置变化时重启”,而不是“每条消息都重启”。

  4. .refreshable.searchable 搭配时,同样可以用 task(id:) 实现“搜索关键字变化即重新拉取”。

  5. 单元测试:

    ViewInspector 之类的框架,可以直接断言 task(id:) 的 Equatable 值,确保依赖组合正确。

扩展场景:搜索 + 分页 + 自动取消

struct SearchResultView: View {
    @State private var keyword = ""
    @State private var page = 1
    @State private var items: [Item] = []
    
    var body: some View {
        List(items) { item in
            Text(item.title)
        }
        .searchable(text: $keyword)
        .task(id: compoundID) {           // 关键字或页码任一变化都重新抓取
            let newItems = await API.search(keyword: keyword, page: page)
            items = page == 1 ? newItems : items + newItems  // 分页拼接
        }
        .onTapGesture {
            page += 1                       // 点击加载下一页
        }
    }
    
    // 复合身份证:搜索词 + 页码
    private var compoundID: [AnyHashable] {
        [keyword, page]
    }
}

好处:

  • 用户快速输入关键词时,旧请求会被自动取消,避免“先发出的请求后返回”导致列表错乱;
  • 页码变化也能自动续载,逻辑统一在一处。

一句话收束

.task(id:) 就是 SwiftUI 世界的 useEffect(callback, [dep1, dep2])——

把“依赖数组”显式地写在代码里,才能让副作用真正随依赖而舞,而不是“跑了一次就躺平”。

记住:任务也有身份,依赖变化就换身份证。

学习文章

chris.eidhof.nl/post/swiftu…

精读GitHub - swift-markdown-ui

一、项目介绍

项目地址:github.com/gonzalezrea…

swift-markdown-ui (也称为 MarkdownUI) 是一个用于在 SwiftUI 中显示和自定义 Markdown 文本的开源库。

其主要特性如下:

  1. 强大的 Markdown 支持

它兼容 GitHub 风格的 Markdown 规范(GitHub Flavored Markdown Spec),基本支持所有类型 Markdown 元素,如:普通文本、标题(H1、H2 等)、图片、链接、有序无序列表、任务(Task)、引用、代码块、表格、分割线、加粗斜体等文本样式

  1. 强大的自定义能力

提供了强大的主题(Theming)功能,让开发者可以精细的自定义 Markdown 样式,支持针对特定标签样式(如代码块、链接等)进行覆盖和修改

  1. 易用性

可以直接通过一个 Markdown 字符串来创建一个 Markdown 视图,也可以通过 MarkdownContentBuilder,使用类似 SwiftUI 的 DSL 来构建 Markdown 内容

该项目自 2021 年起,star 数一路飙升,到现在已斩获 3.6K 的 star: 在这里插入图片描述

二、使用介绍

使用方式很简单,可以直接传入通过 Markdown string 构造 UI:

struct TextView: View {
  let content = """
  Hello World
  # Heading 1
  ## Heading 2
  ### Heading 3
   """

  var body: some View {
    DemoView {
      Markdown(self.content)
    }
  }
}

可以通过markdownTextStyle覆盖默认主题样式,甚至通过markdownTheme完全传入一个新的主题:

struct TextView: View {
  let content = """
  Hello World
  # Heading 1
  ## Heading 2
  ### Heading 3
   """

  var body: some View {
    DemoView {
      Markdown(self.content)
      .markdownTheme(CustomTheme())
      
      Markdown(self.content)
      .markdownTextStyle(\.code) {
        FontFamilyVariant(.monospaced)
        BackgroundColor(.yellow.opacity(0.5))
      }
      .markdownTextStyle(\.emphasis) {
        FontStyle(.italic)
        UnderlineStyle(.single)
      }
      .markdownTextStyle(\.strong) {
        FontWeight(.heavy)
      }
    }
  }
}

也可以通过 MarkdownContentBuilder,使用 DSL 的方式构造 UI:

var body: some View {
  Markdown {
    Heading(.level2) {
      "Try MarkdownUI"
    }
    Paragraph {
      Strong("MarkdownUI")
      " is a native Markdown renderer for SwiftUI"
      " compatible with the "
      InlineLink(
        "GitHub Flavored Markdown Spec",
        destination: URL(string: "https://github.github.com/gfm/")!
      )
      "."
    }
  }
}

更多使用方式,可以参考官方 Demo:

三、架构分析

Sources/MarkdownUI/
├── Parser/           # Markdown 解析器
├── DSL/              # 领域特定语言(构建器)
├── Renderer/         # 渲染器
├── Theme/            # 主题系统
├── Views/            # SwiftUI 视图组件
├── Extensibility/    # 扩展性支持(图片提供者、语法高亮)
├── Utility/          # 工具函数
└── Documentation.docc/ # 文档

swift-markdown-ui 的目录结构如上,主要分为四大块:

  1. DSL:Markdown 构建器,提供 MarkdownContentBuilder,支持声明式语法构造 Markdown
  2. Parser:解析器,调用 cmark-gfm 将 Markdown 字符串解析成 BlockNode、InlineNode 节点
  3. Renderer & Views:渲染器,根据解析的节点类型渲染成对应的样式
  4. Theme:主题系统,提供强大的样式覆盖和自定义主题能力

整体流程如下: 在这里插入图片描述

架构分层如下: 在这里插入图片描述

四、源码分析

前面讲了大致的流程图,下面是详细的输入输出及处理过程: 在这里插入图片描述

下面我们将分别对解析、渲染、样式系统进行拆解。

4.1 Markdown 解析

使用三方库 cmark-gfm 进行 Markdown 解析,cmark-gfm 是从标准的 CommonMark 解析器 cmark fork 出来的一个扩展分支,由 GitHub 官方维护,除了 CommonMark 的标准语法外,还支持表格、删除线、任务(Task)、自动链接识别(AutoLink)等特性,通过插件的方式注入。

如下,是使用 cmark-gfm 解析的核心逻辑: 在这里插入图片描述

cmark-gfm 的解析原理是将 Markdown 字符串解析成语法树,外部可以通过遍历语法树来处理每一个节点,Markdown 的语法树可以通过网站 spec.commonmark.org/dingus/ 查看。

如下,一段简单的 Hello World 文本,对应的语法树(AST)如右图,通过 cmark-gfm 我们就能逐级访问 document -> paragarph -> text

在这里插入图片描述 再来看一个稍复杂一点的列表的例子: 在这里插入图片描述

在 swift-markdown-ui 项目中,会将 Markdown 的语法树节点映射成 BlockNode 和 InlineNode,有前端经验的小伙伴应该比较容易理解,BlockNode 对应块级元素,如段落(paragraph),列表(list、item)等,InlineNode 对应行内元素,如文本、图片、链接等

enum BlockNode: Hashable {
  case blockquote(children: [BlockNode])
  case bulletedList(isTight: Bool, items: [RawListItem])
  case numberedList(isTight: Bool, start: Int, items: [RawListItem])
  case taskList(isTight: Bool, items: [RawTaskListItem])
  case codeBlock(fenceInfo: String?, content: String)
  case htmlBlock(content: String)
  case paragraph(content: [InlineNode])
  case heading(level: Int, content: [InlineNode])
  case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow])
  case thematicBreak
}

enum InlineNode: Hashable, Sendable {
  case text(String)
  case softBreak
  case lineBreak
  case code(String)
  case html(String)
  case emphasis(children: [InlineNode])
  case strong(children: [InlineNode])
  case strikethrough(children: [InlineNode])
  case link(destination: String, children: [InlineNode])
  case image(source: String, children: [InlineNode])
}

如下为详细的映射过程:最终解析完成的结果就是一个 [BlockNode]数组

BlockNode解析InlineNode解析

4.2 Markdown 渲染

渲染过程分为 Block 节点处理和 Inline 节点处理。

BlockNode 处理流程如下: 在这里插入图片描述

InlineNode 处理流程如下: 在这里插入图片描述

关键代码: BlockNode 节点渲染InlineNode 节点渲染

每一个 Block 节点都是一个单独的自定义 View,文本节点使用 AttributedString 拼接各种加粗斜体等样式,最终由 Label 进行渲染。

下面我们挑几个难点进行讲解。

4.2.1 文本的加粗斜体下划线删除线样式是怎么实现的

在这里插入图片描述

这些都是使用 iOS 系统能力,配置 AttributeContainer 实现的,支持配置的样式如下: 在这里插入图片描述

4.2.2 引用的样式是怎么实现的

在这里插入图片描述

如上,引用有背景,左边有边框,背景色支持内容撑开,这是怎么做到的?

上面我们有提到每个 Block 节点都是一个单独的自定义 View,引用也是一个自定义 View,如下使用 HStack 将左边框和内容并排,高度靠内容撑开,关键配置是.fixedSize(horizontal: false, vertical: true),其中horizontal: false表示水平方向允许扩展,受父视图宽度约束影响,vertical: true表示垂直方向固定,完全靠内容撑开。

在这里插入图片描述 以此类推,代码块、任务等的样式也可以靠自定义 View 实现。

4.2.3 无序列表序号和任务标识是怎么实现的

在这里插入图片描述

无序列表前面的小圆点/方块,以及任务前面的已完成、待完成标识是怎么实现的呢。

主要代码如下,可以看出是通过 SF Symbols,即系统自带的符号 icon 实现的 无序列表序号任务(Task)

4.2.4 表格的样式是怎么实现的

在 Parser 阶段,table 会被解析成多行结构

enum BlockNode: Hashable {
  ...
  case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow])
}
enum RawTableColumnAlignment: Character {
  case none = "\0"
  case left = "l"
  case center = "c"
  case right = "r"
}
struct RawTableRow: Hashable {
  let cells: [RawTableCell]
}
struct RawTableCell: Hashable {
  let content: [InlineNode]
}

渲染时使用 SwiftUI 中的 Grid 布局实现:Grid 布局天然支持了同行等高、同列等宽、跨行跨列(合并单元格)等特性,不需要复杂配置就能实现表格的效果。 在这里插入图片描述

但是 Grid 布局也有一些局限:

  • Grid 布局不支持滚动,如下当列很多时内容会很窄;更好的做法是嵌套在 ScrollView 中,进行横向滚动
  • 大数据量时可能有性能问题:Grid 布局是非懒加载的,也不存在 Cell 复用,在大数据量时 FPS、内存可能都是挑战 在这里插入图片描述

4.3 自定义样式 & Theme 系统

如下是样式系统的架构图:

在这里插入图片描述

swift-markdown-ui 提供了 basic、github、docC 三种内置主题,在这三个主题的基础上,支持开发者覆盖默认配置,也可以完全自定义一个新的主题传入。

样式通过 SwiftUI 的 Environment,可以很方便的实现自动注入和父子视图数据传递:

在这里插入图片描述

五、广告位

每周精读一个开源项目,文章首发公众号「非专业程序员 Ping」【精读 GitHub Weekly】专集,欢迎订阅 & 投稿!

我开发了一款关于病历管理的app:安康记

上线了!上线了!基于Vibe Coding我终于也能独立开发出一款属于自己的app-安康记,目前终于成功上架App Store啦。

独立开发app这个事情我很早之前就有点念头,但学习Swift过程还挺痛苦的,总觉的各种语法糖很膈应,导致进度缓慢,后面就一直搁置了。ChatGPT出来之后也尝试了一点,但还是觉得当时的AI能力不够,并且纯靠聊天还是不太行,自从Cursor、Windsurf、CC、Codex这一类AI开发工具出来之后,Vibe Coding的概念也随之火热,我想也是时候重启自己的开发计划了,花了接近两个月的晚上和周末时间,终于将其开发完成并上架!

App Store地址: 安康记(apps.apple.com/cn/app/%E5%…

appstore

这个app的念头起源于我自己的需求,作为一个动不动就省点小毛病然后还总是往医院跑的人,其实经常是同一个病或者症状接近,但总是忘了此前是如何治疗的,目前经常去的那家医院有个自己的小程序,能看到很多检查和药物记录,但是没有就诊记录,而且如果有时候去了别的医院,那么这些记录也不能放在一起,网上搜了一些关于病历管理的app,要么UI不喜欢要么交互不喜欢,那么是时候自己整起来了!

首先整体功能设计还是得靠自己,中间可以跟AI沟通让他提一些建议,但核心的功能点还是需要自己来出。这个app的核心思想还是: 病历->就诊->药物/检查。 也就是说当你生病时,可以创建一次当前病情的病历,若去医院就诊,则绑定在该次病历下,去医院可能会有药物处方和检查项目,这些均可以记录在案。

app的设计思路整理好了之后,可以通过AI工具先帮你使用前端工具生成一个app的原型图,然后再基于这个原型去使用Swift来开发,这样对于开发的准确效果会比直接跟他说要可控一点,这也是我初期直接让AI上手干发现效果不太好才采用的方案,也推荐大家可以试试这么干。

另外我本人主力使用的AI编辑器是Windsurf,但到中后期的时候我几乎已经完全替换成了Codex,因为我发现codex编写Swift代码的效果竟然比Windsurf要好,哪怕都使用OpenAI的模型,甚至Windsurf使用Claude,也是codex的效果更好,这个其实有点超出我的认知,我也是中间因为Windsurf的点数用完了,临时使用Codex上阵才发现的,但平时工作中开发Java或者Python时,Windsurf的效果还是感觉挺好的,不怎么会使用Codex,如果有使用Claude Code或者Cursor的朋友,也可以说说自己有没有感觉某个工具对于Swift开发更友好。

安康记目前支持创建多个用户,也就是说可以一个人来管理家中多人的病历,对于家中有老人小孩的朋友们还是很有帮助的(PS:多用户需要会员)。另外对于医院科室和医生的管理,原本计划是直接从网上公开数据集中导入一些当前国内的医院科室和医生,但是发现数据量太大,而且需要维护,所以最终还是选择自己手动输入,可以自己先创建好了再用,也可以创建就诊记录的时候快捷创建。对于药物和检查项目还提供了快捷输入功能方便输入一些如用药途径药品规格等常见的信息(PS:快捷输入也需要会员)。而且app的所有数据存储都是基于本地的,只会通过iCloud同步,因此使用者们都无需担心隐私问题,对我个人而言,不用花钱买服务器也是极好的。

setting

目前这个app还只是一个初版,当前的设计流程还欠缺几个功能,包括个人独立用药,即生病了也不一定去医院,可能自己在家吃一些药物,这些药物也应当可以直接记录而不是通过就诊记录。此外这个app还应当会支持体检项目,即专门记录个人阶段性的体检报告。这些功能会放在接下来的版本陆续去更新,另外现在的UI我其实谈不上多满意,但一直没找到好的方向去优化,后面也会继续尝试提升UI上的体验。

下个版本预计会在2-4周左右推出,公众号也会同步推送更新日志,后续也可能会公开需求池和开发计划,希望大家可以下载app进行体验,有什么需求或者建议可以通过留言或在app中的意见反馈中提出。谢谢大家 ღ( ´・ᴗ・` )。

Rust RefCell 多线程读为什么也panic了?

这是最近实战中遇到的一个小知识点,没理解之前觉得「不可能」,反应过来之后,觉得自己很蠢🤣,借本文记录下。

看一段复现代码:

struct MyRefCell<T>(RefCell<T>);

unsafe impl<T> Sync for MyRefCell<T> {}

fn main() {
    let shared = Arc::new(MyRefCell(RefCell::new(0usize)));

    let mut handles = Vec::new();
    for i in 0..100 {
        let s = shared.clone();
        handles.push(thread::spawn(move || {
            thread::sleep(Duration::from_millis(10 * (i % 3) as u64));
            let r = s.0.borrow();
            let r = s.0.borrow_mut();
            println!("thread {} read {}", i, *r);
        }));
    }

    for h in handles {
        let _ = h.join();
    }

    println!("done");
}

多线程读一个RefCell封装的变量,却发生了panic,原因是:**already mutably borrowed: BorrowError**

即 RefCell修饰的变量在borrow时检测到已经borrow_mut了,但是代码里其实没有borrow_mut的地方,就很神奇。

另一个迷惑的地方是,多线程读变量居然也是不安全的,也会panic。

或许有小伙伴不理解RefCell,这里简单介绍下:

Rust的借用检查一般在编译期,即一个可变借用(&mut T)同时只能存在一个,不可变借用(&T)和可变借用不能共存;但在实际场景中,借用关系往往很难在编译期满足,这时候就可以用RefCell,RefCell提供两个操作符:borrow()borrow_mut(),支持在运行时检查借用关系,如果运行时违法借用规则,会panic。

在我们的代码中,其实没有违反借用规则,因为我们只有不可变借用,但还是panic了,为什么呢?

原因在于RefCell borrow()底层实现不是原子的,看着是多线程读,其实内部存在写操作,变成了隐藏的多线程写,如下:

可以看出borrow()borrow_mut()内部实现存在写操作,多线程访问时,flag 状态管理可能出错,导致panic。

同样的问题,在Swift中,如果是多线程读一个变量,是安全的吗?

答案我们将在公众号「非专业程序员Ping」的下一期文章揭晓,欢迎订阅交流!

微信与苹果就小程序支付达成和解,iOS用户有望在小程序内直接使用苹果支付

今日,苹果公司正式发布《小程序合作伙伴计划》,为长期悬而未决的iOS小程序支付问题画上句号。这一官方公告,标志着小程序在苹果生态中的地位获得正式认可,同时也为开发者(宿主App)指明了清晰的合规路径。

图片.png

一、苹果推出《小程序合作伙伴计划》

什么是小程序合作伙伴计划
小程序合作伙伴计划,是苹果制定的一套小程序支付合规流程,面对的对象是宿主App开发者(微信、抖音)。

小程序合作伙伴计划有哪些内容
1、写明了技术侧需要做哪些改造——高级商务API、年龄评级API、苹果内购系统、监听苹果退款。 2、加入该计划需要向苹果提交申请。
3、必须使用苹果内购,支持创建”小程序通用内购商品“,比如创建”6元商品“,宿主App里的小程序都用这个内购商品。内购商品需苹果审核。

该计划的核心就是必须使用苹果支付,以方便苹果对支付进行监管(抽成)。高级商务API就是干这个事情的,宿主App调用高级商务API来完成内购支付。

目前,双方博弈的结果是苹果对宿主App(微信)抽成15%。注意,这是苹果对微信抽成15%,落到小程序开发者那边肯定是大于等于15%的。

二、苹果更新了专门针对小程序的审核条款

苹果为了配合上述内容,同时修改了审核指南,并于今日(11月14日)向全员开发者发送了邮件通知。下面我列出了和小程序有关的改动:

应用程序审查指南已经修订,以支持更新的政策,并提供澄清。 请查看以下更改:
1.2.1(a):此新指南规定,创建者应用程序必须为用户提供一种方法来识别超过应用程序年龄评级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问。
4.7:明确HTML5和JavaScript迷你应用和迷你游戏在指南范围内。
4.7.2:澄清在未经Apple事先许可的情况下,提供未嵌入二进制文件的软件的应用程序不得扩展或向软件公开本机平台api或技术。
4.7.5:澄清提供非嵌入式软件的应用程序必须为用户提供一种方法来识别超出应用程序年龄评级的内容,并使用基于验证或声明年龄的年龄限制机制来限制未成年用户访问。

审核条款主要强调了年龄分级不允许原生代码的热更新。原生代码热更新(OC、Swift层面的,游戏更新资源文件不算)一直是苹果不允许的,小程序有点灰色地带,苹果在这里着重强调一下。

针对小程序的审核条款完整内容,详见苹果审核指南4.7部分

三、微信:将尽快为小程序开发者提供接入服务

《微信公开课》公众号今日也发文回应,”欢迎苹果对小程序和小游戏开发者的支持,乐见苹果推出‘小程序合作伙伴计划’。我们将尽快为开发者提供接入服务,共同建设一个健康繁荣的生态。

微信的回应有点微妙啊。微信用的是”欢迎“,而不是”感谢“,”乐见“这个词更是体现出我只有一丢丢满意。看来微信从实力的角度出发,对这15%不太满意啊。

图片.png

四、小程序开发者需要做什么

暂时不用做什么,上述内容都是苹果和宿主App(微信)之间的事情。

现在只需要等微信那边完成技术改造后,更新小程序的对文档、政策文档,小程序开发者届时在完成适配工作。

改造完成后的流程可能会变成:小游戏内可以直接拉起苹果支付进行付款,开发者和微信分成,微信和苹果进行分成。也不排除另外一种模式:小游戏内直接拉微信支付,微信把支付数据上报给苹果,苹果和微信分成。个人还是觉得走苹果支付的可能性更大一点。

对于上述变动,不知道小程序开发者是高兴呢,还是不高兴呢?

好处是iOS端小程序终于可以正大光明的进行支付了,而不是像之前那样”躲躲藏藏“,这点对于用户体验上肯定是更好了;坏处是,之前用的奇巧淫技不用分成或者分成很低,现在需要额外交至少15%的苹果税。

欢迎在评论区留言,谈谈你的感受。

参考来源
【苹果官方】小程序合作伙伴计划
【苹果官方】苹果审核指南
【公众号】苹果官宣:支持iOS小程序小游戏开通支付,抽成15%

《Flutter全栈开发实战指南:从零到高级》- 14 -网络请求与数据解析

网络请求与数据解析

在移动开发中需要与云端服务器进行频繁的数据交互,本节内容讲带你详细了解网络请求与数据解析,让你的应用真正地“活”起来。

一、 为什么网络层如此重要?

举个例子:你正在开发一个新闻App,那些滚动的时事新闻、视频等内容,不可能全部打包在App安装包里,它们需要从服务器实时获取。这个“获取”的过程,就是通过网络请求完成的。

简单来说,流程就是:App 问服务器要数据 -> 服务器返回数据 -> App 把数据展示出来

在Flutter中,常用的两个网络请求库:官方推荐的 http社区维护得 dio。我们将从两者入手,带你彻底玩转网络请求。

二、 http库dio库 如何选择?

选择哪个库,就像选择工具,没有绝对的好坏,只有合不合适。

1. http

http 库是Flutter团队维护的底层库,它:

  • 优点:官方维护,稳定可靠;API简单直接,学习成本低。
  • 缺点:功能相对基础,许多高级功能(如拦截器、文件上传/下载进度等)需要自己手动实现。

核心方法:

  • get(): 向指定URL发起GET请求,用于获取数据。
  • post(): 发起POST请求,用于提交数据。
  • put(), delete(), head() 等:对应其他HTTP方法。

2. dio

dio 是一个强大的第三方HTTP客户端,它:

  • 优点:支持拦截器全局配置请求取消FormData文件上传/下载超时设置等。
  • 缺点:相对于http库更重一些。

如何选择?

  • 新手入门:可以从 http 开始,上手快。
  • 中大型项目:强烈推荐 dio,它能帮你节省大量造轮子的时间。

本节内容主要以 dio 为例进行讲解,它更符合项目开发的实际情况。

三、 引入依赖

首先,在你的 pubspec.yaml 文件中声明依赖。

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.0.0 
  # 用于JSON序列化
  json_annotation: ^4.8.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.3.0
  json_serializable: ^6.5.0

执行 flutter pub get 安装依赖。

四、 http

虽然我们推荐使用dio库,但了解http库的基本用法仍是必要的。

以获取一篇博客文章信息为例

import 'package:http/http.dart' as http; 
import 'dart:convert'; 

class HttpExample {
  
  static Future<void> fetchPost() async {
    try {
      // 1. 发起GET请求
      final response = await http.get(
        Uri.parse('https://jsonplaceholder.typicode.com/posts/1'),
      );

      // 2. 状态码200表示成功
      if (response.statusCode == 200) {
        // 3. 使用 dart:convert 解析返回的JSON字符串
        Map<String, dynamic> jsonData = json.decode(response.body);
        
        // 4. 从解析后的Map中取出数据
        String title = jsonData['title'];
        String body = jsonData['body'];
        print('标题: $title');
        print('内容: $body');
      } else {
        // 请求失败
        print('请求失败,状态码: ${response.statusCode}');
        print('响应体: ${response.body}');
      }
    } catch (e) {
      // 捕获异常
      print('请求发生异常: $e');
    }
  }
}

代码解读:

  1. async/await:网络请求是耗时操作,必须使用异步。await 会等待请求完成,而不会阻塞UI线程。
  2. Uri.parse:将字符串URL转换为Uri对象。
  3. response.statusCode:响应状态码,200系列表示成功
  4. json.decode():反序列化将JSON串转换为Dart中的 Map<String, dynamic>List

五、 dio

下面我们重点讲解下dio库:

1. Dio-发起请求

我们先创建一个Dio实例并进行全局配置。

import 'package:dio/dio.dart';

class DioManager {
  // 单例
  static final DioManager _instance = DioManager._internal();
  factory DioManager() => _instance;
  DioManager._internal() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://jsonplaceholder.typicode.com', 
      connectTimeout: const Duration(seconds: 5), // 连接超时时间
      receiveTimeout: const Duration(seconds: 3), // 接收数据超时时间
      headers: {
        'Content-Type': 'application/json', 
      },
    ));
  }

  late final Dio _dio;
  Dio get dio => _dio;
}

// GET请求
void fetchPostWithDio() async {
  try {
    // baseUrl后面拼接路径
    Response response = await DioManager().dio.get('/posts/1');
    // dio会自动检查状态码,非200系列会抛异常,所以这里直接处理数据
    Map<String, dynamic> data = response.data; // 这里dio帮我们自动解析了JSON
    print('获取数据: ${data['title']}');
  } on DioException catch (e) {
    print('请求异常: $e');
    if (e.response != null) {
      // 错误状态码
      print('错误状态码: ${e.response?.statusCode}');
      print('错误信息: ${e.response?.data}');
    } else {
      // 抛异常
      print('异常: ${e.message}');
    }
  } catch (e) {
    // 未知异常
    print('未知异常: $e');
  }
}

Dio相比Http的优点:

  • 自动JSON解析response.data 直接就是Map或List,无需手动 json.decode,太方便了!
  • 配置清晰BaseOptions 全局配置一目了然。
  • 结构化异常DioException 包含了丰富的错误信息。

2. Dio-网络请求流程

为了让大家更直观地理解,我们用一个流程图来展示Dio处理请求的完整过程:

sequenceDiagram
    participant A as 客户端
    participant I as 拦截器
    participant D as Dio
    participant S as 服务端

    A->>D: 发起请求
    Note right of A: await dio.get('/users')
    
    D->>I: 请求拦截器
    Note right of I: 添加Token、日志等
    
    I->>D: 处理后的请求
    D->>S: 发送网络请求
    
    S-->>D: 返回响应
    D->>I: 响应拦截器
    Note right of I: 解析JSON、错误处理
    
    I->>D: 处理后的响应
    D-->>A: 返回最终结果

3. Dio-拦截器

拦截器允许我们在请求发送前和响应返回后,插入自定义逻辑,对所有经过的请求和响应进行检查和加工。

案例:自动添加认证Token

class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // 请求发送前,为每个请求的Header加上Token
    const String token = 'your_auth_token_here';
    if (token.isNotEmpty) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    // 响应成功处理
    print('请求成功: ${response.requestOptions.path}');
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // 失败处理
    // 当Token过期时,自动跳转到登录页
    if (err.response?.statusCode == 401) {
      print('Token已过期,请重新登录!');
      // 这里可以跳转到登录页面
      // NavigationService.instance.navigateTo('/login');
    }
 
    handler.next(err);
  }
}

// 将拦截器添加到Dio实例中
void main() {
  final dio = DioManager().dio;
  dio.interceptors.add(AuthInterceptor());
  // 这里可以添加其他拦截器
  dio.interceptors.add(LogInterceptor(responseBody: true)); 
}

拦截器的添加顺序就是它们的执行顺序。onRequest 正序执行,onResponseonError 倒序执行。

六、 JSON序列化与反序列化

这是新手最容易踩坑的地方。直接从JSON转换成Dart对象(Model),能让我们的代码更安全、更易维护。

1. 为什么要序列化?

  • 类型安全:直接操作Map,编译器不知道data[‘title‘]是String还是int,容易写错;
  • 代码效率:使用点语法post.title访问属性,比post[‘title‘]更高效且有代码提示;
  • 可维护性:当接口字段变更时,你只需要修改一个Model类,而不是分散在各处的字符串key;;

2. 使用 json_serializable自动序列化

通过代码生成的方式,自动创建 fromJsontoJson 方法,一劳永逸。

步骤1:创建Model类并使用注解

// post.dart
import 'package:json_annotation/json_annotation.dart';

// 运行 `flutter pub run build_runner build` 后,会生成 post.g.dart 文件
part 'post.g.dart';

// 这个注解告诉生成器这个类需要生成序列化代码
@JsonSerializable()
class Post {
  // 使用@JsonKey可以自定义序列化行为
  // 例如,如果JSON字段名是`user_id`,而Dart字段是`userId`,可以这样映射:
  // @JsonKey(name: 'user_id')
  final int userId;
  final int id;
  final String title;
  final String body;

  Post({
    required this.userId,
    required this.id,
    required this.title,
    required this.body,
  });

  // 生成的代码会提供这两个方法
  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
  Map<String, dynamic> toJson() => _$PostToJson(this);
}

步骤2:运行代码生成命令

在项目根目录下执行:

flutter pub run build_runner build

这个命令会扫描所有带有 @JsonSerializable() 注解的类,并生成对应的 .g.dart 文件(如 post.g.dart)。这个文件里包含了 _$PostFromJson_$PostToJson 的具体实现。

步骤3:自动生成

// 具体的请求方法中使用
void fetchPostModel() async {
  try {
    Response response = await DioManager().dio.get('/posts/1');
    // 将响应数据转换为Post对象
    Post post = Post.fromJson(response.data);
    print('文章标题: ${post.title}');
  } on DioException catch (e) {
    // ... 错误处理
  }
}

json_serializable 的优势:

  • 自动处理类型转换,避免手误;
  • 通过 @JsonKey 注解可以处理各种复杂场景;

七、 网络层在MVVM模式中的定位

实际项目中,我们不会直接在UI页面里写网络请求代码。让我们看看网络层在MVVM架构中是如何工作的:

graph LR
    A[View<br>视图层] -->|调用| B[ViewModel<br>视图模型]
    B -->|调用| C[Model<br>模型层]
    C -->|使用| D[Dio<br>网络层]
    D -->|返回JSON| C
    C -->|转换为Model| B
    B -->|更新状态| A

各个分层职责:

  • View:只关心数据的展示和用户交互;
  • ViewModel:持有业务状态,处理UI逻辑,不关心数据从哪里来;
  • Model:决定数据是从网络获取还是本地数据库读取,它调用网络层;
  • Dio:纯粹的网络请求执行者,负责API调用、错误初步处理等;

这样分层的好处是:最终目的是解耦,各司其职,修改网络层不会影响业务逻辑,代码结构清晰,同事方便单元测试。

八、 错误处理

一个好的应用,必须支持处理各种异常情况。

1. DioException

DioException 的类型 (type) 帮助我们准确判断错误根源。

void handleDioError(DioException e) {
  switch (e.type) {
    case DioExceptionType.connectionTimeout:
    case DioExceptionType.sendTimeout:
    case DioExceptionType.receiveTimeout:
      print('超时错误,请检查网络连接是否稳定。');
      break;
    case DioExceptionType.badCertificate:
      print('证书错误。');
      break;
    case DioExceptionType.badResponse:
      // 服务器返回了错误状态码
      print('服务器错误: ${e.response?.statusCode}');
      // 可以根据不同状态码做不同处理
      if (e.response?.statusCode == 404) {
        print('请求的资源不存在(404)');
      } else if (e.response?.statusCode == 500) {
        print('服务器内部错误(500)');
      } else if (e.response?.statusCode == 401) {
        print('未授权,请重新登录(401)');
      }
      break;
    case DioExceptionType.cancel:
      print('请求被取消。');
      break;
    case DioExceptionType.connectionError:
      print('网络连接错误,请检查网络是否开启。');
      break;
    case DioExceptionType.unknown:
      print('未知错误: ${e.message}');
      break;
  }
}

2. 重试机制

对于因网络波动导致的失败,自动重试能大幅提升用户体验。

class RetryInterceptor extends Interceptor {
  final Dio _dio;

  RetryInterceptor(this._dio);

  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    // 只对超时和网络连接错误进行重试
    if (err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError) {
      
      final int retryCount = err.requestOptions.extra['retry_count'] ?? 0;
      const int maxRetryCount = 3;

      if (retryCount < maxRetryCount) {
        // 增加重试计数
        err.requestOptions.extra['retry_count'] = retryCount + 1;
        print('网络不稳定,正在尝试第${retryCount + 1}次重试...');

        // 等待一段时间后重试
        await Future.delayed(Duration(seconds: 1 * (retryCount + 1)));

        try {
          // 重新发送请求
          final Response response = await _dio.fetch(err.requestOptions);
          // 返回成功response
          handler.resolve(response);
          return;
        } catch (retryError) {
          // 如果失败继续传递错误
          handler.next(err);
          return;
        }
      }
    }
    // 如果不是指定错误或已达最大重试次数,则继续传递错误
    handler.next(err);
  }
}

九、 封装一个完整的网络请求库

到这已经把所有的网络请求知识学完了,下面我们用学到的知识封装一个通用的网络请求工具类。

// http_client.dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

class HttpClient {
  static final HttpClient _instance = HttpClient._internal();
  factory HttpClient() => _instance;

  late final Dio _dio;

  HttpClient._internal() {
    _dio = Dio(BaseOptions(
      baseUrl: 'https://api.yourserver.com/v1',
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 10),
      headers: {'Content-Type': 'application/json'},
    ));

    // 添加拦截器
    _dio.interceptors.add(LogInterceptor(
      requestBody: kDebugMode, 
      responseBody: kDebugMode,
    ));
    _dio.interceptors.add(AuthInterceptor());
    _dio.interceptors.add(RetryInterceptor(_dio));
  }

  // 封装GET请求
  Future<Response<T>> get<T>(
    String path, {
    Map<String, dynamic>? queryParameters,
    Map<String, dynamic>? headers,
  }) async {
    try {
      final options = Options(headers: headers);
      return await _dio.get<T>(
        path,
        queryParameters: queryParameters,
        options: options,
      );
    } on DioException {
      rethrow; 
    }
  }

  // 封装POST请求
  Future<Response<T>> post<T>(
    String path, {
    dynamic data,
    Map<String, dynamic>? queryParameters,
    Map<String, dynamic>? headers,
  }) async {
    try {
      final options = Options(headers: headers);
      return await _dio.post<T>(
        path,
        data: data,
        queryParameters: queryParameters,
        options: options,
      );
    } on DioException {
      rethrow;
    }
  }

  // 获取列表数据
  Future<List<T>> getList<T>(
    String path, {
    Map<String, dynamic>? queryParameters,
    T Function(Map<String, dynamic>)? fromJson,
  }) async {
    final response = await get<List<dynamic>>(path, queryParameters: queryParameters);
    // 将List<dynamic>转换为List<T>
    if (fromJson != null) {
      return response.data!.map<T>((item) => fromJson(item as Map<String, dynamic>)).toList();
    }
    
    return response.data as List<T>;
  }

  // 获取单个对象
  Future<T> getItem<T>(
    String path, {
    Map<String, dynamic>? queryParameters,
    required T Function(Map<String, dynamic>) fromJson,
  }) async {
    final response = await get<Map<String, dynamic>>(path, queryParameters: queryParameters);
    return fromJson(response.data!);
  }
}

// 
class PostRepository {
  final HttpClient _client = HttpClient();

  Future<Post> getPost(int id) async {
    final response = await _client.getItem(
      '/posts/$id',
      fromJson: Post.fromJson, 
    );
    return response;
  }

  Future<List<Post>> getPosts() async {
    final response = await _client.getList(
      '/posts',
      fromJson: Post.fromJson,
    );
    return response;
  }

  Future<Post> createPost(Post post) async {
    // Model转JSON
    final response = await _client.post(
      '/posts',
      data: post.toJson(),
    );
    return Post.fromJson(response.data);
  }
}

总结

又到了写总结诶的时候了,让我们用一张表格来回顾所有知识点:

知识点 核心 用途
库选择 http 轻量,dio 强大 中大型项目首选 dio
异步编程 使用 async/await 处理耗时操作 不能阻塞UI线程
JSON序列化 自动生成 推荐 json_serializable
错误处理 区分网络异常和服务器错误 精确捕获 DioException 并分类处理
拦截器 统一处理请求/响应 用于添加Token、日志、重试逻辑
架构分层 MVVM 分离解耦
请求封装 统一封装GET/POST等基础方法 提供 getItem, getList 等语义化方法

网络请求在实际项目中直观重要,没有网络就没有数据,掌握好本章内容,你就能为你Flutter应用注入源源不断的活力。让我们下期见!

iOS 社招 - Runtime 相关知识点

核心概念 本质:runtime是 oc 的一个运行时库(libobjc.A,dylib),它为 oc 添加了 面向对象的能力 以及 运行时的动态特性。 面向对象的能力:rutime用 C 语言实现了类

Vibe Coding 实战!花了两天时间,让 AI 写了一个富文本渲染引擎!

一、先上效果图

最近动手实践了下 Vibe Coding,想尝试一行代码不写,纯通过 Prompt 让 AI 写了一个富文本渲染引擎

整体花了两天时间不到,效果如上图,支持的特性有:

  • 类似前端的 Block、InlineBlock、Inline 布局
  • 文本样式:加粗、斜体、下划线、删除线,前景色,背景色,同一行不同字体大小混排等
  • Attachment:图文混排或插入自定义 View 等
  • 异步排版和算高:基于 CoreText API,支持子线程布局算高,主线程渲染
  • 单测覆盖

项目用的 Claude AI,差不多耗费了 50$(是真的贵!但也是真的强!),本文将记录整个过程和一些经验总结。

二、过程记录

2.1 Claude 安装和项目初始化

Claude 安装和使用在网上有很多教程,细节这里不再赘述,推荐直接使用 VSCode 的 Claude AI 插件;后文「经验总结」部分也会总结 Claude AI 的常用命令,感兴趣可以直接跳转。

首先,我们需要新建一个空的 iOS 项目和富文本渲染引擎的 pod(这里我们叫 RichView),创建完成之后在 VSCode 中打开,点击右上角 Claude AI 的图标开启会话,输入/init 命令初始化工程。

/init命令的作用是让 Claude 理解整个项目,这是在项目中使用 Claude 的第一步,只需要执行一次就好。

/init会在根目录下自动创建一个CLAUDE.md文件,这个文件可以理解成全局上下文,即每次新开 Claude 会话都会自动加载其中的内容,我们可以在这里记录一些如修改历史、全局说明等内容。

2.2 技术选型、架构

让 AI 写代码,和我们自己写代码基本类似,不过是将我们的思路转换成 Prompt 告诉 AI。

编码之前需要先确定几件事情:这些确定好之后,我们后续的任务拆分才会更顺利。

1)需要支持哪些 Feature

  • 支持文本样式:加粗、斜体、下划线、删除线,前景色,背景色,同一行不同字体大小混排等
  • 支持 Attachment:图文混排或插入自定义 View 等
  • 支持子线程排版算高
  • 支持单元测试

2)技术选型

自定义富文本渲染引擎,最难的点在于如何实现精确的文本分词排版(原理可以参考从 0 到 1 自定义文字排版引擎:原理篇),iOS 有内置的 CoreText API(见链接)用于文本分词排版,当然也可以基于开源的跨端排版引擎 HarfBuzz(见链接)进行处理。

我们这里不需要跨端,因此选择 CoreText 作为方案选型。

官方封装的 NSAttributedString 当然也能做这件事情,但是从工程实践看,NSAttributedString 在扩展性(比如支持列表、表格等自定义布局)、使用方便性,以及长文本的性能方面不尽如人意。

3)技术架构

文本分词之后,还需要进行布局排版,为方便后续拓展布局,我们这里参考前端的布局模型,引入 Block、InlineBlock、Inline 的概念。

同时参考浏览器的布局渲染过程,引入三棵树的概念:

  • ElementTree:用户输入,整个富文本可以通过一颗 ElementTree 来表示
  • LayoutTree:负责布局排版,会在这一层处理好文本的分词、图文混排时各自的位置等
  • RenderTree:负责渲染,这一层接收布局完成的结果,进行最终的上屏绘制

敲定技术选型、技术架构之后,我们就可以按思路拆分子任务了。

2.3 子任务:ElementTree

由于我们参考了前端的布局模型,因此我们需要告诉 AI 在 CSS 中 Block、InlineBlock、Inline 的布局规范,这个在 MDN 中可以直接摘录,当然也可以直接让 AI 帮我们生成(如上图)。

接着,我们需要告诉 AI 怎么构建 ElementTree,也就是上图所示 Prompt。

最后,我们就可以让 AI 参照 Prompt,生成 ElementTree 了。

ElementTree 生成完成后,我们发现遗漏了单测环节,继续完善 ElementTree 的 Prompt,然后明确告诉 AI xx 文件新增了 xx 任务,让 AI 继续完成任务,如下图:

ElementTree 的创建还算比较顺利,AI 理解也比较到位,生成的代码基本符合预期。

2.4 子任务:LayoutTree

同样,我们定义好 Prompt,让 AI 生成 LayoutTree。

LayoutTree 的生成不太顺利,而且从最后的测试效果看也有很多 Bug,主要如下:

  • AI 将绘制相关逻辑也加到了 LayoutTree 中,但预期绘制是单独的 RenderTree
  • 布局问题:InlineBlock 无法整体换行,多个 Inline 在同一行时被换行展示,margin、padding 不生效等
  • 对齐问题:同一行包含不同字号的文本时,对齐方式不对
  • attachment 无法显示

2.5 子任务:RenderTree

由于 LayoutTree 这个底层基础没扎实,RenderTree 的搭建也不顺利,RenderTree 的 Prompt 如上。

2.6 BugFix

至此,AI 生成了初版的富文本渲染引擎,接下来就是让 AI 写个 Demo 试用一下,在使用过程中,发现了很多上面罗列的 Bug,针对这些 Bug,也可以让 AI 来修复:

在让 AI 修 Bug 过程中,也踩了一些坑,参见下文经验总结。

三、一些经验总结

3.1 Claude AI 常用命令

  • /init:项目初始化,第一次使用 Claude AI 时执行,每个项目只需要执行一次即可;会生成一个CLAUDE.md文件,这是项目的全局上下文,每次新建 Claude 会话时,会自动读取其中的内容;可以在CLAUDE.md文件中补充修改历史、全局说明等
  • @:可以输入@来添加文件到会话窗口,将文件作为上下文给 AI
  • /exit:关闭当前会话
  • /clear:清除当前会话上下文,和退出会话然后新开一个会话效果一样
  • /compact:压缩和总结当前会话上下文,和/clear的区别是,/compact会将当前会话上下文总结后作为当前会话的新上下文,/clear会直接清除所有上下文
  • /resume:显示和恢复历史上下文
  • 自定义 command:可以将通用的 Prompt 做成自定义 command,文件位置在.claude/commands/;还可以通过 $ARGUMENUTS 来接收自定义参数

  • /agents:有的任务比较复杂,或上下文较多,那可以拆分成多个 agents 进行组合,比如写业务逻辑 -> 构建单元测试 -> CI/CD 等,可以拆分多个 agents 组合使用

  • 会话模式:在最新版本的 Claude AI 插件中,除了之前命令行风格的 GUI 以外,还提供了会话框风格的 GUI,切换会话模式,查看历史会话等会更方便;如下,会话模式可以在输入框左下角切换

    • Edit automatically:AI 根据输入 Prompt 进行理解并直接编辑文件,一般使用该模式即可

    • Plan mode:AI 根据输入 Prompt 列出修改计划,你可以进一步校验和修改 Plan

    • Ask before edits:AI 修改文件前询问

  • MCP:常用的 MCP 是context7context7是用于帮助 AI 查找最新文档的,避免使用过时 API

3.2 经验总结

不得不感叹,AI 编程实在太强大了,相信在不久的将来,一个只会写 Prompt 的非专业程序员,也能完整交付一个 App 了。

让 AI 编程,并不是说给一句话就能让 AI 完成代码,各种细节还是需要人来提前想清楚,毕竟最终维护代码和解决问题的还是我们自己,AI 只是帮我们提效和扩展思路的工具;有句话总结的蛮好:你可以将 AI 视为一个非常聪明,甚至资深,但是没有业务经验的程序员。

下面我想总结下最近实战的一些经验,希望对各位有帮助:

1)架构设计需要提前规划好,尽量想清楚细节

谋定而后动,不管是我们自己写代码,还是让 AI 写代码,我觉得提前想好要做什么,怎么做是非常重要的。

架构设计好了,细节想清楚了,那怎么拆分子任务,其实也就明确了。

2)任务拆分越小越好,上下文越明确越好

AI 最适合做有明确输入输出的事情,给的上下文越明确,AI 产生幻觉的概率越低,输出结果也会越准确。

当然,如果是输入输出明确的任务,也可以让 AI 先输出测试用例,测试用例人工检测完备之后,再让 AI 编码也是可以的(测试驱动开发/TDD)。

3)每一项目任务做好之后再进行下一项任务

基础不牢,地动山摇!

推荐打磨好每一项子任务再继续下一项任务,否则千里之堤毁于蚁穴,每个任务都留一点坑,最终可能带来灾难性的结果!

另外,单测是个好东西,对每项任务补齐单测,可以有效防止后续 AI 改出问题。

4)善用 Git,防止代码污染

Claude 在 Edit automatically 模式下会直接修改文件,为了防止污染其他代码,每次让 AI 修改前尽量保证工作区干净,这样也能方便我们 Review 代码。

5)写 Prompt 尽量用明确的词汇,不要表意不清

比如在构建 ElementTree 时,我会明确告诉 AI 要支持哪些 Style,可以有效避免 AI 臆测

与之相反的反例是,在构建 LayoutTree 时,限定不足,导致 AI 自由发挥,最终实现出很多 Bug。

6)善用提示词:think < think hard < think harder < ultrathink

可以在 Prompt 中追加 think hard / think harder 等词汇,来让 AI 进入深度思考,这并不是什么黑魔法,而是 Claude AI 官方认证的,参见:www.anthropic.com/engineering…

实践下来,确实还是有效果的,如下是让 AI 修复文本对齐问题,加了 think hard AI 会更深入理解代码,找到问题原因;当然,这种方式也有弊端,就是会耗费更多的 token(money)👺

7)善用 /compact /clear命令,减少模型幻觉

如果不主动清除,Claude AI 会话中的上下文是会一直保存的,当一个会话中问答轮次过多,可能会导致 AI 理解不准确(幻觉)。

可以通过/compact/clear命令,来压缩/清除上下文。

一般我在修复有关联性的 Bug 时,会使用/compact命令,这样 AI 就不需要重新理解工程,理解 Bug 了,可以提高效率。

8)BugFix 尽量构造最小可复现 Demo

BugFix 其实也是一个子任务,最小可复现 Demo 减少 AI 的理解负担。

9)及时人工介入,避免在一个问题上死磕

有时候让 AI 修复 Bug 时,可能反复修改都解决不了,这时候大概率是 AI 没有真正理解问题,或者就是输入的 Prompt 有问题,这种情况下就没必要让 AI 死磕问题了,我们可以及时人工介入,避免浪费时间。

10)善用 Plan 模式

在任务拆分时,我们自己可能也没想明白应该怎么做,那可以切换到 Plan 模式,让 AI 和我们一起拆任务。

3.3 Vibe Coding 的一些弊端

1)付费,而且还挺贵!

这是一个挺现实的问题,一些好的模型都挺贵,而且还是消耗的刀乐,国内厂商的模型质量又不尽如人意。

2)编码风格问题 & 扩展性、易用性、鲁棒性不足

AI 写的代码还是挺容易看出来的,感觉很难带有程序员的个人风格,一个明显的表现是会用一些比较少见的 API,虽然,这可能也是 AI 的厉害之处。

另外,AI 在一些函数复用性、扩展性、使用方便性上有时候差强人意,比如 AI 生成代码如下:如果要配置 Element 的 Style,需要不断的调用text.style.xxx,但其实写成链式调用使用起来会更舒服,如下注释部分

let text = TextElement(text: "一、晨光初照")
text.style.color = .red
text.style.font = UIFont.systemFont(ofSize: 17)

// 更好的写法
// text.style.setColor(xxx).setFont(xxx)

鲁棒性方面,AI 不会主动考虑调用场景,比如我虽然告诉了 AI 我要支持子线程布局,但是 AI 生成的代码并不是线程安全的。

当然,上述这些,可以通过完善 Prompt 来部分弥补。

3)问题定位幻觉

有时候让 AI 排查一些 Bug,它无法找到真正的原因,反复修改后还是有问题。

这种情况下,就需要人工介入了,我们可以自己定位问题,再告诉 AI 怎么修改,而不要让 AI 死磕问题,避免浪费时间。

四、贴下源码 & Prompt

github.com/HusterYP/Ri…

内容首发在公众号「非专业程序员Ping」,觉得有用的话,三连再走吧~ (⁎˃ᴗ˂⁎)
富文本相关,你可能感兴趣:

淘宝 9 块 9 的 DeepSeek,撕开了魔幻世界的一角

前言

现在的世界是越来越魔幻,早就超出了常人的理解范畴。

我最近老刷淘宝,昨天不知哪来的兴致,没什么特定缘由,纯粹好奇当下的相关生态,便鬼使神差在搜索栏敲了 “DeepSeek”。翻了没两下,一家店的标题瞬间抓住了他的眼球 ——“无需部署、打开即用,全程不卡顿”,售价才 10 块钱,付款人数却已经破了 1000。

盯着页面琢磨了半天,越想越纳闷:不用买家本地部署,还能保证立刻响应,这到底是什么神仙操作?

要知道,DeepSeek 的热度虽已过去半年,但直到现在,偶尔还是能见到这玩意儿的身影,甚至可能让人猛地想起当初被它支配的日子。

于是点进去了,这数据还确实挺好的,DeepSeek的需求还是旺。图片

按捺不住满心的好奇,我干脆直接下了单、付了款。

收到货后

可等卖家发来所谓的 “产品”,当场就懵了 —— 那瞬间的冲击感,让差点以为自己不小心闯进了平行宇宙,满脑子都是 “这到底是啥?!”

图片

我第一反应直接懵了:这难道是遇到活菩萨了?居然自己部署了 DeepSeek,还开了公网供大家免费似的用?这算力得有多足啊?

有这资源,哥们儿直接去卖算力不比这 10 块钱一单赚得多?

可等定睛一看,瞬间愣住了 —— 这网址怎么这么眼熟?再仔细瞅了瞅,好家伙,居然是n.cn?!

**这不是...360的纳米搜索???图片

我点开另一个网址。

图片

我一时间无语凝噎,我的大脑宕机了整整10秒钟。我想到了这个事情比较抽象,但是,我没想到能抽象到这种地步。因为,反差感极强,我本来会以为,哥们就卖链接,肯定不少差评,但,事情不太一样了起来。这个评论区,几乎全是好评。

图片

甚至有300个88VIP的好评。我一条一条的看了下去,我感觉,这个世界在我的脑中,好像更魔幻,更立体了。有用户留言说,确实不卡,输出的代码很规范。图片

“可以啊,是免费的r1,跟描述一致,性价比很高,不用买会员了也。” 图片

**不用买会员了。我不知道你们是什么感受,我突然鼻子一酸。我看到的,忽然是一个个非常活生生、立体形象的人。

或许对于这些Ta们来说:白天在公司,他得忍着老板的 PUA 强撑着干活,连反驳的底气都没有 —— 这份薪资微薄的工作,是他在四五线小城唯一的糊口依靠。

晚上回到十几平米的出租屋,狭小的空间里塞满了不甘。他太想改变命运了,太怕一辈子困在原地,所以总琢磨着学门新本事,抓住点什么。

AI、大模型、席卷时代的技术革命,这些词像针一样扎着他的心。他知道这是翻身的机会,可每一次听说,都伴随着深深的恐慌 —— 怕自己追不上这股浪潮,怕被时代彻底抛弃。

可真要迈出脚步,才发现前路全是望不到头的坎:官网得靠 “魔法上网” 才能访问,他摸不着门道;会员订阅几十上百块一个月,抵得上他好几天的饭钱,根本舍不得花;那些部署教程里的术语,像天书一样晦涩,硬生生把他挡在门外,连入门的缝隙都不给留。

好不容易扒到 DeepSeek 和 ChatGPT 的官网,他咬碎了牙才掏出不便宜的会员费,以为终于摸到了门槛。可实际用起来,却满是失望 —— 不仅卡顿得让人抓狂,历史记录里还莫名冒出不属于自己的内容,钱花得冤枉又憋屈。

一次次尝试,一次次被现实打回原形。他就像个被遗弃在站台外的人,眼睁睁看着时代的列车呼啸而过,自己却连上车的资格都没有。那种求而不得的无力感,沉甸甸压在胸口,让他喘不过气。

直到那天在淘宝刷到那个 9.9 元的商品,“无需部署”“打开即用”“立刻响应” 这几个字,像黑夜里的一束光,瞬间照亮了他的渴望。

他反复看了好几遍商品页面,纠结了很久 —— 兜里的钱每一分都要算计,他怕这又是一场骗局,怕最后连这两顿拼好饭的钱都打了水漂。可对改变的渴望,终究压过了所有顾虑。

下单后,他攥着手机等回复,收到可直接点开的链接时,手指都带着点颤抖。怀着忐忑点进去,居然真的能用,还异常流畅。

那一刻,他紧绷的肩膀突然垮了下来,长长舒了一口气,眼眶甚至有点发热。他觉得自己终于花最少的钱,攥住了那张三寸见方的、通往新世界的船票。

满心欢喜地跑到评论区,他认认真真打下 “性价比很高,不用买会员了”,字里行间全是满足。他甚至偷偷窃喜,觉得自己占了天大的便宜,用 9.9 元就撬动了原本遥不可及的生产力工具。

他把这个链接小心翼翼收进浏览器书签,像珍藏一件稀世珍宝。这是他在残酷生活里,拼尽全力才抓住的小小捷径,是支撑他继续往前走的一点希望。

可他不知道,这份被他视若珍宝的 “机会”,本就明晃晃摊在阳光下,对所有人免费开放。他只是恰好站在了信息的阴影里,没见过那片触手可及的光明,只能靠着这点 “微光”,笨拙又执着地追赶着时代的脚步。

AI的发展,永不止步。 愿所有人都能跟上时代的洪流,用自己信息差打出一片新的天地!

相关推荐

# 苹果开发者续费大坑及成功续费方案!亲测有效

# AppStore敏感词排查手册,多维度分析Guideline 2.3.1隐藏功能,轻松过审。

# 苹果加急审核是“绿色通道”还是“死亡陷阱”?

# 苹果开发者邮箱,突然收到11.2通知严重么?

# 不想被苹果卡审最好错开这两个提审时间

# 手撕苹果审核4.3是代码问题还是设计问题?

# 有幸和Appstore审核人员进行了一场视频会议特此记录。

❌