苹果的罕见妥协:当高危漏洞遇上“拒升”潮 - 肘子的 Swift 周报 #130
对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截止目前,已有上亿用户受到影响。
对于 iOS 用户来说,最近或多或少都会看到与 Coruna、DarkSword 有关的高危漏洞消息。两个攻击链均采用水坑攻击的方式,攻击者无需受害者进行任何交互,仅需访问一个被植入恶意 iframe 的合法网站或加载恶意广告,即可触发完整的攻击链,在窃取资料后自动清理攻击痕迹。由于工具链利用的漏洞存在于 iOS 13 至 18.7 的绝大多数版本中,截止目前,已有上亿用户受到影响。
我有一个老的域名:devtang.com,上面利用 GitHub Pages 搭了我的 博客。这个域名注册很多年了,一直在 Godaddy 上续费,并且用 DNSPod (后来被阿里收购) 做解析。
我一直想迁移到 Cloudflare,但是域名转移的操作很繁琐,所以一直没有下决心推进。
这次,我想试试用 Claude Cowork 功能帮我做这个事儿。整个流程下来,感觉还挺顺畅的,所以给大家分享一下。
我觉得 AI 时代这些工作的工作流都有变化,所以说分享这样的工作流,有助于大家建立这种基于 AI Agent 的工作模式迁移。
在使用前需要先安装好 Claude in Chrome 插件,然后执行如下操作:
1、我首先打开 Godaddy 和 Cloudflare 官网,登录上去。然后打开 Claude in Chrome 的浏览器面板。输入如下提示词:
1 |
我要将域名 devtang.com 从 godaddy 转移到 cloudflare,帮我继续转移。 |
Claude 给出了如下的操作步骤,点击 Approve Plan。
2、Claude 开始在 Godaddy 和 Cloudflare 上操作,有两次它停下来了,需要我给它发邮箱里面的授权码。于是我打开邮箱把授权码发给它。
3、操作继续,在操作过程中,我可以随意切换 Tab 看它的操作过程,也可以看它的 thinking 的过程。它其实每一步都是通过截图确认操作,也会中间停留 3-5 秒(可能是为了防止被误别成机器人)。
因为它也会停留,所以我有时候会帮它直接点击了,让操作更快一点。这也丝毫不会影响后续的工作,因为它每一步都会截图确认。
最后我看到了操作确认信息,告诉我转移成功。
Cloudflare Pages 支持无限流量,并且全球有多处结点,速度比 GitHub Pages 快。我把域名迁移过去之后,又进一步使用 Cloudflare 的 Pages 功能,将博客重新部署到了 Cloudflare 上。
具体步骤如下:
Looking to deploy Pages? Get started。这个字特别不起眼,如下图:source 分支。None
npx hexo generate
public
NODE_VERSION 设置为 24NPM_VERSION 设置为 11以上设置好就可以测试了,测试遇到问题的话,把 error log 复制发给 claude,claude 会告诉你怎么改。
配置完之后,它默认的域名是 https://tangqiaoboy.pages.dev, 你可以用刚刚迁移好的域名给它设置一个新的域名,像我就设置成了 https://www.devtang.com/。如下图:
利用这个 Pages 可以干很多事情,比如我看到一个人就拿它发布了一个 巴菲特致股东的信 网站。不需要买服务器,也不需要买域名,也不用担心流量不够。
iOS 进阶必修 · Swift 并发编程系列 第 2 期
面试官问你:"Swift 的 actor 是可重入的,你觉得这个设计合理吗?"
很多人第一反应是:可重入?那不是有 bug 风险吗?为什么不做成传统锁那样不可重入?
这篇文章就来彻底说清楚这件事。
可重入(Reentrant):actor 在 await 挂起时会释放自身的"访问权",其他任务可以趁机进入 actor 执行别的方法。
不可重入(Non-reentrant):actor 一旦被某任务占用,其他任务必须排队等待,直到当前任务彻底执行完(包括所有 await)。
用一句话概括差异:
可重入:
await是"暂时离开",锁被放开
不可重入:await是"原地等待",锁被一直握着
actor ServiceA {
let b: ServiceB
func doWork() async {
await b.help() // A 持锁,等待 B
}
}
actor ServiceB {
let a: ServiceA
func help() async {
await a.check() // B 持锁,等待 A ← 死锁!
}
}
两个 actor 互相持锁等待对方,经典死锁。
在真实业务里这种结构比比皆是——网络层调用缓存层,缓存层调用配置层,配置层又依赖某个共享状态…只要存在环形调用,就必然死锁。
而且这种死锁极难排查:没有崩溃日志,没有报错,App 就静静地卡在那里。
actor Logger {
func log(_ msg: String) async {
await writeToFile(msg) // 不可重入 → 自己等自己 → 死锁
}
func writeToFile(_ msg: String) async {
// 磁盘写入…
}
}
这意味着什么?actor 内部完全不能出现 await。但现实中 actor 管理的资源(网络、磁盘、数据库)几乎无一例外需要异步操作。
不可重入 + async/await 生态,在逻辑上根本无法自洽。
可重入把死锁风险消除了,但代价是:await 挂起期间,actor 的状态可能被其他任务修改。
这是最经典的重入陷阱,银行转账场景:
actor BankAccount {
var balance: Double = 1000
func withdraw(_ amount: Double) async throws {
// ① 检查余额:1000 >= 800,通过
guard balance >= amount else { throw InsufficientFundsError() }
// ② await 挂起,actor 释放访问权
// 另一个 withdraw(800) 趁机进来,也通过了 guard
// 它先执行,balance 变成 200
await logTransaction(amount)
// ③ 回来继续执行:800 > 200,但已经没有再次检查!
balance -= amount // balance = 200 - 800 = -600,超支!
}
}
// 并发:两个任务同时取 800
Task { try await account.withdraw(800) }
Task { try await account.withdraw(800) }
// 最终 balance = -600,资损!
问题的根源:guard 检查到 balance -= amount 之间夹着一个 await,整个操作不是原子的。
actor DataPipeline {
var isProcessing = false
var buffer: [Data] = []
func process() async {
guard !isProcessing else { return }
isProcessing = true // 设置标志
// await 挂起,另一个 process() 调用进来
// 它看到 isProcessing = true,直接 return
// 看起来没问题…但如果两个调用"同时"通过 guard 呢?
// → 取决于调度时序,存在 TOCTOU(检查-使用时差)窗口
await doHeavyWork()
isProcessing = false
}
}
actor BankAccount {
var balance: Double = 1000
// ✅ 正确写法
func withdraw(_ amount: Double) async throws {
guard balance >= amount else { throw InsufficientFundsError() }
balance -= amount // ← 先改状态(无 await,绝对原子)
await logTransaction(amount) // 再异步处理(状态已一致)
}
}
规则:guard 检查通过后,立刻完成状态变更,然后才 await。await 之后不再依赖之前检查过的条件。
actor SafeQueue {
private var items: [WorkItem] = []
private var isRunning = false
// 同步方法:无 await,绝对原子
private func takeNext() -> WorkItem? {
guard let item = items.first else { return nil }
items.removeFirst() // 取出即删除,不会被重入影响
return item
}
func drainAll() async {
guard !isRunning else { return }
isRunning = true
while let item = takeNext() {
await item.execute() // await 时 item 已从队列移除,安全
}
isRunning = false
}
}
思路:把"检查 + 修改"合并进一个不含 await 的同步方法,让它成为原子操作。
actor TaskScheduler {
private enum Phase { case idle, running, draining }
private var phase: Phase = .idle
func schedule(_ task: Task<Void, Never>) async {
guard phase == .idle else { return }
phase = .running // ← await 之前切状态,拿到"令牌"
await task.value // 其他调用看到 .running,直接 return
phase = .idle
}
}
用状态机枚举而非 Bool 标志,让每种状态的含义更清晰,也更难被误用。
| 维度 | 不可重入(传统锁语义) | 可重入(Swift actor) |
|---|---|---|
| 跨 actor 调用 | ❌ 极易死锁 | ✅ 安全 |
| actor 内部 await | ❌ 自己等自己,死锁 | ✅ 正常工作 |
| 状态一致性 | await 前后一致 | ⚠️ 开发者自行保证 |
| 死锁风险 | ❌ 高,且难排查 | ✅ 无 |
| 正确性复杂度 | 低(锁语义直觉) | 中(需理解挂起语义) |
| 与 async/await 生态兼容性 | ❌ 根本无法自洽 | ✅ 天然融合 |
这是一道"两害取其轻"的工程决策题:
Apple 把不确定性更高、危害更大的风险消除了,把相对可控的复杂性留给开发者。
从语言设计角度看,这也与 Swift 的一贯哲学吻合:编译器负责能静态验证的安全,开发者负责剩下的语义正确性。
Swift 6 的严格并发检查(
-strict-concurrency=complete)正在把越来越多的重入问题提升为编译器警告,方向是对的。
优先用可重入,配合以下纪律:
await 之前必须完成所有关键状态变更,await 之后不再信任之前读取的条件await 的同步方法await
// 完整示例:安全的资源管理 actor
actor ResourceManager {
private enum State { case idle, acquired, releasing }
private var state: State = .idle
private var resource: Resource?
// ✅ 获取资源:先拿到"凭证"再 await
func acquire() async throws -> Resource {
guard state == .idle else { throw ResourceError.busy }
state = .acquired // 改状态在 await 之前
let res = try await fetchResource()
resource = res
return res
}
// ✅ 释放资源:先清理状态再 await
func release() async {
guard state == .acquired else { return }
let res = resource
resource = nil // 先清空
state = .releasing
await cleanupResource(res)
state = .idle
}
}
| 问题 | 答案 |
|---|---|
| 可重入设计合理吗? | 合理,是工程必要性决定的,不是妥协 |
| 不可重入的最大问题? | 跨 actor 死锁 + 内部 async 调用死锁,且难排查 |
| 可重入最大的坑? | await 前后状态假设失效,经典场景是 guard 通过后 await,回来状态已变 |
| 实际项目怎么用? | 拥抱可重入,用"await 前完成状态变更"作为硬性编码纪律 |
可重入的坑有规律可循,死锁没有。选可重入,然后学会驾驭它。
Kotlin 协程的 Mutex 提供了不可重入的互斥锁,但它是手动使用的工具,而不是语言默认行为——与 Swift actor 的定位完全不同。Java 的 synchronized 则是可重入的(同一线程可以重复进入),与 Swift actor 的可重入语义有些类似,但实现机制不同。
Swift actor 的可重入设计,本质上是结构化并发思想的延伸:任务在 await 时让出资源,让其他任务有机会推进,整个系统的吞吐量更高,而不是让一个任务独占 actor 直到它的所有 await 全部完成。
如果你在项目里遇到过 actor 重入导致的 bug,欢迎评论区分享——是什么场景、如何发现、怎么修复的?优质案例会收录进下一期。
📅 本系列持续更新 ✅ 第 1 期:Swift Concurrency 基础精讲 · ➡️ 第 2 期:Actor 可重入设计深析(本期)· ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定
iOS 进阶必修 · Swift 并发编程系列 第 1 期
Swift Concurrency 是 Apple 在 Swift 5.5(iOS 15+)正式引入的原生并发框架,它让异步代码的编写、错误处理、线程安全变得声明式、结构化、且编译器可静态验证。
| 属性 | 信息 |
|---|---|
| 引入版本 | Swift 5.5 / Xcode 13 |
| 运行时最低要求 | iOS 13+(back-deploy)/ iOS 15+ 全功能 |
| 核心特性 | async/await · Task · Actor · AsyncStream |
| 与 Combine 关系 | 互补共存,AsyncSequence 可与 Combine 互转 |
| 官方文档 | Swift Concurrency |
在 Swift Concurrency 出现之前,iOS 异步编程长期面临这些问题:
| 旧方案 | Swift Concurrency |
|---|---|
| 回调嵌套(Callback Hell),可读性极差 |
async/await 线性写法,与同步代码几乎一致 |
DispatchQueue + 锁保护共享状态,极易出错 |
actor 编译器静态保证线程安全 |
DispatchGroup 聚合多个并行任务,样板代码多 |
async let / withTaskGroup 声明式并行 |
| 任务取消需要自行维护 flag,容易遗漏 | 结构化取消,父取消子自动跟随 |
线程切换 DispatchQueue.main.async {} 到处散落 |
@MainActor 注解,编译器强制保证主线程 |
Combine 学习曲线陡,操作符多 |
AsyncStream 原生支持,与 for await 天然融合 |
核心优势:
无需配置,开箱即用
Swift Concurrency 是语言特性,直接在 Xcode 13+ 的任意 Swift 文件中使用:
// Swift 5.5+ · iOS 13+ (back-deploy) / iOS 15+ (全功能)
import Foundation // 仅需标准库
async/await:异步函数的声明与调用
// ✅ 声明异步函数:加 async 关键字
func fetchUser(id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
// ✅ 调用:必须在 async 上下文中,用 await 挂起
Task {
do {
let user = try await fetchUser(id: 1)
print(user.name)
} catch {
print("加载失败:\(error)")
}
}
await是挂起点而非阻塞点:挂起时线程被释放,恢复后可能在不同线程继续执行。这是 Swift Concurrency 高效的根本原因。
深入理解:await 挂起 vs 传统回调的线程行为
这是理解 Swift Concurrency 为何高效的关键,也是很多人初学时最容易混淆的地方。
传统 GCD 回调的线程行为
// 传统方式:调用线程不阻塞,但"上下文"从此丢失
func fetchData(completion: @escaping (Data) -> Void) {
URLSession.shared.dataTask(with: url) { data, _, _ in
// 回调所在线程:URLSession 内部线程(不确定,通常是子线程)
completion(data!)
}.resume()
}
// 调用方
fetchData { data in
// ⚠️ 线程已改变,需要手动切回主线程
DispatchQueue.main.async {
self.label.text = "done" // 上下文全靠开发者自己管理
}
}
// 调用方线程立即继续往下跑(不等待,也不挂起)
print("这行代码立即执行,不等 fetchData 完成")
async/await 的线程行为
// async/await:await 是挂起点,调用线程被释放回线程池
func fetchData() async -> Data { ... }
func loadPage() async {
print("当前线程:\(Thread.current)") // 线程 A
let data = await fetchData() // ← 挂起点:线程 A 被释放,可去执行其他任务
// 恢复后:可能是不同线程,但 Actor 上下文(如 @MainActor)被自动还原
print("恢复线程:\(Thread.current)") // 可能是线程 B,但上下文依然正确
updateUI(data) // 如果在 @MainActor 中,编译器保证这里一定在主线程
}
两者最本质的区别:线程是否被"占用"
| 维度 | 传统 GCD 回调 | async/await |
|---|---|---|
| 调用方线程 | 继续运行(不等待,不挂起) | 挂起,线程释放回线程池 |
| 等待期间 | 调用线程去干别的事(但无连接) | 线程被其他任务复用 |
| 回调/恢复线程 | 由 GCD 队列决定,不确定 | 由调度器决定,保留 Actor 上下文 |
| 代码连续性 | 回调嵌套,逻辑分散 | 线性代码,逻辑连续 |
| 线程安全 | 手动管理,容易出错 | 编译器 + Actor 静态保证 |
⚠️ 注意:传统回调的调用方线程确实不阻塞,这点和
await一样。但两者的关键区别在于:传统回调是"断开连接"继续跑,而await是"挂起等待"并能恢复连续执行上下文。
为什么 async/await 不会导致线程爆炸
传统 GCD 的一个隐患:当你用 semaphore.wait() 或 DispatchGroup.wait() 真正"等"结果时,线程被阻塞(占着资源啥也不干)。系统发现线程不够用时会持续创建新线程,最终导致线程爆炸。
// ❌ 危险:阻塞线程(传统方式的隐患)
let sema = DispatchSemaphore(value: 0)
fetchData { data in sema.signal() }
sema.wait() // 线程在此阻塞,占着系统资源却无法被复用
// 并发请求多时,可能导致线程数量爆炸
Swift Concurrency 的协作式线程池解决了这个问题:
// ✅ 协作式挂起:线程释放回池子,完全不阻塞
let data = await fetchData()
// 线程池始终维持在约等于 CPU 核数的小规模,高效复用
Swift Concurrency 的线程池设计
传统 GCD 线程池(可能爆炸) Swift Concurrency 协作式线程池
┌──────────────────────────┐ ┌──────────────────────────┐
│ 线程1(等待网络,阻塞) │ │ 线程1(执行 Task A) │
│ 线程2(等待数据库,阻塞) │ │ 线程2(执行 Task B) │
│ 线程3(等待文件,阻塞) │ vs │ 线程3(执行 Task C) │
│ 线程4(新建中...) │ │ ← 线程数 ≈ CPU 核数 │
│ 线程N(继续新建...) 💥 │ │ Task 挂起时释放线程,不占用 │
└──────────────────────────┘ └──────────────────────────┘
一句话总结:
wait() 等待时会阻塞线程这就是为什么同样是"异步",Swift Concurrency 在高并发场景下比传统 GCD 回调效率更高、更安全。
SwiftUI 中使用 .task 修饰符(推荐)
struct UserView: View {
@State private var user: User?
var body: some View {
Text(user?.name ?? "加载中...")
.task {
// 视图消失时任务自动取消,无需手动管理
user = try? await fetchUser(id: 1)
}
}
}
async let:并行执行多个任务
// ❌ 顺序执行:总耗时 = 500ms + 300ms + 200ms = 1000ms
let user = try await fetchUser(id: 1)
let orders = try await fetchOrders(uid: 1)
let profile = try await fetchProfile(uid: 1)
// ✅ async let 并行:总耗时 = max(500ms, 300ms, 200ms) = 500ms
async let user = fetchUser(id: 1)
async let orders = fetchOrders(uid: 1)
async let profile = fetchProfile(uid: 1)
let (u, o, p) = try await (user, orders, profile)
// 三行代码实现并行,耗时减半
withTaskGroup:动态数量的并行任务
// 并行下载数量不固定的图片列表
func downloadImages(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask { try await fetchImage(from: url) }
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
Task:非结构化任务与取消
// 创建任务(继承当前 actor 上下文)
let task = Task(priority: .userInitiated) {
for i in 1...100 {
try Task.checkCancellation() // 取消时自动 throw CancellationError
await processItem(i)
}
}
// 取消(协作式,不会强制停止)
task.cancel()
// Task.detached:不继承 actor 上下文,完全独立
Task.detached(priority: .background) {
let result = await heavyComputation()
await MainActor.run { updateUI(result) }
}
Continuation:桥接旧式回调 API
// 将旧式 completion block API 包装为 async 函数
func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
locationManager.requestLocation { location, error in
if let error {
continuation.resume(throwing: error)
} else if let location {
continuation.resume(returning: location)
}
}
}
}
// ⚠️ resume 只能调用一次,多次调用会 crash
核心模块职责划分
| 特性 | 职责 | 适用场景 |
|---|---|---|
async/await |
异步函数声明与挂起 | 任何异步 IO 操作 |
async let |
静态数量并行任务 | 首页多接口聚合 |
Task |
非结构化任务单元 | 按钮触发的独立操作 |
withTaskGroup |
动态数量结构化并发 | 批量下载/处理 |
actor |
数据竞争保护 | 共享状态管理 |
@MainActor |
主线程强制约束 | UI 更新 |
Sendable |
跨边界类型安全 | actor 参数/返回值 |
AsyncStream |
自定义异步序列 | 事件流/实时数据 |
场景:AI 流式问答 + 打字机渲染
这是目前最热门的应用场景之一,完整演示了 AsyncStream + Task + @MainActor 的协同工作。
// Swift 5.5+
// MARK: - 1. 流式 AI 服务层(可替换为真实 SSE 接口)
enum AIStreamService {
/// Mock:逐字符推送,实际项目替换为 URLSession.bytes 读取 SSE
static func stream(prompt: String) -> AsyncStream<String> {
let response = "Swift Concurrency 让并发编程如行云流水," +
"async/await 消除回调地狱,Actor 守护数据安全," +
"AsyncStream 带来流式体验。🚀"
return AsyncStream { continuation in
Task {
for char in response {
guard !Task.isCancelled else {
continuation.finish()
return
}
continuation.yield(String(char))
try? await Task.sleep(nanoseconds: 60_000_000) // 60ms/字
}
continuation.finish()
}
}
}
/// 接入真实 SSE 接口(生产参考)
static func streamFromSSE(url: URL) -> AsyncStream<String> {
AsyncStream { continuation in
Task {
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await line in bytes.lines {
guard line.hasPrefix("data: "),
let data = line.dropFirst(6).data(using: .utf8),
let json = try? JSONDecoder().decode(TokenResponse.self, from: data)
else { continue }
continuation.yield(json.token)
}
continuation.finish()
}
}
}
}
// MARK: - 2. SwiftUI 打字机视图
struct TypewriterView: View {
@State private var prompt = "Swift 并发编程"
@State private var output = ""
@State private var isStreaming = false
@State private var streamTask: Task<Void, Never>?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
TextField("输入问题…", text: $prompt)
.textFieldStyle(.roundedBorder)
// 打字机光标效果
Text(output + (isStreaming ? "▌" : ""))
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(10)
.animation(.none, value: output)
HStack(spacing: 12) {
Button(isStreaming ? "生成中…" : "开始生成") {
startStream()
}
.buttonStyle(.borderedProminent)
.disabled(isStreaming)
Button("停止") {
streamTask?.cancel()
isStreaming = false
}
.buttonStyle(.bordered)
.tint(.red)
.disabled(!isStreaming)
}
}
.padding()
.onDisappear { streamTask?.cancel() } // ✅ 离开页面时取消
}
private func startStream() {
streamTask?.cancel()
output = ""
isStreaming = true
streamTask = Task {
for await token in AIStreamService.stream(prompt: prompt) {
output += token // SwiftUI 自动感知变化实时渲染
}
isStreaming = false
}
}
}
// MARK: - 3. UIKit 打字机控制器(@MainActor 保证 UI 安全)
@MainActor
class TypewriterViewController: UIViewController {
private let textView = UITextView()
private var streamTask: Task<Void, Never>?
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
streamTask?.cancel() // ✅ 离开页面时取消,防止内存泄漏
}
@objc func startStream() {
streamTask?.cancel()
textView.text = ""
streamTask = Task {
for await token in AIStreamService.stream(prompt: "UIKit") {
guard !Task.isCancelled else { break }
textView.text += token
// 自动滚到底部
let range = NSRange(location: textView.text.count - 1, length: 1)
textView.scrollRangeToVisible(range)
}
}
}
}
这个示例完整演示了:AsyncStream 的创建与消费、Task 的取消管理、@MainActor 的 UI 安全保证、SwiftUI 和 UIKit 的两套接入方式。
Actor 并发计数器(告别 DispatchQueue + 锁)
// ❌ 传统写法:容易因忘记加锁而出现数据竞争
class Counter {
var value = 0
let queue = DispatchQueue(label: "counter.queue")
func increment() { queue.sync { value += 1 } }
}
// ✅ actor:编译器静态保证,忘加 await 直接报错
actor SafeCounter {
private(set) var value = 0
func increment() { value += 1 }
}
// 并发使用:1000 个任务同时递增,结果一定是 1000
let counter = SafeCounter()
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1000 {
group.addTask { await counter.increment() }
}
}
print(await counter.value) // 1000,绝无数据竞争
AsyncStream 资源安全回收
// 定时器流:onTermination 防止 timer 泄漏
func timerStream(interval: Double) -> AsyncStream<Int> {
AsyncStream { continuation in
var tick = 0
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
tick += 1
continuation.yield(tick)
}
// ✅ 流取消/结束时自动调用,清理外部资源
continuation.onTermination = { _ in
timer.invalidate()
}
}
}
结构化并发的思想来源
结构化并发的核心理念来自结构化编程的类比:就像 if/for/while 让控制流有明确的进入和退出点,结构化并发让并发任务的生命周期也有明确的边界。
// 传统 GCD:任务生命周期不受控
func fetchData() {
DispatchQueue.global().async {
// 这个任务完全脱离 fetchData 的控制
// fetchData 返回后,任务仍在跑
}
}
// 结构化并发:任务生命周期受作用域约束
func fetchData() async {
async let result = networkCall() // 任务在这里创建
let data = await result // 函数返回前,任务必须完成
} // ← 离开作用域,所有子任务保证已结束
三大核心约束
| 约束 | 含义 |
|---|---|
| 父子关系 | 子任务归属于父任务,父任务取消时子任务自动取消 |
| 生命周期包含 | 父任务不能在子任务完成之前结束 |
| 错误传播 | 子任务的错误必须传递给父任务处理 |
非结构化 vs 结构化对比
// ❌ 非结构化(Task.detached)—— 孤儿任务,生命周期不受控
Task.detached {
await riskyOperation() // 即使调用方已取消,这里仍然在跑
}
// ✅ 结构化(async let / TaskGroup)—— 任务有明确的父子关系
await withTaskGroup(of: String.self) { group in
group.addTask { await fetch("A") }
group.addTask { await fetch("B") }
// 离开 withTaskGroup 之前,所有子任务保证结束
}
这套思想由 Nathaniel J. Smith 的 Notes on structured concurrency 奠基,Swift 从 5.5 开始通过
async let、TaskGroup、actor全面落地。与 Kotlin 协程的 StructuredConcurrency 一脉相承,但 Swift 通过编译器强制实施,更难写错。
结构化并发:任务树模型
Swift Concurrency 引入了"结构化并发"概念——任务形成父子树形结构:
父任务(Task)
├── 子任务 A(async let)
├── 子任务 B(async let)
└── TaskGroup
├── 子任务 C(addTask)
└── 子任务 D(addTask)
关键特性:
这与 Kotlin 协程的 StructuredConcurrency 思想一脉相承,但 Swift 通过编译器强制实施,更难写错。
Actor 的可重入设计
Actor 内部通过隐式串行队列保证数据安全,但它是可重入的:
actor BankAccount {
var balance: Double = 1000
// ⚠️ 重入陷阱:await 挂起期间,其他任务可进入 actor 修改 balance
func withdrawUnsafe(amount: Double) async throws {
guard balance >= amount else { throw BankError.insufficient }
await logTransaction(amount) // 挂起!balance 可能被别的 withdraw 修改
balance -= amount // 此时 balance 可能已不足!
}
// ✅ 正确:先修改状态再 await
func withdrawSafe(amount: Double) async throws {
guard balance >= amount else { throw BankError.insufficient }
balance -= amount // 先扣,在 await 之前完成关键状态变更
await logTransaction(amount)
}
}
规则:actor 中,await 之前必须完成所有关键状态变更。
问题 1:Continuation.resume 调用了多次导致 crash
func safeContinuation<T>(_ block: (@escaping (T) -> Void) -> Void) async -> T {
await withCheckedContinuation { continuation in
var resumed = false
block { value in
guard !resumed else { return }
resumed = true
continuation.resume(returning: value)
}
}
}
问题 2:Task.detached 中直接更新 UI 导致崩溃
Task.detached 不继承当前 actor 上下文,不在主线程// ❌ 危险
Task.detached { self.label.text = "done" }
// ✅ 正确
Task.detached {
let result = await process()
await MainActor.run { self.label.text = result }
}
问题 3:视图消失后 Task 仍在运行,导致内存泄漏
.task {} 修饰符(自动管理),UIKit 在 viewWillDisappear 中 cancel// UIKit
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
loadTask?.cancel()
}
问题 4:Actor 重入性导致余额多扣
问题 5:AsyncStream 中 timer / 监听器未释放,持续运行
continuation.onTermination
onTermination,清理外部资源continuation.onTermination = { reason in
timer.invalidate()
notificationCenter.removeObserver(observer)
}
问题 6:withTaskGroup 中子任务抛出错误没有被感知
withTaskGroup(不抛出版),错误被吞掉withThrowingTaskGroup
// ✅ 任意子任务失败,整个 group 取消并抛出错误
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls { group.addTask { try await fetch(url) } }
for try await data in group { process(data) }
}
问题 7:在 iOS 13 / 14 上使用 actor 报链接错误
@available(iOS 15, *) 包裹| 方案 | 简介 | 学习曲线 | 线程安全 | 取消支持 | 适用场景 |
|---|---|---|---|---|---|
| Swift Concurrency | Swift 原生,语言级别支持 | 中 | 编译器保证(actor) | 结构化取消 | 新项目首选 |
| GCD + DispatchQueue | 苹果传统并发方案 | 低 | 手动加锁,容易出错 | 无原生支持 | 老项目维护 |
| Combine | 响应式框架,操作符丰富 | 高 | 需手动 receive(on:) | AnyCancellable | 复杂数据流转换 |
| PromiseKit | 基于 Promise 的链式回调 | 中 | 无特殊支持 | 有限支持 | OC/早期 Swift 项目 |
| RxSwift | 响应式编程全家桶 | 很高 | 需配置 scheduler | Disposable | 重度响应式架构 |
Continuation 包装,对调用方透明.values 属性转为 AsyncSequence 互通-strict-concurrency=complete),提前消灭隐患github.com/yourname/ios-swift-concurrency-demos
基于本文的 AsyncStream 示例,实现一个实时心跳检测器:
AsyncStream 每隔 1 秒 yield 一次当前时间戳continuation.finish() 结束流.task {} 消费流,将每次时间戳展示在列表中task.cancel() 终止流,并验证 onTermination 被调用完成后在评论区贴出你的 AsyncStream 创建代码和 onTermination 实现,说明你是如何验证资源正确释放的。
Swift Concurrency 的 actor 选择了可重入设计——即 await 挂起时允许其他任务进入 actor 执行。你认为这个设计决策是合理的吗?
如果设计成不可重入(像传统锁一样),会带来哪些问题?可重入又会引发哪些坑?在你的实际项目中,你更希望哪种行为?
下一期预计深入讲解 Swift Concurrency 进阶:自定义 AsyncSequence、结构化并发原理、Swift 6 严格并发检查实战。
如果你在项目中迁移到 Swift Concurrency 时踩过坑(特别是 actor 重入、Sendable 编译报错、Task 泄漏等),欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!
📅 本系列持续更新 ➡️ 第 1 期:Swift Concurrency 基础精讲(本期)· ○ 第 2 期:Actor 可重入设计深析 · ○ 第 3 期:Swift 6 严格并发检查实战 · ○ 第 4 期:待定
iOS三方库精读 · 第 1 期
Alamofire 是一个用于 iOS / macOS / watchOS / tvOS 的 Swift HTTP 网络库,它让发起网络请求、处理响应、上传/下载文件变得声明式、可组合、且极易阅读。
| 属性 | 信息 |
|---|---|
| ⭐ GitHub Stars | ~41k |
| 最新版本 | 5.x(当前 5.9+) |
| License | MIT |
| 支持平台 | iOS 10+ / macOS 10.12+ / tvOS 10+ / watchOS 3+ |
| Swift 最低版本 | Swift 5.7+ |
苹果的 URLSession 功能完整,但在工程实践中会遇到这些问题:
| 原生 URLSession | Alamofire |
|---|---|
| 需要手动构建 URLRequest | 链式 API,一行发起请求 |
| 响应解析需要大量样板代码 | 内建 Decodable 自动解析 |
| 上传/下载进度管理繁琐 | 原生支持进度回调 |
| 拦截器/重试需要自行实现 | 内建 RequestInterceptor
|
| 错误处理分散、不统一 | 统一的 AFError 体系 |
核心优势:
集成方式(SPM 推荐)
在 Package.swift 或 Xcode 的 Package Dependencies 中添加:
https://github.com/Alamofire/Alamofire.git
最简单的 GET 请求
// Swift 5.7+
import Alamofire
AF.request("https://httpbin.org/get").responseJSON { response in
print(response.value ?? "No data")
}
使用 async/await(推荐)
let response = await AF.request("https://httpbin.org/get")
.serializingDecodable(MyModel.self)
.response
switch response.result {
case .success(let model): print(model)
case .failure(let error): print(error)
}
带参数的 POST 请求
let parameters: [String: Any] = ["username": "swift", "password": "123456"]
AF.request(
"https://httpbin.org/post",
method: .post,
parameters: parameters,
encoding: JSONEncoding.default,
headers: ["Authorization": "Bearer your_token"]
)
.validate(statusCode: 200..<300) // 自动校验状态码
.responseDecodable(of: LoginResponse.self) { response in
// 直接拿到强类型 Model
}
文件上传(带进度)
AF.upload(
multipartFormData: { form in
form.append(fileData, withName: "file", fileName: "photo.jpg", mimeType: "image/jpeg")
},
to: "https://example.com/upload"
)
.uploadProgress { progress in
print("上传进度:\(progress.fractionCompleted)")
}
.responseDecodable(of: UploadResult.self) { response in
print(response.value)
}
文件下载
let destination = DownloadRequest.suggestedDownloadDestination()
AF.download("https://example.com/file.zip", to: destination)
.downloadProgress { progress in
print("下载进度:\(Int(progress.fractionCompleted * 100))%")
}
.responseURL { response in
print("保存路径:\(response.fileURL)")
}
Alamofire 5 的核心模块职责:
| 模块 | 职责 |
|---|---|
Session |
对 URLSession 的封装,全局入口(AF 是默认单例) |
Request 体系 |
DataRequest / UploadRequest / DownloadRequest 三条请求链路 |
RequestInterceptor |
adapt 修改请求 + retry 重试逻辑分离 |
ResponseSerializer |
将 Data 转换为目标类型,可自定义扩展 |
EventMonitor |
全链路事件监听,用于日志/埋点 |
场景:带 Token 自动刷新的 API 客户端
这是工程中最常见的场景——Token 过期后自动刷新并重试原始请求。
// Swift 5.7+
// 1. 定义拦截器
final class AuthInterceptor: RequestInterceptor {
private var accessToken: String = KeychainHelper.accessToken
private var isRefreshing = false
private var requestsToRetry: [RetryCompletion] = []
// adapt:每次请求前注入 Token
func adapt(_ urlRequest: URLRequest,
for session: Session,
completion: @escaping (Result<URLRequest, Error>) -> Void) {
var request = urlRequest
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
completion(.success(request))
}
// retry:401 时触发刷新
func retry(_ request: Request,
for session: Session,
dueTo error: Error,
completion: @escaping RetryCompletion) {
guard let response = request.task?.response as? HTTPURLResponse,
response.statusCode == 401 else {
completion(.doNotRetry)
return
}
requestsToRetry.append(completion)
guard !isRefreshing else { return }
refreshToken { [weak self] success in
self?.requestsToRetry.forEach { $0(success ? .retry : .doNotRetry) }
self?.requestsToRetry.removeAll()
}
}
private func refreshToken(completion: @escaping (Bool) -> Void) {
isRefreshing = true
AF.request("https://api.example.com/refresh",
method: .post,
parameters: ["refreshToken": KeychainHelper.refreshToken])
.responseDecodable(of: TokenResponse.self) { [weak self] response in
self?.isRefreshing = false
if let token = response.value?.accessToken {
self?.accessToken = token
KeychainHelper.accessToken = token
completion(true)
} else {
completion(false)
}
}
}
}
// 2. 创建自定义 Session(全局单例,推荐)
enum APIClient {
static let session = Session(interceptor: AuthInterceptor())
static func fetchUserProfile() async throws -> UserProfile {
try await session.request("https://api.example.com/profile")
.validate()
.serializingDecodable(UserProfile.self)
.value // throws on error
}
}
// 3. 调用
Task {
do {
let profile = try await APIClient.fetchUserProfile()
print("用户:\(profile.name)")
} catch {
print("请求失败:\(error)")
}
}
这个示例涵盖了:Token 注入、自动刷新、队列等待、async/await 调用——工程级最常见的模式。
链式调用设计
Alamofire 所有方法都返回 Self(请求对象本身),使得可以无限链式组合:
AF.request(url)
.validate() // 校验
.responseDecodable(of: T.self) // 解析
.uploadProgress { } // 进度
// 每一步都是独立关注点,互不干扰
自定义 ResponseSerializer
// 扩展支持自定义格式(如 protobuf)
struct ProtobufSerializer<T: Message>: ResponseSerializer {
func serialize(request: URLRequest?, response: HTTPURLResponse?,
data: Data?, error: Error?) throws -> T {
guard let data = data else { throw AFError.responseSerializationFailed(...) }
return try T(serializedData: data)
}
}
责任链模式(Chain of Responsibility)
RequestInterceptor 的 adapt → retry 两个钩子将「请求构造」与「失败重试」完全分离,任何一个环节都可以独立替换,不影响其他逻辑。这是典型的责任链 + 开闭原则实践。
EventMonitor:观察者模式的正确姿势
// 实现一个打印所有请求的 Logger
final class NetworkLogger: EventMonitor {
func requestDidFinish(_ request: Request) {
print("✅ \(request.request?.url?.absoluteString ?? "")")
}
func request<Value>(_ request: DataRequest,
didParseResponse response: DataResponse<Value, AFError>) {
print("📦 StatusCode: \(response.response?.statusCode ?? 0)")
}
}
// 注入 Session
let session = Session(eventMonitors: [NetworkLogger()])
不侵入业务代码,零耦合实现全链路可观测——比 print 打散在各处优雅得多。
问题 1:responseJSON 废弃警告
responseJSON 被标记为 deprecated,官方推荐 responseDecodable
Decodable Model,使用 .responseDecodable(of: MyModel.self)
问题 2:多个请求并发刷新 Token 导致死循环
isRefreshing flag + 队列缓存等待回调(见上方实战示例)问题 3:AF.request 在 Background Task 中失效
Session 使用前台 URLSession 配置URLSessionConfiguration.background(withIdentifier:)
let config = URLSessionConfiguration.background(withIdentifier: "com.app.bg")
let bgSession = Session(configuration: config)
问题 4:上传大文件内存暴涨
Data 形式上传会将整个文件加载进内存fileURL 形式上传,Alamofire 会以流式方式读取AF.upload(fileURL, to: "https://example.com/upload")
问题 5:.validate() 没有按预期触发
.validate(),Alamofire 默认不对 4xx/5xx 报错.validate(statusCode: 200..<300)
问题 6:响应在主线程,但 UI 更新闪烁
responseDecodable 默认回调在主队列,但复杂解析会短暂阻塞queue: 参数将解析切到后台,主动 dispatch 到主线程更新 UIAF.request(url).responseDecodable(of: T.self, queue: .global(qos: .userInitiated)) { response in
DispatchQueue.main.async { /* 更新 UI */ }
}
| 库 | 语言 | 特点 | 学习曲线 | 维护状态 |
|---|---|---|---|---|
| Alamofire | Swift | 功能全面,生态最成熟 | 中 | 活跃 |
| Moya | Swift | 基于 Alamofire,API 抽象层 | 中高 | 活跃 |
| URLSession + async/await | Swift | 零依赖,苹果原生 | 低(但样板多) | 官方 |
| AFNetworking | Objective-C | OC 项目首选 | 低 | 维护模式 |
github.com/yourname/ios-lib-demos
基于本文的 AuthInterceptor 示例,扩展实现以下功能:当 Token 刷新失败(服务端返回 400)时,自动跳转到登录页,并取消所有等待中的请求。在评论区贴出你的关键代码实现。
Alamofire 的 RequestInterceptor 将「修改请求」和「重试决策」放在同一个对象里——你认为这是合理的设计吗?如果让你重新设计这个接口,你会如何拆分职责?
下一期预计介绍 Kingfisher(图片加载库),如果你在使用 Kingfisher 时踩过坑,欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!
📅 本系列每周五晚更新 ➡️ 第1期:Alamofire · ○ 第2期:Kingfisher · ○ 第3期:待定 · ○ 第4期:待定
GRDB.swift 是一个基于 SQLite 的 Swift 数据库工具包,专注于应用开发体验。本文将从核心概念、关键类、使用方法、最佳实践等维度全面介绍 GRDB。
在 iOS/macOS 数据持久化领域,开发者通常面临以下选择:
| 方案 | 优势 | 劣势 |
|---|---|---|
| Core Data | Apple 原生、与 SwiftUI 深度集成 | 学习曲线陡峭、性能开销大、调试困难 |
| Realm | API 简洁、实时同步 | 内存占用高、闭源、版本迁移复杂 |
| FMDB | 轻量、成熟 | 缺乏类型安全、API 偏 Objective-C 风格 |
| SQLite.swift | 类型安全、轻量 | 功能相对基础、缺少迁移和响应式 |
| GRDB.swift | 类型安全 + 功能完整 + 高性能 + 纯 Swift | 学习成本略高于 SQLite.swift |
GRDB 的核心优势:
FetchableRecord、PersistableRecord 协议和 Column 泛型实现编译时检查ValueObservation,原生支持 CombineDatabaseMigrator,支持增量式 Schema 变更Swift Package Manager:
dependencies: [
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0")
]
CocoaPods:
pod 'GRDB.swift'
GRDB 的 API 围绕一组核心协议和类构建,理解它们是高效使用 GRDB 的前提:
| 协议 | 作用 | 说明 |
|---|---|---|
FetchableRecord |
从数据库行读取数据 | 定义如何将查询结果映射为 Swift 类型 |
PersistableRecord |
将数据写入数据库 | 定义如何将 Swift 类型持久化 |
MutablePersistableRecord |
可变持久化记录 | 支持插入后自增 ID 回写等场景 |
TableRecord |
表记录 | 声明表名,提供查询入口(如 FTS5 的 .matching()) |
DatabaseValueConvertible |
数据库值转换 | 自定义类型与 SQLite 值的双向转换 |
| 类 | 作用 | 说明 |
|---|---|---|
DatabasePool |
连接池(推荐) | 支持并发读写,多读连接 + 单写连接 |
DatabaseQueue |
串行队列 | 适用于需要严格串行化访问的场景 |
DatabaseMigrator |
数据库迁移 | 管理增量式 Schema 变更 |
ValueObservation |
响应式观察 | 追踪数据库变化,自动触发重新查询 |
Configuration |
配置 | 外键约束、日志、WAL 模式等 |
DatabaseError |
错误类型 | 封装 SQLite 错误码和信息 |
| 类型 | 作用 |
|---|---|
Column |
类型安全的列引用,支持链式查询 |
ForeignKey |
外键定义,用于关联查询 |
FTS5Pattern |
FTS5 搜索模式 |
SQLLiteral |
安全的 SQL 片段构建 |
import GRDB
// 推荐:DatabasePool 支持并发读,适合大多数应用场景
var config = Configuration()
config.foreignKeysEnabled = true // 启用外键约束
let dbPool = try DatabasePool(path: "/path/to/db.sqlite", configuration: config)
// 替代方案:DatabaseQueue 串行访问
let dbQueue = try DatabaseQueue(path: "/path/to/db.sqlite", configuration: config)
选择建议:
DatabasePool — 读操作可以并发执行,性能更好DatabaseQueue
// 读操作
let users = try dbPool.read { db in
try User.fetchAll(db)
}
// 写操作
try dbPool.write { db in
try user.insert(db)
}
// 批量写入(整个闭包自动包裹在事务中,非常方便)
try dbPool.write { db in
for todo in todos {
try todo.insert(db)
}
}
提示:
DatabasePool.write { }闭包默认就是一个事务,中途抛出异常会自动回滚,无需手动管理。
GRDB 推荐使用 struct + Codable 模式定义模型。只要你的 struct 遵循 Codable,就能极简地接入 GRDB:
import GRDB
struct User: Codable, FetchableRecord, PersistableRecord, Identifiable {
var id: Int64
var name: String
var email: String
var createdAt: Date
static let databaseTableName = "users"
}
就这样,一个可用的 GRDB 模型就定义好了。Codable 负责自动编解码,FetchableRecord 支持查询,PersistableRecord 支持写入。
当 Swift 属性名(camelCase)和数据库列名(snake_case)不一致时,用 CodingKeys 映射:
struct Todo: Codable, FetchableRecord, PersistableRecord, Identifiable, Sendable {
var id: String
var title: String
var isCompleted: Bool
var createdAt: Date
static let databaseTableName = "todos"
// Swift 属性名 → 数据库列名映射
enum CodingKeys: String, CodingKey {
case id, title
case isCompleted = "is_completed"
case createdAt = "created_at"
}
// 类型安全的列引用,用于查询构建
enum Columns {
static let id = Column(CodingKeys.id)
static let title = Column(CodingKeys.title)
static let isCompleted = Column(CodingKeys.isCompleted)
static let createdAt = Column(CodingKeys.createdAt)
}
}
三个组件各司其职:
| 组件 | 职责 |
|---|---|
CodingKeys |
属性名 ↔ 列名映射,Codable 自动使用 |
Columns |
为查询提供类型安全引用,编译时检查列名 |
databaseTableName |
声明对应的 SQLite 表名 |
DatabaseMigrator 是 GRDB 的迁移系统,支持增量式 Schema 变更。每个迁移有唯一标识符,只会执行一次:
var migrator = DatabaseMigrator()
// v1: 创建初始表结构
migrator.registerMigration("v1_create_tables") { db in
try db.create(table: "todos") { t in
t.column("id", .text).primaryKey()
t.column("title", .text).notNull()
t.column("is_completed", .integer).notNull().defaults(to: 0)
t.column("created_at", .datetime).notNull().defaults(to: Date())
}
try db.create(table: "tags") { t in
t.column("id", .text).primaryKey()
t.column("name", .text).notNull()
t.column("color", .text)
}
// 外键 + 级联删除
try db.create(table: "todo_tags") { t in
t.column("todo_id", .text).notNull()
.references("todos", onDelete: .cascade)
t.column("tag_id", .text).notNull()
.references("tags", onDelete: .cascade)
t.primaryKey(["todo_id", "tag_id"])
}
}
// v2: 增量添加新字段
migrator.registerMigration("v2_add_priority") { db in
// 防御性检查:避免重复迁移导致崩溃
let columns = try db.columns(in: "todos")
if !columns.contains(where: { $0.name == "priority" }) {
try db.alter(table: "todos") { t in
t.add(column: "priority", .integer).defaults(to: 0)
}
}
}
// 执行迁移(自动执行所有尚未执行的版本)
try migrator.migrate(dbPool)
最佳实践:
"v1_create_tables", "v2_add_priority")defaults(to:) 设置默认值,保证旧数据兼容.references(..., onDelete: .cascade) 自动清理关联数据let todo = Todo(id: UUID().uuidString, title: "学习 GRDB", isCompleted: false, createdAt: Date())
// 基本插入
try dbPool.write { db in
try todo.insert(db)
}
// 冲突时忽略(适合幂等写入,比如初始种子数据)
try todo.insert(db, onConflict: .ignore)
// Upsert(INSERT OR REPLACE,存在则更新)
try todo.save(db)
// 按 ID 查询单条
let todo: Todo? = try dbPool.read { db in
try Todo.fetchOne(db, key: id)
}
// 条件查询 + 排序
let activeTodos: [Todo] = try dbPool.read { db in
try Todo
.filter(Todo.Columns.isCompleted == false)
.order(Todo.Columns.createdAt.desc)
.fetchAll(db)
}
// 计数
let total = try dbPool.read { db in
try Todo.fetchCount(db)
}
// 聚合查询(如获取最大排序序号)
let maxOrder: Int? = try dbPool.read { db in
try Tag.select(max(Tag.Columns.sortOrder))
.asRequest(of: Int?.self)
.fetchOne(db)
}
// 更新单个对象
try dbPool.write { db in
var todo = try Todo.fetchOne(db, key: id)!
todo.isCompleted = true
try todo.update(db)
}
// 批量更新(高效:一条 SQL 搞定)
try dbPool.write { db in
try Todo
.filter(ids.contains(Todo.Columns.id))
.updateAll(db, [
Todo.Columns.isCompleted.set(to: true)
])
}
// 删除单条
try dbPool.write { db in
try Todo.deleteOne(db, key: id)
}
// 条件批量删除
try dbPool.write { db in
try Todo.filter(Todo.Columns.isCompleted == true).deleteAll(db)
}
GRDB 提供了强大的类型安全查询接口,基于 Column 泛型,告别字符串拼接 SQL:
// WHERE + ORDER BY
let results = try Todo
.filter(Todo.Columns.isCompleted == false)
.order(Todo.Columns.createdAt.desc)
.fetchAll(db)
// 多条件组合
let results = try Todo
.filter(Todo.Columns.isCompleted == false)
.filter(Todo.Columns.createdAt > yesterday)
.order(Todo.Columns.createdAt.desc)
.fetchAll(db)
// LIKE 模糊查询
let results = try Todo
.filter(Todo.Columns.title.like("%GRDB%"))
.fetchAll(db)
// IN 查询
let results = try Todo
.filter([id1, id2, id3].contains(Todo.Columns.id))
.fetchAll(db)
// 第 3 页,每页 20 条
let page = try Todo
.order(Todo.Columns.createdAt.desc)
.limit(20, offset: 40)
.fetchAll(db)
相比手写
SELECT * FROM todos LIMIT 20 OFFSET 40,类型安全查询能在编译期发现列名拼写错误,代码也更易读。
GRDB 提供了原生的关联系统,支持一对多和多对多关系。以经典的「文章-标签」多对多关系为例:
// 标签
struct Tag: Codable, FetchableRecord, PersistableRecord, Identifiable {
var id: String
var name: String
var color: String?
static let databaseTableName = "tags"
}
// 中间表
struct PostTag: Codable, FetchableRecord, PersistableRecord {
var postId: String
var tagId: String
static let databaseTableName = "post_tags"
// 声明外键
static let post = belongsTo(Post.self, using: ForeignKey([Columns.postId.name]))
static let tag = belongsTo(Tag.self, using: ForeignKey([Columns.tagId.name]))
}
// 文章侧扩展
extension Post {
static let postTags = hasMany(PostTag.self, using: ForeignKey([PostTag.Columns.postId.name]))
static let tags = hasMany(Tag.self, through: postTags, using: PostTag.tag)
}
// 标签侧扩展
extension Tag {
static let postTags = hasMany(PostTag.self, using: ForeignKey([PostTag.Columns.tagId.name]))
static let posts = hasMany(Post.self, through: postTags, using: PostTag.post)
}
// 查找某个标签下的所有文章(Join 过滤)
let posts = try Post
.joining(required: Post.tags.filter(Tag.Columns.id == tagId))
.fetchAll(db)
// 预加载关联数据(Eager Loading,避免 N+1 查询)
struct PostInfo: Decodable, FetchableRecord {
var post: Post
var tag: Tag
}
let results = try PostTag
.including(required: PostTag.tag)
.including(required: PostTag.post)
.asRequest(of: PostInfo.self)
.fetchAll(db)
ValueObservation 是 GRDB 最强大的特性之一 — 它能自动追踪查询依赖的表,当数据发生变化时自动重新执行查询,真正实现数据驱动 UI。
import Combine
// 定义观察器
let observation = ValueObservation.tracking { db in
try Todo
.filter(Todo.Columns.isCompleted == false)
.order(Todo.Columns.createdAt.desc)
.fetchAll(db)
}
// 转为 Combine Publisher
let cancellable = observation
.publisher(in: dbPool, scheduling: .immediate)
.sink(
receiveCompletion: { print("完成: \($0)") },
receiveValue: { todos in
// 每次数据库中 todos 表变化,这里会自动收到最新数据
print("当前待办: \(todos.map(\.title))")
}
)
// ViewModel / Store 中订阅
@MainActor
class TodoStore: ObservableObject {
@Published var todos: [Todo] = []
private var cancellables = Set<AnyCancellable>()
init(dbPool: DatabasePool) {
ValueObservation.tracking { db in
try Todo.order(Todo.Columns.createdAt.desc).fetchAll(db)
}
.publisher(in: dbPool, scheduling: .immediate)
.receive(on: RunLoop.main)
.sink { [weak self] todos in
self?.todos = todos // 自动触发 SwiftUI 视图刷新
}
.store(in: &cancellables)
}
}
// SwiftUI 视图
struct TodoListView: View {
@StateObject private var store: TodoStore
var body: some View {
List(store.todos) { todo in
Text(todo.title)
}
// 不需要手动刷新,数据变化时列表自动更新
}
}
核心机制: ValueObservation.tracking 会自动分析闭包中访问了哪些表。当这些表发生写入操作时,闭包会自动重新执行并发送新值。无需手动调用 reload 或发送通知。
GRDB 对 SQLite FTS5 全文搜索提供了开箱即用的支持:
migrator.registerMigration("create_search_index") { db in
try db.create(virtualTable: "articles_fts", using: FTS5()) { t in
t.tokenizer = .unicode61() // Unicode 分词器
t.column("title")
t.column("body")
t.column("author_id").notIndexed() // 不参与搜索,仅用于关联
}
}
struct ArticleFTS: Codable, FetchableRecord, PersistableRecord, TableRecord {
var title: String
var body: String
var authorId: String
static let databaseTableName = "articles_fts"
}
// 前缀搜索(输入 "swi" 能匹配 "swift")
let pattern = try FTS5Pattern(rawPattern: "swi*")
let results = try ArticleFTS
.matching(pattern)
.fetchAll(db)
TableRecord协议为 FTS5 虚拟表提供了.matching()方法入口。
FTS5 的 unicode61 分词器对中文支持有限(按空格/标点分词)。如果需要中文搜索,常见做法是:
simple tokenizer)| 维度 | GRDB | Core Data | Realm |
|---|---|---|---|
| 学习曲线 | 低-中 | 高 | 低 |
| 类型安全 | 编译时检查 | 运行时 | 编译时检查 |
| 性能(查询) | 极快(原生 SQLite) | 中等 | 较慢(内存映射) |
| 内存占用 | 低 | 中 | 高 |
| 响应式更新 | ValueObservation + Combine | NSFetchedResultsController | 内置 LiveData |
| 数据库迁移 | DatabaseMigrator | 轻量级迁移 | 自动但有限 |
| 全文搜索 | FTS5 原生支持 | 需第三方 | 需第三方 |
| 跨平台 | Apple 全平台 | Apple 全平台 | 全平台 |
| 开源 | MIT | Apple 框架 | Apache 2.0 |
| 包大小 | ~2MB | 系统内置 | ~10MB+ |
适用场景建议:
// 所有模型遵循统一的协议组合
struct MyModel: Codable, FetchableRecord, PersistableRecord, Identifiable, Sendable {
// ...
}
当项目规模增长后,建议将数据库操作按领域拆分,保持单一职责:
Database/
├── DatabaseManager.swift // 核心:连接、迁移、通用方法
├── DatabaseManager+Users.swift // 用户相关
├── DatabaseManager+Orders.swift // 订单相关
├── DatabaseManager+Search.swift // 搜索逻辑
└── DatabaseManager+Observers.swift // 响应式订阅
// CodingKeys 负责属性名 ↔ 列名映射
enum CodingKeys: String, CodingKey {
case parentId = "parent_id"
}
// Columns 负责查询构建(类型安全)
enum Columns {
static let parentId = Column(CodingKeys.parentId)
}
// 查询时直接用 Column,编译器会帮你检查拼写
try Model.filter(Model.Columns.parentId == targetId).fetchAll(db)
migrator.registerMigration("v2_add_column") { db in
// 检查列是否已存在,避免重复迁移导致崩溃
let columns = try db.columns(in: "my_table")
if !columns.contains(where: { $0.name == "new_column" }) {
try db.alter(table: "my_table") { t in
t.add(column: "new_column", .text)
}
}
}
// 推荐:一条 SQL 搞定批量更新
try Todo
.filter(ids.contains(Todo.Columns.id))
.updateAll(db, [Todo.Columns.isCompleted.set(to: true)])
// 避免:N 次数据库往返
for id in ids {
var todo = try Todo.fetchOne(db, key: id)!
todo.isCompleted = true
try todo.update(db)
}
try dbPool.write { db in
// 以下操作在同一个事务中,要么全部成功,要么全部回滚
try order.insert(db)
for item in orderItems {
try item.insert(db)
}
try updateInventory(db, for: orderItems) // 扣减库存
}
// 写入后不需要手动刷新 UI
// ValueObservation 会自动检测到表变化并重新发送数据
try dbPool.write { db in
try newTodo.insert(db) // 写入
}
// → Observer 自动收到新的 [Todo],SwiftUI 视图自动刷新
// 软删除:保留恢复能力
try Todo
.filter(ids.contains(Todo.Columns.id))
.updateAll(db, [
Todo.Columns.isDeleted.set(to: true),
Todo.Columns.deletedAt.set(to: Date())
])
// 硬删除:依靠外键级联自动清理关联数据
t.column("todo_id", .text)
.references("todos", onDelete: .cascade) // 删除 todo 时自动清理关联
var config = Configuration()
// 推荐开启外键约束
config.foreignKeysEnabled = true
// 开发阶段可以开启 SQL 日志
config.prepareDatabase { db in
db.trace { print("SQL: \($0)") }
}
let dbPool = try DatabasePool(path: url.path, configuration: config)
GRDB.swift 是一个设计精良的 SQLite 工具包,在类型安全、性能和 API 易用性之间取得了优秀的平衡。其核心优势在于:
FetchableRecord / PersistableRecord)让模型定义简洁直观,一个 struct + Codable 就能开始Column 泛型)消除了字符串拼接 SQL 的隐患,编译时就能发现列名拼写错误ValueObservation + Combine 实现了真正的响应式数据驱动,写入数据后 UI 自动刷新DatabaseMigrator 让数据库 Schema 演进变得可管理,支持增量迁移和防御性编程如果你正在为 iOS/macOS 应用选择数据持久化方案,GRDB 是一个值得认真考虑的优秀选择。它既不像 Core Data 那样复杂,也不像 FMDB 那样缺乏类型安全,而是在功能和易用性之间提供了一个恰到好处的平衡点。
该方案在Xcode构建过程中对App Bundle内的PNG图片资源进行最低有效位(LSB)嵌入,将水印信息隐藏于像素数据中,不影响视觉效果且可追踪版权,当然其他的作用,自己进行体会。
Copy Bundle Resources阶段之后,对已拷贝到应用包中的图片进行原地修改,不污染源文件。ENABLE_INVISIBLE_WATERMARK控制是否执行,默认关闭。invisible_watermark.py
将此脚本放入项目根目录的Scripts文件夹中。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import sys
import struct
from PIL import Image
# ================== 配置 ==================
# 水印内容(建议使用项目标识+构建时间,保证唯一性)
WATERMARK_TEXT = "COPYRIGHT_YOURAPP_2026"
# 水印起始标记(用于定位,避免误读)
START_MARKER = 0xAA # 10101010
END_MARKER = 0x55 # 01010101
# ==========================================
def text_to_bits(text):
"""将字符串转为二进制位列表(含起始/结束标记)"""
# 添加起始标记(8位)
bits = [int(b) for b in format(START_MARKER, '08b')]
# 添加文本长度(32位,大端)
text_bytes = text.encode('utf-8')
length_bits = []
for byte in struct.pack('>I', len(text_bytes)):
length_bits.extend([int(b) for b in format(byte, '08b')])
bits.extend(length_bits)
# 添加文本内容
for byte in text_bytes:
bits.extend([int(b) for b in format(byte, '08b')])
# 添加结束标记(8位)
bits.extend([int(b) for b in format(END_MARKER, '08b')])
return bits
def embed_lsb(image_path, bits):
"""将二进制位嵌入图片的RGB最低有效位,返回是否成功"""
img = Image.open(image_path).convert('RGB')
pixels = img.load()
width, height = img.size
total_bits = width * height * 3 # 每个像素3个通道
if len(bits) > total_bits:
print(f" 错误: 图片容量不足,需要{len(bits)}位,实际{total_bits}位")
return False
idx = 0
for y in range(height):
for x in range(width):
r, g, b = pixels[x, y]
if idx < len(bits):
r = (r & 0xFE) | bits[idx]
idx += 1
if idx < len(bits):
g = (g & 0xFE) | bits[idx]
idx += 1
if idx < len(bits):
b = (b & 0xFE) | bits[idx]
idx += 1
pixels[x, y] = (r, g, b)
if idx >= len(bits):
break
if idx >= len(bits):
break
img.save(image_path, format='PNG', optimize=False)
return True
def process_bundle(bundle_path):
"""遍历App Bundle中的PNG图片,嵌入水印"""
processed = 0
for root, dirs, files in os.walk(bundle_path):
for file in files:
if file.lower().endswith('.png'):
file_path = os.path.join(root, file)
try:
bits = text_to_bits(WATERMARK_TEXT)
if embed_lsb(file_path, bits):
print(f" ✓ 已处理: {os.path.relpath(file_path, bundle_path)}")
processed += 1
else:
print(f" ✗ 容量不足,跳过: {file_path}")
except Exception as e:
print(f" ✗ 处理失败 {file_path}: {e}")
return processed
def main():
if len(sys.argv) < 2:
print("用法: python3 invisible_watermark.py <App Bundle路径>")
sys.exit(1)
bundle_path = sys.argv[1]
if not os.path.isdir(bundle_path):
print(f"错误: 路径不存在或不是目录: {bundle_path}")
sys.exit(1)
print(f"开始处理Bundle: {bundle_path}")
print(f"水印内容: {WATERMARK_TEXT}")
count = process_bundle(bundle_path)
print(f"完成,共处理 {count} 个PNG图片")
if __name__ == '__main__':
main()
在Xcode项目Target的Build Phases中添加一个新的Run Script Phase,放在Copy Bundle Resources之后,确保资源已拷贝到App Bundle。
脚本内容:
# 不可见水印开关:仅在配置了ENABLE_INVISIBLE_WATERMARK且值为YES时执行
if [ "${ENABLE_INVISIBLE_WATERMARK}" != "YES" ]; then
echo "🔇 不可见水印已禁用 (ENABLE_INVISIBLE_WATERMARK != YES)"
exit 0
fi
# 检查Python3环境
if ! command -v python3 &> /dev/null; then
echo "⚠️ 未找到python3,跳过水印处理"
exit 0
fi
# 检查Pillow库,若未安装则自动安装(可选)
python3 -c "import PIL" 2>/dev/null
if [ $? -ne 0 ]; then
echo "📦 安装Pillow库..."
pip3 install Pillow --user --quiet
fi
# 获取App Bundle路径(针对模拟器和真机统一处理)
APP_PATH="${TARGET_BUILD_DIR}/${EXECUTABLE_FOLDER_PATH}"
if [ ! -d "$APP_PATH" ]; then
echo "❌ 未找到App Bundle: $APP_PATH"
exit 1
fi
# 脚本所在路径(假设放在项目根目录/Scripts/下)
SCRIPT_PATH="${SRCROOT}/Scripts/invisible_watermark.py"
if [ ! -f "$SCRIPT_PATH" ]; then
echo "❌ 未找到水印脚本: $SCRIPT_PATH"
exit 1
fi
echo "🔏 开始嵌入不可见水印..."
python3 "$SCRIPT_PATH" "$APP_PATH"
echo "✅ 水印嵌入完成"
在Xcode项目的Build Settings中添加User-Defined Setting:
+ → Add User-Defined Setting
ENABLE_INVISIBLE_WATERMARK
YES(启用)或NO(禁用)建议不同配置使用不同值:
NO(加快构建)YES(正式包加水印)通过Build Configuration下的ENABLE_INVISIBLE_WATERMARK分别设置即可。
如果需要从图片中提取水印以验证版权,可提供以下提取脚本(单独使用,不在构建时执行):
#!/usr/bin/env python3
import sys
from PIL import Image
def extract_lsb(image_path):
img = Image.open(image_path).convert('RGB')
pixels = img.load()
width, height = img.size
bits = []
for y in range(height):
for x in range(width):
r, g, b = pixels[x, y]
bits.append(r & 1)
bits.append(g & 1)
bits.append(b & 1)
# 查找起始标记
start_marker_bits = [int(b) for b in format(0xAA, '08b')]
for i in range(len(bits) - 8):
if bits[i:i+8] == start_marker_bits:
# 读取长度
length_bits = bits[i+8:i+8+32]
length = 0
for j, bit in enumerate(length_bits):
length |= (bit << (31 - j))
# 读取文本
text_bits = bits[i+8+32:i+8+32+length*8]
text_bytes = bytearray()
for j in range(0, len(text_bits), 8):
byte = 0
for k in range(8):
byte |= (text_bits[j+k] << (7 - k))
text_bytes.append(byte)
# 验证结束标记
end_pos = i+8+32+length*8
end_marker_bits = [int(b) for b in format(0x55, '08b')]
if bits[end_pos:end_pos+8] == end_marker_bits:
print(f"提取水印: {text_bytes.decode('utf-8')}")
return
print("未检测到水印")
if __name__ == '__main__':
if len(sys.argv) != 2:
print("用法: python3 extract_watermark.py <image.png>")
else:
extract_lsb(sys.argv[1])
| 特性 | 说明 |
|---|---|
| 无感性 | LSB水印肉眼完全不可见,不改变图片观感 |
| 源文件安全 | 只修改构建产物(DerivedData中的App Bundle),不触碰.xcassets原始图片 |
| 开关可控 | 通过Build Settings一键启用/禁用,不同配置灵活切换 |
| 自动化 | 集成到Xcode构建流程,无需手动操作 |
| 可追溯 | 支持从图片中提取水印,用于版权验证 |
| 轻量 | 仅依赖Python3 + Pillow,macOS自带环境,自动安装缺失库 |
(8+32+文本长度*8+8)位。一张512x512的PNG可容纳约786432位(约96KB文本),完全满足需求。invisible_watermark.py放入项目Scripts/文件夹。ENABLE_INVISIBLE_WATERMARK,Release设为YES。其实类对象和元类对象的结构是相同的,元类对象是一种特殊的类对象.由于类对象和元类对象结构相同,但我们为什么感觉类对象只有对象方法列表,元类对象只有类对象列表呢,原因是不需要的数据都变为nil. 下图是class结构图
![]()
class_rw_t里面的methods、properties、protocols是二维数组,是可读可写的,包含了类的初始内容、分类的内容.
![]()
class_ro_t里面的baseMethodList、baseProtocols、ivars、baseProperties是一维数组,是只读的,包含了类的初始内容.
![]()
注:在runtime的过程中会将ro中的methods和分类中的methods合并到rw中的methods中,class的bits原来的指向是指向ro的,在runtime的过程中bits的指向由指向ro改变成指向rw
nmethod_t的结构体是对方法\函数的封装.
struct method_t{
SEL name; //函数名
const char *types; //编码(返回值类型、参数类型)
IMP imp; //指针函数的指针(函数地址)
};
IMP代表函数的具体实现
typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull,...);
SEL代表方法\函数名,一般叫做选择器,底层结构跟
char *类似
- 可以通过
@selector()和sel_registerName()获得.- 可以通过
sel_getName()和NSStringFromSelector()转成字符串.- 不同类中相同名字的方法,所对应的方法选择器是相同的.
typedef strct objc_selector *SEL;
types包含了函数返回值、参数编码的字符串
| 返回值 | 参数1 | 参数2 | ..... | 参数n |
|---|
Type Encoding
![]()
Class内部结构中有个方法缓存(cache_t),用散列表(哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度.
![]()
散列表的原理:将key传递并计算出一个index(索引).
![]()
用key(selector)的值&_mask就是所需要的imp,如果取值&后selector和key值不相等,_mask-1后再做&的操作.存储的时候已经&_mask计算好了缓存在第几个位置,如果在计算的时候存储的位置有方法缓存,会做_mask-1后再&的操作.(_mask有个初始值,如果容量不足可以扩容,扩容的时候清空缓存).
OC的方法调用,也叫做消息机制,给方法调用者发送一条消息.
OC中的方法调用,其实都是转换成objc_msgsend函数调用的.
objc_msgsend的流程大致分为3个阶段:
1.消息发送.
2.动态方法解析.
3.消息转发.
objc_msgSend执行流程 – 源码跟读流程
![]()
![]()
如果调用的是父类的方法,会把方法缓存到当前类,如果调用的是自己的方法,会把方法的缓存到自己的类中.
![]()
开发者可以实现以下方法,来动态添加方法实现.
+(BOOL)resolveInstanceMethod:(SEL)sel.
+(BOOL)resolveClassMethod:(SEL)sel.
动态解析过后,会重新走“消息发送”的流程.
- 从receiverClass的cache中查找方法”这一步开始执行.
demo:
#import <Foundation/Foundation.h>
@interface CSPersion : NSObject
- (void)test;
@end
#import "CSPersion.h"
#import <objc/runtime.h>
void otherC(id self, SEL _cmd) {
NSLog(@" %@-%s-%s",self,sel_getName(_cmd),__func__);
}
@implementation CSPersion
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(test)) {
struct method_t *method = (struct method_t*)class_getInstanceMethod(self,@selector(other));
class_addMethod([self class], sel, method->imp, method->types);
return YES;
}
return [super resolveInstanceMethod:sel];
}
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(test)) {
struct method_t *method = (struct method_t*)class_getInstanceMethod(self,@selector(other));
class_addMethod([self class], sel,method_getImplementation(methd), method_getTypeEncoding(method));
return YES;
}
return [super resolveInstanceMethod:sel];
}
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(test)) {
class_addMethod([self class], sel, (IMP)otherC, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
- (void)test {
NSLog(@"test ...");
}
- (void)other {
NSLog(@"other...");
}
@end
我们有三种方式进行方法动态解析,还是建议用第二种方式,第二种方式比较清晰.
消息转发的意思是把消息发送给别人,交给能够处理消息的人.
当- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;方法返回的签名types不为nil时,就会调用- (void)forwardInvocation:(NSInvocation *)anInvocation ;.
生成NSMethodSignature
NSMethodSignature *signature = [[NSMethodSignature signatureWithObjCTypes:"i@:i"]];
NSMethodSignature *signature = [[MJStudent alloc] init] methodSignatureForSelector:@selector(test:)];
![]()
@interface Cat : NSObject
- (int)test:(int)age;
@end
@implementation Cat
- (int)test:(int)age {
NSLog(@"%s",__func__);
return age * age;
}
@end
/** 消息发送 */
@interface Student : NSObject
- (void)test:(int)age;
@end
@implementation Student
//+ (BOOL)resolveInstanceMethod:(SEL)sel
//{
// class_addMethod(<#Class _Nullable __unsafe_unretained cls#>, <#SEL _Nonnull name#>, <#IMP _Nonnull imp#>, <#const char * _Nullable types#>)
//}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if (aSelector == @selector(test:)) {
// 测试一
// return [NSMethodSignature signatureWithObjCTypes:"v20@0:8i16"];
// 测试二
// return [NSMethodSignature signatureWithObjCTypes:"i@:i"];
// 测试三
// return [[[Cat alloc] init] methodSignatureForSelector:aSelector];
}
return [super methodSignatureForSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation {
// 参数顺序:receiver、selector、other arguments
// 测试一
// int age;
// [anInvocation getArgument:&age atIndex:2];
// NSLog(@"%d", age + 10);
// 测试二
// anInvocation.target == [[MJCat alloc] init]
// anInvocation.selector == test:
// anInvocation的参数:15
// [[[Cat alloc] init] test:15];
// 测试三
[anInvocation invokeWithTarget:[[Cat alloc] init]];
int ret;
[anInvocation getReturnValue:&ret];
NSLog(@"%d", ret);
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
// 2.消息转发
Student *stu = [[Student alloc] init];
[stu test:10];
}
return 0;
}
####五. objc_msgSend-类方法消息转发
当+ (id)forwardingTargetForSelector:(SEL)aSelector为nil时,会继续调用+ (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector,如果methodSignatureForSelector 为nil,则会报一个非常经典的错误doesNotRecognizeSelector,我们可以看出从方法我们只有在methodSignatureForSelector 为nil时才会报错.
@interface CSCat : NSObject
+ (void)test;
- (void)test;
@end
@implementation CSCat
+ (void)test {
NSLog(@"%s", __func__);
}
- (void)test {
NSLog(@"%s", __func__);
}
@end
/** 类方法的转发过程 */
@interface CSPerson : NSObject
+ (void)test;
@end
@implementation CSPerson
+ (id)forwardingTargetForSelector:(SEL)aSelector {
// objc_msgSend([[MJCat alloc] init], @selector(test))
// [[[MJCat alloc] init] test]
// 该方法显示与注释后有不同的结果
// if (aSelector == @selector(test)) {
// return [[CSCat alloc] init];
// }
return [super forwardingTargetForSelector:aSelector];
}
+ (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
if (aSelector == @selector(test)) {
return [NSMethodSignature signatureWithObjCTypes:"v@:"];
}
return [super methodSignatureForSelector:aSelector];
}
+ (void)forwardInvocation:(NSInvocation *)anInvocation {
NSLog(@"1123");
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
[CSPerson test];
}
return 0;
}
想了解更多iOS学习知识请联系:QQ(814299221)
我们在开发的过程中经常使用KVO和KVC,但是我们并不了解其底层原理和功能,今天我们来详细了解下底层原理.
KVO的机制比较隐蔽,所以我们通过写代码的方式去验证: 新建类Person
#import <Foundation/Foundation.h>
@interface Person : NSObject
@property (nonatomic, assign) int age;
@end
#import "Person.h"
@implementation Person
- (void)setAge:(int)age
{
_age = age;
}
@end
给新建的Person类创建对象person1与person2,并对person1的age属性添加observer(键值观察)。
- (void)viewDidLoad {
[super viewDidLoad];
self.person1 = [[Person alloc] init];
self.person1.age = 1;
self.person2 = [[Person alloc] init];
self.person2.age = 2;
/*
options: 有4个值,分别是:
NSKeyValueObservingOptionOld 把更改之前的值提供给处理方法
NSKeyValueObservingOptionNew 把更改之后的值提供给处理方法
NSKeyValueObservingOptionInitial 把初始化的值提供给处理方法,一旦注册,立马就会调用一次。通常它会带有新值,而不会带有旧值。
NSKeyValueObservingOptionPrior 分2次调用。在值改变之前和值改变之后。
*/
// 给person1对象添加KVO监听
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[self.person1 addObserver:self forKeyPath:@"age" options:options context:@"观察者"];
}
为了测试方便,点击屏幕改变age的值,在- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event方法里面修改person1的age属性值。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
self.person1.age = 22;
}
//当key路径对应的属性值发生改变时,监听器就会回调自身的监听方法,如下
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey,id> *)change
context:(void *)contex
}
控制器销毁了,应当及时移除观察者。
- (void)dealloc {
[self.person1 removeObserver:self forKeyPath:@"age"];
}
触摸手机屏幕,获得log.
2019-07-16 15:18:33.167839+0800 Student[1390:114826] 监听到<Person: 0x60000392c570>的age属性值改变了 - {
kind = 1;
new = 22;
old = 1;
} - 观察者
想知道KVO都做了什么我们可以通过观察isa和对象的指针.
![]()
person1的isa指针居然由Person变成了NSKVONotifying_Person,我们知道实例对象(person1、person2)的isa指针指向类对象(关于isa指针方面的知识,可以参考这篇文章,讲得比较容易理解。浅谈Objective-C的对象本质的理解),这样一来也就说明person1的直接类对象并不是Person,而是NSKVONotifying_Person这个类。
我们还可以进一步的确实是否生成了NSKVONotifying_Person这个类,我们在项目中创建一个NSKVONotifying_Person的类,再次运行项目的时候会报错:
2019-07-16 15:39:45.191295+0800 Student[1576:124208] [general] KVO failed to allocate
class pair for name NSKVONotifying_Person, automatic key-value observing will not
work for this class
同过这两种方式说明了当我们为person1的属性添加了观察者模式的之后,系统通过runtime会动态为我们创建一个继承自Person的类NSKVONotifying_Person.
- (void)viewDidLoad {
[super viewDidLoad];
self.person1 = [[Person alloc] init];
self.person1.age = 1;
self.person2 = [[Person alloc] init];
self.person2.age = 2;
NSLog(@"person1添加KVO监听之前 - %p %p",
[self.person1 methodForSelector:@selector(setAge:)],
[self.person2 methodForSelector:@selector(setAge:)]);
// 给person1对象添加KVO监听
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[self.person1 addObserver:self forKeyPath:@"age" options:options context:@"kvo监听"];
NSLog(@"person1添加KVO监听之后 - %p %p",
[self.person1 methodForSelector:@selector(setAge:)],
[self.person2 methodForSelector:@selector(setAge:)]);
}
//log日志:添加kvo机制前后isa指向的变化
2019-07-17 09:29:40.806381+0800 Student[1395:28559] person1添加KVO监听之前 - 0x10a930570 0x10a930570
2019-07-17 09:29:40.806719+0800 Student[1395:28559] person1添加KVO监听之后 - 0x10ac8b3d2 0x10a930570
(lldb) p IMP(0x10a930570)
(IMP) $0 = 0x000000010a930570 (Student`-[Person setAge:] at Person.m:13)
(lldb) p IMP(0x10ac8b3d2)
(IMP) $1 = 0x000000010ac8b3d2 (Foundation`_NSSetIntValueAndNotify)
- (void)viewDidLoad {
[super viewDidLoad];
self.person1 = [[Person alloc] init];
self.person1.age = 1;
self.person2 = [[Person alloc] init];
self.person2.age = 2;
// 给person1对象添加KVO监听
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[self.person1 addObserver:self forKeyPath:@"age" options:options context:@"kvo监听"];
NSLog(@"类对象 - %@ %@",
object_getClass(self.person1), // self.person1.isa
object_getClass(self.person2)); // self.person2.isa
NSLog(@"元类对象 - %@ %@",
object_getClass(object_getClass(self.person1)), // self.person1.isa.isa
object_getClass(object_getClass(self.person2))); // self.person2.isa.isa
}
//log日志:添加kvo机制前后isa指向的变化
2019-07-17 09:37:58.106744+0800 Student[1477:31924] 类对象 - NSKVONotifying_Person Person
2019-07-17 09:37:58.106895+0800 Student[1477:31924] 元类对象 - NSKVONotifying_Person Person
kvo的全称是Key-Value Observing,俗称"键值监听",可以用与监听某个对象属性值的改变.
![]()
![]()
![]()
NSKVONotifying_MJperson中的class是重写父类的class方法,原因是屏蔽内部实现,隐藏NSKVONotifying_MJperson类.
NSKVONotifying_MJperson是Runtime动态创建的一个类,是MJperson的一个子类.NSKVONotifying_MJperson的set方法会调用.
子类的set方法的实现:
-(void)setAge:(int) age{
//Foundation框架的_NSSetIntValueAndNotify的方法.
_NSSetIntValueAndNotify();
}
_NSSetIntValueAndNotify中调用了:
[self willChangeValueForkey:@"age"];
[super setAge:age];
[self didChangeValueForkey:@"age"];
didChangeValueForkey的实现:
-(void)didChangeValueForkey:(NSString *)key{
//通知监听器,某某属性发生了改变
[oberser observeValueForKeyPath:key ofObject:self change:nil context:nil];
}
#import "Person.h"
@implementation Person
- (void)setAge:(int)age {
_age = age;
}
- (void)willChangeValueForKey:(NSString *)key {
[super willChangeValueForKey:key];
NSLog(@"willChangeValueForKey");
}
- (void)didChangeValueForKey:(NSString *)key {
NSLog(@"didChangeValueForKey - begin");
[super didChangeValueForKey:key];
NSLog(@"didChangeValueForKey - end");
}
@end
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.person1 setAge:21];
}
//log日志:
2019-07-17 09:41:45.186419+0800 Student[1513:33394] willChangeValueForKey
2019-07-17 09:41:45.186572+0800 Student[1513:33394] didChangeValueForKey - begin
2019-07-17 09:41:45.186850+0800 Student[1513:33394] 监听到<Person: 0x600003013860>的age属性值改变了 - {
kind = 1;
new = 21;
old = 1;
} - kvo监听
2019-07-17 09:41:45.186967+0800 Student[1513:33394] didChangeValueForKey - end
2019-07-17 09:41:45.187052+0800 Student[1513:33394] person1 age = 21,person2 age = 22
补充的问题
![]()
_NSSet*ValueAndNotify的内部实现:
[self willChangeValueForkey:@"age"];
//原来set的实现
[self didChangeValueForkey:@"age"];
1.调用willChangeValueForkey.
2.调用原来的setter的实现.
3.调用didChangeValueForkey,didChangeValueForKey:内部会调用observer的observeValueForKeyPath:ofObject:change:context:方法.
面试问题
1.iOS用什么方式实现对一个对象的KVO?(KVO的本质是什么?) 利用RuntimeAPI动态生成一个子类,并且让instance对象的isa指向这个全新的子类; 当修改instance对象的属性时,会调用Foundation的_NSSetXXXValueAndNotify函数
①willChangeValueForKey:
②父类原来的setter
③didChangeValueForKey:
didChangeValueForKey内部会触发监听器(Oberser)的监听方法( observeValueForKeyPath:ofObject:change:context:)
2.如何手动触发KVO? 对监听的对象手动调用下面两行代码即可。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[self.person1 willChangeValueForKey:@"age"];
[self.person1 didChangeValueForKey:@"age"];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"监听到%@的%@属性值改变了 - %@ - %@", object, keyPath, change, context);
}
3.KVO与代理的效率问题?
KVO的效率比代理的效率低,因为KVO需要动态地生成一个类NSKVONotifying_className,耗时。
4.使用KVC给对象属性赋值,能不能触发KVO?
可以触发KVO。因为KVC本质上会调用属性的setXxx:方法。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"调用带下划线的成员变量");
self.person1.age = 10;
}
5.直接修改成员变量会触发KVO嘛? 不会触发KVO,因为修改成员变量不会触发set方法。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"调用带下划线的成员变量");
self.person1->_age = 10;
}
kvc的全称是Key-Value Coding,俗称"键值对编码",可以通过key来访问某个属性.
常见的API有:
- (void)setValue:(id)value forKeyPath:(NSString *)keyPath;-(void)setValue:(id)value forKey:(NSString *)key;-(id)valueForKeyPath:(NSString *)keyPath;- (id)valueForKey:(NSString *)key;
key和keyPath的区别:
key:只能接受当前类所具有的属性,不管是自己的,还是从父类继承过来的,如view.setValue(CGRectZero(),key: "frame");
keypath: 除了能接受当前类的属性,还能接受当前类属性的属性,即可以接受关系链,如view.setValue(5,keypath: "layer.cornerRadius");
KVC在赋值的时候,按照setKey:、_setKey:的顺序查找对象是否有对应的方法实现,如果有的话就传递参数并调用方法,如过这两个方法都没有实现,则调用对象的+ (BOOL)accessInstanceVariablesDirectly方法,查看是否允许直接访问成员变量。下面我们证明一下:
A:证明先调用- (void)setAge:(NSUInteger)age方法,新建一个Person类,不添加任何属性,实现- (void)setAge:(NSUInteger)age、- (void)_setAge:(NSUInteger)age方法。初始化一个Person实例并对其进行KVC赋值,看系统调用结果。
#import <Foundation/Foundation.h>
@interface Person : NSObject
@property(nonatomic, assign) NSInteger age;
@end
#import "Person.h"
@implementation Person
- (void)setAge:(NSUInteger)age{
NSLog(@"setAge : %lu",(unsigned long)age);
}
- (void)_setAge:(NSUInteger)age{
NSLog(@"_setAge : %lu",(unsigned long)age);
}
@end
#import <Foundation/Foundation.h>
#import "Person.h"
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *person = [[Person alloc] init];
[person setValue:@20 forKey:@"age"];
}
return 0;
}
//log日志
2019-07-16 16:47:55.228096+0800 Student[1841:143855] setAge : 20
B:将Person类中的- (void)setAge:(NSUInteger)age注释掉,保留- (void)_setAge:(NSUInteger)age,看系统调用结果。
#import "Person.h"
@implementation Person
//- (void)setAge:(NSUInteger)age{
// NSLog(@"setAge : %lu",(unsigned long)age);
//}
- (void)_setAge:(NSUInteger)age{
NSLog(@"_setAge : %lu",(unsigned long)age);
}
@end
//log日志
//2018-08-02 23:15:08.754741+0800 Student[1841:544138] _setAge : 20
由以上结果可见,我们调用方法
- (void)setValue:(id)value forKeyPath:(NSString *)keyPath;或
- (void)setValue:(id)value forKey:(NSString *)key;时,OC底层依次查找了setKey:或_setKey:方法。
setKey:或_setKey:方法怎么办?没有实现setKey:或_setKey:方法,系统将查看+(BOOL)accessInstanceVariablesDirectly方法的返回结果(该方法默认返回YES),这个方法决定是否可以直接访问成员变量key。
注意:如果+(BOOL)accessInstanceVariablesDirectly方法返回了NO,那么就会调用setValue:forUndefinedKey:并抛出异常NSUnknownKeyException!
注意,这里面为什么提到对象的成员变量,而不是属性呢?
如果是属性的话,系统自动帮我们实现了set方法,所以KVC总是可以找到它需要的`setKey:`方法。如果是
成员变量,系统就不会为你实现set方法了.
KVC在访问成员变量时也严格按照_key、_isKey、key、isKey的顺序查找。下面我们将上面代码中- (void)setAge:(NSUInteger)age、- (void)_setAge:(NSUInteger)age注释掉,并添加四个成员变量_age、_isAge、age、isAge。
#import <Foundation/Foundation.h>
@interface Person : NSObject
{
int _age;
int _isAge;
int age;
int isAge;
}
@end
#import "Person.h"
@implementation Person
+(BOOL)accessInstanceVariablesDirectly{
return YES;
}
@end
通过设置断点观察对象成员变量值得变化,证明了 ‘严格按照_key、_isKey、key、isKey的顺序查找’的结论.
setValue forkey的原理:
![]()
+(BOOL)accessInstanceVariablesDirectly的方法是用来确认是否可以访问成员变量, +(BOOL)accessInstanceVariablesDirectly默认是Yes.
kvc的内部调用了①willChangeValueForkey②didChangeValueForkey两个方法,从而触发了kvo,所以不用实现set方法也可以调起kvo.
- (id)valueForKey:(NSString *)key;或 - (id)valueForKeyPath:(NSString *)keyPath;方法取值的时候,按照getKey、key、isKey、_key的顺序查找对应方法,一旦找到就调用方法获取值。如果没有找到以上四个方法,同样会调用+(BOOL)accessInstanceVariablesDirectly方法,看是否具备直接访问成员变量的权限。与KVC的赋值过程相同,在查找成员变量的时候,也是严格按照 _key、_isKey、key、isKey的顺序查找的。找到了就直接取值,都没有找到的话,后果也是相同的,即调用setValue:forUndefinedKey:并抛出异常NSUnknownKeyException*!
#import "Person.h"
@interface Person ()
@end
@implementation Person
+ (BOOL)accessInstanceVariablesDirectly{
return YES;
}
- (int)getAge{
NSLog(@"getAge");
return 5;
}
- (int)age{
NSLog(@"age");
return 10;
}
- (int)isAge{
NSLog(@"isAge");
return 15;
}
- (int)_age{
NSLog(@"_age");
return 20;
}
@end
#import <Foundation/Foundation.h>
#import "Person.h"
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *person = [[Person alloc] init];
[person valueForKey:@"age"];
}
return 0;
}
依次对getKey、key、isKey、_key方法进行注释,通过log日志可见KVC的取值时候调用的方法顺序依次为:getKey、key、isKey、_key。
Value forkey的原理:
![]()
想了解更多iOS学习知识请联系:QQ(814299221)
我们平时编写的OC代码,其实底层实现都是C/C++代码,类主要是基于C/C++的结构体的数据结构实现的,因为对象或者类有各种类型(NSArray *,NSDictionary *,CFfloat等),因为可以存储不同种类的数据,能够满足的这样的结构就是结构体.
为了证明OC的结构,所以可以转换成C++的代码,窥探内部的结构(有时候C++的代码也不一定能完全表示源码的情况,需要调试到汇编代码或源码查看).
我们可以通过终端进入到要窥探所在文件的位置,使用命令xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main.cpp.(如果电脑上面安装了多个版本的Xcode,转换为C++代码的时候会提示各种框架找不到的错误,一般是因为多个版本的Xcode路径冲突导致的,我们需要在终端指定一个Xcode的路径,例:sudo xcode-select --switch/Applications/Xcode10.0.app/Contents/Developer/).
注释:解释各种参数的翻译
xc就是Xcode的缩写。
xcrun是Xcode的一种工具。
-sdk iphoneos规定sdk需要运行在iOS系统上面。
clang是Xcode内置的llvm编译器前端,也是编译器的一种。
-arch xxx(arm64、i386、armv7...)指出iOS设备的架构。
参数 -rewrite-objc xx.m 是重写objc代码的指令(即重写xx.m文件) 。
-o newFileName.cpp 表示输出新的.cpp文件。
![]()
Class 定义为 : typedef struct objc_class *Class;也就是说Class是个结构体指针.
代码中[NSObject alloc]开辟空间给NSObject。obj的指针指向了isa的地址.isa的地址就是结构体的地址,原因是结构体的地址就是结构体中第一个成员的地址,而结构体只有一个成员,即isa指针的地址.
![]()
答:因为Student继承NSObject,也就继承了NSObject的数据结构,所以继承NSObject的8个字节,也就是NSobject中的isa的大小。
![]()
Person占据class_getInstanceSize=16 malloc_size=16, Student占据class_getInstanceSize=16 malloc_size=16,Person的变量实际用了12,但是由于内存对齐所以占用16.
我们有这种方法在OC中表达一个类内存的大小.
<objc/runtime.h>文件提供class_getInstanceSize(Class _Nullable cls)方法,返回我们一个OC对象
的实例所占用的内存大小(可以说是结构体内存对齐之后的大小,8的倍数);
<malloc/malloc.h>文件提供 size_t malloc_size(const void *ptr)方法返回系统为这个对象分配的
内存大小(16的倍数)。
我们先来看一些内存的例子,更加方便我们去理解内存分配和内存对齐原理:
NSObject *obj = [[NSObject alloc] init];
NSLog(@"NSObject实例大小--> %zd",class_getInstanceSize([obj class]));
NSLog(@"obj实际分配的内存%zd",malloc_size((const void *)obj));
// NSObject实例大小--> 8
// obj实际分配的内存16
@interface Student: NSObject
@property (nonatomic, copy) NSString *name;
@end;
@implementation Student
@end;
Student *stu = [[Student alloc] init];
stu.name = @"Object-C";
NSLog(@"Student实例大小--> %zd",class_getInstanceSize([stu class]));
NSLog(@"stu实际分配的内存%zd",malloc_size((const void *)stu));
// Student实例大小--> 16
// stu实际分配的内存16
@interface Student: NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@end;
@implementation Student
@end;
Student *stu = [Student new];
stu.name = @"Object-C";
stu.age = 25;
NSLog(@"Student实例大小--> %zd",class_getInstanceSize([stu class]));
NSLog(@"stu实际分配的内存%zd",malloc_size((const void *)stu));
// Student实例大小-->24
// stu实际分配的内存32
由以上三次测试:一个OC对象所占用的内存取决于这个对象成员变量的多少.但是同时,系统为其分配内存时,默认会分配最少16个字节的大小.OC中对象的内存小于16就等于16(是Core Foundation的规定),下面是Core Foundation的源码.
size_t instanceSize(size_t extraBytes){
size_t size = alignedInstanceSize()+extraBytes;
//CF requires all objects be at least 16 bytes.
if (size < 16) size = 16;
return size;
}
内存对齐的原则:结构体的大小必须是最大成员的倍数.
更多的内存对齐的知识--内存对齐
补充:sizeof不是个函数是个运算符,传入的时候是类型不是具体的对象,sizeof是在编译的时候进行计算的.
objective-C的对象,简称为OC对象,分为三种:
- instance对象(实例对象).
- class对象(类对象).
- meta-class(元类对象).
NSObject *object1 = [[NSObject alloc] init];
NSObject *object2 = [[NSObject alloc] init];
object1、object2是NSObject的instance对象(实例对象),它们是不同的两个对象,分别占据着两块不同的内存。instance对象是通过类alloc出来的对象,每次调用alloc都会产生新的instance对象.instance对象在内存中存储的信息包括:isa指针,其他成员变量。 实例对象存放的内容包含:
| 实例对象 |
|---|
| isa |
| 成员变量信息 |
NSObject *object1 = [[NSObject alloc] init];
NSObject *object2 = [[NSObject alloc] init];
Class objectClass1 = [object1 class];
Class objectClass2 = [object2 class];
Class objectClass3 = [NSObject class];
Class objectClass4 = object_getClass(object1);//Runtime API
Class objectClass5 = object_getClass(object2);//Runtime API
objectClass1 ~ objectClass5都是NSObject的class对象(类对象).它们是同一个对象,每个类在内存中有且只有一个class对象. 类对象存放的内容包含:
| 类对象 |
|---|
| isa |
| superclass |
| 属性信息 |
| 对象方法信息 |
| 协议信息 |
| 成员变量信息 |
| ............. |
class对象在内存中存储的信息主要包括:isa指针,superclass指针,类的属性信息(@property)、类的对象方法信息(instance method),类的协议信息(protocol)、类的成员变量信息(ivar).
获取一个类对象的元类对象的方法.
Class objectMetaClass = object_getClass([NSObject class]);//Runtime API
| 元类对象 |
|---|
| isa |
| superclass |
| 类方法信息 |
| ............. |
objectMetaClass是NSObject的meta-class对象(元类对象).每个类在内存中有且只有一个meta-class对象.
meta-class对象和class对象的内存结构是一样的,但是用途不一样,在内存中存储的信息主要包括:isa指针,superclass指针,类的类方法信息(class method).
补充:
查看Class是否为meta-class:
BOOL result = class_isMetaClass([NSObject class]);
以下代码获取的objectClass是class对象,并不是meta-class对象
Class objectClass = [[NSObject class] class];
objcget-Class和object-getClass区别
| objc_getClass | 传入字符串类名返回类对象. | 传入字符串类名返回类对象. | 传入字符串类名返回类对象. |
|---|---|---|---|
| object_getClass | 传入实例对象返回类对象. | 传入类对象返回元类对象. | 传入元类对象返回还是元类对象 |
![]()
①instance的isa指向class,当调用对象方法时,通过instance的isa找到class,最后找到对象方法的实现进行调用. ②class的isa指向meta-class,当调用类方法时,通过class的isa找到meta-class,最后找到类方法的实现进行调用.
![]()
当Student的instance对象要调用Person的对象方法时,会先通过isa找到Student的class,然后通过superclass找到Person的class,最后找到对象方法的实现进行调用.
![]()
当Student的class要调用Person的类方法时,会先通过isa找到Student的meta-class,然后通过superclass找到Person的meta-class,最后找到类方法的实现进行调用.
![]()
isa总结
instance的isa都是指向class.
class的isa都是指向meta-class.
meta-class的isa指向基类的meta-class.
superClass总结
class的superClass指向父类的class.
如果没有父类,superClass指针为nil.
meta-class的superClass指向父类的meta-class.
基类meta-class的superClass指向基类的class.
instance的调用轨迹
isa找到class,方法不存在,就通过superclass找父类.
class调用类方法的轨迹
- isa找meta-class,方法不存在,就通过superclass找父类.
- 基类的meta-class方法不存在,就通过superclass找基类的class,如果没有找到就是nil.
![]()
![]()
从64bit开始,isa需要进行一次位运算,才能计算出真实地址,superClass存储的地址值,直接就是父类的地址值,不用做位运算.
![]()
实例对象里只有成员变量没有方法,为什么实例对象的方法要存在类对象里,原因是只要存一份就够了,实例对象会创建多个.
想了解更多iOS学习知识请联系:QQ(814299221)
最近科技圈有个热闹事:钉钉、飞书、企业微信,同一周全都开源了自己的 CLI。
你可能想问:CLI 是什么?跟之前老听到的 MCP 有什么关系?还有个叫 Skill 的又是什么?
别慌,今天用一个比喻把这三样东西讲明白。
假设你是老板,刚招了一个超级能干的实习生(就是 AI Agent)。你想让他帮你在钉钉上干活:发消息、查日程、建表格、安排会议。
问题来了:实习生刚来,他不知道公司用什么工具,也不知道怎么操作。
你得解决三个问题:
这三个问题,分别对应的就是 CLI、MCP 和 Skill。
CLI(Command Line Interface),命令行工具。就是你在电脑终端里敲一行文字,电脑帮你干活。
比如查今天的日程:
1 |
lark-cli calendar +agenda |
比如给同事发条消息:
1 |
wecom-cli im send --text "周五下午开会" --to zhangsan |
没有界面,没有按钮,全靠打字。
你可能觉得这也太原始了吧?但这恰恰是 AI 最喜欢的方式。因为 AI 最擅长处理文字,输入是文字、输出也是文字,非常对口。你让 AI 去操作图形界面,它得先截屏,再用视觉模型找按钮在哪,再模拟鼠标去点——本来一行命令搞定的事,拆成四步,每步都可能出错。
所以,CLI 就是实习生手里的工具箱。 扳手、螺丝刀、锤子,都在里面。他需要的时候拿出来用,不需要的时候放着就行。
MCP(Model Context Protocol),模型上下文协议。名字唬人,但原理不复杂。
MCP 的做法是:提前把所有工具的说明贴在实习生桌上。”你能发消息””你能查日程””你能建表格”……每个能力做成一个按钮,实习生随时能按。
好处很明显:实习生不用四处找工具,一抬头就知道自己能干什么,直接按就行。
但有个代价:桌子就那么大。
AI 的”桌子”叫上下文窗口,大小是有限的。每个 MCP 工具都要在桌上摆一张说明卡。你接三五个工具,桌上还很宽敞。但你要是把钉钉、飞书、企业微信、GitHub、Slack、Jira 全接上,每个软件十几个功能,上百张说明卡往桌上一摊——桌子就被占满了,实习生连写字的地方都没有了。
而且工具太多还有个问题:实习生面对一百个按钮,选错的概率也会变大。
CLI 不一样。 工具箱放在柜子里,桌上不摆东西。需要的时候打开柜子拿出来用,用完放回去。桌子始终是干净的。当然代价是每次用之前得先翻一下工具箱看看有什么(跑个 --help),比直接按按钮慢了一步。
所以两者的核心区别就是:
实际上,两者并不矛盾。钉钉和飞书都同时提供了 MCP 和 CLI 两种接入方式。能访问终端的环境(比如 Claude Code)用 CLI 更灵活,不能访问终端的环境(比如一些桌面端 AI 工具)就用 MCP。
前面两个解决了”有什么工具”和”怎么让 AI 知道工具在哪”的问题。但还有一个问题:AI 知道有工具,不代表它会用好。
你跟 AI 说”帮我把会议纪要里的待办整理出来”,AI 得知道:先用什么命令读会议纪要?提取出来的待办该用什么命令创建?创建的时候需要哪些参数?出错了怎么办?
这就是 Skill 的作用——一本写给 AI 看的操作手册。
Skill 不是工具,它自己不干活。它告诉 AI:你有哪些命令可以用、什么场景该用哪个、参数怎么填、出了错怎么补救。
没有 Skill,AI 也能用 CLI,靠 --help 自己摸索。但这就像让新来的实习生自己翻工具箱说明书——能用,但慢,而且容易犯错。
有了 Skill,相当于给实习生一本经验丰富的老员工写的操作指南:”遇到查日程的需求,先用这个命令;如果对方没说时间范围,默认查本周;如果报权限错误,跑这个命令申请权限。”
实习生拿着这本手册,上手就快得多,犯错也少得多。
而且 Skill 的设计也很聪明——它跟 CLI 一样是按需加载的。AI 的上下文里只放一句话的简介:”你有一本操作钉钉的手册”。只有 AI 判断需要操作钉钉了,才会去翻开手册的详细内容。不用的时候,不占桌面空间。
1 |
你说一句话:"帮我查下周跟张三的会议" |
CLI 和 MCP 二选一(看环境支不支持终端),Skill 是加分项,有了它 AI 干活更靠谱。
说实话,大多数人不需要关心这些底层概念。
你真正会感受到的变化是:以后跟 AI 说一句话,它就能帮你操作钉钉、飞书、企业微信。查日程、发消息、建文档、排会议——你动嘴,AI 动手。
CLI、MCP、Skill,是让这件事成为可能的基础设施。就像你每天用微信,不需要知道 TCP/IP 协议怎么工作一样。
但如果你是那种喜欢搞清楚原理的人,记住这三句话就够了:
CLI 是给 AI 用的工具箱。
MCP 是把工具提前摆在 AI 桌上的一种方式。
Skill 是教 AI 怎么把工具用好的说明书。
过去的软件为人设计界面,现在的软件开始为 AI 设计接口。三大办公平台同一周开源 CLI,就是这个时代转变的一个缩影。
GUI 服务人类,CLI 服务 AI。同一个产品,两种形态,以后会是常态。
当项目从单一 iOS 原生扩展到 Flutter、React Native 或 Unity 时,混淆这件事会变得复杂。原因不在于工具少,而是每一层代码完全不同:
如果只用一种 iOS 混淆工具,通常只能覆盖其中一部分。
拿一个混合项目举例(Flutter + 原生 + H5),解包 IPA 后可以看到:
AppBinary // 原生代码
flutter_assets/ // Dart + 资源
main.jsbundle // JS 逻辑
assets/ // 图片与配置
每一层的“暴露方式”不同:
| 技术 | 可被读取的内容 |
|---|---|
| Swift / OC | 类名、方法名、参数 |
| Flutter | Dart 符号(部分)、资源路径 |
| React Native | JS 逻辑 |
| Unity | DLL + AssetBundle |
这意味着混淆必须分层处理。
先看最传统的一层:Swift / Objective-C。
检查方式:
strings AppBinary | grep Controller
如果看到:
HomeViewController
PaymentManager
说明符号未处理。
使用 Ipa Guard 这类 IPA 级别的 iOS 混淆工具:
执行后:
PaymentManager → a82kd3
这一步直接改变 Mach-O 符号,是跨平台项目中最“统一”的一层处理。
Flutter 提供内置混淆:
flutter build ios --obfuscate --split-debug-info=./symbols
执行后:
但 IPA 解包后仍然可以看到:
assets/images/banner.png
config/app.json
使用 Ipa Guard 的资源模块:
banner.png → x92kd.png
app.json → a83ks.json
这样 Dart 层 + 资源层同时处理。
React Native 的关键在 JS bundle:
main.jsbundle
直接打开可以读。
1)压缩 JS:
terser main.js -o main.min.js
2)替换 bundle
3)用 Ipa Guard 修改文件名称:
main.jsbundle → k39sd.bundle
这样:
Unity 项目解包后:
Data/Managed/Assembly-CSharp.dll
Data/Resources/
DLL 可以被反编译,资源路径也能推断逻辑。
例如:
level1.assetbundle → a82kd.bundle
跨平台项目中,资源重复是一个常见问题。
例如多个 App 使用同一套 UI:
banner.png
icon.png
即使改名,内容仍然一致。
在 Ipa Guard 中开启 MD5 修改:
md5 banner.png
处理前后不同。
这一步可以打散资源特征。
检查:
strings AppBinary | grep NSLog
或:
strings AppBinary | grep Flutter
如果存在调试信息,可以统一清理。Ipa Guard 支持删除调试符号和部分日志字符串。
无论哪个技术栈,只要修改 IPA,就必须重新签名。
可以使用:
kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i
大家好,我最近开发了一款App《SleepDiary(睡眠声音日记)》。
作为一款睡眠监测类 App,核心业务逻辑可以用一句话概括:
“录一整夜的音,把打呼噜和说梦话的片段摘出来,最后生成睡眠报告。 ”
看似简单,但在工程实现上却困难重重:
经过最近这段时间的研究,我用 AVFoundation + Core ML + SwiftData 的纯血原生技术栈把这套流程跑通了。今天就和大家分享一下我的实现思路与踩坑日记。
最初的设计方案很简单粗暴:开个录音,每秒去判断分贝,超过阈值就保存。但这完全不行,深夜翻身的声音、空调声、外面的汽车声都会被误判。
市面上现成的声音分类模型要么太大(动辄上百MB),要么对“鼾声”、“梦话”这种特定场景不够敏锐。于是我决定硬核一点——自己动手,从收集数据开始训练一个专用的轻量级神经网络(SnoreWave.mlpackage)。
为了让模型足够精准,我花了大量时间收集开源数据集并结合自己实录的各种“打雷级”打呼声(最终 1.2w 条数据)。 把杂乱的音频转换成模型能“看懂”的输入是第一步——将音频流转化为梅尔频谱图(Mel-spectrogram) 。这相当于将一维的声音信号,变成了二维的图像图像特征,然后再喂给我用深度学习框架搭建的 CNN(卷积神经网络)进行分类。
模型训练收敛后,我依靠 coremltools 将其转换为了 Apple 原生支持的 .mlpackage。为了控制 App 包体积并保证低功耗运转,这个模型被我极致压缩,剥离了非必要分支,达到了极高的预测效率。
有了自己的模型,下一步就是在 iOS 端跑通流式推理。 我们不使用高层的 AVAudioRecorder,而是使用 AVAudioEngine。因为它允许我们通过 installTap 在音频流经过的过程中“截胡”到 AVAudioPCMBuffer。
然后在端侧把这个 Buffer 原样转化成模型需要的数组输入:
// 截胡音频流的伪代码
let inputNode = audioEngine.inputNode
let format = inputNode.inputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] (buffer, time) in
// 捕获到音频帧后,交给我们自定义的分类器管线
self?.audioCaptureService.processAudioBuffer(buffer)
}
audioEngine.prepare()
try audioEngine.start()
坑点来了!如果每次截取到一个 buffer,都在主线程或者随机的 Dispatch Queue 去实例化这个自定义模型进行预测,一晚上下来你的 App 必因为内存暴涨被系统强制 Kill 掉(Jetsam Event)。
解法:引入 Swift Actor 隔离与复用机制
在《睡眠声音日记》中,我是用全局唯一的 Actor 来维持模型的单一生命周期,使用环形缓冲区去缓存几秒钟的声音片断,组合后一次性输出:
swift
actor EventDetectionPipeline {
// 全局唯一持有我们自己训练好的模型实例
private let model = try? SnoreWaveformCNN(configuration: MLModelConfiguration())
func processAudioWindow(_ window: AudioWindow) async {
// 将音频转化成梅尔频谱所需的 MLMultiArray
guard let multiArray = window.toMLMultiArray() else { return }
// 发起端侧离线推理
if let prediction = try? model?.prediction(input: multiArray) {
if prediction.classLabel == "snore" {
// 命中目标:触发存储!
await persistCapturedEvent(label: .snore)
}
}
}
}
通过自己训练轻量级模型 + Actor 的串行数据处理,保证了模型资源的极致释放。即便后台连续疯狂推理 8 个小时,CPU 的平均占用率也能被压在极低的水平,用户即使整晚充着电,手机也完全不发烫。
识别完事件后,怎么持久化? 这引发了第二个大问题——千万别把音频这种大块二进制流全都写进 SwiftData 或者 Core Data!
我的存储策略是:结构化数据走 SwiftData(打点时间、标签量化数据),音频文件走沙盒原生写入。 在《睡眠声音日记》的 SleepEventRecord 模型中,我只存了一个相对路径(filePath)。
@Model
final class SleepEventRecord {
var timestamp: Date
var duration: TimeInterval
var eventLabel: EventLabel // .snore, .speech, .cough
var filePath: String? // 只存相对路径: "20240315/snore_0234.m4a"
init(timestamp: Date, eventLabel: EventLabel) {
self.timestamp = timestamp
self.eventLabel = eventLabel
}
}
为什么要相对路径? 因为沙盒路径(UUID)在每次应用重签或重新安装时是会变的。如果存绝对路径,第二天文件全找不到了!读取时,永远使用 FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent(filePath) 动态拼装。
录了一晚上的高音质 M4A 文件,如果不加限制,系统的 iCloud 备份会自动把它们全传上去。用户那可怜的 5GB iCloud 很快就会爆满。因此,我在写入音频文件后,立马用原生 API 给文件打上“拒绝备份”的 Tag:
var url = documentDirectoryURL.appendingPathComponent(fileName)
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true // 保护用户的 iCloud 空间!
try url.setResourceValues(resourceValues)
音频不用同步了,但我们的 SleepSessionRecord(当晚评分,鼾声次数统计,分析数据)需要跨设备(尤其是和 Apple Watch 联动时)和防止在用户删掉 App 重装后丢失。
以往做 Core Data + CloudKit 繁琐得让人想死。但在 iOS 17 的 SwiftData 下,它变成了真正的“优雅”:我们甚至可以动态控制它的开启闭合。
我把开关值存到了 NSUbiquitousKeyValueStore(KVS),根据这个从远程同步过来的用户偏好,动态初始化 ModelContainer:
// 在 SleepDiaryApp.swift 入口处动态配置容器
var sharedModelContainer: ModelContainer = {
let schema = Schema([SleepSessionRecord.self, SleepEventRecord.self])
// 读取 UserDefaults/KVS 的 iCloud 开关
let isCloudSyncEnabled = UserDefaults.standard.bool(forKey: "iCloudSyncEnabled")
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
// 如果开启,赋予 private 数据库标识;如果不开启,设为 .automatic 或本地优先
cloudKitDatabase: isCloudSyncEnabled ? .private("iCloud.xxxxx") : .none
)
do {
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Could not create ModelContainer: (error)")
}
}()
依靠云端大容量配额下的 .private 标识,只要用户打开 iCloud 同步,他们在换了新手机后重新下载 App,所有的睡眠数据历史记录就会像魔法一样哗哗哗回到列表中。
开发《睡眠声音日记》的这段时间里,我最大的感触是:苹果的原生护城河真的很香。 只依靠一套 Swift 兵器库:从 SwiftUI 的丝滑动画绘制、到 HealthKit 获取深度睡眠的联动、再到 Core ML 的底层加速,这是以往杂糅其它中间件完全得不到的性能优势和开发爽感。
感兴趣的同行们,可以在 App Store 搜 “睡眠声音日记-SleepDiary” 下载把玩一下,有任何架构或技术点上的建议,大家评论区见,或者私下找我交流。也欢迎吐槽!
在日常的 iOS 开发中,动态图(GIF、APNG、WebP)的展示几乎无处不在。然而,很多开发者在使用系统原生的 UIImageView 加载动态图时,往往会遭遇内存暴涨(OOM)或滑动卡顿的窘境。
作为 iOS 圈内最权威的图片处理框架,SDWebImage 为我们提供了一个非常好的解决方案——SDAnimatedImageView。
本文将从系统痛点出发,结合 SDWebImage 最新源码,深度拆解 SDAnimatedImageView 的底层架构、核心属性机制,并分享在复杂业务场景下的避坑指南。
文中所涉及源码均基于 SDWebImage 5.x 版本,示例代码采用 Objective-C,Swift 开发者可参照类似逻辑使用。
在了解 SDAnimatedImageView 之前,我们必须先明白系统原生方案到底差在哪里。
系统的 UIImage 在解析 GIF 时,采用“全量解码”策略。
一张体积仅为 2MB 的 GIF,如果包含 50 帧,系统会将其每一帧都解码成庞大的位图对象驻留在内存中。
解码后的位图大小 = 图片宽 × 高 × 4 字节(RGBA)。
假设宽高为 1000×1000,一帧就占约 4MB,50 帧就是 200MB,极易触发 OOM 崩溃。
图片的解码过程默认在主线程同步进行,会导致明显的掉帧和卡顿。
系统几乎没有提供控制 GIF 播放进度、暂停、快进的 API。
SDAnimatedImageView 的诞生,正是为了彻底颠覆这种粗放的渲染模式。
SDAnimatedImageView 继承自 UIImageView,但它在内部重构了整个动态图渲染管线。其核心思想是:按需解码,以可控的内存开销换取极致的播放流畅度。
它配合 SDAnimatedImage 使用。SDAnimatedImage 在初始化时,只保存动态图的原始文件数据(NSData),绝不提前解码任何一帧。此时,无论 GIF 有多少帧,内存占用几乎等于文件本身的大小。
// 从网络或本地获取 NSData
NSData *gifData = [NSData dataWithContentsOfFile:path];
// 创建 SDAnimatedImage,此时仅保留原始数据,不解码
SDAnimatedImage *animatedImage = [SDAnimatedImage imageWithData:gifData];
当动画开始播放时,它不会一次性解码所有帧,而是维护一个滑动窗口式的缓冲池。在渲染当前帧的同时,后台异步线程会提前解码接下来的几帧放入内存;当某一帧不再处于缓冲窗口内时,其占用的内存会被立即释放。
抛弃了传统的 NSTimer(容易受 RunLoop 阻塞影响导致掉帧),SDAnimatedImageView 底层采用了基于 VSync 信号的 CADisplayLink。它与屏幕刷新率完美同步,根据每一帧设定的 duration 精准计算渲染时机,保证动画如丝般顺滑,且在 App 退到后台时自动暂停,不浪费 CPU 资源。
很多开发者只把 SDAnimatedImageView 当作普通的 UIImageView 来用,这其实暴殄天物。以下几个核心属性,体现了框架设计的极致细节。
maxBufferSize 与 prefetchNumberOfFrames
@property (nonatomic, assign) NSUInteger maxBufferSize;
@property (nonatomic, assign) NSUInteger prefetchNumberOfFrames;
maxBufferSize:最大缓冲区大小(字节)。
⚠️ 重要纠正:很多人以为默认值 0 代表“不限制缓冲”,这是错误的!
根据官方源码注释,0 代表 Auto(自动调整),框架会根据当前设备的内存压力动态计算缓冲上限。
如果你需要极致的性能,可以设为 NSUIntegerMax(全缓冲,最高性能);如果内存极度吃紧,设为 1(代表无缓冲,最低内存)。
prefetchNumberOfFrames:预解码帧数,默认为 3~5 帧。
增大它可以提高流畅度(尤其在高帧率动图中),但会增加内存;减小则会降低内存占用,但可能在复杂 GIF 时掉帧。
这个值需要根据业务场景权衡。
runLoopMode
@property (nonatomic, strong) NSRunLoopMode runLoopMode;
⚠️ 源码纠正:普遍认为它的默认模式是 NSRunLoopCommonModes,但这并不完全准确。
官方源码的默认逻辑其实更智能:
// SDAnimatedImageView.m 中的 commonInit 片段
if ([[NSProcessInfo processInfo] processorCount] > 1) {
_runLoopMode = NSRunLoopCommonModes;
} else {
_runLoopMode = NSDefaultRunLoopMode;
}
NSRunLoopCommonModes,确保在 UIScrollView 滑动时,GIF 依然能流畅播放(因为滑动时 RunLoop 切换到了 UITrackingRunLoopMode)。NSDefaultRunLoopMode。目的是在滑动时主动暂停 GIF 播放,以节省宝贵的 CPU 资源用来保证列表滑动的流畅度。@property (nonatomic, assign) float playbackRate; // 播放速率,默认 1.0
@property (nonatomic, assign) BOOL clearBufferWhenStopped; // 停止时是否清空缓冲池
@property (nonatomic, assign) BOOL shouldIncrementalLoad; // 是否支持渐进式加载
playbackRate:支持 0.5 慢放、2.0 快进。这在实现类似“表情包编辑器”时非常有用。clearBufferWhenStopped:停止动画时是否清空帧缓存(默认 NO)。shouldIncrementalLoad:是否支持渐进式加载(默认 YES)。得益于 SDWebImage 的封装,日常开发中你甚至不需要手动创建 SDAnimatedImage,框架在下载完毕后会自动识别格式并适配。
步骤:
UIImageView 的 Custom Class 改为 SDAnimatedImageView;SDAnimatedImageView *imageView = [[SDAnimatedImageView alloc] init];
sd_setImage 方法:[self.imageView sd_setImageWithURL:[NSURL URLWithString:@"https://example.com/demo.gif"]
placeholderImage:[UIImage imageNamed:@"placeholder"]];
原理:SDWebImage 在下载完成后,会根据图片数据判断是否为动图(如检查 GIF 头部 GIF89a),如果是,会自动创建 SDAnimatedImage 实例并赋值给 animatedImage 属性,从而触发按需解码机制。
如果是加载 Bundle 或沙盒中的本地数据,必须手动包装为 SDAnimatedImage 才能触发低内存机制:
// 从 Bundle 中获取 GIF 数据
NSString *path = [[NSBundle mainBundle] pathForResource:@"demo" ofType:@"gif"];
NSData *gifData = [NSData dataWithContentsOfFile:path];
// 关键步骤:转换为 SDAnimatedImage,保留原始 NSData
SDAnimatedImage *animatedImage = [SDAnimatedImage imageWithData:gifData];
// 赋值给 SDAnimatedImageView
self.imageView.animatedImage = animatedImage; // 自动开始播放(若 autoPlayAnimatedImage 为 YES)
如果不想自动播放,可以设置 autoPlayAnimatedImage = NO,然后手动调用:
self.imageView.autoPlayAnimatedImage = NO;
self.imageView.animatedImage = animatedImage;
// 在合适的时机手动开始
[self.imageView startAnimating];
也可以获取当前播放状态:
NSUInteger currentFrame = self.imageView.currentFrameIndex;
NSUInteger currentLoop = self.imageView.currentLoopCount;
在将 SDAnimatedImageView 推向线上后,我们踩过几个深坑,这里分享给大家。
这是排名第一的线上低级错误。视觉上看不出区别,GIF 也能播放,但内存监控会报警。
只要没有把 Custom Class 改为 SDAnimatedImageView,它底层就会退化为原生的全量解码模式。
✅ 对策:在创建 ImageView 时,务必确认类型。
场景:首页用 SDAnimatedImageView 加载并缓存了一个 GIF。进入详情页,由于某些原因使用了原生的 UIImageView 加载同一个 URL。
现象:详情页的 GIF 变成了一张静态图。
原因:SDWebImage 的磁盘缓存中,为了保留 SDAnimatedImage 的特性,存储的是经过优化的特殊格式数据。普通的 UIImageView 从缓存读取后,由于不具备解码动态图的能力,只能显示第一帧。
✅ 对策:在项目架构层面,统一动态图加载组件,严禁混用原生 UIImageView 和 SDAnimatedImageView 加载同一个动态图 URL。
SDAnimatedImageView 默认支持 GIF 和 APNG,但不支持 WebP 动图。如果你需要播放 WebP,必须引入独立的解码器。
正确集成方式(在 Podfile 中):
pod 'SDWebImageWebPCoder'
然后在 App 启动时注册:
#import <SDWebImageWebPCoder/SDImageWebPCoder.h>
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[SDImageCodersManager.sharedManager addCoder:SDImageWebPCoder.sharedCoder];
return YES;
}
在包含大量 GIF 的朋友圈或微博 Feed 流中,建议在 UITableViewCell 的 prepareForReuse 中配合以下设置:
- (void)prepareForReuse {
[super prepareForReuse];
// 取消正在进行的图片加载
[self.gifImageView sd_cancelCurrentImageLoad];
// 停止播放并清空缓冲,极大缓解长列表内存压力
self.gifImageView.clearBufferWhenStopped = YES;
[self.gifImageView stopAnimating];
}
为什么这样做?
sd_cancelCurrentImageLoad 避免复用 Cell 时旧图片加载回调错乱。clearBufferWhenStopped = YES 确保 Cell 离开屏幕后立即释放解码内存。stopAnimating 停止 CADisplayLink 回调,节约 CPU。如果 GIF 设置了但不播放,可以按以下顺序检查:
animatedImage 属性不为 nil(如果是网络加载,检查 sd_setImage 的回调中是否成功)。autoPlayAnimatedImage 是否为 YES,或手动调用了 startAnimating。runLoopMode 是否在当前 RunLoop 模式下被允许(常见于滑动时,若设置为了 NSDefaultRunLoopMode 则滑动时会暂停)。SDAnimatedImage 的 images 属性查看帧数)。SDAnimatedImageView 绝不仅仅是一个“能播 GIF 的 ImageView”。它通过 按需解码、动态帧缓冲、VSync 驱动 以及 设备自适应策略,在内存与性能之间找到了最优解。
理解并善用它的进阶属性(如 maxBufferSize 的 Auto 机制、clearBufferWhenStopped 等),不仅能让你的 App 告别动态图引发的 OOM 崩溃,更能体现出一名 iOS 开发者对底层渲染机制的深刻理解。在动态图渲染这一块,SDAnimatedImageView 依然是当前业界当之无愧的标杆。
互动时间:你在项目中遇到过哪些动态图相关的“奇葩”问题?欢迎在评论区留言,我们一起探讨最佳实践!
在很多团队里,混淆这一步常常被外包给在线加固服务:上传 IPA,等结果,下载再签名。流程确实顺手,但当项目涉及商业逻辑或私有算法时,这种方式总让人有点不踏实——完整的二进制、资源、接口结构都离开了本地环境。
后来我们把这一步彻底改成本地执行,不上传任何文件,不改工程源码,只操作已编译好的 IPA
把构建好的 IPA 复制一份并解压:
unzip app.ipa
进入目录:
Payload/App.app
检查三个位置:
strings AppBinary | head
如果能看到:
UserManager
PaymentService
VipController
说明符号没有做处理。
assets/images/vip_banner.png
config/payment.json
路径本身已经带有业务语义。
main.jsbundle
index.html
这些文件如果未压缩,直接可读。
整个流程不依赖任何远程服务,结构如下:
IPA 文件
→ 本地解析
→ 本地混淆
→ 本地资源处理
→ 本地签名
→ 本地测试
关键在于:所有操作都发生在开发机器上。
如果项目中包含 WebView 或 React Native 模块,可以在 IPA 处理前压缩脚本。
例如:
terser main.js -o main.min.js
或者:
uglifyjs page.js -o page.min.js
压缩后再替换回 IPA 资源目录。
这样可以先降低 JS 层的可读性。
这一步是核心。
使用 Ipa Guard 这类本地运行的 IPA 混淆工具,可以直接处理 Mach-O 文件,而不需要源码。
操作过程:
可以看到:
OC 类
Swift 类
OC 方法
Swift 方法
在列表中选择需要处理的符号,例如:
UserManager
PaymentHandler
VipService
执行后:
UserManager → k39sd2
整个过程在本地完成,不会上传任何数据。
继续在 Ipa Guard 的资源模块中操作。
勾选:
执行后:
vip_banner.png → a82kd.png
payment.json → x92ks.json
工具会自动更新引用路径。
这一层的作用是让资源结构失去语义。
如果多个应用使用相同资源,文件内容会成为识别依据。
在 Ipa Guard 中开启 MD5 修改:
md5 banner.png
处理前后不同。
文件视觉效果不变,但指纹已经改变。
检查:
strings AppBinary | grep NSLog
如果存在日志或调试字符串,可以在混淆阶段删除。
Ipa Guard 提供调试信息清理选项。
为了避免 IPA 被二次篡改,可以在原生层加入简单校验:
例如:
if hash != expected { exit(0) }
这一步不依赖混淆工具,但可以作为补充。
混淆后 IPA 已失去原签名,需要重新签名。
可以使用:
kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i
或者直接在 Ipa Guard 中配置证书。
连接设备后可以直接安装。
安装后重点检查:
如果出现异常,通常是:
把 IPA 混淆完全放在本地执行,并不只是“更安全”的选择,它还带来一个实际好处:每一步都可控、可调试、可回滚。相比上传到云端处理,本地流程更适合需要长期维护的项目。
最近 Claude Skills 很火。
但我观察了一圈,发现大家都在陷入一种“开发者的自嗨”。
绝大多数 Skills 的应用场景都被死死锁在 IDE 里,锁在开发者的电脑前。
这叫开发提效,不叫业务提效。
真正的业务发生在移动端,发生在你通勤、吃饭、甚至躺在床上刷 TikTok 的时候。
如果你的 AI 能力必须打开电脑、输入命令行才能调用,那它的时空效率就是零。
于是我抛弃本地的 Claude Code,基于 OpenHands 做了一套云端 Skills 系统。
效果极其简单粗暴:
我在刷 TikTok,看到一个爆款视频,点击复制链接,敲击 iPhone 背面三下。
![]()
20 秒后,我的飞书多维表格里自动新增了一行数据。
![]()
这行数据包含了:这个视频的无水印文件、Gemini 拆解的镜头语言分析、爆款原因推导,以及一套可直接复用的 AI 视频生成提示词。
全过程我不需要打开电脑,不需要切换 APP,不需要等待。
这就是我今天要聊的:如何用 OpenHands + Skills + iOS 快捷指令,构建一套真正落地的业务自动化系统。
先厘清两个概念:OpenHands 和 Claude Code。
Claude Code 是 Anthropic 官方推出的命令行工具,它是一个嵌入在你本地终端里的结对程序员。它的 Skills 本质是上下文记忆和本地工具接口。
它的优势是懂你的代码规范,能直接改你电脑里的文件。
但它有一个对于业务场景的致命弱点:它必须依附于你的会话,你不在,它就不动。
它是一个副驾驶(Copilot)。
而 OpenHands(前身 OpenDevin)是一个开源的、自主的 AI 软件工程师。它运行在 Docker 容器里,是一个独立的服务端 Agent。
![]()
它是一个可以被封装成 API 服务的数字员工。
我看重 OpenHands 的核心理由只有一个:它可以 24 小时在线,并且可以通过 API 远程唤醒。
我做的这个 TikTok 分析系统,本质就是把 OpenHands 部署在服务器上,通过 FastAPI 暴露接口。
Claude Code 是给你用的工具;OpenHands 是你雇佣的、随时待命的员工。
🐵
小提示:FastAPI 的服务地址后加/docs就是文档了
对于做出海营销和短视频矩阵的朋友,拆解爆款是每天的必修课。
传统的流程极其反人类:
这个链路太长,断点太多。任何需要延迟满足的流程,最终都会变成不了了之。
我的远程 Skills 方案,把这个流程压缩到了极致。
整个逻辑是这样的:
![]()
利用 iOS 自带的快捷指令 + 背部轻点功能。
![]()
![]()
OpenHands 接收到请求后,自主执行以下 Skills:
启动无头浏览器。这里有一个技术难点,TikTok 的反爬虫机制非常严格。如果用普通的 request 请求,成功率几乎为零。OpenHands 调用 Playwright 模拟真实浏览器行为,绕过 blob 协议,抓取真实的 MP4 视频流。这种方式的下载成功率稳定在 70%-80%
视频下载后,调用Gemini 2.5 Flash,快且便宜。它不只是看,它是理解。它可以识别拍摄角度(俯拍/特写)、运镜方式(推拉摇移)、BGM 节奏点、色彩心理学。
将清洗好的结构化数据(JSON),通过 API 写入飞书多维表格。
结果:
当你刷完半小时视频,打开飞书,几十个爆款视频的深度分析报告已经整整齐齐躺在那里了。
这才是 AI 赋能业务的本质:隐形化。
![]()
Openhands 的 Skills 文档:
docs.openhands.dev/sdk/guides/…
这套架构的核心逻辑是:移动端触发 -> 服务端 API -> OpenHands 执行复杂 Skills -> 结果回传。
这个逻辑在出海业务里有无限的延展性。
我给几个具体的场景,你们可以拿去直接落地。
场景一:竞品独立站监控
场景二:亚马逊差评自动预警与回复草稿
场景三:广告素材批量生产
这是很多技术出身的朋友最容易陷入的误区。
你这个功能,我写个 Python 脚本 + 定时任务也能跑,为什么要搞这么复杂的 OpenHands Skills?
因为业务逻辑是流动的,而脚本是僵死的。
如果你写死了一个 Python 脚本:
但在 OpenHands Skills 的架构下,我们定义的不是步骤,而是目标。
在我的 Skill 定义里,我告诉 OpenHands:你的任务是下载这个页面上的视频,如果常规方法失败,尝试模拟用户滚动;如果还失败,检查是否有验证码并尝试通过。
OpenHands 作为一个 Agent,它具备自主决策和自我修复的能力。
在跨境出海这种平台规则朝令夕改的环境下,维护脚本的成本极高。
我们需要的是一个能够理解意图并自主寻找路径的智能体。
文章到这里,这套远程 Skills 系统的雏形已经搭建完毕。
但如果你觉得这就结束了,那你就小看了 Agentic Skills 的天花板。
我们现在的架构是“一个请求触发一个 Skill”,但这只是冰山一角。真正的威力在于 Multi-Skill Orchestration(多技能编排)。
OpenHands 的 Skill 本质是可执行的逻辑单元。我们可以像写代码一样,让 Skill A 去调用 Skill B。
你可以构建一个自我迭代的 Agent。让它先写一段代码(Coding Skill),然后自己运行测试(Testing Skill),如果报错,递归调用 Coding Skill 进行修复,直到测试通过。
OpenHands 运行在 Docker 里,这意味着它可以部署在任何地方。
这样,通过 API 网关,你可以指挥内网的 Agent 去调用外网的 Agent,实现数据在安全域和互联网域之间的智能流转。
谁说 API 只有“请求-响应”这一种模式? 在我的系统中,有些复杂任务(如竞品深度调研)可能需要运行 30 分钟。
你点击确认后,Agent 继续执行。这才是真正的人机协作:AI 处理海量冗余信息,人类只在关键节点做决策。
在这个体系下,Skills 不再是静态的脚本,而是可生长、可组合的原子能力。
未来,你的个人服务器里可能运行着上百个这样的 Skills。它们是一群田螺姑娘,在你睡觉的时候,帮你监控市场、回复邮件、整理知识、优化代码。
而你,只需要握着手机,轻轻敲两下背部,就像魔法师挥动了魔杖。
这,才是 Agent 时代的真正玩法。
2025 做了很多场线下AI 跨境电商的沙龙交流,给我一个非常割裂的感觉。
现在AI领域已经迭代的很好了,但跨境电商大多都很传统,别说AI,连自动化数字化都还没做到。
所以如果用AI去升级会是一个超级大的机会,预判到2026年会有一个大爆发。
但这波爆发不是比谁更会铺货、不是谁的亚马逊生图更好看、不是谁的TK UGC 视频更真实
而是比谁更懂精细化运营。
其中,最典型的就是邮件回复。
现在大多都是用人工、或者用规则、最多上个知识库索引。
效果不用想都知道很差,没有灵魂。
因为AI没有记忆,记不住用户的画像。
记住了又有什么用呢?能把单纯是「客服」性质的回答,升级生成「促销转化」的销冠。
例如根据用户的身高三围推荐尺码、根据喜好推荐产品,甚至可以做连带销售的推荐提高客单价。
成本极低,ROI直接拉满。
这样的AI Agent你真的不想要吗?
今天就教你怎么做这样一个n8n+知识库 RAG+AI 记忆的 AGENT!!
这个邮件Agent 是一个典型,搞懂了这个逻辑之后,去跑别的 AI 数字员工,就很丝滑了。
在开始搭建之前,我必须先说一个残酷的通用事实:市面上90%的 AI 客服都是“一次性”的。
你搭了一个基于 RAG(检索增强生成)的知识库,把几万字的退换货政策扔进去。客户问:“怎么退货?” AI 回答得滴水不漏。
但下一秒,客户问:“那我上次买的那件 M 码穿着紧,这次我是不是该换 L 码?”
这时候,你的 AI 傻了。
因为它没有记忆,或者说它的记忆在每轮对话结束后就清零了。
它不知道客户“上次”买了什么,也不知道客户“上次”反馈过 M 码紧。它只能冷冰冰地回复:“请提供您的订单号。”
这就是无状态的痛点。
要解决这个问题,我们需要一个能 读写记忆 的系统,而不仅仅是一个静态的文档库。
最近我挖到了一个王炸级的开源项目 —— MemOS 2.0「星尘 Stardust」。
![]()
它不仅仅是能存数据,它直接把“企业知识库”和“用户动态记忆” 打通了。看看下面这张图,MemOS 是怎么思考的:
![]()
它帮我们解决了三个最核心的问题:
这就相当于给你的 AI 装了一个会自动记笔记的海马体。
![]()
使用上,MemOS 支持把文件和 URL 直接导入知识库。
对话过程中记忆会持续更新并随着增长逐渐形成偏好记忆,并且能把文本、图片、文件、工具调用等信息统一记忆,必要时还能使用自然语言对已有记忆做纠错和清理。
而且,在配置的过程中,我发现了一个华点:系统会根据对话内容自动演化并更新记忆层,从而推动知识库的持续自进化。
![]()
卧槽??这不就是一直在困扰我的知识库动态更新的问题吗?
原本要手动去插入、更新之类的,现在你跟我说,直接对话就能自动更新了??
那我以前熬夜搭的流程算什么??
行吧,下面,直接上实操。
超级福利!!完整n8n工作流源码放文末了。
真的开箱即用了朋友们!!
智能客服对于服装企业来说需求是很大的,几万个SKU能用 AI来管理的话,效率和产出都是成指数增长的。
我们就拿 SHEIN 为例。
![]()
当然我没有SheIn的内部资料,我让GPT老师给我生成了好几个文档,涵盖售前的尺码推荐、物流、售后的退换货、洗护等政策。
![]()
开始前先给大家看下整个流程是什么样的。
![]()
整套系统的核心逻辑在于“身份锚定 + 双重检索 + 记忆闭环” 。
首先,n8n 利用 Gmail 的 threadId 锁定会话上下文,提取发件人邮箱作为唯一身份标识 user_id
接着,系统执行双路并行检索:
一路调用 /search/memory 获取业务文档(如尺码表、退货政策)及用户长期画像(如身高体重);
另一路调用 /get/message 拉取当前邮件往来的短期历史记录。
AI 将这些“静态规则”与“动态偏好”融合,生成兼具专业度与情绪价值的回复。
最后,通过 /add/message 将本次交互回写至 MemOS ,让 AI 的记忆随着每一次沟通自动进化,越用越懂客户。
因为前面的资料都是 AI 生成的,所以我把全部东西都扔到 Gemini 里,让它来给我们判断一下这个工作流的精准度如何。
这是第一次邮件,这里关键就看知识库是否能精准击中需求。
这里我介绍了我的数据,问选型之类的售前问题。
![]()
直接看回复
![]()
Gemini 老师的评价是很好:
![]()
接下来测试一下短期记忆。
![]()
这是第二轮了
此时,通过conversation_id能成功获取前面邮件的对话记录,也就是说成功把两封独立的邮件串起来了,完成了多次连续对话的能力。
![]()
再看下回复效果:
![]()
Gemini 老师表示满分:
![]()
这次,我没有说自己的数据就直接让它推荐一条牛仔裤
Hi,
我这次想买 "SHEIN High Waist Straight Leg Jeans"。 还是以前的身材数据没变,请问这款牛仔裤我该选什么码? 我看评论说这个没有什么弹性,我很怕卡裆或者腰太紧。
回复效果:
![]()
Gemini老师评价是依然发挥稳定哈哈哈:
![]()
看来效果针不戳,但背后操作其实特别简单!!
相信我!!有手就行!!
接下来,我们逐个模块来看下。
到MemOS后台,进入知识库页面,直接右上角点添加知识库
memos-dashboard.openmem.net/cn/knowledg…
如图按要求输入名称就好了:
![]()
接着把之前GPT老师给的资料,也就是公司客服相关的文件扔进去。
这里不需要做任何配置,默认效果就不错了。
![]()
在如图这个位置拿到知识库ID
![]()
MemOS 的接口文档在这里,基本上读写记忆等常规API 都有了,备用:
memos-docs.openmem.net/cn/api_docs…
![]()
至此 MemOS 部分的设置就结束了,简单的令人发指。
接下来就到n8n工作流的部分。主要是用它把 Gmail、MemOS 和 AI 连接起来。
![]()
我把整个工作流拆解成了三个核心模块,大家跟着做就行。
![]()
避免一些垃圾邮件干扰我们处理了。
![]()
我们是电商公司,你是邮件内容判断助手。
请判断当前邮件内容是否为客户的售前、售后咨询。
如果是,回复 {"客户邮件":"是"};否则回复 {"客户邮件":"否"}。
这是最核心的处理部分。
![]()
![]()
通过 http请求节点跟 MemOS 交互。
![]()
![]()
最后一步,不仅是回复,更是为了让 AI 记住这次交互,这是越用越好用的关键。
![]()
# Role
你不是机器人,你是 **SHEIN 专属时尚顾问 (Style Bestie)**。
目标:用温暖、专业且带时尚感的语气解决问题。
# Context Data
1. 记忆与知识库: {{ $('检索记忆').item.json.data.memory_detail_list }}
2. 对话历史: {{ $('获取历史').item.json.data.message_detail_list }}
# Guidelines
- **拒绝机械感**:禁止说“根据数据库显示”。
- **显式记忆**:如果发现用户身高体重(如 170cm),必须在回复中显式提及("考虑到您 170cm 的高挑身材...")。
- **情绪价值**:适当夸赞用户眼光,使用 Emoji 😊。
# Output
必须输出 **HTML 格式** 的邮件正文,使用 <p> 和 <strong>标签排版。
注意这里我让 AI 返回的 HTML 格式,确保客户收到的邮件也是富文本格式的,提高阅读体验。这是简略版,完整版见文末原文。
这一套下来,你不仅拥有了一个能秒回邮件的客服,更拥有了一个能不断自我进化的用户数据资产库。
每一封邮件,都在让你的企业大脑更聪明一点。
这套 n8n + MemOS 的打法,直接把跨境电商的客服水平拉高了一个维度。
它不是在做“问答”,它是在做“关系管理”。
这套系统的核心价值,不在于它省了多少人工(虽然它确实省了),而在于它能留存客户资产。
以前,最有经验的客服离职了,他对客户的了解也就带走了。
现在,所有的记忆、偏好、习惯,全部沉淀在 MemOS 的记忆层里。哪怕你换了 10 批运营,AI 依然记得那个喜欢穿宽松牛仔裤、住在深圳、对运费敏感的老客户。
这就是数据资产。
既然 MemOS 能做大脑,n8n 能做手脚,那这个“超级销售”就不应该只活在邮箱里。
对于做高客单价(如假发、珠宝、3D打印机)的卖家,私域是命脉。
把这套逻辑接入 WhatsApp Business API,AI 能记得客户上个月说了“想给女儿买生日礼物”,并在生日前一周自动推送新品。
这转化率,比群发广告高 100 倍。
别再用那种只会弹优惠券的智障弹窗了。
把 MemOS 接入网站右下角的聊天窗,当用户浏览商品时,AI 能主动提示:“这件大衣和你上次买的靴子超搭哦!”
2026 年的红利,属于那些敢把 AI 塞进业务心脏里的人。
MemOS 2.0 现在的门槛极低,我已经把最难的“路”给探完了。
有兴趣的小伙伴可以去项目里面玩玩看
目前项目已经全面开源 github.com/MemTensor/MemOS
别观望了,去注册个账号,把你的文档扔进去试试。
哪怕只跑通一个场景,你的业务效率都能像滚雪球一样飞起来。
完整n8n工作流源码
关注公众号「饼干哥哥AGI」
后台回复「邮件Agent」即可
Harbeth是一个基于Metal的高性能图像处理库,为iOS和macOS开发者提供了一套简洁、高效的图像滤镜解决方案。它不仅支持传统的图像滤镜效果,还能处理HDR图像,让你的应用在图像处理方面如虎添翼。
| 用故障艺术美学建立动态RGB通道分离 | 实时检测边缘并添加霓虹灯发光效果 |
|---|---|
超过 200+ 内置滤镜,组织成直观的类别,涵盖从基本颜色调整到高级艺术效果的各种功能
// Swift Package Manager
.package(url: "https://github.com/yangKJ/Harbeth.git", from: "0.0.1")
// CocoaPods
pod 'Harbeth'
import Harbeth
// 加载图像
let image = UIImage(named: "example")!
// 创建滤镜
let filter = C7Brightness(brightness: 0.2)
// 应用滤镜
let result = try? HarbethIO(element: image, filter: filter).output() as? UIImage
// 显示结果
imageView.image = result
// 组合多个滤镜
let filters: [C7FilterProtocol] = [
C7Brightness(brightness: 0.1),
C7Contrast(contrast: 1.2),
C7Saturation(saturation: 1.3),
C7GaussianBlur(radius: 2.0)
]
// 应用滤镜链
let result = try? HarbethIO(element: image, filters: filters).output() as? UIImage
// 创建自定义滤镜
struct CustomFilter: C7FilterProtocol {
var modifier: ModifierEnum {
return .compute(kernel: "customKernel")
}
var factors: [Float] = [0.5, 0.5, 0.5]
}
// 应用自定义滤镜
let customFilter = CustomFilter()
let result = try? HarbethIO(element: image, filter: customFilter).output() as? UIImage
// 加载HDR图像
let hdrImage = UIImage(named: "hdr_example")!
// 应用HDR到SDR转换
let hdrFilter = HDRToSDR()
let result = try? HarbethIO(element: hdrImage, filter: hdrFilter).output() as? UIImage
// 实时处理相机捕获的图像
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
// 异步处理
HarbethIO(element: sampleBuffer, filter: C7Vibrance(vibrance: 0.5)).transmitOutput { result in
switch result {
case .success(let processedBuffer):
// 处理成功,显示结果
DispatchQueue.main.async {
self.previewLayer.enqueue(processedBuffer)
}
case .failure(let error):
print("处理失败: \(error)")
}
}
}
Harbeth在性能方面的表现令人印象深刻:
Harbeth适用于各种需要图像处理的场景:
Harbeth 完全支持 macOS 平台,为桌面应用提供强大的图像处理能力,打造原生、优化的用户体验:
探索 Harbeth 在 macOS 上的强大功能:
|
|
|
|
|
|
|
|
Harbeth是一个功能强大、性能优异的Metal图像处理库,它不仅提供了丰富的滤镜效果,还支持HDR图像处理,为iOS和macOS开发者提供了一套完整的图像处理解决方案。
无论是快速原型开发还是生产环境应用,Harbeth都能满足你的需求。它的高性能特性让图像处理不再成为应用的性能瓶颈,而简洁的API设计则让开发过程更加愉快。
如果你正在寻找一个强大而灵活的图像处理库,Harbeth绝对值得尝试!
让Harbeth为你的应用添加绚丽的图像处理能力,让每一张图片都成为艺术品! 🎨✨
许久未更新内容了,除了被公司的项目倒腾、拉扯之外,其实最近几个月还是干了许多事情的。我就随便聊聊吧。
其实这个尝试,没有使用AI的功能,完全就是我自己无聊做的一点尝试,我将自己的UniAppPlayAndroid打包成为wgt,然后把GetXStudy项目的Flutter模块全部都集成到RxStudy项目,做了一个超级大杂烩,并且尝试几个端的通信,大家看看效果。
项目截图:
![]()
CocoaPods 停止维护的消息,iOS 开发者应该都有所耳闻。趁着这个机会,我拿自己 2019 年就开始维护的 RxStudy 练手项目做了一次大迁移。
迁移方案:CocoaPods → Tuist + Swift Package Manager (SPM)
迁移耗时:前前后后大概 3天(从开始到项目能跑起来)。说实话,本来以为会花费更多时间,没想到有了 AI 的帮助,大概就花了这么点时间就搞定了,大大出乎我的意料。
迁移内容:
Project.swift 定义项目结构、Targets、SPM 依赖引用Tuist/Package.swift 管理 20+ 第三方库的 SPM 版本![Tuist + SPM 架构图]
AI 表现:大部分时间花在 Tuist 配置文件的编写上,AI 生成的代码基本可以直接使用,复盘时发现主要还是项目结构本身比较规范。
在完成 CocoaPods 向 Tuist 迁移后,我又给 AI 安排了一个新任务:把 RxSwift 里的 UIKit 代码向 SwiftUI 进行迁移。
迁移策略:采用双 Target 并行架构,而非一次性替换
| Target | 技术栈 | 说明 |
|---|---|---|
| RxStudy | UIKit + RxSwift | 原有代码 |
| SwiftUIApp | SwiftUI + @Observable + async/await | 新迁移代码 |
迁移结果:SwiftUIApp 这个 Target 里的代码,95% 都是 AI 写的,我只是给出了部分建议,以及尝试在两个 Target 中复用网络请求层代码。
迁移模块(共10个):
| 模块 | 功能 |
|---|---|
| Home | Banner + 文章列表 |
| Project | 项目分类 |
| PublicNumber | 公众号 |
| Tree | 体系结构(二级树形) |
| Mine | 用户中心 |
| Login | 登录 |
| Collect | 收藏列表 |
| Coin | 积分明细 |
| CoinRankList | 积分排行榜 |
| Search | 热搜 + 搜索结果 |
技术栈变化:
| 类型 | 迁移前 | 迁移后 |
|---|---|---|
| 状态管理 | RxSwift (RxSwift 6.9.0) | @Observable + async/await |
| 状态绑定 | RxCocoa | SwiftUI 原生 |
| 网络层 | Moya + RxSwift | Moya + async/await |
说明:SwiftUI 迁移没有使用 Combine,而是使用了 iOS 17+ 的
@Observable宏和 Swift 的async/await,代码更简洁。
项目截图:
![]()
AI 表现:对迁移的功能表示满意,尤其是网络请求层的复用处理得不错。不过 SwiftUI 部分复杂的交互动画(比如下拉刷新 + 列表滚动 + 头部视差效果),还是需要自己动手调整。
实际上我很久之前写过一个 UniApp 版本的玩安卓,只是很久没有维护了。由于我想把这个 UniApp 打包的 wgt 文件在 HarmonyOS Next 里通过小程序运行,但 uniCloud 环境仅支持 Vue3 版本的小程序。
想着 AI 不用白不用,于是让它帮我进行迁移。
迁移耗时:大约 2小时 完成全部迁移。
技术栈变化:
| 类型 | Vue2 | Vue3 |
|---|---|---|
| Vue | 2.x | 3.4.21 |
| 状态管理 | Vuex | Pinia 2.1.7 |
| 构建工具 | webpack | Vite 5.2.8 |
| uni-app | 旧版本 | 3.0.0-alpha |
| 页面写法 | Options API | Composition API |
支持平台:
| 平台 | 状态 | 说明 |
|---|---|---|
| H5 | ✅ | 使用 Vite 代理解决跨域 |
| 微信小程序 | ✅ | 完全支持 |
| Android App | ✅ | 可编译 wgt 热更新包 |
| iOS App | ✅ | 可编译 wgt 热更新包 |
| HarmonyOS Next | ✅ | 存在 WebView bug,使用条件编译规避 |
典型问题与解决方案:
| 问题 | 解决方案 |
|---|---|
| 根目录缺少 index.html | 创建 Vue 3 入口 HTML |
| uview-plus 样式找不到 | 改用原生组件 |
可选链 ?. 不支持 |
替换为 && 短路求值 |
| CORS 跨域 | Vite devServer 代理 |
| HarmonyOS WebView 崩溃 | 使用条件编译显示占位页 |
AI 表现:18个页面全部迁移完成,有完整的迁移文档和迁移指南。迁移过程中遇到的一些边界问题,AI 给出的解决方案都比较合理。
让 AI 将项目从 5.0 向 6.0 迁移,它顺便把一些第三方库也帮我进行了迁移和升级。
路由系统 API 重大变更:
// 5.0 (已废弃)
router.pushNamedRoute({ name: 'pageName', params: {} })
router.getParams()
// 6.0
router.push({ uri: 'pages/pageName', params: {} })
LoadingDialog 兼容性问题:
CustomDialogController 必须在正确的 UI 上下文中创建@jxt/xt_hud 库,通过全局 UIContext 初始化第三方库依赖:
| 库 | 版本 | 说明 |
|---|---|---|
| @ohos/axios | 2.2.7 | HTTP 网络请求 |
| @pura/harmony-utils | 1.4.0 | 工具库 |
| @jxt/xt_hud | 3.4.0 | Loading/Toast(6.0 新增) |
| @ohos/imageknife | 3.2.8 | 图片加载缓存 |
项目截图: 配合上面UniAppPlayAndroid的Vue2到Vue3的升级,我终于可以在打包好的wgt文件在HarmonyOS Next正常运行起来了。
![]()
AI 表现:路由迁移采用了最小改动方案,保留兼容性。AI 还顺便优化了 Router 类的实现,并完成了 Network HAR 模块的封装。
我个人觉得这个 Flutter 项目可以优化的地方有限,但 AI 还是给了一些不少的中肯意见,没事就让它跑跑,还是做了不少提交。
优化内容:
| 优化项 | 详情 | 状态 |
|---|---|---|
| 修复废弃 API | MaterialStateProperty → WidgetStateProperty | ✅ 已完成 |
| 替换 print | 8处 print → logger.d() | ✅ 已完成 |
| 图片压缩 | launchImage.png 4.9MB → 可压缩 70-90% | ✅ 已完成 |
| Git Hooks | 添加 pre-commit 自动化检查脚本 | ✅ 已完成 |
| 清理导入 | 5处未使用的 import 移除 | ✅ 已完成 |
| 密码安全 | 明文存储 → flutter_secure_storage 加密 | ✅ 已完成 |
| 网络缓存 | 减少约 60% 重复请求 | ✅ 已完成 |
| 异常处理 | 统一 ErrorHandler 工具类 | ✅ 已完成 |
AI 表现:提供了详细的优化报告(OPTIMIZATION_REPORT.md、ADDITIONAL_OPTIMIZATION.md),优化效果量化可查。
AI 使用组合:Claude + MLG 4.7 和 Claude + MiniMax 2.5
实话实说:
几个项目的共同特点:
个人感悟:时常在想,就这么付费上班,是不是也挺肉疼。后来想想,上班没那么累,下班可以正常走,也算行吧。
| 项目 | GitHub 地址 | 相关分支 | |
|---|---|---|---|
| RxStudy (iOS) | seasonZhu/RxStudy |
refactor/tuist-migration (CocoaPods→Tuist)refactor/swiftui-migration (UIKit→SwiftUI)develop_flutter (集成Flutter、UniApp模块) |
|
| UniAppPlayAndroid (跨平台) | seasonZhu/UniAppPlayAndroid |
develop_vue3 (Vue2→Vue3) |
|
| HarmonyStudy (HarmonyOS) | seasonZhu/HarmonyStudy |
develop_os6 (5.0→6.0) |
|
| GetXStudy (Flutter) | seasonZhu/GetXStudy |
optimize-project (代码优化) |
作者 GitHub:@seasonZhu