普通视图

发现新文章,点击刷新页面。
昨天以前首页

SwiftUI 如何实现 Infinite Scroll?

作者 RickeyBoy
2026年3月28日 00:56

欢迎点个 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 管状态 + 编排

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

作者 RickeyBoy
2026年3月24日 20:38

欢迎给个 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中声明式地应用

独立 App 配置阿里云 CDN 记录

作者 RickeyBoy
2026年3月23日 18:16

独立 App 图片加速:从 OSS 直连到 CDN 的迁移记录。

⚠️ 背景:为什么要做这件事

我的独立 App 里有大量色组图片,存储在阿里云 OSS(深圳节点)。之前图片 URL 是这样的:

https://your-bucket.oss-cn-shenzhen.aliyuncs.com/color-group/example-image.jpg

一直没出什么问题,但最近发现了一个规律:国内用户加载图片很流畅,但国外用户明显慢很多

原因其实很简单——所有用户,不管在哪里,都要跑去深圳的 OSS 拿图片。国内用户到深圳延迟低,自然快;国外用户跨洋传输,当然慢。

所以这次做了一件事:给图片加上 CDN 加速


📚 相关概念

OSS 是什么

OSS(Object Storage Service,对象存储服务)是阿里云提供的云存储服务,可以理解为一个"云端硬盘"——你把文件(图片、视频、文档等)上传上去,它给你一个 URL,任何人通过这个 URL 就能下载或查看文件。

和传统服务器上的文件存储相比,OSS 的优势在于:

  • 容量无限:不需要提前规划磁盘大小,用多少算多少
  • 高可靠:数据自动多副本存储,不用担心硬盘坏了丢数据
  • 按量计费:存储费用和请求次数都是用多少付多少

我的独立 App 中大量图片就存在阿里云 OSS 的深圳节点上。每张图片有一个类似这样的访问地址:

https://your-bucket.oss-cn-shenzhen.aliyuncs.com/color-group/example-image.jpg

其中 your-bucket 是存储桶名称,oss-cn-shenzhen 表示深圳节点。

如何配置阿里云 OSS 可以参考:独立 App 使用阿里云 OSS 的基础配置

CDN 是什么

CDN(Content Delivery Network,内容分发网络)是一个分布在全球各地的缓存节点网络。核心思路是:

把内容缓存到离用户最近的节点,让用户从"最近的服务器"拿数据,而不是每次都跑去源站。

加了 CDN 之后的访问路径变成这样:

用户(UK)→ CDN 节点(英国)→ 命中缓存,直接返回
                             ↓ 首次未命中(cache miss)
                           OSS(深圳)→ 回源取图 → 缓存到节点

第一个访问某张图的用户还是要等回源,但之后同一地区的所有用户都走缓存,速度质的飞跃。缓存是按文件、按节点存储的,跟设备无关——A 设备触发缓存后,B 设备访问同一张图也能命中。

OSS 和 CDN 的关系

OSS 是存储,CDN 是分发。两者是互补关系:

rickey_381.png

  • OSS 负责保存原始文件,是"唯一真实来源"(source of truth)
  • CDN 负责把文件高效地分发给全球用户,OSS 作为 CDN 的回源站

这意味着文件只需要存一份,CDN 自动负责缓存和分发。原来的 OSS URL 永远有效,CDN 只是提供了一条更快的访问路径。

CNAME 是什么

CNAME(Canonical Name)是 DNS 的一种记录类型,作用是把一个域名指向另一个域名

比如这次配置的:

img.example.comimg.example.com.w.cdngslb.com

用户访问 img.example.com 时,DNS 会告诉浏览器"去找 img.example.com.w.cdngslb.com",后者是阿里云 CDN 的接入域名,CDN 再根据用户位置选择最近的节点返回内容。整个过程对用户完全透明。

SSL 证书是什么

HTTPS 需要 SSL 证书来证明"你确实是 img.example.com 的拥有者",同时加密传输内容。

iOS App 默认强制要求 HTTPS(ATS,App Transport Security),所以这一步不能跳过。

证书是跟域名绑定的——之前 OSS 原始域名的证书是阿里云帮你配好的,换了自己的域名之后,证书需要自己申请。


