阅读视图

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

Swift多线程方案-Concurrency

简介

Swift Concurrency(async/await)是从 Swift 5.5 开始引入的一套并发编程模型,用来替代传统的回调(callback)、闭包嵌套(callback hell)、以及部分 GCD 使用场景,让异步代码写起来像同步代码一样清晰。

async / await

  • async:标记函数是 异步函数
  • await:表示 等待异步结果

本质:await 会“挂起当前任务”,但不会阻塞线程

示例

func fetchUser() async -> String {
    return "Tom"
}

func loadData() async {
    let user = await fetchUser()
    print(user)
}

async throws

支持错误处理(替代 callback 的 error)

enum NetworkError: Error {
    case failed
}

func fetchData() async throws -> String {
    throw NetworkError.failed
}

func load() async {
    do {
        let result = try await fetchData()
        print(result)
    } catch {
        print("error: \(error)")
    }
}

Task

Task 是并发执行的基本单位,类似 GCD 的 block,也是一个对象。

  • 普通 Task
Task {
    let data = await fetchUser()
    print(data)
}
let task = Task {
    ...
}
  • Detached Task(独立线程)
Task.detached {
    await doSomething()
}

区别:

Task:继承当前 Actor / 优先级 / 上下文

detached:完全独立(慎用)

async let

并发执行

场景:多个接口同时请求

func loadData() async {
    async let user = fetchUser()
    async let posts = fetchPosts()
    
    let result = await (user, posts)
    print(result)
}

TaskGroup

任务组

场景:批量请求 / 并发处理列表

func fetchAll() async {
    await withTaskGroup(of: String.self) { group in
        for i in 1...3 {
            group.addTask {
                return "Task \(i)"
            }
        }
        
        for await result in group {
            print(result)
        }
    }
}

Actor

线程安全方案

用于解决数据竞争问题(替代锁)

actor Counter {
    private var value = 0
    
    func increment() {
        value += 1
    }
    
    func getValue() -> Int {
        return value
    }
}
let counter = Counter()

Task {
    await counter.increment()
    let v = await counter.getValue()
    print(v)
}

Actor = 自动串行队列 + 数据隔离

对比

方案 问题
NSLock 易死锁
DispatchQueue 需要手动管理
Actor 天然安全

实战应用场景

  • 网络请求
func fetchUser() async -> User { ... }
func fetchPosts() async -> [Post] { ... }

func load() async {
    let user = await fetchUser()
    let posts = await fetchPosts()
}

——对比旧方案

fetchUser { result in
    fetchPosts { posts in
        // 嵌套地狱
    }
}
  • 多个接口并发请求

多个请求任务并行执行,等待异步结果

func loadPage() async {
    async let banner = fetchBanner()
    async let list = fetchList()
    async let profile = fetchProfile()
    
    let (b, l, p) = await (banner, list, profile)
}
  • 主线程更新UI
func loadData() {
    Task {
        let data = await fetchData()
        
        await MainActor.run {
            self.label.text = data
        }
    }
}

或 使用@MainActor

@MainActor
func updateUI() {
    label.text = "Hello"
}
  • 取消任务
let task = Task {
    let data = await fetchData()
}

task.cancel()
  • 图片加载
func downloadImage(url: URL) async throws -> UIImage {
    let (data, _) = try await URLSession.shared.data(from: url)
    return UIImage(data: data)!
}
let task = Task {
    let image = try await downloadImage(url: url)
    cell.imageView.image = image
}

注意:需要处理 cell 复用问题(取消任务)-》 避免图片错位

override func prepareForReuse() {
    task?.cancel()
}
  • 顺序依赖
func process() async {
    let token = await login()
    let data = await fetchData(token: token)
    let result = await parse(data)
}

对比

方案 特点
GCD 底层强,但难维护
Operation 可控但复杂
async/await 简洁 + 可读性强

本质: •GCD:你管理线程 •async/await:系统帮你调度

总结

Swift Concurrency 本质就是:

用“同步写法”写“异步代码”,并且保证线程安全

SwiftUI 如何实现 Infinite Scroll?

欢迎点个 star:github.com/RickeyBoy/R…

面试题:用 SwiftUI 实现一个无限滚动列表,支持分页加载。

这道题我在面试中遇到过好几次,说实话第一次答的时候以为随便写个 LazyVStack + onAppear 就完事了。后来才发现,面试官真正想考的不是你会不会用 API,而是你对状态管理、性能优化、Task 生命周期这些东西到底理解多深。

我的思路是从最简方案出发,一步步暴露问题、一步步优化。在开始写代码之前,先聊一下架构选型。

为什么选 MVVM?

先说一下 SwiftUI 里常见的架构选择。MVC 就不聊了,那是 UIKit 时代的标配,Controller 跟 UIKit 强耦合,到了 SwiftUI 里根本没有 UIViewController 这个角色,MVC 自然也就退出舞台了。

SwiftUI 里最常见的架构,从简单到复杂大概是这么几个:

架构 特点 适合场景
MV(Model-View) 没有 ViewModel,状态直接放 View 里,Apple 官方示例的典型写法 逻辑简单的页面
MVVM 抽出 ViewModel 管理状态和逻辑,SwiftUI 里最主流的选择 中等复杂度,需要可测试性
TCA 单向数据流,State + Action + Reducer + Effect,强约束 大型项目,需要严格的状态管理

其中 MV 是最基础的,逻辑简单的页面,@State 往 View 里一放就完事了,Apple 自己的 WWDC 示例大量都是这么写的。但 infinite scroll 涉及分页状态、加载状态、错误处理、Task 生命周期管理这些东西,全塞 View 里会很乱。抽一个 ViewModel 出来专门管理这些状态,View 只负责渲染和转发用户操作,职责就清晰多了。

所以这道题用 MVVM 是最合适的,不是因为 MVVM 最好,而是这个场景的复杂度刚好适合。并且采用 MVVM 结构规整,可拓展性也强,从面试回答的角度来讲也是正好的。

而 SwiftUI 天然就鼓励这种模式,@Observable 本身就是 binding 机制,ViewModel 状态一变,View 自动更新,不需要手动同步。我们后面的代码就是按这个思路来的。

一、最小可用版本

先写一个能跑的最简版本。

核心思路很简单:LazyVStack 只在 item 即将可见时才实例化 View,我们利用 onAppear 检测"最后一个 item 出现了",然后触发下一页请求。

Model

struct Item: Identifiable, Equatable {
    let id: String
    let title: String
}

struct PageInfo {
    let endCursor: String?
    let hasNextPage: Bool
}

ViewModel

@MainActor @Observable
final class ItemListViewModel {
    private(set) var items: [Item] = []
    private var pageInfo: PageInfo?

    func loadNextPage() async {
        let response = try? await APIService.fetchItems(after: pageInfo?.endCursor)
        guard let response else { return }
        items.append(contentsOf: response.items)
        pageInfo = response.pageInfo
    }
}

View

struct ItemListView: View {
    @State private var viewModel = ItemListViewModel()

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(viewModel.items) { item in
                    ItemRow(item: item)
                        .onAppear {
                            if item == viewModel.items.last {
                                Task { await viewModel.loadNextPage() }
                            }
                        }
                }
            }
        }
        .task { await viewModel.loadNextPage() }
    }
}

代码很短,逻辑也直白:

  1. 每当最后一个 item 出现在屏幕上,就触发 loadNextPage()
  2. loadNextPage() 请求后台去 fetch,拿到数据然后塞进 items 中
  3. View 检测到有更新,自动刷新页面

一句话总结:最后一个 item onAppear 的时候,就进行请求。

1.1 分页方式:cursor vs offset

可能有同学会问:为什么 fetchItems(after: cursor) 用的是 cursor,而不是传统的 pageoffset

分页一般有两种方式:

  • Offset-basedfetchItems(page: 3, size: 20),按页码或偏移量取数据
  • Cursor-basedfetchItems(after: "abc123"),传上一页最后一条的标识,从那里往后取

对于 infinite scroll 这种场景,cursor-based 更合适。详细对比一下:

Cursor-based Offset-based
数据一致性 不受中间插入/删除影响 插入新数据会导致重复或遗漏
性能 数据库只需定位到 cursor 后续 大 offset 需要 skip N 行
适用场景 实时 feed、社交流 固定数据集、后台管理列表

简单来说,cursor-based 更适合"数据随时在变"的场景(比如社交 feed),offset-based 更适合"数据基本不变"的场景(比如后台管理列表)。infinite scroll 的数据通常是动态的,所以用 cursor-based。

1.2 LazyVStack vs List

可能有同学会问:为什么用 LazyVStack 而不是 List

先说浅显的回答:LazyVStack 布局更自由,没有 List 自带的分割线、背景色、cell 样式这些限制,适合高度自定义的 UI。而 List 开箱即用,自带滑动删除、拖拽排序这些交互,适合标准列表场景。

当然,如果想要深入回答,还有可以继续。二者还有一个关键区别其实是内存模型

LazyVStack List
View 回收 ❌ 不回收,创建后常驻内存 ✅ 内部回收机制
内存增长 随滚动距离线性增长 基本恒定
自定义布局 完全自由 受限于 List 样式
万级数据 可能有内存压力 表现更好

为什么会有这个区别?因为它们底层的实现不一样。List 底层是基于 UICollectionView(iOS 16 之前是 UITableView),天然有 cell 回收复用机制,滚出屏幕的 cell 会被回收,滚入时再复用,所以内存占用基本恒定。而 LazyVStack 底层只是一个普通的布局容器,"Lazy" 的意思是延迟创建,item 滚入可见区域时才创建 View,但创建之后就一直留在内存里,不会回收。

所以如果列表数据量很大(比如社交 feed 那种上万条的),List 在内存上更有优势。如果需要高度自定义的 UI,那就用 LazyVStack,但要心里有数:用户滚得越远,内存占用越大。

1.3 为什么加 @MainActor

上面的 ViewModel 代码加了 @MainActor,这个很容易被忽略但其实很关键。

@Observable 本身不会自动保证在主线程更新状态。而我们的 loadNextPage() 是在 Task 里通过 await 拿数据,await 之后的代码在哪个线程执行是不确定的。如果恰好在后台线程执行了 items.append(...),SwiftUI 收到状态变更通知后会在后台线程刷新 UI,这就会导致紫色警告("Publishing changes from background threads is not allowed")甚至崩溃。

加上 @MainActor 之后,这个类的所有属性访问和方法调用都会被隔离到主线程,从根源上避免线程安全问题。

另外补充一下:Swift 6.2(Xcode 26)引入了模块级别的 Default Actor Isolation 设置,可以把整个模块的默认隔离改为 MainActor,开启之后所有类型都默认跑在主线程,不用再手动加 @MainActor。但这是一个 opt-in 的设置,默认值还是 nonisolated,而且不是所有项目都会立刻升级。所以目前来说,显式写 @MainActor 仍然是更稳妥的做法。

1.4 几个小细节

有几个代码细节,不影响功能,但代码质量会好不少,属于面试加分项。

private(set) 控制可见性

itemsprivate(set) 修饰,外部只能读不能写。这样 View 就没法直接改 items,所有数据变更都必须经过 ViewModel 的方法,数据流向是单向的。这个习惯在 MVVM 里很重要,不然 View 和 ViewModel 的职责边界很容易模糊。

让 Item 遵循 Equatable

上面的代码里 Item 已经加了 Equatable,所以判断"是不是最后一个"可以直接写 item == viewModel.items.last,不用绕一圈去比 id。后面加叠加更多功能的时候也可以用,代码更简洁。

.task = .onAppear + Task

View 里首次加载用的是 .task { await viewModel.loadNextPage() },这其实等价于在 .onAppear 里手动创建一个 Task。但 .task 有个好处:当 View 消失时会自动 cancel 这个 Task。手动写 Task {} 的话你得自己管 cancel,容易漏掉,所以首次加载优先用 .task

二、防重复请求

上一个最基础的版本,有个明显的问题:快速滚动时 onAppear 有可能会被多次触发,导致同一页被重复请求了。

怎么解决?思路也很直接:加一个 isLoading 标记 + hasNextPage 判断,双重 guard,然后通过这些属性来判断是否需要发送请求。

@MainActor @Observable
final class ItemListViewModel {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private var pageInfo: PageInfo?

    var canLoadMore: Bool {
        guard let pageInfo else { return items.isEmpty } // 首次加载
        return pageInfo.hasNextPage && !isLoading
    }

    func loadNextPage() async {
        guard canLoadMore else { return }
        isLoading = true
        defer { isLoading = false }

        let response = try? await APIService.fetchItems(after: pageInfo?.endCursor)
        guard let response else { return }
        items.append(contentsOf: response.items)
        pageInfo = response.pageInfo
    }
}

canLoadMore 这个 computed property 干了两件事:

  • 没有下一页时不请求(通过后端返回的 pageInfo.hasNextPage 来判断)
  • 正在加载时不重复请求(通过 isLoading 来判断)

2.1 小细节

defer 管理状态翻转

注意 isLoading 的写法:开头设为 true,然后紧接着 defer { isLoading = false }。这样不管后面是正常返回还是提前 returnisLoading 都会被重置回 false

如果不用 defer,你就得在每个 return 之前手动加一句 isLoading = false,路径一多很容易漏掉,漏掉的后果就是列表永远卡在 loading 状态,再也加载不了下一页。

canLoadMore 作为 computed property

把"能不能加载"的判断收到一个 computed property 里,而不是在 loadNextPage() 里写一堆 if。好处是逻辑集中,后面要加新条件(比如错误状态下不加载)直接改这一个地方就行,调用方不用动。

三、提前预加载:Threshold Prefetch

目前的逻辑是"最后一个 item 出现了才开始加载",那么用户的感受就是:滚到底 → 停顿 → 等数据 → 新数据出现。那个停顿虽然可能只有几百毫秒,但体感上还是挺明显的。

怎么办?提前触发。 不等最后一个 item,而是在还剩 N 个 item 时就开始加载下一页。

// View
ForEach(viewModel.items) { item in
    ItemRow(item: item)
        .onAppear { viewModel.onItemAppear(item) } // View 层仅透传,将逻辑交给 ViewModel
}

// ViewModel,新增 prefetch threshold
private let prefetchThreshold = 5

func onItemAppear(_ item: Item) {
    guard let index = items.firstIndex(of: item),
          index >= items.count - prefetchThreshold else { return } // 判断是否该加载下一页了
    Task { await loadNextPage() }
}

这样一来,用户还剩 5 个 item 可以滚的时候,网络请求就已经在跑了,等滚到底部时,数据大概率已经回来了,体验上就是"无缝衔接"。

那 threshold 到底设多少合适?这个纯属经验值,根据具体的数据量、UI 复杂度都相关,5 只是一个经验值。总的来讲就是一个 trade-off:

  • threshold 太小:快速滚动还是会看到停顿
  • threshold 太大:用户可能只看前几条就走了,白白浪费请求

四、Task 取消 + 错误处理

到这里基本功能已经没问题了。接下来聊聊 Task 生命周期管理和错误恢复,这部分在面试里属于加分项。

4.1 Task 取消

为什么需要管理 Task 取消?我们目前的例子中,单一列表的情况可能不需要考虑。但是如果是搜索页面的列表,或者叠加筛选功能,问题就复杂了。

举个具体的例子:

  1. 用户在搜索页搜"咖啡",然后在列表页向下滑动,触发了一个 loadNextPage 的请求 A
  2. 还没等数据回来,用户改成搜"奶茶",请求 B 又发出去了
  3. 这时候网络上同时有两个请求在跑。如果请求 B 先于 A 回来,那么等请求 A 回来的时候,用户就会发现明明搜索的是“奶茶”,但是却又展示了不少“咖啡”内容。

这种 bug 不是每次都能复现(取决于网络时序),但一旦出现用户会很困惑,所以解决方式就是:发新请求前先 cancel 旧的,被 cancel 的任务即便返回了 response 也不处理

@MainActor @Observable
final class ItemListViewModel {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var error: Error?
    private var pageInfo: PageInfo?
    private var loadTask: Task<Void, Never>? // 💾 持有当前请求的引用

    func loadNextPage() {
        guard canLoadMore else { return }
        loadTask?.cancel() // ❌ 发新请求前,先 cancel 旧的
        isLoading = true

        loadTask = Task { [weak self] in // 🔒 weak self 防止循环引用
            guard let self else { return }
            defer { self.isLoading = false }

            do {
                let response = try await APIService.fetchItems(after: pageInfo?.endCursor)
                guard !Task.isCancelled else { return } // 🛡️ 被 cancel 了就不写入
                self.items.append(contentsOf: response.items)
                self.pageInfo = response.pageInfo
                self.error = nil
            } catch {
                guard !Task.isCancelled else { return } // 🛡️ 同上
                self.error = error
            }
        }
    }

    func reset() {
        loadTask?.cancel() // ❌ 先 cancel,再清空
        items = []
        pageInfo = nil
        isLoading = false
        error = nil
    }

    // ...
}

4.2 错误重试

错误处理其实是一个很容易被忽略,同时也非常复杂的事情。这里我们的方案是当出现错误的时候,展现一个重试按钮。从 UI 的角度来讲不好看,但实际上面试阶段时间有限,能够展示出有错误处理的思维就可以了。

@MainActor @Observable
final class ItemListViewModel {
    // ...
    private(set) var error: Error?

    func retry() {
        error = nil
        loadNextPage()
    }
}
// View — 列表底部
if viewModel.error != nil {
    RetryButton { viewModel.retry() }
} else if viewModel.isLoading {
    ProgressView()
}

4.3 空状态处理

还有一个容易忽略的边界情况:首次加载完成后,后端返回了 0 条数据。

当前的代码里,items 为空有两种可能:一种是"还在加载第一页",另一种是"加载完了但确实没数据"。如果不区分这两种状态,用户看到的就是一片空白,不知道是在等数据还是真的没有内容。

处理方式也很简单,加一个 computed property 判断一下:

var isEmpty: Bool {
    !isLoading && items.isEmpty && error == nil && pageInfo != nil
}

这里的关键是 pageInfo != nil,说明至少请求过一次了(首次加载前 pageInfonil)。四个条件同时满足,才说明"确实没数据"。

View 里对应的处理:

if viewModel.isEmpty {
    ContentUnavailableView("暂无数据", systemImage: "tray")
} else if viewModel.isLoading && viewModel.items.isEmpty {
    ProgressView() // 首次加载中
} else {
    // 正常的列表内容
}

