阅读视图

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

解决 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中声明式地应用

独立 App 配置阿里云 CDN 记录

独立 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 图片取色完全指南:从像素格式到工程实践

本文从一个真实的取色 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 分支处理

参考资料

❌