🔀 请求路径对比

加 CDN 之前,所有用户无论在哪里,都直接访问深圳 OSS:

rickey_382.png

加 CDN 之后 · 首次访问(cache miss),请求经 DNS CNAME 解析到最近节点,节点没有缓存则回源 OSS,取回后缓存到节点:

rickey_383.png

加 CDN 之后 · 再次访问(cache hit),节点已有缓存,直接返回,不再访问 OSS:

rickey_384.png

同一地区只有第一次访问某张图时才会回源,之后都命中缓存直接返回。


📊 实测数据

在 UK 网络下对比同一张图片(约 120 KB):

访问方式 耗时 说明
OSS 直连(深圳) 3.74s 每次都跨洋回源
CDN 首次访问(cache miss) 2.68s 需要先回源,但节点在欧洲更近
CDN 命中缓存(cache hit) 0.14s 直接从英国节点返回

命中缓存后快了将近 27 倍。对 App 用户来说,首次打开色组时可能稍慢(第一个用户触发缓存),之后所有用户都是极速加载。


⚙️ 配置过程

一、申请 SSL 证书(Let's Encrypt)

没有选择购买阿里云的 SSL 证书(免费版只有 90 天,且需要手动续期;付费版自动续期要 270 元/次),而是用 Let's Encrypt + acme.sh

Let's Encrypt 是由非营利组织 ISRG 运营的免费证书颁发机构(CA),资金来自 Mozilla、Google、Meta 等企业赞助。它的目标是消除 HTTPS 的经济门槛——2015 年上线后,直接推动全网 HTTPS 覆盖率从约 40% 升到 80% 以上。

acme.sh 是一个纯 Shell 脚本实现的 ACME 协议客户端,用来自动向 Let's Encrypt 申请和续期证书。支持通过 DNS API 验证域名归属,不需要在服务器上部署 web 服务。

这套方案的优势:

  • 完全免费
  • 有效期 90 天,acme.sh 会自动添加 cron 任务每天检查并续期
  • 全球主流设备均信任

安装 acme.sh:

curl https://get.acme.sh | sh -s email=your@email.com
source ~/.zshrc  # 或重启终端

用阿里云 DNS API 自动完成域名验证并申请证书:

export Ali_Key="your_access_key_id"
export Ali_Secret="your_access_key_secret"

acme.sh --issue --dns dns_ali -d img.example.com

acme.sh 在验证域名归属时,会自动调用阿里云 DNS API 临时添加一条 TXT 记录,验证完成后自动删除,全程无需手动操作。

证书文件生成在 ~/.acme.sh/img.example.com_ecc/ 目录下:

img.example.com.cer    # 域名证书
img.example.com.key    # 私钥
fullchain.cer          # 完整证书链(上传到 CDN 用这个)

二、在阿里云 CDN 控制台添加加速域名

前提条件:你需要有一个自己的域名(如 example.com)。如果加速区域包含中国大陆,域名必须完成 ICP 备案,否则阿里云 CDN 不允许接入。仅加速海外则不需要备案。(但是都用阿里云了肯定是支持国内对不对...)

进入 CDN 控制台 → 域名管理 → 添加域名,配置如下:

配置项
加速域名 img.example.com
源站类型 OSS 域名
源站地址 your-bucket.oss-cn-shenzhen.aliyuncs.com
加速区域 全球
业务类型 图片小文件

添加完成后,在 HTTPS 配置 里上传刚才申请的证书(fullchain.cer.key 文件内容)。

另外配置了月流量封顶(100 GB),防止被恶意刷量导致费用失控。

三、配置 DNS CNAME

CDN 控制台会生成一个 CNAME 接入地址,格式类似:

img.example.com.w.cdngslb.com

在阿里云云解析 DNS 添加一条记录:

主机记录 类型 记录值
img CNAME img.example.com.w.cdngslb.com

DNS 记录生效通常需要 10~30 分钟(TTL 决定)。

四、验证是否生效

# 验证 HTTP
curl -I "http://img.example.com/path/to/image.jpg"
# 期望:HTTP/1.1 200 OK

# 验证 HTTPS
curl -I "https://img.example.com/path/to/image.jpg"
# 期望:HTTP/1.1 200 OK

# 验证缓存命中(连续跑两次,第二次应该出现 X-Cache: HIT)
curl -o /dev/null -s -w "time: %{time_total}s\n" "https://img.example.com/path/to/image.jpg"

响应头里的 X-Cache: HIT TCP_MEM_HIT 说明命中了 CDN 缓存。

五、更新代码中的 URL

代码里维护了一份图片 URL 映射表,把所有 URL 的域名部分批量替换成 CDN 域名:

// 替换前
https://your-bucket.oss-cn-shenzhen.aliyuncs.com/color-group/image.jpg

// 替换后
https://img.example.com/color-group/image.jpg

路径部分完全不变,只是换了域名。用 sed 一条命令批量完成,不需要手动逐条修改。


几个值得注意的点

旧版本 App 完全不受影响:OSS 上的文件和原始 URL 永久有效,不会失效。新版本走 CDN,老版本继续走 OSS 直连,两套 URL 并存,互不干扰。

证书续期:acme.sh 安装时会自动注册 cron 任务,每天自动检查并续期。唯一需要手动操作的是:续期后把新证书重新上传到阿里云 CDN 控制台(每 90 天一次,5 分钟的事)。

DNS 免费版够用:阿里云 DNS 免费版没有海外节点,可能有人担心国外用户的域名解析慢。实际上 DNS 解析只在第一次建立连接时发生,耗时是毫秒级,对图片加载速度的影响可以忽略不计,不需要升级付费版。

iOS 图片取色完全指南:从像素格式到工程实践

作者 RickeyBoy
2026年3月7日 00:53

本文从一个真实的取色 Bug 出发,系统梳理 iOS 图片取色所需的基础知识,包括色彩模型、色彩空间、位深度、像素格式、图片文件格式,以及业界主流的取色方案对比。

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

起因:一个 Display P3 引发的取色 Bug

在开发一个取色功能时,遇到了一个诡异的问题:用户用 iPhone 拍照后进行取色,得到的颜色跟肉眼看到的完全不一样。

问题代码:

guard let pixelData = self.cgImage?.dataProvider?.data else { return nil }
let data: UnsafePointer<UInt8> = CFDataGetBytePtr(pixelData)
let pixelInfo: Int = (pixelWidth * Int(point.y * scale) + Int(point.x * scale)) * 4

let r = CGFloat(data[pixelInfo]) / 255.0
let g = CGFloat(data[pixelInfo+1]) / 255.0
let b = CGFloat(data[pixelInfo+2]) / 255.0

这段代码假设所有图片都是 8-bit RGBA 格式。但现在 iPhone 拍摄的照片使用 Display P3 广色域,部分图片的像素数据是 16-bit per channel。当遇到这类图片时:

  1. 偏移量算错 — 每像素实际占 8 字节(4 通道 × 2 字节),但代码按 × 4 计算
  2. 数值解析错 — 16-bit 值域是 0~65535,用 UInt8 读只取了低 8 位,再除以 255,得到的颜色完全不对

要理解并修复这个问题,需要掌握一系列图片和色彩的基础知识。


一、色彩模型

色彩模型定义如何用数字描述颜色,但不定义具体哪个数字对应哪个物理颜色(那是色彩空间的事)。

1.1 RGB

RGB 是加色模型,通过混合红、绿、蓝三种光来生成颜色。

分量 归一化范围 8-bit 范围 说明
R (红) 0.0 ~ 1.0 0 ~ 255 红光强度
G (绿) 0.0 ~ 1.0 0 ~ 255 绿光强度
B (蓝) 0.0 ~ 1.0 0 ~ 255 蓝光强度
  • (0, 0, 0) = 黑色(无光)
  • (255, 255, 255) = 白色(全光)

RGB 直接对应屏幕像素的发光方式(每个像素由红、绿、蓝子像素组成),是像素存储和取色的底层数据格式。

局限性:RGB 不是感知均匀的。从 (100, 0, 0)(110, 0, 0) 的视觉差异与 (200, 0, 0)(210, 0, 0) 的视觉差异并不相同。

1.2 HSB/HSV

HSB(也叫 HSV)是 RGB 的柱坐标变换,更符合人类对颜色的直觉理解。

分量 范围 说明
H (色相 Hue) 0° ~ 360° 色轮位置。0°=红,120°=绿,240°=蓝
S (饱和度 Saturation) 0% ~ 100% 颜色纯度。0%=灰色,100%=最纯
B (明度 Brightness) 0% ~ 100% 0%=黑色,100%=最亮

HSB vs HSL:两者不同。HSB 中 B=100%, S=0% 是白色;HSL 中 L=100% 不管 H 和 S 都是白色。设计工具(Photoshop、Figma、Sketch)普遍使用 HSB,CSS/Web 开发常用 HSL。

在 iOS 中,UIColor 提供了 getHue(_:saturation:brightness:alpha:) 方法进行 RGB 和 HSB 的互转。HSB 通常用来构建用户可见的取色器 UI。

1.3 CIELAB

CIELAB(Lab*)是国际照明委员会(CIE)在 1976 年定义的感知均匀色彩模型,与设备无关。

分量 范围 说明
L* 0 ~ 100 明度。0=黑,100=白
a* 约 -128 ~ +127 绿色(负)↔ 红色(正)
b* 约 -128 ~ +127 蓝色(负)↔ 黄色(正)

CIELAB 的核心价值:给定的数值变化(ΔE)在整个色彩空间内对应近似相等的视觉变化。当你需要判断"取到的颜色跟目标色差多少"时,Lab 空间的 ΔE 计算比 RGB 欧氏距离有意义得多。

小结

模型 最佳用途
RGB 像素存储、渲染、取色底层数据
HSB 取色器 UI、基于色相的颜色操作
Lab 颜色差异度量、感知均匀的颜色比较

二、色彩空间

色彩空间 = 色彩模型 + 三个具体定义:

  1. 原色(Primaries) — R、G、B 三个基准色的精确色度坐标
  2. 白点(White Point) — "白色"的色温定义
  3. 传输函数(Transfer Function / Gamma) — 线性光值到编码值的映射曲线

同样的 (255, 0, 0) 在 sRGB 和 Display P3 里是不同的红色

2.1 sRGB

属性
原色 R(0.64, 0.33), G(0.30, 0.60), B(0.15, 0.06)
白点 D65 (6504K)
传输函数 分段:接近零时线性,之后约 γ2.2
CIE 1931 色域覆盖 ~35%

sRGB 是互联网、Windows 和绝大多数消费显示器的默认色彩空间,1996 年由 HP 和微软联合标准化(IEC 61966-2-1)。

它的传输函数并非简单的 γ=2.2 幂函数,而是在接近零的部分有一段线性区域,过渡到移位幂函数。实践中很多实现近似为纯 γ2.2。

2.2 Display P3

属性
原色 R(0.680, 0.320), G(0.265, 0.690), B(0.150, 0.060)
白点 D65(与 sRGB 相同)
传输函数 与 sRGB 相同
CIE 1931 色域覆盖 ~45%

Display P3 是 Apple 对 DCI-P3 电影标准的消费级适配。它保留了 DCI-P3 的广色域原色,但将白点从电影的氙灯 (~6300K) 换成 D65,传输函数换成 sRGB 曲线。

与 sRGB 的关系:Display P3 在 CIE xy 色度图上比 sRGB 大约 25% ,体积上大约 50% 。额外的颜色主要在红色、橙色和绿色方向——这些色相可以达到更高的饱和度。

Apple 设备时间线

时间 设备
2015 年底 iMac Retina 5K(首款 P3 显示器的 Apple 设备)
2016.3 9.7 寸 iPad Pro
2016.9 iPhone 7 / 7 Plus(首款 P3 显示 + P3 相机的 iPhone)
2017+ 所有新 iPhone、iPad 和 Retina Mac

2.3 Adobe RGB

属性
原色 R(0.64, 0.33), G(0.21, 0.71), B(0.15, 0.06)
白点 D65
传输函数 纯 γ2.2
CIE 1931 色域覆盖 ~52.1%

Adobe RGB 的设计目标是涵盖 CMYK 打印机可达的大部分颜色,色域优势主要在青绿区域。它是印刷摄影工作流的标准工作空间。

iOS 可以读取和显示 Adobe RGB 图片(通过嵌入的 ICC 配置文件),但 Display P3 的色域并不完全包含 Adobe RGB——部分 Adobe RGB 的绿色和青色超出了 P3 范围,Core Graphics 会自动进行色域映射。

2.4 ProPhoto RGB

属性
原色 部分使用虚拟原色以最大化覆盖
白点 D50 (5003K)——与其他空间不同
传输函数 纯 γ1.8
CIE 1931 色域覆盖 ~79.2%

ProPhoto RGB 覆盖了 CIE Lab* 中超过 90% 的表面色,但约 13% 的可表示颜色是虚拟色——不对应任何可见光。

关键注意:因为色域极广,8-bit 编码会导致明显的色带(banding)。使用 ProPhoto RGB 必须搭配 16-bit 位深

色域对比总结

色彩空间 CIE 覆盖 相对 sRGB 白点 Gamma
sRGB ~35% 1.0x(基准) D65 ~2.2(分段)
Display P3 ~45% ~1.25x D65 sRGB 曲线
Adobe RGB ~52% ~1.5x D65 2.2
ProPhoto RGB ~79% ~2.3x D50 1.8

三、位深度

位深度决定每个颜色通道有多少个离散级别。更多位 = 更细的渐变 = 更少的色带。

位深 每通道值域 RGB 总颜色数 每通道字节 典型用途
8-bit 0 ~ 255 ~1677 万 1(UInt8 消费级图片,JPEG
10-bit 0 ~ 1023 ~10.7 亿 需特殊打包 HDR 视频,专业相机
16-bit 0 ~ 65535 ~281 万亿 2(UInt16 RAW 处理,专业编辑

几个关键事实:

  • iPhone 照片(HEIC)是 8-bit,不是 10-bit。这是非常常见的误解。
  • iPhone 视频可以是 10-bit Dolby Vision HDR(iPhone 12 起)。
  • Apple ProRAW 是 12-bit 或 14-bit 传感器数据,存储在 DNG 格式中。
  • 位深太低 + 色域太广 = 可见色带。这就是 ProPhoto RGB 强制要求 16-bit 的原因。

除整数位深外,iOS 还支持浮点格式

格式 范围 用途
16-bit 半精度浮点 ~6.1e-5 到 65504 Core Image、Metal、扩展范围色
32-bit 单精度浮点 IEEE 754 全范围 Core Image、科学计算

浮点格式可以表示 [0, 1] 范围之外的值,这对扩展范围颜色(extended range colors)和 HDR 内容至关重要。


四、像素格式

4.1 CGImage 的关键属性

当你拿到一个 CGImage 时,以下属性描述了它的像素数据布局:

cgImage.bitsPerComponent  // 每通道位数:8 或 16
cgImage.bitsPerPixel      // 每像素总位数:32 (RGBA8) 或 64 (RGBA16)
cgImage.bytesPerRow       // 每行字节数(可能包含对齐填充)
cgImage.width             // 像素宽度
cgImage.height            // 像素高度
cgImage.colorSpace        // 色彩空间(sRGB、Display P3 等)
cgImage.alphaInfo         // Alpha 通道配置
cgImage.bitmapInfo        // 组合标志:alphaInfo + 字节序

bytesPerRow 的坑bytesPerRow 可能大于 width × bytesPerPixel,因为系统会做内存对齐填充。计算像素偏移时必须用 bytesPerRow,不能假设紧密排列。

4.2 RGBA vs BGRA

在 iOS(ARM,小端序)上,原生最优格式是 BGRA

格式 内存布局 对应 bitmapInfo 说明
RGBA [R][G][B][A] premultipliedLast 常用,直觉友好
BGRA [B][G][R][A] premultipliedFirst + byteOrder32Little iOS 原生最优,GPU 友好

如果你创建了 RGBA 的 CGContext 却按 BGRA 顺序读取,红色和蓝色会互换——取出来的颜色色相完全不对。

iOS 上常见的像素配置

格式 bitsPerComponent bitsPerPixel bytesPerPixel 布局
RGBA8 8 32 4 R, G, B, A
BGRA8 8 32 4 B, G, R, A
RGBA16 16 64 8 R, G, B, A (UInt16)
RGBAf 32 128 16 R, G, B, A (Float32)

4.3 预乘 Alpha(Premultiplied Alpha)

iOS 默认使用预乘 Alpha(premultiplied alpha),即存储的 RGB 值已经乘过 Alpha。

原始色:R=255, G=0, B=0, A=128"纯红,50% 透明"
预乘后:R=128, G=0, B=0, A=128  → 存储的值
// 因为:255 × (128/255) ≈ 128

为什么用预乘?

  1. 合成更快 — 标准 "over" 操作每通道少一次乘法
  2. 避免颜色溢出 — 混合直通 Alpha 颜色在子像素边界可能产生光晕

取色时的影响:如果 Alpha < 255,需要反预乘才能得到真实颜色:

let a = CGFloat(pixelData[offset + 3]) / 255.0
guard a > 0 else { return .clear }
let r = CGFloat(pixelData[offset]) / 255.0 / a    // 反预乘
let g = CGFloat(pixelData[offset + 1]) / 255.0 / a
let b = CGFloat(pixelData[offset + 2]) / 255.0 / a

4.4 CGBitmapContext 支持的格式组合

创建 CGBitmapContext 时,只有特定的参数组合是合法的:

色彩空间 bitsPerComponent bitmapInfo 说明
RGB 8 premultipliedFirst + byteOrder32Little BGRA8(原生最优)
RGB 8 premultipliedLast RGBA8(常用)
RGB 8 noneSkipFirst + byteOrder32Little BGRx8(无 Alpha)
RGB 8 noneSkipLast RGBx8(无 Alpha)
RGB 16 premultipliedLast RGBA16
RGB 32 (float) premultipliedLast + floatComponents RGBAf
Gray 8 .none 灰度 8-bit

五、图片文件格式

5.1 JPEG

属性 支持情况
位深 仅 8-bit
通道 3 (RGB),不支持 Alpha
色彩空间 sRGB(默认),可通过嵌入 ICC 支持 P3、Adobe RGB
压缩 有损(DCT)

JPEG 压缩原理:图片从 RGB 转换为 Y'CbCr(亮度 + 色度),色度通道降采样(4:2:0 或 4:2:2),每个 8×8 块进行 DCT 变换、量化(有损步骤)和熵编码。

5.2 PNG

属性 支持情况
位深 1, 2, 4, 8, 或 16-bit
通道 1~4(灰度、灰度+Alpha、RGB、RGBA)
Alpha 完整支持(8 或 16 bit)
色彩空间 通过嵌入 ICC 或 sRGB chunk
压缩 无损(DEFLATE)

16-bit PNG 每通道 65536 级,一个 RGBA16 PNG 每像素 8 字节,文件大小约为同尺寸 8-bit PNG 的两倍。

5.3 HEIF/HEIC

属性 支持情况
位深 8-bit 或 10-bit(规范支持 16-bit)
通道 3 (RGB) 或 4 (RGBA)
Alpha 支持
色彩空间 sRGB、Display P3 等
压缩 有损或无损(HEVC)
压缩率 同等画质下约为 JPEG 的 2 倍

关键事实:iPhone HEIC 照片是 8-bit。尽管 HEIF 规范支持 10-bit 及更高,Apple iPhone 相机拍摄的 HEIC 静态照片始终是 8-bit per channel。不过 HEIC 照片包含额外的 8-bit HDR 增益图(gain map),使系统能在 HDR 屏幕上展示扩展动态范围,但基础图像数据是 8-bit。

不同厂商的 HEIF 实现有差异:

厂商 HEIF 位深
Apple iPhone 8-bit(附 HDR 增益图)
Canon (R5, R6 等) 10-bit
Nikon (Z8, Z9) 10-bit

格式对比

特性 JPEG PNG HEIF/HEIC
最大位深 8-bit 16-bit 16-bit(iPhone 实际 8-bit)
Alpha 通道 不支持 支持 支持
有损压缩 支持 不支持 支持
无损压缩 不支持 支持 支持
广色域 (P3) 通过 ICC 通过 ICC 原生
HDR 增益图 不支持 不支持 支持
文件大小 最小

六、iOS 取色方案对比

方案 A:dataProvider 直接读原始数据

guard let cgImage = image.cgImage,
      let data = cgImage.dataProvider?.data,
      let bytes = CFDataGetBytePtr(data) else { return nil }

let offset = (y * cgImage.bytesPerRow) + (x * bytesPerPixel)
let r = bytes[offset]
let g = bytes[offset + 1]
let b = bytes[offset + 2]

特点

  • 最快,零拷贝,仅指针运算
  • 致命缺陷:读到的是图片的原始像素数据,格式完全取决于源图片
  • 必须自己处理 8/16-bit、RGBA/BGRA、不同色彩空间等差异
  • 本文开头的 Bug 就是这个方案导致的

适用场景:已知图片格式固定且追求极致性能的场景。生产环境不推荐。

方案 B:CGContext 重绘(推荐)

// 使用 Device RGB,系统根据设备自动适配(P3 屏保留广色域)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue

var pixelData = [UInt8](repeating: 0, count: bytesPerRow * height)

guard let context = CGContext(
    data: &pixelData,
    width: width, height: height,
    bitsPerComponent: 8,
    bytesPerRow: bytesPerRow,
    space: colorSpace,
    bitmapInfo: bitmapInfo
) else { return nil }

context.draw(cgImage, in: CGRect(origin: .zero, size: CGSize(width: width, height: height)))
// 现在 pixelData 保证是 RGBA8 格式,不管源图片是什么格式

特点

  • 业界最主流。Stack Overflow、简书、掘金上绝大多数取色方案都是此方式

  • 你定义输出格式,Core Graphics 自动完成所有转换:

    • 16-bit → 8-bit 降采样
    • Display P3 → sRGB 色彩空间转换
    • BGRA → RGBA 字节重排
    • 直通 Alpha → 预乘 Alpha
  • 代价:需要分配完整的像素缓冲区并重绘(12MP ≈ 48MB)

适用场景:通用取色,各类图片来源不可控的生产环境。

方案 C:Core Image

// CIAreaAverage —— 取区域平均色
let filter = CIFilter(name: "CIAreaAverage", parameters: [
    kCIInputImageKey: ciImage,
    kCIInputExtentKey: CIVector(cgRect: extent)
])

特点

  • CIImage 是操作图(recipe),不是像素缓冲区,只有在 render 时才产生像素
  • 适合取区域平均色或主题色提取
  • 创建 CIContext + 渲染管线的开销大,单像素取色太重
  • Core Image 内部有三级色彩空间管理(输入、工作、输出)

适用场景:图片主题色提取、区域平均色分析。不适合实时拖动取色。

方案 D:vImage(Accelerate 框架)

let format = vImage_CGImageFormat(
    bitsPerComponent: 8,
    bitsPerPixel: 32,
    colorSpace: CGColorSpaceCreateDeviceRGB(),
    bitmapInfo: ...
)
var buffer = try vImage_Buffer(cgImage: cgImage, format: format)
// 通过 buffer.data 访问像素

特点

  • Apple 官方高性能图像处理框架,SIMD 优化
  • vImageConverter 可以精确控制任意格式间的色彩空间转换
  • API 较复杂,单像素取色有点 overkill

适用场景:批量像素处理、需要最高色彩精度控制的专业场景。

方案对比总结

维度 dataProvider (A) CGContext (B) Core Image (C) vImage (D)
格式安全 危险 安全 安全 安全
色彩空间处理 自动转换 3 级管线 精细控制
16-bit/P3 支持 需手动处理 自动 自动 自动
单像素性能 最快 缓存后 O(1) 最慢 中等
批量性能 快但脆弱 最佳
API 复杂度 低但易错 适中 较高 较高
可靠性

七、工程实践:PixelReader 缓存方案

方案 B(CGContext 重绘)的问题是:如果每次取色都重新创建 CGContext 并绘制,在拖动放大镜时(每秒 60+ 次)会非常卡顿。解决方案是缓存——只在初始化时绘制一次,后续取色做数组索引查找。

public final class PixelReader {
    private let pixelData: [UInt8]  // 缓存的像素数据
    private let width: Int
    private let height: Int
    private let bytesPerRow: Int
    private let colorSpace: CGColorSpace

    /// 初始化时一次性完成绘制和缓存
    public init?(image: UIImage) {
        guard let cgImage = image.cgImage else { return nil }
        self.width = cgImage.width
        self.height = cgImage.height

        // 使用 Device RGB,系统会根据设备能力自动适配(P3 屏保留广色域)
        self.colorSpace = CGColorSpaceCreateDeviceRGB()

        let bytesPerPixel = 4
        self.bytesPerRow = bytesPerPixel * width
        var data = [UInt8](repeating: 0, count: bytesPerRow * height)

        let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue

        guard let context = CGContext(
            data: &data,
            width: width, height: height,
            bitsPerComponent: 8,
            bytesPerRow: bytesPerRow,
            space: colorSpace,
            bitmapInfo: bitmapInfo
        ) else { return nil }

        context.draw(cgImage, in: CGRect(origin: .zero,
                     size: CGSize(width: width, height: height)))
        self.pixelData = data  // 缓存
    }

    /// 快速查询——仅数组索引,O(1)
    /// 注意:因为 CGContext 使用 premultipliedLast,需要反预乘还原真实颜色
    public func color(at point: CGPoint) -> UIColor? {
        let x = Int(point.x)
        let y = Int(point.y)
        guard x >= 0, x < width, y >= 0, y < height else { return nil }

        let offset = y * bytesPerRow + x * 4

        // 反预乘 Alpha,还原真实 RGB 值
        let a = CGFloat(pixelData[offset + 3]) / 255.0
        guard a > 0 else { return nil }
        let r = min(CGFloat(pixelData[offset])     / 255.0 / a, 1.0)
        let g = min(CGFloat(pixelData[offset + 1]) / 255.0 / a, 1.0)
        let b = min(CGFloat(pixelData[offset + 2]) / 255.0 / a, 1.0)

        return UIColor(red: r, green: g, blue: b, alpha: a)
    }
}

在视图层只创建一次,缓存复用:

@State private var pixelReader: PixelReader? = nil

.onFirstAppear {
    fixedImage = UIImage.fixedOrientation(for: image) ?? image
    pixelReader = PixelReader(image: fixedImage) // 只创建一次
}
无缓存 PixelReader 缓存
每次取色 分配缓冲区 + CGContext + draw 数组下标访问
时间复杂度 O(W×H) / 次 O(1) / 次
拖动时开销 每秒 60+ 次全量位图解码 仅初始化时一次

本质上是一个经典的空间换时间优化


八、取色常见坑点

坑点 说明 解决方案
Scale 倍率 UIImage.size 是点(point),不是像素。@3x 设备上 100pt = 300px 取色坐标需要乘以 UIImage.scale
色彩空间选择 CGColorSpace(name: CGColorSpace.sRGB)! 会强制转换到 sRGB,丢失 P3 色域 CGColorSpaceCreateDeviceRGB() 让系统根据设备自动适配,P3 屏保留广色域
bytesPerRow 填充 系统可能在行尾添加对齐字节 始终用 bytesPerRow 计算偏移,不要用 width × 4
图片方向 CGImage 不存方向信息,UIImage 的 imageOrientation 可能是旋转/镜像的 取色前先调用 fixedOrientation 校正方向
预乘 Alpha 半透明区域的 RGB 不是原始值 需要反预乘:R_real = R_stored / A
HEIC ≠ 10-bit iPhone 照片是 8-bit HEIC,不要误判为 16-bit 检查 cgImage.bitsPerComponent 确认实际位深
内存 12MP RGBA8 ≈ 48MB,48MP(iPhone 15 Pro)≈ 192MB 注意内存压力,必要时降采样
16-bit 像素 部分 PNG 或专业相机输出是 16-bit 用 CGContext 重绘方案自动转换,或检查 bitsPerComponent 分支处理

参考资料

❌
❌