这样用户就能清楚地区分"加载中"和"没有数据"这两种状态了。

4.4 用 enum 收敛 View 状态

到这里你会发现,View 层需要处理的状态越来越多:首次加载中、有数据、空数据、出错。如果全用 if/else if 判断,条件一多很容易写乱,漏掉某个分支也不会有编译器提醒。

可以定义一个 enum 来收敛这些状态:

enum ViewState {
    case initialLoading    // 首次加载中
    case loaded            // 有数据,正常展示列表
    case empty             // 加载完了但没数据
    case error(String)     // 出错了
}

然后在 ViewModel 里加一个 computed property,从现有属性推导出当前的 View 状态:

var viewState: ViewState {
    if let error, items.isEmpty {
        return .error(error.localizedDescription)
    }
    if isLoading && items.isEmpty {
        return .initialLoading
    }
    if isEmpty {
        return .empty
    }
    return .loaded
}

注意这里的关键:ViewState 是 computed property,不是存储属性。底层的数据源还是 isLoadingitemserrorpageInfo 这些独立属性,viewState 只是把它们组合成 View 更容易消费的形式。这样既不会出现之前 LoadingState enum 耦合状态的问题,又让 View 的代码变得很干净:

var body: some View {
    Group {
        switch viewModel.viewState {
        case .initialLoading:
            ProgressView()
        case .empty:
            ContentUnavailableView("暂无数据", systemImage: "tray")
        case .error(let message):
            ErrorView(message: message) { viewModel.retry() }
        case .loaded:
            ScrollView {
                LazyVStack(spacing: 0) {
                    ForEach(viewModel.items) { item in
                        ItemRow(item: item)
                            .onAppear { viewModel.onItemAppear(item) }
                    }
                    loadingFooter
                }
            }
        }
    }
    .task { viewModel.loadNextPage() }
}

switch 替代 if/else if,每个分支对应一种状态,漏掉任何一个编译器都会报错。用 Group 包裹 switch 是为了能在外层挂 .task 触发首次加载。

五、完整代码

前面一步步拆解完了,最后把所有东西整合到一起。先看一下整体架构:

graph LR
    View -->|用户操作| ViewModel
    ViewModel -->|状态更新| View
    ViewModel -->|网络请求| APIService
    APIService -->|响应数据| ViewModel

    style View fill:#E8F5E9,stroke:#4CAF50
    style ViewModel fill:#E3F2FD,stroke:#2196F3
    style APIService fill:#FFF3E0,stroke:#FF9800

View 只管渲染和转发用户操作,ViewModel 管状态和请求编排,APIService 做实际的网络调用。数据流向是单向的:用户操作 → ViewModel 处理 → APIService 请求 → 数据回来更新状态 → View 自动刷新。

Model

struct Item: Identifiable, Equatable {
    let id: String
    let title: String
}

struct PageInfo: Equatable {
    let endCursor: String?
    let hasNextPage: Bool
}

struct PagedResponse {
    let items: [Item]
    let pageInfo: PageInfo
}

ViewState

enum ViewState {
    case initialLoading
    case loaded
    case empty
    case error(String)
}

ViewModel

@MainActor @Observable
final class ItemListViewModel {
    // MARK: - State

    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var error: Error?

    // MARK: - Private

    private let prefetchThreshold = 5
    private var pageInfo: PageInfo?
    private var loadTask: Task<Void, Never>?

    // MARK: - Computed

    var canLoadMore: Bool {
        guard !isLoading else { return false }
        guard let pageInfo else { return items.isEmpty }
        return pageInfo.hasNextPage
    }

    var isEmpty: Bool {
        !isLoading && items.isEmpty && error == nil && pageInfo != nil
    }

    var viewState: ViewState {
        if let error, items.isEmpty {
            return .error(error.localizedDescription)
        }
        if isLoading && items.isEmpty {
            return .initialLoading
        }
        if isEmpty {
            return .empty
        }
        return .loaded
    }

    // MARK: - Trigger

    func onItemAppear(_ item: Item) {
        guard let index = items.firstIndex(of: item),
              index >= items.count - prefetchThreshold else { return }
        loadNextPage()
    }

    // MARK: - Actions

    func loadNextPage() {
        guard canLoadMore else { return }
        loadTask?.cancel()
        isLoading = true

        loadTask = Task { [weak self] in
            guard let self else { return }
            defer { self.isLoading = false }

            do {
                let response = try await APIService.fetchItems(after: pageInfo?.endCursor)
                guard !Task.isCancelled else { return }
                self.items.append(contentsOf: response.items)
                self.pageInfo = response.pageInfo
                self.error = nil
            } catch is CancellationError {
                // Task was cancelled, do nothing
            } catch {
                guard !Task.isCancelled else { return }
                self.error = error
            }
        }
    }

    func retry() {
        error = nil
        loadNextPage()
    }

    func reset() {
        loadTask?.cancel()
        items = []
        pageInfo = nil
        isLoading = false
        error = nil
    }
}

View

struct ItemListView: View {
    @State private var viewModel = ItemListViewModel()

    var body: some View {
        Group {
            switch viewModel.viewState {
            case .initialLoading:
                ProgressView()
            case .empty:
                ContentUnavailableView("暂无数据", systemImage: "tray")
            case .error(let message):
                ErrorView(message: message) { viewModel.retry() }
            case .loaded:
                ScrollView {
                    LazyVStack(spacing: 0) {
                        ForEach(viewModel.items) { item in
                            ItemRow(item: item)
                                .onAppear { viewModel.onItemAppear(item) }
                        }
                        loadingFooter
                    }
                }
            }
        }
        .task { viewModel.loadNextPage() }
    }

    @ViewBuilder
    private var loadingFooter: some View {
        if viewModel.error != nil {
            VStack(spacing: 8) {
                Text("加载失败")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Button("Retry") { viewModel.retry() }
                    .buttonStyle(.bordered)
            }
            .frame(maxWidth: .infinity)
            .padding()
        } else if viewModel.isLoading {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
        }
    }
}

总结

回顾一下整个思路:

  1. 从简单方案说起LazyVStack + onAppear last item,先把原理讲清楚
  2. 暴露问题并优化 — 重复请求 → guard;体验停顿 → threshold prefetch
  3. 展示工程素养 — Task 取消、error handling、retry
  4. 完整架构 — View 只渲染 + 转发,ViewModel 管状态 + 编排

厌倦了那些看着像一个模版复刻出来的抓包工具,我开发了一款iOS端HTTPS抓包调试工具

最近的一份工作,因为对业务不熟悉,产品经理出的需求又不考虑历史兼容性,问同事同事也不清楚,作为一个后端开发,我也拿不到客户端的代码,于是我就想到了抓包,通过安装app,抓取某块功能使用了哪些接口。

因为我手机是iPhone, 我因此试用了很多款在app store下载的HTTPS抓包工具,包括免费的Stream、ProxyPin、付费了一款螃蟹抓包。但这些工具感觉都是出自于同一个模版,体验雷同,因为没有别得选择,当时只好忍受。

当时没被满足的一些需求:

1、发现一些图片无法抓取到(我想知道图片用的域名和路径,知道是直接访问云存储,还是用的哪个文件系统服务,这在后端项目中看不出来,因为这个项目的后端也没提供文件上传功能)。

2、JSON无高亮、无搜索功能,也无法对比某个业务参数(比如当商品类型是电子钥匙时、以及商品类型是摄像头时,实际传的参数以及响应的Body有哪些不同的字段)。

3、除体验外,我当时还希望能满足我这个需求:我想把这些接口导入到Apifox,并且基于当前接口和新的迭代需求在此基础上去修改接口,并在团队中共享这份接口。 而当时我只能基于抓取的响应结构,自己在Apifox里面写接口,这耗费了我整整一天时间。

经过那次之后,我决定自己研究写一个,这个HTTPS抓包工具一定把用户体验做好,一定支持抓图片、支持JSON高亮和搜索(甚至是JSON Diff),以及支持自动生成API文档,可以一键导出到Apifox。

2026年1月我开发出来了,这款APP就叫ApiCatcher(因为一开始的目的就是抓API的,所以取名ApiCatcher),所有产品功能皆为原创设计。

能做出来要感谢那些开源项目的,比如ProxyPin,或许是因为开源项目没有盈利,所以体验没做好吧。我似乎也能理解为什么大多数抓包工具长得那么相似了。

我研究了他们的核心抓包功能是如何实现,用了哪些技术,然后自己花两周时间在Claude辅助下用Swift造了一份轮子(就是核心的NIO代理服务器以及SSL握手),在此基础又花两周时间做了优化性能,降低CPU和内存的占用,同时支持抓取大文件请求,避免进程被系统kill掉。我使用SwiftData和文件来存储抓包数据,将请求和响应Body存文件,其它字符串存SwiftData,然后通过边读边写文件来降低对内存的占用,而SwitData则提供更强大的搜索能力,这为产品做查询过滤功能提供了支持,所以ApiCatcher支持非常多的过滤条件。

以下是产品最初几个核心功能的产品设计:

1、极简风格的抓包页面。(我还加了个小创意:正在抓包中的背景是一张蜘蛛网,有一只蜘蛛在上面爬) ApiCatcher | HTTPS抓包工具

2、请求详情内容聚合,便于在手机这种小设备上更好的查看数据,同时减少操作步骤。请求响应的每个部分都是一个卡片,卡片可展开收起。Body可导出和一键复制。Body可展开全屏预览。Body目前支持渲染图片、svg、html、xml和json。 ApiCatcher |请求详情页

3、JSON格式化、高亮、搜索、Diff支持: ApiCatcher | JSON格式化、高亮、搜索、Diff支持

4、接口文档自动生成,以及导出接口文档到Apifox等API调试工具,因为海外用户不用Apifox,所以也支持了Postman和Bruno: ApiCatcher | api导出到Apifox、Postman、Bruno

5、可以抓文件,其实任何HTTP请求都支持,不仅仅是图片,而且没有限制图片大小,多大都能抓,这些图片还可以导出来拿来测试用(一些需要上传特定图片测试的接口):

在这里插入图片描述

经过两个月时间,加上有不少用户给我提需求,于是慢慢功能都完善了。基本app store上的https抓包工具有的功能ApiCatcher都支持了,并且体验更好,像一些正则表达式、脚本都集成AI生成功能提升效率,让用户自己填API Key 。

工具本就是为开发者提升工作效率而开发,所以我们做了支持导入企业内部使用的受信的自签私钥和证书,也可以自己开发一个接收器实时接收抓包流量,实现API扫描分析需求。

这款工具不支持iOS17以下系统,因为用了SwiftData,SwiftData需要17.0以上才支持。整个项目纯SwiftUI开发,核心功能代码用swift-nio等apple官网库。代码高亮则用了WebView+CodeMirror+Highlight.js以及一些插件。这些在app关于我们->开源组件许可都有声明。

ApiCatcherChatTCP这两款网络数据包抓包分析工具都是我自己原创设计、开发的作品,目前两款产品在海外还是不少用户喜欢的,我知道国内大家都喜欢用免费的,比如Stream、ProxyPin、Reqable,但我还是要在各个平台上分享一下的,避免后面被人借鉴反被别人说是我们抄袭,赚不赚钱是次要的,得先证明自己是原创的。

不同阶段的 iOS 应用混淆工具怎么组合使用,源码混淆、IPA混淆

如果把 iOS 应用的混淆只理解成改类名,就会低估这个问题。实际项目里,信息暴露点分散在多个阶段,源码命名、编译产物、资源目录、甚至签名后的 IPA 结构。只用一个工具,很难覆盖完整路径。

这篇文章沿着构建流程往下走,看看每个阶段可以做什么处理,以及不同工具如何拼在一起使用。

在源码阶段先做可控改名

项目还在开发阶段时,可以先处理一部分明显暴露语义的命名,例如:

class VipSubscriptionManager
class PaymentOrderController

如果直接进入编译阶段,这些名称会被带入二进制。

可以通过脚本做一轮批量替换,例如:

  • 使用 Python 脚本扫描类名
  • 生成映射表
  • 替换为无语义名称

这一步的特点是:

  • 控制粒度高
  • 需要改动工程
  • 对团队规范有要求

如果项目已经稳定,这一步不一定适合继续做。

利用 Xcode 构建参数裁剪符号

进入构建阶段,可以先减少一部分信息暴露。

在 Release 配置中:

Strip Debug Symbols = YES
Dead Code Stripping = YES

构建后检查:

strings AppBinary | head

输出会比 Debug 包干净,但核心类名仍然存在。

这一阶段主要是“减少冗余”,不是混淆。

用命令行工具检查当前暴露程度

在进入下一步之前,可以用工具做一次快速判断:

strings AppBinary | grep ViewController

如果输出类似:

LoginViewController
ProfileViewController

说明结构仍然清晰,也可以用:

  • class-dump 查看接口
  • Hopper 查看符号表

这一步的目的是明确需要处理的范围。


在 IPA 层做统一混淆

当项目已经打包成 IPA 后,可以用专门的 iOS 应用混淆工具进行处理。

这里引入 Ipa Guard,它的处理方式不是修改源码,而是直接解析 Mach-O 文件并替换符号。

操作流程:

  1. 打开工具,加载 IPA
  2. 进入代码模块
  3. 选择需要处理的内容

可以看到:

OC 类
Swift 类
OC 方法
Swift 方法

代码混淆

在实际项目中,我们会筛选:

UserManager
PaymentService
VipController

执行混淆后:

UserManager → a82k3

再次用 strings 查看,原名称不会再出现。


资源文件处理不要忽略

很多人只处理代码,但资源同样是入口。

例如:

config/payment.json
assets/vip_banner.png

这些文件名称直接说明业务。

Ipa Guard 的资源模块可以:

  • 批量改名
  • 更新引用路径

处理后:

payment.json → x92ks.json
vip_banner.png → a8d3k.png

重命名


引入前端工具处理 JS / H5

如果项目中有 WebView 或 H5 页面,仅改名不够。

可以在构建阶段执行:

terser main.js -o main.min.js

或:

uglifyjs page.js -o page.min.js

压缩后再交给 IPA 混淆工具处理文件名。

这样组合后:

  • 内容不可读
  • 文件名无语义

修改资源指纹用于打散特征

当多个应用使用相同资源时,文件内容会成为识别依据。

Ipa Guard 支持修改资源 MD5:

md5 banner.png

处理前后结果不同。

这一层不影响功能,但会改变资源特征。 md5


清理调试信息

很多项目在 Release 包中仍然保留日志。

可以检查:

strings AppBinary | grep NSLog

如果输出较多,可以在 IPA 处理阶段删除。

Ipa Guard 支持清理调试信息,使二进制更简洁。


签名工具补上最后一步

所有修改完成后,必须重新签名。

可以使用:

kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

或者直接在 Ipa Guard 中配置签名参数。

安装到设备后,验证:

  • 页面是否正常
  • 动态调用是否有效
  • 资源是否加载

重签名


iOS 应用混淆不是某个工具的功能,而是一整条流程。源码阶段、构建阶段、IPA 阶段,各自能做的事情不同。把这些步骤串起来,比单独使用某一个工具更有效。

参考链接:ipaguard.com/blog/161

关于Xcode26.4 踩坑适配

Xcode26.4 踩坑适配

不建议升级Xcode 26.4,Xcode底部控制台无法使用po命令;

iOS 26.4模拟器启动加载巨缓慢,建议保持26.3.1。

随着 Xcode 26.4 正式版发布,编译器对私有头文件访问链式比较语法C++标准库特化的校验规则进一步收紧,导致 iOS 开发中常用的 AFNetworking、YYText、WCDB 三个主流第三方库出现编译报错/警告。本文针对这三类问题提供修复方案,帮助开发者快速完成 Xcode 26.4 适配。

一、AFNetworking:私有头文件访问报错

报错信息

Use of private header from outside its module: 'netinet6/in6.h'

问题原因

Xcode 26.4 强化了模块私有头文件的访问权限校验,AFNetworking 源码中直接引入了系统私有头文件 <netinet6/in6.h>,违反了 Xcode 的模块访问规则,触发编译报错。

解决方案

直接注释掉AFNetworking 中引入该私有头的代码行,无需其他修改即可解决。

  1. 找到 AFNetworking 中包含 #import <netinet6/in6.h> 的文件(通常为AFURLSessionManager.m或核心头文件);
  2. 注释该行代码:
// #import <netinet6/in6.h>
  1. Clean 项目缓存,重新编译即可。

二、YYText:链式比较语法错误

报错信息

Chained comparison 'X < Y < Z' does not behave the same as a mathematical expression

问题原因

Xcode 26.4 编译器对链式比较语法做了严格校验:X < Y < Z 在 OC/C 语言中并非数学意义的连续比较,而是先计算X<Y得到布尔值(0/1),再用该值与 Z 比较,逻辑完全错误。编译器会强制抛出警告,影响编译流程。

解决方案

前半段比较逻辑添加括号,明确运算优先级,修复语法歧义。

代码修改
[self _insideComposedCharacterSequences:line position:position block: ^(CGFloat left, CGFloat right, NSUInteger prev, NSUInteger next) {
    if (isVertical) {
-        position = fabs(left - point.y) < fabs(right - point.y) < (right ? prev : next);
+        position = (fabs(left - point.y) < fabs(right - point.y)) < (right ? prev : next);
    } else {
-        position = fabs(left - point.x) < fabs(right - point.x) < (right ? prev : next);
+        position = (fabs(left - point.x) < fabs(right - point.x)) < (right ? prev : next);
    }
}];
  1. 按上述代码添加括号;
  2. 重新编译,错误自动消失。

三、WCDB:C++标准库特化报错

报错信息

'is_integral' cannot be specialized: Users are not allowed to specialize this standard library entity

报错文件:Tag.hpp

报错截图

问题原因

Xcode 26.4 升级了底层 Clang/LLVM 编译器,严格遵循 C++标准规范:禁止开发者手动特化std::is_integral等标准库实体,WCDB 旧版源码的 Tag.hpp 文件触发了该规则限制。

解决方案

官方暂未提供修复方案,推荐两种任选其一

方案 1:其他开发者提交的修复 PR(源码修改)

直接应用 WCDB 其他开发者针对该问题的修复 PR,一键修复源码:

  • PR 地址:#1540
  • 操作:拉取 PR 代码替换本地 Tag.hpp 文件,重新编译即可。
