Macbook Neo:苹果重回校园的起点 - 肘子的 Swift 周报 #126
上周,苹果推出了若干新款硬件产品。与以往的发布会不同,这次发布显得异常低调。起初我只对其中新发布的显示器感兴趣,但在看到不少数码媒体对 Macbook Neo 配置的吐槽后,也不由得多留意了这款产品。相较于其“减配”的表象,我更从其精准的定价中看到了苹果重返教育市场的决心。
上周,苹果推出了若干新款硬件产品。与以往的发布会不同,这次发布显得异常低调。起初我只对其中新发布的显示器感兴趣,但在看到不少数码媒体对 Macbook Neo 配置的吐槽后,也不由得多留意了这款产品。相较于其“减配”的表象,我更从其精准的定价中看到了苹果重返教育市场的决心。
近期,由小红书联合多伦多大学等高校的研究人员发布了 《SWE-Bench Mobile》(2602.09540) 论文,内容主要是评估 LLM 智能体在处理真实生产级移动端应用开发任务时的能力,并提出了首个针对该领域的基准测试——SWE-Bench Mobile。
这个论文对比之前那些简单的需求场景,明显更具备说服力,最重要的是,用真实的数据给目前的 AI 狂热浇一浇冷水。
![]()
目前的编程基准测试大多集中在孤立的算法问题,而 SWE-Bench 则是关注 GitHub 上的 Bug 修复,然而真实的工业级移动端开发汪汪更为复杂:
所以研究团队从目前小红书自己的真实产品流水线中提取了 50 个具有代表性的开发任务,构建了该基准测试:
数据集组成 :
代码库规模:基于约 5GB 大小的真实 iOS 生产代码库(Swift/Objective-C)
任务复杂度:平均每个任务涉及修改 4.2 个文件,远超之前的基准测试
![]()
整个基准的规则是:
每个任务包含:
![]()
对于任务两个关键指标:
任务成功率:所有测试通过的任务比例
测试通过率:所有测试案例通过的比率
![]()
而对于 LLM,论文评估了 22 种 不同的“智能体-模型”配置,涵盖了四个主流框架:
评估维度包括:任务完成率、任务复杂度影响、成本效果对比、多次运行稳定性、Prompt 设计影响等。
而根据论文可以得出结论:当前 AI 在生产级的软件工程力存在巨大局限性:
![]()
![]()
对于失败,论文还针对失败类型归类:
这些失败的类似,在一定程度上反映了智能体对真实工程流程、跨文件依赖、与视觉设计的理解严重不足,也就是这些问题是“工程级问题”,而不是“语言问题”:
所以哪怕换成 Android / Flutter,这类跨文件工程理解问题仍然存在。
基于这些数据,论文认为当前 LLM Agent 尽管在单一代码生成上有突破,但在端到端工程上下文(包含设计、代码库理解、工程流程)仍远未达到企业生产标准。
另外,论文也有一个有趣的结论数据,主要统计了各 Agent + Model 的每任务成本(美元)和平均耗时(分钟),例如:
![]()
对此可以看出来:
最后,下图是论文的最终结果对比,例如在 Success 和 Pass 上:
![]()
这么看,OpenCode 的实际数据表现是真的一般。
这个在同一个模型,在不同 agent 上的成功率也有所体现,OpenCode 再一次被鞭尸:
![]()
所以,可以看出来,目前的 AI 智能体离独立完成中大型移动开发还有很大距离,主要瓶颈在于多模态理解、大规模代码导航和跨文件逻辑一致性等。
另外,SWE-Bench Mobile 采用了托管基准挑战(Hosted Benchmark)模式 ,不公开测试集答案,以防止数据泄露到未来的模型训练中。
最后,论文只针对原生 iOS 开发进行测试,没有测试 Android 原生、Flutter、RN 等其他情况,按照一般直觉,这些框架的 AI 表现应该会好于 iOS 原生,当然这也只是我的个人直觉,真实数据还是得有企业做过 Benchmark 才知道。
不过至少从目前看,在移动端开发领域写代码上,至少比前端安全性高一些?你怎么看?
项目上线前的安全处理,经常被放在发布流程的最后一步。很多团队在代码开发阶段关注功能实现,等到准备提交 App Store 时,才开始思考应用被反编译或资源被提取的问题。
在一个包含 Swift + Flutter 模块的项目中,我们曾经遇到过这样一个情况:测试包被外部获取后,对方直接解压 IPA,通过类名和资源目录快速定位了核心模块。那次经历之后,我们把 iOS app 保护单独整理成一套固定流程,并加入到发布前的检查清单中。
这篇文章按实际操作过程记录一个流程。工具不会只有一个,而是组合使用系统能力、命令行工具以及 Ipa Guard 等二进制处理工具。
在进行任何保护操作之前,可以先观察当前 IPA 包含的信息。
把 .ipa 文件复制一份并改名为 .zip:
mv app.ipa app.zip
unzip app.zip
进入目录:
Payload/AppName.app
此时可以看到:
如果资源目录中存在明显业务含义的文件,例如:
vip_purchase_bg.png
subscription_config.json
payment_success.html
那么即使没有阅读代码,也能推测应用功能结构。
在 IPA 层处理之前,可以在 Xcode 构建阶段减少调试信息。
Release 配置中可以检查两个选项:
Strip Debug Symbols During Copy = YES
Deployment Postprocessing = YES
构建完成后,用命令查看二进制中的字符串:
strings AppBinary | grep ViewController
如果能看到大量业务类名,例如 OrderManager 或 VipViewController,说明符号仍然暴露。
源码阶段可以通过脚本或重命名策略减少可读性,但很多项目已经进入稳定阶段,不希望再修改代码结构。这时可以转向 IPA 级处理。
在编译完成的情况下,可以通过 Ipa Guard 直接对 IPA 包进行处理,而不需要修改项目源码。
加载 IPA 后,工具会解析其中的 Mach-O 二进制结构,并列出类名与方法列表。
在界面中可以看到类似结构:
代码模块
├─ OC 类
├─ Swift 类
├─ OC 方法
└─ Swift 方法
![]()
实际操作时,我们只勾选包含业务逻辑的类,例如:
OrderManager
VipSubscriptionController
PaymentService
处理后,这些名称会被替换为无意义字符串,从而降低反编译可读性。
Ipa Guard 支持 Objective-C、Swift、Flutter、Unity3D 等多种开发平台,因此混合项目也可以统一处理。
代码不是唯一需要保护的内容。资源文件往往更容易暴露信息。
在 Ipa Guard 的资源模块中,可以选择处理以下文件类型:
工具会执行两类操作:
1. 文件名混淆
例如:
vip_background.png
会变为:
a9d3f21.png
这样在解包 IPA 时无法通过名称判断用途。
![]()
2. 修改 MD5
图片或资源的 MD5 值也可以被修改,这可以打散资源特征值。
处理完成后,重新解压 IPA 可以看到所有资源名称已经变为随机字符串。
![]()
如果应用包含 H5 页面,需要额外处理 JS 与 HTML 文件。
在构建阶段可以使用前端压缩工具,例如:
terser
uglify-js
压缩完成后再由 Ipa Guard 修改资源名称。
这样做的效果是:
即使解包 IPA,也很难通过资源结构还原功能模块。
很多项目在构建过程中会留下调试日志或符号信息。
Ipa Guard 提供调试信息清理功能,可以删除:
处理后可以再次检查:
strings AppBinary
输出内容会明显减少。
任何 IPA 内容修改都会导致签名失效。
因此混淆完成后需要重新签名。
可以使用签名工具,例如:
kxsign sign my.ipa \
-c cert.p12 \
-p password \
-m dev.mobileprovision \
-z test.ipa \
-i
参数 -i 会尝试直接安装到连接的设备。
也可以使用 Ipa Guard 内置签名模块,在混淆完成后直接选择证书并生成新 IPA。
设备测试阶段主要检查:
测试通过后,需要重新签名生成发布版本。
发布阶段只需要更换证书:
Distribution Certificate
App Store Provisioning Profile
生成的 IPA 将用于提交 App Store。
发布类型 IPA 不允许直接安装到设备,但可以通过 Xcode Organizer 或上传工具提交审核。
iOS app 保护并不是单一技术,而是一组连续操作:减少符号暴露、混淆代码名称、处理资源文件、清理调试信息、重新签名并验证运行。
在移动应用开发中,性能测试不是某个阶段才开始做的事情。很多问题在开发早期就已经发生,只是在功能逐渐增多之后才表现出来。例如:
如果只依赖单一工具去分析这些问题,往往会比较吃力。实际项目中更常见的做法是多工具组合使用,让每个工具负责不同方面。
这里结合一次真实项目中的测试,介绍一套比较实用的 iOS App 性能测试流程。
在开始之前,需要先确定要观察哪些数据。常见的性能指标包括:
不同阶段关注的重点会有所不同。开发阶段通常更关注函数级性能,而测试阶段更关注设备整体运行情况。
在很多团队里,测试人员并不一定使用 Mac 环境。如果需要在 Windows 或 Linux 上查看设备性能,就需要借助设备监控工具。
我在项目中比较常用的是 克魔助手(Keymob) 来做这方面的数据采集。
它的作用主要是:
这类监控通常用于快速发现问题出现的时间点。
实际操作过程比较简单。
准备一台测试设备,然后:
设备识别后可以看到当前设备信息。
在左侧导航中选择:
性能图表
这里会显示设备当前的资源使用情况。
在界面右上角可以选择需要观察的指标,例如:
如果只是测试页面流畅度,通常只需要勾选 CPU 和 FPS。
![]()
点击 选择 App
输入应用名称即可找到目标应用。
也可以同时勾选 系统总 CPU,用来判断设备整体负载。
![]()
点击 开始 按钮之后,就可以在手机上执行测试流程,例如:
性能图表会实时显示资源变化。
通过观察曲线可以判断:
设备监控工具只能告诉我们问题出现在哪里,但不能直接解释原因。
当发现异常之后,通常需要回到开发工具进行深入分析。
Instruments 是 iOS 官方提供的性能分析工具。
它可以分析:
例如,当设备监控发现某个操作 CPU 突然升高,可以用 Instruments 再跑一次相同操作。
这样可以找到具体的函数或对象。
有一次测试人员反馈:
“进入某个页面之后滑动明显卡顿。”
排查过程是这样的:
第一步
使用克魔助手监控 CPU 与 FPS。
发现滑动列表时 CPU 占用突然升高,同时 FPS 出现下降。
第二步
在 Mac 上使用 Instruments 重新测试。
最终定位到问题原因:
页面滚动时触发了大量图片解码。
第三步
修改代码,将图片解码改为后台线程处理。
再次测试后 CPU 曲线明显平稳。
有些开发者希望找到一个全能工具,但在实际项目中很少存在这种工具。
更合理的方式通常是:
设备监控工具,用于观察设备运行情况
开发分析工具,用于定位具体代码问题
这样可以形成一个完整的测试流程。
性能测试并不是某个阶段才进行的工作,而是贯穿整个开发周期的过程。只要在每个版本发布前进行简单的性能监控,就可以提前发现很多潜在问题。
weak 是 Objective-C / Swift 中的一种弱引用修饰符。它的核心行为只有两条:
这个"自动置 nil"是 weak 最关键的特性,也是它和 __unsafe_unretained(不置 nil,会变野指针)的根本区别。
两个对象互相强引用,谁都释放不了:
A 强引用 B(B 的引用计数 +1)
B 强引用 A(A 的引用计数 +1)
想释放 A → 但 B 还在引用 A → A 释放不了
想释放 B → 但 A 还在引用 B → B 释放不了
→ 内存泄漏
把其中一方改成 weak 就打破了循环:
A 强引用 B(B 的引用计数 +1)
B 弱引用 A(A 的引用计数不变)
外部释放 A → A 的引用计数归零 → A 被销毁 → A 释放对 B 的强引用 → B 也被销毁
常见场景:delegate、block 捕获 self、父子视图关系。
这是 weak 原理的核心。Runtime 维护了一套 SideTable 数据结构来管理 weak 引用。
全局有一个 SideTable 数组(StripedMap<SideTable>)
包含 64 个 SideTable(根据对象地址哈希分配到不同的表,减少锁竞争)
每个 SideTable 包含三样东西:
+---------------------------+
| spinlock_t 自旋锁 | 用于多线程安全
+---------------------------+
| RefcountMap 引用计数表 | 存储对象的引用计数(非 isa 优化时)
+---------------------------+
| weak_table_t 弱引用表 | 存储所有 weak 指针的信息
+---------------------------+
weak_table_t
+-----------------------------------+
| weak_entry_t *weak_entries | 哈希数组
| size_t num_entries | 当前条目数
| uintptr_t mask | 哈希掩码(数组大小 - 1)
| uintptr_t max_hash_displacement | 最大哈希冲突偏移
+-----------------------------------+
每个 weak_entry_t 对应一个被弱引用的对象:
weak_entry_t
+-----------------------------------+
| referent(被弱引用的对象地址) | Key:对象是谁
| referrers(弱引用指针的地址数组) | Value:谁在弱引用它
+-----------------------------------+
用人话说就是:Runtime 维护了一张大表,Key 是对象地址,Value 是所有指向这个对象的 weak 指针的地址列表。
类比理解:
想象一个"粉丝登记簿"。每个明星(对象)有一页,上面记着所有粉丝(weak 指针)的联系方式。明星退役(对象释放)时,工作人员翻到那一页,逐个通知粉丝"他退了"(置 nil),然后撕掉这一页。
当你写 __weak id weakObj = obj; 时,Runtime 调用 objc_initWeak,完整流程:
objc_initWeak(&weakObj, obj)
|
v
storeWeak(&weakObj, obj)
|
v
1. 根据 obj 的地址,哈希计算找到对应的 SideTable
|
v
2. 加锁(SideTable 的 spinlock)
|
v
3. 在 weak_table 中查找 obj 对应的 weak_entry_t
- 如果不存在:创建一个新的 weak_entry_t,插入 weak_table
- 如果已存在:直接使用
|
v
4. 将 &weakObj(weak 指针的地址)添加到 weak_entry_t 的 referrers 数组中
|
v
5. 解锁
|
v
6. 返回 obj(weakObj 现在指向 obj,但不增加引用计数)
当你使用 weakObj 时(比如 [weakObj doSomething]),Runtime 调用 objc_loadWeakRetained:
objc_loadWeakRetained(&weakObj)
|
v
1. 读取 weakObj 当前指向的对象
|
v
2. 如果对象正在被释放(deallocating)→ 返回 nil
|
v
3. 如果对象还活着 → 对它做一次 retain(引用计数 +1)
|
v
4. 返回对象(调用方用完后会 release)
为什么读取时要 retain? 防止你拿到对象后、使用之前的瞬间,对象被其他线程释放。retain 一下确保对象在使用期间不会消失。
这也是为什么常见的模式是:
__weak typeof(self) weakSelf = self;
[obj doSomething:^{
__strong typeof(weakSelf) strongSelf = weakSelf; // 读取时 retain
if (!strongSelf) return; // 如果已释放就退出
[strongSelf doWork]; // 安全使用
}];
__strong typeof(weakSelf) strongSelf = weakSelf 这一步就触发了 retain,保证 block 执行期间 self 不会消失。
当一个被弱引用的对象引用计数归零时,dealloc 过程中会清理所有 weak 引用。
完整流程:
对象引用计数归零
|
v
objc_object::rootDealloc()
|
v
object_dispose()
|
v
objc_destructInstance(obj)
|
v
clearDeallocating(obj)
|
v
clearDeallocating_slow(obj)
|
v
1. 根据 obj 地址找到对应的 SideTable
|
v
2. 加锁
|
v
3. 在 weak_table 中查找 obj 对应的 weak_entry_t
|
v
4. 遍历 weak_entry_t 的 referrers 数组
对每个 weak 指针地址:*referrer = nil (置 nil!)
|
v
5. 从 weak_table 中删除这个 weak_entry_t
|
v
6. 解锁
|
v
7. 释放对象内存(free)
第 4 步就是 weak 自动置 nil 的核心:Runtime 遍历所有指向这个对象的 weak 指针,把它们全部设为 nil。
当 weak 指针指向新对象或超出作用域时,Runtime 调用 objc_destroyWeak:
objc_destroyWeak(&weakObj)
|
v
storeWeak(&weakObj, nil)
|
v
1. 找到旧对象的 SideTable
|
v
2. 从旧对象的 weak_entry_t 的 referrers 中移除 &weakObj
|
v
3. 如果 referrers 为空了,删除这个 weak_entry_t
如果只有一个全局的 SideTable,所有线程操作 weak 引用时都要抢同一把锁,性能极差。
64 个 SideTable 通过对象地址哈希分散到不同的表上,不同表用不同的锁,大幅减少了锁竞争。
对象 A(地址 0x1000)→ 哈希 → SideTable[3] → 锁3
对象 B(地址 0x2000)→ 哈希 → SideTable[17] → 锁17
对象 C(地址 0x3000)→ 哈希 → SideTable[3] → 锁3(和A竞争,但概率低)
weak_entry_t 内部存储 referrers(弱引用指针数组)有两种模式:
大多数对象的 weak 引用数量不超过 4 个(通常就一两个 delegate),所以内联模式覆盖了大部分场景,性能更好。
现代 Objective-C 使用"优化的 isa"(Non-pointer isa),把引用计数和一些标志位直接存在 isa 指针里:
isa 指针(64位):
| 1bit | 1bit | 1bit | 33bit | ... |
| nonptr| has_assoc | has_cxx_dtor | shiftcls | ... |
| | | | | |
| | | | | weakly_referenced (1bit)
weakly_referenced 位标记这个对象是否有 weak 引用。dealloc 时,如果这个位是 0,就跳过 weak 清理流程,加速释放。
dealloc 快速路径:
if (!nonpointer) → 慢路径
if (weakly_referenced) → 需要清理 weak 表 → 慢路径
if (has_assoc) → 需要清理关联对象 → 慢路径
if (has_cxx_dtor) → 需要调用 C++ 析构 → 慢路径
否则 → 直接 free,最快
所以一个没有 weak 引用、没有关联对象、没有 C++ 析构的纯 OC 对象,释放速度是最快的。
weak 不是"免费的",它有实实在在的性能开销:
| 操作 | 开销 |
|---|---|
| 创建 weak 引用 | 哈希查找 SideTable + 加锁 + 插入 weak_entry |
| 读取 weak 引用 | 读取 + retain + autorelease(或 release) |
| 对象释放时 | 哈希查找 + 加锁 + 遍历所有 weak 指针置 nil + 删除条目 |
对比 __unsafe_unretained:创建和读取都只是普通的指针赋值和读取,几乎零开销。代价是对象释放后变成野指针。
实际影响:在绝大多数场景下,weak 的开销完全可以忽略。但在极端高频的场景下(比如每秒创建销毁上万个弱引用对象),可以考虑用 __unsafe_unretained 配合手动管理来优化。
在 MRC 和早期 ARC 实现中,读取 weak 变量会自动将对象注册到 autoreleasepool:
id obj = objc_loadWeak(&weakObj);
// 等价于:
id obj = objc_loadWeakRetained(&weakObj);
objc_autorelease(obj);
这意味着在一个循环里频繁读取 weak 变量,会导致 autoreleasepool 膨胀:
for (int i = 0; i < 100000; i++) {
NSLog(@"%@", weakObj); // 每次读取都往 pool 里加一个
}
// pool 里积累了 10 万个对象,直到 pool drain 才释放
解决方案:在循环外用 strong 变量接住,循环里用 strong 变量。
现代 ARC(编译器优化后)在很多场景下已经不走 autorelease 了,但理解这个机制仍然重要。
Tagged Pointer 是苹果对小对象(短 NSString、小 NSNumber 等)的优化:把值直接编码在指针里,不是真正的堆对象。
对 Tagged Pointer 做 weak 引用时:
| 维度 | Objective-C weak | Swift weak |
|---|---|---|
| 类型 | 可以是任意 OC 对象 | 必须是 Optional 类型 |
| 类限制 | 无 | 只能用于 class 类型(AnyObject) |
| 底层机制 | SideTable | Swift 有自己的实现,但原理类似 |
| unowned | 没有直接等价物 | 有 unowned(类似 unsafe_unretained,但 debug 模式有检查) |
Swift 的 unowned vs weak:
weak:可选类型,对象释放后变 nil,有 SideTable 开销unowned:非可选类型,假设对象一定还活着。释放后访问会在 debug 模式 crash(比野指针更安全)。性能比 weak 好(不走 SideTable)Runtime 维护了一个全局的 SideTable 结构,其中的 weak_table 以对象地址为 Key,以所有指向该对象的 weak 指针地址数组为 Value。对象 dealloc 时,Runtime 从表中找到所有 weak 指针,逐个置 nil,然后删除表项。
assign 只是简单的指针赋值,对象释放后指针变成野指针(指向已释放的内存)。weak 会在对象释放时自动置 nil,安全。assign 用于基本类型(int、CGFloat 等),weak 用于对象类型。
strong 只是引用计数的原子操作(+1/-1)。weak 需要额外的哈希查找、加锁、SideTable 操作。读取时还需要 retain 保证线程安全。但在绝大多数场景下差异可忽略。
理论上没有限制。weak_entry_t 内部先用 4 个内联槽位,超过后切换为动态哈希数组,可以按需增长。
在触发 dealloc 的那个线程。谁释放了最后一个 strong 引用,就在谁的线程上走 dealloc 流程,进而清理 weak 表。
weak 的本质就是 Runtime 维护了一张"对象 -> 弱指针列表"的全局哈希表(SideTable 中的 weak_table)。创建 weak 引用时注册,读取时 retain 保安全,对象释放时遍历置 nil 后删除条目。代价是哈希查找和加锁的开销,换来的是零野指针的安全性。
手机电池的电量本质上就是电能。App 的各种操作最终都会驱动硬件工作,硬件工作就要消耗电能。
主要的耗电硬件:
┌────────────────────────────────────────────────────┐
│ App 的各种操作 │
├──────┬──────┬──────┬──────┬──────┬──────┬──────────┤
│ CPU │ GPU │ 网络 │ 定位 │ 屏幕 │ 传感器│ 蓝牙/NFC │
│ │ │模块 │模块 │背光 │ │ │
├──────┴──────┴──────┴──────┴──────┴──────┴──────────┤
│ 电池 │
└────────────────────────────────────────────────────┘
关键认知:硬件有两种状态——空闲态和活跃态。
空闲态几乎不耗电,活跃态耗电量可能是空闲态的 10-100 倍。电量优化的核心就是:尽量让硬件处于空闲态,减少活跃态的持续时间。
iOS 不会让硬件被频繁地"唤醒-休眠-唤醒-休眠"。它会把多个 App 的小任务合并到同一个时间窗口集中处理。
不合并:
App1 ─▮─────────▮─────────▮───────── (每 10 秒唤醒一次)
App2 ──────▮─────────▮─────────▮──── (每 10 秒唤醒一次)
CPU ─▮───▮──▮──▮───▮──▮──▮───▮──▮ (被唤醒了 9 次)
合并后:
App1 ─▮─────────▮─────────▮─────────
App2 ─▮─────────▮─────────▮───────── (和 App1 对齐)
CPU ─▮─────────▮─────────▮───────── (只被唤醒 3 次)
对开发者的启示: 不要自己用精确的 Timer 去定时做事,用系统提供的 API(如 BGTaskScheduler),让系统帮你合并。
iOS 在系统层面持续监控每个 App 的能量消耗。如果你的 App 耗电异常:
iOS 对后台 App 的电量管控非常严格:
| 状态 | 允许做什么 | 时间限制 |
|---|---|---|
| 前台 | 任何事 | 无限制 |
| 后台(刚切走) | 完成当前任务 | 约 30 秒(可申请延长到 ~3 分钟) |
| 后台(挂起) | 什么都不能做 | 0(被冻结) |
| 后台模式(音乐/导航/VoIP等) | 特定任务 | 持续但受监控 |
App 被挂起后,CPU 完全不分配给它,所以不耗电。 这是 iOS 比 Android 省电的核心原因之一。
CPU 频率越高、负载越重、持续时间越长,耗电越多。
| 问题 | 场景 |
|---|---|
| 死循环 / 忙等待 |
while(flag) {} 没有 sleep |
| 过度计算 | 主线程做复杂的 JSON 解析、图片处理 |
| Timer 间隔太短 | 每 0.01 秒刷新一次,但界面根本看不出差别 |
| 后台还在跑 | 切后台了 Timer 还在走 |
1. 避免忙等待,用事件驱动替代轮询
❌ 轮询:每 0.1 秒检查一次数据有没有准备好
while (!dataReady) { usleep(100000); }
✅ 事件驱动:数据好了通知我
NotificationCenter / KVO / Completion Handler / Combine
2. Timer 的电量陷阱
NSTimer / DispatchSourceTimer 默认是精确触发的,会阻止 CPU 进入深度休眠。
优化方式:给 Timer 加 tolerance(容差)。
timer.tolerance = interval * 0.1 // 允许 10% 的偏差
加了 tolerance 后,系统可以把你的 Timer 和其他 Timer 合并触发,减少 CPU 唤醒次数。
苹果的建议:tolerance 至少设为间隔的 10%。
3. 用合适的 QoS(Quality of Service)
iOS 的任务队列有不同的优先级,低优先级的任务系统会安排在"电量友好"的时间执行:
| QoS 级别 | 用途 | CPU 调度 |
|---|---|---|
.userInteractive |
UI 更新、动画 | 最高优先级,立即执行 |
.userInitiated |
用户触发的操作(点击后加载) | 高优先级 |
.default |
默认 | 中等 |
.utility |
长时间任务(下载、导入) | 低优先级,省电模式可能延迟 |
.background |
用户不关心何时完成(预加载、备份) | 最低,系统自行安排 |
原则:不需要立即响应的任务,用 .utility 或 .background。 系统会在电量充足或充电时才执行这些任务。
蜂窝网络模块(4G/5G)有三种功耗状态:
空闲态(Idle)─── 几乎不耗电
│ 有数据要发送
▼
升频态(Ramp Up)─── 功耗急剧上升(从空闲到全速需要 1-2 秒)
│
▼
全速态(Active)─── 高功耗传输数据
│ 数据传完
▼
拖尾态(Tail)─── 仍保持高功耗约 10-15 秒!等待可能的后续请求
│ 超时无新数据
▼
空闲态(Idle)
关键问题在"拖尾态": 传完数据后,蜂窝模块不会立刻休眠,而是保持活跃 10-15 秒等待新数据。如果你的 App 每 20 秒发一个小请求,蜂窝模块就永远无法进入空闲态。
❌ 零散请求(蜂窝模块永远醒着):
请求──拖尾──请求──拖尾──请求──拖尾──请求──拖尾
████████████████████████████████████████████ 全程高功耗
✅ 批量请求(只唤醒一次):
──────────批量请求──拖尾──────────────────────
░░░░░░░░░░█████████████░░░░░░░░░░░░░░░░░░░░ 大部分时间低功耗
1. 请求合并(Batching)
不要每个事件都立即发网络请求。把多个请求攒在一起,一次性发送。
例如:埋点数据不要实时上报,累积 20 条或间隔 30 秒批量上报。
2. 避免轮询,用推送替代
❌ 每 30 秒轮询一次服务器检查新消息
✅ 用 APNs 推送通知客户端有新消息
3. 适配网络类型
NWPathMonitor 或 Reachability 判断当前网络类型4. 减少数据传输量
URLCache、ETag、Last-Modified)5. 超时和重试策略
| 精度 | API | 耗电 | 适用场景 |
|---|---|---|---|
| 最佳精度 | kCLLocationAccuracyBest |
极高(GPS 全速运转) | 导航 |
| 10 米 | kCLLocationAccuracyNearestTenMeters |
高 | 跑步记录 |
| 100 米 | kCLLocationAccuracyHundredMeters |
中 | 附近商家 |
| 公里级 | kCLLocationAccuracyKilometer |
低 | 天气、城市级服务 |
| 3公里级 | kCLLocationAccuracyThreeKilometers |
很低 | 粗略地理围栏 |
| 显著位置变化 | startMonitoringSignificantLocationChanges |
极低 | 只在基站切换时触发 |
GPS 芯片功耗约 25-35mW,WiFi 定位约 5-10mW,基站定位约 1-2mW。
1. 用够了就关
开始定位 → 拿到位置 → 立即 stopUpdatingLocation
很多 App 犯的错误:开启定位后忘了关,GPS 一直在后台运转。
2. 用最低够用的精度
外卖 App 展示附近餐厅用 100 米精度足够了,不需要 Best。只有导航才需要最高精度。
3. 用"显著位置变化"替代持续定位
如果你只需要在用户换了个区域时更新内容(比如新闻 App 根据城市推荐),用 startMonitoringSignificantLocationChanges。它基于基站切换触发,几乎不额外耗电。
4. distanceFilter 过滤无意义的更新
locationManager.distanceFilter = 50 // 移动 50 米以上才回调
默认是 kCLDistanceFilterNone(每次都回调),设一个合理的值可以大幅减少回调次数。
5. allowsBackgroundLocationUpdates 谨慎使用
只有导航、运动记录等真正需要后台定位的场景才开启。开启后要搭配 pausesLocationUpdatesAutomatically = true,让系统在检测到用户静止时自动暂停。
| 操作 | 为什么耗电 |
|---|---|
| 离屏渲染 | 需要额外的帧缓冲区,GPU 要来回切换上下文 |
| 大量透明度混合 | 每一层都要计算混合,层越多越慢 |
| 大图缩小显示 | GPU 要对大图做缩放计算 |
| 实时模糊(UIBlurEffect) | 每帧都要对底层内容做高斯模糊 |
| 高帧率动画 | 120Hz 的计算量是 60Hz 的两倍 |
1. 避免不必要的离屏渲染
触发离屏渲染的操作:
- cornerRadius + masksToBounds(圆角裁剪)
- shadow(阴影,没有设 shadowPath 时)
- mask(遮罩)
- group opacity(组透明度)
优化方式:
- 圆角:用贝塞尔曲线预先裁剪成圆角图片,或在绘图时直接画圆角
- 阴影:设置 shadowPath,避免实时计算阴影形状
- 模糊:对静态内容用截图+模糊的方式,而不是实时 UIVisualEffectView
2. 图片大小匹配显示大小
一张 3000x3000 的图显示在 100x100 的 ImageView 里,GPU 每帧都要缩放。应该在加载时就缩放到显示尺寸。
3. 降低不必要的帧率
不是所有动画都需要 60fps / 120fps。滚动和交互动画需要高帧率,但一个缓慢变化的进度条用 30fps 就够了。
CADisplayLink 可以设置 preferredFramesPerSecond
| 模式 | 耗电 | 说明 |
|---|---|---|
| 主动扫描(Active Scan) | 高 | 蓝牙模块持续发射扫描请求 |
| 被动监听 | 低 | 只监听广播包 |
CBCentralManagerScanOptionAllowDuplicatesKey = NO,避免重复上报同一个设备notify 替代 read(让外设主动通知,而不是 App 轮询读取)beginBackgroundTask 的正确用法切后台时申请额外执行时间来完成当前任务:
关键要点:
① 一定要在超时回调里调用 endBackgroundTask,否则系统会杀掉你的 App
② 不要用它来"偷偷"执行长时间任务
③ 系统给的时间在 iOS 13+ 只有约 30 秒(以前是 3 分钟)
BGTaskScheduler(iOS 13+)用于安排后台任务,系统会在合适的时间执行:
| 类型 | 用途 | 触发条件 |
|---|---|---|
BGAppRefreshTask |
数据刷新(拉新闻、同步) | 系统根据用户使用习惯决定 |
BGProcessingTask |
重计算任务(数据库清理、ML训练) | 通常在充电 + WiFi 时 |
系统会综合考虑电量、网络、充电状态、用户使用习惯来决定何时执行你的任务。 你只需要提交任务,不需要操心何时执行。
静默推送(content-available: 1)会唤醒 App 在后台执行代码。如果推送频率太高(比如每分钟一次),相当于 App 每分钟被唤醒一次,持续消耗 CPU 和网络。
苹果会限制频率: 如果系统检测到你的静默推送太频繁,会开始丢弃推送。
优化: 静默推送只用于"有重要数据需要预加载"的场景,不要当成轮询的替代品。
| 传感器 | 耗电 | 优化 |
|---|---|---|
| 加速度计 | 低 | 用 CMMotionManager 的合理更新频率,不用就 stop |
| 陀螺仪 | 中 | 同上 |
| 磁力计(指南针) | 中 | 用 headingFilter 过滤微小变化 |
| 气压计 | 低 | 按需使用 |
| 摄像头 | 极高 | 分辨率调到够用即可,不用就释放 |
| 麦克风 | 高 | 用 VAD(语音活动检测)避免持续录音 |
Xcode 的 Instruments 提供 Energy Log 模板,能看到:
每个指标用 0-20 的等级表示功耗水平。
Debug Navigator 里实时显示 Energy Impact(低/中/高/极高),直观但粗略。
Energy Impact 的颜色含义:
sysdiagnose在设备上触发 sysdiagnose(同时按 音量上 + 音量下 + 电源键),生成一份详细的系统诊断报告,包含详细的电量日志。
苹果提供的官方线上性能监控框架,每 24 小时汇总一次数据:
| Metric | 说明 |
|---|---|
MXCPUMetric |
CPU 使用指令数 |
MXGPUMetric |
GPU 使用时间 |
MXNetworkTransferMetric |
网络传输量(上/下行) |
MXLocationActivityMetric |
定位活动时间 |
MXCellularConditionMetric |
蜂窝信号质量(信号差时更耗电) |
MXAppRunTimeMetric |
前台/后台运行时间 |
这些数据以直方图形式提供,包含 P50/P90/P99 分位值。 可以帮你了解真实用户的耗电情况。
Xcode → Window → Organizer → Energy,可以看到线上用户的能量报告。如果你的 App 被系统判定为"耗电异常",这里会有日志。
当发现 App 耗电高时,排查思路:
1. CPU 高?
└── 用 Time Profiler 找到热点函数
└── 是主线程还是子线程?
└── 是否有不必要的循环/计算?
└── 后台是否有 Timer 在跑?
2. 网络频繁?
└── 用 Network instrument 看请求频率和数据量
└── 是否有轮询?
└── 请求是否可以合并?
└── 是否在蜂窝网络下做了大量传输?
3. 定位一直开着?
└── 检查 CLLocationManager 的 start/stop 配对
└── 精度是否过高?
└── 后台是否还在定位?
4. GPU 负载高?
└── 用 Core Animation instrument 检查离屏渲染
└── 是否有不必要的透明度混合?
└── 帧率是否过高?
用户开启低电量模式后,系统会:
你的 App 应该监听并适配:
| 检测方式 | 说明 |
|---|---|
ProcessInfo.processInfo.isLowPowerModeEnabled |
查询当前状态 |
NSProcessInfoPowerStateDidChangeNotification |
监听状态变化 |
适配建议:
| 原则 | 含义 | 例子 |
|---|---|---|
| 少做 | 能不做就不做 | 不需要的数据不请求,不在屏的 View 不渲染 |
| 晚做 | 能推迟就推迟 | 非关键 SDK 延迟初始化,后台任务等充电时做 |
| 批量做 | 能合并就合并 | 网络请求合并,埋点批量上报 |
按耗电影响从大到小:
1. 🔴 网络(尤其是蜂窝网络的拖尾效应)
2. 🔴 定位(GPS 持续开启)
3. 🟡 CPU(后台 Timer、忙等待、过度计算)
4. 🟡 GPU(离屏渲染、高帧率)
5. 🟢 蓝牙(持续扫描)
6. 🟢 传感器(持续采集)
□ Timer 是否设置了 tolerance?
□ 切后台后是否停止了不必要的 Timer / 定位 / 蓝牙扫描?
□ 网络请求是否有合并?是否有缓存?
□ 定位精度是否是最低够用的?用完是否关闭了?
□ 是否有轮询可以用推送替代?
□ 后台任务是否用了 BGTaskScheduler 而不是自己计时?
□ 图片是否压缩到了合适的尺寸?
□ 是否适配了低电量模式?
□ 大型计算任务是否标记了合适的 QoS?
□ 是否用 MetricKit 监控了线上耗电数据?
很多人以为,只要开了 梯子,自己的真实 IP 就完全隐藏了。
但实际上,在很多浏览器里,你的 真实 IP 仍然可能被网站看到。
原因可能是:WebRTC。
WebRTC 是浏览器里的一个实时通信技术,用于:
为了建立点对点连接,浏览器会主动检测你的网络信息,例如:
问题在于:
WebRTC 的网络请求有时候不会走代理,而是直接从本地网络发出。
这就导致一个情况:
即使你开启了 梯子,网站仍然可能获取到你的 真实 IP 地址。
可以打开这个网站检测:
https://browserleaks.com/webrtc
如果页面出现类似提示:
说明你的浏览器 存在 WebRTC IP 泄露。
解决方法其实非常简单:
限制 WebRTC 只通过代理连接。
在 Chrome / Edge 浏览器里安装官方插件:
WebRTC Network Limiter
安装地址:
https://chrome.google.com/webstore/detail/webrtc-network-limiter/npeicpdbkakmehahjeeohfdhnlpdklia
安装之后:
让 WebRTC 流量也走代理,从而避免真实 IP 泄露。设置方法见下图:
很多人开了 梯子,但 WebRTC 仍然可能泄露真实 IP。
最简单的解决办法就是:
安装 WebRTC Network Limiter,让所有 WebRTC 流量走代理。
这样你的浏览器隐私保护才算真正完整。
除了 WebRTC 外,IPv6 也可能是泄露点,检测链接是:https://browserleaks.com/ip,解决方案是开启 IPv6 相关的代理。
HTTP(HyperText Transfer Protocol)就是浏览器和服务器之间"对话"的规则。你在浏览器输入一个网址,浏览器按照 HTTP 规则发一个请求,服务器按照 HTTP 规则回一个响应。
浏览器:「我要 /index.html」 → 请求(Request)
服务器:「给你,200 OK,内容如下...」 ← 响应(Response)
HTTP 从 1991 年诞生到现在,经历了 5 个大版本。每个版本都是为了解决上一个版本的痛点。
请求:GET /hello.html
响应:<html>Hello World</html>
(连接断开)
就这么简单粗暴。没有状态码,没有 Content-Type,啥都没有。
| 新增能力 | 说明 |
|---|---|
| 请求头 & 响应头 | 可以携带元数据了(Content-Type、Content-Length 等) |
| 多种方法 | GET、POST、HEAD |
| 状态码 | 200、404、500 等,知道请求成功还是失败了 |
| Content-Type | 可以传图片、音频、视频,不再局限于 HTML |
| 版本号 | 请求行里带上 HTTP/1.0
|
请求:
GET /logo.png HTTP/1.0
Host: www.example.com
Accept: image/png
响应:
HTTP/1.0 200 OK
Content-Type: image/png
Content-Length: 4096
(图片二进制数据)
(连接断开)
最大的问题:短连接。 每个请求都要经历 TCP 三次握手 → 传数据 → 四次挥手。
一个网页有 1 个 HTML + 10 个图片 + 3 个 CSS + 5 个 JS = 19 个请求,就要建立 19 次 TCP 连接。
连接1:[三次握手] → GET /index.html → [响应] → [四次挥手]
连接2:[三次握手] → GET /style.css → [响应] → [四次挥手]
连接3:[三次握手] → GET /logo.png → [响应] → [四次挥手]
...重复 19 次
每次握手和挥手都要消耗时间和系统资源,极其浪费。
虽然有些实现支持非标准的 Connection: keep-alive,但这不是规范的一部分,行为不统一。
默认开启 TCP 连接复用。一个连接可以发多个请求,不用每次都握手挥手。
HTTP/1.0:
连接1 → 请求1 → 响应1 → 断开
连接2 → 请求2 → 响应2 → 断开
连接3 → 请求3 → 响应3 → 断开
HTTP/1.1:
连接1 → 请求1 → 响应1 → 请求2 → 响应2 → 请求3 → 响应3 → 断开
理论上可以不等响应就发下一个请求:
客户端:请求1 → 请求2 → 请求3 →
等待...
服务端: ← 响应1 ← 响应2 ← 响应3
但实际几乎没人用(原因见下面的问题)。
不需要预先知道内容的总大小,边生成边发送:
HTTP/1.1 200 OK
Transfer-Encoding: chunked
5\r\n
Hello\r\n
6\r\n
World\r\n
0\r\n
\r\n
适用场景:服务端流式输出(比如 ChatGPT 的逐字输出)。
| 能力 | 说明 |
|---|---|
| Host 头必选 | 一个 IP 可以托管多个域名(虚拟主机) |
| Cache-Control | 更精细的缓存控制(替代 Expires) |
| Range 请求 | 支持断点续传(下载了一半断了,可以接着下) |
| 100 Continue | 先问服务器"我要发一个大文件,你准备好了吗?" |
| PUT / DELETE / OPTIONS / PATCH | 更多的方法,RESTful API 的基础 |
这是 HTTP/1.1 最致命的问题。
虽然支持管线化,但响应必须按请求的顺序返回。如果第一个请求处理很慢,后面的请求即使已经处理完了,也必须排队等着。
请求顺序:请求1(慢查询)→ 请求2(静态图片)→ 请求3(CSS)
实际情况:
请求1 ████████████████████░░░░░(处理中...3秒)
请求2 ░░░░░░░░░░░░░░░░░░░████░ (早就好了,但必须等请求1)
请求3 ░░░░░░░░░░░░░░░░░░░░░░██ (也在排队等)
就像高速公路只有一条车道,前面的大卡车开得慢,后面的跑车再快也超不过去。
为了绕开队头阻塞,浏览器的做法是 对同一个域名开 6 个并行 TCP 连接。
但这导致了新的问题:
每个请求都要带上完整的头部(Cookie、User-Agent、Accept 等),这些头部在同一个连接上几乎不变,但每次都要重复发送。一个大 Cookie 可能有 1-2KB,100 个请求就白白多传 100-200KB。
服务器不能主动给客户端推数据,只能客户端请求、服务器响应。想实现"服务器推送"只能用长轮询或 WebSocket。
在一条 TCP 连接上实现真正的并行传输。
HTTP/1.x 是文本协议(人能直接看懂),HTTP/2 改成了二进制协议。
所有数据被拆分成更小的 帧(Frame),每个帧有一个 Stream ID,标记它属于哪个请求/响应。
HTTP/1.1(文本):
GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
\r\n
HTTP/2(二进制帧):
┌──────────┬───────────┬──────────────┐
│ Length:9 │ Type:HEAD │ Stream ID: 1 │ HEADERS 帧
├──────────┴───────────┴──────────────┤
│ :method = GET, :path = /index.html │
└─────────────────────────────────────┘
多个请求/响应可以在同一个 TCP 连接上交错传输,互不阻塞。
HTTP/1.1:6 条连接,每条排队
连接1: [请求1 ████████████]
连接2: [请求2 ████]
连接3: [请求3 ██████]
连接4: [请求4 ███]
连接5: [请求5 ████████]
连接6: [请求6 ██]
HTTP/2:1 条连接,交错并行
连接1: [帧1a][帧2a][帧3a][帧1b][帧2b][帧3b][帧1c][帧2c]...
↑stream1 ↑stream2 ↑stream3 ↑stream1 ↑stream2
类比理解:
HTTP/1.1 就像有 6 条单行道,每条道上车要排队。HTTP/2 就像有一条超宽的高速公路,所有车可以同时跑,通过车牌号(Stream ID)区分。
这彻底解决了 HTTP 层的队头阻塞,也不再需要"域名分片"等 hack 手段。
HTTP/2 用 HPACK 算法压缩头部:
:method: GET、content-type: text/html),用索引号代替完整字符串第一次请求:
Cookie: session=abc123def456... (完整发送,同时存入动态表,索引 62)
第二次请求:
62 (只发一个索引号,省掉了几百字节)
效果:头部大小减少 85-95%。
服务器可以主动推送客户端可能需要的资源。
客户端请求 /index.html
服务器响应 /index.html
服务器主动推送 /style.css ← 不用等客户端解析 HTML 后再请求
服务器主动推送 /app.js ← 提前到达,减少等待
但实际效果争议较大——很难准确预测客户端需要什么,推错了反而浪费带宽。Chrome 已在 2022 年移除了对 Server Push 的支持。
可以给不同的请求设置优先级。比如 CSS 优先级高于图片,因为 CSS 会阻塞渲染。
HTTP/2 解决了 HTTP 层的队头阻塞,但底下的 TCP 层还有队头阻塞。
TCP 保证数据按序交付。如果一个 TCP 包丢失了,即使后面的包已经到达,TCP 也不会把它们交给应用层,而是等丢失的包重传回来。
TCP 传输:包1 → 包2(丢了!) → 包3 → 包4 → 包5
TCP 层:包1 ✓ 包2 ?等重传... 包3-5 已到但不能用
└── 所有 Stream 都被阻塞!
HTTP/1.1 开 6 个连接,一个连接丢包只影响那一个连接上的请求。HTTP/2 所有请求共用一个连接,一个包丢失会阻塞所有请求。
在网络质量差(丢包率 > 2%)的环境下,HTTP/2 的表现可能反而不如 HTTP/1.1。
HTTP/2 虽然协议本身不强制加密,但所有浏览器都要求 HTTP/2 必须走 HTTPS。TLS 握手需要额外的 1-2 个 RTT。
既然 TCP 的队头阻塞无法在 TCP 层面解决,那就 不用 TCP 了,改用 QUIC(基于 UDP)。
QUIC(Quick UDP Internet Connections)是 Google 设计的传输层协议,跑在 UDP 上。你可以把它理解为"重新实现了一个更好的 TCP"。
HTTP/2 的协议栈: HTTP/3 的协议栈:
┌──────────┐ ┌──────────┐
│ HTTP/2 │ │ HTTP/3 │
├──────────┤ ├──────────┤
│ TLS │ │ QUIC │ ← 把 TLS 融合进来了
├──────────┤ ├──────────┤
│ TCP │ │ UDP │
├──────────┤ ├──────────┤
│ IP │ │ IP │
└──────────┘ └──────────┘
QUIC 在传输层就支持多路复用。每个 Stream 独立管理自己的数据包顺序,一个 Stream 丢包不影响其他 Stream。
HTTP/2 + TCP:
Stream 1 的包丢了 → 所有 Stream 被阻塞等重传
HTTP/3 + QUIC:
Stream 1 的包丢了 → 只有 Stream 1 等重传,Stream 2/3/4 正常收发
TCP + TLS 需要的握手:
TCP 三次握手: 1 RTT(客户端→服务器→客户端)
TLS 1.2 握手: 2 RTT
TLS 1.3 握手: 1 RTT
──────────────────
总计:TCP + TLS 1.3 = 2 RTT(数据才能开始传)
QUIC 的握手:
首次连接:1 RTT(QUIC 把传输层握手和加密握手合并了)
重连(0-RTT):0 RTT(第一个包就可以带数据!)
类比理解:
TCP + TLS 就像打电话:先拨号等接通(TCP),再输密码验证身份(TLS),然后才能说话。QUIC 就像发微信:直接把消息和身份验证一起发出去,对方收到就能回。重连时更像对方还认识你,直接开聊。
TCP 连接用"源IP + 源端口 + 目标IP + 目标端口"四元组标识。你手机从 WiFi 切到 4G,IP 变了,TCP 连接就断了,必须重新握手。
QUIC 用一个 Connection ID 标识连接。IP 变了没关系,只要 Connection ID 不变,连接就能无缝切换。
TCP:WiFi → 4G = 连接断开 → 重新三次握手 + TLS 握手 → 恢复
QUIC:WiFi → 4G = IP 变了 → Connection ID 没变 → 无缝继续
对移动端体验提升巨大(电梯、地铁、从室内走到室外)。
HPACK 依赖严格的包顺序(因为动态表需要同步更新),和 QUIC 的乱序特性冲突。QPACK 解决了这个问题,允许在乱序到达的情况下也能正确解压头部。
| 问题 | 说明 |
|---|---|
| UDP 被运营商/防火墙限制 | 部分网络环境会限速或丢弃 UDP 包,需要回退到 TCP |
| 中间设备不友好 | 很多老旧的路由器、防火墙对 UDP 支持不好 |
| CPU 开销 | QUIC 在用户态实现(不在内核里),加解密和拥塞控制消耗更多 CPU |
| 生态还在成熟 | 服务端支持(Nginx 2022 才正式支持)、调试工具都还在完善 |
| 无法利用 TCP 的内核优化 | TCP 经过几十年优化,内核的 TCP 栈非常高效;QUIC 在用户态,暂时没法比 |
HTTP/0.9 HTTP/1.0 HTTP/1.1 HTTP/2 HTTP/3
年份 1991 1996 1997 2015 2022
传输层 TCP TCP TCP TCP UDP(QUIC)
连接方式 短连接 短连接 持久连接 多路复用 多路复用
并发能力 无 无 管线化(废了) Stream并行 Stream并行
头部格式 无 文本 文本 HPACK压缩 QPACK压缩
加密 无 可选 可选 事实强制 强制(内置TLS)
队头阻塞 - 有 有(HTTP层) 有(TCP层) 无!
握手RTT 1(TCP) 1(TCP) 1(TCP) 2-3(TCP+TLS) 0-1(QUIC)
服务器推 无 无 无 支持(已废弃) 无
HTTP 是明文传输,存在三大安全风险:
| 风险 | 场景 | 后果 |
|---|---|---|
| 窃听 | 连公共 WiFi 时,路由器能看到所有内容 | 密码、银行卡号泄露 |
| 篡改 | 运营商在网页中插入广告 | 页面被注入恶意代码 |
| 冒充 | 钓鱼网站伪装成银行 | 用户被骗输入密码 |
HTTPS 就是 HTTP + TLS,用加密解决这三个问题:
| 安全需求 | 解决方案 |
|---|---|
| 防窃听 | 对称加密(AES),加密传输内容 |
| 防篡改 | 消息认证码(MAC),验证数据完整性 |
| 防冒充 | 数字证书 + 非对称加密(RSA/ECDSA),验证服务器身份 |
理解 HTTPS 之前,必须搞清楚这两种加密方式。
加密和解密用同一把钥匙。
明文 "Hello" + 密钥 K → 加密 → 密文 "x7$f2"
密文 "x7$f2" + 密钥 K → 解密 → 明文 "Hello"
常见算法:AES、ChaCha20
有两把钥匙:公钥(公开)和 私钥(保密)。公钥加密的只有私钥能解,私钥加密的只有公钥能解。
公钥加密:明文 + 公钥 → 密文(只有私钥能解)
私钥签名:数据 + 私钥 → 签名(公钥可验证)
常见算法:RSA、ECDSA、Ed25519
既然对称加密快但密钥分发难,非对称加密安全但太慢,那就结合使用:
① 用非对称加密安全地交换一个"临时密钥"(只需要一次,慢就慢吧)
② 之后所有数据都用这个临时密钥做对称加密(快!)
这是 HTTPS 最核心的部分——建立安全连接的过程。
客户端 服务器
│ │
│ ① ClientHello │
│ - 支持的 TLS 版本 │
│ - 支持的加密套件列表 │
│ - 客户端随机数(Client Random) │
│ ─────────────────────────────────────────────────► │
│ │
│ ② ServerHello │
│ - 选定的 TLS 版本 │
│ - 选定的加密套件 │
│ - 服务器随机数(Server Random) │
│ ③ 服务器证书 │
│ ④ ServerHelloDone │
│ ◄───────────────────────────────────────────────── │
│ │
│ ⑤ 验证证书(证书链 → 根证书) │
│ ⑥ 生成预主密钥(Pre-Master Secret) │
│ ⑦ 用服务器公钥加密预主密钥 │
│ ⑧ ClientKeyExchange(发送加密后的预主密钥) │
│ ⑨ ChangeCipherSpec("我准备好了,之后都加密") │
│ ⑩ Finished(第一条加密消息) │
│ ─────────────────────────────────────────────────► │
│ │
│ ⑪ 用私钥解密得到预主密钥 │
│ ⑫ 双方各自计算会话密钥 │
│ = f(Client Random + │
│ Server Random + │
│ Pre-Master Secret) │
│ ⑬ ChangeCipherSpec │
│ ⑭ Finished │
│ ◄───────────────────────────────────────────────── │
│ │
│ ═══════ 之后所有数据用会话密钥做对称加密 ═══════════ │
需要 2 个 RTT 才能开始传数据。
最终的会话密钥由三个随机数共同生成:
会话密钥 = PRF(Pre-Master Secret, Client Random, Server Random)
三个随机数混合,即使其中一个被猜到,也无法推导出会话密钥。
TLS 1.3 是对 TLS 1.2 的大幅简化和优化。
| 改进 | TLS 1.2 | TLS 1.3 |
|---|---|---|
| 握手 RTT | 2 RTT | 1 RTT(首次),0 RTT(重连) |
| 密钥交换 | RSA 或 ECDHE | 只支持 ECDHE(前向安全) |
| 加密套件 | 几十种 | 精简到 5 种 |
| 废弃的算法 | - | 砍掉 RC4、DES、3DES、MD5、SHA-1 等不安全算法 |
| 握手加密 | 握手过程明文 | 握手消息也加密(ServerHello 之后) |
客户端 服务器
│ │
│ ClientHello │
│ - 客户端随机数 │
│ - 支持的加密套件 │
│ - 客户端的 ECDHE 公钥 ← 关键!直接带上了 │
│ ─────────────────────────────────────────────────► │
│ │
│ ServerHello │
│ + 服务器的 ECDHE 公钥 │
│ + 证书 │
│ + Finished │
│ ◄───────────────────────────────────────────────── │
│ │
│ 双方用 ECDHE 算出相同的密钥 │
│ Finished │
│ ─────────────────────────────────────────────────► │
│ │
│ ═══════ 1 RTT 后就可以传数据了 ═══════════ │
为什么快了? 因为 TLS 1.2 客户端要等拿到服务器证书和公钥后才能开始密钥交换,而 TLS 1.3 客户端直接在 ClientHello 里就把自己的 ECDHE 公钥发出去了(赌服务器会接受),省了一个来回。
如果之前连过这个服务器,客户端缓存了一个 PSK(Pre-Shared Key):
客户端:ClientHello + PSK + 加密的应用数据 →
服务器直接解密处理
第一个包就能带业务数据,延迟降到极致。
安全代价: 0-RTT 数据没有前向安全性,而且可能被重放攻击。所以只适合幂等请求(GET),不适合会产生副作用的操作(POST 转账)。
非对称加密解决了"传密钥"的问题,但引入了新问题:你怎么知道拿到的公钥是真的?
中间人攻击:
客户端 ←→ 中间人(伪装成服务器) ←→ 真正的服务器
中间人把自己的公钥发给客户端
客户端以为这是服务器的公钥,用它加密数据
中间人解密 → 看到明文 → 用服务器真正的公钥重新加密 → 发给服务器
引入一个双方都信任的第三方 —— CA(Certificate Authority,证书颁发机构)。
① 服务器生成公私钥对
② 服务器把公钥 + 域名信息提交给 CA
③ CA 验证服务器确实拥有这个域名
④ CA 用自己的私钥对"服务器公钥 + 域名信息"做数字签名
⑤ CA 颁发证书(包含:服务器公钥 + 域名 + CA 签名 + 有效期等)
⑥ 客户端收到证书后,用 CA 的公钥验证签名
⑦ 签名正确 → 公钥可信 → 建立加密连接
类比理解:
就像你去政务大厅办事,需要身份证(证书)。身份证是公安局(CA)发的,上面有你的照片(公钥)和钢印(CA 签名)。办事员通过钢印确认身份证是真的,从而相信你就是本人。
CA 的公钥又是谁来保证的?答案是证书链:
根证书(Root CA) ← 预装在操作系统/浏览器中,无条件信任
│
└── 中间证书(Intermediate CA) ← 根 CA 签发的
│
└── 服务器证书(End Entity) ← 中间 CA 签发的
验证过程从下往上:
为什么要分层?如果根 CA 直接签发所有证书,一旦根 CA 私钥泄露,后果不堪设想。分层后,即使中间 CA 出问题,只需要吊销这个中间 CA,不影响其他。
如果用 RSA 做密钥交换(TLS 1.2 的一种模式),一旦服务器私钥泄露,攻击者可以:
每次连接都生成临时的(Ephemeral) 公私钥对,用完即销毁。
连接1:临时密钥对 A → 会话密钥 X → 销毁临时密钥对 A
连接2:临时密钥对 B → 会话密钥 Y → 销毁临时密钥对 B
连接3:临时密钥对 C → 会话密钥 Z → 销毁临时密钥对 C
即使服务器的长期私钥泄露,也无法解密之前的通信,因为临时密钥已经不存在了。
这就是"前向安全"(Forward Secrecy)。TLS 1.3 强制要求使用 ECDHE,所有连接都具有前向安全性。
| 开销 | 说明 | 缓解方案 |
|---|---|---|
| 握手延迟 | TLS 1.2 多 2 RTT,TLS 1.3 多 1 RTT | 升级 TLS 1.3、会话复用、0-RTT |
| 加解密 CPU | AES 加解密消耗 CPU | 现代 CPU 都有 AES-NI 硬件加速,开销几乎可忽略 |
| 证书传输 | 证书链可能 3-5KB | OCSP Stapling 减少验证开销 |
| 内存 | 每个连接需要维护加密上下文 | 影响很小 |
现代环境下 HTTPS 的额外开销已经非常小了,Google 的数据显示 HTTPS 只增加了不到 2% 的 CPU 负载和不到 10ms 的延迟。
| 概念 | 解释 |
|---|---|
| HSTS | 服务器告诉浏览器"以后只用 HTTPS 访问我",防止降级攻击 |
| OCSP Stapling | 服务器主动把证书有效性证明发给客户端,省去客户端向 CA 查询 |
| CT(Certificate Transparency) | 所有颁发的证书必须公开记录,防止 CA 偷偷签发恶意证书 |
| SNI | 客户端在 TLS 握手时告诉服务器要访问哪个域名(一个 IP 上多个 HTTPS 站点) |
| ESNI / ECH | 加密 SNI,防止中间人知道你在访问哪个网站 |
| 证书固定(Pinning) | App 内置预期的证书指纹,防止中间人使用合法但非预期的证书 |
| Let's Encrypt | 免费、自动化的 CA,推动了 HTTPS 的全面普及 |
| 双向认证(mTLS) | 不仅服务器要证书,客户端也要证书(常见于企业内部、金融系统) |
1991 HTTP/0.9 ─── 只能传 HTML,连个状态码都没有
│
1996 HTTP/1.0 ─── 有了头部、状态码、多媒体,但每次请求都要新建连接
│
1997 HTTP/1.1 ─── 持久连接、缓存控制、断点续传,但有队头阻塞
│ └── HTTPS(TLS 1.0~1.2) 解决安全问题
│
2015 HTTP/2 ──── 二进制分帧、多路复用、头部压缩,但 TCP 层仍有队头阻塞
│ └── TLS 1.3:1-RTT 握手、前向安全
│
2022 HTTP/3 ──── 换用 QUIC(UDP),彻底消灭队头阻塞,0-RTT 连接,连接迁移
每一代都在解决上一代留下的问题,同时也带来了新的挑战。技术演进就是这样一步步往前走的。
从用户点击 App 图标,到第一个页面完整渲染出来,这段时间就是启动时间。
苹果把启动分为两个阶段:
用户点击图标
│
▼
┌──────────────────────────────┐
│ Pre-main 阶段 │ ← 系统在干活,你的代码还没执行
│ (dyld 加载 → Runtime 初始化) │
└──────────────┬───────────────┘
│
▼ main() 函数被调用
┌──────────────────────────────┐
│ Post-main 阶段 │ ← 你的代码开始执行
│ (AppDelegate → 首页渲染完成) │
└──────────────────────────────┘
│
▼
用户看到首页
苹果的标准:冷启动应在 400ms 以内完成,超过 20 秒系统会杀掉 App(Watchdog 机制)。
| 类型 | 条件 | 耗时 |
|---|---|---|
| 冷启动 | App 不在内存中,从零开始加载 | 最长 |
| 温启动 | App 刚被杀掉,部分数据还在系统缓存中 | 中等 |
| 热启动 | App 在后台,从挂起状态恢复 | 最短(几乎瞬间) |
启动优化主要针对冷启动,因为它是最慢的。
从点击图标到 main() 函数执行,中间经历了以下步骤:
① 内核 fork 进程,加载可执行文件(Mach-O)
│
▼
② dyld 接管,开始加载动态库
│
▼
③ Rebase & Bind:修复指针、绑定外部符号
│
▼
④ Objc Runtime 初始化:注册类、处理 Category
│
▼
⑤ 执行 +load 方法和 C++ 静态构造函数
│
▼
⑥ 调用 main()
iOS 的可执行文件格式叫 Mach-O(Mach Object)。内核先 fork 出一个新进程,把 Mach-O 文件映射到内存中。
Mach-O 的结构:
┌─────────────────┐
│ Header │ ← 架构信息(arm64)、文件类型
├─────────────────┤
│ Load Commands │ ← 告诉 dyld 需要加载哪些动态库、各段放在哪
├─────────────────┤
│ __TEXT 段 │ ← 代码(只读)
├─────────────────┤
│ __DATA 段 │ ← 全局变量、指针(可读写)
├─────────────────┤
│ __LINKEDIT 段 │ ← 符号表、签名信息
└─────────────────┘
dyld(dynamic link editor) 是苹果的动态链接器,负责把 App 依赖的所有动态库(.dylib / .framework)加载到内存中。
一个普通 App 依赖的动态库数量:
每个动态库的加载过程:
.dylib 文件由于 ASLR(地址空间布局随机化)的存在,每次启动时 Mach-O 被加载到内存的地址都不同。但代码里的指针是编译时确定的固定地址,所以需要修正。
Rebase(内部指针修正):
0x1000,ASLR slide 是 0x5000,修正后变成 0x6000
Bind(外部符号绑定):
UIKit 里的 UIViewController)绑定到实际的内存地址类比理解:
Rebase 就像搬家后更新通讯录里自己家人的地址(内部),Bind 就像更新朋友的地址(外部,需要打电话问)。
类越多,这一步越慢。 如果你的项目有上万个类,这里的耗时就很可观。
这是 Pre-main 阶段最后一步,也是开发者唯一能直接控制的部分:
+load 方法(按编译顺序,先父类后子类,先主类后 Category)__attribute__((constructor)) 的 C 函数这些代码在 main() 之前就执行了,而且是在主线程上同步执行。如果你在 +load 里做了耗时操作(比如 Swizzle 大量方法、读文件、网络请求),启动就会被拖慢。
main()
│
▼
UIApplicationMain()
│
▼
application:didFinishLaunchingWithOptions:
│ ← 大量初始化代码通常堆在这里
│ SDK 初始化、数据库初始化、推送注册、路由注册...
▼
创建 UIWindow、设置 rootViewController
│
▼
首页 viewDidLoad → viewWillAppear → viewDidAppear
│
▼
首帧渲染完成 → 用户看到界面
这个阶段的耗时主要来自 didFinishLaunchingWithOptions 中的各种初始化。
这是重点中的重点。苹果在 dyld 上做了三次大的版本迭代,每次都大幅优化了启动速度。
最初的版本,设计简单粗暴:
问题:随着系统库越来越多,启动速度越来越慢。
这是大家最熟悉的版本,做了很多重要优化:
| 优化项 | 做了什么 | 效果 |
|---|---|---|
| 共享缓存(dyld shared cache) | 把几百个系统库预先合并成一个大的缓存文件 | 系统库加载速度大幅提升 |
| 懒绑定(Lazy Binding) | 外部符号不在启动时全部绑定,而是在第一次调用时才绑定 | 减少启动时的 Bind 耗时 |
| 符号缓存 | 缓存已解析的符号地址 | 避免重复查找 |
这是 dyld 2 最重要的优化。
问题: 一个 App 可能依赖 300+ 个系统动态库。如果每次启动都逐个加载、解析,太慢了。
解决: 苹果在系统更新(或首次启动)时,预先把所有系统库打包合并成一个大文件,叫 dyld shared cache。存放在 /System/Library/dyld/。
打包前: 打包后:
UIKit.framework ─┐
Foundation.framework ├──→ dyld_shared_cache_arm64
CoreGraphics.framework│ (一个约 1-2GB 的文件)
libsystem.dylib ─┘
所有系统库的 Rebase/Bind 已经预先完成
所有系统库共用一个地址空间
好处:
dyld 2 引入了 PLT(Procedure Linkage Table) 机制:
启动时:
外部函数调用 → PLT 桩函数 → dyld_stub_binder(绑定真实地址并修改 PLT 条目)
首次调用后:
外部函数调用 → PLT 桩函数 → 直接跳到真实地址(已经绑定好了)
意思是:你的 App 引用了 UIKit 的 100 个函数,启动时不会全部绑定。而是在你第一次调用某个函数时,才去解析它的真实地址。这样启动时的 Bind 工作就分散到了运行时。
尽管有了很多优化,dyld 2 仍然是串行、逐步执行的:
解析 Mach-O → 查找依赖库 → 逐个加载 → Rebase → Bind → 初始化
└── 每一步都在主线程上同步执行 ──┘
而且:
dyld 3 是一次架构级别的重写,核心思想是:把能预先做的工作提前到"启动之外"去做。
┌─────────────────────────────────────────────┐
│ ① 进程外的 Mach-O 解析器 │
│ (App 安装/更新时运行,不在启动路径上) │
│ │
│ - 解析 Mach-O header 和依赖关系 │
│ - 查找所有依赖库的位置 │
│ - 执行安全校验(代码签名) │
│ - 把结果写入 启动闭包(Launch Closure) │
└────────────────────┬────────────────────────┘
│ 预先计算好的结果
▼
┌─────────────────────────────────────────────┐
│ ② 启动闭包缓存 │
│ │
│ 一个预先序列化好的数据结构,包含: │
│ - 所有 dylib 的加载地址 │
│ - 所有需要的 Rebase/Bind 信息 │
│ - 初始化顺序 │
│ - 已验证的代码签名结果 │
└────────────────────┬────────────────────────┘
│ 直接读取缓存
▼
┌─────────────────────────────────────────────┐
│ ③ 进程内的引擎 │
│ (真正在 App 启动时运行的部分) │
│ │
│ - 读取启动闭包(一次 mmap) │
│ - 按预先计算好的结果直接加载 │
│ - 极少的运行时计算 │
└─────────────────────────────────────────────┘
这是 dyld 3 最核心的概念。
类比: dyld 2 就像每次做菜都要翻菜谱、找食材、洗切配。dyld 3 相当于提前把所有食材洗好切好配好放在盒子里(启动闭包),做菜时直接下锅就行。
闭包在什么时候创建?
闭包里存了什么?
| 维度 | dyld 2 | dyld 3 |
|---|---|---|
| Mach-O 解析 | 每次启动都做 | 安装时做好,缓存到闭包 |
| 依赖库查找 | 每次启动都在文件系统搜索 | 闭包里已记录完整路径 |
| 代码签名校验 | 每次启动都验证 | 安装时验证,结果缓存 |
| Rebase/Bind 计算 | 每次启动都计算 | 闭包里已预计算 |
| 安全性 | 解析器在进程内,有被攻击风险 | 解析器在进程外,更安全 |
| 启动速度 | 慢 | 快 40%+ |
dyld 4 没有大的架构变化,主要是在 dyld 3 基础上做了进一步优化:
| 优化项 | 说明 |
|---|---|
| 统一两种模式 | dyld 3 有"有闭包"和"无闭包"两种路径(模拟器上不用闭包),dyld 4 统一成一种 |
| Just-In-Time 加载 | 更激进的懒加载,某些 dylib 推迟到真正使用时才加载 |
| 页面级别的按需加载 | 不再把整个 dylib 映射进来,而是按页(Page)按需加载 |
| 更好的 Swift 支持 | 优化了 Swift metadata 的初始化 |
| Compact Info | 用更紧凑的格式存储链接信息,减少 __LINKEDIT 段的大小 |
| Pre-warming | 系统会在后台预热高频 App 的启动闭包 |
dyld 1 → dyld 2 → dyld 3 → dyld 4
(原始) (iOS 3.1) (iOS 13) (iOS 16)
│ │ │ │
│ 共享缓存 启动闭包 统一架构
│ 懒绑定 进程外解析 按页懒加载
│ 符号缓存 签名缓存 Swift 优化
│ │ │ │
▼ ▼ ▼ ▼
全量加载 减少重复工作 大量工作移到 极致的懒加载
每次解析 分散绑定时机 安装/更新时 页级按需加载
每多一个动态库,就多一次查找、加载、签名校验的过程。
| 做法 | 效果 |
|---|---|
| 合并自己的动态 framework | 直接减少加载次数 |
| 能用静态库就不用动态库 | 静态库在编译时已合并进主二进制,启动时无额外加载 |
控制 Pods 的 use_frameworks!
|
改用 use_frameworks! :linkage => :static
|
| 苹果建议:第三方动态库不超过 6 个 | 超过就考虑合并 |
+load 是启动优化的头号敌人。
| 原方案 | 优化方案 |
|---|---|
在 +load 中做 Method Swizzling |
移到 +initialize(首次使用时才触发) |
在 +load 中注册路由 |
改用编译期方案(__attribute__((section)) 写入 Mach-O 段) |
在 +load 中初始化 SDK |
移到 didFinishLaunching 或更晚 |
+initialize vs +load 的关键区别:
+load:App 启动时全部执行,即使这个类从未被使用+initialize:某个类第一次收到消息时才执行,懒加载这是近年最热门的 Pre-main 优化手段。
问题: App 启动时需要执行很多函数,但这些函数分散在不同的内存页上。每访问一个新页面就会触发一次 Page Fault(缺页中断),内核需要从磁盘加载这一页到物理内存。每次 Page Fault 大约耗时 0.1~1ms。
启动时可能触发几百到上千次 Page Fault,累计就是几百毫秒。
解决: 把启动时需要执行的函数重新排列,让它们尽量排在相邻的内存页上,减少 Page Fault 次数。
优化前:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A │ X │ B │ Y │ C │ 启动需要 A→B→C,触发 3 次 Page Fault
│ │ │ │ │ │
└──────┴──────┴──────┴──────┴──────┘
优化后:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A │ X │ Y │ │ │ 启动需要 A→B→C,只触发 1 次 Page Fault
│ B │ │ │ │ │
│ C │ │ │ │ │
└──────┴──────┴──────┴──────┴──────┘
怎么做?
-fsanitize-coverage 插桩,收集启动时调用的所有函数的顺序Order File 路径不要把所有 SDK 初始化都堆在 didFinishLaunchingWithOptions 里。
┌─────────────────────────────────────────────────────┐
│ 分级初始化策略 │
├─────────────┬──────────────────┬────────────────────┤
│ 必须立即做 │ 首页出现后做 │ 用到时才做 │
│ │ │ │
│ 崩溃统计 │ 推送注册 │ 分享 SDK │
│ 日志系统 │ 数据统计 SDK │ 地图 SDK │
│ 网络库初始化 │ ABTest │ 支付 SDK │
│ 数据库核心表 │ 开屏广告 │ 蓝牙/定位 │
│ │ │ AI 相关 SDK │
└─────────────┴──────────────────┴────────────────────┘
| 优化手段 | 说明 |
|---|---|
| 首页用纯代码布局 | 避免 xib/storyboard 解析的开销 |
| 首页数据缓存 | 先展示上次的缓存数据,再异步请求新数据 |
| 预加载 | 在 viewDidLoad 发起网络请求,不要等 viewDidAppear
|
| 骨架屏 | 先展示骨架屏,给用户"已经在加载"的感觉 |
| 减少首页层级 | AutoLayout 约束越少越好,层级越浅越好 |
把不依赖 UI 的初始化工作放到子线程:
主线程:UI 配置 → rootVC 创建 → 首页渲染
子线程:SDK 初始化 / 数据库 Migration / 缓存预热
注意:UIKit 相关的操作必须在主线程,但大部分 SDK 的 init 是线程安全的。
在 Xcode 的 Scheme → Arguments → Environment Variables 中添加:
DYLD_PRINT_STATISTICS = 1 // 基础信息
DYLD_PRINT_STATISTICS_DETAILS = 1 // 详细信息
会输出类似:
Total pre-main time: 420.17 milliseconds (100.0%)
dylib loading time: 154.88 milliseconds (36.8%)
rebase/binding time: 37.43 milliseconds (8.9%)
ObjC setup time: 52.29 milliseconds (12.4%)
initializer time: 175.54 milliseconds (41.7%)
每一项对应的优化方向一目了然。
在 main() 开头和首页 viewDidAppear 各打一个时间戳,相减就是 Post-main 耗时。
更精细的测量可以用 Instruments 的 App Launch 模板(Xcode 11+),它会自动标注各阶段的耗时。
iOS 13+ 提供了 MetricKit 框架,可以在线上采集启动耗时数据:
MXAppLaunchMetric:冷启动 / 恢复启动的耗时分布按性价比从高到低排列:
| 优先级 | 优化项 | 预期收益 | 难度 |
|---|---|---|---|
| ★★★★★ | 删除 +load,改用 +initialize | 立竿见影 | 低 |
| ★★★★★ | didFinishLaunching 分级初始化 | 几十到几百 ms | 低 |
| ★★★★☆ | 减少动态库数量 / 改用静态库 | 每个库 5-10ms | 中 |
| ★★★★☆ | 首页数据缓存 | 体感提升明显 | 低 |
| ★★★☆☆ | 减少 OC 类数量 / 用 Swift Struct | Rebase 阶段提升 | 中 |
| ★★★☆☆ | 子线程并行初始化 | 分担主线程压力 | 中 |
| ★★☆☆☆ | 二进制重排 | 约 10-30% Page Fault 减少 | 高 |
| ★★☆☆☆ | 骨架屏 / 闪屏优化 | 体感优化(非真正提速) | 低 |
用户点击图标
│
├── 内核 fork 进程,加载 Mach-O
│
├── dyld 启动
│ ├── [dyld 3/4] 读取启动闭包(大量工作已预先完成)
│ ├── 加载动态库(系统库走 shared cache,极快)
│ ├── Rebase(修复内部指针)
│ ├── Bind(绑定外部符号,非懒绑定部分)
│ └── 加载完成
│
├── Objc Runtime 初始化
│ ├── 注册所有类
│ └── 处理 Category
│
├── Initializers
│ ├── +load 方法(尽量消灭它们!)
│ └── C++ 静态构造函数
│
╞══════════════════════ main() ═══════════════
│
├── UIApplicationMain
│
├── didFinishLaunchingWithOptions
│ ├── 🔴 必须立即做的初始化
│ ├── 🟡 延迟到首页出现后
│ └── 🟢 延迟到用到时
│
├── 首页 ViewController 初始化
│ ├── viewDidLoad(发起网络请求)
│ ├── viewWillAppear
│ └── viewDidAppear ← 首帧渲染完成
│
▼
用户看到首页 ✅
前段时间反复研读了蘑菇街 App 的组件化之路、蘑菇街 App 的组件化之路·续和iOS应用架构谈 组件化方案,然后又找到了其它一些研究组件化、模块化方案的文章,但是总觉得差点什么,所以还是决定从头开始思考。文章的标题起的好宽泛,感觉给自己挖了个深坑-。-,其实只是自己对组件化、模块化的一些看法、总结。
先总结下为什么要大动干戈的对代码分模块、拆组件。
代码量膨胀,不利于维护,更不利于新功能的开发
现在随便开发一个App的代码行数都是数以万计的,如果不对代码做合理的拆分,那简直就是灾难性的,估计只有最初的开发人员知道如何维护修改,如果换人开发的话,难以下手,更不用说开发新功能了。
不同业务代码耦合严重,难以多人合作,职责不分明
多人一起开发时,如果代码结构、模块化的不好,就很难对不同业务划分出分界线,难以明确各自的职责,牵一发动全身,出了问题更是容易相互扯皮(这个时候只能说一句“怪我咯o(╯□╰)o”),更不用提合并代码时的冲突了。
所以,合理的组织代码,划分模块、拆分组件是项目可以高效迭代的基础。
那到底什么是模块化、组件化?查资料的时候一会儿模块,一会儿组件,有什么联系,有什么区别?有人说这只是叫法习惯问题,知道大概意思就好,不用咬文嚼字,但是总觉得没有个“定义”感觉不踏实,所以还是求助了万能的维基百科=。=
维基百科的Modular programming的开头定义如下:
Modular programming is a software design technique that emphasizes separating the functionality of a program into independent, interchangeable modules, such that each contains everything necessary to execute only one aspect of the desired functionality.
接着,在Key aspects部分的开头也说了:
With modular programming, concerns are separated such that modules perform logically discrete functions, interacting through well-defined interfaces.
可以总结为:模块化的目的在于将一个程序按照其功能做拆分,分成相互独立的模块,以便于每个模块只包含与其功能相关的内容,模块之间通过接口调用。
当然,模块化编程的具体概念是包含了很多内容的,读者可以详细阅读下维基百科的定义。
关于组件化,能找到的比较接近的就是维基百科的Component-based software engineering,其开头内容如下:
Component-based software engineering (CBSE), also known as component-based development (CBD), is a branch of software engineering that emphasizes the separation of concerns in respect of the wide-ranging functionality available throughout a given software system. It is a reuse-based approach to defining, implementing and composing loosely coupled independent components into systems.
乍一看,这不是跟模块化Modular programming的定义很相似嘛=。=
的确,文中也提到组件化跟模块化是很类似的,都是主要为了对一个系统做拆分,比如文中提到:
All system processes are placed into separate components so that all of the data and functions inside each component are semantically related (just as with the contents of classes). Because of this principle, it is often said that components are modular and cohesive.
同时,组件还具有其他属性,如可替代性(substitutable),通过接口(interface)访问,可重用性(Reusability)等,读者可自行阅读。
难道模块化跟组件化真的是完全一样的?的确,很多时候两者的概念完全可以相互替换,在实践中更是经常混用。
在求助谷歌,甚至阅读了大量的前端技术等其它技术领域的组件化、模块化的文章后,我觉得如果真要将它们两者做个对比,大概总结如下:
当然,并不是说模块就不能被复用,还是要根据实际情况来看,使系统更加容易维护,开发更加方便,才是最终目的。
无论是模块化还是组件化,首先肯定是做拆分,但是如何拆分?怎么下手?依照什么标准?
下面简单总结一些方法。
横向拆分业务、功能模块
很多时候,一个完整的软件程序是同时为多种业务服务的,所有可以优先按照业务的不同,将整个系统进行拆分。
如一个电商类型的App,就可以分出商品浏览模块、订单模块、购物车模块、消息模块、支付模块等。又如微信这种社交型应用,可以拆分出联系人模块、朋友圈模块、聊天模块、消息模块等。
其实就是从用户使用的角度,按照功能的不同划分模块,当然,这种业务模块是要由各种技术模块作支撑的。
![]()
编辑
纵向拆分技术、架构模块
如果脱离业务,只从技术角度来看,则可以尝试纵向对系统拆分模块。
其实这里的纵向拆分跟对系统的架构做分层有点像=。=,现如今只要需要联网请求API的App都免不了有网络请求、数据缓存、数据加工处理、数据展示、反馈用户操作等行为,所有这些环节层层递进才能完成一个功能。
当开始着手规划一个完整软件系统,或者说App时,就可以按照这些环节划分模块,纵向分层次的组合,搭建出一个以技术模块组成的简易系统架构图,方便后续的开发,如下图。
![]()
编辑
大体上的技术模块划分好以后,就可以按照具体的需求,实现每个技术模块,乃至细分出更多的子模块,如缓存模块可能由键值对缓存(NSUserDefaults)、数据库缓存(SQLite、Realm)、图片缓存等子模块组成,根据具体情况而定。
从界面入手,拆分可视化组件
现在再来看看如何从界面入手拆分可复用的组件。假如有如下布局的界面:
![]()
编辑
很多时候,像界面里面的“搜索框”、“头像按钮”、“内容框”和显示提示用的“加载中”HUD,甚至整个内容的Cell,都是可能在很多地方出现的,而且本身的样式、功能比较集中。
如头像可能要支持点击跳转,头像图片圆角,内容框有特定的Padding和字体大小等,所以可以将这些界面上的元素“提”出来,单独封装成一个组件,供整个App复用。或者直接用第三方的组件,如图中的“加载中”HUD,就可以用SVProgressHUD、MBProgressHUD等开源库。
其实这里的组件有种sunnyxx大大提到过的“Self-Manager”的味道=。=,组件本身负责自己的所有功能、样式,参考:iOS 开发中的 Self-Manager 模式。当然跟前端的组件化也挺像的,如React里面的component,样式、功能都封装到component里面,以便更好地解耦复用。
从数据入手,拆分数据加工组件
再来看看从数据入手,拆分可复用的组件。假如有如下数据处理流程:
![]()
编辑
其实大部分时候,拆分模块、组件都是以清晰的流程、逻辑为基础的,就如上图的过程,当流程清晰后,可以拆分复用的组件也就“出来了”。
如从JSON数据实例化出对应的Entity对象,这个功能就是一个完整独立的组件,当然实际开发中会用Mantle、JSONModel等库实现。
以此类推,校验、格式化日期(如“几秒钟前、几天前”)、多语言等环节,都可以独立成一个个的组件。
当然,这里的组件一般是指能在多个模块使用的功能组件,如果只是在某个界面上才用的,倒不如放到ViewModel、Presenter等这些直接跟界面有关的类里面。
小节
上面的几种方法比较适合不知道如何下手时使用=。=,真正的开发中,还是要根据实际情况考虑,情况也会复杂些。不过倒是可以总结几点原则:
一切为了更加干净整洁的代码,“May the clean code be with you”
大家好,我是嘉豪。
这两天我又把 RunLoop 重新翻了一遍。这个话题在 iOS 里其实一点都不新,甚至已经算老朋友了,但有意思的是:很多同学平时天天在和它打交道,却未必真的知道它在干什么。
比如下面这些场景,你大概率都见过:
NSTimer 在滑动 ScrollView 的时候会“失灵”?performSelector:afterDelay: 有时候不执行?这些问题看起来东一榔头西一棒子,实际上背后都能收敛到同一个东西:RunLoop。
所以这篇文章,我不打算只聊“RunLoop 是什么”,而是想带大家把这件事真正串起来:它和线程是什么关系、内部都有哪些角色、每一轮循环在做什么,以及它到底怎么影响我们平时的业务开发。RunLoop 本质上是线程基础设施的一部分,是一个事件处理循环:有事就处理,没事就让线程休眠;主线程的 RunLoop 会在应用启动过程中由系统自动建立并运行,子线程则通常需要你自己决定是否显式启动。
我一直觉得,RunLoop 这个东西最容易被误解的地方,在于它听起来太“底层”了,于是很多人会下意识觉得:业务开发也用不上。
但真相往往比较朴素,甚至有点滑稽:你不是用不上 RunLoop,而是你天天在被 RunLoop 影响。
主线程为什么能不断响应点击、手势、定时器、刷新 UI?因为它背后一直有一个 RunLoop 在接收事件、分发事件、决定什么时候睡眠、什么时候醒来。Apple 官方对它的定义也很直白:RunLoop 是线程关联的基础设施之一,用来调度任务并协调传入事件的接收。
所以理解 RunLoop,不只是为了背面试题,而是为了在遇到卡顿、定时器异常、线程保活、异步回调这些问题时,不至于两眼一黑,开始对着代码做法事。
先别急着上源码,我们先用最朴素的方式理解它。
如果让我们自己写一个“线程不退出,但能不断处理事件”的模型,伪代码大概会长这样:
function loop() {
while (!stopped) {
const event = getNextEvent();
if (event) {
handle(event);
} else {
sleep();
}
}
}
RunLoop 的本质,和这个思路几乎一模一样。它是一个“事件循环”模型:线程进入循环后,反复执行“接收消息 -> 处理消息 -> 没消息就休眠 -> 被唤醒后继续处理”这一套流程。Apple 官方文档也明确说明,RunLoop 是一个 event processing loop;而从经典的 CFRunLoop 源码解析视角来看,它也完全可以理解成线程内部长期运行的事件循环。
所以从结果上看,RunLoop 解决的是两个核心问题:
这个设计非常重要。否则主线程如果一直死循环轮询事件,手机发热和掉电会快得像开了涡轮;如果线程处理完一个任务就退出,那 App 也根本不可能持续响应事件。宇宙不会允许这种离谱工程存在太久。
这一点其实是 RunLoop 最关键的前置知识。
可以先记住一句话:RunLoop 和线程是一一对应理解的。
Apple 文档里给出的说法是:每个线程都有关联的 RunLoop 对象,主线程的 RunLoop 会由应用框架自动配置并运行,而二级线程是否运行 RunLoop,则取决于你自己。只有在你真的需要它的时候,才需要显式启动。
这句话翻译成人话就是:
还有一个很容易踩坑的点:RunLoop 里必须至少有一个输入源(source)或者 timer,否则一启动就会立刻退出。 Apple 官方文档对此写得很直白。
所以,很多同学在子线程里写个 Timer,结果发现根本不回调,本质原因通常不是 Timer 坏了,而是线程的 RunLoop 根本没跑,或者刚跑起来就退出了。
从概念上讲,一个 RunLoop 主要围绕四类东西运转:
ModeSourceTimerObserverApple 文档里把 RunLoop Mode 描述为:一组要监听的 input sources、timers,以及要通知的 observers 的集合。每次 RunLoop 运行时,只会在某个特定 mode 下处理对应的事件;不属于当前 mode 的 source/timer,不会在这一轮被处理。
很多人第一次看 Mode,会觉得这名字有点抽象。其实你可以把它理解成:
RunLoop 当前这一轮,只看哪一组事件。
这就像你开了一个筛子。默认状态下,线程处理一部分事件;当用户开始拖拽 ScrollView 时,RunLoop 可以切到另一个 mode,只处理和拖拽更相关的输入,暂时忽略别的一些东西。Apple 也明确说明了,mode 的作用是根据 source 来过滤事件,而不是根据事件类型本身来过滤。
常见的几个模式可以先记住:
NSDefaultRunLoopMode / kCFRunLoopDefaultMode:默认模式,大多数情况下主线程都在这个模式下运行。UITrackingRunLoopMode:控件跟踪时使用的模式,比如滑动列表时。Apple 当前文档对它的描述很直接:这是 tracking 发生时使用的模式,可用于让某些 timer 在 tracking 期间继续触发。NSRunLoopCommonModes / .common:这是一个“伪 mode”,表示一组 common modes。把对象加到这里后,RunLoop 会在所有 common modes 下都监控它。Apple 官方主要把 input source 分成两类:
如果你平时看的是 CFRunLoop 源码分析文章,那还会经常见到 Source0 和 Source1 这套说法。可以先粗暴理解成:
Source0:更偏“手动触发”的 source;Source1:更偏“基于 port 被唤醒”的 source。这两套说法并不冲突,只是一个更偏官方抽象分类,一个更偏底层实现语境。
Timer 属于时间源。Apple 文档里强调了两件很重要的事:
这也是为什么你不能把 NSTimer 当成一把精确到毫秒的手术刀。它更像一个“尽量按时提醒你”的闹钟,而不是原子钟。
Observer 不产生事件,它负责观察 RunLoop 当前走到了哪一步。Apple 文档列出的典型观察时机包括:
这个东西很关键,因为系统里很多“顺便做一下”的工作,恰恰就是挂在这些观察点上的。
Apple 官方文档把一次 RunLoop 的执行顺序列得很清楚,大体可以压缩成下面这条主线:先通知 observer -> 处理 timer/source -> 没事就休眠 -> 被 timer、source、超时或显式唤醒后再继续处理。
为了更好理解,我把它翻译成一个更贴近开发直觉的版本:
1. 进入 loop
2. 通知 observer:我要处理 timer 了
3. 通知 observer:我要处理 source 了
4. 处理非 port 的 source
5. 如果没有可立即处理的事,就准备休眠
6. 线程休眠,等待被 timer / source / wakeup 唤醒
7. 被唤醒后,处理对应事件
8. 决定是继续下一轮,还是退出 loop
如果你看过一些调用栈或者源码解析文章,会发现 RunLoop 的底层核心休眠/唤醒机制和 mach port 消息密切相关;这也是为什么它能做到“没事就睡,有事马上醒”。
NSTimer 会不执行?这个问题几乎是 RunLoop 的必考题了。
原因并不神秘:你创建出来的 Timer,大概率默认被加在了 DefaultMode 里;而当你拖拽 ScrollView 时,主线程 RunLoop 会进入 tracking 相关的 mode,这时候默认 mode 下的 timer 就不会被处理。 Apple 文档明确说明,timer 和 source 都和特定 mode 绑定;不在当前 mode 里的对象,要等 RunLoop 以后切回支持它的 mode 才会触发。
所以解决思路也就顺理成章了:把 Timer 加到 common modes。
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0
repeats:YES
block:^(NSTimer * _Nonnull timer) {
NSLog(@"tick");
}];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
common 本质上不是一个真正的独立 mode,而是一个“公共模式集合”。把 timer 加进去以后,它就能在多种 common mode 下都被监控,自然也就不会在滚动时轻易“哑火”了。
如果只把 RunLoop 理解成“Timer 的家”,那就太小看它了。结合 Apple 文档和经典的 CFRunLoop 源码解析,RunLoop 至少和下面这些机制高度相关。
经典分析里提到,主线程 RunLoop 上挂了和 autorelease pool 相关的 observer:进入 loop 时创建池,准备休眠时销毁旧池并重建,退出 loop 时再做一次销毁。也就是说,我们很多主线程回调,其实天然就被 autorelease pool 包着。
触摸、手势、各种输入事件之所以能不断进入 App,被分发到 UIWindow、UIView、UIGestureRecognizer,背后同样离不开主线程 RunLoop 对事件源的处理。经典解析中也展示了系统事件如何通过 Source1 进入应用内部分发链路。
很多 setNeedsLayout、setNeedsDisplay 并不会让 UI 立刻重绘,而是先标记“需要更新”,再等到 RunLoop 的某个合适时机统一提交。经典分析中把这部分和 BeforeWaiting / Exit 这些阶段关联了起来。
performSelector 系列方法Apple 官方文档明确说了:performSelector:onThread: 这一类调用,目标线程必须有一个 active run loop;performSelector:withObject:afterDelay: 也是在当前线程的下一次 run loop cycle 中调度执行。
所以它们“有时不执行”的根本原因,经常不是 selector 本身有问题,而是:
Apple 给出的建议其实很实用:只有当子线程需要更强交互性时,才需要显式运行 RunLoop。 比如下面这些场景:
performSelector...
如果你的线程只是做一个明确的、一次性的耗时任务,比如图片解码、文件处理、纯计算,那干完退出往往更合适,没必要强行塞一个 RunLoop 进去。别什么都开火车,线程也会累。
一个很常见的“子线程保活”写法大概是这样:
- (void)threadMain {
@autoreleasepool {
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}
这个写法的核心不是 NSMachPort 本身有多神秘,而是:先往 RunLoop 里塞一个 source,避免它因为空空如也而直接退出,然后再让 RunLoop 跑起来。 Apple 官方文档也明确说明,secondary thread 的 RunLoop 在启动前必须至少附着一个 input source 或 timer,否则会立刻结束。
不是。主线程是系统自动托管的,子线程通常需要你自己决定是否获取、配置并运行 RunLoop。
NSTimer 不回调,就是 Timer 不准也不一定。它可能只是:
NSRunLoop 和 CFRunLoop 完全一样,随便跨线程改这也不对。Apple 文档提到,Core Foundation 那套 API 通常是线程安全的;但 NSRunLoop 本身并不像底层 CFRunLoopRef 那么天然线程安全,最好只在拥有它的线程里修改它。
到这里,RunLoop 的主线其实就已经很清楚了。
它不是某个冷门 API,也不只是 NSTimer 的背景板。它本质上是线程背后的事件循环机制,负责把 source、timer、observer 和 mode 组织起来,让线程做到:
很多平时看起来零碎的问题,比如 Timer 在滚动时失效、子线程任务不回调、performSelector 不执行、UI 为什么不是立刻刷新,本质上都能用 RunLoop 这套模型解释清楚。
所以 RunLoop 这东西,真的不是为了面试八股才学。它更像是一把钥匙:平时你可能把它丢在抽屉里,但一旦遇到线程、事件、时序、刷新相关的问题,它就会突然变得非常好用。
GetX 是 Flutter 生态中的一个 全家桶式框架,它不只是一个状态管理方案,而是把状态管理、路由导航、依赖注入三件事打包在了一起。
很多人第一次接触 GetX 的感受是:"怎么什么都能做?" —— 这既是它的优势,也是它的争议所在。
GetX
├── 状态管理(State Management) ── 替代 setState / Provider / Bloc
├── 路由管理(Route Management) ── 替代 Navigator 系列 API
└── 依赖注入(Dependency Injection)── 替代 Provider / get_it
一个字:简单。
用 Bloc 写一个计数器功能,你需要:Event 类 + State 类 + Bloc 类 + BlocProvider + BlocBuilder,至少 4~5 个文件。
用 GetX 写同样的功能:一个 Controller 类 + Obx(() => Text()),两行搞定。
GetX 提供了两种状态管理方式,理解它们的区别是用好 GetX 的第一步。
update() 通知刷新setState(),但作用域更小工作原理很朴素:Controller 内部维护一个监听者列表,调用 update() 时遍历通知所有 GetBuilder Widget 重建。本质就是一个 手动版的观察者模式。
.obs 标记,变化时自动触发 UI 刷新update()
// 声明
var count = 0.obs;
// 修改(自动触发 UI 刷新)
count.value++;
// UI 监听
Obx(() => Text('${controller.count}'))
.obs 的底层原理.obs 是 GetX 最核心的魔法。要理解它,需要拆解三个问题:
问题一:.obs 做了什么?
当你写 var count = 0.obs,实际上是把一个普通的 int 包装成了一个 RxInt 对象。这个 Rx 对象内部持有:
_value)Stream)它本质上就是一个 带通知能力的值容器。
问题二:修改值时发生了什么?
当你写 count.value++,Rx 对象的 set value 被触发。在 setter 内部:
_value
Stream 广播一个"值变了"的事件问题三:Obx 怎么知道要监听哪些变量?
这是 GetX 最巧妙的设计。Obx 并不需要你手动告诉它"我依赖了哪些变量",它是 自动收集依赖 的。
原理分三步:
Obx 在首次 build 时,先打开一个"全局监听开关"() => Text('${count.value}'))count.value 的 getter 被调用时,Rx 对象检测到"监听开关"是打开的,就把自己注册到 Obx 的依赖列表中之后任何被收集到的 Rx 变量发生变化,Obx 就会自动重建。
Obx 首次 build
→ 开启"依赖收集模式"
→ 执行 builder: () => Text('${count.value}')
→ count.value 的 getter 被调用
→ count(Rx 对象)发现正在收集依赖,把自己注册进去
→ 关闭"依赖收集模式"
→ 依赖收集完成:[count]
之后 count.value++ 触发
→ Rx 广播变化
→ Obx 收到通知,重新执行 builder
这个机制和 Vue 3 的
watchEffect、MobX 的autorun原理几乎一模一样 —— 基于 getter 劫持的自动依赖收集。
相比之下,GetBuilder 的原理简单得多:
GetBuilder 在 initState 时,把自己注册到 Controller 的监听者列表update(),遍历列表,调用每个 GetBuilder 的 setState
GetBuilder 在 dispose 时,从列表中移除自己没有 Stream、没有依赖收集,就是最朴素的 观察者模式 + setState。
| 场景 | 推荐 | 原因 |
|---|---|---|
| 表单页面、简单列表 | GetBuilder | 手动控制,性能开销最小 |
| 数据频繁联动、多变量交叉依赖 | Obx + .obs | 自动依赖收集,代码更简洁 |
| 超大列表、高性能场景 | GetBuilder + update([id])
|
可以精确控制刷新范围 |
GetX 内置了一套依赖注入系统,核心 API 就两个:
Get.put(Controller()) —— 注册Get.find<Controller>() —— 获取你可以把它理解成一个 全局的"服务柜台":先把东西放进去,需要的时候按类型取出来。
GetX 内部维护了一个 全局的 Map<String, Object>,key 是类型名(或类型名 + tag),value 是实例。
全局容器(简化理解):
{
"HomeController": HomeController 实例,
"UserService": UserService 实例,
"ApiClient_v2": ApiClient 实例(带 tag)
}
Get.put() 就是往 Map 里写,Get.find() 就是从 Map 里读。
| 方式 | 何时创建 | 何时销毁 | 适用场景 |
|---|---|---|---|
Get.put() |
立即创建 | 手动或路由关闭时 | 页面 Controller |
Get.lazyPut() |
首次 find 时 |
同上 | 可能用不到的依赖 |
Get.putAsync() |
立即创建(支持异步) | 同上 | 需要异步初始化的服务 |
Get.create() |
每次 find 都新建 |
不自动销毁 | 每次需要新实例的场景 |
GetX 最被低估的能力之一。它有一套 智能内存管理机制,可以在路由关闭时自动销毁关联的 Controller。
三种模式:
GetBuilder / GetX 使用的 Controller 才自动管理find 时重新创建这解决了 Flutter 状态管理中一个常见的痛点:谁来负责销毁 Controller? 在 Provider/Bloc 中你需要手动处理,GetX 帮你自动化了。
Flutter 原生路由的痛点:
context,在非 Widget 层(Service、Controller)中很难拿到GetX 的路由通过全局 NavigatorKey 持有 Navigator 的引用,所以 不需要 context 就能跳转。
GetX 路由的核心做了两件事:
第一:全局 NavigatorKey
GetX 在 GetMaterialApp 初始化时,创建了一个全局的 GlobalKey<NavigatorState>,保存在静态变量中。之后所有路由操作都通过这个 key 拿到 Navigator,不再依赖 context。
第二:路由与依赖注入联动
这是 GetX 路由最独特的地方。当你用 Get.to(HomePage()) 跳转时:
GetBuilder 或 Bindings),自动 put 进依赖容器pop 时,自动 delete 关联的 ControllerGet.to(HomePage())
→ 创建路由
→ 自动注册 HomeController(如果有 Binding)
→ 用户在 HomePage 操作...
→ Get.back()
→ 路由 pop
→ 自动销毁 HomeController
→ 内存释放
这就形成了一个 路由驱动的生命周期管理:Controller 的生死和页面的进出自动绑定。
Bindings 是连接路由和依赖注入的纽带。它定义了"进入某个页面时需要准备哪些依赖"。
你可以把它类比为 iOS 的 viewDidLoad —— 页面加载时做初始化工作,页面销毁时自动清理。
GetX 路由支持中间件,可以在路由跳转前/后插入逻辑:
中间件按优先级执行,可以中断跳转(返回 null 表示拦截),和 Web 框架的中间件概念一致。
GetX 是个全家桶,除了三大核心模块,还打包了很多实用工具:
| 能力 | 说明 |
|---|---|
| 国际化(i18n) |
'hello'.tr 即可翻译,动态切换语言 |
| 主题切换 |
Get.changeTheme() 一行切换深色/浅色 |
| 网络请求 |
GetConnect 封装了 HTTP 客户端 |
| 本地存储 |
GetStorage 类似 SharedPreferences 但更快 |
| 响应式表单验证 | 配合 .obs 做实时校验 |
| Snackbar / Dialog / BottomSheet | 不需要 context 的全局弹窗 |
| Worker |
ever / debounce / interval 等响应式工具 |
Worker 是 GetX 响应式系统中很实用的工具,用于对 .obs 变量的变化做 节流、防抖、一次性监听 等处理:
| Worker | 行为 |
|---|---|
ever(count, callback) |
每次变化都执行 |
once(count, callback) |
只在第一次变化时执行 |
debounce(count, callback) |
停止变化后一段时间才执行(搜索场景) |
interval(count, callback) |
变化期间按固定间隔执行(节流) |
底层实现就是对 Rx 的 Stream 做了 listen / first / debounceTime / throttle 等 Dart Stream 操作的封装。
把所有模块串起来,GetX 的底层可以概括为三个核心机制:
.obs 变量(Rx 对象)
└── 内部持有 Stream
└── Obx / Worker 订阅 Stream
└── 变量变化 → Stream 广播 → 订阅者响应
这是 Dart 语言自带的 Stream 机制,GetX 没有发明新东西,只是在 Stream 之上做了 语法糖封装(.obs、Obx、ever 等),降低了使用门槛。
静态 Map<String, InstanceInfo>
└── Get.put() 写入
└── Get.find() 读取
└── Get.delete() 删除
└── SmartManagement 自动清理
没有复杂的 IoC 容器,就是一个 Map。简单直接。
GetMaterialApp 初始化 → 持有全局 NavigatorKey
└── Get.to() / Get.back() → 通过 Key 拿到 Navigator → 执行路由操作
└── 路由变化 → 触发 Bindings → 联动依赖注入的创建/销毁
| 项目类型 | 推荐度 | 建议 |
|---|---|---|
| 个人项目 / Demo | 强烈推荐 | 快速出活 |
| 中小型商业项目 | 推荐 | 配合良好的分层架构使用 |
| 大型团队协作项目 | 谨慎 | 建议考虑 Riverpod / Bloc,或严格约束 GetX 的使用范围 |
| 学习 Flutter 阶段 | 不推荐先学 | 先理解 Flutter 原生机制,再用 GetX 提效 |
| 维度 | GetX | Provider | Riverpod | Bloc |
|---|---|---|---|---|
| 学习成本 | 低 | 低 | 中 | 高 |
| 模板代码量 | 极少 | 少 | 中 | 多 |
| 依赖 context | 不需要 | 需要 | 不需要 | 需要 |
| 内置路由 | 有 | 无 | 无 | 无 |
| 内置依赖注入 | 有 | 自身就是 DI | 自身就是 DI | 无(需配合) |
| 可测试性 | 中 | 高 | 高 | 高 |
| 官方推荐 | 否 | 是(早期) | 是(现在) | 社区主流 |
| 适合规模 | 小中型 | 中型 | 中大型 | 大型 |
GetX 的哲学是 "约定优于配置,简单优于正确"。它牺牲了一些架构上的严谨性,换来了极致的开发效率。理解它的底层原理(Rx Stream + 全局 Map + 全局 NavigatorKey),你就能用好它,也知道它的边界在哪里。
最早大家都是 手动写代码埋点:在每个按钮点击、页面出现的地方,手动调用 track("事件名")。这种方式最精确,但有两个痛点:
于是业界开始思考:能不能让机器自动采集?能不能让运营自己配置?
这就催生了三种埋点方式的演进:
手动代码埋点 → 无痕埋点(全自动) → 可视化埋点(半自动)
| 维度 | 代码埋点 | 无痕埋点 | 可视化埋点 |
|---|---|---|---|
| 谁来埋 | 开发 | 机器自动 | 运营圈选 |
| 需要发版吗 | 需要 | 不需要 | 不需要 |
| 能带业务参数吗 | 能(商品ID、金额等) | 不能 | 有限支持 |
| 数据量 | 按需 | 巨大 | 按需 |
| 精确度 | 最高 | 最低 | 中等 |
不写任何埋点代码,SDK 自动采集用户的所有操作。
iOS 有个强大的 Runtime 机制叫 Method Swizzling —— 可以在运行时把系统方法的实现"偷偷换掉"。
举个例子,iOS 中所有按钮点击最终都会走 UIControl 的 sendAction:to:forEvent: 方法。SDK 做的事情就是:
原本的调用链:
用户点击按钮 → sendAction → 执行业务逻辑
Swizzle 之后:
用户点击按钮 → SDK 拦截,记录"谁在哪个页面点了什么" → 再调用原始 sendAction → 执行业务逻辑
业务方完全无感知,SDK 悄悄在中间插了一层数据采集。
| 拦截点 | 能采集到什么 |
|---|---|
UIControl 的点击事件 |
按钮、开关、滑块等操作 |
UITableView 的 Cell 点击 |
列表项点击 |
UIViewController 的页面出现 |
页面浏览量(PV) |
UIGestureRecognizer |
手势操作 |
SDK 需要给每个 UI 元素生成一个 唯一标识(ViewPath),方式是沿着 View 层级往上爬,记录每一层的类名和位置:
UIWindow / UINavigationController / 首页VC / UIView / UITableView / 第3个Cell / 购买按钮
转化成路径就是:
UIWindow[0]/UINavigationController[0]/HomeVC[0]/UIView[0]/UITableView[0]/Cell[3]/UIButton[0]
这就像是给每个按钮一个"门牌号"。
门牌号不稳定:UI 稍微改一下层级(比如在按钮外面多套一层 View),路径就变了,之前的数据就对不上了
只知道行为,不知道内容:SDK 能告诉你"用户点了第3个 Cell 里的按钮",但不知道那个 Cell 显示的是什么商品、多少钱
数据量爆炸:用户每一次点击、每一次滑动都会上报,90% 的数据可能没人看
在无痕埋点的基础上,加了一个"后台圈选"的功能。运营在后台看到 App 截图,用鼠标点选要追踪的元素,SDK 只上报被选中的事件。
第一步:App 和后台建立 WebSocket 连接
第二步:App 截图 + View 树结构 → 发给后台
(后台能看到 App 当前界面的"透视图")
第三步:运营在后台的截图上点击"立即购买"按钮
→ 后台自动识别出这个按钮的 ViewPath
→ 运营给它命名为 "click_buy_button"
第四步:后台把配置下发给 SDK
{ viewPath: "xxx", eventName: "click_buy_button" }
第五步:SDK 在 Hook 点拦截事件时,拿当前元素的 ViewPath 去配置表里匹配
→ 匹配到了才上报,匹配不到就忽略
无痕埋点:先采集所有数据 → 后期在数据平台筛选(先采后筛)
可视化埋点:先配置要采什么 → 只采集配置过的(先筛后采)
这就像是:
成熟的 App 不会只用一种,而是 混合使用:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 页面 PV、App 启动/退出 | 无痕埋点 | 标准化行为,不需要业务参数 |
| 运营活动按钮、Tab 切换 | 可视化埋点 | 需求变化快,运营自助配置 |
| 支付、注册、加购、分享 | 代码埋点 | 需要精确的业务参数(金额、商品ID) |
一个简单的原则:
数据越重要、越需要业务参数的事件,越应该用代码埋点;越通用、越标准化的行为,越适合自动采集。
ViewPath 不稳定是无痕埋点和可视化埋点最大的技术挑战。业界的应对思路:
传统方案依赖 UIKit 的 View 层级树,但 SwiftUI 的渲染机制完全不同 —— 开发者写的 Button 和实际渲染出来的 View 层级之间没有稳定的对应关系。
目前的解决方向:
.tracked("buy_button") 的声明式埋点这个领域还在发展中,还没有像 UIKit 时代那样成熟的方案。
| 平台 | 特点 |
|---|---|
| GrowingIO | 国内可视化埋点先驱,圈选体验好 |
| 神策 Sensors Analytics | 全埋点 + 可视化 + 代码埋点全覆盖,iOS SDK 开源 |
| Mixpanel | 可视化埋点 + 代码埋点,国际主流 |
| Heap | 全埋点理念的代表,"Capture Everything" |
| Firebase Analytics | Google 出品,自动事件 + 自定义事件 |
本文从一个真实的取色 Bug 出发,系统梳理 iOS 图片取色所需的基础知识,包括色彩模型、色彩空间、位深度、像素格式、图片文件格式,以及业界主流的取色方案对比。
我的 Github: github.com/RickeyBoy/R…
在开发一个取色功能时,遇到了一个诡异的问题:用户用 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。当遇到这类图片时:
× 4 计算UInt8 读只取了低 8 位,再除以 255,得到的颜色完全不对要理解并修复这个问题,需要掌握一系列图片和色彩的基础知识。
色彩模型定义如何用数字描述颜色,但不定义具体哪个数字对应哪个物理颜色(那是色彩空间的事)。
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) 的视觉差异并不相同。
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。
CIELAB(Lab*)是国际照明委员会(CIE)在 1976 年定义的感知均匀色彩模型,与设备无关。
| 分量 | 范围 | 说明 |
|---|---|---|
| L* | 0 ~ 100 | 明度。0=黑,100=白 |
| a* | 约 -128 ~ +127 | 绿色(负)↔ 红色(正) |
| b* | 约 -128 ~ +127 | 蓝色(负)↔ 黄色(正) |
CIELAB 的核心价值:给定的数值变化(ΔE)在整个色彩空间内对应近似相等的视觉变化。当你需要判断"取到的颜色跟目标色差多少"时,Lab 空间的 ΔE 计算比 RGB 欧氏距离有意义得多。
小结
| 模型 | 最佳用途 |
|---|---|
| RGB | 像素存储、渲染、取色底层数据 |
| HSB | 取色器 UI、基于色相的颜色操作 |
| Lab | 颜色差异度量、感知均匀的颜色比较 |
色彩空间 = 色彩模型 + 三个具体定义:
同样的 (255, 0, 0) 在 sRGB 和 Display P3 里是不同的红色。
| 属性 | 值 |
|---|---|
| 原色 | 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。
| 属性 | 值 |
|---|---|
| 原色 | 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 |
| 属性 | 值 |
|---|---|
| 原色 | 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 会自动进行色域映射。
| 属性 | 值 |
|---|---|
| 原色 | 部分使用虚拟原色以最大化覆盖 |
| 白点 | 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 处理,专业编辑 |
几个关键事实:
除整数位深外,iOS 还支持浮点格式:
| 格式 | 范围 | 用途 |
|---|---|---|
| 16-bit 半精度浮点 | ~6.1e-5 到 65504 | Core Image、Metal、扩展范围色 |
| 32-bit 单精度浮点 | IEEE 754 全范围 | Core Image、科学计算 |
浮点格式可以表示 [0, 1] 范围之外的值,这对扩展范围颜色(extended range colors)和 HDR 内容至关重要。
当你拿到一个 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,不能假设紧密排列。
在 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) |
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
为什么用预乘?
取色时的影响:如果 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
创建 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 |
| 属性 | 支持情况 |
|---|---|
| 位深 | 仅 8-bit |
| 通道 | 3 (RGB),不支持 Alpha |
| 色彩空间 | sRGB(默认),可通过嵌入 ICC 支持 P3、Adobe RGB |
| 压缩 | 有损(DCT) |
JPEG 压缩原理:图片从 RGB 转换为 Y'CbCr(亮度 + 色度),色度通道降采样(4:2:0 或 4:2:2),每个 8×8 块进行 DCT 变换、量化(有损步骤)和熵编码。
| 属性 | 支持情况 |
|---|---|
| 位深 | 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 的两倍。
| 属性 | 支持情况 |
|---|---|
| 位深 | 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 增益图 | 不支持 | 不支持 | 支持 |
| 文件大小 | 小 | 大 | 最小 |
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]
特点:
适用场景:已知图片格式固定且追求极致性能的场景。生产环境不推荐。
// 使用 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 自动完成所有转换:
代价:需要分配完整的像素缓冲区并重绘(12MP ≈ 48MB)
适用场景:通用取色,各类图片来源不可控的生产环境。
// CIAreaAverage —— 取区域平均色
let filter = CIFilter(name: "CIAreaAverage", parameters: [
kCIInputImageKey: ciImage,
kCIInputExtentKey: CIVector(cgRect: extent)
])
特点:
适用场景:图片主题色提取、区域平均色分析。不适合实时拖动取色。
let format = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: ...
)
var buffer = try vImage_Buffer(cgImage: cgImage, format: format)
// 通过 buffer.data 访问像素
特点:
vImageConverter 可以精确控制任意格式间的色彩空间转换适用场景:批量像素处理、需要最高色彩精度控制的专业场景。
方案对比总结
| 维度 | dataProvider (A) | CGContext (B) | Core Image (C) | vImage (D) |
|---|---|---|---|---|
| 格式安全 | 危险 | 安全 | 安全 | 安全 |
| 色彩空间处理 | 无 | 自动转换 | 3 级管线 | 精细控制 |
| 16-bit/P3 支持 | 需手动处理 | 自动 | 自动 | 自动 |
| 单像素性能 | 最快 | 缓存后 O(1) | 最慢 | 中等 |
| 批量性能 | 快但脆弱 | 好 | 好 | 最佳 |
| API 复杂度 | 低但易错 | 适中 | 较高 | 较高 |
| 可靠性 | 差 | 好 | 好 | 好 |
方案 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 分支处理 |
我在适配完iOS 26时发现一个很奇怪的问题:
在第一次手势返回根页面时,tabBar没有渐显动画直接显示在顶部。但是如果第一次没有返回,再次手势返回时则有渐显动效。如下图所示:(测试设备iPhone 17;iOS 26.2)
一开始代码实现如下,在跳页的时候使用hidesBottomBarWhenPushed来隐藏tabBar
SecondVC *secondVC = [[SecondVC alloc] init];
secondVC.titleName = @"首页";
secondVC.hidesBottomBarWhenPushed = YES; // 隐藏tabbar
UIViewController *currentVC = self.window.rootViewController;
if ([currentVC isKindOfClass:[UITabBarController class]]) {
UITabBarController *tabBarController = (UITabBarController *)currentVC;
UINavigationController *homeNav = (UINavigationController *)tabBarController.viewControllers[0];
[homeNav pushViewController:secondVC animated:YES];
}
后面经过测试,发现使用[tabBarController setTabBarHidden:NO animated:animated]; 就正常渐显了。
关键代码如下
RootTabBarController *tabBarController = [[RootTabBarController alloc] init];
self.tabController = tabBarController;
// 设置首页tab
UINavigationController *homeNav = [[UINavigationController alloc] initWithRootViewController:homeVC];
homeNav.tabBarItem.title = @"首页";
homeNav.tabBarItem.image = [UIImage systemImageNamed:@"house"];
// 设置代理
homeNav.delegate = tabBarController;
// 设置我的tab
UINavigationController *profileNav = [[UINavigationController alloc] initWithRootViewController:profileVC];
profileNav.tabBarItem.title = @"我的";
profileNav.tabBarItem.image = [UIImage systemImageNamed:@"person"];
// 设置代理
profileNav.delegate = tabBarController;
[tabBarController setViewControllers:@[homeNav, profileNav]];
self.window.rootViewController = tabBarController;
[self.window makeKeyAndVisible];
2. 实现 UINavigationControllerDelegate
#pragma mark - UINavigationControllerDelegate
- (void)navigationController:(UINavigationController *)navigationController willShowViewController:(UIViewController *)viewController animated:(BOOL)animated {
// 判断是否为根视图控制器
if ([navigationController.viewControllers indexOfObject:viewController] == 0) {
// 返回到根页面,显示tabbar
[self setTabBarHidden:NO animated:animated];
} else {
// 跳转到子页面,隐藏tabbar
[self setTabBarHidden:YES animated:animated];
}
}
以上,希望有帮助到大家
当项目进入稳定迭代阶段,很多团队都会把构建流程放进 CI,例如 Jenkins、GitHub Actions 或 GitLab CI。编译 IPA、运行测试、生成构建产物都可以自动完成。但如果需要在发布前做代码混淆或资源处理,图形界面工具就会显得有些不方便。
我在维护一个长期更新的 iOS 项目时遇到过类似问题:每次构建完成后,都需要对 IPA 进行一次混淆处理。如果完全依赖界面操作,就意味着要人工导入 IPA、选择符号、再导出结果。几次之后就会发现,这一步完全可以放进自动化脚本里。
Ipa Guard 的命令行版本正好适合这种场景。它把 IPA 解析、符号混淆、资源处理这些步骤拆成可以调用的命令,同时还能输出符号映射文件,方便排查崩溃问题。下面记录一套实际操作流程。
CI 构建完成后会生成一个 Release IPA,例如:
build/game.ipa
这就是后续混淆操作的输入文件。
在开始处理前,可以简单检查一下包内结构:
unzip game.ipa
确认 Payload 中包含应用二进制与资源目录即可。之后重新打包,保持原始 IPA 作为备份。
Ipa Guard 命令行工具的第一步是解析 IPA,提取可修改符号。
执行命令:
ipaguard_cli parse game.ipa -o sym.json
执行完成后会生成一个 sym.json 文件。
这个文件的作用很直接:列出 IPA 中可以被混淆的符号,例如类名、方法名或变量名,并附带相关引用信息。
打开文件后可以看到类似结构:
{
"confuse": true,
"name": "_isPreTTS",
"refactorName": "_isPreTTS",
"types": ["oc_method_name"]
}
name 是原始符号名,
refactorName 用于填写混淆后的名称。
这一步比较关键,因为它决定哪些符号会被修改。
编辑 sym.json 时需要注意两件事:
1. refactorName 长度要保持一致
某些二进制符号长度变化可能影响结构,因此建议保持长度不变。
例如:
_isPreTTS
可以改为:
_a1b2c3d4
字符数量一致即可。
2. 不适合混淆的符号需要关闭
例如下面这个方法:
addEventListener:
如果 JS 或 H5 模块中通过字符串调用它,修改后可能导致运行失败。
可以把:
"confuse": true
改成:
"confuse": false
sym.json 中的 fileReferences 字段可以帮助判断某个符号是否在脚本或资源文件中被引用。
完成符号文件修改后,就可以执行 IPA 混淆。
示例命令:
ipaguard_cli protect game.ipa -c sym.json --image --js -o confused.ipa --email ipaguard@gmail.com
参数含义:
-c sym.json 指定符号配置文件--image 修改图片 MD5--js 混淆 JS 资源-o confused.ipa 输出文件--email 登录账号执行后会生成新的 IPA,例如:
confused.ipa
此时包内的符号和资源已经完成处理。
由于混淆修改了 IPA 内容,原有签名已经失效。
需要重新签名才能安装到设备。
可以使用签名工具,例如 kxsign:
kxsign sign confused.ipa \
-c cert.p12 \
-p certpassword \
-m dev.mobileprovision \
-z test.ipa \
-i
参数说明:
-c 证书文件-p 证书密码-m 描述文件-z 输出 IPA-i 安装到设备如果连接了测试手机,命令执行完成后会自动安装。
混淆后的版本一定要运行一遍完整流程,例如:
如果发生崩溃,可以借助 Ipa Guard 生成的符号映射文件查找原始函数名。
映射文件会记录:
混淆前符号
混淆后符号
这样在 Crash 日志中看到混淆名称时,仍然可以找到对应代码位置。
当流程稳定后,可以写一个简单脚本:
build ipa
ipaguard_cli parse
edit sym.json
ipaguard_cli protect
kxsign sign
在 Jenkins 或 GitHub Actions 中执行即可。
这样每次构建完成都会自动生成混淆后的 IPA。
测试通过后,签名流程保持一致,只需要换成发布证书:
kxsign sign confused.ipa \
-c dist.p12 \
-p certpassword \
-m dist.mobileprovision \
-z release.ipa
发布证书生成的 IPA 无法直接安装,但可以上传 App Store。
如果构建环境是 Linux 或 Windows,也可以使用上传工具完成提交。
将 IPA 混淆接入自动化流程后,发布过程会变得更稳定。符号解析、混淆处理、资源修改和签名测试都可以通过脚本完成,而不是依赖人工操作。
从代码到像素,都经历了什么?一帧画面是怎么到屏幕上的?
┌──────────────────────────────────────────────────────────────────────────────┐
│ 一帧的完整生命周期 (Render Loop) │
│ │
│ VSYNC₁ VSYNC₂ VSYNC₃ │
│ │ │ │ │
│ │ ┌─────────── App 进程 ───────────────┐ │ │
│ │ │ ① Handle Event ② Commit Transaction│ │ │
│ │ │ (触摸/定时器) ┌────────────────┐ │ │ │
│ │ │ │Layout → Display ││ │ │
│ │ │ │Prepare → Package││ │ │
│ │ │ └────────────────┘│ │ │
│ │ └──────────┬──────────────────────────┘ │ │
│ │ │ Layer Tree 发送 │ │
│ │ ▼ │ │
│ │ ┌─────────── Render Server (独立进程) ─────┐ │ │
│ │ │ ③ Render Prepare ④ Render Execute(GPU)│ │ │
│ │ │ (编译绘制指令) (逐层合成到纹理) │ │ │
│ │ └──────────────────────────┬────────────────┘ │ │
│ │ │ 最终纹理就绪 │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ ⑤ Display/硬件合成│◀─── 帧上屏 ────│ │
│ │ └──────────────────┘ │ │
│ │ │ │
│ │◀─── 1 frame (16.67ms @60Hz / 8.33ms @120Hz) ──►│ │ │
│ │◀──────────── 2 frames: 事件到上屏的最小延迟 (Double Buffering) ──────►│ │
│ │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ 超时在 App 端 (①②) ──→ Hang (卡顿/无响应) + Commit Hitch (掉帧) │
│ 超时在 GPU 端 (③④) ──→ Render Hitch (掉帧/动画抖动) │
│ │
│ Hang: 主线程被占 > 250ms,用户感知 "按不动" "界面冻结" │
│ Hitch: 帧未在 VSYNC deadline 前就绪,用户感知 "动画跳了一下" │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │CPU: Event│→ │CPU: Commit│→│GPU: Render │→ │Hardware: Display │ │
│ │ 事件处理 │ │ 提交变更 │ │ 合成 + 离屏 │ │ 像素点亮 │ │
│ └──────────┘ └──────────┘ └──────────────┘ └───────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
渲染机制定义了每一帧画面从产生到上屏幕的流水线;
动画是连续多帧的有规律变化,利用渲染流水线实现;
卡顿是流水线中任意环节超时,导致帧被丢弃;
| 概念 | 本质 | 与其他概念的关系 |
|---|---|---|
| Render Loop | 系统以屏幕刷新率(60/120Hz)驱动的持续循环 | 所有可见变化的底层引擎 |
| CALayer | 视觉内容的载体,持有位图和属性 | 动画的作用对象,渲染的输入 |
| Core Animation | 动画+渲染的基础框架 | 管理 Layer Tree,驱动 Render Server |
| 动画 (Animation) | 属性随时间的插值变化 | 在 Render Loop 中被逐帧求值 |
| Hang(卡顿) | 主线程被占用导致事件无法及时处理 | 用户感知为"按不动""无反应" |
| Hitch(掉帧) | 某帧未能在 VSYNC 截止时间前就绪 | 用户感知为动画跳跃、滚动卡顿 |
Rnder Loop 是一个以 VSYNC 为节拍、流水线式并行的循环。在 Double Buffering 模式下,一帧从事件到上屏幕需要经过 2 个 VSYNC 周期。
VSYNC₁ VSYNC₂ VSYNC₃
│ │ │
┌─── App 进程 ───────┤ │ │
│ ① Event Phase │ │ │
│ ② Commit Phase │ │ │
└───────────────────┤ │ │
│ ┌─ Render Server──┤ │
│ │ ③ Prepare Phase │ │
│ │ ④ Execute Phase │ │
│ └─────────────────┤ │
│ │ ⑤ Display │
│ │ 帧上屏 │
| 阶段 | 进程 | 做什么 | 关键耗时原因 |
|---|---|---|---|
| Event | App | 接收触摸、定时器等事件,决定 UI 是否需要变化 | — |
| Commit | App | Layout → Display(drawRect) → Prepare(图片解码) → 打包 Layer Tree 发给 Render Server | 布局复杂、视图层级深、大图解码 |
| Render Prepare | Render Server | 遍历 Layer Tree,编译为 GPU 绘制指令流水线 | Layer 数量多、需要 Offscreen Pass |
| Render Execute | GPU | 逐层合成到最终纹理 | Offscreen Pass、大面积模糊/阴影 |
| Display | 硬件 | 把纹理推上屏幕 |
Commit 是 App 端最关键的阶段,它本身又分为四步:
Commit Transaction
│
├─ 1. Layout 调用 layoutSubviews / SwiftUI body
│ → setNeedsLayout 触发
│
├─ 2. Display 调用 drawRect / draw(_:)
│ → setNeedsDisplay 触发
│ → 生成 backing store (位图)
│
├─ 3. Prepare 图片解码 + 色彩空间转换
│ → 大图 / 非标准格式图开销大
│
└─ 4. Package 递归打包 Layer Tree 发送
→ 层级越深越慢
Commit Transaction 是一个 RunLoop 循环结束时自动提交的隐式事务;Backing Store 是 Layer 的位图缓存。Double Buffering(双缓冲) 是 iOS 渲染流水线的默认工作模式,指系统同时维护 两个帧缓冲区,让 App 准备下一帧和屏幕显示当前帧可以并行进行,互不干扰。
为什么需要缓冲区?
如果只有一个缓冲区(Single Buffering),屏幕 正在读取这个缓冲区显示画面 的同时,GPU 也在往里写新内容,就会出现 画面撕裂(Screen Tearing)——上半截是旧帧,下半截是新帧。
CALayer 是一个 模型对象,它不做绘制,它只持有:
View 和 Layer 的关系: iOS 上每个 UIView 都自动持有一个 backing layer。View 负责事件响应(触摸、手势)和响应链,Layer 负责视觉呈现。你改 view.frame 其实改的是view.layer 的属性。Layer 不处理事件、不参与响应链。
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Model Tree │ │ Presentation Tree│ │ Render Tree │
│ (图层树) │ │ (呈现树) │ │ (渲染树) │
│ │ │ │ │ │
│ 你代码改的值 │ │ 动画进行中的当前值 │ │ 实际渲染用 │
│ = 动画目标值 │ │ = 屏幕上的即时值 │ │ (私有,不可访问)│
└─────────────┘ └──────────────────┘ └─────────────┘
│ ▲
│ layer.presentationLayer
└────────────────────┘
layer.position = newPos 改的就是它。它始终保存 “最终目标值”。layer.presentationLayer。关键推论: 给 layer 加动画后,Model Tree 里的值就已经是终点值了。动画结束后如果 removedOnCompletion = YES(默认),layer 就直接呈现 Model Tree 的值。如果你没改 Model Tree 的值,layer 就会"跳回去"——这就是动画结束后 layer 回到原位的经典问题。
presentationLayer 的使用场景: 用户在动画飞行途中点击/拖拽 layer 时,需要用 presentationLayer 获取当前真实位置来做 hitTest 或启动新动画。
所有对 Layer 的属性修改都被 CATransaction 捕获:
[CATransaction begion] ... [CATransaction commit],可以控制动画时长、completionBlock 等。隐式事务是 UIView 隐式动画(改 layer 属性自动产生 0.25s 动画)的底层机制。UIView 的 animateWithDuration: 本质上就是开一个显式事务并配置参数。
动画 = 内容(什么在变) + 时间(多久完成) + 变化规律(怎么变)
| 要素 | 对应 API | 说明 |
|---|---|---|
| 内容 | keyPath (如 position, opacity, transform.rotation.z) | 必须是 CALayer 上标记为 Animatable 的属性 |
| 时间 | duration + timingFunction | timingFunction 控制"时间的流速"(加速/减速/弹性) |
| 变化规律 | 动画子类决定(Basic = 两点插值,Keyframe = 多点插值,Spring = 弹簧物理) | — |
CAAnimation (基类:timingFunction, delegate, removedOnCompletion)
│
├─ CAPropertyAnimation (抽象:keyPath, additive, cumulative)
│ │
│ ├─ CABasicAnimation (fromValue / toValue / byValue)
│ │ │
│ │ └─ CASpringAnimation (mass / stiffness / damping / initialVelocity)
│ │
│ └─ CAKeyframeAnimation (values / keyTimes / path / calculationMode)
│
├─ CATransition (type / subtype — 转场快照动画)
│
└─ CAAnimationGroup (animations[] — 组合多个动画)
提供起止状态,系统通过插值(Interpolation)算出任意时刻的值。三个属性的语义:
关键帧动画 = N 段 BasicAnimation 的串联。提供一组 values 和对应的 keyTimes(归一化 0~1),系统在相邻关键帧之间插值。
calculationMode 决定插值方式:linear(默认)
继承自 CABasicAnimation,用弹簧力学模型驱动动画曲线:mass(质量越大,运动越慢,但衰减也越慢)等。
CATransition 不指定 from/to 值。它的工作方式:
把多个动画放在 animations 数组里同时执行。注意:
Hang = 主线程无法在合理时间内处理用户事件。
WWDC23 统计过一期人类感知阈值,大概如下:
0ms 100ms 250ms 500ms
│─── 感觉即时 ──│── 微妙可感 ──│── 明显延迟 ──│── 严重卡顿 ──▶
│ │ │
目标上限 Micro Hang(系统开始上报) Hang
| 类型 | 主线程 CPU | 表现 | 典型原因 |
|---|---|---|---|
| Busy Main Thread | 高(60~100%) | 主线程在拼命算东西 | 大量布局计算、同步图片处理、JSON 解析 |
| Blocked Main Thread | 极低(~0%) | 主线程在等锁/等IO/等网络 | 同步网络请求、信号量等待、锁竞争、同步文件IO |
| Asynchronous Hang | 可高可低 | 不是当前事件导致的,而是之前调度到主线程的任务占了时间 | dispatch_async(main) 的耗时任务、@MainActor 下的同步代码 |
同步 Hang:
用户点击 → [────── 主线程处理耗时 ──────] → 响应
←─── 这段就是 hang ───→
异步 Hang:
之前调度的任务 → [──── 主线程被占 ────]
↑ 用户点击来了,但得排队
←── 这段是 hang ──→
WWDC23 Session 10248 中详细阐述的一个经典问题:
struct BackgroundThumbnailView: View {
var body: some View { // body 隐式继承 @MainActor
ProgressView()
.task { // .task 闭包继承外部 actor 隔离 → 也在 MainActor
image = background.thumbnail // 同步属性 → 在主线程执行!
}
}
}
.task 闭包继承 body 的 @MainActor 隔离,同步属性 thumbnail 在主线程执行。await 只在调用 async 函数时才切换线程。thumbnail 改为 async getter,使其能在 Cooperative Thread Pool 上执行:public var thumbnail: UIImage {
get async { /* compute and cache */ }
}
// 使用处
.task {
image = await background.thumbnail // 现在能离开 @MainActor 了
}
Hitch = 某一帧没能在 VSYNC deadline 前就绪,导致前一帧重复显示。
单次 hitch time(毫秒)不方便跨测试对比。Apple 定义了 Hitch Time Ratio:
Hitch Time Ratio = 总 hitch 时间 / 总持续时间 (单位: ms/s)
| 等级 | Hitch Time Ratio | 用户感知 |
|---|---|---|
| Good | < 5 ms/s | 基本无感 |
| Warning | 5~10 ms/s | 能注意到部分中断 |
| Critical | > 10 ms/s | 严重影响体验,必须立即修复 |
| 类型 | 超时发生在 | 常见原因 |
|---|---|---|
| Commit Hitch | App 端 Commit 阶段 | 复杂布局、drawRect 耗时、大图解码、深层级打包 |
| Render Hitch | Render Server / GPU | Offscreen Pass 过多、大面积模糊/阴影、复杂遮罩 |
最终纹理(屏幕画面)
┌──────────────────┐
│ │
│ 第1层:蓝色背景 │ ← GPU 先画这个
│ 第2层:白色卡片 │ ← 再叠上这个
│ 第3层:文字 │ ← 最后叠上这个
│ │
└──────────────────┘
GPU 直接在最终纹理上一层层往上画,画完就上屏。
这就是"正常渲染",也叫"当屏渲染"。
离屏渲染:GPU 无法直接在最终纹理上绘制某个 layer,必须先在 离屏纹理 上画好再拷贝回来。每次 Offscreen Pass 都是额外的 纹理切换 + 像素拷贝。
┌─────────────────────────────────┐
│ 最终纹理 │
│ │
│ ●●●●● │
│ ●●●●●●● ← 圆形 │
│ ●●●●● │
│ ████████ ← 长条 │
│ │
│ 阴影的形状 = 圆形+长条的轮廓 │
│ 但 GPU 还没画圆形和长条呢! │
│ 它怎么知道阴影该长什么样? │
└─────────────────────────────────┘
步骤1:GPU 切到临时纹理,先把圆形和长条画上去
┌── 临时纹理 ──┐
│ ●●●●● │
│ ●●●●●●● │ → 现在知道轮廓了
│ ●●●●● │
│ ████████ │
└──────────────┘
步骤2:把轮廓变黑 + 模糊 = 阴影形状
┌── 临时纹理 ──┐
│ ░░░░░░░ │
│ ░░░░░░░░░ │ → 这就是阴影
│ ░░░░░░░ │
│ ░░░░░░░░░░ │
└──────────────┘
步骤3:把阴影拷贝回最终纹理
步骤4:在最终纹理上再画一次圆形和长条(盖在阴影上面)
四大触发场景:
| 场景 | 为什么必须离屏 | 怎么避免 |
|---|---|---|
| 阴影 | GPU 不知道阴影形状,得先画内容才能反推 | 设 shadowPath,直接告诉 GPU 形状,不用反推 |
| 遮罩 (mask) | 先画内容,再用 mask 裁剪,裁掉的像素不能污染最终纹理 | 用 cornerRadius + masksToBounds 代替自定义 mask layer |
| 圆角 + 裁剪内容 | 子视图超出圆角范围需要被裁掉,和遮罩同理 | 确认子视图不超出 bounds 时去掉 masksToBounds |
| 模糊/毛玻璃 | 需要拷贝底层像素到临时纹理再做模糊 | 不可避免,控制数量和面积 |
用户反馈"卡"
│
├─ 按钮按不动 / 界面冻结 → 这是 Hang
│ │
│ ├─ Time Profiler 看 CPU 高 → Busy Main Thread
│ │ → 减少主线程计算、用 async/await 移到后台
│ │
│ └─ Thread States 看线程 Blocked → Blocked Main Thread
│ → 找到阻塞的系统调用(锁/IO/信号量),异步化
│
└─ 滚动/动画跳帧 → 这是 Hitch
│
├─ Animation Hitches 模板看 Commit 阶段超时 → Commit Hitch
│ → 简化布局、减少 drawRect、预处理图片、扁平化层级
│
└─ Render/GPU 阶段超时 → Render Hitch
→ View Debugger 看 offscreen count
→ 设置 shadowPath、用 cornerRadius 代替 mask
CGRectIntegral 或 SnapKit 的 snp.makeConstraints 保持像素对齐。iOS Bug AutoFix 是一个基于 AI 的 iOS 代码 Bug 自动定位工具。它从自然语言 Bug 描述出发,通过三步流水线(信息提取 → 粗筛定位 → 精确定位)自动定位到问题代码的具体文件和行号。本次分析以两条实际命令的运行为例。
index — 构建代码索引1 |
npx ts-node src/index.ts index |
入口文件 index.ts 的 main() 函数首先调用 loadConfig() 读取配置文件:
tool/config/autofix.config.json
repoRoot → /Users/wyan/Develop/Code/branch/Bugfix
openai.model → deepseek-chat
index.includeDirs → ["Classes/Modules"]
同时在构造 BugAutoFixer 时,基于 repoRoot 设置了运行时目录:
.autofix/ 根目录.autofix/index.db — SQLite 索引数据库.autofix/results/ — 定位结果目录.autofix/logs/ — 日志目录(预留)BugAutoFixer 构造函数中创建 FileLocator,而 FileLocator 构造时会创建 PageMapper。 page-mapper.ts 会按优先级搜索 page-mapping.json 文件:
1 |
✓ 已加载页面映射表: .../page-mapping.json (14 个页面) |
映射表内容示例(来自 page-mapping.example.json):
1 |
{ |
页面映射表同时构建了反向映射(类名 → 页面名),共 14 个页面。
code-indexer.ts 的 buildFullIndex() 方法执行以下步骤:
创建 SQLite 数据库(WAL 模式),包含:
| 表 | 用途 |
|---|---|
file_index |
文件级索引(类名、方法名、协议、UI 类、无障碍标记等) |
class_hierarchy |
类继承关系 |
file_fts (FTS5) |
全文搜索虚拟表,通过触发器自动同步 |
使用 find 命令扫描仓库,由于配置了 includeDirs: ["Classes/Modules"],实际执行的命令相当于:
1 |
find "/Users/wyan/Develop/Code/branch/Bugfix" -type f \( -name "*.swift" -o -name "*.m" -o -name "*.h" \) -and \( -path "*/Classes/Modules/*" \) |
1 |
Found 17522 source files to index |
在一个 SQLite 事务中,对每个文件进行解析。根据文件扩展名分别调用:
.swift 文件 → parseSwiftFile(): 用正则提取 class/struct/enum/extension 声明、func 方法名、协议、UI* 类使用、accessibility* 属性、@IBOutlet
.m / .h 文件 → parseObjCFile(): 用正则提取 @interface/@implementation(含 Category)、方法名(-/+ (type)methodName)、<Protocol> 协议、UI* 类指针声明、accessibility* 属性每个文件还会:
raw_summary :取前 30 行 + 所有关键声明行(class/func/@interface/@implementation/accessibility 等),控制在 2000 字符以内pod_name :从路径中匹配 Pods/ModuleName/ 或 Modules/ModuleName/ 模式class_hierarchy 表FTS5 是 Full-Text Search version 5 的缩写,即 SQLite 内置的第 5 版全文搜索引擎。
本项目用它来对 17522 个源文件的类名、方法名等元数据建立倒排索引,让 Step 2 的关键词搜索可以在毫秒级完成。
通过 SQLite 触发器,file_index 表的 INSERT/UPDATE/DELETE 操作会自动同步到 file_fts 全文搜索虚拟表,支持后续的 MATCH 全文搜索。
1 |
Indexed: 17522, Skipped: 0 |
17522 个源文件全部成功索引。
locate — 定位 Bug1 |
npx ts-node src/index.ts locate "个人主页导航栏更多按钮无障碍响应错误" |
整个 locate 流程分为三个 Step,总耗时 73.7 秒。
执行者: bug-info-extractor.ts
将 bug 描述嵌入一个结构化 prompt 中,要求 LLM 以 JSON 格式输出提取结果。Prompt 关键指令:
“keywords 要包含各种可能的命名变体,比如中文’播放页’对应可能的类名 PlayerViewController, PlayViewController, PlayerVC…”
使用 OpenAI SDK 的 chat.completions.create:
deepseek-chat
0.1(低温度确保输出稳定)json_object(强制 JSON 输出)1 |
Type: accessibility |
关键观察:LLM 从简短的中文描述中猜测了大量可能的英文类名/属性名变体,这些关键词将在 Step 2 中被用于多策略搜索。
执行者: file-locator.ts
6 种策略全部并行执行(Promise.allSettled),互不影响:
bugInfo.codeScanIssue?.filePath 是否存在keywords 中长度 ≥ 3 的关键词,逐个并行执行 ripgrep:1 |
rg -l --type swift --type objc "ProfileViewController" "/Users/wyan/Develop/Code/branch/Bugfix" 2>/dev/null | head -50 |
NavBar → 匹配到 QMPersonalInfoViewController.m, QMGeneralUserHeaderView.m
MoreButton → 匹配到 QMPersonTitleView.m, QMPersonHeaderCell.m, QMPersonalInfoViewController.m
MoreBtn → 匹配到多个文件accessibilityHint → 匹配到 QMPersonalInfoViewController.m
accessibilityTraits → 匹配到 QMPersonTitleView.m, QMPersonHeaderCell.m, QMPersonalInfoViewController.m
ProfileViewController → 匹配到 ProfileViewController_V3Pad.m, ProfileViewController_V3+Follow.m 等ProfileVC → 匹配到多个 Profile 相关文件UserProfile → 匹配到 QMPersonalInfoViewController.m, QMPersonalInfoViewController+JumpAction.m
页面映射匹配(最高权重 40 分):
bugInfo.pageName = "个人主页"["QMPersonalInfoViewController", "QMGeneralUserHeaderView", "QMGeneralUserV2TabVC"]
SELECT file_path FROM file_index WHERE class_names LIKE '%QMPersonalInfoViewController%'
QMPersonalInfoViewController 的 .m/.h 及 Category 文件,每个 40 分
类名 FTS5 匹配(30 分):
viewControllers 列表(ProfileViewController, PersonalHomeViewController 等)执行全文搜索SELECT file_path FROM file_fts WHERE class_names MATCH 'ProfileViewController' LIMIT 30
ProfileViewController_V3Pad.m 等文件,每个 30 分
关键词 FTS5 匹配(8 分):
MoreBtn, accessibilityLabel, accessibilityTraits, isAccessibilityElement)执行全文搜索QMPersonTitleView.m, QMPersonHeaderCell.m 等pageName(”个人主页”)和 moduleName(”个人主页/用户资料”)执行 find 命令搜索匹配的目录1 |
git log --since="2 weeks ago" --name-only --pretty=format: | sort | uniq -c | sort -rn | head -100 |
searchAccessibilityIssues()
1 |
SELECT file_path FROM file_index WHERE has_accessibility = 0 AND ui_classes LIKE '%UIButton%' LIMIT 30 |
所有策略结果通过 candidateMap 合并。同一文件多次命中的分数会叠加。
关键的交叉验证加分机制:
1 |
// 命中策略数 > 1 时,每多一种策略额外加 5 分 |
例如 QMPersonalInfoViewController.m:
结果按 score 降序排序,取前 MAX_CANDIDATES = 20 个文件:
| 排名 | 分数 | 文件 | 主要得分来源 |
|---|---|---|---|
| 1 | 81 | QMPersonalInfoViewController.m |
ripgrep(6项) + 页面映射 + 交叉验证 |
| 2 | 57 | QMPersonalInfoViewController+JumpAction.m |
ripgrep(ProfileVC,UserProfile) + 页面映射 + 交叉验证 |
| 3 | 55 | ProfileViewController_V3Pad.m |
ripgrep + 索引类名 + 索引关键词 + 交叉验证 |
| 4 | 55 | ProfileViewController_V3+Follow.m |
同上 |
| 5 | 55 | QMPersonTitleView.m |
ripgrep(MoreButton,MoreBtn,accessibilityTraits) + 索引关键词(多个) + 交叉验证 |
| 6 | 55 | QMPersonHeaderCell.m |
同上 |
| … | … | … | … |
执行者: precise-locator.ts
这是整个流程中消耗 token 最多的阶段,通过漏斗式两轮筛选来控制成本。
对 Top 10(MAX_SCREENING_FILES = 10)候选文件,调用 loadFileSummaries():
fs.readFileSync(filePath, "utf-8")
extractSummary(content) — 取前 30 行 + 所有关键声明行(class/func/@interface/@implementation/accessibility 等),约控制在 ~500 token/文件
1 |
private extractSummary(content: string): string { |
目的:用低 token 成本快速排除无关文件。
构建 Prompt:将 bug 描述 + 10 个文件的摘要和匹配原因拼接成一个 prompt:
1 |
你是 iOS 开发专家。以下是一个 bug 的描述和几个候选文件的摘要。 |
LLM 返回:JSON 格式的相关文件列表
1 |
{ "relevantFiles": ["path1", "path2", "path3", "path4", "path5"] } |
结果:从 10 个文件筛选到 5 个真正相关的文件。
1 |
Round 1: Screening with file summaries... |
在这个工具中,**”关键声明”(Key Declarations)** 是指源代码中以特定模式开头的、具有结构性意义的代码行。具体来说,就是通过正则表达式匹配出的以下内容:
在 precise-locator.ts 的 extractSummary 方法(第 371 行)中:
1 |
const importantLines = lines.filter((line) => { |
也就是说,关键声明行 = 匹配以下任一模式的代码行:
| 模式 | 含义 | 示例 |
|---|---|---|
class |
Swift 类声明 | class MyViewController: UIViewController |
struct |
Swift 结构体声明 | struct Config { ... } |
enum |
枚举声明 | enum State { ... } |
extension |
Swift 扩展声明 | extension UIView { ... } |
func |
Swift 函数声明 | func viewDidLoad() { ... } |
@interface |
ObjC 类/分类声明 | @interface QMPersonalInfoViewController |
@implementation |
ObjC 实现声明 | @implementation QMPersonTitleView |
@IBOutlet |
Storyboard 关联 | @IBOutlet weak var moreBtn: UIButton! |
@IBAction |
Storyboard 事件 | @IBAction func didClickMore() |
import / #import
|
导入语句 | #import "QMPersonalInfoViewController.h" |
/accessibility/i |
任何包含 accessibility 的行 | moreBtn.accessibilityLabel = @"更多"; |
最终生成的摘要格式为:
1 |
[文件前 30 行原文] |
用一个具体例子来说明,对于 QMPersonTitleView.m,摘要大概长这样:
1 |
// 前 30 行(包含 #import、文件注释等) |
这个设计的目的是用极少的 token(约 500 token/文件)让 AI 快速理解一个文件的”骨架”:
这样 Round 1 用 20 个文件 × 500 token ≈ 10,000 token 就能完成初筛,而不需要发送 20 个完整文件(可能要 200,000+ token)。
这里的漏斗设计是整个工具的核心性能优化:
1 |
Step 2: 20个候选文件(纯本地,0 token) |
如果直接对 20 个文件都发送完整内容,token 消耗将极其巨大(一个 ObjC 文件可能有数千行)。
对筛选出的 Top 5(MAX_PRECISE_FILES = 5)文件,逐个调用 locateInFile():
大文件智能截取:对超过 500 行的文件(ObjC 文件通常非常长),不是简单截断前 500 行,而是使用 smartExtract() 进行智能截取:
extractKeywordsFromDescription()accessibility, button, more, navigation 等导航栏, 更多, 按钮, 无障碍 等最终生成带行号的截取内容:
1 |
1: #import "QMPersonalInfoViewController.h" |
构建 Prompt:
1 |
你是 iOS 开发专家。请在以下代码中精确定位 bug 所在位置。 |
1 |
请返回 JSON: |
5 个文件的 LLM 返回结果:
| 文件 | 行号 | 置信度 | 核心发现 |
|---|---|---|---|
QMPersonTitleView.m |
189-195 | 90% |
accessibilityLabel 被设置后又被硬编码为 @"更多" 覆盖 |
QMPersonHeaderCell.m |
70-70 | 90% |
accessibilityLabel = moreBtnTitle 但缺少完整的无障碍配置 |
QMPersonalInfoViewController.m |
5667-5673 | 85% | 导航栏更多按钮创建处,可能存在本地化字符串问题 |
ProfileViewController_V3Pad.m |
1010-1013 | 85% |
accessibilityLabel:atIndex: 方法始终返回空字符串 @""
|
ProfileViewController_V3+Follow.m |
176-200 | 85% | 关注按钮点击处理缺少无障碍属性更新 |
所有定位结果按 confidence(置信度)降序排序:
1 |
return results.sort((a, b) => b.confidence - a.confidence); |
90% 的两个结果排在前面,85% 的三个排在后面。
对每个定位结果,根据 lineStart 和 lineEnd 从完整文件内容中截取代码:
1 |
const contentLines = content.split("\n"); |
定位结果同时输出到终端和 JSON 文件:
1 |
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); |
1 |
Results saved to: .../result-2026-03-06T14-04-42-624Z.json |
本次 locate 命令总共进行了 7 次 LLM API 调用:
| 次序 | 阶段 | 输入 | 输出 | 预估 Token |
|---|---|---|---|---|
| 1 | Step 1: 信息提取 | bug 描述 + prompt模板 | BugInfo JSON | ~500 |
| 2 | Step 3 Round 1: 摘要筛选 | 10个文件摘要 | 5个相关文件路径 | ~6000 |
| 3-7 | Step 3 Round 2: 精确定位 | 每个文件的内容(智能截取) | 行号 + 置信度 + 解释 | ~3000-8000/次 |
Step 2 完全在本地执行(ripgrep + SQLite + find + git),无 API 调用,0 token 消耗。
1 |
graph TD |
| 来源 | 分值 | 设计意图 |
|---|---|---|
| 直接路径 | 100 | 代码扫描报告给出的路径几乎必中 |
| 页面映射 | 40 | 人工维护的映射最可靠 |
| 索引类名匹配 | 30 | FTS5 匹配到类名,可信度高 |
| Bug 类型专项 | 15 | 有针对性的搜索 |
| 索引关键词匹配 | 8 | 关键词范围更广,可能有噪声 |
| 目录推断 | 8 | 目录名和模块名可能不完全对应 |
| ripgrep | 6 | 全文搜索覆盖广但噪声多 |
| Git 热点 | 2 | 纯统计信息,低权重兜底 |
| 交叉验证加分 | +5/策略 | 多策略命中说明文件高度相关 |
1 |
17,522 源文件 |
总 token 消耗约: 30,000-40,000 token,相比直接将 20 个大文件发给 AI(可能 500,000+ token),节省了 90% 以上。
简单截断前 500 行的问题:ObjC 文件头部通常是 #import 和属性声明,真正有 bug 的代码可能在第 5000+ 行。智能截取通过关键词搜索 + 上下文窗口(前后各 15 行)确保问题代码被覆盖。
本次案例中 QMPersonalInfoViewController.m 的问题代码在第 5667 行,如果简单截断前 500 行将完全漏掉。
对于 bug 描述 **”个人主页导航栏更多按钮无障碍响应错误”**:
accessibility 类型,正确推断了 个人主页 页面名,关键词覆盖了 MoreButton/MoreBtn/accessibilityLabel/accessibilityTraits 等关键变体QMPersonalInfoViewController.m(81 分),得益于页面映射(40分)+ ripgrep 多关键词命中(36分)+ 交叉验证加分(5分)
accessibilityLabel 被错误覆盖和不完整设置的代码行本文基于 Flutter 框架,从 Canvas 绘制、K 线数据结构、蜡烛图核心绘制逻辑、MA 指标实现,到手势冲突优化,全方位拆解金融 APP K 线图开发流程,分享实战问题与解决方案,助力开发者快速实现流畅可落地的 K 线组件。
在金融类 APP 开发中,K 线图是必不可少的组件之一,体验直接可导致用户数量的流失
本文将通过 Flutter 框架,并结合实际的开发经验,从 Canvas 绘制基础、数据结构定义、核心绘制逻辑、技术指标实现到手势系统优化,全方位的拆解 K 线图的开发过程,分享我开发过程中遇到的问题以及解决方案,帮助你掌握 Flutter K 线图开发技巧
先看最终效果
![]()
首先我们得先学习 Flutter 中的 Canvas 绘制
懂 Canvas 绘制基础可直接跳过这条段,想要在 Flutter 中自定义绘制,核心需要通过 CustomPaint + CustomPainter
在动手之前需要先把 Flutter Canvas 坐标系规规则给理解一下
与我们日常认知的“y轴向上为正”不同,需要记住这一点,这是避免绘制错位的关键
![]()
![]()
为快速熟悉Canvas的使用方式,我们先实现一个简单的Demo,绘制一个填充圆形和一根线条,掌握Paint配置、坐标计算及Canvas绘制方法:
import 'package:flutter/material.dart';
class CanvasApp extends StatefulWidget {
const CanvasApp({super.key});
@override
State<CanvasApp> createState() => _CanvasAppState();
}
class _CanvasAppState extends State<CanvasApp> {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: CustomPaint(painter: DemoPainter()),
);
}
}
class DemoPainter extends CustomPainter {
final fill = Paint()
..style = PaintingStyle.fill
..color = Colors.blue;
final stroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6
..color = Colors.black;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
canvas.drawCircle(center, 60, fill); // 绘制填充圆形
canvas.drawLine(center, center + Offset(80, -40), stroke); // 绘制线条
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
![]()
上面的 Demo中,通过Paint配置绘制样式(填充/描边、颜色、线宽)
在paint方法中通过Canvas的drawCircle、drawLine方法完成绘制
shouldRepaint 返回false表示不重复绘制,提升性能
![]()
接下来我们就需要先了解 K 线数据接口的定义,进入 K 线的开发
首先需定义规范的数据结构,存储单根K线的核心信息
一根完整的K线包含开盘价、最高价、最低价、收盘价、成交量和时间戳六大核心字段,对应的数据结构如下:
class CandleEntity {
double open; // 开盘价
double high; // 最高价
double low; // 最低价
double close; // 收盘价
double vol; // 成交量
int? time; // 时间戳(毫秒)
}
![]()
CandleEntity 类是K线图开发的“数据载体”,后面所有绘制逻辑(蜡烛、均线)均围绕该类的实例展开
实际开发中,也可以根据需求扩展字段,比如添加均线值列表(maValueList),用于存储单根K线对应的各类均线数据
K线图的核心是 Candle 的绘制,单根 Candle 由实体部分(开盘价与收盘价之间的矩形)和影线部分(最高价与最低价之间的线段)组成,而且需区分阳线(涨)和阴线(跌),绘制逻辑如下
因为Canvas坐标系和实际价格维度不一样,所以得把价格转换成屏幕上的Y坐标。核心逻辑就是用当前K线数据集的最高价、最低价算缩放比例,再把价格映射成屏幕坐标,公式如下:
double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;
![]()
通过这个公式可确保价格越高,对应的屏幕Y坐标越小
单根蜡烛的绘制需处理三个核心细节:阳线与阴线的颜色区分、实体部分的最小高度(避免十字星看不见)、动态影线宽度(根据缩放级别调整,提升视觉体验)
完整代码如下:
/// 绘制单根蜡烛图
/// [curPoint] 当前 K 线数据
/// [canvas] 画布
/// [curX] 当前 K 线的 X 坐标(中心点)
void drawCandle(CandleEntity curPoint, Canvas canvas, double curX) {
// 将价格转换为屏幕 Y 坐标
var high = getY(curPoint.high); // 最高价对应的 Y 坐标
var low = getY(curPoint.low); // 最低价对应的 Y 坐标
var open = getY(curPoint.open); // 开盘价对应的 Y 坐标
var close = getY(curPoint.close); // 收盘价对应的 Y 坐标
double r = mCandleWidth / 2; // 实体半宽
// 动态影线宽度计算:根据缩放级别平滑调整影线宽度,缩放越小影线越粗
double lineR = _calculateDynamicShadowWidth() / 2; // 影线半宽
// 阳线(涨):开盘价 >= 收盘价
if (open >= close) {
// 确保实体有最小可见高度(避免十字星看不见)
if (open - close < mCandleLineWidth) {
open = close + mCandleLineWidth;
}
chartPaint.color = this.chartColors.upColor; // 阳线颜色(如红色)
// 绘制实体矩形(从收盘价到开盘价)
canvas.drawRect(
Rect.fromLTRB(curX - r, close, curX + r, open), chartPaint);
// 绘制上下影线(从最高价到最低价)
canvas.drawRect(
Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
}
// 阴线(跌):收盘价 > 开盘价
else if (close > open) {
// 确保实体有最小可见高度
if (close - open < mCandleLineWidth) {
open = close - mCandleLineWidth;
}
chartPaint.color = this.chartColors.dnColor; // 阴线颜色(如绿色)
// 绘制实体矩形(从开盘价到收盘价)
canvas.drawRect(
Rect.fromLTRB(curX - r, open, curX + r, close), chartPaint);
// 绘制上下影线
canvas.drawRect(
Rect.fromLTRB(curX - lineR, high, curX + lineR, low), chartPaint);
}
}
![]()
上面的代码中,通过判断开盘价与收盘价的大小区分阳阴线,动态调整实体高度和影线宽度,确保在不同缩放级别下,K线都能清晰显示,提升用户体验
![]()
![]()
K线图除了蜡烛本身,还需展示各类技术指标,其中移动平均线(MA)是最常用的指标之一
MA的实现核心是滑动窗口算法,通过维护固定周期的收盘价累加和,计算每个周期的均值,时间复杂度为O(n)
/// 计算移动平均线(Moving Average)
/// [dataList] K 线数据列表
/// [maDayList] 均线周期列表,例如 [5, 10, 20] 表示计算 MA5、MA10、MA20
static calcMA(List<KLineEntity> dataList, List<int> maDayList) {
// ma[i] 保存第 i 个周期的累加和
List<double> ma = List<double>.filled(maDayList.length, 0);
if (dataList.isNotEmpty) {
for (int i = 0; i < dataList.length; i++) {
KLineEntity entity = dataList[i];
final closePrice = entity.close;
// 为每个 K 线创建 MA 值列表
entity.maValueList = List<double>.filled(maDayList.length, 0);
// 计算每个周期的 MA 值
for (int j = 0; j < maDayList.length; j++) {
ma[j] += closePrice; // 累加当前收盘价
// 达到周期时开始计算均值
if (i == maDayList[j] - 1) {
entity.maValueList?[j] = ma[j] / maDayList[j];
}
// 滑动窗口:减去最早的值,保持窗口大小
else if (i >= maDayList[j]) {
ma[j] -= dataList[i - maDayList[j]].close;
entity.maValueList?[j] = ma[j] / maDayList[j];
}
}
}
}
}
![]()
上面即是实现 MA均线计算的逻辑,通过双重循环实现多周期MA计算:外层循环遍历所有K线数据,内层循环针对每个均线周期,累加收盘价,当达到周期长度时计算均值,后续通过滑动窗口更新均值(减去滑出窗口的收盘价,加上新的收盘价),确保计算高效
当完成逻辑的计算之后,通过绘制线段实现绘制,核心是获取相邻两根K线的MA值对应的屏幕坐标,调用drawLine方法完成绘制
void drawMaLine(CandleEntity lastPoint, CandleEntity curPoint, Canvas canvas,
double lastX, double curX) {
// 获取均线线条宽度
final lineWidth = _calculateMainIndicatorWidth();
for (int i = 0; i < (curPoint.maValueList?.length ?? 0); i++) {
if (i == 3) break; // 控制均线显示数量(如只显示前3条)
if (lastPoint.maValueList?[i] != 0) {
// 绘制相邻两根K线的MA线段,区分不同均线颜色
drawLine(lastPoint.maValueList?[i], curPoint.maValueList?[i], canvas,
lastX, curX, this.chartColors.getMAColor(i),
lineWidth: lineWidth);
}
}
}
![]()
交互体验在 K 线图中是非常重要的,必须要支持缩放、拖拽、点击、长按这四个核心的手势
但是 Flutter 的手势系统有一个手势竞技场(Gesture Arena)的机制,导致有手势冲突的问题
下面我提供了解决方案
问题描述:如果同时用了 HorizontalDrag 拖拽 和 ScaleGesture 缩放,这两个手势会互相抢焦点,导致双指缩放时,水平滑动会被拖拽抢走,缩放就断了,有种卡顿的感觉
解决办法很简单:
用 Listener 组件处理先判断有几根手指在屏幕上,再自动切换是拖拽还是缩放,互不干扰:
缩放 + 拖拽
Listener(
onPointerDown: (_) => setState(() => _pointerCount++),
onPointerUp: (_) => setState(() => _pointerCount--),
onPointerCancel: (_) => setState(() => _pointerCount--),
child: RawGestureDetector(
scaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
(instance) {
instance
..onStart = (details) {
// 保存基线值,用于缩放计算
_scaleBase = 1.0;
_scaleXBase = mScaleX;
// 计算缩放锚点(焦点对应的 K 线索引)
_anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
}
..onUpdate = (details) {
// 检测手指数量变化,重置基线
if (_pointerCount != _lastPointerCount) {
_scaleBase = details.scale;
_scaleXBase = mScaleX;
_anchorIndex = painter.calculateSelectedX(details.focalPoint.dx);
}
if (_pointerCount < 2) {
// 单指:拖拽,调整滑动偏移量
final delta = details.focalPointDelta.dx / mScaleX;
mScrollX = (mScrollX + delta).clamp(0.0, maxScrollX);
} else {
// 双指:缩放,控制缩放范围(0.2~4.0)
final relativeScale = details.scale / _scaleBase;
mScaleX = (_scaleXBase * relativeScale).clamp(0.2, 4.0);
// 焦点锚定:保持缩放中心不动,提升体验
}
}
..onEnd = (details) {
// 单指拖拽结束:启动惯性滚动
if (_pointerCount == 0 && _lastPointerCount == 1) {
_onFling(details.velocity.pixelsPerSecond.dx);
}
};
},
),
// 长按、点击手势配置
longPressGestureRecognizer: ...,
tapGestureRecognizer: ...
),
);
![]()
(1)点击手势
点击手势点击主要做两件事:切换十字线显示、画趋势线,通过 TapGestureRecognizer 实现
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(TapGestureRecognizer instance) {
instance.onTapUp = (details) {
// 普通点击模式:切换十字线显示状态
if (!widget.isTrendLine &&
painter.isInMainRect(details.localPosition)) {
if (_isCrossLocked) {
// 十字线已显示,点击则隐藏
_isCrossLocked = false;
isOnTap = false;
mInfoWindowStream.sink.add(null); // 清空信息弹窗
} else {
// 十字线未显示,点击则显示并锁定
_isCrossLocked = true;
isOnTap = true;
mSelectX = details.localPosition.dx;
}
notifyChanged();
}
// 趋势线模式:记录点击的坐标点
if (widget.isTrendLine && !isLongPress && enableCordRecord) {
enableCordRecord = false;
Offset p1 = Offset(getTrendLineX(), mSelectY);
// 第一次点击:创建趋势线的起点
if (!waitingForOtherPairofCords) {
lines.add(TrendLine(
p1, Offset(-1, -1), trendLineMax!, trendLineScale!));
}
// 第二次点击:完成趋势线的终点
if (waitingForOtherPairofCords) {
var a = lines.last;
lines.removeLast();
lines.add(
TrendLine(a.p1, p1, trendLineMax!, trendLineScale!));
waitingForOtherPairofCords = false;
} else {
waitingForOtherPairofCords = true;
}
notifyChanged();
}
};
},
),
![]()
(2)长按手势
长按手势长按用来移动十字线、调整趋势线,通过 LongPressGestureRecognizer 实习那
LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<
LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(),
(LongPressGestureRecognizer instance) {
instance
// 长按开始
..onLongPressStart = (details) {
isOnTap = false;
isLongPress = true;
// 普通模式:记录十字线位置
if ((mSelectX != details.localPosition.dx ||
mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
notifyChanged();
}
// 趋势线模式:初始化位置记录
if (widget.isTrendLine && changeinXposition == null) {
mSelectX = changeinXposition = details.localPosition.dx;
mSelectY = changeinYposition = details.globalPosition.dy;
notifyChanged();
}
if (widget.isTrendLine && changeinXposition != null) {
changeinXposition = details.localPosition.dx;
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按移动 - 更新十字线位置
..onLongPressMoveUpdate = (details) {
// 普通模式:跟随手指移动十字线
if ((mSelectX != details.localPosition.dx ||
mSelectY != details.globalPosition.dy) &&
!widget.isTrendLine) {
mSelectX = details.localPosition.dx;
mSelectY = details.localPosition.dy;
notifyChanged();
}
// 趋势线模式:移动趋势线
if (widget.isTrendLine) {
// 计算相对移动距离
mSelectX = mSelectX +
(details.localPosition.dx - changeinXposition!);
changeinXposition = details.localPosition.dx;
mSelectY = mSelectY +
(details.globalPosition.dy - changeinYposition!);
changeinYposition = details.globalPosition.dy;
notifyChanged();
}
}
// 长按结束
..onLongPressEnd = (details) {
isLongPress = false;
enableCordRecord = true; // 启用趋势线坐标记录
// 长按结束后锁定十字线,保持显示
if (!widget.isTrendLine) {
_isCrossLocked = true;
isOnTap = true; // 保持 isOnTap 为 true 以显示十字线
} else {
mInfoWindowStream.sink.add(null); // 趋势线模式清空信息弹窗
}
notifyChanged();
};
},
),
double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;
![]()
最费时间就是缩放和拖拽的冲突问题
后面借鉴 Interactive Chart 这个开源项目的实现思路,用“Listener + ScaleGesture”实现水平移动和缩放,解决了这个问题,缩放和拖拽都丝滑不卡顿
总结一下,本篇文章主要讲解 Canvas 绘制、坐标映射、K 线图绘制基础,并解决了手势冲突的问题
其实K线图开发看着复杂,只要把绘制、数据处理、手势这几个核心模块拆解开,逐一突破,就能轻松搞定,做出高效、流畅、能落地的组件
本文的思路和代码,大家可以直接用到实际项目里,也能根据业务需求扩展功能(比如MACD、RSI指标、成交量显示、行情标注等),希望能帮到正在做Flutter K线图的小伙伴,少走弯路、快速落地!
参考:
AI 时代已经到来,当下最好的破局机会,就是加入一家有潜力的 AI 公司
比特鹰致力于将每位成员,打造成 AI 时代的超级个体,在为用户创造价值的同时实现人生梦想
以下岗位持续开放中:
如果您想在 AI 时代实现百倍的个人提升,欢迎加入我们
联系方式:join@biteagle.xyz