深入 iMessage 底层:一个 Agent 是如何诞生的
iMessage 深度集成在 Apple 生态中,却从未提供官方 API。本文邀请 imessage-kit 作者 LingJueYa 分享如何突破这一限制,让 AI Agent 进入 iMessage。文章详细介绍了从解析 SQLite 数据库、处理 Core Data 时间戳、绕过 macOS 沙盒限制,到用 AppleScript 实现消息发送的完整技术方案,以及在构建过程中踩过的坑与解决之道。
iMessage 深度集成在 Apple 生态中,却从未提供官方 API。本文邀请 imessage-kit 作者 LingJueYa 分享如何突破这一限制,让 AI Agent 进入 iMessage。文章详细介绍了从解析 SQLite 数据库、处理 Core Data 时间戳、绕过 macOS 沙盒限制,到用 AppleScript 实现消息发送的完整技术方案,以及在构建过程中踩过的坑与解决之道。
![]()
📮 想持续关注 Swift 技术前沿?
每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。
- 📬 在 weekly.fatbobman.com 免费订阅
- 💬 加入 Discord 与中文 Swift 开发者深入交流
- 📚 访问 fatbobman.com 查看数百篇深度原创教程
一起构建更好的 Swift 应用!🚀
几天前,我像往常一样在输入 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 是一款在 173 个国家和地区获得 App Store 编辑推荐、拥有超过 18 万五星评价的健康管理应用。在适配 iOS 26 的 Liquid Glass 设计语言时,团队遇到了不少挑战:如何在 UIKit + SwiftUI 混合架构下实现原生的 morph 效果?如何精确控制 Scroll Edge Effect?如何处理自定义导航栏元素的动态尺寸?Grow 的开发者之一 Shuhari,分享了团队在这次适配过程中的实战经验。文章涵盖 Sheet、Navigation、Popover 等场景的改造方案,深入探讨 UIBarButtonItem 尺寸计算、CABackdropLayer 副作用处理等底层细节,还展示了如何利用 Core Text 创造“玻璃文字”效果。所有核心概念都配有完整的 Demo 工程。
参数化测试 (Parameterized Tests)是 Swift Testing 中颇具代表性的新特性,它让开发者能够在最小化重复代码的同时扩大测试覆盖范围,为同一逻辑轻松验证多组输入。然而 Alex Ozun 在大规模迁移实践中发现,这项功能虽然便捷,却也暗藏不少容易忽略的陷阱,甚至可能悄悄降低测试的有效性。文章结合多个示例展示了一些常见陷阱,并提出了如避免在 #expect 两侧重复使用测试参数、明确区分示例测试与属性测试等多项实践建议。
在 SwiftUI 中,task / onAppear 会在视图“出现”时执行一次,但它们并不会像视图那样自动跟踪依赖——如果任务闭包依赖了某个状态,该状态变化后任务本身不会自动重新触发。Chris Eidhof 以加载远程图片为例,展示了这一容易被忽略的问题,并建议为任务显式指定“身份”(identity),例如使用 .task(id: url),让相关依赖(如 URL 或由多个值组合而成的复合标识)参与任务的重新执行条件,使 SwiftUI 能在依赖更新时取消旧任务并启动新任务。作者提醒,凡是在视图中使用 task / onAppear 时,都应确保相关的依赖已经体现在任务的身份(identity)中。
Swift 已经诞生十年了,但在日常开发中开发者使用的很多 Swift API 仍只是对 Objective-C API 的简单包装,这可能会引发一些容易忽视的严重问题。Paul Hudson 在本文中就通过 replacingOccurrences(of:with:) 展示了这种情况:在处理由多个 Unicode 标量组成的字符(如国旗表情)时,该方法可能会“误拆”字符、匹配不存在的序列,从而生成完全错误的结果。Paul 的建议非常简单:在 Swift 中应优先使用原生的 replacing(_:with:),它能够正确地按字符语义处理 Unicode,避免这些诡异且难以排查的字符串错误。
随着 Foundation 在 Swift 社区重构完成,在 macOS 等平台上,对于具备类似功能的 API,通常应优先选择新 Foundation 中提供的 Swift 原生版本。这样不仅可以避免上述问题,而且也提前为跨平台做好准备。
Swift 的并发演进并非一帆风顺,引入 Approachable Concurrency 概念后,不同编译选项组合甚至可能得到完全不同的编译结果,理解成本也随之水涨船高。Christian Tietze 原本只打算做一个简短演示:展示如何使用卡片盒笔记法(Zettelkasten)来消化 Matt Massicotte 关于 Swift 并发的博客文章,结果在实作过程中不断撞见更深层的复杂性——例如:actor 无法直接满足带有 nonisolated 要求的 Sendable 协议,除非显式将成员标记为 nonisolated 或 nonisolated(unsafe)。等他回过神来,视频已经录到了 80 分钟。
视频很好地呈现了“深入学技术”的真实面貌:不是线性的知识堆叠,而是充满困惑、假设以及有待日后用代码与文档验证的开放问题。同时也侧面证明,卡片盒笔记法非常适合应对 Swift 并发这类复杂且持续演进的主题,通过构建可搜索、可链接的笔记网络,承载理解在时间维度上的逐步收敛。
Ein Verne 在本文中介绍了 Claude 新推出的 Skills 机制 —— 一种用于扩展 Claude 能力的模块化体系。相比 MCP、Slash Commands 和传统插件,Skills 更强调可组合性、可移植性以及对上下文窗口的友好使用方式。每个 Skill 都以独立文件夹的形式存在,包含名称、描述、操作指令(SKILL.md)、可执行脚本、参考文档与资源文件等。Claude 会在执行任务时自动扫描并匹配合适的技能,并通过“渐进式披露(Progressive Disclosure)”按需加载细节,从而显著降低上下文消耗。作者认为,Skills 本质上将“提示词工程”演进为“工作流工程”,让 Claude 从通用智能助手进一步迈向可维护的智能基础设施形态。
就像许多 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 代码打下了良好基础。
Davide Ficano 将其经营多年的 macOS 文件对比工具 VisualDiffer 完全开源,并从 Objective-C 彻底重写为 Swift。这不是简单的语言迁移或 AI 辅助转换,而是一次从零开始的手工重构。
核心功能保持不变:
在 Reddit 上,作者坦言自己依旧非常欣赏 Objective-C,但 Swift 的潜力让他愿意承受迁移的巨大成本。UI 层(特别是 NSTableView 与 delegate 模式)的重写过程尤为艰难,早期充满了并发属性标注,但随着理解加深,Swift 的优势逐渐显现。
十里 在开发图片压缩工具 Zipic 时,需要实时感知图片文件变化以便进行及时处理,为此开发了 FSWatcher。这是一个基于 macOS/iOS 底层 kqueue 机制的文件系统监控库,采用事件驱动而非轮询方式,资源消耗极低。
核心特性:
该库非常适合作为图片处理流程的监听器、开发工具的热重载组件,或构建轻量化自动备份系统等需要实时文件变动感知的场景。
市面上已有不少用于改进 SF Symbols 使用体验的库,但 LiYanan 的 SFSymbolKit 仍然颇具特色:所有符号与可用性信息都由工具直接从系统框架自动生成,一键即可完成更新,真正做到无需人工维护。
核心优势:
/System/Library/PrivateFrameworks/SFSymbols.framework/,与系统 100% 同步./update_symbols.sh 即可更新,无需手动添加新符号@available 属性,编译时检查符号兼容性如果你觉得这份周报或者我的文章对你有所帮助,欢迎 点赞 并将其 转发 给更多的朋友。
📮 想持续关注 Swift 技术前沿?
每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。
- 📬 在 weekly.fatbobman.com 免费订阅
- 💬 加入 Discord 与中文 Swift 开发者深入交流
- 📚 访问 fatbobman.com 查看数百篇深度原创教程
一起构建更好的 Swift 应用!🚀
我的 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 的提示,可以清楚地知道:Block 预期在一个线程上执行,而实际在另一个线程上执行。第一反应:大概率是主线程 vs 后台线程之间的不一致导致的。
如果经常熟练处理 Swift 6 升级的小伙伴就知道,一定是有地方标记了 @MainActor,也就意味着相对应的方法一定是要在主线程上执行的;而实际阴差阳错,在后台线程执行了。
所以接下来可能需要找,到底哪里线程不一致呢?我们根据代码来寻找即可。
![]()
不难找到,根据 Combine 调用链,可以发现其中一处对于 userPublisher 的监听时,选择了在 global 上去执行后面的操作,所以这里需要将这一个逻辑去掉。
于此同时,对于 userPublisher 的发布,我们也最好将其默认放在主线程上,因为他是和 UI 相关的,所以需要做这样的改动:
![]()
目前是不是觉得好像这个类型的 crash 不算很难解决?没错,这个 crash 的提示相对清楚,知道大概原因后去找就相对容易了。
不过需要注意的是,当使用 Combine 框架遇上这类型的 crash 时,crash 断点只会发生在 Publisher 而不是 Observer 处,所以我们需要自己去寻找调用方,看下在哪里出现了线程使用不一致的问题。
好的,那么同类型的一个 crash 再来看看:
![]()
报错信息就不贴了,和上一个 crash 是一样的,都是:"%sBlock was %sexpected to execute on queue [%s (%p)]
这里可以看到,crash 断点处在子线程,也是在 AsyncStream 发布处断点。那么根据经验推断,可以大概知道原因:
这里寻找过程就不赘述了。原来的发布者是处在子线程,而后面的监听者处在主线程,因此需要改在主线程发布。
![]()
真实场景里:
如果还用传统锁:
objc_sync_enter(self)
hp -= damage
objc_sync_exit(self)
轻则性能抖动,重则死锁;而 Swift 5.5 起的 Actor 模型 把“互斥”升级为消息队列,编译期即可检查“跨 actor 引用是否安全”,让“数据竞争”成为编译错误。
actor Boss {
var hp: Double = 100
func takeDamage(_ amount: Double) {
hp = max(0, hp - amount)
}
}
hp;await 异步消息,编译器自动加队列。let boss = Boss()
await boss.takeDamage(10) // 编译通过
boss.hp // ❌ 编译错误:actor-isolated
Actor 隔离域(isolation domain):同一时间只有一条消息在执行,天然“可线性化”(Serializability)。
目标:
/// 可并发受伤
protocol ConcurrentWoundable: AnyObject {
associatedtype Value: NumericValue
func takeDamage(_ amount: Value) async
var currentHp: Value { get }
}
注意:
AnyObject 限制只让 class/actor 符合,因为需要共享引用;async,调用方必须 await。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 关键字:编译器允许外部同步读取,但不能写。
把上篇的 DamageCalculator 泛型算法保持值语义,计算过程无锁;只有最后 takeDamage 进 actor 才排队。
let calc = AnyDamageCalculator(Double.self) { base in base * 1.5 }
let damage = calc.calculate(base: 50) // 无锁计算
await boss.takeDamage(damage) // 一次消息
分离“计算”与“状态变更”:计算无锁、变更串行,兼顾性能与安全。
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 传输消息,开发者零成本获得分布式对象模型。
场景:
代码:
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。
@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 串行执行,零数据竞争。
在 actor 里访问全局可变状态
同样要 await,否则编译报错。
nonisolated 只能读,不能写;写必须走消息。
不要把长时间阻塞代码(sleep、sync I/O)直接放进 actor,会卡住消息队列;应拆到 Task.detached 或 AsyncSequence。
跨 actor 调用时,值类型会被拷贝,不要传递大型数组;可改用 AsyncSequence 流式输出。
分布式 actor 的方法参数/返回值必须遵循 Codable,否则无法序列化
上一篇我们写出了这样的代码:
let calc: any DamageCalculator<Double> = CritCalculator(rate: 1.5)
它编译得快、跑得也快,但当你想把它存进数组、或者作为属性逃逸到运行时,就会遇到三个灵魂问题:
associatedtype,为什么不能用 DamageCalculator 直接当做类型?calculate(base:),为什么有时走内联、有时走虚表?答案都指向同一个机制:Existential Container(存在性容器),社区俗称“类型擦除盒”。
Swift 把“符合某个协议的值”打包成一种统一大小的盒子,这个盒子就叫 existential。
语法层面:
any Protocol // Swift 5.6+ 显式 existentialProtocol // 隐式 existential,即将被逐步废弃盒子内部到底长什么样?继续看。
以 64 bit 为例,标准布局 5 个字(40 byte):
+-------- 0: value buffer (3 ptr = 24 byte)
+--------24: value witness table (VWT)
+--------32: protocol witness table (PWT)
value buffer
VWT
管理“值语义”生命周期:拷贝、销毁、搬移。
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 才基本无额外开销;否则请考虑“手写类型擦除”或“泛型特化”。
目标:
Value 泛型参数。class AnyDamageCalculatorBox<Value: NumericValue>: DamageCalculator {
func calculate(base: Value) -> Value { fatalError("abstract") }
}
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)
}
}
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)
}
}
let crit = CritCalculator(rate: 1.5)
let erased: AnyDamageCalculator<Double> = AnyDamageCalculator(crit)
array.append(erased) // 数组元素大小 = 1 ptr,无 existential 盒子
let list: [any DamageCalculator<Double>] = [
CritCalculator(rate: 1.5),
MultiplierCalculator(upstream: CritCalculator(rate: 2), multiplier: 1.2)
]
编译器会自动生成“隐藏盒子”,但仍带 48 byte 拷贝成本。
适合场景:
高性能路径(渲染、音频、网络解析)继续用手写擦除或泛型特化。
任何带 associatedtype 的协议,都可以套下面 4 步:
AnyProtocolBase<AssociatedType> 抽象类;ConcreteBox<T: Protocol> 具体类,持有 T;AnyProtocol<AssociatedType> 值类型,内部存 AnyProtocolBase 指针;override / forward 到抽象类。需求 \ 方案 泛型特化 any Protocol 手写擦除
------------------------------------------------------------
编译期已知类型 ✅ ❌ ❌
需要进数组/逃逸 ❌ ✅ ✅
对性能极度敏感 ✅ ❌ ✅
不想写样板代码 ✅ ✅ ❌(可用宏)
一句话:编译期能定类型就用泛型;运行时再决定就用擦除;原型阶段先 any 再说。
上两篇我们已经用协议把“攻击”拆成了能力插件,但遗留了一个硬核问题:
Int 足够,后期为了避免除法误差想换成 Double,甚至金融级精度要用 Decimal;AttackableInt、AttackableDouble…爆炸式增长。Swift 的泛型(Generic)+ 关联类型(associatedtype)可以“一次性”写出算法,然后让编译器在调用点自动生成对应版本的代码,既保证类型安全,又保持运行时零成本。
先约定一个“可运算、可比较”的基本协议,把 +、*、/、> 等运算符包进去:
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) // 能从整数字面量初始化
}
Swift 5.7 之后可以用 extension 给标准库类型“批量”实现:
extension Int: NumericValue {}
extension Double: NumericValue {}
extension Decimal: NumericValue {
static func *(lhs: Decimal, rhs: Double) -> Decimal {
lhs * Decimal(rhs)
}
}
(Float、CGFloat 同理)
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 全部生效;protocol DamageCalculator<Value> {
associatedtype Value: NumericValue
/// 传入基础伤害,返回最终伤害
func calculate(base: Value) -> Value
}
struct CritCalculator<Value: NumericValue>: DamageCalculator {
let rate: Value // 暴击倍率
func calculate(base: Value) -> Value {
base * rate
}
}
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() }
}
结果:
Double 专用版本:0.046 s差距在 2% 以内,属于测量误差;汇编层面已无线程堆分配、无 protocol witness 调用。
其余场景继续 struct + 泛型协议。
| 需求场景 | 首选方案 | 备选方案 |
|---|---|---|
| 只是多态 | protocol 默认实现 | class + override |
| 多精度算法 | 泛型 protocol + associatedtype | 宏/模板代码生成 |
| 共享可变状态 | class | actor |
| 值语义 + 组合 | struct + protocol | 无 |
| 运行时动态替换 | class + objc | SwiftUI 的 AnyView 类型擦除 |
用 protocol + extension 把上一篇的 BOSS 战例彻底重构,让代码轻量、可测试、易扩展
上一篇我们用 class Entity → Monster / Boss 的经典继承树完成了需求,但留下几个隐痛:
一句话:协议只定义“契约”,不关心“怎么存”。
protocol Attackable {
func attack() -> Double
}
任何类型(class / struct / enum / actor)只要实现 attack(),就自动“符合” Attackable,从而获得多态能力。
协议本身不能存属性,但可以通过“关联属性”或“协议扩展”给出默认实现,达到“代码复用”而“不强制继承”。
语法:
extension Attackable {
func attack() -> Double { 10.0 } // 默认伤害
}
现在任何符合者如果不自己写 attack(),就自动拿到 10 点伤害。
想定制?在自己的类型里重新实现即可,不需要 override 关键字——因为协议不涉继承链。
protocol Attackable {
func attack() -> Double
}
extension Attackable {
func attack() -> Double { 10.0 }
}
protocol Locatable {
var x: Double { get set }
var y: Double { get set }
}
protocol Woundable {
var hp: Double { get set }
var maxHp: Double { get }
}
extension Woundable {
var isRage: Bool { hp < maxHp * 0.2 } // 狂暴判断
}
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)
}
}
Swift 的 struct 可以符合多个协议,享受所有默认实现,零继承。
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)
}
}
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
}
}
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)
typealias GameUnit = Attackable & Woundable & Locatable
func move(_ unit: inout GameUnit, toX x: Double, y: Double) {
unit.x = x
unit.y = y
}
一个类型别名即可把“能力包”当成一个整体使用,比继承树清爽得多。
其余场景,优先 struct + 协议。
![]()
在真实世界里,我们习惯把事物归类:车 → 自行车 → 双人自行车。
Swift 的 class 类型允许我们用同样的层级方式建模,把公共的代码放在“上层”,把差异化的代码放在“下层”,这就是继承(Inheritance)。
它带来的三大价值:
/// 基类:最普通的“车”
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
语法:
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
class Train: Vehicle {
override func makeNoise() {
print("Choo Choo")
}
}
Train().makeNoise() // Choo Choo
属性重写
子类“看不到”父类属性到底是存储型还是计算型,只能按“名字 + 类型”去匹配。
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
属性观察器重写
可以为任何继承来的属性(无论存储/计算)添加 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
class Parent {
final var id = 1 // 子类不能 override
final func foo() {} // 子类不能 override
}
final class Tool {} // 任何人写 class MyTool: Tool {} 都会编译失败
super.someMethod()
super.someProperty
super[index]
继承是“白盒复用”,子类会依赖父类实现细节,容易造成“脆弱基类”问题。Swift 提供了更轻量的组合手段:
需求:
代码:
// 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,伤害翻倍
日常开发里,我们写 for item in list 像呼吸一样自然。
但 Swift 编译器在背后悄悄做了三件事:
list.makeIterator() 拿到一个迭代器iterator.next()
item
一旦理解这三步,你就能
AsyncSequence
协议定义(核心部分,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?
}
关键知识点
count。struct:值语义保证“复制一份就从头开始”,不会意外共享状态。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 额外保证
count、endIndex、下标访问协议片段
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
}
因为多趟安全,map、filter 可以提前分配内存;
因为下标存在,Array、Dictionary、Set 都直接 conform。
编译器把
for element in container {
print(element)
}
翻译成
var iterator = container.makeIterator()
while let element = iterator.next() {
print(element)
}
理解这段模板代码,你就能:
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
}
}
原因:数组缓冲区搬迁,迭代器指针失效。
三种安全写法:
todoItems.removeAll { $0 == "B" }
let indexesToRemove = todoItems.indices.filter { todoItems[$0] == "B" }
for i in indexesToRemove.reversed() {
todoItems.remove(at: i)
}
todoItems = todoItems.filter { $0 != "B" }
协议定义
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)
}
}
需求:保持最新 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
}
IteratorProtocol → Sequence → Collection → BidirectionalCollection → RandomAccessCollection
每一层只加必要约束,绝不多要一颗糖。
结构体迭代器复制即“新游标”,避免共享状态,这点与 Objective-C 的 NSEnumerator 形成鲜明对比。
所有带指针/索引的集合都存在,掌握“先记录后改”或“一次性 API”即可。
网络下载、蓝牙数据、用户点击序列都能用同一套思维建模;配合 AsyncStream 几乎零成本桥接老代码。
RingBuffer 这类小容器写一遍,你会深刻理解“下标换算”、“容量与 count 区别”、“前置条件断言”这些日常被标准库隐藏的细节。
几天前,我像往常一样在输入 `brew update` 后顺手执行了 `brew upgrade`。出乎意料的是,终端里突然出现了从未见过的画面——大量组件与工具并行下载、整齐排列、同时推进。短暂的惊讶之后,我才从新闻中得知:Homebrew 已经发布了 5.0 版本
在 Swift Concurrency 时代,即使你把 addObserver 的 queue 设成 .main,只要闭包里调用了 @MainActor 隔离的函数,编译器依旧会甩出警告:
⚠️ Main actor-isolated property 'xxx' can not be referenced from a non-isolated context
根因:
Notification 默认被标记为 nonisolated,它与 @MainActor 之间没有建立任何“隔离约定”,编译器无法证明线程安全。
| 协议 | 作用 | 适用场景 |
|---|---|---|
MainActorMessage |
保证观察回调一定在主线程执行 | 更新 UI、访问 @MainActor 属性 |
AsyncMessage |
允许在任意隔离域异步投递 | 后台处理、跨 actor 通信 |
系统版本要求:iOS / macOS 26+
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 允许你从 userInfo、object 里取出强类型数据,告别 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)")
}
}
@MainActor
func postDidBecomeActive() {
let message = DidBecomeActiveMessage()
NotificationCenter.default.post(message, subject: UIApplication.shared)
}
由于
post方法本身被标记为@MainActor,系统保证同步投递,即观察闭包会立即在当前主线程执行,与旧 API 的“异步队列投递”行为不同。
迁移时需评估是否会对现有时序产生副作用。
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 的核心差异
@MainActor 限制@Sendable async 形式,可并发执行post 不要求主线程,异步分发假设 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 上下文 | ❌ | ✅ |
优先使用 MainActorMessage
只要最终需要刷新 UI,就直接选它,编译期强制主线程,再也不用手写 DispatchQueue.main.async。
AsyncMessage 适合“纯后台”链路
例如数据库落地、网络日志上报、跨 actor 通信,不会阻塞主线程。
逐步替换,而非一刀切
旧通知可以先封装成新消息类型,双轨并行;观察到无异常后再删除旧代码。
单元测试更友好
强类型消息让测试断言不再依赖 userInfo 魔法字符串,可读性↑ 维护性↑。
当我们关闭App再重新打开,为什么有些数据(比如登录状态、用户设置、文章草稿)还在,而有些数据(比如临时弹窗状态)却消失了?这背后就是 “本地存储” 在发挥作用。可以说,一个不懂得如何管理本地数据的开发者,很难做出用户体验好的应用。
今天,我们就来彻底搞懂Flutter中的本地存储。
举个例子:如果你每天醒来都会失忆,不记得自己的名字、家在哪里、昨天做了什么……这简直是一场灾难。对于App而言,本地存储就是它的 “记忆系统”。
主要应用场景:
Flutter拥有多种本地数据存储方案,下面我们先看下用张图来了解下存储方案脉络:
shared_preferences
shared_preferences 这个名字听起来有点拗口,但其实很简单。你可以把它理解成 Flutter 为我们在本地提供的一个 “小本子”,专门用来记录一些简单的、键值对形式的数据。
shared(共享):指这些数据在你的App内是共享的,任何页面都能读写。preferences(偏好):顾名思义,最适合存储用户的偏好设置。它的本质是什么?
在 Android 上,它背后是通过 SharedPreferences API 将数据以 XML 文件形式存储;在 iOS 上,则使用的是 NSUserDefaults。Flutter 插件帮我们统一了这两端的接口,让我们可以用一套代码搞定双端存储。
让我们看看当你调用 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线程。第一步:引入依赖
在 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('删除用户信息'),
),
],
),
),
);
}
}
getInstance() 并等待其完成。class SPKeys {
static const String username = 'username';
static const String userAge = 'user_age';
static const String isVip = 'is_vip';
}
try-catch 包裹可能出错的操作。当你的数据不适合用键值对存储时,文件存储就派上用场了:
在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() - 用户生成的内容getApplicationSupportDirectory() - App运行所需文件一个完整的文件管理类如下代码所示:
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;
}
}
}
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是一个轻量级的、文件式的关系型数据库。它不需要单独的服务器进程,整个数据库就是一个文件,非常适合移动端应用。
使用场景:
在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[数据持久化]
第一步:添加依赖
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),
),
),
);
},
),
);
}
}
当你的数据结构需要变更时,就需要数据库迁移:
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成功');
}
}
虽然直接使用SQL很强大,但ORM可以让代码更简洁。推荐 floor 或 moor:
dependencies:
floor: ^1.4.0
sqflite: ^2.0.0
batch() 进行批量插入/更新LIMIT 和 OFFSET
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;
以上选型策略概述以下:
shared_preferences
下面我们构建一个完整的用户数据管理方案,综合运用多种存储方式:
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();
}
}
至此本地数据存储的知识就全学完了,记住这几个核心要点:
本地存储是App开发的基础,掌握好它,就能开发出体验流畅、数据可靠的应用。希望这篇文章能帮助到你! 我们下期再见!
.task 默认不会“跟着变量跑”在 UIKit 时代,我们手动 addObserver、removeObserver,一不小心就忘记移除。
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:),因为:
SwiftUI 工程经理 Matt Ricketson 在 Mastodon 的回应:
for await 持续监听;因此,把“决定权”交给开发者,是最安全、最灵活的做法。
看到 .task { ... } 先问自己:闭包里用到了哪些外部变量?
只要有一个变量可能变化且希望任务重新执行,就给它一个 id。
多依赖就打包成一个 Equatable 值,别写多个 .task,否则顺序与取消策略会变得不可控。
如果任务里需要持续监听(如 AsyncSequence),记得在 task 内部手动 for await,此时 id 只用于“配置变化时重启”,而不是“每条消息都重启”。
与 .refreshable、.searchable 搭配时,同样可以用 task(id:) 实现“搜索关键字变化即重新拉取”。
单元测试:
用 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])——
把“依赖数组”显式地写在代码里,才能让副作用真正随依赖而舞,而不是“跑了一次就躺平”。
记住:任务也有身份,依赖变化就换身份证。
swift-markdown-ui (也称为 MarkdownUI) 是一个用于在 SwiftUI 中显示和自定义 Markdown 文本的开源库。
其主要特性如下:
它兼容 GitHub 风格的 Markdown 规范(GitHub Flavored Markdown Spec),基本支持所有类型 Markdown 元素,如:普通文本、标题(H1、H2 等)、图片、链接、有序无序列表、任务(Task)、引用、代码块、表格、分割线、加粗斜体等文本样式
提供了强大的主题(Theming)功能,让开发者可以精细的自定义 Markdown 样式,支持针对特定标签样式(如代码块、链接等)进行覆盖和修改
可以直接通过一个 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 的目录结构如上,主要分为四大块:
整体流程如下:
![]()
架构分层如下:
![]()
前面讲了大致的流程图,下面是详细的输入输出及处理过程:
![]()
下面我们将分别对解析、渲染、样式系统进行拆解。
使用三方库 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]数组
![]()
![]()
渲染过程分为 Block 节点处理和 Inline 节点处理。
BlockNode 处理流程如下:
![]()
InlineNode 处理流程如下:
![]()
关键代码:
![]()
![]()
每一个 Block 节点都是一个单独的自定义 View,文本节点使用 AttributedString 拼接各种加粗斜体等样式,最终由 Label 进行渲染。
下面我们挑几个难点进行讲解。
![]()
这些都是使用 iOS 系统能力,配置 AttributeContainer 实现的,支持配置的样式如下:
![]()
![]()
如上,引用有背景,左边有边框,背景色支持内容撑开,这是怎么做到的?
上面我们有提到每个 Block 节点都是一个单独的自定义 View,引用也是一个自定义 View,如下使用 HStack 将左边框和内容并排,高度靠内容撑开,关键配置是.fixedSize(horizontal: false, vertical: true),其中horizontal: false表示水平方向允许扩展,受父视图宽度约束影响,vertical: true表示垂直方向固定,完全靠内容撑开。
以此类推,代码块、任务等的样式也可以靠自定义 View 实现。
![]()
无序列表前面的小圆点/方块,以及任务前面的已完成、待完成标识是怎么实现的呢。
主要代码如下,可以看出是通过 SF Symbols,即系统自带的符号 icon 实现的
![]()
![]()
在 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 布局也有一些局限:
如下是样式系统的架构图:
![]()
swift-markdown-ui 提供了 basic、github、docC 三种内置主题,在这三个主题的基础上,支持开发者覆盖默认配置,也可以完全自定义一个新的主题传入。
样式通过 SwiftUI 的 Environment,可以很方便的实现自动注入和父子视图数据传递:
![]()
每周精读一个开源项目,文章首发公众号「非专业程序员 Ping」【精读 GitHub Weekly】专集,欢迎订阅 & 投稿!
上线了!上线了!基于Vibe Coding我终于也能独立开发出一款属于自己的app-安康记,目前终于成功上架App Store啦。
独立开发app这个事情我很早之前就有点念头,但学习Swift过程还挺痛苦的,总觉的各种语法糖很膈应,导致进度缓慢,后面就一直搁置了。ChatGPT出来之后也尝试了一点,但还是觉得当时的AI能力不够,并且纯靠聊天还是不太行,自从Cursor、Windsurf、CC、Codex这一类AI开发工具出来之后,Vibe Coding的概念也随之火热,我想也是时候重启自己的开发计划了,花了接近两个月的晚上和周末时间,终于将其开发完成并上架!
App Store地址: 安康记(apps.apple.com/cn/app/%E5%…
![]()
这个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同步,因此使用者们都无需担心隐私问题,对我个人而言,不用花钱买服务器也是极好的。
![]()
目前这个app还只是一个初版,当前的设计流程还欠缺几个功能,包括个人独立用药,即生病了也不一定去医院,可能自己在家吃一些药物,这些药物也应当可以直接记录而不是通过就诊记录。此外这个app还应当会支持体检项目,即专门记录个人阶段性的体检报告。这些功能会放在接下来的版本陆续去更新,另外现在的UI我其实谈不上多满意,但一直没找到好的方向去优化,后面也会继续尝试提升UI上的体验。
下个版本预计会在2-4周左右推出,公众号也会同步推送更新日志,后续也可能会公开需求池和开发计划,希望大家可以下载app进行体验,有什么需求或者建议可以通过留言或在app中的意见反馈中提出。谢谢大家 ღ( ´・ᴗ・` )。
你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。
Runtime 的源码发布新版本,主要更新的是 Xcode26 的 objc_storeStrong 的逻辑,有兴趣可以自行查看。
参与计划的开发者在销售符合条件的 App 内购买项目时,可享受 15% 的收益抽成减免。需要适配 Advanced Commerce API (ACA) 。微信已经发公告预计会进行接入,期待后续更多中国公司都能同样谈成优惠,让更多增值业务在 iOS 端上线。
@Smallfly:该视频围绕应用性能优化展开,结合苹果开发中心的实践场景,系统讲解了从 Swift UI 渲染到数据流管理的多维度优化策略。核心内容包括:
Observable 类优化数据流,降低卡片视图对状态变量的依赖。视频通过代码演示与数据对比,为开发者提供了从工具使用到工程实践的全链路优化指南,特别提到 Liquid Glass 和 SwiftUI 的优化,推荐有兴趣的同学按需观看。
@含笑饮砒霜:作者在文章中分享了非厂商性质技术密集型企业组建性能工程团队的核心建议,指出该团队能通过基础设施成本节约、延迟降低、可扩展性与可靠性提升、工程速度加快实现显著投资回报(如初期可减半基础设施成本,长期每年目标 5%-10% 成本节约),详细说明了性能工程师在新产品测试调优、内部工具开发、瓶颈分析、参数优化等十大核心职责,并给出组建时机与团队规模的参考规则(基础设施年支出超 100 万美元配 1 名工程师,后续每 1000-2000 万美元增 1 名;团队支出应不低于可观测性监测支出;延迟或可靠性阻碍增长时需组建),同时提及部分企业已有相关专职人员可纳入考量,后续第二部分将补充职位描述、潜在陷阱等内容。
@JonyFang: Apple 在这篇技术说明中系统讲解了设备端基础模型的上下文窗口限制以及开发者应如何应对。文章强调:本地模型不会自动截断超长输入,超过窗口会直接报错,因此必须在应用设计中主动“预算”与管理上下文。Apple 建议使用三类策略来保持对话连续性同时不溢出窗口:
整体来看,TN3193 的核心信息是:Apple 设备端模型的上下文有限,开发者必须自行设计“记忆管理策略”,否则会遇到输入过长错误。通过“滑动 + 摘要 + 保留”组合策略,可在有限窗口内维持长对话与复杂任务的质量。
@Cooper Chen:这篇文章不仅手把手带你构建了一个可工作的 Swift AI Coding Agent,更重要的是,它用非常清晰、务实的方式揭开了 "AI 编码助理" 背后的底层原理。作者强调:这些看似强大的智能行为,其实都是从「语言模型 + 工具 + 循环」这三件极其简单的事组合而成,让人一下子从使用者变成真正理解者。
文章最大的价值在于 去魅 + 实操。它不讲虚的,不堆概念,而是用不到 300 行的 Swift 代码,就实现了一个能读文件、写代码、重构逻辑、与用户来回对话的 Coding Agent,让读者第一次意识到 Cursor、Claude Code 这类产品背后并没有不可思议的魔法。
与此同时,作者也展示了真实工程中会遇到的问题:上下文膨胀、循环保护、安全、错误处理、工具设计等,让内容不仅能“跑起来”,还具备工程实用性。
如果你想理解 Coding Agent 的本质,或者想自己打造一个轻量但功能完整的 Swift Agent,这篇文章绝对值得一读。它让复杂的概念变得透明,让看似神秘的 AI 能力真正变成可掌握、可自行构建的技术。
@AidenRao:你是否也曾被 the compiler is unable to type-check this expression in reasonable time 的错误困扰?Swift 编译器团队最近发布了一份详细的路线图,旨在系统性地解决这一由来已久的问题。文章深入浅出地解释了类型检查慢的根源——由类型推导和重载解析带来的指数级复杂度。
路线图不仅展示了近期 Swift 6.2 和 6.3 在编译速度上取得的显著成果(真实项目检查时间从 42 秒降至 10 秒),还规划了未来的改进方向:包括加速大型集合字面量检查、移除历史性能 Hack,乃至引入更先进的 SAT 求解技术。如果你对 Swift 编译性能的未来走向感兴趣,这篇文章值得一读。
@david-clang:iOS 26 上 WebView 点击失效,核心仍是 iOS 18.2 起 WKWebView 的手势状态缓存问题。
当 WebView 被 Flutter overlay 遮挡时,Flutter 通过 delayingGestureRecognizer 延迟 overlay 下方的 UIKit recognizer,使其暂时不触发,从而让 overlay 接管触摸。但 iOS 18.2 起 WKWebView 的手势状态缓存问题导致 overlay 消失后,WKWebView 内部的点击 recognizer 状态仍停留在延迟状态,未能恢复到 recognized,tap 或 JS click 无法派发,元素只能高亮却无法响应点击。
解决方案:
@阿权:Apple 官方开源的二进制解析库,使用纯 Swift 编写,旨在构建安全、高效的二进制解析。该库提供了一系列用于安全解析二进制数据的工具,同时管理类型和内存安全,以消除常见的基于值的未定义行为,例如类型溢出。
Swift 一直致力于将不安全的内存操作尽可能安全地让用户访问、修改,此库跟 Swift 的思想如出一辙,本来之前 Apple 强推用 Swift 替代 C/C++ 直接操作内存,包括嵌入式也是这个切入点,此库一出如同如虎添翼了,也算是给内存操作提供一套完整的最佳实践了。
重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考
具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)
我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。
关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参
同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom 。
🚧 表示需某工具,🌟 表示编辑推荐
预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)
这是最近实战中遇到的一个小知识点,没理解之前觉得「不可能」,反应过来之后,觉得自己很蠢🤣,借本文记录下。
看一段复现代码:
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小程序支付问题画上句号。这一官方公告,标志着小程序在苹果生态中的地位获得正式认可,同时也为开发者(宿主App)指明了清晰的合规路径。
![]()
什么是小程序合作伙伴计划?
小程序合作伙伴计划,是苹果制定的一套小程序支付合规流程,面对的对象是宿主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%不太满意啊。
![]()
暂时不用做什么,上述内容都是苹果和宿主App(微信)之间的事情。
现在只需要等微信那边完成技术改造后,更新小程序的对文档、政策文档,小程序开发者届时在完成适配工作。
改造完成后的流程可能会变成:小游戏内可以直接拉起苹果支付进行付款,开发者和微信分成,微信和苹果进行分成。也不排除另外一种模式:小游戏内直接拉微信支付,微信把支付数据上报给苹果,苹果和微信分成。个人还是觉得走苹果支付的可能性更大一点。
对于上述变动,不知道小程序开发者是高兴呢,还是不高兴呢?
好处是iOS端小程序终于可以正大光明的进行支付了,而不是像之前那样”躲躲藏藏“,这点对于用户体验上肯定是更好了;坏处是,之前用的奇巧淫技不用分成或者分成很低,现在需要额外交至少15%的苹果税。
欢迎在评论区留言,谈谈你的感受。
参考来源
【苹果官方】小程序合作伙伴计划
【苹果官方】苹果审核指南
【公众号】苹果官宣:支持iOS小程序小游戏开通支付,抽成15%
在移动开发中需要与云端服务器进行频繁的数据交互,本节内容讲带你详细了解网络请求与数据解析,让你的应用真正地“活”起来。
举个例子:你正在开发一个新闻App,那些滚动的时事新闻、视频等内容,不可能全部打包在App安装包里,它们需要从服务器实时获取。这个“获取”的过程,就是通过网络请求完成的。
简单来说,流程就是:App 问服务器要数据 -> 服务器返回数据 -> App 把数据展示出来。
在Flutter中,常用的两个网络请求库:官方推荐的 http 库 和 社区维护得 dio 库。我们将从两者入手,带你彻底玩转网络请求。
http库 与 dio库 如何选择?选择哪个库,就像选择工具,没有绝对的好坏,只有合不合适。
http 库http 库是Flutter团队维护的底层库,它:
核心方法:
get(): 向指定URL发起GET请求,用于获取数据。post(): 发起POST请求,用于提交数据。put(), delete(), head() 等:对应其他HTTP方法。dio 库dio 是一个强大的第三方HTTP客户端,它:
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');
}
}
}
代码解读:
async/await:网络请求是耗时操作,必须使用异步。await 会等待请求完成,而不会阻塞UI线程。Uri.parse:将字符串URL转换为Uri对象。response.statusCode:响应状态码,200系列表示成功。json.decode():反序列化将JSON串转换为Dart中的 Map<String, dynamic> 或 List;dio 库下面我们重点讲解下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的优点:
response.data 直接就是Map或List,无需手动 json.decode,太方便了!BaseOptions 全局配置一目了然。DioException 包含了丰富的错误信息。为了让大家更直观地理解,我们用一个流程图来展示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: 返回最终结果
拦截器允许我们在请求发送前和响应返回后,插入自定义逻辑,对所有经过的请求和响应进行检查和加工。
案例:自动添加认证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正序执行,onResponse和onError倒序执行。
这是新手最容易踩坑的地方。直接从JSON转换成Dart对象(Model),能让我们的代码更安全、更易维护。
data[‘title‘]是String还是int,容易写错;post.title访问属性,比post[‘title‘]更高效且有代码提示;json_serializable自动序列化通过代码生成的方式,自动创建 fromJson 和 toJson 方法,一劳永逸。
步骤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 注解可以处理各种复杂场景;实际项目中,我们不会直接在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
各个分层职责:
这样分层的好处是:最终目的是解耦,各司其职,修改网络层不会影响业务逻辑,代码结构清晰,同事方便单元测试。
一个好的应用,必须支持处理各种异常情况。
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;
}
}
对于因网络波动导致的失败,自动重试能大幅提升用户体验。
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应用注入源源不断的活力。让我们下期见!