方案 2:脚本打包 XCFramework(推荐)

使用 WCDB 官方脚本打包为xcframework,绕过源码编译的规则限制:

  1. 进入 WCDB 源码根目录;
  2. 执行官方打包脚本:
# 路径:/tools/version/build_xcframework.sh

./build_xcframework.sh \
  --scheme WCDBObjc \
  --configuration Release \
  --platforms ios ios-simulator \
  --output ./wcdb_xcframework
🧩 Creating XCFramework for WCDBObjc ...

[cmd] xcodebuild -create-xcframework -archive /Users/fjl/GitHub/wcdb/./wcdb_xcframework/archives/WCDBObjc-ios.xcarchive -framework WCDBObjc.framework -archive /Users/fjl/GitHub/wcdb/./wcdb_xcframework/archives/WCDBObjc-ios-simulator.xcarchive -framework WCDBObjc.framework -output /Users/fjl/GitHub/wcdb/./wcdb_xcframework/xcframeworks/WCDBObjc.xcframework

xcframework successfully written out to: /Users/fjl/GitHub/wcdb/wcdb_xcframework/xcframeworks/WCDBObjc.xcframework

✅ Created XCFramework: /Users/fjl/GitHub/wcdb/./wcdb_xcframework/xcframeworks/WCDBObjc.xcframework

⏱ Total elapsed time: 1 min 7 sec (67 s)
  1. 用生成的 xcframework 替换项目中原有 WCDB 的集成方式;
  2. Clean 项目后编译,问题彻底解决。

参考链接:WCDB Tag.hpp 报错官方 issue


适配总结

  1. AFNetworking:注释私有头引入行,解决模块访问权限问题;
  2. YYText:链式比较加括号,修复编译器语法校验;
  3. WCDB:合并官方 PR 或脚本打包 xcframework,解决 C++标准库特化限制。

完成以上修改后,清理 Xcode 缓存(Cmd+Shift+K),即可适配 Xcode 26.4,正常编译运行。

总结

  1. 三个第三方库的报错均由 Xcode 26.4编译器规则升级导致,修复无需改动业务代码;
  2. AFNetworking、YYText 为轻量代码修改,WCDB 推荐用官方脚本打包方案,稳定性更高;
  3. 适配后务必清理项目缓存,避免编译缓存残留问题。

iOS 26 适配 | 使用 `hidesSharedBackground` 保持导航栏按钮原有样式

iOS 26 适配 | 使用 hidesSharedBackground 保持导航栏按钮原有样式

背景

iOS 26 引入了全新的液态玻璃(Liquid Glass)设计语言,导航栏按钮的默认视觉风格发生了较大变化——多个按钮会被合并在一个统一的玻璃背景块中展示。对于希望在 iOS 26 下保持 iOS 26 之前导航栏按钮样式的开发者来说,苹果提供了 hidesSharedBackground API,用于将共享背景拆分,让每个 item 拥有独立的 Liquid Glass 背景:

if (@available(iOS 26.0, *)) {
    item.hidesSharedBackground = YES;
}

启用后,每个 item 的玻璃背景块会被单独渲染,视觉上更接近旧版导航栏中按钮各自独立的呈现方式。但问题随之而来:系统会在每个玻璃背景块之间插入默认间距,开发者无法通过常规 API 将这个间距收紧为 0,导致多个按钮之间出现明显的视觉割裂感,与 iOS 26 之前的紧凑排列效果存在差异。

因此,仅设置 hidesSharedBackground = YES 还不够,还需要额外处理 PlatterView 的间距问题,才能真正还原旧版导航栏的按钮布局样式。


问题根因分析

在 iOS 26 中,每个 UIBarButtonItem 的 Liquid Glass 背景块由私有容器 _UINavigationBarPlatterView 承载。

UINavigationBar
  └── _UINavigationBarContentView
        ├── _UINavigationBarPlatterView   ← 左侧按钮容器(含独立玻璃背景)
        │     └── _UIButtonBarButton
        └── _UINavigationBarPlatterView   ← 右侧按钮容器(含独立玻璃背景)
              └── _UIButtonBarButton

每个 PlatterView 负责绘制该按钮的 Liquid Glass 背景块,同时也决定了按钮在导航栏中的排列位置。系统在计算这些容器的布局时,会在相邻 PlatterView 之间注入固定的默认间距,且这个间距:

  • 无法通过 UIBarButtonSystemItemFixedSpace 负间距消除(iOS 26 已失效)
  • 无法通过修改 customView 的约束影响
  • 无法通过 UINavigationBar 的公开布局 API 干预

解决方案

核心思路:在布局完成后,运行时递归查找所有 PlatterView 容器,强制重置其 x 坐标与 Leading 约束,将相邻玻璃背景块之间的间距收紧为 0,从而还原 iOS 26 之前导航栏按钮的紧凑排列效果。

完整代码

#pragma mark - iOS 26 PlatterView 间距修复

- (void)fixPlatterViewSpace {
    // 收集所有 PlatterView
    NSMutableArray<UIView *> *platterViews = [NSMutableArray array];
    [self collectPlatterViews:self result:platterViews];
    
    if (platterViews.count == 0) return;
    
    CGFloat navBarWidth = self.frame.size.width;
    CGFloat midX = navBarWidth / 2.0;
    
    // 按中心点分左右
    NSMutableArray *leftViews  = [NSMutableArray array];
    NSMutableArray *rightViews = [NSMutableArray array];
    
    for (UIView *v in platterViews) {
        CGFloat centerX = v.frame.origin.x + v.frame.size.width / 2.0;
        if (centerX < midX) {
            [leftViews addObject:v];
        } else {
            [rightViews addObject:v];
        }
    }
    
    // 左侧:按 x 升序,从 0 开始依次排列
    [leftViews sortUsingComparator:^NSComparisonResult(UIView *a, UIView *b) {
        return a.frame.origin.x > b.frame.origin.x
            ? NSOrderedDescending : NSOrderedAscending;
    }];
    CGFloat leftX = 0;
    for (UIView *v in leftViews) {
        [self fixPlatterView:v toX:leftX];
        leftX += v.frame.size.width;
    }
    
    // 右侧:按 x 降序,从右边缘 -5 开始向左排列
    [rightViews sortUsingComparator:^NSComparisonResult(UIView *a, UIView *b) {
        return a.frame.origin.x < b.frame.origin.x
            ? NSOrderedDescending : NSOrderedAscending;
    }];
    CGFloat rightX = navBarWidth - 5;
    for (UIView *v in rightViews) {
        rightX -= v.frame.size.width;
        [self fixPlatterView:v toX:rightX];
    }
}

- (void)collectPlatterViews:(UIView *)view result:(NSMutableArray *)result {
    for (UIView *subview in view.subviews) {
        if ([NSStringFromClass(subview.class) containsString:@"PlatterView"]) {
            [result addObject:subview];
        } else {
            [self collectPlatterViews:subview result:result];
        }
    }
}

- (void)fixPlatterView:(UIView *)platterView toX:(CGFloat)x {
    // 优先修改约束
    for (NSLayoutConstraint *constraint in platterView.superview.constraints) {
        if (constraint.firstItem == platterView &&
            constraint.firstAttribute == NSLayoutAttributeLeading) {
            constraint.constant = x;
        }
    }
    // frame 兜底
    CGRect frame = platterView.frame;
    frame.origin.x = x;
    platterView.frame = frame;
}

调用时机

该方法需要在UINavigationBar布局完成后调用,推荐在 layoutSubviews 末尾触发:

- (void)layoutSubviews {
    [super layoutSubviews];
    
    if (@available(iOS 26.0, *)) {
        [self fixPlatterViewSpace];
    }
}

逻辑拆解

1. 递归收集 PlatterView

[self collectPlatterViews:self result:platterViews];

使用类名字符串匹配 PlatterView,而非直接引用私有类,规避了编译报错。找到 PlatterView 后立即收集,不再递归其子视图,防止嵌套层级的重复收集。

2. 以中线划分左右语义区

CGFloat midX = navBarWidth / 2.0;

导航栏天然地以中线分隔 leftBarButtonItemsrightBarButtonItems 的语义区域,以此作为分组依据,保证左右按钮的 PlatterView 不会被错误归类。

3. 左侧从 x=0 紧密排列

leftX = 0
[BackButton]x = 0
[OtherButton]x = BackButton.width

从导航栏左侧起点开始,将各 PlatterView 依次紧贴排列,彻底消除相邻玻璃背景块之间的系统默认间距,还原旧版左侧按钮的紧凑布局。

4. 右侧从右边缘留 5pt 向左排列

rightX = navBarWidth - 5
[Button2] → rightX -= Button2.width
[Button1] → rightX -= Button1.width

保留 5pt 右侧安全边距,确保最右侧玻璃背景块不会贴边,同时各 PlatterView 之间零间距紧密排布,与旧版右侧按钮排列保持一致。

5. 约束修改 + frame 双保险

// 先改约束(正确路径)
constraint.constant = x;
// 再改 frame(兜底)
platterView.frame = frame;

优先走 Auto Layout 路径修改 Leading 约束保证一致性,frame 赋值作为兜底,确保在纯 frame 布局场景下同样生效。


注意事项

事项 说明
仅限 iOS 26+ @available(iOS 26.0, *) 包裹调用,避免影响低版本行为
调用时机 必须在 layoutSubviews 之后,frame 确定后才能正确分组
Safe Area 左侧从 x=0 起排,刘海屏 / Dynamic Island 下需结合 safeAreaInsets.left 调整起始偏移
私有类名风险 依赖类名包含 PlatterView 的字符串匹配,若苹果后续改名则需同步更新
约束冲突 当前仅修改 Leading 约束;若 PlatterView 同时存在 Trailing / Center 约束,可能引发冲突,需一并处理

小结

iOS 26 的 Liquid Glass 设计语言改变了导航栏按钮的默认视觉风格。对于需要在 iOS 26 下维持旧版导航栏样式的项目,完整的适配路径分为两步:第一步通过 hidesSharedBackground = YES 拆分共享玻璃背景,让每个 item 独立渲染;第二步通过运行时遍历 PlatterView 并强制重置间距,将按钮排列收紧为旧版的紧凑样式。两步缺一不可。

我做了一个鼾声记录App,聊聊背后的功能设计

最近做了一款叫「睡眠声音日记」的App,主要用来记录睡眠时的鼾声和梦话。

今天主要聊聊这个App的功能设计思路。

为什么做这个App?

起因很简单:我自己打鼾,但完全不知道每晚打多少、什么时候最严重。 市面上的睡眠App大多侧重睡眠阶段分析,对鼾声的处理比较粗糙。我想做一个真正能听清楚每一段鼾声的工具。

核心:ML鼾声识别

App用CoreML跑了一个本地训练的声音分类模型,实时区分鼾声和人声(梦话)。每检测到一段就自动裁剪保存音频片段,第二天可以逐段回听。

不依赖网络,所有识别都在本地完成,隐私上比较放心。

灵敏度做了三档可调,适配不同噪音环境。

睡眠评分:5个维度

单纯告诉用户"你昨晚打了12次鼾"其实没什么指导意义,所以我做了一套100分制的评分系统,拆成5个维度:睡眠时长、鼾声/呼吸、深睡质量、睡眠连续性、身体恢复。每个维度单独打分,用户一眼就能看出问题出在哪。

AI个性化分析

接入了大模型做每日分析。不是泛泛的建议,而是把用户昨晚的实际数据(鼾声次数、时段分布、评分、HealthKit数据)传进去,生成针对性的建议。

历史页面还有基于多晚数据的趋势分析,能发现长期规律。如果鼾声连续多晚偏重,会主动建议用户去做专业评估。

趋势可视化

做了7晚和30晚两个维度的趋势图表:鼾声趋势、评分趋势、心率趋势、血氧趋势、睡眠时长柱状图。

还有一个昼夜节律分析,记录满5晚后自动解锁,分析用户的时型(早起型/夜猫子)。

这些图表对于观察干预效果很有用——比如换了枕头之后鼾声是不是真的少了。

Apple Watch用户体验拉满

如果你有Apple Watch,体验会更完整:

  • 手表上能看录音状态,直接停止记录
  • 昨晚的评分、鼾声、时长一目了然
  • 详情页有睡眠阶段时间线(核心/深睡/REM),鼾声事件直接叠在上面,一眼看出"你在深睡的时候鼾声最重"
  • 心率、血氧趋势图也有

小组件 + 灵动岛

桌面小组件做了3个尺寸,核心交互是一键开始/停止记录。大号组件额外展示鼾声时间分布图。录音期间支持灵动岛实时活动,锁屏上也能看到计时和事件计数。

其他细节

  • iCloud多设备同步
  • 数据备份恢复,支持导出
  • 音频自动清理(3/7/14/30天),重要片段可钉住跳过清理
  • 睡眠目标 + 睡前提醒
  • 成就系统,增加使用粘性
  • 一键生成分享图片,方便发给医生或朋友
  • iPad侧边栏适配

订阅模式

月订阅6元,年订阅38元,终身买断68元。

欢迎试用,有反馈随时评论区交流~

使用Wireshark进行TCP数据包抓包分析:三次握手与四次挥手详解

wireshark抓包分析TCP数据包

除了Wireshark,Sniffmaster作为一款全平台抓包工具,支持HTTPS、TCP和UDP协议,可在iOS、Android、Mac、Windows设备上实现无需代理、越狱或root的抓包操作,特别适合移动端和跨平台网络分析。

1、直接从TCP的 三次握手 开始说起

三次握手就是客户与服务器建立连接的过程

  • 客户向服务器发送SYN(SEQ=x)报文,然后就会进入SYN_SEND状态
  • 服务器收到SYN报文之后,回应一个SYN(SEQ=y)ACK(ACK=x+1)报文,然后就会进入SYN_RECV状态
  • 客户收到服务器的SYN报文,回应一个ACK(ACK=y+1)报文,然后就会进入Established状态

举例时间到!我们把客户端比作男生,服务器比作女生

第一次握手就像是男生对女生的告白:我喜欢你我们在一起吧。(之后,男孩就要等待女孩的回复,因为要确定女孩听到他说的话)

第二次握手则是女生的回应:好呀好呀。(之后,女孩也要等待,因为要确定男孩听到她的答复)

第三次握手就是男生的回应:真好,我们去吃火锅吧~。(此时,两人都确定对方收到了消息,关系成功建立)

也就是客户端和服务器数据的传输

接下来,我们抓包分析一下三次握手建立的过程

第一次握手:我向服务器发送了SYN,并设置Seq=0(x),请求与服务器建立连接

第二次握手:服务器向我回应了SYN,并设置Seq=0(y),ACK=1(x+1)

第三次握手:我收到服务器的SYN报文,回应一个ACK=1(y+1)

2、再接着说说四次挥手

四次挥手就是客户与服务器断开连接的过程

  • 客户发送一个FIN,断开与服务器的连接
  • 服务器收到FIN,回应一个ACK,确认序号为收到的序号加1
  • 服务器关闭客户端的连接,并发送一个FIN
  • 客户回发ACK确认,并将确认序号设置为收到序号加1

又到了举例时间!我们同样把客户端比作男生,服务器比作女生

第一次挥手:随着时间的流逝,女生变了,于是男生给女生发了分手短信,然后等待女生的回复

第二次挥手:女生听到后,伤心欲绝,就告诉男生:分手就分手,我把你的东西收拾收拾都还给你。男生就知道了女生同意了分手,于是等待女生把东西收拾好交还给他

第三次挥手:女生把男生的东西都收拾好,给男生发了第二条短信让他来取

第四次挥手:男生收到后,在回复最后一条短信,我知道了,我现在去取。于是关系断了

也就是客户端和服务器的连接中断

接下来,抓包看一下四次挥手的过程

第一次挥手:我向服务器发送FIN,Seq=3092,Ack=183

第二次挥手:服务器回发了ACK,Seq=183,Ack=3093

第三次挥手:服务器发送FIN,Seq=183,Ack=3093

第四次挥手:我向服务器回复了ACK,Seq=3093,Ack=184

3、TCP报文段格式分析

源端口和目的端口: 各占16位,这两个字段分别填入发送该报文段应用程序的源端口号和接收该报文段的应用程序的目的端口号

序列号: 占32位,TCP连接中传送的数据流中的每一个字节都编上一个序号,序号字段的值则指的是本报文段所发送的数据的第一个字节的序号

确认号: 占32位,表示期望收到对方下一个报文段的第一数据字节的序号。

数据偏移: 占4位,又称首部长度。指出首部的长度,即数据离开报文段开始的偏移量。

保留: 占6位,留待后用,目前置为0

标志: 占6位,又称控制字段,各位都有特定意义

  • 紧急URG,表示本报文数据的紧急程度,URG=1表示本报文具有高优先级
  • 确认ACK,ACK=1时,确认号字段才有意义
  • 推送PSH,PSH=1时,表示请求接收端TCP将本报文段立即送往其应用层
  • 复位RST,RST=1时,表示TCP连接中出现了严重错误,必须释放传输连接,而后在重建
  • 同步SYN,该位在连接建立时使用,起着序号同步的作用
  • 终止FIN,用来释放一个链接

窗口: 占16位,该字段用于流控制

校验和: 占16位,该字段的校验范围是整个报文段(包括首部和数据)

紧急指针: 占16位,当URG=1时有意义,指出紧急数据的末尾在报文段中的位置,使得接收端能知道紧急数据的字节数

选项与填充: 最长可达40B

Flutter iOS 包破解风险处理 可读信息抹除

Flutter 项目上线 iOS 后,如果有人拿到 IPA,第一步有可能不是反编译,而是直接解包。解压之后,目录结构非常清晰:Dart 代码、资源文件、插件模块都在不同位置。只要把这些信息拼起来,就能还原出应用的大致逻辑。

在一个包含会员系统和动态配置的 Flutter 项目中,我们专门做过一次抗破解处理。


先把 Flutter IPA 拆开看

构建完成 IPA 后,直接解压:

unzip Runner.ipa

进入目录:

Payload/Runner.app

可以看到几个关键内容:

App.framework
flutter_assets/
Frameworks/

进入 flutter_assets

assets/
isolate_snapshot_data
kernel_blob.bin

其中:

  • kernel_blob.bin:Dart 编译产物
  • assets/:资源文件
  • App.framework:部分逻辑代码

先处理 Dart 层(但不要停在这里)

Flutter 提供了混淆选项:

flutter build ios --obfuscate --split-debug-info=./symbols

执行后:

  • Dart 符号被替换
  • 生成符号映射文件

但这一步完成后,如果你再解包 IPA,会发现:

  • 资源名称仍然清晰
  • JS / JSON 可读
  • iOS 原生符号仍然存在

也就是说,这一步只是处理了 Dart 层。


处理 Flutter 资源目录(重点)

进入 flutter_assets/assets,如果看到类似:

images/vip_banner.png
config/payment.json
html/activity.html

这些名称已经足够说明业务结构。

我们做的处理是:不改 Flutter 工程,而是在 IPA 层统一修改

使用 Ipa Guard:

  • 导入 IPA
  • 切换到资源模块
  • 勾选图片、JSON、HTML、JS

资源混淆

执行后:

vip_banner.png → a8d3k.png
payment.json → x92ks.json

把 JS / HTML 再压一遍

如果 Flutter 中嵌入了 H5 页面(WebView),这些文件仍然是可读的。

在构建阶段或解包后处理:

terser main.js -o main.min.js

或者:

uglifyjs page.js -o page.min.js

处理后再放回 IPA,再用 Ipa Guard 改名。

这样做的结果是:

  • 内容压缩
  • 文件名无意义
  • 路径不可读

处理 iOS 原生层(很多人忽略)

Flutter 并不完全是 Dart,还包含:

  • 插件代码(Swift / OC)
  • 原生桥接层
  • SDK 逻辑

这些内容在 IPA 中属于 Mach-O 二进制。

检查一下:

strings AppBinary | grep Manager

如果看到:

FlutterPaymentManager
UserAuthHandler

说明原生层完全可读。


用 Ipa Guard 做二进制混淆

在代码模块中:

  • 选择 Swift 类
  • 选择 OC 方法
  • 勾选关键符号

代码混淆

执行后:

FlutterPaymentManager → k39sd2

再次查看:

strings AppBinary | grep Payment

已经找不到原始名称。


修改资源 MD5(解决“复用识别”问题)

如果多个应用使用同一套 UI 资源,即使改名也可能被识别。

Ipa Guard 提供 MD5 修改功能:

  • 图片内容不变
  • 文件指纹改变

md5修改

验证:

md5 vip_banner.png

处理前后不同。

这一步更多是避免资源被简单比对。


删掉那些“多余信息”

Flutter 构建过程中,有时会带入调试信息。

可以检查:

strings AppBinary | grep Flutter

如果输出包含日志或调试字段,可以在 IPA 处理阶段清理。

Ipa Guard 支持删除部分调试信息。


签名并直接安装测试

所有修改完成后,必须重新签名。

可以使用:

kxsign sign app.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i

或者直接在 Ipa Guard 中配置证书。

设备连接后可以直接安装。 重签名


测试关注点(Flutter 特有)

Flutter 项目测试时,需要特别看:

  • 页面渲染是否正常
  • Dart 调用是否异常
  • 插件是否还能调用
  • WebView 是否加载成功

如果某些页面加载失败,基本可以定位到资源路径被误处理。


Flutter iOS 包的破解入口并不只有 Dart 代码。资源目录、JS 文件、原生模块符号,这些地方同样可以被利用。单一手段很难覆盖所有暴露点。

在实际项目中,通过 Flutter 构建参数处理 Dart 层,再结合 Ipa Guard 对 IPA 进行资源混淆、二进制符号处理和 MD5 修改,可以在不侵入项目结构的情况下完成一轮补强。

参考链接:ipaguard.com/blog/159

ObservableObject @Published @ObservedObject那些事

先理解这三个为什么要一起讲

它们是一套组合拳,缺一不可:

角色 是什么
ObservableObject 一个协议,贴在 class 上,宣告"我是可被观察的数据源"
@Published 一个 Property Wrapper,贴在属性上,宣告"这个属性变化时要通知订阅者"
@ObservedObject 一个 Property Wrapper,贴在 View 的属性上,宣告"我订阅这个数据源,它变化我就刷新"

为什么需要这套东西?@State 不够用吗?

@State 适合简单的值类型,但现实中你的数据模型往往是一个 class,有很多属性和方法,且需要被多个平级 View 共享

// 一个用户信息模型,多个页面都要用
class UserModel {
   var name: String = "Tom"
   var age: Int = 18
   var score: Int = 0
   // ... 还有很多方法
}

把这个 class 塞进 @State 是行不通的——@State 是为值类型设计的,对 class 的引用地址变化不敏感,属性改了 UI 也不会刷新。


三件套的用法

// 第一步:让你的 class 遵守 ObservableObject 协议
class UserModel: ObservableObject {
   // 第二步:在需要触发 UI 刷新的属性上加 @Published
   @Published var name: String = "Tom"
   @Published var score: Int = 0
   var internalCache: String = ""  // 不加 @Published,改它不会刷新 UI
}

// 第三步:在 View 里用 @ObservedObject 订阅这个模型
struct ProfileView: View {
   @ObservedObject var user: UserModel

   var body: some View {
       VStack {
           Text(user.name)
           Text("\(user.score)")
           Button("加分") {
               user.score += 1   // 改 @Published 属性 → 触发 UI 刷新
           }
       }
   }
}

// 使用:顶层 View 用 @StateObject 持有并创建模型
struct ContentView: View {
   @StateObject var user = UserModel()

   var body: some View {
       ProfileView(user: user)
   }
}

三件套的本质

@Published 本质上是:

@propertyWrapper
public struct Published<Value> {
   // 每次 wrappedValue 被 set,就通过 objectWillChange 发出通知
   public var wrappedValue: Value
   // $score 拿到的是一个 Combine Publisher,可以接链式操作
   public var projectedValue: Publisher
}

ObservableObject 协议本质上是:

public protocol ObservableObject: AnyObject {
   // 编译器会自动合成这个,你的 @Published 属性改变时,它会发出信号
   var objectWillChange: ObservableObjectPublisher { get }
}

@ObservedObject 本质上是:View 订阅了 user.objectWillChange,只要它 emit,SwiftUI 就重新计算这个 View 的 body。

整个流程: user.score += 1@Published 的 setter 触发 → user.objectWillChange.send() → 订阅了它的 @ObservedObject 感知到 → SwiftUI 重新渲染对应的 View


@ObservedObject vs @StateObject

这是一个非常容易踩的坑:

@ObservedObject @StateObject
数据归属 不拥有,由外部传入 拥有,由这个 View 创建和持有
生命周期 跟随外部,不负责销毁 跟随 View,View 消失时销毁
典型场景 子 View 接收父 View 传来的模型 根 View 或顶层 View 创建模型

经验法则:谁创建,谁用 @StateObject;谁接收,谁用 @ObservedObject


使用时需要关心的问题

  1. 只有 class 能用ObservableObjectAnyObject 的子协议,struct 和 enum 无法遵守,这套机制天生是为引用类型设计的。

  2. @Published 要精准:不是所有属性都需要 @Published,只给真正需要驱动 UI 的属性加,滥加会导致不必要的 View 重渲染,影响性能。

  3. objectWillChange 是"将要改变":SwiftUI 在属性改变之前就会收到通知,你通常不需要手动调用它,但在某些手动控制的场景可以用 objectWillChange.send() 主动触发刷新。

@Binding 的那些事

先理解 @Binding 解决什么问题

@State 的时候,状态归属于某一个 View。但子 View 怎么修改父 View 的状态?

struct ParentView: View {
    @State var isOn: Bool = false

    var body: some View {
        ToggleView(isOn: isOn) // ❌ 子 View 拿到的只是一个值的拷贝
    }
}

你把 isOn 传给子 View,子 View 改了它自己的拷贝,父 View 毫不知情,UI 也不会更新。


@Binding 就是用来解决这个问题的

@Binding 不是一份数据的拷贝,而是一条双向通道,指向原始数据的存储位置。 读它,读的是原始值;写它,写的是原始存储,父 View 会同步感知并刷新。

// 父 View:状态归属于这里
struct ParentView: View {
    @State var isOn: Bool = false

    var body: some View {
        // 用 $ 前缀把 @State 转成 Binding 传下去
        ToggleView(isOn: $isOn)
    }
}

// 子 View:不拥有状态,只拿到一条"通道"
struct ToggleView: View {
    @Binding var isOn: Bool  // 声明为 Binding,表示"我不拥有这个数据"

    var body: some View {
        Button("切换") {
            isOn.toggle()   // 写的是父 View 里的原始 @State,触发父 View 刷新
        }
    }
}

@Binding 的本质

@propertyWrapper
public struct Binding<Value> {
    // 你平时用 isOn 读写的就是这个
    public var wrappedValue: Value { get nonmutating set }

    // 你用 $isOn 拿到的还是 Binding 自身,可以继续往下传
    public var projectedValue: Binding<Value> { get }
}

@Binding 内部存的不是值本身,而是一对 getter + setter 闭包,分别指向上层 @State(或其他数据源)的读写操作。所以写 isOn = true 时,实际上是调用了那个 setter 闭包,最终改变的是父 View 的 @State


使用 @Binding 时需要关心的问题

  1. 数据归属权问题@Binding 的原则是"我不拥有数据,我只是一个读写通道"。如果一个 View 需要拥有状态,用 @State;如果只是借用和修改上层的状态,用 @Binding

  2. 单向来源原则(Single Source of Truth):一条 @Binding 链条最终必须溯源到某个真实的数据存储(比如 @State@StateObject 中的属性),不要出现 Binding 套 Binding 套 Binding 的迷宫,链条越短越清晰。

  3. $ 符号的含义$isOn 拿到的是 projectedValue,对 @State 来说它是一个 Binding<Bool>,这就是为什么父 View 传 $isOn,而子 View 声明 @Binding var isOn,类型是对得上的。

  4. 不要在 body 外部调用:和 @State 一样,对 @Binding 属性的读写应发生在 bodybody 调用的方法中,以确保 SwiftUI 能正确追踪依赖。

Xcode 26.4 AFNetworking 私有头文件报错处理记录

问题现象

在当前工程执行 Pods 编译时,AFNetworking 4.0.1 出现以下报错:

/Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m:26:9 Use of private header from outside its module: 'netinet6/in6.h'
/Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m:32:9 Use of private header from outside its module: 'netinet6/in6.h'

根因分析

AFNetworking 4.0.1 在以下源码中直接引用了私有头文件 #import <netinet6/in6.h>

  • Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m
  • Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m

新版 Xcode / Clang 会将该引用识别为“模块外私有头文件访问”,从而在编译阶段直接报错。

这里并不需要通过放宽全局编译限制来绕过问题。对应文件实际依赖的 IPv6 结构体和常量可由公开头 #import <netinet/in.h> 提供,因此直接移除 #import <netinet6/in6.h> 即可。

处理方案

为了避免每次 pod install 后手动修改 Pods 目录,本次将修复逻辑固化到 Podfilepost_install 阶段。

处理原则如下:

  1. 扫描 AFNetworking 目录下所有 .h/.m 文件。
  2. 查找 #import <netinet6/in6.h>
  3. 如果存在,则自动删除该导入。
  4. 由于 Pods 内目标文件可能是只读权限,写入前临时补充写权限,写入后恢复原权限。

最终 Podfile 补丁

本次在 Podfile 中新增以下逻辑:

def patch_afnetworking_private_header(installer)
  # 扫描并移除 AFNetworking 对私有 IPv6 头文件的直接引用,兼容新版 Xcode 的模块校验。
  afnetworking_dir = File.join(installer.sandbox.pod_dir('AFNetworking'), 'AFNetworking')
  return unless Dir.exist?(afnetworking_dir)

  private_header_import = '#import <netinet6/in6.h>'
  Dir.glob(File.join(afnetworking_dir, '**', '*.{h,m}')).each do |file_path|
    next unless mcs_file_exists(file_path)

    file_content = File.read(file_path)
    next unless file_content.include?(private_header_import)

    original_mode = File.stat(file_path).mode
    File.chmod(original_mode | 0o200, file_path)
    File.write(file_path, file_content.gsub(private_header_import, ''))
    File.chmod(original_mode, file_path)
    puts "patched AFNetworking private header import: #{File.basename(file_path)}"
  end
end

post_install do |installer|
  patch_afnetworking_private_header(installer)

  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0'
      config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
      config.build_settings['CLANG_ENABLE_OBJC_WEAK'] = 'YES'
      config.build_settings['SWIFT_VERSION'] = '5.0'
    end
  end
end

实际修复结果

执行 pod install 后,以下两个文件中的私有头导入已被移除:

  • Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m
  • Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m

修复后的关键导入区如下:

#import <netinet/in.h>

#import <arpa/inet.h>
#import <ifaddrs.h>
#import <netdb.h>

验证过程

1. 安装 Pods

执行:

pod install

结果:

  • Pod installation complete!
  • post_install 补丁正常执行。

2. 检查私有头是否已全部移除

执行:

rg -n "netinet6/in6.h" Pods/AFNetworking/AFNetworking

结果:

  • 无输出。
  • 说明 AFNetworking 目录下已不存在该私有头引用。

3. 单独编译 AFNetworking Target

执行:

xcodebuild -project Pods/Pods.xcodeproj \
  -scheme AFNetworking \
  -configuration Debug \
  -sdk iphonesimulator \
  -derivedDataPath /tmp/TXLAPP_IOS_Pods_DerivedData \
  CODE_SIGNING_ALLOWED=NO build

结果:

** BUILD SUCCEEDED **

说明:

  • AFNetworking 已成功完成真实编译。
  • AFHTTPSessionManager.mAFNetworkReachabilityManager.m 均已通过编译。
  • 本次处理的私有头报错已被清除。

影响文件

  • Podfile
  • Podfile.lock
  • Pods/Manifest.lock
  • Pods/AFNetworking/AFNetworking/AFNetworkReachabilityManager.m
  • Pods/AFNetworking/AFNetworking/AFHTTPSessionManager.m

注意事项

  1. 这次修复是对第三方库源码的安装阶段补丁,不建议直接长期手改 Pods 文件后不保留 Podfile 逻辑。
  2. 后续只要重新执行 pod install,该补丁就会再次自动生效。
  3. 当前 Podfile 里仍将 Pods 的 IPHONEOS_DEPLOYMENT_TARGET 设置为 9.0,在新 Xcode 下会出现最低部署版本警告;这不是本次私有头报错的根因,但后续可以单独再整理处理。

结论

本次问题的本质是旧版 AFNetworking 源码中引用了私有系统头文件,导致新编译环境不再允许通过。采用 Podfile post_install 自动移除 netinet6/in6.h 引用的方式,可以稳定修复该问题,并保证后续重新安装 Pods 时无需重复人工处理。

@state的一些琐事

先理解 Property Wrapper 是什么

@propertyWrapper 就是让你可以自定义 @ 修饰符的机制。 @State@Binding 这些不是Swift内置的魔法,它们本质上就是普通的 struct,只不过被 @propertyWrapper 修饰了,所以才能用 @ 语法来用。 能理解吗?是不是还是很难理解,没事我写一个例子你就能理解了

假设你有一个属性,每次读取它都想打印一条日志:
var age: Int = 18
var age: Int = 18 { 
    didSet { print("age 变了,新值是 \(age)") }
}
但如果你有 100 个属性都需要这个功能呢?你要写 100 次 `didSet`?

Property Wrapper 就是用来解决这个问题的

你可以把"通用的包装逻辑"封装起来,然后像帖标签一样贴到任何属性上。

// 第一步:定义一个 Property Wrapper
@propertyWrapper
struct Logged {
    private var value: Int
    // initialValue 参数后面可以跟很多参数,自定义
    init(initialValue: Int) {
        self.value = initialValue
    }
    
    var wrappedValue: Int {
    //这里的get 和set 我们可以自定义任何我们想要的操作,比如有多个参数我们可以把这些参数拼接起来返回等等
        get { value }
        set {
            print("值变了,新值是 \(newValue)")  // 通用逻辑写在这里
            value = newValue
        }
    }
}

// 第二步:像贴标签一样使用它
@Logged var age = 18
@Logged var score = 100

// 现在 age 和 score 改变时,都会自动打印日志
age = 20   // 打印:值变了,新值是 20
score = 99 // 打印:值变了,新值是 99

所以 @propertyWrapper 本质上就是

把"对属性的操作逻辑"打包成一个 struct,然后用 @ 语法贴到属性上,让这个属性自动拥有那些逻辑。

回到 @State

@State 干的事情无非就是:

@propertyWrapper
public struct State<Value> {  
    // 1. 让你能直接赋初始值
    public init(initialValue value: Value)   
    // 2. 你平时用 brain 读写的就是这个 (这里set 之后苹果偷偷的去给你刷新了UI)
    public var wrappedValue: Value { get nonmutating set }    
    // 3. 你用 $brain 拿到的就是这个(一个 Binding)
    public var projectedValue: Binding<Value> { get }
}

@State 非常适合 struct 或者 enum 这样的值类型,它可以自动为我们完成从状态 到 UI 更新等一系列操作。但是它本身也有一些限制,我们在使用 @State 之前,对 于需要传递的状态,最好关心和审视下面这两个问题:

  1. 这个状态是属于单个 View 及其子层级,还是需要在平行的部件之间传递和使 用?@State 可以依靠 SwiftUI 框架完成 View 的自动订阅和刷新,但这是有 条件的:对于 @State 修饰的属性的访问,只能发生在 body 或者 body 所调 用的方法中。你不能在外部改变 @State 的值,它的所有相关操作和状态改变 都应该是和当前 View 挂钩的。如果你需要在多个 View 中共享数据,@State 可能不是很好的选择;如果还需要在 View 外部操作数据,那么 @State 甚至 就不是可选项了。
  2. 状态对应的数据结构是否足够简单?对于像是单个的 Bool 或者 String, @State 可以迅速对应。含有少数几个成员变量的值类型,也许使用 @State 也还不错。但是对于更复杂的情况,例如含有很多属性和方法的类型,可能其 中只有很少几个属性需要触发 UI 更新,也可能各个属性之间彼此有关联,那 么我们应该选择引用类型和更灵活的可自定义方式。

Xcode MCP Server 完全指南:从智能配置到编程控制

目录概要


为什么需要 MCP?

如果你用过 Cursor 或 Claude 写代码,一定有过这样的体验:AI 能侃侃而谈生成代码,但真要它帮你跑个测试、修个编译错误,它就傻眼了——因为它"看不见"你的 Xcode 工程,也"摸不着"编译器。

Model Context Protocol (MCP) 就是来解决这个问题的。它像一根 USB 线,把 AI 助手和 Xcode 连接起来,让 AI 可以直接读取文件、运行构建、执行测试,甚至渲染 SwiftUI 预览。换句话说,MCP 让 Xcode 变成了一个可编程的"智能引擎"。

本文将从系统配置工具实战,带你完整掌握 Xcode MCP Server 的使用。文章后半部分,我还会穿插一些编译器演进的历史故事——毕竟,理解了"从哪里来",才更能明白"往哪里去"。

graph LR
    subgraph AI["🤖 AI 助手"]
        A1[Cursor]
        A2[Claude CLI]
        A3[Codex]
    end

    subgraph MCP["🔌 MCP 桥梁"]
        B[mcpbridge]
    end

    subgraph Xcode["🛠️ Xcode 引擎"]
        C1[📄 文件读写]
        C2[🔨 编译构建]
        C3[✅ 测试运行]
        C4[👁️ UI 预览]
        C5[🔍 代码搜索]
    end

    A1 --> B
    A2 --> B
    A3 --> B
    B --> C1
    B --> C2
    B --> C3
    B --> C4
    B --> C5

    style AI fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    style MCP fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
    style Xcode fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px

一、系统与环境前置要求

要玩转 Xcode Intelligence 和 MCP,硬件和系统是第一道门槛。Apple 这次把门槛卡得很死:

  • macOS:必须运行 macOS Sequoia 15.2 或更高版本。别问为什么,问就是 Apple Intelligence 强依赖于端侧 NPU 算力。
  • 硬件:必须使用 Apple Silicon (M1 及后续芯片) 的 Mac。Intel 用户暂时只能眼馋。
  • XcodeXcode 26.3 或更高版本。这个版本内置了 mcpbridge 工具,也就是 MCP 的服务端。

另外,需要在 系统设置 > Apple Intelligence & Siri 中确保开关已开启。Xcode 的智能功能是 Apple Intelligence 的一部分,系统层面不开,Xcode 里也开不了。

graph TD
    subgraph 前置检查清单
        direction TB
        H["💻 硬件检查"] --> H1{"Apple Silicon?<br/>M1 / M2 / M3 / M4"}
        H1 -->|✅ 是| S["🖥️ 系统检查"]
        H1 -->|❌ Intel| FAIL["⛔ 不支持"]
        S --> S1{"macOS Sequoia 15.2+?"}
        S1 -->|✅ 是| X["📱 Xcode 检查"]
        S1 -->|❌ 版本过低| UPDATE1["⬆️ 升级 macOS"]
        X --> X1{"Xcode 26.3+?"}
        X1 -->|✅ 是| AI["🧠 Apple Intelligence"]
        X1 -->|❌ 版本过低| UPDATE2["⬆️ 升级 Xcode"]
        AI --> AI1{"系统设置中<br/>Apple Intelligence 已开启?"}
        AI1 -->|✅ 是| OK["🎉 环境就绪!"]
        AI1 -->|❌ 未开启| ENABLE["⚙️ 前往设置开启"]
    end

    style FAIL fill:#ffcdd2,stroke:#c62828
    style OK fill:#c8e6c9,stroke:#2e7d32
    style UPDATE1 fill:#fff9c4,stroke:#f57f17
    style UPDATE2 fill:#fff9c4,stroke:#f57f17
    style ENABLE fill:#fff9c4,stroke:#f57f17

二、开启 Xcode Intelligence:模型提供商配置

Xcode 26.3 的智能功能采用了插件化的模型提供商架构(Provider Architecture),你可以同时接入多个模型源,根据任务需求灵活切换。

在 Xcode 中打开 Settings (⌘,) > Intelligence,你会看到三个主要提供商:

A. Apple (本地/云端混合)

  • 默认集成,无需额外配置。
  • 提供基础的代码补全(Predictive Code Completion)和轻量级重构建议,针对 Swift 和 Apple SDK 有优化。

B. ChatGPT (OpenAI)

  • 点击 ChatGPT in Xcode 下的 Turn On。
  • 绑定 ChatGPT 账号(支持 Free 和 Plus)。
  • 在 Project Editor 中,可以为特定 Target 选择模型的 Reasoning Level(推理等级),控制生成代码的深度。

C. Claude (Anthropic)

  • 点击 Claude 下的 Sign In 授权。
  • 如果安装了 Claude Agent 组件,可以在此配置其构建和测试权限。

小贴士:Xcode 内置的 Agent 配置目录位于 ~/Library/Developer/Xcode/CodingAssistant/,与标准的 .codex.claude 配置独立,所以不会干扰你现有的命令行工具配置。

D. 关键一步:启用 Xcode Tools MCP Server

在 Intelligence 设置页面的最底部,找到 Model Context Protocol 区域,将 Xcode Tools 的开关拨至 ON

技术原理:开启后,Xcode 主进程会启动一个名为 mcpbridge 的 XPC 服务,监听来自外部工具的连接请求。当外部工具首次尝试连接时,Xcode 会弹出权限确认对话框——务必点击 Allow,否则一切免谈。

graph TB
    subgraph XcodeSettings["⚙️ Xcode Settings > Intelligence"]
        direction TB
        P1["🍎 Apple<br/>━━━━━━━━━━<br/>本地 + 云端混合<br/>代码补全 / 重构建议<br/>🟢 默认开启"]
        P2["🤖 ChatGPT (OpenAI)<br/>━━━━━━━━━━<br/>Turn On → 绑定账号<br/>支持 Free / Plus<br/>可调 Reasoning Level"]
        P3["🟣 Claude (Anthropic)<br/>━━━━━━━━━━<br/>Sign In → 授权<br/>Claude Agent 构建权限<br/>独立配置目录"]
        P4["🔌 MCP Server<br/>━━━━━━━━━━<br/>Xcode Tools → ON<br/>启动 mcpbridge XPC<br/>⚠️ 首次连接需 Allow"]
    end

    P1 --- P2
    P2 --- P3
    P3 --- P4

    P4 -->|开启后| XPC["mcpbridge<br/>XPC 服务启动"]
    XPC -->|外部工具连接| ALLOW{"权限弹窗<br/>Allow?"}
    ALLOW -->|✅ Allow| READY["🎉 MCP 就绪"]
    ALLOW -->|❌ Deny| BLOCKED["⛔ 连接被拒"]

    style P4 fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
    style READY fill:#c8e6c9,stroke:#2e7d32
    style BLOCKED fill:#ffcdd2,stroke:#c62828

三、MCP 架构揭秘:桥梁是如何搭建的

MCP 是一个标准化的协议,旨在解决 AI 模型与本地开发环境"隔离"的问题。它的架构可以用以下流程图清晰地表示:

graph TD
    subgraph Client["🌐 MCP 客户端"]
        A1["Claude Code<br/>(CLI)"]
        A2["Cursor<br/>(IDE)"]
        A3["Codex<br/>(CLI)"]
    end

    subgraph Bridge["🌉 MCP Bridge 层"]
        B["xcrun mcpbridge<br/>━━━━━━━━━━━━━<br/>协议: stdio JSON-RPC<br/>角色: 翻译官"]
    end

    subgraph XcodeProcess["🏗️ Xcode 主进程"]
        C["Xcode App<br/>━━━━━━━━━━━━━<br/>通信: XPC"]
        C --> D["🔨 Build System<br/>增量编译 / 错误诊断"]
        C --> E["📝 Source Editor<br/>文件读写 / 代码分析"]
        C --> F["🧪 XCTest Runner<br/>测试运行 / 结果收集"]
        C --> G["👁️ Preview Engine<br/>SwiftUI 预览渲染"]
        C --> H["📚 Documentation<br/>Apple 文档搜索"]
    end

    A1 -->|"stdio<br/>JSON-RPC"| B
    A2 -->|"stdio<br/>JSON-RPC"| B
    A3 -->|"stdio<br/>JSON-RPC"| B
    B -->|"XPC<br/>进程间通信"| C

    style Client fill:#e1f5fe,stroke:#01579b,stroke-width:2px
    style Bridge fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
    style XcodeProcess fill:#d1c4e9,stroke:#512da8,stroke-width:2px

关键路径解读

  • mcpbridge 是一个命令行工具,通过 xcrun mcpbridge 启动。
  • 它与 Xcode 主进程通过 XPC 通信,调用 Xcode 内部的构建、编辑、调试等 API。
  • 外部客户端(如 Cursor、Claude CLI)通过**标准输入输出(stdio)**与 mcpbridge 交互,协议基于 JSON-RPC
  • 简单来说,mcpbridge 就是那个"翻译官",把 AI 的意图翻译成 Xcode 能懂的操作。
sequenceDiagram
    participant AI as 🤖 AI 助手 (Cursor)
    participant MCP as 🌉 mcpbridge
    participant Xcode as 🛠️ Xcode

    AI->>MCP: JSON-RPC 请求<br/>"BuildProject"
    MCP->>Xcode: XPC 调用<br/>触发编译
    Xcode-->>Xcode: 执行增量构建...
    Xcode->>MCP: XPC 响应<br/>编译结果 + 错误列表
    MCP->>AI: JSON-RPC 响应<br/>结构化错误信息

    Note over AI: 解析错误,定位文件和行号

    AI->>MCP: JSON-RPC 请求<br/>"XcodeRead" 读取错误文件
    MCP->>Xcode: XPC 调用
    Xcode->>MCP: 文件内容
    MCP->>AI: 带行号的源码

    Note over AI: 分析问题,生成修复代码

    AI->>MCP: JSON-RPC 请求<br/>"XcodeUpdate" 修复代码
    MCP->>Xcode: XPC 调用
    Xcode->>MCP: 更新成功
    MCP->>AI: 确认响应

四、客户端接入:让 Cursor/Claude 操作 Xcode

场景 A:命令行工具 (Claude CLI / Codex)

Claude Code

claude mcp add --transport stdio xcode -- xcrun mcpbridge

Codex

codex mcp add xcode -- xcrun mcpbridge

场景 B:集成开发环境 (Cursor / Trae)

在编辑器的 MCP 配置文件中添加 Server 定义。

GUI 方式:进入 Settings > Features > MCP,点击 + Add New MCP Server。

Name: xcode
Transport: stdio
Command: xcrun mcpbridge

JSON 方式:修改配置文件 ~/.cursor/mcp.json~/.config/trae/mcp.json

{
  "mcpServers": {
    "xcode": {
      "command": "xcrun",
      "args": ["mcpbridge"]
    }
  }
}

注意mcpbridge 会自动检测当前运行的 Xcode 进程 ID(PID),一般无需手动指定环境变量。如果 Xcode 没打开,连接会失败——这是最常见的坑。

场景 C:项目级上下文提示

在项目根目录添加 AGENTS.mdCLAUDE.md 文件,里面可以写清楚:

  • 核心 Scheme 名称
  • 主要的 Test Plan
  • 特殊的构建脚本路径
  • 架构模式(MVVM/TCA 等)

MCP Client 会优先读取这些文件作为 System Prompt 的一部分,让 AI 更懂你的项目结构。

graph LR
    subgraph CLI["💻 命令行接入"]
        C1["claude mcp add<br/>--transport stdio<br/>xcode -- xcrun mcpbridge"]
        C2["codex mcp add<br/>xcode -- xcrun mcpbridge"]
    end

    subgraph IDE["🖥️ IDE 接入"]
        I1["Cursor<br/>~/.cursor/mcp.json"]
        I2["Trae<br/>~/.config/trae/mcp.json"]
    end

    subgraph Context["📋 项目上下文"]
        X1["AGENTS.md"]
        X2["CLAUDE.md"]
    end

    CLI --> MCP["🔌 mcpbridge"]
    IDE --> MCP
    Context -.->|"System Prompt"| MCP
    MCP --> Xcode["🛠️ Xcode"]

    style CLI fill:#e3f2fd,stroke:#1565c0
    style IDE fill:#f3e5f5,stroke:#7b1fa2
    style Context fill:#e8f5e9,stroke:#2e7d32
    style MCP fill:#fff9c4,stroke:#fbc02d,stroke-width:2px

五、MCP 工具集详解(20 个工具分类说明)

xcrun mcpbridge 暴露了 20 个核心工具,覆盖了从文件操作到测试运行的方方面面。下面按功能分类逐一说明。

graph TB
    subgraph Tools["🧰 MCP 工具集 - 20 个工具"]
        direction TB
        subgraph FileOps["📄 文件操作 (6)"]
            F1["XcodeRead"]
            F2["XcodeWrite"]
            F3["XcodeUpdate"]
            F4["XcodeMV"]
            F5["XcodeRM"]
            F6["XcodeMakeDir"]
        end
        subgraph SearchOps["🔍 代码搜索 (3)"]
            S1["XcodeGrep"]
            S2["XcodeGlob"]
            S3["XcodeLS"]
        end
        subgraph BuildOps["🔨 构建诊断 (4)"]
            B1["BuildProject"]
            B2["GetBuildLog"]
            B3["XcodeListNavigatorIssues"]
            B4["XcodeRefreshCodeIssuesInFile"]
        end
        subgraph TestOps["✅ 测试运行 (3)"]
            T1["GetTestList"]
            T2["RunSomeTests"]
            T3["RunAllTests"]
        end
        subgraph PreviewOps["👁️ 预览运行时 (2)"]
            P1["RenderPreview"]
            P2["ExecuteSnippet"]
        end
        subgraph DocOps["📚 文档窗口 (2)"]
            D1["DocumentationSearch"]
            D2["XcodeListWindows"]
        end
    end

    style FileOps fill:#e3f2fd,stroke:#1565c0
    style SearchOps fill:#fff3e0,stroke:#e65100
    style BuildOps fill:#fce4ec,stroke:#c62828
    style TestOps fill:#e8f5e9,stroke:#2e7d32
    style PreviewOps fill:#f3e5f5,stroke:#7b1fa2
    style DocOps fill:#efebe9,stroke:#4e342e

1. 文件操作类

工具 功能 关键参数
XcodeRead 读取文件内容,支持分页(limit/offset),最多 600 行 filePath, offset, limit
XcodeWrite 创建新文件或覆盖现有文件,自动添加到工程组 filePath, content
XcodeUpdate 增量编辑(基于字符串替换),比全量重写更省 token filePath, edits
XcodeMV 移动或重命名文件,保持工程结构一致性 sourcePath, destPath
XcodeRM 删除文件 filePath
XcodeMakeDir 创建目录 path

最佳实践:务必使用 XcodeRead 返回的行号作为参考,避免后续编辑时行号偏移。路径格式示例:MyProject/ViewControllers/MyViewController.swift

2. 代码搜索类

工具 功能 关键参数
XcodeGrep 在工程中搜索文本模式(支持正则) pattern, path, glob, type, outputMode
XcodeGlob 基于 glob 模式列出文件(如 **/*.swift pattern
XcodeLS 列出目录内容,类似 ls 命令 path

3. 构建与诊断类

工具 功能 关键返回值
BuildProject 触发当前 Scheme 的增量构建(阻塞调用) buildResult, errors[], elapsedTime
GetBuildLog 获取最近一次构建的详细日志 log
XcodeListNavigatorIssues 获取 Issue Navigator 中的实时问题(无需完整构建) issues 列表
XcodeRefreshCodeIssuesInFile 强制刷新并检索特定文件的编译器诊断 filePath 对应的诊断信息

4. 测试运行类

工具 功能 关键返回值
GetTestList 获取所有可用测试的层级结构(Test Plan → Class → Method) tests 层级列表
RunSomeTests 运行指定的测试用例 指定测试的结果
RunAllTests 运行当前 Scheme 中的所有测试 counts, results[](最多 100 条)

测试标识符示例MyProjectTests/UserProfileTests/testUserNameValidation

5. 预览与运行时

工具 功能 关键参数
RenderPreview 构建并渲染 SwiftUI 预览(#PreviewPreviewProvider),返回图片路径 sourceFilePath, timeout
ExecuteSnippet 在目标文件的上下文中动态执行 Swift 代码,类似 LLDB 的 expression 代码片段

6. 文档与窗口

工具 功能
DocumentationSearch 搜索 Xcode 内置的 Apple 开发文档和 WWDC 视频
XcodeListWindows 列出当前打开的 Xcode 窗口信息

注意:所有工具的参数中,tabIdentifier 通常可以省略,mcpbridge 会自动关联当前活跃的 Xcode 窗口。


六、实战场景与最佳实践

场景 1:修复编译错误

// 1. 构建并获取错误列表
BuildProject()

// 2. 读取有错误的文件
XcodeRead(filePath: "MyProject/ViewControllers/MyViewController.swift")

// 3. 修改文件
XcodeWrite(filePath: "MyProject/ViewControllers/MyViewController.swift", content: "修正后的代码...")

// 4. 再次构建验证
BuildProject()

场景 2:代码搜索与重构

// 搜索所有调用某个类的地方
XcodeGrep(pattern: "MyViewController", outputMode: "filesWithMatches")

// 逐个读取文件并修改
XcodeRead(filePath: "MyProject/ViewControllers/AnotherViewController.swift")
XcodeUpdate(...)

场景 3:测试驱动开发

// 获取测试列表
GetTestList()

// 运行指定测试
RunSomeTests(testIdentifiers: ["MyProjectTests/UserProfileTests/testUserInitialization"])

// 如果失败,修复后再运行

场景 4:UI 预览验证

// 修改 SwiftUI 代码后
XcodeWrite(filePath: "MyProject/Views/ProfileView.swift", content: "更新后的预览代码...")

// 渲染预览
RenderPreview(sourceFilePath: "MyProject/Views/ProfileView.swift")

最佳工作流建议

graph LR
    A["📖 读<br/>XcodeRead"] --> B["✏️ 写<br/>XcodeUpdate"]
    B --> C["🔨 验<br/>BuildProject"]
    C --> D{"编译通过?"}
    D -->|❌ 失败| A
    D -->|✅ 通过| E["🔍 搜<br/>XcodeGrep"]
    E --> F["🧪 测<br/>RunSomeTests"]
    F --> G{"测试通过?"}
    G -->|❌ 失败| A
    G -->|✅ 通过| H["👁️ 看<br/>RenderPreview"]
    H --> I["🎉 完成"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#fff3e0,stroke:#e65100
    style C fill:#fce4ec,stroke:#c62828
    style D fill:#f5f5f5,stroke:#616161
    style E fill:#f3e5f5,stroke:#7b1fa2
    style F fill:#e8f5e9,stroke:#2e7d32
    style G fill:#f5f5f5,stroke:#616161
    style H fill:#ede7f6,stroke:#311b92
    style I fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px

七、踩坑指南:解决 Cursor 兼容性问题

在 Xcode 26.3 (26.3 RC) 中,xcrun mcpbridge 返回的响应不完全符合 MCP 规范——缺少 structuredContent 字段,这会导致 Cursor 报错。解决办法是写一个 Python 包装脚本,在中间层添加缺失的字段。

graph LR
    subgraph Problem["❌ 问题"]
        P1["Cursor"] -->|"JSON-RPC"| P2["mcpbridge"]
        P2 -->|"响应缺少<br/>structuredContent"| P1
        P1 --> P3["⛔ 报错!"]
    end

    subgraph Solution["✅ 解决方案"]
        S1["Cursor"] -->|"JSON-RPC"| S2["🐍 Python Wrapper<br/>mcpbridge-wrapper"]
        S2 -->|"透传请求"| S3["mcpbridge"]
        S3 -->|"原始响应"| S2
        S2 -->|"注入<br/>structuredContent"| S1
        S1 --> S4["🎉 正常工作"]
    end

    style Problem fill:#ffebee,stroke:#c62828
    style Solution fill:#e8f5e9,stroke:#2e7d32
    style P3 fill:#ffcdd2,stroke:#c62828
    style S4 fill:#c8e6c9,stroke:#2e7d32

步骤 1:创建脚本 ~/bin/mcpbridge-wrapper(记得 chmod +x):

#!/usr/bin/env python3
"""
Wrapper for xcrun mcpbridge that adds structuredContent to responses.
"""
import sys, json, subprocess, threading

def process_response(line):
    try:
        data = json.loads(line)
        if isinstance(data, dict) and 'result' in data:
            result = data['result']
            if isinstance(result, dict):
                if 'content' in result and 'structuredContent' not in result:
                    content = result.get('content', [])
                    if isinstance(content, list) and len(content) > 0:
                        for item in content:
                            if isinstance(item, dict) and item.get('type') == 'text':
                                text = item.get('text', '')
                                try:
                                    result['structuredContent'] = json.loads(text)
                                except json.JSONDecodeError:
                                    result['structuredContent'] = {"text": text}
                                break
        return json.dumps(data)
    except json.JSONDecodeError:
        return line

def main():
    proc = subprocess.Popen(
        ['xcrun', 'mcpbridge'] + sys.argv[1:],
        stdin=subprocess.PIPE, stdout=subprocess.PIPE,
        stderr=sys.stderr, text=True, bufsize=1
    )

    def pipe_output(stdout):
        for line in stdout:
            print(process_response(line.strip()), flush=True)

    threading.Thread(target=pipe_output, args=(proc.stdout,), daemon=True).start()

    for line in sys.stdin:
        proc.stdin.write(line)
        proc.stdin.flush()

if __name__ == '__main__':
    main()

步骤 2:修改 ~/.cursor/mcp.json

{
  "mcpServers": {
    "xcode-tools": {
      "command": "/Users/YOUR_USERNAME/bin/mcpbridge-wrapper"
    }
  }
}

重启 Cursor,问题解决。


八、从 GCC 到 MCP:编译器与工具链的进化之路

写到这里,我突然想起多年前研究 Clang 和 Swift 编译时写的一篇文章(就是简书上那篇《OC 与 Swift 编译对比》)。当时梳理了从 GCC 到 LLVM 再到 Swift 的历史,现在回头看,MCP 的出现其实也是这条进化线上的必然一环。

timeline
    title 编译器与工具链进化史
    section GCC 时代
        2000s : GCC 作为 Xcode 默认编译器
              : 编译器是"黑盒"
              : IDE 交互能力有限
              : 扩展困难
    section LLVM/Clang 时代
        2007 : Chris Lattner 创建 LLVM
             : Clang 取代 GCC
             : 模块化设计 + libTooling
             : SourceKit 诞生
    section Swift 编译器
        2014 : Swift 语言发布
             : SIL 中间表示层
             : ARC 优化 / 泛型特化
             : 智能代码补全基础
    section MCP 时代
        2025-2026 : Xcode Intelligence
                  : mcpbridge MCP Server
                  : AI 可"操作"代码
                  : 编译-测试-预览全闭环

为什么这么说?

  • GCC 时代:编译器是个"黑盒",只负责把源码变成机器码,与 IDE 的交互很有限。Xcode 早期也是通过调用 GCC 来完成构建,但想要扩展功能(比如代码索引、实时诊断)非常困难。

  • LLVM/Clang 时代:LLVM 的模块化设计让编译器变成了可重用的库。Clang 提供了 libTooling,开发者可以编写插件遍历 AST,实现代码检查、重构。Xcode 的 SourceKit 也应运而生,为 IDE 提供了实时的代码分析能力。

  • Swift 编译器:更进一步,引入了 SIL (Swift Intermediate Language),在 LLVM IR 之前增加了一层高级中间表示,专门用于 Swift 特有的优化(如 ARC 优化、泛型特化)。这为更智能的代码补全和诊断打下了基础。

  • MCP 时代:现在,我们把编译器 + IDE 的能力通过 MCP 暴露给 AI。AI 不再是"看"代码,而是能"操作"代码——读取、修改、构建、测试、预览,整个闭环自动化。

graph BT
    subgraph Evolution["📈 能力进化路径"]
        direction BT
        L1["🔧 GCC<br/>编译源码 → 机器码<br/>黑盒,不可扩展"]
        L2["⚙️ LLVM / Clang<br/>模块化编译器库<br/>libTooling + AST 遍历<br/>SourceKit 实时分析"]
        L3["🦅 Swift Compiler<br/>SIL 中间表示<br/>ARC / 泛型优化<br/>智能补全基础"]
        L4["🤖 MCP<br/>编译器 + IDE 可编程化<br/>AI 直接操作工程<br/>读-写-编译-测试-预览 闭环"]

        L1 -->|"模块化突破"| L2
        L2 -->|"语言级创新"| L3
        L3 -->|"AI 可编程化"| L4
    end

    style L1 fill:#efebe9,stroke:#4e342e
    style L2 fill:#e3f2fd,stroke:#1565c0
    style L3 fill:#fff3e0,stroke:#e65100
    style L4 fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px

从 GCC 到 LLVM,我们解决了编译器的模块化;从 SourceKit 到 MCP,我们解决了 IDE 能力的可编程化。每一步都在打破工具的边界,让开发者(或 AI)能更深入地控制开发环境。


九、总结与展望

Xcode MCP Server 的出现,意味着 AI 辅助编程进入了一个新阶段:从聊天式代码生成,进化到工程级自动运维。你可以让 AI 帮你修编译错误、跑测试、甚至验证 UI,而不再只是粘贴代码让你自己试错。

当然,目前还有些粗糙(比如 Cursor 兼容性问题),但方向已经非常明确。未来,随着 Apple Intelligence 的成熟和第三方模型的接入,Xcode 可能会变成一个"AI 优先"的 IDE——你只需要描述需求,剩下的交给 AI 和 MCP 去执行。

graph LR
    subgraph Past["📼 过去"]
        P1["AI 生成代码片段"] --> P2["复制粘贴到 IDE"] --> P3["手动编译调试"]
    end

    subgraph Present["📍 现在 (MCP)"]
        N1["AI 理解工程结构"] --> N2["直接读写文件"] --> N3["自动编译测试"]
    end

    subgraph Future["🔮 未来"]
        F1["描述需求"] --> F2["AI 自主规划"] --> F3["全自动交付"]
    end

    Past -.->|"进化"| Present
    Present -.->|"展望"| Future

    style Past fill:#efebe9,stroke:#795548
    style Present fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Future fill:#e1f5fe,stroke:#0277bd

奇奇怪怪的无用知识又增加了:从 GCC 到 LLVM 再到 MCP,每一步都是因为开发者"受不了"现有工具的局限而推动的。历史总是惊人地相似,但每次进化都让工具离人更近一步。


解决 Swift Testing 中 DI 容器的竞态条件

欢迎给个 star:RickeyBoy-Github

🎬 背景:单元测试不稳定

当项目逐渐扩大,Unit Test 越来越多的时候,必然会出现问题:某些单元测试有时能通过,有时又不行。最麻烦的是有些时候本地运行能通过,在 CI Pipeline 中又通过不了。

通常来讲本地设备和 CI 设备确实很不一样,一个常见的问题就是存在静态条件(Race Condition)的问题,本篇就尝试从根本上解决这类似的问题,并且从原理层面也讲清楚来龙去脉。

🔁 快速回顾:为什么 DI 很重要

如果是在 SwiftUI 上使用 MVVM 的架构,那 DI (Dependency Injection 依赖注入)对你来说肯定不陌生。这里简单回顾一下,为什么要用 DI

不可测试的 ViewModel

final class CheckoutViewModel {
    private let paymentService = PaymentService()  // 硬编码的依赖

    func placeOrder() async throws {
        try await paymentService.charge(amount: 99.99)
    }
}

这个 ViewModel 直接持有了它的依赖。想在测试中把 PaymentService 换成 mock?根本换不了。

可测试的 ViewModel

final class CheckoutViewModel {
    private let paymentService: PaymentServiceProtocol

    init(paymentService: PaymentServiceProtocol) {
        self.paymentService = paymentService
    }
    func placeOrder() async throws {
        try await paymentService.charge(amount: 99.99)
    }
}

现在我们可以在测试中注入 mock,在生产环境中使用真实服务了:

// 生产环境
let vm = CheckoutViewModel(paymentService: StripePaymentService())
// 测试
let vm = CheckoutViewModel(paymentService: MockPaymentService())

生产环境中,ViewModel 连接真实服务。测试中,由 mock 替代:

image.png

道理很简单,小项目用着也没问题。但随着 app 不断膨胀,事情就开始变味了。

💥 MVVM 的依赖问题

构造器注入复杂度爆炸

现实中的 ViewModel 不可能只有一个依赖,随着业务迭代它们只会越来越多:

init(
    paymentService: PaymentServiceProtocol,
    orderRepository: OrderRepositoryProtocol,
    analyticsTracker: AnalyticsTrackerProtocol,
    authService: AuthServiceProtocol,
    featureFlagService: FeatureFlagServiceProtocol
) { ... }

而且如果父级 ViewModel 需要创建子级 ViewModel,那父级就必须知道子级的全部依赖。往下嵌套三层之后再新增一个依赖,你就得一路往上改文件……

SwiftUI 的 @Environment 帮不上忙

SwiftUI 有一个内置的 DI 机制,@Environment

struct CheckoutView: View {
    @Environment(.paymentService) var paymentService  // 可以!
}

看起来挺好。但问题是 @Environment 只能在 View 的 body 里用,不适用于 ViewModel:

class CheckoutViewModel {
    @Environment(.paymentService) var paymentService  // 编译报错
}

所以我们需要一个像 @Environment 一样好用,但能在 View 层之外工作的方案。

🏭 Factory:适配 MVVM 的 DI 框架

Factory 是一个轻量级的 DI 框架,刚好解决了这个问题。ViewModel 通过 @Injected 主动从容器中拉取依赖:

final class CheckoutViewModel {
    @Injected(.paymentService) private var paymentService
    @Injected(.orderRepository) private var orderRepository

    func placeOrder() async throws {
        let order = try await orderRepository.createOrder()
        try await paymentService.charge(amount: order.total)
    }
}

不用传 init 参数,也不用维护依赖链。容器负责创建每个服务,@Injected 在运行时自动解析,ViewModel 压根不需要关心依赖从哪来。

Factory 就像个中间人,根据当前环境决定注入什么:

image.png

ViewModel 只管说"我要什么",Factory 负责"给你什么"。

Container:依赖注册中心

Container 是你定义每个依赖创建方式的地方:

extension Container {
    var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
    var orderRepository: Factory<OrderRepositoryProtocol> {
        self { OrderRepository() }
    }
}

在生产环境中,@Injected(.paymentService) 解析为 StripePaymentService()。在测试中,你可以覆盖它:

Container.shared.paymentService.register { MockPaymentService() }

SharedContainer:按模块组织依赖

一个全局 Container 小项目够用。但在拥有几十个服务的模块化工程里,它很快就变成了大杂烩。Factory 提供了 SharedContainer,让你按模块拆分管理,就像是文件夹一样:

PaymentSharedContainer
├── paymentService
└── paymentGateway

OrderSharedContainer
├── orderRepository
└── orderValidator

AuthSharedContainer
├── authService
└── tokenStorage

每个模块管好自己的容器,边界清晰,各司其职:

public final class PaymentSharedContainer: SharedContainer {
    public static var shared = PaymentSharedContainer()
    public let manager = ContainerManager()

    public var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
    public var paymentGateway: Factory<PaymentGatewayProtocol> {
        self { PaymentGateway() }
    }
}

在 ViewModel 中使用:

final class CheckoutViewModel {
    @Injected(\PaymentSharedContainer.paymentService)
    private var paymentService
}

到这里一切都很美好,直到你开始并行跑测试(尤其是 Swift Testing 默认就是并行模式)。

🐛 核心问题:并行测试与共享可变状态

好,现在终于说到重点了。

一个典型的测试配置

@Suite
struct CheckoutViewModelTests {
    let mockPayment: PaymentServiceProtocolSpy
    let sut: CheckoutViewModel

    init() {
        mockPayment = PaymentServiceProtocolSpy()
        PaymentSharedContainer.shared.paymentService.register { mockPayment }
        sut = CheckoutViewModel()
    }

    @Test func placeOrder_chargesCorrectAmount() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.chargeCallCount == 1)
    }
}

看着没毛病,单独跑这个测试 Suite 的时候确实也没问题。

并行执行时发生了什么

Swift Testing 默认并发执行测试。这对 CI 速度来说是好事,但这意味着多个测试 Suite 同时执行,且共享同一个 PaymentSharedContainer.shared 实例:

image.png

Suite A 注册了 MockASuite B 在同一个容器上注册了 MockB。当 Suite A 解析依赖时,它可能拿到的是 MockB,反过来也一样。最终结果完全取决于线程调度顺序,而线程调度本身就是不确定的。换句话说,你的测试结果现在全凭运气。

更糟糕的是:Suite 内部的竞态

即使是同一个 Suite 内部的测试,也可能产生竞态。Swift Testing 并不保证 @Test 方法按顺序执行:

@Suite
struct OrderViewModelTests {
    let mockRepo: OrderRepositoryProtocolSpy
    let sut: OrderViewModel

    init() {
        mockRepo = OrderRepositoryProtocolSpy()
        OrderSharedContainer.shared.orderRepository.register { mockRepo }
        sut = OrderViewModel()
    }

    @Test func fetchOrders_success() async {
        mockRepo.fetchOrdersResult = [Order.sample]
        await sut.fetchOrders()
        #expect(sut.orders.count == 1)
    }

    @Test func fetchOrders_empty() async {
        mockRepo.fetchOrdersResult = []
        await sut.fetchOrders()
        #expect(sut.orders.isEmpty)
    }
}

两个测试配置的是同一个 mockRepo 实例。并发运行时,一个测试的配置会影响到另一个,本来期望拿到空列表的测试,结果却看到了一条数据,显然这样会影响最终的测试结果。

最终症状

  • 测试本地通过但 CI 上失败(不同机器,不同时序)
  • 重跑失败的流水线就能通过
  • 添加或删除不相关的测试导致其他地方失败
  • 稳定运行很长时间的测试突然变得不可靠

根本原因都是一样的:DI 容器中的共享可变状态,导致 Race Condition

🔐 解决方案:使用 @TaskLocal 实现容器隔离

思路很简单:给每个测试分配自己的独立容器副本。不共享,自然就不会有竞态。

理解 @TaskLocal

在讲方案之前,先简单科普一下 @TaskLocal。这是 Swift Concurrency 提供的一个属性,它能让每个 Task 持有自己的 copy,互相之间完全隔离:

enum Scope {
    @TaskLocal static var currentUser: String = "default"
}

// Task A 看到 "Alice"
Task {
    Scope.$currentUser.withValue("Alice") {
        print(Scope.currentUser)  // "Alice"
    }
}

// Task B 看到 "Bob",完全独立
Task {
    Scope.$currentUser.withValue("Bob") {
        print(Scope.currentUser)  // "Bob"
    }
}

每个 Task 有自己的 currentUser。无需加锁,没有竞态,没有共享状态。

将 @TaskLocal 应用到容器

聪明的你大概已经猜到了,把容器的 shared 属性标记为 @TaskLocal,每个测试任务就能拿到自己的容器了:

public final class PaymentSharedContainer: SharedContainer {
    @TaskLocal public static var shared = PaymentSharedContainer()
    // ^^^^^^^^^ 就是这行代码

    public let manager = ContainerManager()

    public var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
}

就这么一行代码的事。当测试运行在一个绑定了新容器到 $sharedTask 里时,所有 @Injected 解析拿到的都是当前测试专属的容器,而不是全局那个。

Factory 的内置支持:Container Traits

当然,你也可以手动调用 $shared.withValue(...),但写起来实在太啰嗦了。Factory(通过 FactoryTesting)提供了 Container Traits 来帮你搞定这些样板代码,可以直接嵌入 Swift Testing 的 @Suite@Test 属性。

定义 Container Trait

为每个 SharedContainer 在测试支持模块中定义一个 trait:

import Testing
import FactoryTesting

// 辅助方法:配置测试默认值
private func configurePaymentDefaults(_ container: PaymentSharedContainer) {
    container.paymentService.register { PaymentServiceProtocolSpy() }
    container.paymentGateway.register { PaymentGatewayProtocolSpy() }
}

// Suite 级别的 trait:隔离整个 Suite
extension SuiteTrait where Self == ContainerTrait<PaymentSharedContainer> {
    static var paymentContainer: ContainerTrait<PaymentSharedContainer> {
        let container = PaymentSharedContainer()
        configurePaymentDefaults(container)
        return .init(shared: PaymentSharedContainer.$shared, container: container)
    }
}

// Test 级别的 trait:允许单个测试覆盖配置
extension TestTrait where Self == ContainerTrait<PaymentSharedContainer> {
    static func paymentContainer(
        _ configure: @escaping (PaymentSharedContainer) -> Void
    ) -> ContainerTrait<PaymentSharedContainer> {
        let container = PaymentSharedContainer()
        configurePaymentDefaults(container)
        configure(container)  // 应用测试专属的配置
        return .init(shared: PaymentSharedContainer.$shared, container: container)
    }
}

在测试中使用 Traits

Suite 级别的隔离。 Suite 中的每个测试都获得自己的容器:

@Suite(.paymentContainer, .orderContainer)
struct CheckoutViewModelTests {
    let mockPayment: PaymentServiceProtocolSpy
    let sut: CheckoutViewModel

    init() {
        // 在隔离的容器上注册测试专属的 mock
        let spy = PaymentServiceProtocolSpy()
        PaymentSharedContainer.shared.paymentService.register { spy }
        mockPayment = spy
        sut = CheckoutViewModel()
    }

    @Test func placeOrder_chargesOnce() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.chargeCallCount == 1)
    }

    @Test func placeOrder_passesCorrectAmount() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.lastChargedAmount == 99.99)
    }
}

这样两个测试就可以放心地并行跑了,各自都有独立的 PaymentSharedContainer 实例,互不干扰。

Test 级别的覆盖。 还可以针对某个测试单独定制配置:

@Suite(.paymentContainer)
struct DiscountTests {
    @Test(
        "高级用户享受 20% 折扣",
        .paymentContainer { $0.discountRate.register { 0.20 } }
    )
    func premiumDiscount() async {
        let vm = CheckoutViewModel()
        let price = vm.calculateFinalPrice(for: 100.0)
        #expect(price == 80.0)
    }

    @Test("普通用户没有折扣")
    func noDiscount() async {
        let vm = CheckoutViewModel()
        let price = vm.calculateFinalPrice(for: 100.0)
        #expect(price == 100.0)  // 使用 Suite 默认值(无折扣)
    }
}

整体运作方式

用上 Container Traits 之后,每个测试都拥有自己的独立容器,彼此之间完全隔离:

image.png

不再有共享状态,不再有竞态,单元测试从此稳定!

📋 完整实践清单

我们在每个模块中遵循的模式:

1. 为 SharedContainer 标记 @TaskLocal

public final class OrderSharedContainer: SharedContainer {
    @TaskLocal public static var shared = OrderSharedContainer()
    public let manager = ContainerManager()
    // ... 依赖定义
}

2. 在测试支持模块中创建 trait 辅助方法:

// OrderMocks/ContainerTraits.swift
private func configureOrderDefaults(_ container: OrderSharedContainer) {
    container.orderRepository.register { OrderRepositoryProtocolSpy() }
    container.orderValidator.register { OrderValidatorProtocolSpy() }
}

extension SuiteTrait where Self == ContainerTrait<OrderSharedContainer> {
    static var orderContainer: ContainerTrait<OrderSharedContainer> {
        let container = OrderSharedContainer()
        configureOrderDefaults(container)
        return .init(shared: OrderSharedContainer.$shared, container: container)
    }
}

3. 在测试Suite中应用 traits:

@Suite(.orderContainer, .paymentContainer)
struct OrderFlowTests {
    // 每个测试都获得隔离的容器,可安全并行执行
}

🧭 核心要点

  1. DI 的本质是为了可测试性
  2. Factory 填补了 MVVM + DI 的空白,解决了 SwiftUI @Environment 无法覆盖的场景
  3. 并行测试 + 共享容器(Shared Container) = 竞态条件(Race Condition)
  4. @TaskLocal 容器隔离从根本上解决了问题,为每个测试提供独立的容器实例
  5. Container Traits 让方案切实可行, 每个模块定义一次,在每个测试Suite中声明式地应用

不依赖 Mac 也能做 iOS 开发?跨设备开发流程

在 iOS 开发这个领域,需要一台 Mac几乎是默认前提。项目创建、代码编译、设备调试都围绕着 macOS 和 Xcode 展开。但在一些实际场景里,比如临时接手项目、在非 Mac 设备上验证功能,这个前提会变成限制条件。

前段时间在帮朋友看一个小项目时,我尝试了一种不同的方式:在没有使用传统 Mac 开发环境的情况下,完成 iOS 应用的编写和运行。

项目不复杂,但流程完整,刚好可以验证这种开发方式是否可行。


在非传统环境中创建 iOS 项目

打开快蝎 IDE 后,可以直接进入项目创建界面。界面提供几种项目类型:

  • Swift
  • Objective-C
  • Flutter

选择 Swift 项目,输入名称后点击创建,IDE 会生成项目目录。

项目结构已经包含基础代码文件和资源目录。打开入口文件就可以开始写代码,没有额外的初始化步骤。

在这个阶段没有遇到环境缺失的问题。IDE 已经准备好编译所需工具,因此项目创建后可以直接进入开发阶段。 创建项目


编写一个简单功能验证项目

为了测试开发流程,我写了一个简单页面:

  • 一个按钮
  • 一个文本区域

按钮点击后读取本地数据,并把结果显示在界面上。

在代码编辑过程中,IDE 提供了自动补全和语法提示。输入类名或方法时,会弹出可选项列表。保存文件后,IDE 会检查代码结构并标记错误位置。

编辑体验接近常见代码编辑器,键盘操作和插件支持也比较完整。


连接 iPhone 并执行应用构建

代码写好之后,需要在真实设备上运行。

将 iPhone 连接到电脑 IDE 开始执行构建流程。

构建过程中会完成:

  • 编译源代码
  • 构建应用程序
  • 安装到手机

构建完成后,手机桌面上会出现应用图标。点击打开应用,可以看到界面正常显示。

点击按钮后,文本区域成功更新为读取的数据,说明代码已经正确执行。 连接手机


修改代码并再次运行

在开发过程中,需要不断调整代码。

我在按钮点击逻辑中增加了一段处理,然后保存文件并再次点击运行按钮。IDE 会重新编译应用并安装新版本。

打开手机应用,可以看到更新后的效果。

整个过程保持一致:

修改代码 → 点击运行 → 编译应用 → 安装到设备 → 查看结果

没有出现额外导出或手动安装的步骤。


编译能力的实现方式

在这个流程中,没有使用 Mac 上的 Xcode。

快蝎 IDE 内置了一套编译工具套装。安装 IDE 时,这些工具已经配置完成。点击运行或构建时,IDE 会调用内部工具完成代码编译和应用构建。

开发者在这种环境中可以直接编写 iOS 应用,并完成编译和运行。

对于需要在非 Mac 环境下验证代码的场景,这种方式提供了一种可行路径。


多项目类型的开发测试

为了进一步验证 IDE 的能力,我创建了一个 Flutter 项目。

Flutter 页面写好后,连接设备点击运行,IDE 可以完成编译并安装应用。

随后测试了 Objective-C 项目,也可以正常运行。

在同一个开发环境中可以处理:

  • Swift 项目
  • Objective-C 项目
  • Flutter 项目

这在需要跨项目开发时会比较方便。


构建安装包用于分发

当应用开发完成之后,需要生成安装包。

在快蝎 IDE 中,可以通过构建功能生成应用安装文件。IDE 会执行编译并输出安装包。

构建日志会显示在输出面板中,如果出现编译问题,可以查看详细信息。

生成的安装文件可以用于测试或分发。 构建

对于开发者来说,这种方式可以在特定场景下使用,例如临时开发、功能验证或环境受限时。 参考链接:www.kxapp.com/blog/15

Windows 上传 IPA 到 App Store 的步骤讲解

在 Windows 环境开发 iOS 项目时,最容易卡住的一步不是写代码,而是怎么把 IPA 上传到 App Store。

很多资料默认使用 Xcode 或 Transporter,但这些工具依赖 macOS。 如果开发和发布都在 Windows 上完成,就需要把流程分开:

  • IPA 可以在任意环境生成
  • 上传只是一个独立步骤

只要 IPA 符合要求,上传完全可以在 Windows 上完成。


确认 IPA 已经具备上传条件

在考虑上传之前,需要先确认 IPA 是“可发布包”。

检查三个关键点:

1. 使用的是发布证书

打包时必须使用:

  • Distribution 证书

如果是 Development 证书:

  • IPA 可以安装
  • 但无法上传 App Store

2. 描述文件类型正确

描述文件必须是:

  • App Store 类型

可以通过解包 IPA 检查:

unzip -p app.ipa Payload/*.app/embedded.mobileprovision

确认没有设备 UDID。


3. Bundle ID 与后台一致

需要保证:

  • IPA 中 Bundle ID
  • App Store Connect 中的 Bundle ID

完全一致。


在 Windows 准备上传环境

Windows 上没有 Xcode,但可以使用以下工具:

  • AppUploader
  • iTMSTransporter(需额外配置 Java 环境)
  • Fastlane(Windows 支持有限)

在实际使用中,直接使用图形工具或命令行工具会更稳定。


使用 AppUploader 上传 IPA

在 Windows 上,AppUploader(开心上架) 可以直接完成上传操作。

具体步骤如下:


1. 打开上传界面

启动 AppUploader,进入「提交上传」页面。


2. 设置 Apple 专用密码

需要在 Apple ID 中生成 App 专用密码。

输入:

  • Apple ID
  • 专用密码

注意:这里不能使用账号登录密码。 专用密码

app专用密码


3. 选择 IPA 文件

点击选择本地 .ipa 文件。


4. 选择上传通道

工具提供多个上传通道:

  • 通道 1(旧通道)
  • 通道 2(新通道)

如果上传过程中卡住,可以切换通道重新尝试。 上传页面


5. 执行上传

点击上传按钮,等待完成。

上传成功后,Apple 会返回处理结果。 上传成功


上传后的状态确认

上传完成并不代表立即可见。

需要进入:

App Store Connect → My Apps → TestFlight

Apple 会进行一次处理(Processing)。

处理完成后:

  • 构建版本才会出现
  • 才能提交审核

如果上传成功但没有构建

遇到上传成功但没有构建时,可以检查:

检查构建号

  • CFBundleVersion 必须递增

检查签名类型

  • 是否使用 App Store 描述文件

检查 Bundle ID

  • 是否与 App Store Connect 中一致

重新上传

可以换一个上传通道再次提交。


结合其他工具的上传方式

在一些团队中,上传流程会和构建工具结合。

例如:

Fastlane + Windows

可以在 Mac 构建后,将 IPA 传到 Windows,再用 AppUploader 上传。


CI 流程

流程可以拆成:

  1. Mac 或云端构建 IPA
  2. 上传到服务器
  3. Windows 节点执行上传

这样可以减少对 macOS 的依赖。


实际流程示例

团队使用 Windows 开发时,可以这样:

  1. 使用 HBuilderX 或 CI 构建 IPA
  2. 使用 AppUploader 创建证书和描述文件
  3. 下载 .p12.mobileprovision
  4. 打包生成 IPA
  5. 在 Windows 上使用 AppUploader 上传

整个流程中:

  • 不需要 Xcode 上传
  • 不依赖 macOS 设备

参考链接:www.appuploader.net/blog/231

为什么要做一个 iOS bug 自动修复的 agent 程序

为什么要做一个 iOS Bug 自动修复的 Agent 程序

一、为什么不直接做 IDE 插件

如果目标只是”在 IDE 里更方便地写代码”,那直接基于 CodeBuddy 这类 Agent IDE 做插件,通常更快、更省成本。

但我的目标不止于此:

  • 把”修复/分析/定位/验证”沉淀成可复用能力
  • 让 Agent 不依赖某个 IDE 才能工作
  • 把人的经验流程产品化、平台化、自动化
  • 围绕 iOS 问题定位、日志分析、修复建议形成领域能力

这些目标决定了我需要一套自建的 Agent 架构,而不是一个 IDE 插件。


二、自建 Agent 架构的核心优势

1. 掌握的是”工作流”,不是”某个 IDE 的扩展点”

基于 Agent IDE 做插件,本质上是在它既有能力上”加一层”。而自建 Agent 架构,本质上是在定义:

  • 任务如何拆解
  • 上下文如何收集
  • 工具如何选择
  • 失败如何回退
  • 结果如何验证
  • 多轮推理如何收敛

这意味着我拥有的是流程控制权

这个差别很大:

  • 插件模式:在别人的操作系统上写 App
  • Agent 架构模式:在定义自己的操作系统

未来我想做的事情——自动读取崩溃日志、自动定位可疑代码、自动生成 patch、自动跑校验、自动输出修复报告、自动接入 CI / 工单 / IM / 代码平台——自建 Agent 架构会比 IDE 插件自然得多。

2. 沉淀的是”领域智能”,不是通用编程助手能力

CodeBuddy 这类 Agent IDE 的强项是通用编码协作:补全、重构、对话式改代码、搜索代码、生成测试。

而我的系统围绕 iOS Bug Auto Fix 在做,重点是:

  • 崩溃堆栈理解
  • 符号/模块/调用链关联
  • Objective-C / Swift / Pod 生态理解
  • 特定业务代码结构的定位
  • 历史问题模式复用
  • 修复策略模板化

这些能力,通用 Agent IDE 不会天然替我做好。我做 Agent 架构的真正价值不是”我也能调用 LLM 了”,而是:把特定领域的高价值决策流程封装成了 Agent。 这会形成自己的护城河。

3. 能做”非交互式自动化”,而不只是”人在 IDE 里点来点去”

IDE 插件天然偏向人在本地、打开工程、交互式提问、临场辅助。而自建 Agent 更容易扩展到命令行、批处理、服务化、CI/CD、机器人触发、定时任务、工单驱动修复。

未来完全可以做成这种链路:

1
崩溃日志/工单 → 问题归类 Agent → 代码定位 Agent → 修复策略 Agent → 生成 Patch → 验证 Agent → 提交 PR / 输出报告

这类能力,不是 IDE 插件的主战场。

4. 更强的”可解释性”和”可观测性”

自建 Agent 架构时,可以记录每一步:

  • 为什么选这个工具
  • 为什么判定这个文件相关
  • 哪一步检索到了关键证据
  • 哪次修复失败了
  • 哪种策略成功率最高
  • 每种 Bug 类型平均耗时多久

这带来几个重要价值:便于调试 Agent、便于持续优化 prompt / 工具策略、便于做质量评估、便于做企业内部合规审计。而很多 Agent IDE 内部流程只能”感觉它这么做了”,但很难完整掌控它的决策细节。

5. 模型、工具、供应商解耦

基于某个 Agent IDE 插件体系开发,通常会受到模型支持、上下文拼装方式、工具协议、权限边界、升级兼容性等限制。而自建 Agent 架构,可以自由决定用哪个模型、不同子任务切哪个模型、怎么做路由、缓存、检索、工具编排、降级和兜底。获得的是架构主导权,而不是平台适配权

6. 能把”经验”复用到 IDE 之外

如果只是写成 IDE 插件,很多价值会被锁死在 IDE 内。但做成独立 Agent 能力,以后这些东西都能复用到 Web 界面、命令行、VSCode / JetBrains / 自研 IDE、Slack / 飞书 / 企业微信机器人、服务端 API、测试平台、发布平台、缺陷平台。投入更像是在建设能力中台,而不是做一个单点入口。

7. 更适合做多 Agent 分工

当任务复杂到需要角色分工时,自建架构优势更明显。可以拆成:

Agent 职责
Planner Agent 任务拆解
Retriever Agent 找代码和上下文
Diagnoser Agent 判断根因
Patch Agent 生成修复代码
Verifier Agent 运行验证
Reporter Agent 输出报告

IDE 插件当然也能”伪多 Agent”,但一般都会受限于宿主产品的交互模型。自建则可以真正把分工、状态、上下文边界、交接协议做清楚。


三、这条路的代价

1. 在重复造很多”基础设施轮子”

包括上下文管理、工具调用协议、提示词编排、重试/超时/回退、文件读写安全、结果验证、token 成本控制、观测和日志、会话状态管理。这些在成熟 Agent IDE 里很多已经做好了。短期看,肯定更慢、更贵、更累。

2. 需要自己为”效果稳定性”负责

自建后,要自己解决:为什么这次检索不到、为什么上下文污染了、为什么选错工具、为什么 patch 不可执行、为什么修复建议不稳定、为什么不同仓库表现差异大。获得自由的同时,也接管了复杂性。

3. 如果场景主要是”本地编码辅助”,ROI 未必更高

如果用户核心诉求只是在 IDE 里聊天、改改代码、顺手做点搜索、生成一些 patch,那自建 Agent 架构带来的收益可能并不明显。可能出现架构先进了,但用户体感未必更强的情况。


四、与通用 Agent IDE 的差异化定位

一句话定位

不是”更会写代码的 IDE 助手”,而是”面向 iOS Bug 定位、诊断、修复与验证的专用智能执行系统”。

差异对比表

维度 我的 Agent 架构 CodeBuddy / Cursor / Copilot Workspace
核心目标 解决特定领域问题(iOS bug 定位、分析、修复、验证) 提升通用编码效率
核心对象 问题处理流程 代码编辑过程
工作单元 一次完整的缺陷处理链路 一次对话、一段代码修改
触发方式 日志、崩溃堆栈、工单、CI、命令行、服务调用 IDE 内交互、选中代码、对话输入
能力重点 诊断、定位、决策、修复策略、验证闭环 补全、解释、生成、搜索、重构
领域知识密度 很高,内置 iOS/Crash/工程结构知识 偏通用,领域知识较浅
自动化深度 半自动甚至全自动链路 多数以人机协作为主
可观测性 记录每一步证据、推理、工具调用、成功率 通常黑盒程度更高
可扩展性 可接日志系统、工单系统、测试系统、CI、PR 流程 主要受限于 IDE 插件边界
平台依赖 独立能力层,不依赖单一 IDE 依赖宿主 IDE/平台生态
长期资产 沉淀为组织级问题处理能力 沉淀为某 IDE 内的使用体验
护城河来源 领域流程、知识、验证闭环、历史反馈 产品体验、模型集成、编辑器生态

五、哪些是真壁垒,哪些是在重复造轮子

判断标准

这个模块如果明天被一个成熟框架替换掉,我的核心价值会不会下降?

  • 不会明显下降:大概率是基础设施
  • 会明显下降:可能是壁垒层

真正值得重点投入的壁垒层

1. iOS 问题理解与归因知识

崩溃堆栈解析、符号/模块/类/调用链映射、OC/Swift 混编上下文理解、生命周期/线程/内存/KVO/通知/Block/主线程 UI 更新等问题模式、常见崩溃类型到修复策略的映射。这类能力沉淀好了,不是通用 IDE 随便能替代的。强壁垒。

2. 问题处理 SOP

稳定的处理链路:读取错误信号 → 归类问题类型 → 缩小嫌疑范围 → 关联代码上下文 → 选择修复策略 → 生成 patch → 执行验证 → 输出结论与风险。这个流程代表了团队如何排查问题、如何做决策分层、如何降低误修概率。强壁垒。

3. 修复策略库 / 模式库

沉淀 CrashType → RootCauseCandidates → VerificationSteps → PatchTemplate → RiskHints 这种结构。覆盖数组越界、空指针、野指针、线程竞争、主线程违规调用、通知/KVO 生命周期遗漏、容器并发修改、异步回调释放时序问题等。每一类问题对应典型特征、定位信号、修复范式、验证点。可复利的核心资产。

4. 验证闭环

改完代码后做静态检查、跑定向测试、检查编译影响范围、输出风险说明、对修复结果做置信度评估。从”助手”进入”执行系统”。关键护城河。

5. 历史案例反馈系统

积累哪类问题最常见、哪类修复策略成功率最高、哪些模块最容易出问题、哪种上下文组合最容易定位成功、哪类 patch 最容易被 reject。系统会越来越像”会学习的故障处理平台”。长期壁垒。

有价值但不要过度自研的中间层

模块 建议
任务拆解器 / Planner 可以做,但必须领域化,否则容易沦为通用壳子
多 Agent 分工 多 Agent 本身不是壁垒,领域化分工才是壁垒
上下文组装系统 保留策略,少造基础设施

大概率属于”重复造轮子”的部分

模块 建议
通用聊天壳 / UI 壳 够用就行
通用代码读写工具封装 稳定优先,不要过度精雕
通用 ReAct / Agent Loop 不要把”有 loop”误认为”有壁垒”
通用记忆系统 通用 memory 尽量轻,重点做 case memory
通用 Prompt 编排器 prompt 系统化可以有,但别把它当主产品

六、系统架构收敛

我把整个系统收敛成三层:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────┐
│ 入口层(做薄) │
│ IDE / CLI / CI工单 / 服务API │
├─────────────────────────────────────────┤
│ 领域决策层(持续加厚) │
│ iOS缺陷分类 / 上下文选择策略 / 根因分析 │
│ 修复策略选择 / 风险评估 │
├─────────────────────────────────────────┤
│ 执行与验证层(够稳就行) │
│ 代码检索编辑 / Patch生成 / 构建测试 │
│ 结果验证 / 报告输出 │
└─────────────────────────────────────────┘
  • 入口层:不要重投太多
  • 执行与验证层:够稳就行
  • 领域决策层:最该持续加厚的地方

七、最容易出现的风险

把大量精力花在让 Agent 看起来更像 Agent,而不是让它更会解决 iOS 问题。

典型表现:角色越来越多、prompt 越来越复杂、tool 越来越多、框架越来越完整,但定位成功率没上升、修复成功率没上升、验证能力没增强、真实用户价值不明显。

判断功能优先级时,统一用三个指标:

  1. 定位准确率是否提升
  2. 修复成功率是否提升
  3. 端到端处理时间是否下降

只要不能提升这三项之一,就谨慎做。


八、投入优先级

优先级 方向
第一优先级 iOS bug 分类 → 根因 → 修复策略 → 验证 做成稳定闭环
第二优先级 把历史案例沉淀成可复用知识
第三优先级 把入口做薄,支持 IDE / CLI / CI 复用
第四优先级 尽量复用通用 Agent 基础设施,不要在壳层卷复杂度

九、结论

我不是在做另一个通用 AI 编码助手,而是在做 iOS 缺陷处理的领域执行系统。

真正的壁垒不在 Agent 外壳,而在领域知识、决策流程、修复策略和验证闭环。

通用的对话、工具调用、Planner、Memory 更多是基础设施,应尽量复用而非重造。

后续投入应聚焦提升定位准确率、修复成功率和端到端效率,而不是继续增加通用 Agent 复杂度。

分水岭在于:我的系统是在”帮助程序员在 IDE 里更快操作”,还是在”替程序员执行一整套 iOS 问题处理流程”。答案是后者,所以自建 Agent 架构是正确的选择。

iOS复习必看!weak关键字底层原理(Deepseek&豆包)回答整理

一 weak引用

当一个对象被 weak 引用时,Runtime 需要把这个 weak 指针记录到一个全局的数据结构中,以便将来对象销毁时能够找到它并置为 nil。整个过程涉及编译器、Runtime 函数和复杂的数据结构。下面我们详细拆解这个过程。


1. 编译器对 weak 变量的处理

假设我们有如下代码:

NSObject *obj0 = [NSObject new];
__weak id obj1 = obj0;

在 ARC 环境下,编译器会将 __weak 变量的赋值操作编译为对 Runtime 函数的调用。具体来说:

  • 对于 __weak 变量的初始化(首次赋值),编译器会调用 objc_initWeak
  • 对于 __weak 变量的重赋值(即已经存在一个 weak 变量,再将其指向另一个对象),编译器会调用 objc_storeWeak

这两者最终都会调用核心函数 objc_storeWeak(id *location, id newObj),其中:

  • location 是 weak 指针的地址(例如 &obj1)。
  • newObj 是要指向的对象(即 obj0)。

所以,我们以 objc_storeWeak 为主线,讲解存储过程。


2. 进入 Runtime:objc_storeWeak 的主要流程

objc_storeWeak 的核心逻辑可以简化为以下步骤:

  1. 获取要指向的对象 newObj
  2. 如果有旧值(即这个 weak 指针之前已经指向过某个对象),需要先从 weak 表中移除该指针。
  3. 如果 newObj 不为 nil,则将当前 weak 指针注册到 newObj 的 weak 表中。
  4. 返回 newObj

下面重点说明注册过程,即如何将 weak 指针存储到表中。


3. 存储 weak 指针的核心步骤

3.1 获取 SideTable

Runtime 维护一个全局的 SideTables 哈希表(实际上是一个 StripedMap),它以对象的内存地址为 key,映射到一个 SideTable 结构体。这个 SideTable 包含了引用计数和 weak 表等。

为了操作 obj0 的 weak 表,需要先通过 obj0 的地址找到对应的 SideTable

SideTable *table = &SideTables[obj0];

这里使用 obj0 的地址进行哈希计算,得到一个索引,从而取出对应的 SideTable

为什么需要 SideTable 主要为了锁分离,提高并发性能。不同的对象可能映射到不同的 SideTable,操作不同对象的 weak 引用时可以并行执行。

3.2 获取 weak_table_t

每个 SideTable 内部包含一个 weak_table_t 结构,它是一个独立的哈希表,专门管理所有指向该 SideTable 所管辖对象的 weak 引用。

weak_table_t &weak_table = table->weak_table;

weak_table_t 的定义大致如下:

struct weak_table_t {
    weak_entry_t *weak_entries;   // 哈希数组,元素是 weak_entry_t
    size_t    num_entries;        // 当前条目数
    uintptr_t mask;               // 掩码,用于哈希计算
    uintptr_t max_hash_displacement; // 最大偏移量
};

3.3 在 weak_table_t 中查找或创建 weak_entry_t

接下来,使用 对象地址 obj0 作为 key,在 weak_table_t 中查找对应的 weak_entry_t

weak_entry_t 是一个容器,它负责存储所有指向同一个对象的 weak 指针地址(即 location)。

  • 如果找到已有的 weak_entry_t,说明已经有一些 weak 指针指向 obj0,那么直接将当前 weak 指针地址添加到这个 weak_entry_t 中。
  • 如果没找到,说明这是第一个指向 obj0 的 weak 指针,需要创建一个新的 weak_entry_t,并将它插入到 weak_table_t 中。

3.4 weak_entry_t 的结构:如何存储多个 weak 指针

weak_entry_t 的设计目标是高效地存储多个指向同一个对象的 weak 指针。它的简化结构如下:

struct weak_entry_t {
    DisguisedPtr<objc_object> referent;  // 指向对象,即 obj0
    union {
        struct {
            weak_referrer_t *referrers;   // 动态数组
            uintptr_t        out_of_line : 1; // 标志位,表示使用动态数组
            // ... 其他字段
        };
        struct {
            weak_referrer_t  inline_referrers[4]; // 静态数组,容量为4
        };
    };
};

这里有一个优化:当一个对象只有少量(<=4)weak 引用时,使用静态数组 inline_referrers,避免额外堆分配。当超过4个时,会转换为动态数组 referrers,可以动态扩容。

weak_referrer_t 本质上就是 id *,即 weak 指针的地址(例如 &obj1)。存储的是指针的地址,这样当对象销毁时,Runtime 可以找到这个地址并将其内容置为 nil

3.5 添加 weak 指针到 weak_entry_t

在找到或创建了 weak_entry_t 之后,调用 weak_entry_append 或类似函数,将当前 weak 指针的地址(location)添加到 weak_entry_t 的数组中。

添加时,会根据当前是静态数组还是动态数组,采取不同的插入逻辑。如果是静态数组且未满,直接放入;如果静态数组已满(即已有4个),则触发扩容,将静态数组内容迁移到新分配的动态数组,再加入新元素。

3.6 线程安全

整个过程中,对 SideTable 的操作都在其持有的自旋锁(spinlock_t)保护下进行,确保多个线程同时操作 weak 引用时的安全性。


4. 一个完整的例子

让我们用代码模拟这个过程:

NSObject *obj0 = [NSObject new];
__weak id obj1 = obj0;
__weak id obj2 = obj0;
  1. 编译器为 obj1 的赋值生成类似 objc_storeWeak(&obj1, obj0) 的代码。
  2. 进入 objc_storeWeak
    • 获取 obj0SideTable,加锁。
    • weak_table_t 中以 obj0 为 key 查找 weak_entry_t,未找到,创建一个新的 weak_entry_t,其中 referent 指向 obj0,静态数组 inline_referrers 为空。
    • &obj1 添加到 weak_entry_t 的数组中。
    • weak_entry_t 插入 weak_table_t
    • 解锁。
  3. 编译器为 obj2 的赋值生成 objc_storeWeak(&obj2, obj0)
  4. 再次进入 objc_storeWeak
    • 获取 obj0SideTable,加锁。
    • weak_table_t 中以 obj0 为 key 查找 weak_entry_t,这次找到了。
    • &obj2 添加到该 weak_entry_t 的数组中(此时数组内已有 &obj1&obj2)。
    • 解锁。

此时,obj0 的 weak 表中就记录了这两个 weak 指针的地址。


5. 为什么存储的是 weak 指针的地址,而不是指针本身?

当对象 obj0 销毁时,Runtime 会遍历 weak_entry_t 中的数组,对于数组中的每一项(即每个 weak_referrer_t,它是一个 id * 类型),执行 *referrer = nil,将 weak 变量本身置为 nil。如果存储的是指针的值(即 obj1 本身),那么 Runtime 只能知道这个 weak 指针指向了 obj0,却无法修改这个 weak 变量(因为不知道它的内存位置)。存储地址使得 Runtime 能够直接修改该变量的内容,从而实现自动置 nil


6. 小结

对象被 weak 引用后,存储到 weak 表中的过程可以概括为:

  1. 编译器将 weak 赋值转换为 objc_storeWeak 调用。
  2. Runtime 通过对象地址找到对应的 SideTable(分离锁)。
  3. SideTableweak_table_t 中以对象地址为 key 查找或创建 weak_entry_t
  4. weak_entry_t 是一个容器(静态或动态数组),存储所有指向该对象的 weak 指针的地址。
  5. 将当前 weak 指针地址添加到该容器中。

这种设计保证了:

  • 高效查找:通过两层哈希表,快速定位到对象对应的 weak 容器。
  • 并发友好:通过 SideTable 分离锁,不同对象的 weak 操作可以并行。
  • 内存优化:使用小对象优化(inline array),减少大多数情况下的堆分配。
  • 自动置 nil:存储指针地址,使得对象销毁时可以直接修改 weak 变量的内容。

二 释放weak引用

当一个对象的引用计数降为 0 时,系统会回收该对象。在这个过程中,Runtime 必须完成两件与 weak 相关的重要工作:

  1. 将所有指向该对象的 weak 指针置为 nil,防止产生悬垂指针。
  2. 清理该对象在 weak 表中对应的条目(weak_entry_t),避免表膨胀。

下面我们从“引用计数归零”开始,逐步剖析整个流程,重点讲解 weak 的处理机制。


1. 引用计数归零的入口

在 ARC 下,当对象最后一次被释放时,会调用 objc_releaseobjc_release 内部会执行:

if (--newRetainCount == 0) {
    // 引用计数变为 0,准备销毁
    ((id)obj)->dealloc();
}

因此,引用计数归零的最终结果就是调用对象的 dealloc 方法。


2. dealloc 的核心调用链

对象的 dealloc 方法(通常由编译器自动生成)最终会调用 Runtime 函数 objc_destructInstance,该函数负责真正的析构工作。objc_destructInstance 的执行顺序大致如下:

  1. 如果有 C++ 析构函数,先调用。
  2. 如果有关联对象,则移除关联对象。
  3. 调用 clearDeallocating,这是处理 weak 和引用计数表的关键步骤。

3. clearDeallocating 的作用

objc_object::clearDeallocating 函数的简化逻辑如下:

void objc_object::clearDeallocating() {
    // 获取 SideTable
    SideTable *table = SideTable::tableForPointer(this);
    
    // 加锁,防止并发操作
    table->lock();
    
    // 处理 weak 引用:将指向当前对象的所有 weak 指针置 nil,并移除 weak_entry_t
    weak_clear_no_lock(table, this);
    
    // 处理引用计数表:清除该对象在 RefcountMap 中的条目
    table->refcnts.erase(this);
    
    table->unlock();
}

可见,weak 的处理先于引用计数表的清理。这是因为 weak_clear_no_lock 需要读取 weak_table_t,而该表在对象销毁后便不再需要,所以先处理 weak 再清理 refcnts 是合理的。


4. weak_clear_no_lock 详解:将 weak 指针置 nil 并清理 entry

weak_clear_no_lock 是真正执行 weak 清理的函数。它的实现思路是:

  1. 根据对象地址(this)找到对应的 weak_table_t
  2. weak_table_t 中查找该对象对应的 weak_entry_t
  3. 如果找到了,就遍历 weak_entry_t 中的所有 weak 指针地址,将每个指针的内容置为 nil
  4. weak_table_t 中移除这个 weak_entry_t(释放其占用的内存)。

下面我们拆解每一步。

4.1 获取 SideTable 和 weak_table_t

SideTable *table = &SideTables[this];
weak_table_t *weak_table = &table->weak_table;

这里 this 就是待释放对象的地址。通过对象地址取模得到对应的 SideTable(分离锁),然后取出其中的 weak_table_t

4.2 在 weak_table_t 中查找 weak_entry_t

以对象地址为 key,在 weak_table_t 的哈希表(weak_entries 数组)中查找对应的 weak_entry_t。如果找不到,说明没有 weak 指针指向该对象,直接返回。否则,进入下一步。

4.3 遍历 weak_entry_t,将 weak 指针置 nil

weak_entry_t 内部存储着所有指向该对象的 weak 指针的地址(即 weak_referrer_t,类型是 id *)。weak_clear_no_lock 会遍历这个容器(无论是静态数组还是动态数组),对每个 referrer 执行:

*referrer = nil;

这一步直接修改了 weak 变量的内容,使其变为 nil。由于 weak 变量本身是 __weak 修饰的,它们的存储位置可能是栈上的局部变量,也可能是堆上的实例变量,但都是有效的内存地址,因此可以直接赋值。

4.4 从 weak_table_t 中移除 weak_entry_t

遍历并置 nil 完成后,weak_entry_t 已经没有任何作用了。需要将其从 weak_table_t 的哈希表中删除,并释放 weak_entry_t 占用的内存(如果是动态数组,也要释放)。

这一步涉及哈希表的删除操作,具体会:

  • weak_entries 数组中对应的槽位标记为空。
  • 减少 weak_table_tnum_entries 计数。
  • 如果 weak_entry_t 使用了动态数组(即 out_of_line 标志为 1),则释放 referrers 指向的堆内存。

5. 引用计数表的清理

weak_clear_no_lock 之后,clearDeallocating 还会调用:

table->refcnts.erase(this);

refcnts 是一个 DenseMap(或类似结构),存储了该对象的额外引用计数信息(例如 weak 引用计数、deallocating 标志等)。由于对象即将被销毁,这些信息也不再需要,因此从表中删除。


6. 为什么 weak 处理必须加锁?

在整个过程中,SideTable 的锁一直持有,直到 clearDeallocating 结束。这是因为可能有多个线程同时操作同一个对象的 weak 引用(例如一个线程正在释放对象,另一个线程正在对这个对象取 weak 值),锁保证了操作的原子性,避免出现数据竞争。


7. 完整流程图示

对象引用计数 → 0
    ↓
调用 dealloc
    ↓
objc_destructInstance
    ↓
clearDeallocating
    ↓
获取 SideTable,加锁
    ↓
weak_clear_no_lock
    ├── 根据对象地址查找 weak_table_t
    ├── 在 weak_entries 中找到 weak_entry_t
    ├── 遍历 referrers 数组
    │     └── 将每个 weak 指针内容置为 nil
    └── 从 weak_entries 中移除 weak_entry_t,释放内存
    ↓
从 refcnts 中擦除该对象的条目
    ↓
解锁 SideTable
    ↓
对象内存被释放(free

8. 总结

当对象引用计数归零时,Runtime 通过以下步骤处理 weak

  1. 定位 SideTable:通过对象地址找到对应的 SideTable,加锁保证线程安全。
  2. 查找 weak_entry_t:在 weak_table_t 的哈希表中找到该对象对应的 weak_entry_t
  3. 遍历并置 nil:遍历 weak_entry_t 中存储的所有 weak 指针地址,将每个指针的值设为 nil。由于存储的是指针的地址,所以可以直接修改 weak 变量的内容。
  4. 清理 entry:从 weak_table_t 中删除该 weak_entry_t,释放相关内存。
  5. 清理引用计数表:从 refcnts 中删除该对象的条目。
  6. 解锁:完成 weak 和引用计数表的清理后,解锁 SideTable

这一过程确保了在对象被彻底销毁前,所有指向它的 weak 指针都被安全地置为 nil,从而避免程序出现野指针崩溃。同时,通过 SideTable 和锁的分离设计,保证了多线程环境下的性能和正确性。

❌