阅读视图

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

HTTP 各版本演进与 HTTPS 原理详解

一、先搞清楚 HTTP 是什么

HTTP(HyperText Transfer Protocol)就是浏览器和服务器之间"对话"的规则。你在浏览器输入一个网址,浏览器按照 HTTP 规则发一个请求,服务器按照 HTTP 规则回一个响应。

浏览器:「我要 /index.html」          →  请求(Request)
服务器:「给你,200 OK,内容如下...」   ←  响应(Response

HTTP 从 1991 年诞生到现在,经历了 5 个大版本。每个版本都是为了解决上一个版本的痛点。


二、HTTP/0.9(1991)—— 最原始的版本

特点

  • 只支持 GET 方法
  • 没有请求头、没有响应头
  • 只能传 HTML 文本
  • 响应完就断开连接

一次对话长这样

请求:GET /hello.html
响应:<html>Hello World</html>
(连接断开)

就这么简单粗暴。没有状态码,没有 Content-Type,啥都没有。

存在的问题

  • 只能传 HTML,不能传图片、CSS、JS
  • 没有任何元数据(不知道内容多大、什么类型、什么编码)
  • 每次请求都要新建连接

三、HTTP/1.0(1996)—— 有模有样了

解决了什么

新增能力 说明
请求头 & 响应头 可以携带元数据了(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,但这不是规范的一部分,行为不统一。


四、HTTP/1.1(1997)—— 用了二十多年的主力

解决了什么

1. 持久连接(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 → 断开

2. 管线化(Pipelining)

理论上可以不等响应就发下一个请求:

客户端:请求1 → 请求2 → 请求3 →
                                  等待...
服务端:                    ← 响应1 ← 响应2 ← 响应3

但实际几乎没人用(原因见下面的问题)。

3. 分块传输(Chunked Transfer)

不需要预先知道内容的总大小,边生成边发送:

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 的逐字输出)。

4. 其他新增

能力 说明
Host 头必选 一个 IP 可以托管多个域名(虚拟主机)
Cache-Control 更精细的缓存控制(替代 Expires)
Range 请求 支持断点续传(下载了一半断了,可以接着下)
100 Continue 先问服务器"我要发一个大文件,你准备好了吗?"
PUT / DELETE / OPTIONS / PATCH 更多的方法,RESTful API 的基础

存在的问题

问题一:队头阻塞(Head-of-Line Blocking)

这是 HTTP/1.1 最致命的问题。

虽然支持管线化,但响应必须按请求的顺序返回。如果第一个请求处理很慢,后面的请求即使已经处理完了,也必须排队等着。

请求顺序:请求1(慢查询)→ 请求2(静态图片)→ 请求3(CSS)

实际情况:
  请求1 ████████████████████░░░░░(处理中...3秒)
  请求2 ░░░░░░░░░░░░░░░░░░░████░  (早就好了,但必须等请求1)
  请求3 ░░░░░░░░░░░░░░░░░░░░░░██  (也在排队等)

就像高速公路只有一条车道,前面的大卡车开得慢,后面的跑车再快也超不过去。

问题二:并发限制的妥协

为了绕开队头阻塞,浏览器的做法是 对同一个域名开 6 个并行 TCP 连接

但这导致了新的问题:

  • 每个连接都要三次握手 + TLS 握手,开销不小
  • 服务器要维护大量连接
  • 催生了"域名分片"等 hack 手段(把资源分散到 cdn1.xxx.com、cdn2.xxx.com 来突破 6 个限制)

问题三:头部冗余

每个请求都要带上完整的头部(Cookie、User-Agent、Accept 等),这些头部在同一个连接上几乎不变,但每次都要重复发送。一个大 Cookie 可能有 1-2KB,100 个请求就白白多传 100-200KB。

问题四:只能客户端主动

服务器不能主动给客户端推数据,只能客户端请求、服务器响应。想实现"服务器推送"只能用长轮询或 WebSocket。


五、HTTP/2(2015)—— 质的飞跃

核心思想

在一条 TCP 连接上实现真正的并行传输。

解决了什么

1. 二进制分帧(Binary Framing)—— 基础革新

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:9Type:HEADStream ID: 1HEADERS 帧
  ├──────────┴───────────┴──────────────┤
  │ :method = GET, :path = /index.html  │
  └─────────────────────────────────────┘

2. 多路复用(Multiplexing)—— 最重要的改进

多个请求/响应可以在同一个 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 手段。

3. 头部压缩(HPACK)

HTTP/2 用 HPACK 算法压缩头部:

  • 维护一个静态表(61 个常见头部,如 :method: GETcontent-type: text/html),用索引号代替完整字符串
  • 维护一个动态表,记录当前连接用过的头部,后续只发索引号
  • 对值做 Huffman 编码 压缩
第一次请求:
  Cookie: session=abc123def456...   (完整发送,同时存入动态表,索引 62)

第二次请求:
  62                                 (只发一个索引号,省掉了几百字节)

效果:头部大小减少 85-95%

4. 服务器推送(Server Push)

服务器可以主动推送客户端可能需要的资源。

客户端请求 /index.html
服务器响应 /index.html
服务器主动推送 /style.css   ← 不用等客户端解析 HTML 后再请求
服务器主动推送 /app.js      ← 提前到达,减少等待

但实际效果争议较大——很难准确预测客户端需要什么,推错了反而浪费带宽。Chrome 已在 2022 年移除了对 Server Push 的支持。

5. 流优先级(Stream Priority)

可以给不同的请求设置优先级。比如 CSS 优先级高于图片,因为 CSS 会阻塞渲染。

存在的问题

TCP 层的队头阻塞 —— HTTP/2 的阿喀琉斯之踵

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

TLS 握手开销

HTTP/2 虽然协议本身不强制加密,但所有浏览器都要求 HTTP/2 必须走 HTTPS。TLS 握手需要额外的 1-2 个 RTT。


六、HTTP/3(2022)—— 换掉了 TCP

核心思想

既然 TCP 的队头阻塞无法在 TCP 层面解决,那就 不用 TCP 了,改用 QUIC(基于 UDP)。

QUIC 是什么

QUIC(Quick UDP Internet Connections)是 Google 设计的传输层协议,跑在 UDP 上。你可以把它理解为"重新实现了一个更好的 TCP"。

HTTP/2 的协议栈:          HTTP/3 的协议栈:
┌──────────┐              ┌──────────┐
│  HTTP/2  │              │  HTTP/3  │
├──────────┤              ├──────────┤
│   TLS    │              │   QUIC   │ ← 把 TLS 融合进来了
├──────────┤              ├──────────┤
│   TCP    │              │   UDP    │
├──────────┤              ├──────────┤
│    IP    │              │    IP    │
└──────────┘              └──────────┘

解决了什么

1. 彻底消灭队头阻塞

QUIC 在传输层就支持多路复用。每个 Stream 独立管理自己的数据包顺序,一个 Stream 丢包不影响其他 Stream

HTTP/2 + TCP:
  Stream 1 的包丢了 → 所有 Stream 被阻塞等重传

HTTP/3 + QUIC:
  Stream 1 的包丢了 → 只有 Stream 1 等重传,Stream 2/3/4 正常收发

2. 0-RTT 连接建立

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 就像发微信:直接把消息和身份验证一起发出去,对方收到就能回。重连时更像对方还认识你,直接开聊。

3. 连接迁移

TCP 连接用"源IP + 源端口 + 目标IP + 目标端口"四元组标识。你手机从 WiFi 切到 4G,IP 变了,TCP 连接就断了,必须重新握手。

QUIC 用一个 Connection ID 标识连接。IP 变了没关系,只要 Connection ID 不变,连接就能无缝切换。

TCP:WiFi → 4G = 连接断开 → 重新三次握手 + TLS 握手 → 恢复
QUIC:WiFi → 4G = IP 变了 → Connection ID 没变 → 无缝继续

对移动端体验提升巨大(电梯、地铁、从室内走到室外)。

4. 改进的头部压缩(QPACK)

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)
服务器推  无          无          无            支持(已废弃)    无

八、HTTPS 详解

8.1 为什么需要 HTTPS?

HTTP 是明文传输,存在三大安全风险:

风险 场景 后果
窃听 连公共 WiFi 时,路由器能看到所有内容 密码、银行卡号泄露
篡改 运营商在网页中插入广告 页面被注入恶意代码
冒充 钓鱼网站伪装成银行 用户被骗输入密码

HTTPS 就是 HTTP + TLS,用加密解决这三个问题:

安全需求 解决方案
防窃听 对称加密(AES),加密传输内容
防篡改 消息认证码(MAC),验证数据完整性
防冒充 数字证书 + 非对称加密(RSA/ECDSA),验证服务器身份

8.2 对称加密 vs 非对称加密

理解 HTTPS 之前,必须搞清楚这两种加密方式。

对称加密

加密和解密用同一把钥匙

明文 "Hello" + 密钥 K → 加密 → 密文 "x7$f2"
密文 "x7$f2" + 密钥 K → 解密 → 明文 "Hello"
  • 优点:速度快(AES 可达 GB/s 级别)
  • 缺点:密钥怎么安全地传给对方?如果密钥被截获,加密就白搭

常见算法:AES、ChaCha20

非对称加密

有两把钥匙:公钥(公开)和 私钥(保密)。公钥加密的只有私钥能解,私钥加密的只有公钥能解。

公钥加密:明文 + 公钥 → 密文(只有私钥能解)
私钥签名:数据 + 私钥 → 签名(公钥可验证)
  • 优点:不需要传递私钥,天然解决密钥分发问题
  • 缺点:速度极慢(比对称加密慢 100-1000 倍)

常见算法:RSA、ECDSA、Ed25519

HTTPS 的选择:混合加密

既然对称加密快但密钥分发难,非对称加密安全但太慢,那就结合使用

① 用非对称加密安全地交换一个"临时密钥"(只需要一次,慢就慢吧)
② 之后所有数据都用这个临时密钥做对称加密(快!)

8.3 TLS 握手流程(TLS 1.2)

这是 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)
  • Client Random:防止服务器重放攻击
  • Server Random:防止客户端重放攻击
  • Pre-Master Secret:只有双方知道的秘密(通过非对称加密安全交换)

三个随机数混合,即使其中一个被猜到,也无法推导出会话密钥。

8.4 TLS 1.3(2018)—— 更快更安全

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 之后)

TLS 1.3 握手流程(只需 1 RTT)

客户端                                              服务器
  │                                                    │
  │ ClientHello                                        │
  │    - 客户端随机数                                    │
  │    - 支持的加密套件                                   │
  │    - 客户端的 ECDHE 公钥 ← 关键!直接带上了            │
  │ ─────────────────────────────────────────────────►  │
  │                                                    │
  │                              ServerHello           │
  │                              + 服务器的 ECDHE 公钥   │
  │                              + 证书                 │
  │                              + Finished            │
  │ ◄─────────────────────────────────────────────────  │
  │                                                    │
  │ 双方用 ECDHE 算出相同的密钥                           │
  │ Finished                                           │
  │ ─────────────────────────────────────────────────►  │
  │                                                    │
  │ ═══════ 1 RTT 后就可以传数据了 ═══════════           │

为什么快了? 因为 TLS 1.2 客户端要等拿到服务器证书和公钥后才能开始密钥交换,而 TLS 1.3 客户端直接在 ClientHello 里就把自己的 ECDHE 公钥发出去了(赌服务器会接受),省了一个来回。

0-RTT 重连

如果之前连过这个服务器,客户端缓存了一个 PSK(Pre-Shared Key):

客户端:ClientHello + PSK + 加密的应用数据 →
                                           服务器直接解密处理

第一个包就能带业务数据,延迟降到极致。

安全代价: 0-RTT 数据没有前向安全性,而且可能被重放攻击。所以只适合幂等请求(GET),不适合会产生副作用的操作(POST 转账)。

8.5 数字证书:怎么证明"你是你"

问题

非对称加密解决了"传密钥"的问题,但引入了新问题:你怎么知道拿到的公钥是真的?

中间人攻击:

客户端 ←→ 中间人(伪装成服务器) ←→ 真正的服务器

中间人把自己的公钥发给客户端
客户端以为这是服务器的公钥,用它加密数据
中间人解密 → 看到明文 → 用服务器真正的公钥重新加密 → 发给服务器

解决:数字证书 + CA

引入一个双方都信任的第三方 —— CA(Certificate Authority,证书颁发机构)

① 服务器生成公私钥对
② 服务器把公钥 + 域名信息提交给 CA
③ CA 验证服务器确实拥有这个域名
④ CA 用自己的私钥对"服务器公钥 + 域名信息"做数字签名
⑤ CA 颁发证书(包含:服务器公钥 + 域名 + CA 签名 + 有效期等)
⑥ 客户端收到证书后,用 CA 的公钥验证签名
⑦ 签名正确 → 公钥可信 → 建立加密连接

类比理解:

就像你去政务大厅办事,需要身份证(证书)。身份证是公安局(CA)发的,上面有你的照片(公钥)和钢印(CA 签名)。办事员通过钢印确认身份证是真的,从而相信你就是本人。

证书链

CA 的公钥又是谁来保证的?答案是证书链

根证书(Root CA)         预装在操作系统/浏览器中,无条件信任
  
  └── 中间证书(Intermediate CA)  CA 签发的
        
        └── 服务器证书(End Entity)    中间 CA 签发的

验证过程从下往上:

  1. 用中间 CA 的公钥验证服务器证书的签名
  2. 用根 CA 的公钥验证中间 CA 证书的签名
  3. 根 CA 证书在本地受信任列表中 → 整条链可信

为什么要分层?如果根 CA 直接签发所有证书,一旦根 CA 私钥泄露,后果不堪设想。分层后,即使中间 CA 出问题,只需要吊销这个中间 CA,不影响其他。

8.6 前向安全(Forward Secrecy)

问题

如果用 RSA 做密钥交换(TLS 1.2 的一种模式),一旦服务器私钥泄露,攻击者可以:

  1. 解密之前录制的所有流量中的 Pre-Master Secret
  2. 从而推导出所有历史会话密钥
  3. 所有历史通信全部暴露

解决:ECDHE 密钥交换

每次连接都生成临时的(Ephemeral) 公私钥对,用完即销毁。

连接1:临时密钥对 A → 会话密钥 X → 销毁临时密钥对 A
连接2:临时密钥对 B → 会话密钥 Y → 销毁临时密钥对 B
连接3:临时密钥对 C → 会话密钥 Z → 销毁临时密钥对 C

即使服务器的长期私钥泄露,也无法解密之前的通信,因为临时密钥已经不存在了。

这就是"前向安全"(Forward Secrecy)。TLS 1.3 强制要求使用 ECDHE,所有连接都具有前向安全性。

8.7 HTTPS 的性能影响

开销 说明 缓解方案
握手延迟 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 的延迟。

8.8 常见 HTTPS 相关概念

概念 解释
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 连接,连接迁移

每一代都在解决上一代留下的问题,同时也带来了新的挑战。技术演进就是这样一步步往前走的。

iOS 应用启动流程与优化详解

一、什么算"启动"?

从用户点击 App 图标,到第一个页面完整渲染出来,这段时间就是启动时间。

苹果把启动分为两个阶段:

用户点击图标
    │
    ▼
┌──────────────────────────────┐
│         Pre-main 阶段         │  ← 系统在干活,你的代码还没执行
│  (dyld 加载 → Runtime 初始化)  │
└──────────────┬───────────────┘
               │
               ▼  main() 函数被调用
┌──────────────────────────────┐
│         Post-main 阶段        │  ← 你的代码开始执行
│  (AppDelegate → 首页渲染完成)  │
└──────────────────────────────┘
               │
               ▼
          用户看到首页

苹果的标准:冷启动应在 400ms 以内完成,超过 20 秒系统会杀掉 App(Watchdog 机制)。

冷启动 vs 温启动 vs 热启动

类型 条件 耗时
冷启动 App 不在内存中,从零开始加载 最长
温启动 App 刚被杀掉,部分数据还在系统缓存中 中等
热启动 App 在后台,从挂起状态恢复 最短(几乎瞬间)

启动优化主要针对冷启动,因为它是最慢的。


二、Pre-main 阶段:系统在干什么?

从点击图标到 main() 函数执行,中间经历了以下步骤:

① 内核 fork 进程,加载可执行文件(Mach-O)
                    │
                    ▼
② dyld 接管,开始加载动态库
                    │
                    ▼
③ Rebase & Bind:修复指针、绑定外部符号
                    │
                    ▼
④ Objc Runtime 初始化:注册类、处理 Category
                    │
                    ▼
⑤ 执行 +load 方法和 C++ 静态构造函数
                    │
                    ▼
⑥ 调用 main()

2.1 加载可执行文件(Mach-O)

iOS 的可执行文件格式叫 Mach-O(Mach Object)。内核先 fork 出一个新进程,把 Mach-O 文件映射到内存中。

Mach-O 的结构:

┌─────────────────┐
│    Header        │ ← 架构信息(arm64)、文件类型
├─────────────────┤
│  Load Commands   │ ← 告诉 dyld 需要加载哪些动态库、各段放在哪
├─────────────────┤
│   __TEXT 段       │ ← 代码(只读)
├─────────────────┤
│   __DATA 段       │ ← 全局变量、指针(可读写)
├─────────────────┤
│  __LINKEDIT 段    │ ← 符号表、签名信息
└─────────────────┘

2.2 dyld 加载动态库

dyld(dynamic link editor) 是苹果的动态链接器,负责把 App 依赖的所有动态库(.dylib / .framework)加载到内存中。

一个普通 App 依赖的动态库数量:

  • 系统库(UIKit、Foundation、CoreGraphics 等):100~400 个
  • 第三方库(如果用了动态 framework):几个到几十个

每个动态库的加载过程:

  1. 从磁盘找到 .dylib 文件
  2. 验证代码签名(安全检查)
  3. 映射到内存
  4. 如果这个库还依赖其他库,递归加载(这就是为什么依赖关系复杂时会很慢)

2.3 Rebase & Bind

由于 ASLR(地址空间布局随机化)的存在,每次启动时 Mach-O 被加载到内存的地址都不同。但代码里的指针是编译时确定的固定地址,所以需要修正。

Rebase(内部指针修正):

  • 把 Mach-O 内部指向自己的指针,加上一个随机偏移量(slide)
  • 比如:代码里写的是 0x1000,ASLR slide 是 0x5000,修正后变成 0x6000

Bind(外部符号绑定):

  • 把 Mach-O 引用的外部符号(比如 UIKit 里的 UIViewController)绑定到实际的内存地址
  • 需要在符号表中查找,比 Rebase 更慢

类比理解:

Rebase 就像搬家后更新通讯录里自己家人的地址(内部),Bind 就像更新朋友的地址(外部,需要打电话问)。

2.4 Objc Runtime 初始化

  • 注册所有 Objective-C 类到全局类表
  • 处理 Category(把 Category 中的方法附加到对应的类上)
  • 确保 selector 唯一性

类越多,这一步越慢。 如果你的项目有上万个类,这里的耗时就很可观。

2.5 Initializers(+load 和静态构造函数)

这是 Pre-main 阶段最后一步,也是开发者唯一能直接控制的部分

  • 执行所有类的 +load 方法(按编译顺序,先父类后子类,先主类后 Category)
  • 执行 C++ 的全局/静态对象的构造函数
  • 执行标记了 __attribute__((constructor)) 的 C 函数

这些代码在 main() 之前就执行了,而且是在主线程上同步执行。如果你在 +load 里做了耗时操作(比如 Swizzle 大量方法、读文件、网络请求),启动就会被拖慢。


三、Post-main 阶段:你的代码在干什么?

main()
  │
  ▼
UIApplicationMain()
  │
  ▼
application:didFinishLaunchingWithOptions:
  │  ← 大量初始化代码通常堆在这里
  │     SDK 初始化、数据库初始化、推送注册、路由注册...
  ▼
创建 UIWindow、设置 rootViewController
  │
  ▼
首页 viewDidLoad → viewWillAppear → viewDidAppear
  │
  ▼
首帧渲染完成 → 用户看到界面

这个阶段的耗时主要来自 didFinishLaunchingWithOptions 中的各种初始化。


四、dyld 版本演进与优化

这是重点中的重点。苹果在 dyld 上做了三次大的版本迭代,每次都大幅优化了启动速度。

4.1 dyld 1.0(远古时代,macOS 早期)

最初的版本,设计简单粗暴:

  • 全量加载:启动时把所有动态库一次性全部加载到内存
  • 无缓存:每次启动都重新解析、绑定
  • 无优化:Rebase/Bind 逐个处理,没有批量优化

问题:随着系统库越来越多,启动速度越来越慢。

4.2 dyld 2.0(iOS 3.1 ~ iOS 12)

这是大家最熟悉的版本,做了很多重要优化:

核心改进

优化项 做了什么 效果
共享缓存(dyld shared cache) 把几百个系统库预先合并成一个大的缓存文件 系统库加载速度大幅提升
懒绑定(Lazy Binding) 外部符号不在启动时全部绑定,而是在第一次调用时才绑定 减少启动时的 Bind 耗时
符号缓存 缓存已解析的符号地址 避免重复查找

共享缓存(dyld shared cache)详解

这是 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 已经预先完成
所有系统库共用一个地址空间

好处:

  • 系统库启动时只需映射这一个文件,不需要逐个解析
  • Rebase/Bind 已经预先做完,启动时不需要再做
  • 所有 App 共享同一份缓存,节省内存

懒绑定(Lazy Binding)

dyld 2 引入了 PLT(Procedure Linkage Table) 机制:

启动时:
  外部函数调用 → PLT 桩函数 → dyld_stub_binder(绑定真实地址并修改 PLT 条目)

首次调用后:
  外部函数调用 → PLT 桩函数 → 直接跳到真实地址(已经绑定好了)

意思是:你的 App 引用了 UIKit 的 100 个函数,启动时不会全部绑定。而是在你第一次调用某个函数时,才去解析它的真实地址。这样启动时的 Bind 工作就分散到了运行时。

dyld 2 的残留问题

尽管有了很多优化,dyld 2 仍然是串行、逐步执行的:

解析 Mach-O → 查找依赖库 → 逐个加载 → Rebase → Bind → 初始化
              └── 每一步都在主线程上同步执行 ──┘

而且:

  • 第三方动态库没法享受 shared cache
  • 每次启动还是要做一遍 Rebase/Bind(对 App 自身的 Mach-O)
  • 安全校验(代码签名)也是启动时做的

4.3 dyld 3.0(iOS 13+,重大重构)

dyld 3 是一次架构级别的重写,核心思想是:把能预先做的工作提前到"启动之外"去做。

三层架构

┌─────────────────────────────────────────────┐
│            ① 进程外的 Mach-O 解析器            │
│    (App 安装/更新时运行,不在启动路径上)         │
│                                             │
│    - 解析 Mach-O header 和依赖关系             │
│    - 查找所有依赖库的位置                       │
│    - 执行安全校验(代码签名)                    │
│    - 把结果写入 启动闭包(Launch Closure)        │
└────────────────────┬────────────────────────┘
                     │ 预先计算好的结果
                     ▼
┌─────────────────────────────────────────────┐
│             ② 启动闭包缓存                     │
│                                             │
│    一个预先序列化好的数据结构,包含:              │
│    - 所有 dylib 的加载地址                      │
│    - 所有需要的 Rebase/Bind 信息                │
│    - 初始化顺序                                │
│    - 已验证的代码签名结果                        │
└────────────────────┬────────────────────────┘
                     │ 直接读取缓存
                     ▼
┌─────────────────────────────────────────────┐
│             ③ 进程内的引擎                     │
│     (真正在 App 启动时运行的部分)               │
│                                             │
│    - 读取启动闭包(一次 mmap)                   │
│    - 按预先计算好的结果直接加载                   │
│    - 极少的运行时计算                           │
└─────────────────────────────────────────────┘

启动闭包(Launch Closure)

这是 dyld 3 最核心的概念。

类比: dyld 2 就像每次做菜都要翻菜谱、找食材、洗切配。dyld 3 相当于提前把所有食材洗好切好配好放在盒子里(启动闭包),做菜时直接下锅就行。

闭包在什么时候创建?

  • App 安装时
  • App 更新时
  • 系统更新时(shared cache 变了)

闭包里存了什么?

  • 完整的依赖关系图
  • 每个 dylib 的磁盘路径和内存加载地址
  • Rebase/Bind 所需的全部信息
  • 代码签名验证结果(通过/失败)
  • 初始化器的执行顺序

dyld 3 vs dyld 2 对比

维度 dyld 2 dyld 3
Mach-O 解析 每次启动都做 安装时做好,缓存到闭包
依赖库查找 每次启动都在文件系统搜索 闭包里已记录完整路径
代码签名校验 每次启动都验证 安装时验证,结果缓存
Rebase/Bind 计算 每次启动都计算 闭包里已预计算
安全性 解析器在进程内,有被攻击风险 解析器在进程外,更安全
启动速度 快 40%+

4.4 dyld 4.0(iOS 16+ / WWDC 2022)

dyld 4 没有大的架构变化,主要是在 dyld 3 基础上做了进一步优化:

主要改进

优化项 说明
统一两种模式 dyld 3 有"有闭包"和"无闭包"两种路径(模拟器上不用闭包),dyld 4 统一成一种
Just-In-Time 加载 更激进的懒加载,某些 dylib 推迟到真正使用时才加载
页面级别的按需加载 不再把整个 dylib 映射进来,而是按页(Page)按需加载
更好的 Swift 支持 优化了 Swift metadata 的初始化
Compact Info 用更紧凑的格式存储链接信息,减少 __LINKEDIT 段的大小
Pre-warming 系统会在后台预热高频 App 的启动闭包

4.5 dyld 各版本一张图总结

dyld 1   →   dyld 2      →   dyld 3      →   dyld 4
(原始)       (iOS 3.1)        (iOS 13)        (iOS 16)
  │             │                │                │
  │        共享缓存            启动闭包           统一架构
  │        懒绑定             进程外解析          按页懒加载
  │        符号缓存            签名缓存           Swift 优化
  │             │                │                │
  ▼             ▼                ▼                ▼
全量加载      减少重复工作      大量工作移到      极致的懒加载
每次解析      分散绑定时机      安装/更新时       页级按需加载

五、启动优化实战指南

5.1 Pre-main 阶段优化

减少动态库数量

每多一个动态库,就多一次查找、加载、签名校验的过程。

做法 效果
合并自己的动态 framework 直接减少加载次数
能用静态库就不用动态库 静态库在编译时已合并进主二进制,启动时无额外加载
控制 Pods 的 use_frameworks! 改用 use_frameworks! :linkage => :static
苹果建议:第三方动态库不超过 6 个 超过就考虑合并

减少 Rebase/Bind

  • 减少 Objective-C 类的数量(合并功能相近的类)
  • 减少 Category 的数量
  • 减少 C++ 虚函数
  • 用 Swift Struct 代替 OC 对象(Struct 不需要 Rebase)

干掉 +load

+load 是启动优化的头号敌人。

原方案 优化方案
+load 中做 Method Swizzling 移到 +initialize(首次使用时才触发)
+load 中注册路由 改用编译期方案(__attribute__((section)) 写入 Mach-O 段)
+load 中初始化 SDK 移到 didFinishLaunching 或更晚

+initialize vs +load 的关键区别:

  • +load:App 启动时全部执行,即使这个类从未被使用
  • +initialize:某个类第一次收到消息时才执行,懒加载

二进制重排(Page Fault 优化)

这是近年最热门的 Pre-main 优化手段。

问题: App 启动时需要执行很多函数,但这些函数分散在不同的内存页上。每访问一个新页面就会触发一次 Page Fault(缺页中断),内核需要从磁盘加载这一页到物理内存。每次 Page Fault 大约耗时 0.1~1ms。

启动时可能触发几百到上千次 Page Fault,累计就是几百毫秒。

解决: 把启动时需要执行的函数重新排列,让它们尽量排在相邻的内存页上,减少 Page Fault 次数。

优化前:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A    │ X    │ B    │ Y    │ C    │   启动需要 AB→C,触发 3 次 Page Fault
│      │      │      │      │      │
└──────┴──────┴──────┴──────┴──────┘

优化后:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A    │ X    │ Y    │      │      │   启动需要 AB→C,只触发 1 次 Page Fault
│ B    │      │      │      │      │
│ C    │      │      │      │      │
└──────┴──────┴──────┴──────┴──────┘

怎么做?

  1. 用 Clang 的 -fsanitize-coverage 插桩,收集启动时调用的所有函数的顺序
  2. 生成一个 order 文件,列出这些函数的符号名
  3. 在 Xcode 的 Build Settings 中设置 Order File 路径
  4. 链接器会按照这个顺序重新排列函数在二进制中的位置

5.2 Post-main 阶段优化

分级初始化

不要把所有 SDK 初始化都堆在 didFinishLaunchingWithOptions 里。

┌─────────────────────────────────────────────────────┐
│                   分级初始化策略                       │
├─────────────┬──────────────────┬────────────────────┤
│   必须立即做   │    首页出现后做    │    用到时才做       │
│              │                  │                    │
│  崩溃统计     │  推送注册         │  分享 SDK          │
│  日志系统     │  数据统计 SDK     │  地图 SDK          │
│  网络库初始化  │  ABTest          │  支付 SDK          │
│  数据库核心表  │  开屏广告         │  蓝牙/定位         │
│              │                  │  AI 相关 SDK       │
└─────────────┴──────────────────┴────────────────────┘

首页渲染优化

优化手段 说明
首页用纯代码布局 避免 xib/storyboard 解析的开销
首页数据缓存 先展示上次的缓存数据,再异步请求新数据
预加载 viewDidLoad 发起网络请求,不要等 viewDidAppear
骨架屏 先展示骨架屏,给用户"已经在加载"的感觉
减少首页层级 AutoLayout 约束越少越好,层级越浅越好

子线程分担

把不依赖 UI 的初始化工作放到子线程:

主线程:UI 配置 → rootVC 创建 → 首页渲染
子线程:SDK 初始化 / 数据库 Migration / 缓存预热

注意:UIKit 相关的操作必须在主线程,但大部分 SDK 的 init 是线程安全的。


六、启动耗时测量

6.1 Pre-main 耗时

在 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%)

每一项对应的优化方向一目了然。

6.2 Post-main 耗时

main() 开头和首页 viewDidAppear 各打一个时间戳,相减就是 Post-main 耗时。

更精细的测量可以用 Instruments 的 App Launch 模板(Xcode 11+),它会自动标注各阶段的耗时。

6.3 MetricKit(线上监控)

iOS 13+ 提供了 MetricKit 框架,可以在线上采集启动耗时数据:

  • MXAppLaunchMetric:冷启动 / 恢复启动的耗时分布
  • 以直方图形式提供 P50 / P90 / P99 数据

七、优化优先级总结

按性价比从高到低排列:

优先级 优化项 预期收益 难度
★★★★★ 删除 +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等这些直接跟界面有关的类里面。

小节

上面的几种方法比较适合不知道如何下手时使用=。=,真正的开发中,还是要根据实际情况考虑,情况也会复杂些。不过倒是可以总结几点原则:

  • 单一职责,意味着一个模块、一个组件只做一件事,绝不多做。
  • 正交性,意思是不重复,一个模块跟另一个模块的职责是正交的,没有重叠,组件也是一样。
  • 单向依赖,模块之间最多是单向的依赖,如果出现A依赖B,B也依赖A,那么要么是A、B应该属于一个模块,要么就是整体的拆分有问题。一个完整的软件系统的模块依赖应该是一张有向无环图。(当然这是最终理想=。=)
  • 紧凑性,模块、组件对外暴露的接口、属性应该尽可能的少,接口的参数个数也要少。
  • 面向接口,模块、组件对外提供服务时最好是面向接口的,以便后期可以灵活的变更实现。

最后

一切为了更加干净整洁的代码,“May the clean code be with you”

参考

面试常问的 RunLoop,到底在Loop什么?

大家好,我是嘉豪。

这两天我又把 RunLoop 重新翻了一遍。这个话题在 iOS 里其实一点都不新,甚至已经算老朋友了,但有意思的是:很多同学平时天天在和它打交道,却未必真的知道它在干什么。

比如下面这些场景,你大概率都见过:

  • 为什么 NSTimer 在滑动 ScrollView 的时候会“失灵”?
  • 为什么 performSelector:afterDelay: 有时候不执行?
  • 为什么子线程里的定时任务就是不回调?
  • 为什么主线程明明没有代码在跑,却也不会退出?

这些问题看起来东一榔头西一棒子,实际上背后都能收敛到同一个东西:RunLoop

所以这篇文章,我不打算只聊“RunLoop 是什么”,而是想带大家把这件事真正串起来:它和线程是什么关系、内部都有哪些角色、每一轮循环在做什么,以及它到底怎么影响我们平时的业务开发。RunLoop 本质上是线程基础设施的一部分,是一个事件处理循环:有事就处理,没事就让线程休眠;主线程的 RunLoop 会在应用启动过程中由系统自动建立并运行,子线程则通常需要你自己决定是否显式启动。

前言:为什么 RunLoop 值得理解?

我一直觉得,RunLoop 这个东西最容易被误解的地方,在于它听起来太“底层”了,于是很多人会下意识觉得:业务开发也用不上。

但真相往往比较朴素,甚至有点滑稽:你不是用不上 RunLoop,而是你天天在被 RunLoop 影响。

主线程为什么能不断响应点击、手势、定时器、刷新 UI?因为它背后一直有一个 RunLoop 在接收事件、分发事件、决定什么时候睡眠、什么时候醒来。Apple 官方对它的定义也很直白:RunLoop 是线程关联的基础设施之一,用来调度任务并协调传入事件的接收。

所以理解 RunLoop,不只是为了背面试题,而是为了在遇到卡顿、定时器异常、线程保活、异步回调这些问题时,不至于两眼一黑,开始对着代码做法事。

RunLoop 到底是什么?

先别急着上源码,我们先用最朴素的方式理解它。

如果让我们自己写一个“线程不退出,但能不断处理事件”的模型,伪代码大概会长这样:

function loop() {
  while (!stopped) {
    const event = getNextEvent();
    if (event) {
      handle(event);
    } else {
      sleep();
    }
  }
}

RunLoop 的本质,和这个思路几乎一模一样。它是一个“事件循环”模型:线程进入循环后,反复执行“接收消息 -> 处理消息 -> 没消息就休眠 -> 被唤醒后继续处理”这一套流程。Apple 官方文档也明确说明,RunLoop 是一个 event processing loop;而从经典的 CFRunLoop 源码解析视角来看,它也完全可以理解成线程内部长期运行的事件循环。

所以从结果上看,RunLoop 解决的是两个核心问题:

  1. 让线程在有事做的时候保持工作。
  2. 让线程在没事做的时候别空转耗 CPU。

这个设计非常重要。否则主线程如果一直死循环轮询事件,手机发热和掉电会快得像开了涡轮;如果线程处理完一个任务就退出,那 App 也根本不可能持续响应事件。宇宙不会允许这种离谱工程存在太久。

RunLoop 和线程是什么关系?

这一点其实是 RunLoop 最关键的前置知识。

可以先记住一句话:RunLoop 和线程是一一对应理解的。

Apple 文档里给出的说法是:每个线程都有关联的 RunLoop 对象,主线程的 RunLoop 会由应用框架自动配置并运行,而二级线程是否运行 RunLoop,则取决于你自己。只有在你真的需要它的时候,才需要显式启动。

这句话翻译成人话就是:

  • 主线程一定有 RunLoop,而且系统已经帮你跑起来了。
  • 子线程就算能拿到 RunLoop,也不代表它已经在跑。
  • 如果子线程要长期存活、处理 Timer、接收 Selector、接收 Port/Source 事件,那你得自己把它跑起来。

还有一个很容易踩坑的点:RunLoop 里必须至少有一个输入源(source)或者 timer,否则一启动就会立刻退出。 Apple 官方文档对此写得很直白。

所以,很多同学在子线程里写个 Timer,结果发现根本不回调,本质原因通常不是 Timer 坏了,而是线程的 RunLoop 根本没跑,或者刚跑起来就退出了。

RunLoop 里到底有什么?

从概念上讲,一个 RunLoop 主要围绕四类东西运转:

  • Mode
  • Source
  • Timer
  • Observer

Apple 文档里把 RunLoop Mode 描述为:一组要监听的 input sources、timers,以及要通知的 observers 的集合。每次 RunLoop 运行时,只会在某个特定 mode 下处理对应的事件;不属于当前 mode 的 source/timer,不会在这一轮被处理。

1. Mode:不是模式切换开关,而是“事件分组”

很多人第一次看 Mode,会觉得这名字有点抽象。其实你可以把它理解成:

RunLoop 当前这一轮,只看哪一组事件。

这就像你开了一个筛子。默认状态下,线程处理一部分事件;当用户开始拖拽 ScrollView 时,RunLoop 可以切到另一个 mode,只处理和拖拽更相关的输入,暂时忽略别的一些东西。Apple 也明确说明了,mode 的作用是根据 source 来过滤事件,而不是根据事件类型本身来过滤。

常见的几个模式可以先记住:

  • NSDefaultRunLoopMode / kCFRunLoopDefaultMode:默认模式,大多数情况下主线程都在这个模式下运行。
  • UITrackingRunLoopMode:控件跟踪时使用的模式,比如滑动列表时。Apple 当前文档对它的描述很直接:这是 tracking 发生时使用的模式,可用于让某些 timer 在 tracking 期间继续触发。
  • NSRunLoopCommonModes / .common:这是一个“伪 mode”,表示一组 common modes。把对象加到这里后,RunLoop 会在所有 common modes 下都监控它。

2. Source:事件从哪来

Apple 官方主要把 input source 分成两类:

  • Port-Based Source:基于端口,通常由内核自动发信号。
  • Custom Input Source:自定义 source,需要你自己定义事件传递机制,并在另一个线程手动 signal。

如果你平时看的是 CFRunLoop 源码分析文章,那还会经常见到 Source0Source1 这套说法。可以先粗暴理解成:

  • Source0:更偏“手动触发”的 source;
  • Source1:更偏“基于 port 被唤醒”的 source。

这两套说法并不冲突,只是一个更偏官方抽象分类,一个更偏底层实现语境。

3. Timer:线程给自己定闹钟

Timer 属于时间源。Apple 文档里强调了两件很重要的事:

  • Timer 不是实时机制,它不是“时间一到,立刻绝对执行”;
  • Timer 也受 RunLoop mode 影响,如果它不在当前被监控的 mode 里,就不会触发;如果 RunLoop 根本没在跑,那它永远不会触发。

这也是为什么你不能把 NSTimer 当成一把精确到毫秒的手术刀。它更像一个“尽量按时提醒你”的闹钟,而不是原子钟。

4. Observer:旁观者,但很重要

Observer 不产生事件,它负责观察 RunLoop 当前走到了哪一步。Apple 文档列出的典型观察时机包括:

  • 即将进入 RunLoop
  • 即将处理 Timer
  • 即将处理 Input Source
  • 即将进入休眠
  • 刚从休眠中唤醒
  • 即将退出 RunLoop

这个东西很关键,因为系统里很多“顺便做一下”的工作,恰恰就是挂在这些观察点上的。

RunLoop 一次循环到底会发生什么?

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 在系统里都干了些什么?

如果只把 RunLoop 理解成“Timer 的家”,那就太小看它了。结合 Apple 文档和经典的 CFRunLoop 源码解析,RunLoop 至少和下面这些机制高度相关。

1. 自动释放池的维护

经典分析里提到,主线程 RunLoop 上挂了和 autorelease pool 相关的 observer:进入 loop 时创建池,准备休眠时销毁旧池并重建,退出 loop 时再做一次销毁。也就是说,我们很多主线程回调,其实天然就被 autorelease pool 包着。

2. 事件响应

触摸、手势、各种输入事件之所以能不断进入 App,被分发到 UIWindowUIViewUIGestureRecognizer,背后同样离不开主线程 RunLoop 对事件源的处理。经典解析中也展示了系统事件如何通过 Source1 进入应用内部分发链路。

3. 界面刷新与提交

很多 setNeedsLayoutsetNeedsDisplay 并不会让 UI 立刻重绘,而是先标记“需要更新”,再等到 RunLoop 的某个合适时机统一提交。经典分析中把这部分和 BeforeWaiting / Exit 这些阶段关联了起来。

4. performSelector 系列方法

Apple 官方文档明确说了:performSelector:onThread: 这一类调用,目标线程必须有一个 active run loop;performSelector:withObject:afterDelay: 也是在当前线程的下一次 run loop cycle 中调度执行。

所以它们“有时不执行”的根本原因,经常不是 selector 本身有问题,而是:

  • 当前线程没有 RunLoop
  • 目标线程的 RunLoop 没启动
  • 或者当前 mode 不对

什么时候你需要手动启动子线程 RunLoop?

Apple 给出的建议其实很实用:只有当子线程需要更强交互性时,才需要显式运行 RunLoop。 比如下面这些场景:

  • 线程间通过 port 或自定义 input source 通信
  • 在线程里使用 timer
  • 使用 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 在工作

不是。主线程是系统自动托管的,子线程通常需要你自己决定是否获取、配置并运行 RunLoop。

误区二:NSTimer 不回调,就是 Timer 不准

也不一定。它可能只是:

  • 当前 RunLoop 没跑;
  • 当前 mode 不匹配;
  • 当前正在执行长任务,错过了触发时机。Apple 官方文档明确说明 timer 不是实时机制,而且如果错过一个或多个计划时间点,也不会把所有错过的触发一股脑补回来。

误区三:NSRunLoopCFRunLoop 完全一样,随便跨线程改

这也不对。Apple 文档提到,Core Foundation 那套 API 通常是线程安全的;但 NSRunLoop 本身并不像底层 CFRunLoopRef 那么天然线程安全,最好只在拥有它的线程里修改它。

小结

到这里,RunLoop 的主线其实就已经很清楚了。

它不是某个冷门 API,也不只是 NSTimer 的背景板。它本质上是线程背后的事件循环机制,负责把 source、timer、observer 和 mode 组织起来,让线程做到:

  • 有事件就处理;
  • 没事件就休眠;
  • 在合适的时机完成事件分发、定时任务、界面提交等工作。

很多平时看起来零碎的问题,比如 Timer 在滚动时失效、子线程任务不回调、performSelector 不执行、UI 为什么不是立刻刷新,本质上都能用 RunLoop 这套模型解释清楚。

所以 RunLoop 这东西,真的不是为了面试八股才学。它更像是一把钥匙:平时你可能把它丢在抽屉里,但一旦遇到线程、事件、时序、刷新相关的问题,它就会突然变得非常好用。

Flutter GetX 深入浅出详解

一、GetX 是什么?

GetX 是 Flutter 生态中的一个 全家桶式框架,它不只是一个状态管理方案,而是把状态管理、路由导航、依赖注入三件事打包在了一起。

很多人第一次接触 GetX 的感受是:"怎么什么都能做?" —— 这既是它的优势,也是它的争议所在。

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()),两行搞定。


二、状态管理

2.1 两种响应式风格

GetX 提供了两种状态管理方式,理解它们的区别是用好 GetX 的第一步。

简单状态管理(GetBuilder)

  • 手动调用 update() 通知刷新
  • 类似 setState(),但作用域更小
  • 性能好,适合不需要频繁自动响应的场景

工作原理很朴素:Controller 内部维护一个监听者列表,调用 update() 时遍历通知所有 GetBuilder Widget 重建。本质就是一个 手动版的观察者模式

响应式状态管理(Obx)

  • 变量用 .obs 标记,变化时自动触发 UI 刷新
  • 不需要手动调用 update()
  • 类似 Vue 的响应式、MobX 的 observable
// 声明
var count = 0.obs;

// 修改(自动触发 UI 刷新)
count.value++;

// UI 监听
Obx(() => Text('${controller.count}'))

2.2 .obs 的底层原理

.obs 是 GetX 最核心的魔法。要理解它,需要拆解三个问题:

问题一:.obs 做了什么?

当你写 var count = 0.obs,实际上是把一个普通的 int 包装成了一个 RxInt 对象。这个 Rx 对象内部持有:

  • 真正的值(_value
  • 一个监听者列表(Stream

它本质上就是一个 带通知能力的值容器

问题二:修改值时发生了什么?

当你写 count.value++Rx 对象的 set value 被触发。在 setter 内部:

  1. 更新 _value
  2. 通过 Stream 广播一个"值变了"的事件
  3. 所有订阅了这个 Stream 的监听者收到通知

问题三:Obx 怎么知道要监听哪些变量?

这是 GetX 最巧妙的设计。Obx 并不需要你手动告诉它"我依赖了哪些变量",它是 自动收集依赖 的。

原理分三步:

  1. Obx 在首次 build 时,先打开一个"全局监听开关"
  2. 执行你传入的 builder 函数(比如 () => Text('${count.value}')
  3. count.value 的 getter 被调用时,Rx 对象检测到"监听开关"是打开的,就把自己注册到 Obx 的依赖列表中
  4. builder 执行完毕,关闭"监听开关"

之后任何被收集到的 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 劫持的自动依赖收集

2.3 GetBuilder 的底层原理

相比之下,GetBuilder 的原理简单得多:

  1. GetBuilderinitState 时,把自己注册到 Controller 的监听者列表
  2. Controller 调用 update(),遍历列表,调用每个 GetBuildersetState
  3. GetBuilderdispose 时,从列表中移除自己

没有 Stream、没有依赖收集,就是最朴素的 观察者模式 + setState

2.4 两种方式怎么选?

场景 推荐 原因
表单页面、简单列表 GetBuilder 手动控制,性能开销最小
数据频繁联动、多变量交叉依赖 Obx + .obs 自动依赖收集,代码更简洁
超大列表、高性能场景 GetBuilder + update([id]) 可以精确控制刷新范围

三、依赖注入

3.1 是什么?

GetX 内置了一套依赖注入系统,核心 API 就两个:

  • Get.put(Controller()) —— 注册
  • Get.find<Controller>() —— 获取

你可以把它理解成一个 全局的"服务柜台":先把东西放进去,需要的时候按类型取出来。

3.2 底层原理

GetX 内部维护了一个 全局的 Map<String, Object>,key 是类型名(或类型名 + tag),value 是实例。

全局容器(简化理解):
{
  "HomeController": HomeController 实例,
  "UserService": UserService 实例,
  "ApiClient_v2": ApiClient 实例(带 tag)
}

Get.put() 就是往 Map 里写,Get.find() 就是从 Map 里读。

3.3 四种注册方式的区别

方式 何时创建 何时销毁 适用场景
Get.put() 立即创建 手动或路由关闭时 页面 Controller
Get.lazyPut() 首次 find 同上 可能用不到的依赖
Get.putAsync() 立即创建(支持异步) 同上 需要异步初始化的服务
Get.create() 每次 find 都新建 不自动销毁 每次需要新实例的场景

3.4 SmartManagement:自动内存管理

GetX 最被低估的能力之一。它有一套 智能内存管理机制,可以在路由关闭时自动销毁关联的 Controller。

三种模式:

  • full(默认):不被任何路由或 Widget 使用的 Controller 自动销毁
  • onlyBuilder:只有通过 GetBuilder / GetX 使用的 Controller 才自动管理
  • keepFactory:销毁实例但保留工厂函数,下次 find 时重新创建

这解决了 Flutter 状态管理中一个常见的痛点:谁来负责销毁 Controller? 在 Provider/Bloc 中你需要手动处理,GetX 帮你自动化了。


四、路由管理

4.1 为什么要替换 Flutter 原生路由?

Flutter 原生路由的痛点:

  • 跳转需要 context,在非 Widget 层(Service、Controller)中很难拿到
  • 传参和接收返回值写法繁琐
  • 路由动画自定义复杂

GetX 的路由通过全局 NavigatorKey 持有 Navigator 的引用,所以 不需要 context 就能跳转。

4.2 底层原理

GetX 路由的核心做了两件事:

第一:全局 NavigatorKey

GetX 在 GetMaterialApp 初始化时,创建了一个全局的 GlobalKey<NavigatorState>,保存在静态变量中。之后所有路由操作都通过这个 key 拿到 Navigator,不再依赖 context。

第二:路由与依赖注入联动

这是 GetX 路由最独特的地方。当你用 Get.to(HomePage()) 跳转时:

  1. 创建一个路由条目
  2. 如果 HomePage 关联了 Controller(通过 GetBuilderBindings),自动 put 进依赖容器
  3. 当路由 pop 时,自动 delete 关联的 Controller
Get.to(HomePage())
  → 创建路由
  → 自动注册 HomeController(如果有 Binding)
  → 用户在 HomePage 操作...
  → Get.back()
  → 路由 pop
  → 自动销毁 HomeController
  → 内存释放

这就形成了一个 路由驱动的生命周期管理:Controller 的生死和页面的进出自动绑定。

4.3 Bindings:依赖与路由的桥梁

Bindings 是连接路由和依赖注入的纽带。它定义了"进入某个页面时需要准备哪些依赖"。

你可以把它类比为 iOS 的 viewDidLoad —— 页面加载时做初始化工作,页面销毁时自动清理。

4.4 中间件(Middleware)

GetX 路由支持中间件,可以在路由跳转前/后插入逻辑:

  • 登录拦截:未登录自动跳转登录页
  • 权限检查:没有权限的页面拒绝访问
  • 埋点:自动记录页面访问

中间件按优先级执行,可以中断跳转(返回 null 表示拦截),和 Web 框架的中间件概念一致。


五、GetX 的其他能力

GetX 是个全家桶,除了三大核心模块,还打包了很多实用工具:

能力 说明
国际化(i18n) 'hello'.tr 即可翻译,动态切换语言
主题切换 Get.changeTheme() 一行切换深色/浅色
网络请求 GetConnect 封装了 HTTP 客户端
本地存储 GetStorage 类似 SharedPreferences 但更快
响应式表单验证 配合 .obs 做实时校验
Snackbar / Dialog / BottomSheet 不需要 context 的全局弹窗
Worker ever / debounce / interval 等响应式工具

Worker 机制

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 的底层架构总结

把所有模块串起来,GetX 的底层可以概括为三个核心机制:

1. Rx + Stream:响应式引擎

.obs 变量(Rx 对象)
  └── 内部持有 Stream
        └── Obx / Worker 订阅 Stream
              └── 变量变化 → Stream 广播 → 订阅者响应

这是 Dart 语言自带的 Stream 机制,GetX 没有发明新东西,只是在 Stream 之上做了 语法糖封装.obsObxever 等),降低了使用门槛。

2. 全局 Map:依赖注入容器

静态 Map<String, InstanceInfo>
  └── Get.put() 写入
  └── Get.find() 读取
  └── Get.delete() 删除
  └── SmartManagement 自动清理

没有复杂的 IoC 容器,就是一个 Map。简单直接。

3. 全局 NavigatorKey:脱离 context 的路由

GetMaterialApp 初始化 → 持有全局 NavigatorKey
  └── Get.to() / Get.back() → 通过 Key 拿到 Navigator → 执行路由操作
  └── 路由变化 → 触发 Bindings → 联动依赖注入的创建/销毁

七、GetX 的争议

赞成派观点

  • 开发效率极高:原型开发、中小项目飞速
  • 学习曲线平缓:API 直觉化,新手友好
  • 全家桶一站式:不用在多个库之间做选型和协调

反对派观点

  • 过度封装:把 Flutter 的很多设计理念(如 BuildContext、InheritedWidget)绕过了,新手可能对 Flutter 本身理解不深
  • 隐式行为多:自动依赖收集、自动销毁,出了问题难以调试
  • 大型项目维护难:全局状态 + 隐式依赖,随着项目变大,依赖关系会变得不透明
  • 和 Flutter 官方方向渐行渐远:Flutter 团队推崇的是 Riverpod / Provider 思路

客观建议

项目类型 推荐度 建议
个人项目 / Demo 强烈推荐 快速出活
中小型商业项目 推荐 配合良好的分层架构使用
大型团队协作项目 谨慎 建议考虑 Riverpod / Bloc,或严格约束 GetX 的使用范围
学习 Flutter 阶段 不推荐先学 先理解 Flutter 原生机制,再用 GetX 提效

八、GetX vs 其他状态管理方案

维度 GetX Provider Riverpod Bloc
学习成本
模板代码量 极少
依赖 context 不需要 需要 不需要 需要
内置路由
内置依赖注入 自身就是 DI 自身就是 DI 无(需配合)
可测试性
官方推荐 是(早期) 是(现在) 社区主流
适合规模 小中型 中型 中大型 大型

九、一句话总结

GetX 的哲学是 "约定优于配置,简单优于正确"。它牺牲了一些架构上的严谨性,换来了极致的开发效率。理解它的底层原理(Rx Stream + 全局 Map + 全局 NavigatorKey),你就能用好它,也知道它的边界在哪里。

iOS 可视化埋点与无痕埋点详解

一、为什么需要不同的埋点方式?

最早大家都是 手动写代码埋点:在每个按钮点击、页面出现的地方,手动调用 track("事件名")。这种方式最精确,但有两个痛点:

  1. 每加一个埋点就要改代码、发版,周期太长
  2. 埋点需求爆炸式增长,开发根本忙不过来

于是业界开始思考:能不能让机器自动采集?能不能让运营自己配置?

这就催生了三种埋点方式的演进:

手动代码埋点 → 无痕埋点(全自动) → 可视化埋点(半自动)
维度 代码埋点 无痕埋点 可视化埋点
谁来埋 开发 机器自动 运营圈选
需要发版吗 需要 不需要 不需要
能带业务参数吗 能(商品ID、金额等) 不能 有限支持
数据量 按需 巨大 按需
精确度 最高 最低 中等

二、无痕埋点(全埋点)

一句话理解

不写任何埋点代码,SDK 自动采集用户的所有操作。

原理:偷梁换柱

iOS 有个强大的 Runtime 机制叫 Method Swizzling —— 可以在运行时把系统方法的实现"偷偷换掉"。

举个例子,iOS 中所有按钮点击最终都会走 UIControlsendAction:to:forEvent: 方法。SDK 做的事情就是:

原本的调用链:
  用户点击按钮 → sendAction → 执行业务逻辑

Swizzle 之后:
  用户点击按钮 → SDK 拦截,记录"谁在哪个页面点了什么" → 再调用原始 sendAction → 执行业务逻辑

业务方完全无感知,SDK 悄悄在中间插了一层数据采集。

SDK 需要 Hook 哪些地方?

拦截点 能采集到什么
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]

这就像是给每个按钮一个"门牌号"。

致命缺陷

  1. 门牌号不稳定:UI 稍微改一下层级(比如在按钮外面多套一层 View),路径就变了,之前的数据就对不上了

  2. 只知道行为,不知道内容:SDK 能告诉你"用户点了第3个 Cell 里的按钮",但不知道那个 Cell 显示的是什么商品、多少钱

  3. 数据量爆炸:用户每一次点击、每一次滑动都会上报,90% 的数据可能没人看


三、可视化埋点

一句话理解

在无痕埋点的基础上,加了一个"后台圈选"的功能。运营在后台看到 App 截图,用鼠标点选要追踪的元素,SDK 只上报被选中的事件。

工作流程

第一步:App 和后台建立 WebSocket 连接

第二步:App 截图 + View 树结构 → 发给后台
       (后台能看到 App 当前界面的"透视图")

第三步:运营在后台的截图上点击"立即购买"按钮
       → 后台自动识别出这个按钮的 ViewPath
       → 运营给它命名为 "click_buy_button"

第四步:后台把配置下发给 SDK
       { viewPath: "xxx", eventName: "click_buy_button" }

第五步:SDK 在 Hook 点拦截事件时,拿当前元素的 ViewPath 去配置表里匹配
       → 匹配到了才上报,匹配不到就忽略

和无痕埋点的本质区别

无痕埋点:先采集所有数据 → 后期在数据平台筛选(先采后筛)
可视化埋点:先配置要采什么 → 只采集配置过的(先筛后采)

这就像是:

  • 无痕埋点 = 装了 360 度全景摄像头,24 小时录像,需要的时候回看
  • 可视化埋点 = 在关键位置装定向摄像头,只拍你关心的区域

优势

  • 运营自助:不需要开发介入,运营在后台圈选即可生效
  • 动态生效:配置下发后立即生效,不需要发版
  • 数据可控:只采集被圈选的事件,数据量小

局限

  • 依赖 ViewPath 稳定性:和无痕埋点一样,如果 UI 层级变了,之前圈选的配置就失效了
  • 无法携带复杂业务参数:你能圈选"购买按钮被点击",但很难自动带上"买的是哪个商品"
  • WebView / H5 页面支持复杂:需要额外注入 JS SDK

四、实战中怎么选?

成熟的 App 不会只用一种,而是 混合使用

场景 推荐方式 原因
页面 PV、App 启动/退出 无痕埋点 标准化行为,不需要业务参数
运营活动按钮、Tab 切换 可视化埋点 需求变化快,运营自助配置
支付、注册、加购、分享 代码埋点 需要精确的业务参数(金额、商品ID)

一个简单的原则:

数据越重要、越需要业务参数的事件,越应该用代码埋点;越通用、越标准化的行为,越适合自动采集。


五、ViewPath 稳定性:所有自动埋点方案的阿喀琉斯之踵

ViewPath 不稳定是无痕埋点和可视化埋点最大的技术挑战。业界的应对思路:

  1. 用 accessibilityIdentifier 做锚点:给关键元素设置固定 ID,优先用 ID 而不是层级位置来标识
  2. 模糊匹配:不要求路径完全一致,允许中间层级有增减,只要首尾和关键节点匹配度达到 80% 就算命中
  3. 哈希指纹:结合元素类型、文本内容、相对位置等多维度信息生成指纹,不完全依赖层级路径

六、SwiftUI 时代的新挑战

传统方案依赖 UIKit 的 View 层级树,但 SwiftUI 的渲染机制完全不同 —— 开发者写的 Button 和实际渲染出来的 View 层级之间没有稳定的对应关系。

目前的解决方向:

  • 利用 SwiftUI 的 ViewModifier 机制,做类似 .tracked("buy_button") 的声明式埋点
  • 借助 Accessibility Tree(辅助功能树)作为更稳定的元素标识来源
  • 通过 SwiftUI Introspect 获取底层 UIKit View 做桥接

这个领域还在发展中,还没有像 UIKit 时代那样成熟的方案。


七、隐私合规

  • 截图上传时必须对密码框、身份证号等敏感区域做 模糊处理
  • 不采集键盘输入内容
  • 需要在隐私政策中明确告知用户数据采集范围
  • 遵循 GDPR / 中国个人信息保护法
  • SDK 必须提供关闭开关

八、业界参考

平台 特点
GrowingIO 国内可视化埋点先驱,圈选体验好
神策 Sensors Analytics 全埋点 + 可视化 + 代码埋点全覆盖,iOS SDK 开源
Mixpanel 可视化埋点 + 代码埋点,国际主流
Heap 全埋点理念的代表,"Capture Everything"
Firebase Analytics Google 出品,自动事件 + 自定义事件

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

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

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

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

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

问题代码:

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

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

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

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

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


一、色彩模型

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

1.1 RGB

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

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

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

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

1.2 HSB/HSV

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

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

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

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

1.3 CIELAB

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

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

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

小结

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

二、色彩空间

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

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

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

2.1 sRGB

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

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

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

2.2 Display P3

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

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

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

Apple 设备时间线

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

2.3 Adobe RGB

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

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

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

2.4 ProPhoto RGB

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

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

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

色域对比总结

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

三、位深度

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

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

几个关键事实:

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

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

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

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


四、像素格式

4.1 CGImage 的关键属性

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

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

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

4.2 RGBA vs BGRA

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

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

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

iOS 上常见的像素配置

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

4.3 预乘 Alpha(Premultiplied Alpha)

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

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

为什么用预乘?

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

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

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

4.4 CGBitmapContext 支持的格式组合

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

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

五、图片文件格式

5.1 JPEG

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

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

5.2 PNG

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

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

5.3 HEIF/HEIC

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

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

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

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

格式对比

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

六、iOS 取色方案对比

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

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

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

特点

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

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

方案 B:CGContext 重绘(推荐)

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

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

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

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

特点

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

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

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

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

方案 C:Core Image

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

特点

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

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

方案 D:vImage(Accelerate 框架)

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

特点

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

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

方案对比总结

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

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

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

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

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

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

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

        let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue

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

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

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

        let offset = y * bytesPerRow + x * 4

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

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

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

@State private var pixelReader: PixelReader? = nil

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

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


八、取色常见坑点

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

参考资料

iOS 26手势返回到根页面时TabBar的动效问题

问题描述

我在适配完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]; 就正常渐显了。

关键代码如下

  1. 创建首页的UINavigationController时设置代理
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];
    }
}

以上,希望有帮助到大家

使用 Ipa Guard 命令行版本将 IPA 混淆接入自动化流程

当项目进入稳定迭代阶段,很多团队都会把构建流程放进 CI,例如 Jenkins、GitHub Actions 或 GitLab CI。编译 IPA、运行测试、生成构建产物都可以自动完成。但如果需要在发布前做代码混淆或资源处理,图形界面工具就会显得有些不方便。

我在维护一个长期更新的 iOS 项目时遇到过类似问题:每次构建完成后,都需要对 IPA 进行一次混淆处理。如果完全依赖界面操作,就意味着要人工导入 IPA、选择符号、再导出结果。几次之后就会发现,这一步完全可以放进自动化脚本里。

Ipa Guard 的命令行版本正好适合这种场景。它把 IPA 解析、符号混淆、资源处理这些步骤拆成可以调用的命令,同时还能输出符号映射文件,方便排查崩溃问题。下面记录一套实际操作流程。


一、准备待处理的 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 进行签名

由于混淆修改了 IPA 内容,原有签名已经失效。

需要重新签名才能安装到设备。

可以使用签名工具,例如 kxsign

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

参数说明:

  • -c 证书文件
  • -p 证书密码
  • -m 描述文件
  • -z 输出 IPA
  • -i 安装到设备

如果连接了测试手机,命令执行完成后会自动安装。


六、设备测试与崩溃排查

混淆后的版本一定要运行一遍完整流程,例如:

  • 登录
  • 支付
  • 页面加载
  • H5 模块调用

如果发生崩溃,可以借助 Ipa Guard 生成的符号映射文件查找原始函数名。

映射文件会记录:

混淆前符号
混淆后符号

这样在 Crash 日志中看到混淆名称时,仍然可以找到对应代码位置。


七、将混淆步骤接入 CI

当流程稳定后,可以写一个简单脚本:

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 混淆接入自动化流程后,发布过程会变得更稳定。符号解析、混淆处理、资源修改和签名测试都可以通过脚本完成,而不是依赖人工操作。

参考链接:ipaguard.com/tutorial/zh…

iOS 知识点 - 渲染机制、动画、卡顿小集合

一、基本骨架

从代码到像素,都经历了什么?一帧画面是怎么到屏幕上的?

┌──────────────────────────────────────────────────────────────────────────────┐
│                     一帧的完整生命周期 (Render Loop)                            │
│                                                                              │
│   VSYNCVSYNCVSYNC₃        │
│     │                               │                             │          │
│     │  ┌─────────── App 进程 ───────────────┐                      │          │
│     │  │ ① Handle EventCommit Transaction│                   │          │
│     │  │   (触摸/定时器)    ┌────────────────┐  │                   │          │
│     │  │                   │LayoutDisplay ││                   │          │
│     │  │                   │PreparePackage││                   │          │
│     │  │                   └────────────────┘│                    │          │
│     │  └──────────┬──────────────────────────┘                    │          │
│     │             │ Layer Tree 发送                                │          │
│     │             ▼                                               │          │
│     │  ┌─────────── Render Server (独立进程) ─────┐                 │          │
│     │  │ ③ Render PrepareRender 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 截止时间前就绪 用户感知为动画跳跃、滚动卡顿

二、Render Loop(渲染循环):渲染机制拆解,一帧是怎样诞生的

2.1 五个阶段

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 硬件 把纹理推上屏幕

2.2 Commit 阶段的四个子步骤

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 的位图缓存。

2.3 Double Buffering 与 Triple Buffering

Double Buffering(双缓冲) 是 iOS 渲染流水线的默认工作模式,指系统同时维护 两个帧缓冲区,让 App 准备下一帧和屏幕显示当前帧可以并行进行,互不干扰。

  • Double Buffering(默认):App 和 Render Server 各占一个 VSYNC 周期,总延迟 2 帧。
  • Triple Buffering(降级模式):当 Render Server 来不及时,系统自动切换,给 Render Server 多一帧的时间。帧延迟增加到 3 帧,但能避免更严重的掉帧。

为什么需要缓冲区?

如果只有一个缓冲区(Single Buffering),屏幕 正在读取这个缓冲区显示画面 的同时,GPU 也在往里写新内容,就会出现 画面撕裂(Screen Tearing)——上半截是旧帧,下半截是新帧。


三、CALayer 与三棵树:动画的根基

3.1 Layer 是什么?

CALayer 是一个 模型对象,它不做绘制,它只持有:

  • 几何信息: bounds / position / transform / anchorPoint
  • 视觉属性: backgroundColor / opacity / cornerRadius / shadow
  • 内容: contents 位图

View 和 Layer 的关系: iOS 上每个 UIView 都自动持有一个 backing layer。View 负责事件响应(触摸、手势)和响应链,Layer 负责视觉呈现。你改 view.frame 其实改的是view.layer 的属性。Layer 不处理事件、不参与响应链。

3.2 三棵 Layer Tree

┌─────────────┐    ┌──────────────────┐    ┌─────────────┐
│  Model Tree │    │ Presentation Tree│    │ Render Tree │
│  (图层树)    │    │   (呈现树)        │    │  (渲染树)    │
│             │    │                  │    │             │
│ 你代码改的值  │    │ 动画进行中的当前值  │    │ 实际渲染用    │
│ = 动画目标值  │    │ = 屏幕上的即时值   │    │  (私有,不可访问)│
└─────────────┘    └──────────────────┘    └─────────────┘
       │                    ▲
       │    layer.presentationLayer
       └────────────────────┘
  • Model Tree(图层树): 比如 layer.position = newPos 改的就是它。它始终保存 “最终目标值”。
  • Presentation Tree(呈现树): 动画进行时,layer 实际所在位置是 layer.presentationLayer
  • Render Tree(渲染树): Core Animation 内部使用,无法访问。

关键推论: 给 layer 加动画后,Model Tree 里的值就已经是终点值了。动画结束后如果 removedOnCompletion = YES(默认),layer 就直接呈现 Model Tree 的值。如果你没改 Model Tree 的值,layer 就会"跳回去"——这就是动画结束后 layer 回到原位的经典问题。

presentationLayer 的使用场景: 用户在动画飞行途中点击/拖拽 layer 时,需要用 presentationLayer 获取当前真实位置来做 hitTest 或启动新动画。

3.3 CATransaction - 变更打包器

所有对 Layer 的属性修改都被 CATransaction 捕获:

  • 隐式事务: 哪怕不写 begin/commit,系统也会在每个 RunLoop 循环自动包裹一次。
  • 显式事务: [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[] — 组合多个动画)

4.1 CABasicAnimation — 两点插值

提供起止状态,系统通过插值(Interpolation)算出任意时刻的值。三个属性的语义:

  • fromValue:起始值(绝对值)
  • toValue:结束值(绝对值)
  • byValue:变化量(相对值,"变化了多少")

4.2 CAKeyframeAnimation — 多点插值

关键帧动画 = N 段 BasicAnimation 的串联。提供一组 values 和对应的 keyTimes(归一化 0~1),系统在相邻关键帧之间插值。

calculationMode 决定插值方式:linear(默认)

4.3 CASpringAnimation — 弹簧物理

继承自 CABasicAnimation,用弹簧力学模型驱动动画曲线:mass(质量越大,运动越慢,但衰减也越慢)等。

4.4 CATransition — 两张快照之间的过渡

CATransition 不指定 from/to 值。它的工作方式:

  1. 把动画添加到 layer 时,拍下当前 layer 的快照(开始状态)
  2. 紧接着你对 layer 做修改(比如替换子视图、改文字)
  3. 修改后的 layer 是结束状态
  4. 系统在两张快照之间播放指定的过渡效果

4.5 CAAnimationGroup — 组合动画

把多个动画放在 animations 数组里同时执行。注意:

  • Group 的 duration 是一个 硬截止:到时间所有子动画停止,不管子动画是否结束。
  • 各子动画独立执行,不互相等待。

五、Hang(卡顿/无响应):主线程被占的代价

Hang = 主线程无法在合理时间内处理用户事件。

WWDC23 统计过一期人类感知阈值,大概如下:

  0ms          100ms         250ms         500ms
   │─── 感觉即时 ──│── 微妙可感 ──│── 明显延迟 ──│── 严重卡顿 ──▶
                    │              │              │
              目标上限   Micro Hang(系统开始上报)    Hang

5.1 Hang 的三种类型

类型 主线程 CPU 表现 典型原因
Busy Main Thread 高(60~100%) 主线程在拼命算东西 大量布局计算、同步图片处理、JSON 解析
Blocked Main Thread 极低(~0%) 主线程在等锁/等IO/等网络 同步网络请求、信号量等待、锁竞争、同步文件IO
Asynchronous Hang 可高可低 不是当前事件导致的,而是之前调度到主线程的任务占了时间 dispatch_async(main) 的耗时任务、@MainActor 下的同步代码
同步 Hang:
  用户点击 → [────── 主线程处理耗时 ──────] → 响应
              ←─── 这段就是 hang ───→

异步 Hang:
  之前调度的任务 → [──── 主线程被占 ────]
                           ↑ 用户点击来了,但得排队
                           ←── 这段是 hang ──→

5.2 Swift Concurrency 中的陷阱

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(掉帧):动画不流畅的元凶

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 严重影响体验,必须立即修复

6.1 Hitch 的两种类型

类型 超时发生在 常见原因
Commit Hitch App 端 Commit 阶段 复杂布局、drawRect 耗时、大图解码、深层级打包
Render Hitch Render Server / GPU Offscreen Pass 过多、大面积模糊/阴影、复杂遮罩

6.2 Offscreen Pass(离屏渲染)—— Render Hitch 的主要元凶

  • 当屏渲染:GPU 的任务是把所有 layer 从后往前逐个画到一块最终纹理上(就是你屏幕看到的那一帧画面):
最终纹理(屏幕画面)
┌──────────────────┐
│                  │
│  第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

八、GPU 优化

  • 图层混合(Blending):当 layer 不是完全不透明时(opacity < 1 或 backgroundColor 为 nil/透明),GPU 需要把当前 layer 和底下的 layer 做像素混合计算。
    • 优化方式:给 view 设不透明背景色、设 opaque = YES、避免不必要的透明。
  • shouldRasterize(光栅化缓存):把一个复杂的 layer 子树一次性渲染成位图缓存,后续帧直接复用。适合内容不常变的复杂视图(如带阴影+圆角+多子视图的卡片)。但缓存有 100ms 未使用自动释放的限制,且 内容变化时需要重新光栅化,用不好反而更慢
  • 像素对齐(Pixel Alignment):frame 的坐标不是整数像素时,GPU 需要做抗锯齿混合。用 CGRectIntegral 或 SnapKit 的 snp.makeConstraints 保持像素对齐。

Flutter 实现手势缩放丝滑的 K 线(内涵源码)

本文基于 Flutter 框架,从 Canvas 绘制、K 线数据结构、蜡烛图核心绘制逻辑、MA 指标实现,到手势冲突优化,全方位拆解金融 APP K 线图开发流程,分享实战问题与解决方案,助力开发者快速实现流畅可落地的 K 线组件。

在金融类 APP 开发中,K 线图是必不可少的组件之一,体验直接可导致用户数量的流失

本文将通过 Flutter 框架,并结合实际的开发经验,从 Canvas 绘制基础、数据结构定义、核心绘制逻辑、技术指标实现到手势系统优化,全方位的拆解 K 线图的开发过程,分享我开发过程中遇到的问题以及解决方案,帮助你掌握 Flutter K 线图开发技巧

先看最终效果

925596b7e263610b1bab63b6fa1529cd.png

一、Canvas 绘制基础

首先我们得先学习 Flutter 中的 Canvas 绘制

懂 Canvas 绘制基础可直接跳过这条段,想要在 Flutter 中自定义绘制,核心需要通过 CustomPaint + CustomPainter

在动手之前需要先把 Flutter Canvas 坐标系规规则给理解一下

  • 原点 (0,0) 在绘制区域的左上角
  • x 轴向右为正
  • y 轴向下为正

与我们日常认知的“y轴向上为正”不同,需要记住这一点,这是避免绘制错位的关键

简单 Demo

为快速熟悉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线的核心信息

一根完整的K线包含开盘价、最高价、最低价、收盘价、成交量和时间戳六大核心字段,对应的数据结构如下:

class CandleEntity {
  double open;    // 开盘价
  double high;    // 最高价
  double low;     // 最低价
  double close;   // 收盘价
  double vol;     // 成交量
  int? time;      // 时间戳(毫秒)
}

CandleEntity 类是K线图开发的“数据载体”,后面所有绘制逻辑(蜡烛、均线)均围绕该类的实例展开

实际开发中,也可以根据需求扩展字段,比如添加均线值列表(maValueList),用于存储单根K线对应的各类均线数据

三、单根K线绘制逻辑

K线图的核心是 Candle 的绘制,单根 Candle 由实体部分(开盘价与收盘价之间的矩形)和影线部分(最高价与最低价之间的线段)组成,而且需区分阳线(涨)和阴线(跌),绘制逻辑如下

价格与屏幕坐标映射

因为Canvas坐标系和实际价格维度不一样,所以得把价格转换成屏幕上的Y坐标。核心逻辑就是用当前K线数据集的最高价、最低价算缩放比例,再把价格映射成屏幕坐标,公式如下:

double getY(double y) => (maxValue - y) * scaleY + _contentRect.top;

  • maxValue 为当前K线数据集的最高价
  • 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)

MA均线计算逻辑

/// 计算移动平均线(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线数据,内层循环针对每个均线周期,累加收盘价,当达到周期长度时计算均值,后续通过滑动窗口更新均值(减去滑出窗口的收盘价,加上新的收盘价),确保计算高效

MA均线绘制逻辑

当完成逻辑的计算之后,通过绘制线段实现绘制,核心是获取相邻两根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 组件处理先判断有几根手指在屏幕上,再自动切换是拖拽还是缩放,互不干扰:

  • 一根手指(_pointerCount < 2):只走拖拽逻辑,让 K 线图左右滑动,看更早的数据
  • 两根及以上手指(_pointerCount ≥ 2):只走缩放逻辑,让 K 线图放大缩小,看细节或看整体

缩放 + 拖拽

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 实现

  • 普通模式:点一下 K 线图,十字线就显示 / 隐藏,同时会显示这根 K 线的详细数据,比如开盘价、收盘价
  • 趋势线模式:点两下,第一下记起点,第二下记终点,就能画出趋势线
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 实习那

  • 普通模式:长按屏幕并移动手指,十字线会跟着手指走,实时显示指到哪里的 K 线信息
  • 趋势线模式:长按画好的趋势线,就能拖动调整位置,方便修改
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线图的小伙伴,少走弯路、快速落地!

源码:github.com/kian-lian/c…

参考:

团队招募 | 共同探索技术边界

AI 时代已经到来,当下最好的破局机会,就是加入一家有潜力的 AI 公司

比特鹰致力于将每位成员,打造成 AI 时代的超级个体,在为用户创造价值的同时实现人生梦想

以下岗位持续开放中:

  • 后端开发工程师
  • 前端开发工程师
  • AI 应用开发工程师
  • 爬虫工程师
  • 大数据开发工程师
  • HR 人事

如果您想在 AI 时代实现百倍的个人提升,欢迎加入我们
联系方式:join@biteagle.xyz

【Flutter×鸿蒙】通关手册(二):FVM 不认鸿蒙 SDK?4步手动塞进去

系列导航:

我第一次让 FVM 管理鸿蒙版 Flutter SDK 时,前后踩了 4 个坑,花了大半天才跑通。事后复盘发现,每个坑都不难,只是没人提前告诉我"为什么要这样做"。这篇把整个过程拆成 5 关,每关讲清「为什么」和「怎么做」,争取让你 20 分钟一次通关。

前置条件:请先完成第一篇的全部内容——DevEco Studio 已安装,ohpm、node、hvigorw 在终端里都能正常调用。


🗺️ 通关路线图

关卡 任务 预计耗时
第1关 安装 FVM 2 min
第2关 克隆鸿蒙版 SDK 5 min(取决于网速)
第3关 修复版本"身份证" 3 min
第4关 指定鸿蒙 SDK 路径 1 min
第5关 全绿验证 2 min

🎯 第 1 关:安装 FVM

目标

让终端认识 fvm 命令。

为什么需要 FVM

一句话——让不同项目用不同版本的 Flutter,互不干扰。比如项目 A 用官方 3.24 跑 Android/iOS,项目 B 用鸿蒙版 3.35.8。FVM 就是 Flutter 的"版本档案柜",每个抽屉放一个版本。

📋 操作

# macOS(在终端里执行,这是用 Homebrew 包管理器安装 FVM)
brew install fvm
# Windows(在 cmd 或 PowerShell 中执行,这是用 Chocolatey 包管理器安装 FVM)
choco install fvm

安装完后,配置 FVM 缓存路径。把以下两行写入 ~/.zshrc(上一篇介绍过,这是 Mac 终端的配置文件):

# FVM 存放所有 Flutter 版本的目录
export FVM_CACHE_PATH=$HOME/fvm
# 让 FVM 的默认版本可以直接用 flutter 命令调用
export PATH="$HOME/fvm/default/bin:$PATH"

保存后执行下面这条命令,让刚才的配置立即生效(否则要关掉终端重新打开):

source ~/.zshrc

✅ 验证

# 查看 FVM 版本号,确认安装成功
fvm --version

看到版本号(如 3.1.4)就过关了。

⚠️ 如果报 command not found:Mac 用户确认已安装 Homebrew(执行 brew --version 看有没有输出);Windows 用户确认已安装 Chocolatey(执行 choco --version)。如果包管理器本身都没装,请先去官网安装。


🎯 第 2 关:克隆鸿蒙版 SDK

目标

把华为的鸿蒙版 Flutter 放进 FVM 管辖。

为什么不能直接 fvm install

正常装 Flutter 只需要 fvm install 3.24.0,FVM 会自动去 GitHub 下载。但鸿蒙版是华为团队在 AtomGit(国内代码托管平台)上单独维护的,FVM 的世界里它根本不存在。所以我们要"手动入库"——自己下载代码,放到 FVM 的档案柜里,假装它一直在那。

⚠️ 本关最大的坑:分支名和版本号是两回事!

仓库的分支叫 oh-3.35.7-dev,看到 3.35.7 你会以为版本就是 3.35.7。但实际上代码里的版本已经迭代到了 3.35.8-ohos-0.0.2

类比:Git 分支叫 feature/login-v1,但代码早就改到 v3 了。分支名是创建时起的,不会跟着版本号自动更新。

千万别拿分支名当版本号用,团队必须统一用 3.35.8-ohos-0.0.2 这个真实版本号。

📋 操作

# --depth 1 只取最新代码,省空间(省去几个 GB 的历史记录)
git clone -b oh-3.35.7-dev --depth 1 
https://atomgit.com/openharmony-tpc/flutter_flutter.git 
~/fvm/versions/3.35.8-ohos-0.0.2

注意看:clone 命令里分支名是 oh-3.35.7-dev,但目标文件夹名是 3.35.8-ohos-0.0.2——这不是写错了,上面已经解释了为什么不一样。

💡 怎么确认真实版本号? clone 完后执行 ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter --version 看输出。如果加入已有团队,直接看项目的 .fvmrc 文件(命令:cat .fvmrc)。

✅ 验证

# 确认文件下载成功(ls = 列出目录内容)
ls ~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter

文件存在就过关。

⚠️ 如果报 No such file or directory:回去检查 clone 命令是否执行成功。常见原因是网络超时(AtomGit 在国内,通常不需要梯子,但公司内网可能有限制)。重新执行 clone 前,先删掉残留目录:rm -rf ~/fvm/versions/3.35.8-ohos-0.0.2,再重试。


🎯 第 3 关:修复版本"身份证"

目标

让 FVM 正确识别这个 SDK 的版本号。

为什么要做这步

clone 下来的 SDK 有两张"证件":

  1. version 文件——相当于身份证,一行文本写着版本号
  2. bin/cache/flutter.version.json——相当于内部档案,JSON 格式的详细版本信息

问题是,这两张证件上都写着 0.0.0-unknown(因为鸿蒙团队是从开发分支构建的,没有打标准标签)。但我们的文件夹名叫 3.35.8-ohos-0.0.2。FVM 一查——名字对不上,直接翻脸。

⚠️ 不做这步的后果:FVM 会弹出 "Version mismatch" 并试图删掉你的 SDK 重装。如果看到了这个弹窗,千万不要选任何选项,按 Ctrl+C(Mac 也是 Ctrl 不是 Cmd)退出,回来做这步。

📋 操作

macOS / Linux:

cd ~/fvm/versions/3.35.8-ohos-0.0.2

# 第一步:改"身份证"
echo -n "3.35.8-ohos-0.0.2" > version

# 第二步:初始化 Flutter 引擎(首次运行会下载 Dart SDK,需要等 1-3 分钟)
bin/flutter --version

# 第三步:改"内部档案"(把所有 0.0.0-unknown 替换成正确的版本号)
sed -i '' 's/0.0.0-unknown/3.35.8-ohos-0.0.2/g' bin/cache/flutter.version.json

Windows PowerShell:

# 进入 SDK 所在目录
cd $env:USERPROFILE\fvm\versions\3.35.8-ohos-0.0.2

# 第一步:改"身份证"
"3.35.8-ohos-0.0.2" | Set-Content version -NoNewline

# 第二步:初始化引擎
bin\flutter --version

# 第三步:改"内部档案"(PowerShell 的查找替换写法)
(Get-Content bin\cache\flutter.version.json) -replace '0.0.0-unknown', '3.35.8-ohos-0.0.2' | Set-Content bin\cache\flutter.version.json

⚠️ 三步的顺序不能乱——第二步会生成 flutter.version.json 文件,第三步才有东西可改。如果你先执行了第三步,会报文件不存在。

✅ 验证

# 回到任意目录都可以执行(fvm list = 列出 FVM 管理的所有 Flutter 版本)
fvm list

看到 Version 列显示 3.35.8-ohos-0.0.2(不是空白、不是 Need setup、不是 0.0.0-unknown),这关就过了。

02_fvm_list.png ⚠️ 如果还是显示异常,逐一排查两张"证件":

# 检查"身份证"内容
cat ~/fvm/versions/3.35.8-ohos-0.0.2/version
# 应该输出:3.35.8-ohos-0.0.2(没有多余空行)

# 检查"内部档案"有没有残留的 0.0.0-unknown
cat ~/fvm/versions/3.35.8-ohos-0.0.2/bin/cache/flutter.version.json
# 里面所有 version 字段应该都是 3.35.8-ohos-0.0.2

如果 version 文件内容不对,重新执行第一步;如果 JSON 里还有 0.0.0-unknown,重新执行第三步。


🎯 第 4 关:指定鸿蒙 SDK 路径

目标

让 Flutter 知道鸿蒙的 SDK(OpenHarmony SDK)装在哪。

为什么不用环境变量

我试过 HOS_SDK_HOMEOHOS_SDK_HOME 等环境变量,时灵时不灵。原因是不同方式打开的终端(VS Code 内置终端 vs 系统终端 vs CI 环境)加载配置文件的顺序不一样,变量可能没被读到。flutter config 会把路径写入 Flutter 自己的配置文件,不管从哪里启动都能读到,最稳。

📋 操作

# 把鸿蒙 SDK 的位置"写死"到 Flutter 的配置里(一次性操作)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter config \
--ohos-sdk="/Applications/DevEco-Studio.app/Contents/sdk"

⚠️ Windows 用户路径改为:--ohos-sdk="C:\Program Files\Huawei\DevEco Studio\sdk"

请根据 DevEco Studio 实际安装路径调整。不确定装在哪?打开 DevEco Studio → Settings → SDK 页面可以看到路径。

终端输出 Setting "ohos-sdk" value to "..." 就成功了。

✅ 验证

不急,下一关一起验收。


🎯 第 5 关:全绿验证

目标

flutter doctor 中 HarmonyOS toolchain 一栏显示绿色对勾。

📋 操作

# 运行 Flutter 的环境诊断工具(-v 表示显示详细信息)
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor -v

✅ 验证

关注输出中的 HarmonyOS 那一栏:

[✓] HarmonyOS toolchain - develop for HarmonyOS devices
    • OpenHarmony Sdk at /Applications/DevEco-Studio.app/Contents/sdk,
      available api versions has [22:default]
    • Ohpm version 6.0.1
    • Node version v18.20.1
    • Hvigorw binary at .../hvigor/bin/hvigorw

看到 [✓] 加上 4 个子项都有值 = 通关!

02_flutter_doctor.png 💡 你可能会看到 Flutter 那栏有几个 ! 警告(channel 不标准、upstream 不是官方地址)。这是鸿蒙版的正常现象,完全不影响开发和打包,放心忽略。

⚠️ 如果 HarmonyOS 那栏还是红叉,按优先级排查:

  1. SDK not found → 回第 4 关检查 config 路径是否正确
  2. ohpm/hvigorw missing → 回第一篇检查环境变量
  3. Version mismatch → 回第 3 关检查两张"证件"

🔧 附加关:FVM 的"碎碎念"

通关后你会发现,每次用 fvm flutter xxx 时 FVM 都会弹 "not a valid version" 的警告让你确认。这不是报错,只是 FVM 在说:"这个版本号我在官方列表里查不到,你确定要用吗?"

三种应对方式:

  1. 手动按 y——每次弹出输入 y 回车
  2. 自动确认——命令前加 yes |
yes | fvm flutter doctor
  1. 绕过 FVM——直接用绝对路径调用,完全不弹警告:
~/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter doctor

我推荐第三种,路径虽长但最省心。可以设个快捷方式(alias)缩短它:

# 把这行加到 ~/.zshrc 里(alias = 给一条长命令起个短名字)
alias hflutter="$HOME/fvm/versions/3.35.8-ohos-0.0.2/bin/flutter"

保存后 source ~/.zshrc,之后直接 hflutter runhflutter doctor 就行。


🏆 通关总结

项目 状态
FVM ✅ 已安装
鸿蒙版 Flutter SDK ✅ ~/fvm/versions/3.35.8-ohos-0.0.2
version 文件 ✅ 已修复
flutter.version.json ✅ 已修复
flutter config --ohos-sdk ✅ 已配置
flutter doctor HarmonyOS ✅ 全绿

回顾核心逻辑:FVM 只管官方 Flutter,鸿蒙版要我们手动塞进去(第 2 关);塞进去后"证件"信息对不上,需要手动修正(第 3 关);最后告诉 Flutter 鸿蒙 SDK 在哪(第 4 关)。理解了这条线,以后鸿蒙版 SDK 升级换版本号,你也能照样搞定。

如果中途卡住,大概率是版本号写错了——检查文件夹名、version 文件内容、flutter.version.json 里的版本号,三者必须完全一致


下一篇预告:SDK 准备好了,接下来要把你的老 Flutter 项目跑到鸿蒙上——听起来就是敲几行命令的事?没那么简单。→ 【Flutter×鸿蒙】通关手册(三):debug 包也要签名,这点和 Android 差远了

iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

iOS PDF 阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

目标读者:有 PDFKit 使用经验的 iOS 开发者。
本文重点:几何分块算法、段落识别逻辑、跨栏语义合并三个核心难点。


背景:段评是什么,难在哪里

杂志类 App 有一个常见需求——用户长按某段正文,划出一段话,然后对这段话写评论。这个交互在微信读书、Kindle 里都很成熟,但它们针对的是结构化的电子书格式(ePub、MOBI),正文结构天然清晰。

PDF 没有这种结构。一份杂志 PDF 在底层只有一堆带坐标的"文字片段"(glyph run),没有段落、没有栏、没有语义层次。PDFKit 提供的 PDFSelectionselectionsByLine 能给你"行",但它不知道哪些行属于同一个段落,也不知道这一页有几栏。

因此,段评的核心问题是:给定用户选中的一行文字,如何还原它所在的完整自然段?

这个问题比想象中复杂,主要难点有三个:

  1. 几何噪声:PDF 的行坐标存在浮点误差,标题、页码、图注混杂其中,必须过滤。
  2. 多栏布局:杂志常见双栏、三栏排版,阅读顺序不是简单地从上到下。
  3. 跨栏断段:一个自然段可能从左栏末尾延续到右栏开头,PDFKit 对此一无所知。

XLPDFParagraphEngine 的设计思路,就是用纯几何方法逐层解决这三个问题。


整体架构:四层流水线

整个引擎的入口是:

/// 自定义PDFView里面获取menu
+ (NSString *)paragraphTextFromSelection:(PDFSelection *)selection
                                document:(PDFDocument *)document;

它的内部执行路径是一条清晰的四层流水线:

PDFSelection
    │
    ▼
① buildLinesFromSelection     — 行提取 + 噪声过滤
    │
    ▼
② buildBlocksFromLinesIteratively  — 几何连通分块
    │
    ▼
③ readingOrderForBlock         — 列识别 + 段落切分
    │
    ▼
④ mergeSemanticContinuousBlocks — 跨栏语义合并
    │
    ▼
paragraphTextFromLines         — 拼接文本输出

每一层解决一个独立问题,下面逐层展开。


第一层:行提取与噪声过滤

PDFKit 的 selectionsByLine 会把选区内的每一行作为独立的 PDFSelection 返回,这是我们的原始数据源。但原始数据有大量噪声需要清理。

/// 获取所有lines
+ (NSArray<XLPDFLine *> *)buildLinesFromPage:(PDFPage *)page document:(PDFDocument *)document {
    CGRect pageRect = [page boundsForBox:kPDFDisplayBoxMediaBox];
    PDFSelection *pageSelection = [page selectionForRect:pageRect];
    return [self buildLinesFromBaseSelection:pageSelection document:document];
}

/// 获取选中的lines
+ (NSArray<XLPDFLine *> *)buildLinesFromSelection:(PDFSelection *)selection document:(PDFDocument *)document {
    return [self buildLinesFromBaseSelection:selection document:document];
}

+ (NSArray<XLPDFLine *> *)buildLinesFromBaseSelection:(PDFSelection *)baseSelection document:(PDFDocument *)document {

    NSMutableArray<XLPDFLine *> *lines = [NSMutableArray array];

    NSArray<PDFPage *> *pages = baseSelection.pages;

    for (PDFSelection sel in baseSelection.selectionsByLine) {

        NSString *text = sel.string;
        if (text.length == 0) continue;

        // 找到当前行所属 page
        PDFPage *linePage = nil;
        CGRect rect = CGRectZero;

        for (PDFPage *page in pages) {
            rect = [sel boundsForPage:page];
            if (!CGRectIsEmpty(rect)) {
                linePage = page;
                break;
            }
        }

        if (!linePage) continue;

        if (CGRectIsEmpty(rect)) continue;

        // ========= 公共过滤逻辑 =========

        CGFloat width = CGRectGetWidth(rect);

        CGFloat height = CGRectGetHeight(rect);

        // 过滤竖排
        if (text.length > 1 && height > width * 2.0) continue;
        // 过滤异常高度
        CGRect pageRect = [linePage boundsForBox:kPDFDisplayBoxMediaBox];
        CGFloat pageHeight = CGRectGetHeight(pageRect);
        if (height > pageHeight * 0.05) continue;

  
        NSString *trimText = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

        if (trimText.length == 0) continue;

        // 过滤纯数字编号(01、02、1、2、一、二 等页码/序号)
        NSString *numberPattern = @"^\\s*[零一二三四五六七八九十百\\d]+[、.]?\\s*$";
        NSPredicate *numberPredicate = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", numberPattern];
        if ([numberPredicate evaluateWithObject:trimText]) continue;

        // ========= 构建模型 =========
        XLPDFLine *line = [XLPDFLine new];
        line.selection = sel;
        line.page = linePage;
        line.rect = rect;
        line.text = trimText;

        NSInteger pageIndex = [document indexForPage:linePage];
        line.pageIndex = pageIndex == NSNotFound ? -1 : pageIndex;
        line.font = [**self** dominantFontFromSelection:sel]; // 提取该行的主体字体
        [lines addObject:line];
    }

    return lines;
}

这里的过滤策略针对杂志 PDF 的典型噪声:

  • 竖排文字:部分杂志有竖向装饰文字,height > width * 2.0 可以有效识别并排除。
  • 异常高度:正文行高通常不超过页面高度的 5%,超出这个比例的往往是大图、横幅或装饰元素。
  • 页码与序号:正则 ^[零一二三四五六七八九十百\d]+[、.]?$ 可以匹配中英文页码和列表编号,避免它们干扰后续分段判断。

过滤完成后,每一行被封装成 XLPDFLine 模型,携带 textrectpagepageIndex 等属性,供后续层使用。


第二层:几何连通分块

拿到干净的行列表后,下一个问题是:这一页上有几个独立的文字区域?

杂志版式复杂,一页上可能同时存在主正文区、侧边栏、图注、引言框等多个互不相连的文字区域。如果不先区分这些区域,段落识别就会跨区混淆。

引擎使用了一个经典的**几何连通图(Connected Components)**算法:

将每一行的 rect 向外膨胀(inflate)半个行高
如果两行膨胀后的 rect 有交叉 → 认为它们"连通"
对所有行做图的连通分量遍历 → 每个连通分量就是一个 Block

膨胀量选择行高的 50%,是一个关键的经验值设定:

+ (BOOL)linesConnected:(XLPDFLine *)a other:(XLPDFLine *)b {
    CGFloat insetA = a.rect.size.height * 0.5;
    CGFloat insetB = b.rect.size.height * 0.5;
    CGRect ra = CGRectInset(a.rect, -insetA, -insetA);
    CGRect rb = CGRectInset(b.rect, -insetB, -insetB);
    return CGRectIntersectsRect(ra, rb);
}

为什么不用固定像素值?因为杂志里的字号差异很大——正文可能是 10pt,大标题可能是 36pt。固定像素膨胀会导致小字号的脚注与正文粘连,或者大标题与相邻栏文字误连。用行高比例膨胀,让每行的"感知范围"与自身字号成正比,鲁棒性更好。

连通分量的遍历使用迭代 DFS:

+ (NSArray *)buildBlocksFromLinesIteratively:(NSArray *)lines {
    NSMutableArray *remaining = [lines mutableCopy];
    NSMutableArray *resultBlocks = [NSMutableArray array];

    while (remaining.count > 0) {
        NSArray *block = [self buildSingleBlockFromLines:remaining];
        [resultBlocks addObject:block];
        [remaining removeObjectsInArray:block];
    }
    return resultBlocks;
}

每次从剩余行中任取一行作为起点,BFS/DFS 扩展出整个连通分量,然后从剩余集合中移除,直到所有行都被分配完毕。


第三层:阅读顺序还原与段落切分

每个 Block 内部可能还有多列(例如一个双栏正文区,在几何上是一个连通分量)。这一层先识别列,再在每列内部切分段落。

3.1 列识别:X 轴区间合并

+ (NSArray<XLPDFLine *> *)readingOrderForBlock:(NSArray<XLPDFLine *> *)block {
 
    NSArray *ranges = [self xRangesFromBlock:block]; // 投影到X轴:x+w
    NSArray *columnRanges = [self mergeXRanges:ranges]; // 算出有多少列
    NSArray *columns = [self splitBlock:block intoColumns:columnRanges]; // 划入列里
    NSMutableArray *result = [NSMutableArray array];
    NSInteger paragraphIndex = 0;

    // 列里面直接按照Y排序即可
    for (NSArray<XLPDFLine *> *column in columns) {
        // 分段
        NSArray *ordered = [self readingOrderForColumnByIndentOnly:column paragraphStartIndex:&paragraphIndex];
        [result addObjectsFromArray:ordered];
    }

    return result;
}

具体做法:把 Block 内每一行的 [minX, maxX] 区间收集起来,按 minX 排序后做区间合并(sweep line),相互重叠或相接的区间合并为一个列边界。最终得到若干互不重叠的列区间,每一行按其中心 X 坐标归入对应的列。

这个方法的优势是完全不依赖任何先验知识,无论一页有几栏、栏宽是否均等,都能正确识别。

3.2 段落切分:三条几何规则

列识别完成后,列内的行按 Y 坐标从上到下排好序。接下来要判断相邻两行是否属于同一段落,引擎使用了三条互补的规则:

+ (NSArray<XLPDFLine *> *)readingOrderForColumnByIndentOnly:(NSArray<XLPDFLine *> *)column paragraphStartIndex:(NSInteger *)paragraphIndex {

    NSArray<XLPDFLine *> *sorted = [self sortLinesByYDescending:column];
    CGFloat baseMinX = [self baseMinXForColumn:sorted];
    CGFloat baseMaxX = [self baseMaxXForColumn:sorted];
    CGFloat columnWidth = baseMaxX - baseMinX;

    [sorted enumerateObjectsUsingBlock:^(XLPDFLine * _Nonnull line, NSUInteger idx, BOOL * _Nonnull stop) {

        if (idx > 0) {

            XLPDFLine *prevLine = sorted[idx - 1];

            // 条件1:当前行首行缩进
            CGFloat indent = CGRectGetMinX(line.rect) - baseMinX;
            BOOL currentLineIsHead = indent > 10.0;

            // 条件2:上一行是尾行(右侧留白超过列宽 10%)
            CGFloat prevLineTrailingGap = baseMaxX - CGRectGetMaxX(prevLine.rect);
            BOOL prevLineIsTail = prevLineTrailingGap > columnWidth * 0.1;

            // 条件3:行间距超过行高阈值
            CGFloat gap = CGRectGetMinY(prevLine.rect) - CGRectGetMaxY(line.rect);
            CGFloat lineHeight = CGRectGetHeight(line.rect);
            BOOL hasLargeGap = gap > lineHeight * 0.8;

            if (currentLineIsHead || prevLineIsTail || hasLargeGap) {
                (*paragraphIndex)++;
            }
        }

        line.paragraphIndex = *paragraphIndex;
    }];

    return sorted;

}

规则1(首行缩进) 是中文排版最常见的段落标记,10pt 的阈值约等于一个汉字的宽度。

规则2(末行留白) 是规则1的补充:段落末行通常不会写满整行。15% 的阈值过滤掉因行尾标点导致的微小留白,同时能识别出明显的短尾行。注意这里使用的 baseMaxX 是列内所有行 maxX 的中位数,而不是最大值,这样对行尾有标点突出的情况更鲁棒。

规则3(行间距) 用于处理无缩进、无留白但通过空行分隔的段落风格(英文排版常见)。

三条规则取 OR,任意一条满足就认为新段落开始,paragraphIndex 递增。


第四层:跨栏语义合并(最难的部分)

前三层解决了单个 Block 内部的问题,但杂志双栏排版有一个特殊情况:

一个自然段从左栏末尾开始,写满后在右栏顶部继续。

这两部分在几何上属于不同的 Block(左栏和右栏不连通),但语义上是同一个段落。这就是跨栏语义合并问题。

4.1 判断标准:段尾 + 段首

合并的充要条件是:左栏某 Block 的最后一行是段尾行(Tail) ,同时右栏某 Block 的第一行是段首行(Head) ,且两者字号一致。

段尾判断:末行写满(右侧留白 < 列宽10%)且没有句末标点(。!?;…等):

+ (BOOL)isTailBlock:(NSArray *)block {
    XLPDFLine *lastLine = block.lastObject;
    CGFloat trailingGap = columnMaxX - CGRectGetMaxX(lastLine.rect);
    BOOL noTrailingGap  = trailingGap <= columnWidth * 0.1;
    BOOL noEndingSymbol = ![self lineEndsWithParagraphSymbol:lastLine];
    return noTrailingGap && noEndingSymbol;
}

段尾判断:判断一行是否以句末标点结尾(处理引号包裹和英文小数)

+ (BOOL)lineEndsWithParagraphSymbol:(XLPDFLine *)line {

    NSCharacterSet *endingSymbols = [NSCharacterSet characterSetWithCharactersInString:@"。!?;.!?;…"];
    NSCharacterSet *wrapperSet =
    [NSCharacterSet characterSetWithCharactersInString:@"”’\"'))】》〉 "];
    NSString *trimmed = [line.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

    if (trimmed.length == 0) return NO;

    NSInteger index = (NSInteger)trimmed.length - 1;

    // 跳过包裹字符
    while (index >= 0 &&
           [wrapperSet characterIsMember:[trimmed characterAtIndex:index]]) {
        index--;
    }

    if (index < 0) return NO;

    unichar c = [trimmed characterAtIndex:index];

    // 英文小数点不算句末(3.14)
    if (c == '.' && index > 0 &&
        [[NSCharacterSet decimalDigitCharacterSet]
         characterIsMember:[trimmed characterAtIndex:index - 1]]) {
        return NO;
    }

  
    return [endingSymbols characterIsMember:c];
}

段首判断:首行无明显缩进(缩进量 ≤ 10pt),说明这一行是接续上一栏的内容,而不是新段落的起点:

+ (BOOL)isHeadBlock:(NSArray *)block {
    XLPDFLine *firstLine = block.firstObject;
    CGFloat indent = CGRectGetMinX(firstLine.rect) - columnMinX;
    return indent <= 10.0;
}

解决多栏PDF的跨列语义连续问题

/// blocks先要进行font过滤,保留主体字体的block进行跨列连续合并
/// 先对所有blocks里面的pageLines进行分列(大概2、3列)
/// 将所有block归列,每一列N个block
/// 每一列的从后往前找可能是段尾的block
/// 从第二列内部blocks遍历从前往后判断是否是段首
/// 合并段位和段首,处理blockIndex、paragraphIndex
+ (NSArray<NSArray<XLPDFLine *> *> *)mergeSemanticContinuousBlocks:(NSArray<NSArray<XLPDFLine *> *> *)blocks {

    if (blocks.count < 2) return blocks;
    // 直接从 blocks 展平生成 pageLines
    NSMutableArray<XLPDFLine *> *pageLines = [NSMutableArray array];
    for (NSArray<XLPDFLine *> *block in blocks) {
        [pageLines addObjectsFromArray:block];
    }
    NSArray<NSValue *> *columnRanges = [self detectColumnRanges:pageLines];

    if (columnRanges.count < 2) return blocks;

    // 按列分组(保持列内Y排序)
    NSMutableArray<NSMutableArray *> *columns = [NSMutableArray array];
    for (NSInteger i = 0; i < columnRanges.count; i++) {
        [columns addObject:[NSMutableArray array]];
    }

    for (NSArray<XLPDFLine *> *block in blocks) {

        NSInteger colIdx = [self columnIndexForBlock:block inRanges:columnRanges];
        if (colIdx >= 0) [columns[colIdx] addObject:[block mutableCopy]];
    }

    for (NSMutableArray *column in columns) {
        [column sortUsingComparator:^NSComparisonResult(NSArray *a, NSArray *b) {

            CGFloat maxYA = 0, maxYB = 0;
            for (XLPDFLine *l in a) maxYA = MAX(maxYA, CGRectGetMaxY(l.rect));
            for (XLPDFLine *l in b) maxYB = MAX(maxYB, CGRectGetMaxY(l.rect));
            return maxYA > maxYB ? NSOrderedAscending : NSOrderedDescending;
        }];
    }

    // 跨列合并
    for (NSInteger col = 0; col < (NSInteger)columns.count - 1; col++) {

        NSMutableArray *currentCol = columns[col];
        NSMutableArray *nextCol    = columns[col + 1];
        if (currentCol.count == 0 || nextCol.count == 0) continue;

        CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
        NSMutableArray<XLPDFLine *> *tailBlock = nil;

        for (NSInteger blockIdx = (NSInteger)currentCol.count - 1; blockIdx >= 0; blockIdx--) {
            // 特别注意要倒叙,然后过滤飞主体文本block
            NSMutableArray<XLPDFLine *> *block = currentCol[blockIdx];
            if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
                [self isTailBlock:block]) {
                tailBlock = block;
                break;
            }
        }

        if (!tailBlock) continue;

        NSInteger searchCol = col + 1;
        while (searchCol < (NSInteger)columns.count) {

            NSMutableArray *searchNextCol = columns[searchCol];
            if (searchNextCol.count == 0) {
                searchCol++;
                continue;
            }

            NSArray<XLPDFLine *> *headBlock = nil;
            NSInteger headIdx = -1;
            for (NSInteger i = 0; i < (NSInteger)searchNextCol.count; i++) {
                NSArray<XLPDFLine *> *block = searchNextCol[i];
                if ([self isHeadBlock:block] &&
                    [self blockContainsParagraphEndingSymbol:block] &&
                    [self lineHeightMatches:tailBlock with:block]) {
                    headBlock = block;
                    headIdx   = i;
                    break;
                }
            }

            if (!headBlock) break;

            [self mergeBlock:headBlock intoBlock:tailBlock];
            [searchNextCol removeObjectAtIndex:headIdx];

            if (![self isTailBlock:tailBlock]) break;

            searchCol++;
        }
    }

    // 重整blockIndex + 构建结果数组
    NSMutableArray<NSArray<XLPDFLine *> *> *result = [NSMutableArray array];
    NSInteger idx = 0;
    for (NSMutableArray *column in columns) {
        for (NSArray<XLPDFLine *> *block in column) {
            for (XLPDFLine *line in block) line.blockIndex = idx;
            idx++;
            if ([self blockContainsParagraphEndingSymbol:block] || block.count > 6) {
                [result addObject:block];
            }
        }
    }

    return [result copy];
}

4.2 列检测:中心 X 聚类

跨栏合并需要先知道页面有几列,以及每列的 X 边界。引擎用了一个轻量的聚类方法:

// 收集所有行的中心X,排序后按间隙聚类
// 相邻 centerX 差值超过页宽的 10% → 认为是列间距
CGFloat gapThreshold = CGRectGetWidth(pageRect) * 0.10;

通过这个间隙阈值,可以把所有行的中心 X 分成若干簇,每簇的 [minX, maxX] 加上半行高的 padding 就是列的 X 范围。这比依赖页面宽度平均分割更准确,因为杂志栏宽不一定均等。

4.3 合并过程:倒序扫描 + 链式追踪

for (NSInteger col = 0; col < columns.count - 1; col++) {

    // 在当前列,倒序找最后一个"主体文字"的段尾 Block
    // (倒序是为了跳过可能存在的图注、小标题等非主体 Block)
    NSMutableArray *tailBlock = nil;
    CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
    for (NSInteger i = currentCol.count - 1; i >= 0; i--) {
        NSArray *block = currentCol[i];
        if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
            [self isTailBlock:block]) {
            tailBlock = block;
            break;
        }
    }

    if (!tailBlock) continue;

    // 在下一列,找第一个满足条件的段首 Block
    // 字号一致 + 无缩进 + 含句末标点(保证是正文段落,不是纯标题)
    NSArray *headBlock = nil;
    for (NSArray *block in nextCol) {
        if ([self isHeadBlock:block] &&
            [self blockContainsParagraphEndingSymbol:block] &&
            [self lineHeightMatches:tailBlock with:block]) {
            headBlock = block;
            break;
        }
    }

    // 合并:将 headBlock 的所有行追加进 tailBlock,修正 blockIndex 和 paragraphIndex
    [self mergeBlock:headBlock intoBlock:tailBlock];
    [nextCol removeObject:headBlock];

    // 如果合并后 tailBlock 仍是段尾 → 继续追踪到下下列(三栏情况)
    if ([self isTailBlock:tailBlock]) { /* 继续向右搜索 */ }
}

合并时对 paragraphIndex 的修正是一个容易出错的地方。next Block 的 paragraphIndex 从 0 开始编号,合并时需要续接 prev Block 的最大 paragraphIndex,同时修正 blockIndex 保持一致:

for (XLPDFLine *line in next) {
    line.blockIndex     = prevBlockIndex;
    line.paragraphIndex = (line.paragraphIndex - nextBaseIndex) + maxParagraphIndex;
    [prev addObject:line];
}

段落 ID 的设计

完成以上步骤后,每一行都携带了 pageIndexblockIndexparagraphIndex 三个坐标。段落 ID 由此生成:

mgid_pageIndex_blockIndex_paragraphIndex

例如:mag001_3_2_1 表示杂志 mag001,第 3 页,第 2 个文字区域,第 1 个段落。

这个 ID 有两个关键用途:

写入评论时:通过 paragraphIDFromSelection:document:mgid: 生成 ID,与评论数据一起存储到服务端。

读取评论时:通过 paragraphTextFromParagraphID:document: 反向解析 ID,定位到页面 → Block → paragraphIndex,取出对应行集合,用于高亮展示或文字复原。

反向定位的路径:

// 1. 解析 ID,得到 pageIndex / blockIndex / paragraphIndex
// 2. 取出对应页面
PDFPage *page = [document pageAtIndex:pageIndex];
// 3. 对整页重新执行分块
NSArray *pageBlocks = [self pageLinesBlocksFromPage:page document:document];
// 4. 按 blockIndex 取出对应 Block
NSArray *block = pageBlocks[blockIndex];
// 5. 按 paragraphIndex 过滤出段落行
NSArray *paragraph = [self paragraphLinesForParagraphIndex:paragraphIndex inBlock:block];

评论气泡(PDFAnnotation)的锚点应该定位在段落最后一行的位置,这样气泡显示在段尾更自然,同时把段尾行的位置信息传给服务器,服务端也能精确还原气泡坐标。


几个值得关注的工程细节

同行判断的阈值:PDF 中同一行的不同字符因字体 baseline 差异,midY 可能相差 1~3pt。引擎用行高的 50% 作为阈值,而不是固定的 1pt,避免同行字符被误判为不同行:

+ (BOOL)isSameLineByY:(CGRect)r1 rect:(CGRect)r2 {
    CGFloat threshold = MIN(CGRectGetHeight(r1), CGRectGetHeight(r2)) * 0.5;
    return fabs(CGRectGetMidY(r1) - CGRectGetMidY(r2)) < threshold;
}

列 maxX 用中位数baseMaxXForColumn: 返回的是所有行 maxX 的中位数,而不是最大值。这样可以过滤掉个别行尾有标点符号溢出导致的 maxX 偏大问题,让"末行留白"的判断更稳定。

主体行高过滤:在跨栏合并中,用 dominantLineHeightInColumn: 计算列内出现频率最高的行高(取整后做频次统计),作为主体正文的行高基准。倒序扫描段尾 Block 时,只考虑字号与主体行高接近的 Block,这样可以跳过可能夹在正文之间的小字号图注或大字号小标题。


局限性与未来方向

当前实现在以下场景有一定局限:

  • 竖排中文:过滤规则直接丢弃竖排行,不支持竖排杂志。
  • 不规则分栏:栏宽差异极大时(如 1:3 的图文混排),X 轴聚类可能误判列数。
  • 跨页段落:目前只处理单页内的跨栏,跨页的段落连续暂不支持。
  • 表格内文字:表格单元格中的文字可能因行高相近而被当作正文处理。

小结

XLPDFParagraphEngine 的核心设计思路可以归纳为:用几何信息替代语义信息,逐层收敛不确定性

层次 输入 输出 解决的问题
行提取 PDFSelection XLPDFLine 数组 去除噪声行
几何分块 XLPDFLine 数组 Block 数组 区分独立文字区域
列识别 + 分段 Block 带 paragraphIndex 的行 还原阅读顺序和段落边界
跨栏合并 Block 数组 合并后的 Block 数组 修复跨栏断段

整个流水线不依赖任何 PDF 元数据,仅凭坐标和文本表面特征运作,因此对不同来源、不同排版风格的杂志 PDF 均有较好的适应性。

DEMO地址

iOS 知识点 - IAP 是怎样的?

一、IAP 基本概念

  • 定义: In-App Purchase 是苹果提供的支付机制,允许用户在 App 内购买虚拟商品或订阅服务,所有数字内容和服务的交易必须通过 IAP 完成(苹果会抽成 15%~30%),否则会拒审。

  • 类型:

类型 描述 使用场景 特点
Consumable(消耗型) 用完即消失,可反复购买 金币、体力、道具 不可恢复,需自己记录消耗
Non-Consumable(非消耗型) 永久可用,购买一次即可 解锁关卡、去广告、付费功能 可恢复,支持跨设备恢复
Auto-Renewable Subscription(自动续订订阅) 周期性付费,自动续订 VIP 会员、内容订阅 苹果处理续订、退款、过期提醒
Non-Renewing Subscription(非续订订阅) 周期性付费,但需手动续订 课程、限时会员 不自动续订,需要自己管理过期

消耗型商品必须在验证成功后调 finishTransaction,否则 Apple 会认为你还没发货,下次启动继续提醒。

  • 四个核心名词:

    • 商品(Product):
      • 在 App Store Connect 后台配置的可购买项目。
      • 每个商品都有唯一的 productIdentifier,App 需要用这个 ID 去 Apple 查询商品的实时价格和货币信息,返回类型是 SPProduct(StoreKit 91) 或 Product(StoreKit 2)。
    • 订单(Order):
      • 自己服务器创建的记录。
      • 在调用 Apple 支付之前创建,拿到 order_id,用于后期的对账(钱对应哪个商品、给哪个用户、是购买还是赠送等等)。
      • Apple 不知道这个东西的存在,业务层概念。
    • 交易(Transaction):
      • Apple 侧产生的记录。
      • 在 Apple 的支付弹窗上确认付款后,Apple 会生成一笔交易。
      • 交易有多种状态:
      purchasing(支付中) → purchased(已支付) → finished(已完成)
                        → failed(失败)
                        → deferred(等待家长审批)
                        → restored(恢复购买)
      
    • 收据(Receipt):
      • Apple 侧产生的付款凭证,证明用户确实付了钱。
      • 需要拿该凭证去服务器校验,服务器确认为真后才能发货,有两种格式:
        • StoreKit 1: 一个 Base64 编码的二进制文件(ASN.1),存在 App 沙盒里(Bundle.main.appStoreReceiptURL)。
        • StoreKit 2: 一个 JWS(JSON Web Signature)字符串,带有 Apple 的数字签名,更安全,不存本地,通过 Transaction API 获取。
  • 流程概述:

用户点击购买
  │
  ├─ ① App 用 productIdentifier 向 Apple 查询商品信息
  │     └─ Apple 返回 Product(价格、货币、描述)
  │
  ├─ ② App 向自己服务器创建订单,拿到 order_id
  │
  ├─ ③ 调用购买 API,Apple 弹出支付确认框
  │     └─ 用户输入密码 / Face ID / Touch ID
  │
  ├─ ④ Apple 返回交易结果(Transaction)
  │     ├─ purchased → 继续验证
  │     ├─ failed → 提示用户
  │     └─ deferred → 等待家长审批
  │
  ├─ ⑤ 拿 receipt/transaction 发给自己服务器验证
  │     └─ 服务器向 Apple 验证真伪(或本地验签 JWS)
  │     └─ 服务器确认后发货(加金币/开会员/解锁功能)
  │
  └─ ⑥ 调用 finishTransaction,告诉 Apple "我已发货"

二、StoreKit 1 与 StoreKit 2

维度 StoreKit 1(已废弃,但仍可用) StoreKit 2(推荐)
语言 Objective-C / Swift 均可 Swift only
异步模式 Delegate 回调 async/await
最低版本 iOS 3+ iOS 15+
交易监听 SKPaymentTransactionObserver Transaction.updates(AsyncSequence)
恢复购买 手动调 restoreCompletedTransactions() 自动可用,Transaction.currentEntitlements
收据验证 /verifyReceipt(服务端,已废弃) JWS 本地验签 / App Store Server API
订阅状态 自己解析 receipt 推算 Product.SubscriptionInfo.status 直接获取
退款处理 收不到通知 Transaction.revocationDate 有值 = 已退款
家庭共享 不支持 内置支持

2.1 StoreKit 1 核心类

SKProductsRequest          → 请求商品信息
  └─ SKProductsRequestDelegate  → 回调商品列表
       └─ SKProduct            → 单个商品(价格、标题、描述)

SKPayment                  → 支付请求对象
SKPaymentQueue             → 交易队列(单例)
  └─ SKPaymentTransactionObserver  → 交易状态回调
       └─ SKPaymentTransaction     → 单笔交易(状态、receipt)

SKReceiptRefreshRequest    → 强制刷新本地收据
  • StoreKit 1 典型代码流程:
// 1. 查询商品
let request = SKProductsRequest(productIdentifiers: ["com.app.coin100"])
request.delegate = self
request.start()

// 2. 收到商品信息
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    let product = response.products.first!
    // 展示价格:product.localizedTitle, product.price
}

// 3. 发起购买
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)

// 4. 监听交易状态
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    for tx in transactions {
        switch tx.transactionState {
        case .purchased:
            // 验证收据 → 发货 → finish
            verifyAndDeliver(tx)
            queue.finishTransaction(tx)
        case .failed:
            queue.finishTransaction(tx)
        case .restored:
            queue.finishTransaction(tx)
        case .deferred, .purchasing:
        break
        }
    }
}

2.2 StoreKit 2 核心类

Product                         → 商品(静态方法 .products(for:) 查询)
Product.purchase()              → 发起购买,返回 PurchaseResult
Transaction                     → 交易(自带签名验证)
Transaction.updates             → AsyncSequence,监听新交易
Transaction.currentEntitlements → 当前有效权益(自动含恢复)
Transaction.finish()            → 确认已发货
Product.SubscriptionInfo        → 订阅状态(续订、过期、宽限期)
  • StoreKit 2 典型代码流程:
// 1. 查询商品
let products = try await Product.products(for: ["com.app.coin100"])
let product = products.first!

// 2. 发起购买
let result = try await product.purchase()

switch result {
case .success(let verification):
    // 3. 验证交易
    switch verification {
    case .verified(let transaction):
        // 发货
        await deliverContent(transaction)
        // 4. 告诉 Apple 已发货
        await transaction.finish()
    case .unverified(_, let error):
        // 签名验证失败,可能被篡改
        handleError(error)
    }
case .userCancelled:
    break
case .pending:
    // 等待家长审批 / 支付确认
    break
}

// 5. App 启动时监听未完成的交易
Task {
    for await result in Transaction.updates {
        if case .verified(let transaction) = result {
            await deliverContent(transaction)
            await transaction.finish()
        }
    }
}

三、购买流程

3.1 正常购买流程

    App                     Apple                   自己服务器
     │                        │                        │
     │──Product.products()──> │                        │
     │<──返回商品信息─────────  │                        │
     │                        │                        │
     │──创建订单────────────────────────────────────────>│
     │<──返回 order_id──────────────────────────────────│
     │                        │                        │
     │──product.purchase()──> │                        │
     │    (用户确认支付)        │                        │
     │<──Transaction───────── │                        │
     │                        │                        │
     │──发送 transaction/receipt 验证─────────────────> │
     │                        │          │──验证签名/调 Apple API──>│
     │                        │          │<──确认有效──────────────│
     │<──验证通过,已发货─────────────────────────────────│
     │                        │                        │
     │──transaction.finish()─>│                        │
     │                        │                        │

3.2 异常场景处理

场景 现象 处理方式
支付成功但 App 崩溃/网络断开 没调 finish,下次启动 Apple 会重新推送交易 App 启动时监听 Transaction.updates,重新验证并发货
用户取消支付 result == .userCancelled 不做任何处理
家庭共享/家长审批 result == .pending 提示用户等待,后续通过 Transaction.updates 接收结果
重复购买(消耗型) 正常,消耗型可重复买 每次都走完整验证流程
重复购买(非消耗型) Apple 提示"你已购买过" 不会重复扣费,返回原始交易
退款 Apple 后台处理 服务端收到 S2S 通知 REFUND,或客户端检查 transaction.revocationDate
掉单(钱扣了但没收到交易) 极少见,通常是网络问题 服务端定期用 App Store Server API 查询用户交易历史

3.3 收据验证

客户端验证 服务端验证(推荐)
安全性 低,可被越狱设备绕过 高,服务器可信环境
实现复杂度 简单 需要后端配合
适用场景 个人开发者、低价值商品 商业应用、涉及虚拟货币

四、服务端通知(Server-to-Server Notifications)

Apple 会主动推送事件到你配置的服务器 URL(在 App Store Connect 中设置)。

4.1 主要通知类型

通知类型 含义
SUBSCRIBED 用户首次订阅
DID_RENEW 自动续订成功
EXPIRED 订阅过期
DID_FAIL_TO_RENEW 续订失败(信用卡过期等)
GRACE_PERIOD_EXPIRED 宽限期结束
REFUND 用户退款
REVOKE 家庭共享撤销
CONSUMPTION_REQUEST Apple 要求你提供消耗信息(用于退款裁决)
ONE_TIME_CHARGE 一次性购买通知(2025 新增)

4.2 通知格式

{
  "signedPayload": "<JWS 字符串>"
}

解码 JWS 后得到:

{
  "notificationType": "DID_RENEW",
  "subtype": "AUTO_RENEW",
  "data": {
    "signedTransactionInfo": "<JWS>",
    "signedRenewalInfo": "<JWS>"
  }
}

五、App Store Connect 配置

商品要在 App Store Connect 对应 App 的 App 内购买项目中配置并审核。

  • 沙盒测试:
    • App Store Connect → 用户和访问 → 沙盒测试员 → 添加测试 Apple ID
    • 手机 App Store 登入沙盒账号,测试环境会使用沙盒账号模拟支付。

六、常见的架构策略

6.1 项目分层

View 层
  └─ 购买按钮、价格展示、订阅状态 UI

ViewModel / Manager 层
  └─ IAPManager(单例)
     ├─ 查询商品
     ├─ 发起购买
     ├─ 监听交易
     └─ 管理订阅状态

Service 层
  └─ IAPService(与服务器交互)
     ├─ 创建订单
     ├─ 验证收据
     └─ 查询购买记录

Apple 层
  └─ StoreKit 2 API

6.2 防掉单策略

                    正常流程                         异常恢复
                  ┌──────────┐                   ┌──────────────┐
用户购买  ───────> │ 服务器验证 │──> 发货            │ App 启动      │
                  │ + finish │                   │ Transaction   │
                  └──────────┘                   │ .updates 推送 │
                                                 │ 未 finish 的  │
                                                 │ 交易          │──> 重新验证发货
                                                 └──────────────┘

服务端兜底:
  - 定期调 App Store Server API 查用户交易历史
  - 对比自己数据库,找出 Apple 有但自己没发货的交易
  - 补发

七、一些 2025 年新动向

  • StoreKit 1 已被标记为 Deprecated(WWDC 2024),虽然仍能用,但新项目应该用 StoreKit 2
  • /verifyReceipt 接口已废弃,改用 App Store Server API
  • S2S Notifications V1 已废弃,改用 V2
  • iOS 18.4+ 新增:
    • appTransactionID:每个 Apple 账户唯一标识
    • Offer Codes 扩展到消耗型/非消耗型商品(之前只支持订阅)
    • Advanced Commerce API:支持复杂的订阅附加组件
  • Xcode 16.2+ 必须升级,否则 iOS 18.2 上可能出现购买失败

UniApp开发应用多平台上架全流程:H5小程序iOS和Android

UniApp 开发的应用上架流程因目标平台(如H5、小程序、iOS、Android)而异。以下是 UniApp 应用上架的详细流程和注意事项。

1.H5 上架

H5 应用的上架主要是将应用部署到服务器,并通过域名访问。

1.1打包 H5 应用

1.2部署到服务器

  • 将打包后的文件上传到服务器(如Nginx、Apache)。
  • 配置服务器,确保正确路由和资源加载。

1.3配置域名与 HTTPS

  • 绑定域名,确保用户可以通过域名访问应用。
  • 配置 HTTPS,确保数据传输安全。

1.4测试与发布

  • 在浏览器中访问应用,确保功能正常。
  • 将应用链接分享给用户。

2.小程序上架

以微信小程序为例,其他小程序(如支付宝、百度)流程类似。

2.1打包小程序

2.2上传到微信开发者工具

  • 打开微信开发者工具,选择“导入项目”。
  • 选择打包后的小程序目录,填写 AppID 和项目名称。
  • 点击“确定”导入项目。

2.3调试与测试

  • 在微信开发者工具中调试应用,确保功能正常。
  • 使用真机预览功能,在手机上测试应用。

2.4提交审核

  • 在微信开发者工具中点击“上传”。
  • 填写版本号和项目备注,点击“上传”。
  • 登录微信公众平台,提交审核。

2.5发布

  • 审核通过后,在微信公众平台点击“发布”。
  • 用户可通过微信搜索或扫码使用小程序。

3.iOS 上架

iOS 应用的上架需要通过 App Store 审核。

3.1打包 iOS 应用

  • 使用 HBuilderX 的云打包功能:

    • 打开 HBuilderX,选择“发行” -> “原生App-云打包”。
    • 选择 iOS 平台,配置证书和描述文件。
    • 点击“打包”,生成 .ipa 文件。

对于证书和描述文件的管理,开发者可以使用AppUploader工具直接创建和管理iOS开发者或发布证书,无需钥匙串助手,支持多电脑协同使用,简化证书申请流程。

3.2配置 App Store Connect

  • 登录 App Store Connect。
  • 创建新应用,填写应用名称、描述、截图等信息。
  • 上传应用图标和预览视频。

AppUploader还支持批量上传应用截图和描述信息到App Store Connect,提高效率。

3.3上传应用

  • 使用 Xcode 或 Transporter 工具上传 .ipa 文件到 App Store Connect。

此外,AppUploader工具允许开发者在Windows、Linux或Mac系统中直接上传IPA文件到App Store,无需Mac电脑,比传统工具更高效。

3.4提交审核

  • 在 App Store Connect 中提交应用审核。
  • 填写审核信息,确保符合 Apple 的审核指南。

3.5发布

  • 审核通过后,设置发布日期。
  • 应用会自动发布到 App Store。

4.Android 上架

Android 应用的上架主要通过 Google Play 或其他应用商店。

4.1打包 Android 应用

  • 使用 HBuilderX 的云打包功能:

    • 打开 HBuilderX,选择“发行” -> “原生App-云打包”。
    • 选择 Android 平台,配置签名证书。
    • 点击“打包”,生成 .apk 或 .aab 文件。

4.2配置 Google Play Console

  • 登录 Google Play Console。
  • 创建新应用,填写应用名称、描述、截图等信息。
  • 上传应用图标和预览视频。

4.3上传应用

  • 在 Google Play Console 中上传 .aab 或 .apk 文件。

4.4提交审核

  • 填写应用内容分级和隐私政策。
  • 提交应用审核,确保符合 Google Play 的政策。

4.5发布

  • 审核通过后,设置发布日期。
  • 应用会自动发布到 Google Play。

5.上架注意事项

5.1应用合规

  • 确保应用内容符合各平台的政策和法律法规。
  • 提供隐私政策链接,明确用户数据使用方式。

5.2应用图标与截图

  • 提供高质量的图标和截图,符合平台要求。
  • 确保截图展示应用的核心功能。

5.3版本管理

  • 使用语义化版本号(如 v1.0.0)。
  • 记录版本更新日志,方便用户了解新功能。

5.4测试与优化

  • 在上架前进行全面测试,确保应用稳定运行。
  • 优化应用性能,提升用户体验。

总结

UniApp 应用的上架流程因目标平台而异,但总体包括打包、配置、上传、审核和发布等步骤。通过合理的上架流程和注意事项,可以确保应用顺利发布并触达用户。

flutter接入三方库运行报错:Error running pod install

最近在研究flutter,在flutter中引入第三方webview_flutter后,运行iOS设备时报错,具体报错如下:

Error running pod install

Error launching application on iPhone 15.

看问题时iOS的cocoapod在执行pod install命令下载第三方库时出现问题,如是我们找到项目中ios目录,使用xcode打开项目看看情况,

打开后发现项目包错Module 'webview_flutter_wkwebview' not found,很明显这里第三方没有下载下来

如是我们打开控制台cd 到项目的iOS目录,执行pod install这时候我们能看到控制台提示:/Library/Ruby/Gems/2.6.0/gems/ffi-1.17.0-arm64-darwin/lib/2.6/ffi_c.bundle (LoadError)

/System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/core_ext/kernel_require.rb:54:in `require': cannot load such file -- ffi_c (LoadError)

这个错误表明 Ruby 在尝试加载 ffi_c 时失败了,通常是因为 ffi gem 没有正确安装或与系统 Ruby 不兼容

 重新安装 ffi gem

ffi gem 可能没有正确安装或损坏,可以尝试重新安装。

卸载ffi gem:

gem uninstall ffi

#如果报权限问题就使用

sudogem uninstall ffi

重新安装 ffi gem:

gem install ffi

#如果报权限问题就使用

sudo gem install ffi

升级ruby环境

本地环境很多依赖于默认的ruby环境,所以我们可以使用rvm管理和安装多个版本ruby

安装 rvm:

\curl -sSL get.rvm.io | bash -s stable

安装一个 Ruby 版本(如 3.0.0):

rvm install 3.0.0

rvm use 3.0.0 --default

验证 Ruby 版本:

ruby-v

查看所有ruby:

rvm list

在安装ruby的时候我出现了下面的包错

Error running '__rvm_make -j6',

please read /Users/apple/.rvm/log/1739849439_ruby-3.0.0/make.log

我怀疑指定的OpenSSL版本可能没生效,于是干脆通过brew uninstall openssl命令把最新版本的OpenSSL卸载了,再次执行上面的命令一切正常🎉!

解决方案

如果不局限于安装Ruby 3.0版本,那么可以通过安装更高的Ruby版本解决该问题,可以参考这篇文章RVM - 安装最新Ruby版本。

如果一定要安装Ruby 3.0版本,请安装1.1版本的OpenSSL,并卸载最新版本,同时指定使用HomeBrew安装的OpenSSL完成安装:

安装1.1版本的OpenSSL

brew install openssl@1.1

卸载最新版本的OpenSSL

brew uninstall openssl

指定使用HomeBrew安装的OpenSSL完成安装

rvm install ruby-3.0.0 --with-openssl-dir=brew --prefix openssl

如果不想卸载最新版本,可以通过brew link命令切换(链接)openssl的版本完成安装:

安装1.1版本的OpenSSL

brew install openssl@1.1

切换OpenSSL的版本为1.1

brew link --overwrite openssl@1.1

--overwrite参数的作用是强制切换。如果不使用该参数,可以先执行brew unlink openssl命令后再执行brew link openssl@1.1命令完成切换。

指定使用HomeBrew安装的OpenSSL完成安装

rvm install ruby-3.0.0 --with-openssl-dir=brew --prefix openssl@1.1

执行完成后再次执行pod install 报错: `find_spec_for_exe':can't find gem cocoapods (>= 0.a) with executable pod (Gem::GemNotFoundException)

因使用 brew 安装工具导致 ruby 环境错乱, 执行pod install时报错提示找不到 gem 可执行文件

Traceback (most recent call last):    2: from /usr/local/bin/pod:23:in '    1: from /Library/Ruby/Site/2.6.0/rubygems.rb:294:in activate_bin_path'/Library/Ruby/Site/2.6.0/rubygems.rb:275:in `find_spec_for_exe': can't find gem cocoapods (>= 0.a) with executable pod (Gem::GemNotFoundException)

解决办法:

重新安装 ruby 环境(默认安装最新版本)

rvm reinstall ruby --disable-binary

运行结果

mruby-1.3.0 - #removing src/mruby-1.3.0 - please waitmruby-1.3.0 - #removing rubies/mruby-1.3.0 - please waitRVM does not have prediction for required space for mruby-1.3.0, assuming 150MB should be enough, let us know if it was not.Checking requirements for osx.Certificates bundle '/usr/local/etc/openssl@1.1/cert.pem' is already up to date.Requirements installation successful.Installing Ruby from source to: /Users/jack/.rvm/rubies/mruby-1.3.0, this may take a while depending on your cpu(s)...mruby-1.3.0 - #downloading 1.3.0, this may take a while depending on your connection...mruby-1.3.0 - #extracting 1.3.0 to /Users/jack/.rvm/src/mruby-1.3.0 - please waitmruby-1.3.0 - #compiling - please waitmruby-1.3.0 - #installing - please waitInstall of mruby-1.3.0 - #completeRequired ruby-2.7.0 is not installed.To install do: 'rvm install "ruby-2.7.0"'

重新安装 cocoapods

 gem install cocoapods

运行结果:

Successfully installed cocoapods-1.9.3Parsing documentation for cocoapods-1.9.3Done installing documentation for cocoapods after 1 seconds1 gem installed

再重新执行pod install就OK

Homebrew,是Mac OS X上的软件包管理工具,使用起来非常方便,安装任意软件包时 brew 会自动下载其依赖;

RubyGems 提供了ruby社区gem的托管服务,主要用于下载、安装使用 ruby 软件包

平常 iOS 开发使用 cocoapods 等工具都是使用 gems 进行安装管理,当使用 brew 安装软件包时有可能因依赖导致 ruby 环境错乱,不建议混合使用(使用 brew 也可以安装 cocoapods 而且很方便)

再次运行项目,又发现新的包错: 'Flutter/Flutter.h not found'

这里这样做

1、为了保守起见先备份ios文件

2、删除iOS文件

3、cd到项目跟目录执行命令重新创建ios项目

flutter create .

4、将备份的ios重要文件替换进来

详细见:www.kindacode.com/article/flu…

免 Xcode 的 iOS 开发新选择?聊聊一款更轻量的 iOS 开发 IDE kxapp 快蝎

在 iOS 开发领域,Xcode 几乎是默认标配。但这些年做项目的过程中,我越来越频繁地遇到一些现实问题:版本更新频繁、安装包体积巨大、不同系统环境兼容性复杂、团队成员机器配置差异明显……尤其是在需要快速验证想法、做 Demo 或维护多个项目时,环境本身反而成了效率瓶颈。

最近在技术社区里看到不少人讨论一款名为 快蝎 的 iOS 开发 IDE( kxapp.com/ ),主打“免 Xcode 开发 iOS”。


一、为什么会有人想“绕开”Xcode?

不是说 Xcode 不好,而是它确实越来越“重”。

  • 安装包体积大,更新频率高
  • 多版本切换成本高
  • 真机调试证书配置复杂
  • 某些跨平台项目需要额外适配

对于资深开发者来说,这些问题可以解决,但它们会消耗时间。对于新手来说,这些反而是第一道门槛。

如果有一个工具能把“环境搭建”这件事去掉,让开发者更专注于代码本身,其实是件挺有吸引力的事。


二、从项目创建开始,流程确实更简化

快蝎给我的第一印象是轻量。安装完成后,可以直接创建 Swift、Objective-C 或 Flutter 项目,不需要手动搭建模板结构。

项目结构是规范化生成的,新建即用,没有那种“先配半天环境再写第一行代码”的感觉。尤其是 Flutter 项目直接支持 iOS 构建,这点在跨端开发中比较实用。

对于经常写原生和混合项目的人来说,这种“一站式支持多项目类型”的方式确实省事,不用在不同工具之间频繁切换。


三、真机调试体验,少了一些步骤

iOS 开发最真实的体验一定是在真机上。模拟器再强,也无法完全替代真实设备环境。

快蝎内置了真机实时调试引擎,连接 iPhone 后可以一键构建并安装运行,不需要额外打开 Xcode,也不用手动导出 IPA。

我实际测试时,从修改代码到同步到手机,大概几秒完成,调试过程比较顺畅。对比传统流程:

  1. 切回 Xcode
  2. 选择设备
  3. 构建运行
  4. 可能还要处理签名问题

这种流程减少带来的体验差异还是挺明显的。

特别是在频繁改 UI、调交互细节时,所见即所得的反馈节奏,会让开发状态更连贯。


四、免 Xcode 开发 iOS 的可行性

不少人第一反应会问:真的可以不装 Xcode 吗?

从使用体验来看,快蝎内置了自主研发的编译工具套装,可以完成 iOS App 的开发、构建与生成安装包流程。对于日常开发、测试构建来说是完全够用的。

当然,如果涉及某些极端底层调试或特殊配置场景,传统工具链依然有价值。但对于大多数业务开发者来说,能减少对 Xcode 的依赖,本身就是一种效率提升。

尤其是在不想频繁升级 Xcode 版本、担心系统兼容问题时,这种独立工具链的价值就会体现出来。


五、基于 VSCode 架构的编码体验

这一点是我比较喜欢的。

快蝎的编辑体验基于 VSCode 生态,可以使用熟悉的快捷键、插件体系以及各种 AI 代码助手。对于已经习惯 VSCode 工作流的开发者来说,上手成本几乎为零。

智能提示、代码补全、规范化项目结构都做得比较流畅。写代码时没有明显卡顿感,整体体验偏“轻快型”,而不是传统 IDE 的沉重感。

对于长期写业务代码的人来说,工具的流畅度其实会直接影响专注度。少一点卡顿和等待,多一点即时反馈,长时间开发时差异会非常明显。


六、从开发到发布:流程闭环

很多工具只解决某个环节,但真正提高效率的是“闭环”。

在快蝎里,从创建项目、编码、调试到构建生成安装包,流程都在同一个界面内完成。开发完成后可以一键构建安装包,用于测试分发或提交 App Store。

整个过程不需要频繁切换工具,也没有复杂命令行操作。这种全流程整合,对于中小团队或者个人开发者来说尤其友好。


七、适合什么类型的开发者?

根据我的体验,这类工具比较适合:

  • 想快速验证产品想法的独立开发者
  • 需要维护多个 iOS 项目的工程师
  • 使用 Flutter 同时涉及 iOS 构建的开发者
  • 希望减少环境折腾时间的新手

它并不是要取代传统工具,而是提供另一种更轻量的选择。


工具趋势的一个信号

这几年开发工具的发展方向很明显:更轻量、更自动化、更智能。

从容器化部署到云开发环境,再到 AI 辅助编码,本质上都是在减少非核心成本。iOS 开发工具链也在发生变化,出现像快蝎这样的方案,其实是顺应趋势。

开发者真正关心的不是工具本身,而是效率、稳定性和可控性。如果一个 IDE 能让开发流程更简单,同时不牺牲性能和安全性,它就有存在的空间。


做开发这些年,最大的感受是:时间比工具重要。

如果一个工具能让你少花时间在配置上,多花时间在产品和代码质量上,那它就值得尝试。快蝎这种免 Xcode 的 iOS 开发 IDE,本质上是在优化流程,而不是改变语言或技术栈。

对于习惯传统工具链的人来说,也许可以把它当作一个备用方案或效率补充。对于刚入门 iOS 的开发者来说,它可能会让第一步走得更轻松。

技术从来不是非黑即白,多一个选择,往往意味着多一种可能。

iOS 深度解析


目录

  1. iOS 启动流程
  2. 启动优化
  3. 网络优化
  4. RunLoop
  5. Runtime
  6. 卡顿监控
  7. AFNetworking
  8. SDWebImage

1. iOS 启动流程

1.1 启动的宏观阶段划分

iOS App 的启动可分为两个大阶段:pre-main 阶段(main 函数执行之前)和 post-main 阶段(main 函数执行之后到首帧渲染完成)。

  • 冷启动(Cold Launch):App 完全不在内存中,需要从磁盘加载所有资源,经历完整的 pre-main 和 post-main 流程。
  • 热启动(Warm Launch):App 进程虽然被终止,但部分数据仍然在系统内核的页缓存中(page cache),此时 dyld 加载速度会更快。
  • 恢复启动(Resume):App 只是从后台切回前台,不涉及进程创建,严格意义上不算"启动"。

1.2 Pre-main 阶段详解

1.2.1 内核阶段(Kernel)

当用户点击 App 图标时,系统通过 launchd 进程(PID=1)fork 出一个新的进程。内核为新进程完成以下工作:

  • 创建进程:分配 PID,创建虚拟内存空间(每个进程都有独立的 4GB/16EB 虚拟地址空间)。
  • ASLR(Address Space Layout Randomization):生成一个随机偏移值(slide),将 Mach-O 的加载基地址随机化,防止固定地址攻击。ASLR 是在内核层面实现的,每次启动 slide 不同。
  • 加载可执行文件:将 Mach-O 的头部和 Load Commands 映射到虚拟内存中(注意是映射,不是全部读入物理内存,利用的是 mmap 和按需缺页机制)。

1.2.2 dyld 阶段(Dynamic Linker)

dyld(dynamic link editor)是 Apple 的动态链接器,它是第一个在用户态运行的代码。Apple 在 iOS 13/macOS 11 之后将 dyld 升级到了 dyld3 和后来的 dyld4,引入了启动闭包(Launch Closure)机制。

dyld 的核心工作流程:

a) 加载动态库(Load Dylibs)

dyld 根据 Mach-O 的 LC_LOAD_DYLIB 等 Load Commands,递归地加载所有依赖的动态库。每个动态库自身也可能依赖其他动态库,形成一棵依赖树。系统共享库(如 UIKit、Foundation)通过 dyld shared cache(共享缓存)提前合并优化,存放在 /System/Library/Caches/com.apple.dyld/ 下,加载速度极快。

动态库的加载过程:

  • 解析 Mach-O Header,验证魔数(Magic Number)、CPU 架构、文件类型。
  • 读取 Load Commands,确定各 Segment(__TEXT__DATA__LINKEDIT)的内存映射方式。
  • 调用 mmap() 将文件内容映射到虚拟内存。
  • 由于使用了 Copy-on-Write(COW)技术,只读段可以被多个进程共享物理内存。

b) Rebase(基址重定位)

由于 ASLR 的存在,Mach-O 中所有写死的内部指针地址都需要加上 slide 偏移量。这个过程就是 Rebase。

Rebase 主要操作 __DATA 段中的指针。现代的 chained fixups(链式修正)格式将 rebase 信息直接编码在指针值中,减少了 __LINKEDIT 的大小,也加速了处理。

Rebase 的性能瓶颈不在于计算(加法操作极快),而在于 Page Fault:当访问尚未加载到物理内存的虚拟页时,会触发缺页中断,内核需要从磁盘读取对应的页并进行解密验证(如果开启了代码签名验证)。

c) Bind(符号绑定)

Bind 处理的是对外部动态库符号的引用。App 中调用的 NSLogobjc_msgSend 等函数,在编译时并不知道它们的真实地址,需要在运行时通过符号名查找。

  • Lazy Binding(懒绑定):大部分外部函数调用使用懒绑定,第一次调用时才通过 dyld_stub_binder 查找真实地址并回填到 __DATA.__la_symbol_ptr(Lazy Symbol Pointer)中,后续调用直接跳转,不再走 dyld。
  • Non-Lazy Binding(非懒绑定):部分符号(如 Objective-C 类引用、全局变量指针)需要在启动时立即绑定,存放在 __DATA.__nl_symbol_ptr(Non-Lazy Symbol Pointer)中。
  • Weak Binding(弱绑定)__attribute__((weak)) 修饰的符号需要搜索所有已加载的镜像来确定是否有强定义覆盖,开销较大。

d) dyld3/dyld4 的 Launch Closure

dyld3 引入了 Launch Closure(启动闭包)机制——将首次启动时的解析结果(依赖关系、rebase/bind 信息、初始化顺序等)序列化保存到磁盘。后续启动时直接读取闭包文件,跳过大量解析工作。

dyld4 进一步引入了 PrebuiltLoaderSet,对 App 的启动路径做了更激进的预计算。

1.2.3 Objective-C Runtime 初始化

dyld 在完成所有动态库的加载和绑定后,会调用注册的初始化函数。ObjC Runtime 的初始化是其中最重要的一步:

  • map_images:当新的 Mach-O 镜像被映射到内存时调用。Runtime 解析 __DATA.__objc_classlist__DATA.__objc_catlist(Category 列表)、__DATA.__objc_protolist(Protocol 列表)等 section,将类、分类、协议注册到全局表中。
  • 类的实现(Realize):将类从磁盘格式转换为运行时格式,设置 superclass 指针、method list、ivar layout 等。这个过程是懒加载的——只有第一次使用类时才会 realize。
  • Category 的附加:将 Category 中的方法、属性、协议"织入"到对应的类中。方法会被插入到方法列表的前面,这就是 Category 能"覆盖"原类方法的原因。
  • load_images:调用所有类和 Category 的 +load 方法。调用顺序:先按编译顺序调用父类的 +load,再调用子类的,最后调用 Category 的。+load 在所有类完成注册后、任何 +initialize 之前执行。

1.2.4 C++ 静态初始化器

所有标记了 __attribute__((constructor)) 的函数以及 C++ 全局对象的构造函数会在此阶段被调用。它们通过 __DATA.__mod_init_func section 记录。

1.2.5 执行 main 函数

完成以上所有步骤后,dyld 调用 App 可执行文件的入口点,即 main() 函数。

1.3 Post-main 阶段详解

1.3.1 UIApplicationMain

main() 函数通常只做一件事:调用 UIApplicationMain()。这个函数完成:

  • 创建 UIApplication 单例对象。
  • 创建 App Delegate 对象。
  • 启动主线程的 RunLoop(CFRunLoopGetMain())。
  • 加载 Info.plist,如果指定了 Main Storyboard,则加载并实例化初始 ViewController。

1.3.2 Application Lifecycle Callbacks

按照 iOS 13+ 的 Scene-based Life Cycle(多窗口架构):

  1. application:didFinishLaunchingWithOptions: — App 级别的初始化入口。
  2. scene:willConnectToSession:options: — Scene 连接。
  3. sceneWillEnterForeground: — 即将进入前台。
  4. sceneDidBecomeActive: — 已激活,用户可交互。

1.3.3 首帧渲染(First Frame Render)

首帧渲染标志着用户可以看到 App 的实际界面。系统在第一次 CATransaction commit 时将渲染树提交给 Render Server(一个独立进程 backboardd),完成 GPU 合成并上屏。

Apple 的 App Launch InstrumentCA::Transaction::commit() 中第一帧绘制完成作为启动结束的标志。

1.4 Mach-O 文件格式补充

Mach-O 是 macOS/iOS 的可执行文件格式,理解它对理解启动流程至关重要:

区域 内容
Header 魔数、CPU 类型、文件类型(MH_EXECUTE/MH_DYLIB)、Load Commands 数量
Load Commands 描述文件布局的元数据:段的位置和大小、动态库依赖、入口点、代码签名位置等
__TEXT 只读、可执行:机器码(__text)、ObjC 方法名(__objc_methname)、字符串常量(__cstring)等
__DATA 可读写:全局变量、ObjC 类数据、符号指针表等
__DATA_CONST 启动后只读:ObjC 类列表、协议列表等(rebase/bind 后被 mprotect 设为只读)
__LINKEDIT 动态链接器使用的元数据:符号表、字符串表、rebase/bind 操作码、代码签名等

2. 启动优化

2.1 度量体系

2.1.1 Apple 官方指标

  • TTID(Time to Initial Display):App 进程创建到第一帧渲染完成的时间。Apple 建议冷启动控制在 400ms 以内。
  • MetricKitMXAppLaunchMetric 提供生产环境的启动耗时数据(p50/p90/p99)。
  • DYLD_PRINT_STATISTICS:设置此环境变量可在控制台输出 pre-main 阶段各步骤的耗时。

2.1.2 自建度量

+load 或进程创建时记录起始时间戳,在首帧 viewDidAppear:CADisplayLink 回调中记录结束时间戳,差值即为端到端启动时间。注意要使用 mach_absolute_time()clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) 获取高精度时间,避免使用 NSDate(会受 NTP 校时影响)。

2.2 Pre-main 阶段优化

2.2.1 减少动态库数量

每个自定义动态库都会增加 dyld 的加载、rebase、bind 开销。Apple 建议自定义动态库不超过 6 个

优化手段:

  • 将多个小型动态 framework 合并为一个。
  • 能用静态库的场景优先使用静态库(静态库在编译链接阶段就合并到了主二进制中,不增加 dyld 的运行时负担)。
  • 使用 xcframework 统一管理多架构,避免重复链接。

2.2.2 减少 ObjC 元数据

  • 减少类和 Category 的数量:每个 ObjC 类都需要在 map_images 阶段注册到 Runtime 的全局类表中,每个 Category 都需要被合并到宿主类。大量无用的类会拖慢这个过程。
  • 清理无用代码:使用 LinkMap 文件分析各模块大小,结合 AppCode 的 Inspect Code 或开源工具(如 fuiperiphery)找出未使用的类和方法。
  • Swift 优势:Swift 的结构体和枚举不经过 ObjC Runtime,不产生 map_images 的注册开销。能用 Swift 值类型代替 ObjC 类的场景应优先考虑。

2.2.3 消灭 +load 方法

+load 方法在启动的极早期串行执行(持有 Runtime 的全局锁),任何耗时操作都会直接阻塞启动。

替代方案:

  • +initialize:懒加载,在类第一次收到消息时调用,且只调用一次(线程安全由 Runtime 保证)。将初始化逻辑从 +load 迁移到 +initialize 可以将开销延后到实际使用时。
  • __attribute__((constructor)) 也应减少:与 +load 类似,在 main() 之前执行。

2.2.4 二进制重排(Binary Reordering)

原理:App 启动时并非所有代码都会被立即执行。由于虚拟内存的分页机制(iOS 上每页 16KB),启动时执行的函数如果分散在不同的页中,会导致大量 Page Fault。每次 Page Fault 需要从磁盘读取一页并进行代码签名验证(对于加密的 App),耗时约 0.10.3ms。如果启动路径上有 2000 次 Page Fault,累计开销可达 200600ms。

做法

  1. 使用 Clang 的 SanitizerCoverage-fsanitize-coverage=func,trace-pc-guard)编译代码,在每个函数入口插入回调,记录启动路径上所有被调用的函数及其顺序。
  2. 生成 Order File.order 文件),按启动调用顺序列出函数符号。
  3. 在 Xcode 的 Build Settings 中设置 Order File 路径,链接器会按指定顺序排布函数,使启动路径上的函数尽量集中在连续的页中,减少 Page Fault。

效果:对于大型 App,Page Fault 次数可减少 30%70%,带来 100300ms 的启动提升。

2.2.5 dyld3/dyld4 闭包缓存

现代 iOS 系统已默认使用 dyld3 闭包。开发者能做的是确保不破坏闭包缓存的有效性——每次 App 更新后首次启动闭包需要重新生成,这属于不可避免的开销。

2.3 Post-main 阶段优化

2.3.1 任务分级与延迟加载

didFinishLaunchingWithOptions: 中的初始化任务按优先级分为三类:

优先级 任务类型 执行时机
P0 崩溃监控、AB 实验框架 didFinishLaunching 最前面,同步执行
P1 网络库初始化、用户登录态恢复 didFinishLaunching 中异步执行
P2 分享 SDK、推送注册、非首屏功能 首帧渲染后延迟执行(通过 RunLoop idle 或延时 dispatch)

关键原则:首帧渲染前只做必须做的事

2.3.2 首页渲染优化

  • 缓存上次的首页截图:在启动时展示缓存截图(skeleton screen 或快照),让用户感知到"已打开",待真实数据加载完成后替换。
  • 减少首页视图层级:使用 Instruments 的 View Debugger 分析视图层级深度,减少不必要的嵌套。
  • 避免首帧同步网络请求:使用本地缓存数据渲染首帧,网络数据到达后差量更新。

2.3.3 子线程预加载

将不需要在主线程执行的初始化任务放到并发队列中并行执行:

  • 数据库初始化和预热。
  • 预加载常用的图片资源到内存缓存。
  • 预建立 HTTP/2 连接(TCP + TLS 握手)。

注意:UIKit 操作必须在主线程,CoreData 的 NSManagedObjectContext 要注意线程隔离。

2.3.4 启动任务调度框架

大型 App 通常会搭建启动任务调度框架,支持:

  • 声明式地定义任务、依赖关系和线程要求。
  • 自动拓扑排序确定执行顺序。
  • 并行执行无依赖关系的任务。
  • 监控每个任务的耗时,自动上报异常。

2.4 持续劣化防护

  • CI 卡口:在 CI 流水线中集成启动耗时测试(使用 XCTest + MetricKit 或自定义打点),设置阈值,超标则阻断合入。
  • LinkMap 体积监控:监控二进制体积增长(尤其是 __DATA 段的增长),它与 rebase/bind 耗时正相关。
  • +load 扫描:通过静态分析工具在编译期扫描新增的 +load 方法。

3. 网络优化

3.1 网络请求的全链路分析

一次 HTTPS 请求的完整链路:

DNS 解析 → TCP 三次握手 → TLS 握手 → 请求发送 → 服务器处理 → 响应接收 → 数据解析

每个环节都有优化空间。

3.2 DNS 优化

3.2.1 传统 DNS 的问题

  • 解析延迟:首次解析需要递归查询根域名服务器 → 顶级域名服务器 → 权威域名服务器,耗时 50~200ms,极端情况下可达数秒。
  • DNS 劫持:运营商 LocalDNS 可能返回篡改的 IP 地址,将用户引导到广告页或错误服务器。
  • 调度不精准:运营商 DNS 的出口 IP 与用户的实际 IP 可能不在同一地区,导致 CDN 调度到非最优节点。
  • DNS 缓存不可控:系统 DNS 缓存(res_9_getaddrinfo)的 TTL 由服务端控制,App 无法主动管理。

3.2.2 HTTPDNS

HTTPDNS 通过 HTTP/HTTPS 协议直接向 DNS 服务商(如阿里云 HTTPDNS、腾讯云 HTTPDNS)发送域名解析请求,绕过运营商 LocalDNS。

核心优势:

  • 防劫持:使用 HTTPS 通道加密传输,运营商无法篡改。
  • 精准调度:可以携带客户端真实 IP(EDNS Client Subnet),CDN 能调度到最优节点。
  • 可控缓存:App 自主管理 DNS 缓存和预解析策略。

实现要点:

  • 预解析:App 启动时对常用域名发起预解析,将结果缓存在本地。
  • 缓存策略:本地维护 IP 缓存池,设置合理的 TTL。TTL 过期后异步刷新,期间仍使用旧 IP("乐观缓存"策略),避免解析等待。
  • 降级机制:HTTPDNS 服务异常时自动降级到系统 DNS。
  • SNI 问题:使用 HTTPDNS 后,HTTPS 请求的 Host 头是 IP 地址,需要手动设置 SNI(Server Name Indication)字段为原始域名,否则 TLS 握手会因证书不匹配而失败。在 NSURLSession 中需要实现 URLSession:didReceiveChallenge:completionHandler: 代理方法处理证书验证。

3.2.3 DNS-over-HTTPS (DoH) / DNS-over-TLS (DoT)

iOS 14+ 原生支持 DoH/DoT(通过 NEDNSSettingsManager),但这是系统级别的配置,App 级别的定制灵活性不如 HTTPDNS。

3.3 连接优化

3.3.1 连接复用

  • HTTP/1.1 Keep-Alive:在同一个 TCP 连接上串行发送多个请求,避免每次请求都建立新连接。但存在 队头阻塞(Head-of-Line Blocking) 问题——前一个请求未完成时后续请求必须等待。
  • HTTP/2 多路复用(Multiplexing):在单个 TCP 连接上并行发送多个请求/响应,通过帧(Frame)和流(Stream)的概念实现真正的并发。一个连接可以同时承载上百个请求。但 TCP 层的队头阻塞依然存在——一个丢包会阻塞整个连接上的所有流。
  • HTTP/3 (QUIC):基于 UDP,在传输层消除了队头阻塞。每个流独立进行丢包重传,互不影响。同时集成了 TLS 1.3,握手延迟更低(0-RTT/1-RTT)。iOS 15+ 的 NSURLSession 默认支持 HTTP/3。

3.3.2 预连接(Pre-connect)

在用户可能发起请求之前,提前完成 TCP + TLS 握手,使后续请求可以直接发送数据。

实现方式:使用 NSURLSession 的连接预热 API,或自行管理连接池。

3.3.3 连接迁移(Connection Migration)

传统 TCP 连接以四元组(源 IP、源端口、目的 IP、目的端口)标识,当用户从 WiFi 切换到蜂窝时,源 IP 变化导致连接断开。QUIC 使用 Connection ID 标识连接,网络切换时连接不中断,实现无缝迁移。

3.4 数据传输优化

3.4.1 数据压缩

  • Gzip/Brotli:在 HTTP 响应头中设置 Content-Encoding: gzip/br。Brotli 压缩率比 gzip 高 15~25%,特别适合文本类数据。NSURLSession 自动处理 gzip 解压。
  • Protocol Buffers / FlatBuffers:使用二进制序列化替代 JSON。Protobuf 体积比 JSON 小 310 倍,解析速度快 20100 倍。适用于高频接口和大数据量场景。
  • 增量更新(Delta Sync):只传输变化的部分,而非全量数据。可以使用 JSON Patch(RFC 6902)或自定义 diff 算法。

3.4.2 请求合并与批处理

将多个小请求合并为一个批量请求,减少网络往返次数(RTT)。例如将 10 个独立的埋点上报请求合并为 1 个批量请求。

3.4.3 精简数据

  • 按需请求字段:使用 GraphQL 或接口的 fields 参数,只请求客户端真正需要的字段,减少无用数据传输。
  • 分页加载:对列表类数据实施分页,避免一次加载全量数据。

3.5 缓存策略

3.5.1 HTTP 缓存

  • 强缓存Cache-Control: max-age=3600Expires 头。在有效期内直接使用本地缓存,不发起网络请求。
  • 协商缓存ETag / If-None-MatchLast-Modified / If-Modified-Since。客户端携带标识请求服务器,若资源未变则返回 304,节省传输带宽。
  • NSURLSession 的缓存策略:通过 NSURLRequest.cachePolicy 控制,NSURLCache 自动管理磁盘和内存缓存。

3.5.2 业务层缓存

  • 将接口返回数据持久化到本地(SQLite、文件),优先展示缓存数据,网络数据到达后更新 UI("先展示后刷新"策略)。
  • 对于不频繁变化的数据(如配置信息),使用较长的本地缓存有效期。

3.6 弱网优化

  • 超时策略:针对不同网络质量动态调整超时时间。WiFi 下 15s,4G 下 20s,3G/2G 下 30s。
  • 重试策略:指数退避(Exponential Backoff)+ 抖动(Jitter)。避免重试风暴压垮服务器。只对幂等请求(GET、PUT)重试,POST 请求需要业务层保证幂等性。
  • 网络质量检测:通过 NWPathMonitor(Network Framework)实时监听网络状态变化,结合 RTT、丢包率估算网络质量,动态降级(如切换到低分辨率图片)。
  • 多通道竞速:在 WiFi 和蜂窝同时可用时,并行发起请求,取先返回的结果。NSURLSessionConfiguration.multipathServiceType 支持 MPTCP(Multipath TCP)。

3.7 安全层优化

  • TLS 1.3:将握手往返从 2-RTT(TLS 1.2)减少到 1-RTT,支持 0-RTT 恢复(PSK,Pre-Shared Key)。iOS 12.2+ 默认支持。
  • 证书固定(Certificate Pinning):在 App 内预埋服务器证书的公钥哈希,防止中间人攻击。需要注意证书轮换的运维流程。
  • OCSP Stapling:服务器在 TLS 握手时主动提供证书状态(是否被吊销),避免客户端额外查询 OCSP 服务器。

3.8 监控体系

  • URLSessionTaskMetrics(iOS 10+):提供每个请求的详细时间线——DNS 解析时间、连接建立时间、TLS 握手时间、请求发送时间、响应接收时间等。这是做网络性能分析的核心数据源。
  • 端到端监控指标:成功率、平均耗时、P99 耗时、DNS 解析耗时、首字节时间(TTFB)、错误类型分布等。
  • 网络链路追踪:在请求头中注入 Trace ID,贯穿客户端 → CDN → 网关 → 后端服务,实现全链路问题定位。

4. RunLoop

4.1 RunLoop 的本质

RunLoop 本质上是一个 事件循环(Event Loop) 机制。它让线程在没有任务时进入休眠(不消耗 CPU),在有任务时被唤醒处理事件。没有 RunLoop 的线程执行完任务就会退出;有了 RunLoop,线程可以常驻内存,随时响应事件。

RunLoop 与线程是一一对应的关系:

  • 主线程的 RunLoop 在 UIApplicationMain 中自动创建和启动。
  • 子线程的 RunLoop 默认不创建,需要手动调用 [NSRunLoop currentRunLoop]CFRunLoopGetCurrent() 时才会懒加载创建。
  • RunLoop 保存在一个全局的 CFMutableDictionaryRef 中,以 pthread_t 作为 key。

4.2 RunLoop 的核心架构

4.2.1 三大核心对象

a) CFRunLoopSource(输入源)

  • Source0(非端口事件源):不能主动唤醒 RunLoop,需要手动调用 CFRunLoopSourceSignal() 标记为待处理,再调用 CFRunLoopWakeUp() 唤醒 RunLoop。触摸事件、performSelector:onThread: 等使用 Source0 分发。
  • Source1(端口事件源):基于 Mach Port,能主动唤醒 RunLoop。系统内核通过 Mach Port 发送消息来通知事件,如硬件事件(触摸/锁屏/摇晃)首先由 IOKit 通过 Mach Port 传递给 SpringBoard,再由 SpringBoard 通过 Mach Port 分发给对应的 App 进程。App 内部的 Source1 接收到事件后,通常会封装成 Source0 在主线程 RunLoop 中处理。

b) CFRunLoopTimer(定时器源)

基于时间的触发器,与 NSTimer 是 toll-free bridged 的。Timer 的触发时间并非绝对精确——它依赖于 RunLoop 的运行状态。如果 RunLoop 正在处理一个耗时任务,Timer 的回调会被延迟到当前任务完成后才执行。Timer 有一个 tolerance(容差)属性,系统可以在 fireDate ± tolerance 范围内选择最佳触发时机以节能。

c) CFRunLoopObserver(观察者)

可以监听 RunLoop 的状态变化:

状态 含义
kCFRunLoopEntry 即将进入 RunLoop
kCFRunLoopBeforeTimers 即将处理 Timer
kCFRunLoopBeforeSources 即将处理 Source
kCFRunLoopBeforeWaiting 即将进入休眠
kCFRunLoopAfterWaiting 刚从休眠中唤醒
kCFRunLoopExit 即将退出 RunLoop

4.2.2 RunLoop Mode

RunLoop 在某一时刻只能运行在一个 Mode 下。每个 Mode 包含独立的 Source/Timer/Observer 集合。切换 Mode 时,当前 Mode 下的 Source/Timer/Observer 不会被处理。

常用 Mode:

  • kCFRunLoopDefaultModeNSDefaultRunLoopMode:默认 Mode,App 空闲时运行在此 Mode。
  • UITrackingRunLoopMode:ScrollView 滑动时切换到此 Mode。这就是为什么 NSTimer 在 Default Mode 下注册时,滑动 ScrollView 期间 Timer 不触发——因为 RunLoop 此时运行在 Tracking Mode 下。
  • kCFRunLoopCommonModesNSRunLoopCommonModes:这不是一个真正的 Mode,而是一个"模式集合"的标记。被标记为 Common 的 Source/Timer/Observer 会被同步到所有被标记为 Common 的 Mode 中。默认情况下 Default Mode 和 Tracking Mode 都是 Common Mode。将 Timer 添加到 Common Modes 可以让它在滑动时也能触发。

4.3 RunLoop 的运行机制(核心循环)

RunLoop 的核心运行逻辑(简化版):

  1. 通知 Observer:即将进入 RunLoop(kCFRunLoopEntry)。
  2. 通知 Observer:即将处理 Timer(kCFRunLoopBeforeTimers)。
  3. 通知 Observer:即将处理 Source0(kCFRunLoopBeforeSources)。
  4. 处理所有待处理的 Source0 事件。
  5. 如果有 Source1(Mach Port 消息)待处理,跳转到步骤 9 直接处理。
  6. 通知 Observer:即将进入休眠(kCFRunLoopBeforeWaiting)。
  7. 休眠,等待唤醒。线程通过 mach_msg() 系统调用陷入内核态,让出 CPU。可以被以下事件唤醒:
    • Mach Port 消息到达(Source1 事件、Timer 触发、CFRunLoopWakeUp() 调用)。
    • 超时(RunLoop 有一个超时参数)。
    • 被外部手动唤醒。
  8. 通知 Observer:刚从休眠中被唤醒(kCFRunLoopAfterWaiting)。
  9. 处理唤醒事件:
    • 如果是 Timer 到期:处理 Timer 回调。
    • 如果是 dispatch_main_queue 的 block:执行 block(GCD 派发到主队列的任务通过 RunLoop 的 Source1 唤醒主线程执行)。
    • 如果是 Source1 事件:处理 Source1 回调。
  10. 判断是否需要退出(Mode 中没有任何 Source/Timer、被外部停止、超时等)。
  11. 如果不退出,跳转到步骤 2 继续循环。
  12. 通知 Observer:即将退出 RunLoop(kCFRunLoopExit)。

4.4 RunLoop 与系统功能的关系

4.4.1 AutoreleasePool

主线程 RunLoop 注册了两个 Observer 与 AutoreleasePool 配合:

  • 第一个 Observer 监听 kCFRunLoopEntry(优先级最高,保证在所有回调之前):调用 _objc_autoreleasePoolPush() 创建自动释放池。
  • 第二个 Observer 监听 kCFRunLoopBeforeWaiting(优先级最低,保证在所有回调之后):调用 _objc_autoreleasePoolPop() 释放旧池中的对象,再调用 _objc_autoreleasePoolPush() 创建新池。同时监听 kCFRunLoopExit:调用 _objc_autoreleasePoolPop() 做最终释放。

这意味着主线程上被 autorelease 的对象会在每次 RunLoop 循环即将休眠时被释放。

4.4.2 事件响应

硬件事件(触摸)传递链:

  1. 硬件产生中断 → IOKit.framework 封装为 IOHIDEvent。
  2. 通过 Mach Port 传递给 SpringBoard 进程。
  3. SpringBoard 判断前台 App,通过 Mach Port 传递给 App 进程。
  4. App 主线程 RunLoop 的 Source1 被唤醒,回调 __IOHIDEventSystemClientQueueCallback()
  5. Source1 内部触发 Source0(__UIApplicationHandleEventQueue())。
  6. Source0 中进行 Hit Test、手势识别、UIResponder 事件分发。

4.4.3 UI 刷新

setNeedsLayoutsetNeedsDisplay 等调用不会立即触发布局/绘制,而是标记为"需要更新"。主线程 RunLoop 注册了一个 Observer 监听 kCFRunLoopBeforeWaitingkCFRunLoopExit,在回调中遍历所有标记了需要更新的视图,执行实际的 layout、display、render 操作,最终打包提交给 Render Server。

这就是 Core Animation 的 Transaction 机制

4.4.4 GCD 与 RunLoop

当调用 dispatch_async(dispatch_get_main_queue(), block) 时,libDispatch 会唤醒主线程的 RunLoop(通过向 RunLoop 的 dispatch port 发送 Mach 消息),RunLoop 在循环中检测到 dispatch port 有消息后,会调用 _dispatch_main_queue_callback_4CF() 来执行 block。

4.4.5 performSelector:afterDelay:

performSelector:withObject:afterDelay: 实际上是创建了一个 Timer 添加到当前线程的 RunLoop 中。如果当前线程没有 RunLoop(子线程默认没有),这个方法不会执行。

4.5 RunLoop 的实际应用

  • 常驻子线程:为子线程创建 RunLoop 并添加一个永不触发的 Port(防止 RunLoop 因没有 Source/Timer 而退出),使线程常驻内存,随时可以接收任务。AFNetworking 2.x 和 SDWebImage 早期版本都使用过这个技巧。
  • NSTimer 滑动不停:将 Timer 添加到 NSRunLoopCommonModes
  • 卡顿监控:通过 Observer 监听 RunLoop 状态,检测主线程 Source 处理或休眠前等待是否超时(详见卡顿监控章节)。
  • 线程保活(Thread Keep-Alive):网络库中用于在子线程持续接收回调。
  • 任务拆分:将大量计算任务拆分成小块,每次 RunLoop 循环处理一块,避免长时间阻塞主线程(类似协程的思想)。

5. Runtime

5.1 Runtime 的本质

Objective-C Runtime 是一个用 C/C++/汇编编写的运行时库,它实现了 ObjC 的面向对象特性和动态性。ObjC 是一门动态语言——许多决定(调用哪个方法、对象是什么类型)被推迟到运行时。

核心思想:消息发送(Messaging)。ObjC 中的方法调用 [obj method] 会被编译器转换为 objc_msgSend(obj, @selector(method)),由 Runtime 在运行时查找并执行对应的实现。

5.2 对象模型

5.2.1 对象(id / objc_object)

每个 ObjC 对象本质上是一个结构体,其第一个成员是 isa 指针,指向该对象所属的类。

从 ARM64 开始,Apple 使用了 Tagged PointerNon-pointer ISA 优化:

Tagged Pointer:对于 NSNumberNSDate、短字符串等小对象,指针本身就直接存储了对象的值,不需要在堆上分配内存。判断方法:指针的最高位(ARM64)或最低位(x86_64)为 1 则是 Tagged Pointer。Tagged Pointer 不是真正的对象,没有 isa、没有 retain/release 开销,内存效率和访问速度极高。

Non-pointer ISA(优化的 isa):在 64 位系统上,isa 不再是单纯的类指针。64 位中只有 33~44 位用于存储类地址,其余位存储了:

  • 引用计数extra_rc,19 位,存储引用计数减 1 的值)。当 extra_rc 溢出时,将一半的引用计数转存到 SideTable 的 RefcountMap 中,has_sidetable_rc 标志位置 1。
  • 是否有关联对象has_assoc)。
  • 是否有 C++ 析构函数has_cxx_dtor)。
  • 是否使用了弱引用weakly_referenced)。
  • 是否正在释放deallocating)。

5.2.2 类(objc_class)

类也是一个对象(元类的实例),继承自 objc_object。关键成员:

  • isa:指向元类(metaclass)。
  • superclass:指向父类。
  • cache:方法缓存(cache_t),使用哈希表存储最近调用的方法,加速消息发送。
  • bits / class_rw_t
    • class_ro_t(Read-Only):编译期确定的只读数据——方法列表、属性列表、ivar 列表、协议列表、实例大小等。存储在 Mach-O 的 __DATA_CONST 段中。
    • class_rw_t(Read-Write):运行时创建的可读写数据,包含对 class_ro_t 的引用,以及运行时动态添加的方法、属性、协议列表。
    • class_rw_ext_t:iOS 14+ 优化,只有在类被运行时修改过(如添加了 Category、使用了 class_addMethod)时才会创建 class_rw_ext_t,约 90% 的类不需要,节省大量内存(Apple 称全系统节省约 14MB)。

5.2.3 元类(Metaclass)

  • 实例对象的 isa → 类对象。
  • 类对象的 isa → 元类对象。
  • 元类对象的 isa → 根元类(NSObject 的元类)。
  • 根元类的 isa → 自身。
  • 根元类的 superclass → NSObject 类。

这个链条解释了为什么实例方法存储在类中,类方法存储在元类中——消息发送总是沿着 isa 链查找方法。

5.3 消息发送机制(objc_msgSend)

5.3.1 快速查找(缓存查找)

objc_msgSend 是用汇编语言编写的(ARM64),追求极致性能。

执行流程:

  1. 判断 receiver 是否为 nil(Tagged Pointer 的特殊处理)。
  2. 通过 receiver 的 isa 找到类对象。
  3. 在类的 cache_t(方法缓存)中查找 SEL 对应的 IMP。cache_t 是一个开放寻址的哈希表,使用 SEL 的地址值做 mask 运算得到索引,查找效率接近 O(1)。
  4. 如果命中缓存(Cache Hit),直接跳转到 IMP 执行——整个过程几十纳秒,纯汇编实现。

5.3.2 慢速查找(方法列表查找)

缓存未命中时,进入 C/C++ 实现的 lookUpImpOrForward 函数:

  1. 在当前类的 class_rw_t 中搜索方法列表。方法列表已按 SEL 地址排序(在类 realize 时排序),使用二分查找,时间复杂度 O(log n)。
  2. 如果未找到,沿 superclass 链向上逐级查找父类的方法列表(每级都先查缓存再查方法列表)。
  3. 如果一直到 NSObject(根类)都未找到,进入消息转发流程。
  4. 如果找到了,将 SEL→IMP 的映射写入当前类的 cache_t(注意是写入最初接收消息的类的缓存,不是找到方法的那个父类的缓存)。

5.3.3 方法缓存(cache_t)的实现细节

  • 哈希表使用 掩码(mask) 而非取模,因为 mask 可以用位与运算(& mask)替代除法,更快。
  • 缓存容量始终是 2 的幂次,初始容量为 4(ARM64)。
  • 当缓存使用率超过 3/4(75%) 时,容量翻倍并清空所有旧缓存(而非 rehash),因为 Apple 认为缓存的时间局部性很强,旧缓存大概率不再需要。
  • 类在第一次收到消息时分配缓存。

5.4 消息转发机制(Message Forwarding)

当消息发送的快速查找和慢速查找都未找到方法实现时,进入消息转发的三个阶段:

5.4.1 第一阶段:动态方法解析(Dynamic Method Resolution)

Runtime 调用:

  • 实例方法:+resolveInstanceMethod:
  • 类方法:+resolveClassMethod:

在这个方法中,类有机会动态地为 SEL 添加一个 IMP(通过 class_addMethod)。如果返回 YES 且添加了方法,Runtime 会重新执行消息发送流程。

应用场景:@dynamic 属性的实现、Core Data 的 NSManagedObject 动态生成属性的 getter/setter。

5.4.2 第二阶段:快速转发(Fast Forwarding / Forwarding Target)

Runtime 调用 -forwardingTargetForSelector:

在这个方法中,可以返回另一个对象来处理这条消息(消息转发给备用接收者)。这一步效率很高,因为直接对新对象执行 objc_msgSend,不需要创建 NSInvocation

应用场景:多重代理(将消息转发给多个对象)、组合模式的简化实现。

5.4.3 第三阶段:完整转发(Normal Forwarding)

Runtime 依次调用:

  1. -methodSignatureForSelector::返回方法的类型签名(NSMethodSignature),描述参数类型和返回值类型。
  2. -forwardInvocation::接收一个封装了完整调用信息的 NSInvocation 对象,可以修改目标、参数、甚至调用多次。

这是最灵活但最慢的阶段,NSInvocation 的创建涉及堆分配和参数拷贝。

如果以上三个阶段都未处理,最终调用 -doesNotRecognizeSelector:,抛出经典的 "unrecognized selector sent to instance" 异常。

5.5 Method Swizzling

通过 Runtime 函数交换两个方法的 IMP,实现 AOP(面向切面编程)。

核心 API:

  • method_exchangeImplementations:交换两个 Method 的 IMP。
  • class_replaceMethod:替换某个 SEL 的 IMP。
  • method_setImplementation:设置某个 Method 的 IMP。

陷阱与最佳实践

  • 必须在 +load 中执行(或用 dispatch_once 保证只执行一次),避免竞态条件。
  • 必须调用原始实现:Swizzle 后的方法中要调用"看似递归实际不是"的原始方法(因为 IMP 已经交换了)。
  • 父类方法问题:如果当前类没有实现目标方法(继承自父类),直接交换会影响父类。正确做法是先 class_addMethod 尝试添加,成功则只需 class_replaceMethod 替换父类的实现到当前类的新 SEL,失败(说明当前类已有实现)才 method_exchangeImplementations
  • _cmd 问题:Swizzle 后方法内部的 _cmd 值是交换后的 SEL,可能导致日志、KVO 等依赖 _cmd 的逻辑出错。

5.6 关联对象(Associated Objects)

通过 objc_setAssociatedObject / objc_getAssociatedObject 为已存在的类动态添加"属性"(实际是绑定的键值对)。

内部存储结构

全局维护一个 AssociationsManager(自带锁),内部是一个 AssociationsHashMap

AssociationsHashMap: { 对象地址(disguised_ptr_t) → ObjectAssociationMap }
ObjectAssociationMap: { key(const void*) → ObjcAssociation(policy + value) }
  • 关联对象不存储在对象本身的内存中,而是存储在全局的哈希表中,以对象地址为 key。
  • 对象销毁时(dealloc),Runtime 检查 isa 的 has_assoc 标志位,如果为 1,则调用 _object_remove_associations() 清除该对象的所有关联对象。
  • 关联策略:OBJC_ASSOCIATION_ASSIGN(弱引用)、OBJC_ASSOCIATION_RETAIN_NONATOMIC(强引用,非原子)、OBJC_ASSOCIATION_COPY_NONATOMIC(拷贝)等,语义与 property 属性一致。

5.7 Category 的实现原理

Category 在编译后生成 category_t 结构体,包含:方法列表、属性列表、协议列表(但没有 ivar 列表,这就是 Category 不能添加实例变量的原因——实例变量列表在编译期确定,存储在 class_ro_t 中,不可修改)。

加载过程

  1. map_images 阶段,Runtime 遍历所有镜像的 __objc_catlist section,收集所有 Category。
  2. 调用 attachCategories() 将 Category 的方法列表倒序插入到类的方法列表数组的前面(使用 attachListsATTACH_EXISTING 方式)。
  3. 因此,后编译的 Category 的方法会排在最前面,最先被找到——这就是 Category "覆盖"原类方法的真相(原方法仍然存在,只是排在后面不会被优先找到)。

多个 Category 有同名方法时:取决于编译顺序(Build Phases → Compile Sources 中的文件顺序),最后编译的 Category 的方法排在最前面。

5.8 Weak 引用的实现

全局 Weak 表:Runtime 维护一个全局的 SideTable(实际上是一个 StripedMap,包含 64 个 SideTable 以减少锁竞争),每个 SideTable 包含:

  • spinlock_t:自旋锁,保护并发访问。
  • RefcountMap:存储对象的额外引用计数(extra_rc 溢出时使用)。
  • weak_table_t:弱引用表,核心结构。

weak_table_t 是一个哈希表,以对象地址为 key,value 是 weak_entry_t,包含所有指向该对象的 weak 指针的地址。

weak 指针的赋值过程

  1. 调用 objc_initWeak()(或 objc_storeWeak())。
  2. 如果旧值非 nil,从旧对象的 weak_entry_t 中移除该 weak 指针。
  3. 如果新值非 nil,将该 weak 指针注册到新对象的 weak_entry_t 中。

对象销毁时清除 weak 引用

  1. dealloc_objc_rootDeallocrootDeallocobject_disposeobjc_destructInstance
  2. objc_destructInstance 中:清除关联对象 → 清除弱引用(weak_clear_no_lock)→ 清除 SideTable 引用计数。
  3. weak_clear_no_lock:遍历对象的 weak_entry_t 中所有 weak 指针地址,将它们全部置为 nil。

这就是 weak 指针在对象销毁后自动变为 nil 的底层机制。

5.9 KVO 的底层实现

KVO(Key-Value Observing)完全依赖 Runtime 实现:

  1. 当对某个对象的属性添加 KVO 观察时,Runtime 动态创建一个该对象所属类的子类(命名为 NSKVONotifying_OriginalClass)。
  2. 将对象的 isa 指向这个动态子类(isa swizzling)。
  3. 动态子类重写了被观察属性的 setter 方法,在 setter 中插入:
    • willChangeValueForKey: → 调用原始 setter → didChangeValueForKey:
    • didChangeValueForKey: 内部触发 observeValueForKeyPath:ofObject:change:context: 回调。
  4. 动态子类还重写了 class 方法(返回原类而非 NSKVONotifying_ 前缀的子类,对外隐藏 KVO 的实现细节),以及 dealloc(清理观察)和 _isKVOA(标识 KVO 类)。

6. 卡顿监控

6.1 卡顿的定义与原理

iOS 设备的屏幕刷新率通常为 60Hz(ProMotion 设备最高 120Hz),意味着每帧的渲染时间预算为 16.67ms(60fps)或 8.33ms(120fps)。如果主线程在一帧的时间内未完成 UI 更新的所有工作(布局计算、绘制、图层合成提交),就会导致掉帧(Frame Drop),用户感知为卡顿。

渲染流水线(Render Pipeline):

App 进程(CPU)                      Render Server(GPU)
┌─────────────────┐                  ┌──────────────────┐
│ Layout          │                  │ 图层树解码       │
│ Display (Draw)  │ ──Commit──────→  │ 纹理上传         │
│ Prepare         │   Transaction    │ 合成渲染         │
│ Commit          │                  │ 显示             │
└─────────────────┘                  └──────────────────┘
        ← 一帧 16.67ms →                 ← 一帧 16.67ms →

CPU 和 GPU 是流水线式工作的。CPU 在当前帧完成布局和绘制后提交给 GPU,GPU 在下一帧完成合成渲染。任一环节超时都会导致掉帧。

6.2 卡顿的常见原因

CPU 侧

  • 复杂布局计算:Auto Layout 的约束求解是多项式时间复杂度,视图层级深、约束多时开销显著。
  • 文本计算与渲染NSAttributedString 的排版(Text Kit / Core Text)、行高计算、折行计算。
  • 图片解码UIImage 在首次渲染时才进行解码(从 PNG/JPEG 压缩格式解码为位图),大图的解码可能耗时数十毫秒。
  • 对象创建与销毁:大量对象的 alloc/dealloc(尤其涉及 ARC 的 retain/release 操作和 SideTable 锁竞争)。
  • 数据库/文件 I/O:主线程同步读写磁盘。
  • 锁等待:主线程等待子线程持有的锁。

GPU 侧

  • 离屏渲染(Offscreen Rendering)cornerRadius + masksToBoundsshadowmaskgroup opacity 等会触发离屏渲染,GPU 需要额外创建帧缓冲区。
  • 过度绘制(Overdraw):大量重叠的不透明图层导致 GPU 重复渲染。
  • 大图纹理:超大图片上传到 GPU 的纹理缓存,占用大量显存和带宽。
  • 图层爆炸:大量 CALayer 导致合成开销增大。

6.3 卡顿监控方案

6.3.1 方案一:RunLoop Observer 监控

原理:主线程的所有任务都在 RunLoop 中执行。通过监听 RunLoop 的状态变化,检测两个关键时间间隔:

  • kCFRunLoopBeforeSources 到 kCFRunLoopBeforeWaiting(Source 处理阶段):如果这个间隔过长,说明 Source0 事件处理耗时过久(如触摸事件处理中有耗时操作)。
  • kCFRunLoopAfterWaiting 到下一次 kCFRunLoopBeforeWaiting(被唤醒后的处理阶段):如果这个间隔过长,说明被唤醒后的任务处理耗时过久。

实现思路

  1. 在主线程注册一个 CFRunLoopObserver,监听所有状态变化。
  2. 在 Observer 回调中记录状态变化的时间戳和当前状态。
  3. 创建一个子线程,用信号量(dispatch_semaphore)定期检测(如每 50ms 一次)主线程 RunLoop 是否长时间停留在某个状态。
  4. 如果连续多次(如 3 次)检测到主线程处于同一个状态超过阈值(如 250ms),判定为卡顿。
  5. 在子线程中抓取主线程的调用堆栈。

卡顿判定策略

  • 超过 1 帧(16ms):微卡顿,通常不记录。
  • 超过 3 帧(50ms):轻微卡顿。
  • 超过 250ms:明显卡顿,需要记录堆栈。
  • 超过 3s:严重卡顿(ANR),需要立即上报。

6.3.2 方案二:子线程 Ping(心跳检测)

原理:子线程定期向主线程发送一个"心跳"任务(通过 dispatch_async 派发到主队列),如果主线程在规定时间内未能执行该任务,则认为主线程被阻塞。

实现思路

  1. 子线程设置一个 flag 为 false,通过 dispatch_async(dispatch_get_main_queue(), ^{ flag = true; }) 发送心跳。
  2. 子线程等待一段时间(如 500ms 或 1s)。
  3. 检查 flag:如果仍为 false,说明主线程在此期间一直忙碌,判定为卡顿。
  4. 抓取主线程堆栈。

优缺点比较

  • RunLoop Observer 方案更精确,能定位到具体的 RunLoop 阶段,但实现复杂。
  • 心跳检测方案简单可靠,但只能检测到"主线程忙",无法区分是哪种任务导致的。

6.3.3 方案三:CADisplayLink 帧率监控

利用 CADisplayLink 的回调计算实际帧率。CADisplayLink 会在每次屏幕刷新前调用回调,如果两次回调的间隔超过 16.67ms,说明发生了掉帧。

局限性:只能检测掉帧的发生和严重程度,无法直接获取卡顿原因的堆栈信息。通常作为辅助监控手段,与上述方案配合使用。

6.3.4 方案四:基于 MetricKit(iOS 14+)

MXHangDiagnostic 提供系统级别的卡顿诊断信息,包括卡顿时长和调用堆栈。MXCPUExceptionDiagnostic 报告 CPU 异常使用情况。

优点是零性能开销(系统在后台采集),缺点是数据延迟(次日推送),适合线上监控而非实时调试。

6.4 堆栈采集

卡顿检测到后,最关键的是采集主线程的调用堆栈,用于定位卡顿的根因。

6.4.1 基于 mach_thread API

使用 task_threads() 获取所有线程列表,通过 thread_get_state() 获取目标线程(主线程)的寄存器状态(包含 PC、FP、LR 等),然后沿着 Frame Pointer(FP)链回溯调用栈,结合 DWARF 调试信息或 dSYM 文件符号化。

6.4.2 基于 backtrace() / backtrace_symbols()

标准 POSIX 接口,但只能获取当前线程的堆栈,无法跨线程采集。

6.4.3 基于 PLCrashReporter

开源的崩溃报告库,提供了安全的跨线程堆栈采集能力(信号安全、锁安全),是业界常用方案。

6.5 堆栈聚合与分析

  • 调用树合并:将多次采集的堆栈按调用路径合并成火焰图/调用树,识别热点函数。
  • 符号化:将内存地址转换为函数名+偏移量,需要对应版本的 dSYM 文件。使用 atos 命令或 dwarfdump 工具。
  • 去噪:过滤系统框架的堆栈帧(如 CFRunLoopRunSpecificmach_msg_trap),聚焦业务代码。

6.6 治理策略

  • 文本异步计算:使用 NSAttributedStringboundingRectWithSize: 在子线程预计算文本高度。
  • 图片异步解码:在子线程用 CGBitmapContextCreate + CGContextDrawImage 强制解码图片,主线程直接使用解码后的位图。
  • 预排版/预计算:Cell 的高度、布局信息在数据到达时在子线程预计算完成,主线程直接使用。
  • 按需加载:屏幕外的 Cell 不进行复杂渲染。
  • 减少离屏渲染:用 UIBezierPath + CAShapeLayer 替代 cornerRadius + masksToBounds;用 shadowPath 替代自动计算的阴影。
  • 异步绘制:使用 drawRect: 在后台线程绘制位图,再赋值给 CALayer.contents(参考 Texture/AsyncDisplayKit 框架的思想)。

7. AFNetworking

7.1 整体架构

AFNetworking 是 iOS/macOS 上最流行的网络库。目前主流版本为 AFNetworking 4.x,完全基于 NSURLSession(3.x 开始移除了 NSURLConnection 支持)。

核心架构分层:

┌────────────────────────────────────────────┐
│           AFHTTPSessionManager            │  ← 最高层:便捷 HTTP 接口
│     (GET/POST/PUT/DELETE 等快捷方法)       │
├────────────────────────────────────────────┤
│           AFURLSessionManager             │  ← 核心层:Session 管理
│   (NSURLSession delegate 的完整实现)       │
├────────────────────────────────────────────┤
│  AFURLRequestSerialization                │  ← 请求序列化
│  (HTTP/JSON/PropertyList Request)         │
├────────────────────────────────────────────┤
│  AFURLResponseSerialization               │  ← 响应反序列化
│  (HTTP/JSON/XML/Image/PropertyList)       │
├────────────────────────────────────────────┤
│  AFSecurityPolicy                         │  ← 安全策略(HTTPS/证书验证)
├────────────────────────────────────────────┤
│  AFNetworkReachabilityManager             │  ← 网络状态监听
└────────────────────────────────────────────┘

7.2 AFURLSessionManager 深入解析

7.2.1 核心职责

AFURLSessionManager 是整个库的心脏,它:

  • 持有并管理一个 NSURLSession 实例。
  • 实现了 NSURLSessionDelegateNSURLSessionTaskDelegateNSURLSessionDataDelegateNSURLSessionDownloadDelegate 四个协议的所有关键方法。
  • 维护一个 mutableTaskDelegatesKeyedByTaskIdentifier 字典,将每个 NSURLSessionTask 映射到一个 AFURLSessionManagerTaskDelegate 对象,实现任务级别的回调隔离。

7.2.2 线程安全设计

  • 使用 NSLock(名为 lock)保护 mutableTaskDelegatesKeyedByTaskIdentifier 字典的并发访问。
  • NSURLSession 的 delegate 回调在一个专用的串行 OperationQueueoperationQueue.maxConcurrentOperationCount = 1)上执行,保证回调的串行化,避免多线程问题。
  • 完成回调(success/failure block)默认 dispatch 到主队列(completionQueue 默认为 dispatch_get_main_queue()),保证 UI 更新的线程安全。开发者也可以自定义 completionQueuecompletionGroup

7.2.3 任务代理(AFURLSessionManagerTaskDelegate)

每个 NSURLSessionTask 对应一个 AFURLSessionManagerTaskDelegate 实例,它负责:

  • 收集响应数据:在 URLSession:dataTask:didReceiveData: 中将接收到的数据追加到 mutableData 中。
  • 跟踪上传/下载进度:通过 NSProgress 对象提供 KVO 兼容的进度更新。
  • 任务完成时:根据 responseSerializer 反序列化响应数据,在 completionQueue 上回调 success/failure block。

7.2.4 KVO 与通知机制

AFNetworking 大量使用了 KVO 和 NSNotification:

  • NSURLSessionTaskstate 属性进行 KVO 观察,当任务状态变为 completed 时自动清理。
  • 任务 resume/suspend/complete 时发送全局通知(如 AFNetworkingTaskDidResumeNotification),方便外部监听(如网络活动指示器 AFNetworkActivityIndicatorManager)。
  • 使用 Method Swizzling 交换了 NSURLSessionTaskresumesuspend 方法,在调用时发送通知。这是因为 NSURLSession 不对 task 的 state 变化发送 KVO 通知,AF 需要自己实现。

7.3 请求序列化(AFURLRequestSerialization)

7.3.1 AFHTTPRequestSerializer

基础的 HTTP 请求序列化器:

  • 设置通用 HTTP Header(User-Agent、Accept-Language、Authorization 等)。
  • 将参数字典编码为 URL query string(GET/HEAD/DELETE)或 HTTP body(POST/PUT/PATCH)。
  • 参数编码规则:对键值对进行百分号编码(Percent Encoding),嵌套字典和数组使用方括号语法(key[subkey]=valuekey[]=value)。
  • multipartFormData:支持 multipart/form-data 编码,用于文件上传。内部使用 AFMultipartBodyStream(自定义的 NSInputStream 子类)实现流式上传,避免将整个文件载入内存。

7.3.2 AFJSONRequestSerializer

继承自 AFHTTPRequestSerializer,将参数字典使用 NSJSONSerialization 编码为 JSON 格式放入 HTTP Body,设置 Content-Typeapplication/json

7.4 响应序列化(AFURLResponseSerialization)

响应序列化器负责验证响应的合法性并将数据转换为目标格式。

7.4.1 验证机制

所有序列化器都继承自 AFHTTPResponseSerializer,它的 validateResponse:data:error: 方法检查:

  • HTTP 状态码是否在 acceptableStatusCodes(默认 200~299)范围内。
  • 响应的 Content-Type 是否在 acceptableContentTypes 集合中。

如果验证失败,生成对应的 NSErrorAFURLResponseSerializationErrorDomain),并将响应数据放入 error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey] 中,方便调试。

7.4.2 AFJSONResponseSerializer

使用 NSJSONSerialization 将 Data 解析为字典/数组。支持自动移除 JSON 中的 NSNull 值(removesKeysWithNullValues 属性)。

7.4.3 AFImageResponseSerializer

将 Data 解码为 UIImage。支持自动解压(inflate)——在子线程强制解码图片位图,避免在主线程首次渲染时的解码开销(与 SDWebImage 的思路一致)。

7.5 安全策略(AFSecurityPolicy)

7.5.1 三种验证模式

模式 说明 安全级别
AFSSLPinningModeNone 使用系统默认的证书链验证
AFSSLPinningModeCertificate 将服务器证书与 App 内预埋的证书进行完整比对 最高
AFSSLPinningModePublicKey 只比对证书中的公钥(Public Key) 高(推荐)

7.5.2 证书验证流程

  1. 获取服务器返回的证书链(SecTrustRef)。
  2. 设置锚点证书(Anchor Certificates)为 App 预埋的证书。
  3. 调用 SecTrustEvaluateWithError() 进行系统级证书链验证。
  4. 根据 Pinning Mode:
    • Certificate Mode:逐一比对证书的 DER 编码数据。
    • PublicKey Mode:提取证书的公钥数据进行比对。
  5. validatesDomainName:是否验证证书中的域名与请求域名匹配。

7.5.3 公钥固定的优势

比证书固定更灵活——即使服务器更换了证书(只要使用相同的密钥对),App 无需更新。

7.6 网络可达性(AFNetworkReachabilityManager)

基于 SCNetworkReachability(SystemConfiguration 框架),监听网络状态变化。

核心流程:

  1. 使用 SCNetworkReachabilityCreateWithAddressSCNetworkReachabilityCreateWithName 创建 reachability 引用。
  2. 设置回调函数,当网络状态变化时触发。
  3. 将 reachability 引用加入 RunLoop(CFRunLoopGetMain())以持续监听。
  4. 回调中解析 SCNetworkReachabilityFlags,判断:
    • 是否可达(kSCNetworkReachabilityFlagsReachable)。
    • 是否通过 WWAN(kSCNetworkReachabilityFlagsIsWWAN)。

注意:SCNetworkReachability 检测的是"是否有网络路径",不是"是否能真正连通互联网"。飞行模式能检测到,但连上 WiFi 但无法上网的情况检测不到。

7.7 与 Alamofire 的对比

Alamofire 是 AFNetworking 作者在 Swift 生态下的重写,核心思想一致但做了现代化改进:

对比维度 AFNetworking Alamofire
语言 Objective-C Swift
并发模型 GCD + NSOperationQueue Swift Concurrency (async/await)
请求构建 Mutable URL Request 链式调用 + Request 协议
响应处理 Block 回调 Result + async/await
拦截器 需自行封装 内置 RequestInterceptor 协议
重试 需自行实现 内置 RetryPolicy

8. SDWebImage

8.1 整体架构

SDWebImage 是 iOS 上最广泛使用的图片加载和缓存库。其核心设计哲学是将复杂的图片加载流程封装为简洁的 API(如 sd_setImageWithURL:),同时提供高度可定制的扩展点。

架构分层:

┌──────────────────────────────────────────────────┐
│              UIView+WebCache                     │  ← 最上层:UIKit 扩展
│  (UIImageView / UIButton 的分类方法)              │
├──────────────────────────────────────────────────┤
│              SDWebImageManager                   │  ← 核心调度器
│  (协调缓存查找和网络下载)                          │
├──────────────┬───────────────────────────────────┤
│ SDImageCache │  SDWebImageDownloader             │  ← 缓存 / 下载
│ (内存+磁盘)   │  (网络下载管理)                    │
├──────────────┴───────────────────────────────────┤
│ SDWebImageDownloaderOperation                    │  ← 下载操作
│ (基于 NSURLSessionDataTask 的下载单元)             │
├──────────────────────────────────────────────────┤
│ SDImageCoder / SDImageTransformer                │  ← 编解码 / 变换
│ (PNG/JPEG/GIF/WebP/HEIF 编解码, 圆角/缩放等)      │
└──────────────────────────────────────────────────┘

8.2 加载流程全景

当调用 [imageView sd_setImageWithURL:url] 时,完整的执行流程:

Step 1:取消旧任务 取消该 UIImageView 上一次尚未完成的图片加载任务(通过关联对象存储的 operation key)。这避免了 Cell 复用场景下的图片错乱问题。

Step 2:设置占位图 如果提供了 placeholder,立即在主线程设置占位图。

Step 3:查询缓存 SDWebImageManager 调用 SDImageCache 查询缓存:

  • 内存缓存查询SDMemoryCache(基于 NSCache)中以 URL 的 MD5/SHA256 哈希为 key 查找。命中则直接返回。
  • 磁盘缓存查询:如果内存未命中,在串行 I/O 队列ioQueue)中异步查询磁盘缓存。磁盘缓存使用文件存储,文件名为 URL 的 MD5 哈希值。查询过程包括:
    1. 检查文件是否存在(fileExistsAtPath:)。
    2. 读取文件数据。
    3. 对图片进行解码(从 PNG/JPEG 数据解码为位图)。
    4. 将解码后的图片写入内存缓存(回填)。

Step 4:网络下载 如果缓存完全未命中(或设置了 SDWebImageRefreshCached 选项),启动网络下载:

  • SDWebImageDownloader 创建或复用一个 SDWebImageDownloaderOperation
  • 同一个 URL 的多次请求会被合并(Coalescing)——只发一次网络请求,结果回调给所有等待者。这通过 URLOperations 字典(以 URL 为 key)实现。
  • 下载操作基于 NSURLSessionDataTask

Step 5:图片处理 下载完成后:

  1. 在子线程进行图片解码(Decode)。
  2. 如果设置了 SDImageTransformer(如圆角、缩放、高斯模糊),在子线程执行变换。
  3. 将处理后的图片同时写入内存缓存和磁盘缓存。

Step 6:回调主线程 在主线程设置 imageView.image,触发 UI 更新。支持渐变动画(SDWebImageTransition)。

8.3 缓存机制深入解析

8.3.1 内存缓存(SDMemoryCache)

继承自 NSCache,具备以下特性:

  • 自动淘汰:当系统内存紧张时,NSCache 会自动释放对象。开发者可以设置 countLimit(最大数量)和 totalCostLimit(最大总开销,以图片像素数为 cost)。
  • 线程安全NSCache 内部使用锁保护,可以在任意线程安全访问。
  • 弱引用表(mapTable):SDWebImage 额外维护了一个 NSMapTable(weakToStrongObjects),当 NSCache 因内存压力淘汰了某张图片时,如果该图片仍被某个 UIImageView 持有(强引用),通过 mapTable 仍然可以找到它,避免不必要的重新解码/下载。

8.3.2 磁盘缓存(SDDiskCache)

  • 存储格式:原始的图片数据(未解码的 PNG/JPEG/WebP 数据),不是解码后的位图。这大幅减少了磁盘空间占用。
  • 文件命名:URL 的 MD5 哈希值作为文件名,避免特殊字符问题。
  • 过期策略:默认缓存保留 1 周maxDiskAge = 60 * 60 * 24 * 7)。
  • 容量限制:可设置 maxDiskSize(最大磁盘缓存大小),超限时按最近最久未使用(LRU) 策略淘汰——根据文件的 NSFileModificationDate(修改日期)排序,优先删除最旧的文件,直到缓存大小降至限制的一半。
  • 清理时机
    • App 进入后台时(UIApplicationDidEnterBackgroundNotification)触发异步清理。
    • App 终止时(UIApplicationWillTerminateNotification)触发清理。
    • 开发者手动调用 clearDiskOnCompletion:

8.3.3 缓存 Key 的计算

默认使用完整的 URL 字符串作为缓存 key。开发者可以通过 SDWebImageManagercacheKeyFilter block 自定义 key 生成逻辑(例如去除 URL 中的 token 参数,使相同内容的不同签名 URL 共享缓存)。

如果使用了 SDImageTransformer,变换后的图片使用 originalKey + transformerKey 作为缓存 key,与原图分开缓存。

8.4 图片解码机制

8.4.1 为什么需要预解码

UIImageimageWithData: 创建的图片是未解码的——它只是持有压缩的图片数据。只有在图片首次被渲染到屏幕上时(CALayerdisplay 方法中),Core Animation 才会调用解码器将其解码为位图。这个解码发生在主线程,可能导致掉帧。

SDWebImage 的策略是在子线程提前解码(Force Decode / Decompressing),将位图缓存到内存中,主线程直接使用解码后的位图,消除主线程解码开销。

8.4.2 解码实现

解码的核心步骤:

  1. 创建 CGBitmapContext(位图上下文),指定颜色空间、每像素字节数、Alpha 通道信息。
  2. 使用 CGContextDrawImageCGImageRef 绘制到上下文中——这一步触发实际的解码。
  3. 从上下文中获取解码后的 CGImageRef,创建新的 UIImage

内存占用计算:一张 1000×1000 的图片解码后占用 1000 × 1000 × 4 bytes = 4MB(RGBA 格式,每像素 4 字节)。因此,SDWebImage 提供了 SDImageCoderDecodeScaleDownLimitBytes 选项,对超大图片进行降采样后再解码,避免内存暴涨。

8.4.3 渐进式解码(Progressive Decoding)

对于 JPEG 等支持渐进式加载的格式,SDWebImage 可以在下载过程中边下载边解码。每接收一段数据就解码一次,UI 上展示从模糊到清晰的渐进效果。

通过 SDImageCoderProgressiveCoder 协议实现,每次调用 updateIncrementalData:finished: 更新数据并产生部分解码的图片。

8.4.4 编解码器架构(SDImageCoder)

SDWebImage 5.x 使用了协议化的编解码器架构:

  • SDImageCoder 协议定义了 canDecodeFromData:decodedImageWithData:encodedDataWithImage: 等方法。
  • 内置编解码器:SDImageIOCoder(PNG/JPEG/TIFF/GIF 静图)、SDImageGIFCoder(GIF 动图)、SDImageAPNGCoder(APNG)。
  • 可扩展:通过 SDImageCodersManager 注册自定义编解码器,如 SDImageWebPCoder(WebP 支持)、SDImageHEICCoder(HEIC 支持)。
  • 解码器按注册的逆序遍历(后注册的优先),调用 canDecodeFromData: 判断哪个解码器能处理当前数据格式。

8.5 下载机制深入

8.5.1 SDWebImageDownloader

  • 维护一个 NSOperationQueuedownloadQueue),控制最大并发下载数(默认 6)。
  • 支持 LIFO(后进先出)和 FIFO(先进先出)两种执行顺序。LIFO 适合瀑布流场景——用户快速滑动时,最新可见的 Cell 的图片优先下载。通过设置 Operation 之间的依赖关系实现 LIFO。
  • 支持 HTTP Header 自定义、认证(URLCredential)、超时配置等。

8.5.2 SDWebImageDownloaderOperation

继承自 NSOperation,内部封装了一个 NSURLSessionDataTask

关键设计:

  • 回调合并:使用 callbackBlocks 数组存储所有对同一 URL 的下载回调。当下载完成时,遍历数组逐一回调。
  • 后台下载:支持 App 进入后台后继续下载(通过 UIApplication.beginBackgroundTaskWithExpirationHandler:)。
  • 响应数据拼接:在 URLSession:dataTask:didReceiveData: 中将数据追加到 NSMutableDataimageData),下载完成后一次性交给解码器。
  • 取消机制:调用 cancel 时取消 NSURLSessionDataTask,从 callbackBlocks 中移除对应的回调。如果所有回调都被移除,则取消整个下载任务。

8.5.3 URL 请求去重(Coalescing)

SDWebImageDownloader 维护一个 URLOperations 字典(以 URL 为 key,以 SDWebImageDownloaderOperation 为 value)。当新请求到来时:

  • 如果该 URL 已有进行中的下载操作,直接将新的回调添加到现有 Operation 的 callbackBlocks 中,不创建新的网络请求。
  • 如果没有,创建新的 Operation 并加入队列。

这种设计在列表场景下极为高效——同一张头像被多个 Cell 引用时,只会发起一次网络请求。

8.6 UIView+WebCache 的设计

通过 ObjC Runtime 的关联对象机制,为 UIImageView 等视图绑定当前的加载操作。

核心流程:

  1. 调用 sd_setImageWithURL: 时,先通过 sd_cancelCurrentImageLoad 取消当前关联的旧操作。
  2. 使用 objc_setAssociatedObject 将新的 SDWebImageCombinedOperation 关联到视图上。
  3. 加载完成或 Cell 复用时,通过 objc_getAssociatedObject 获取并取消/检查操作状态。

这解决了经典的 Cell 复用导致图片错乱问题:当 Cell 被复用时,旧 Cell 的下载完成回调中设置的图片会被忽略(因为旧操作已被取消)。

8.7 动图支持

8.7.1 GIF / APNG

SDWebImage 使用 SDAnimatedImageView(继承自 UIImageView)播放动图。其内部实现:

  • 使用 CADisplayLink 驱动动画帧切换。
  • 按需解码:不一次性解码所有帧(一个 GIF 可能有数百帧,全部解码会占用大量内存),而是维护一个帧缓存(NSMutableDictionary),预解码当前帧附近的若干帧(预取缓冲区),按需释放远离当前播放位置的帧。
  • 帧缓冲区大小根据可用内存动态调整。

8.7.2 WebP / HEIF

通过可插拔的编解码器支持:

  • SDImageWebPCoder:使用 libwebp 库进行 WebP 编解码。
  • SDImageHEICCoder:使用系统 ImageIO 框架进行 HEIF 编解码(iOS 11+)。

8.8 性能优化细节

  • 异步 I/O:磁盘缓存的所有读写操作都在专用的串行 ioQueue 上异步执行,不阻塞主线程。
  • 解码降采样:对于超大图片(如 4000×3000 的相机照片),先使用 CGImageSourceCreateThumbnailAtIndex 进行降采样到目标显示尺寸,再解码。这比先解码再缩放效率高得多——直接操作压缩数据,内存峰值大幅降低。
  • 内存警告响应:监听 UIApplicationDidReceiveMemoryWarningNotification,立即清空内存缓存(NSCacheremoveAllObjects)。
  • URL 黑名单:对于下载失败的 URL(非超时错误),加入 failedURLs 集合,短期内不再重试,避免无效请求浪费资源(可通过 SDWebImageRetryFailed 选项关闭此行为)。
  • Prefetch(预加载)SDWebImagePrefetcher 支持批量预加载图片到缓存中,适用于已知用户即将浏览的内容(如下一页的列表数据)。

8.9 SDWebImage 5.x 的架构升级

SDWebImage 5.x 相比 4.x 做了大量架构优化:

特性 4.x 5.x
编解码 硬编码在内部 协议化(SDImageCoder)
缓存 固定实现 协议化(SDImageCache Protocol)
下载 固定实现 协议化(SDImageLoader Protocol)
变换 需第三方库 内置 SDImageTransformer
动图 FLAnimatedImage 依赖 内置 SDAnimatedImage
指标 SDImageLoadIndicator

协议化设计使得每个组件都可以被替换为自定义实现,极大提升了灵活性。


总结

上述八个知识点构成了 iOS 开发中性能优化与底层原理的核心体系:

  • 启动流程启动优化帮助我们理解 App 从点击图标到用户可见的完整链路,并从 pre-main 和 post-main 两个阶段系统性地优化启动速度。
  • 网络优化覆盖了从 DNS 到数据传输、从连接管理到弱网对抗的全链路优化策略。
  • RunLoop 是 iOS 事件驱动模型的基石,理解它才能理解触摸事件、Timer、UI 刷新等核心机制的运作方式。
  • Runtime 是 Objective-C 动态性的根基,消息发送、方法缓存、消息转发、KVO、Category 等特性都建立在它之上。
  • 卡顿监控将 RunLoop 和性能分析结合,提供了从检测到治理的完整方案。
  • AFNetworkingSDWebImage 作为两个最经典的第三方库,它们的架构设计、线程安全策略、性能优化思路值得深入学习和借鉴。

移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据

近期,由小红书联合多伦多大学等高校的研究人员发布了 《SWE-Bench Mobile》(2602.09540) 论文,内容主要是评估 LLM 智能体在处理真实生产级移动端应用开发任务时的能力,并提出了首个针对该领域的基准测试——SWE-Bench Mobile

这个论文对比之前那些简单的需求场景,明显更具备说服力,最重要的是,用真实的数据给目前的 AI 狂热浇一浇冷水

目前的编程基准测试大多集中在孤立的算法问题,而 SWE-Bench 则是关注 GitHub 上的 Bug 修复,然而真实的工业级移动端开发汪汪更为复杂:

  • 多模态输入:开发者需要根据产品需求文档(PRD)和 Figma 设计稿等来写代码
  • 复杂的工程环境:中大厂的移动端代码库通常规模巨大( 5GB 以上),且涉及 Swift 与 Objective-C 混编、特定系统 API 及复杂的 UI 交互,还有编译环境影响
  • 任务类型多样化:不限于 Bug 修复,更多是功能开发和 UI 增强

所以研究团队从目前小红书自己的真实产品流水线中提取了 50 个具有代表性的开发任务,构建了该基准测试:

  • 数据集组成

    • 50 个真实任务:源自实际的产品需求
    • 449 个人工验证的测试用例:平均每个任务 9.1 个测试点,用于评估功能正确性
    • 多模态支持:70% 的任务附带 Figma 设计链接,92% 附带参考图
  • 代码库规模:基于约 5GB 大小的真实 iOS 生产代码库(Swift/Objective-C)

  • 任务复杂度:平均每个任务涉及修改 4.2 个文件,远超之前的基准测试

整个基准的规则是:

  • 70% 任务包含 Figma
  • 92% 包含参考图片
  • 平均 PRD 长度 450 字

每个任务包含:

  • 一个统一 diff 补丁(patch)输出
  • 综合测试套件(平均 9.1 个测试案例)
  • 任务难度分级:从简单 UI 调整到复杂跨模块改造

对于任务两个关键指标:

  • 任务成功率:所有测试通过的任务比例
  • 测试通过率:所有测试案例通过的比率

而对于 LLM,论文评估了 22 种 不同的“智能体-模型”配置,涵盖了四个主流框架:

  • 商业智能体:Cursor、Codex (由 DeepSeek/OpenAI 等模型驱动)、Claude Code
  • 开源智能体:OpenCode

评估维度包括:任务完成率、任务复杂度影响、成本效果对比、多次运行稳定性、Prompt 设计影响等。

而根据论文可以得出结论:当前 AI 在生产级的软件工程力存在巨大局限性:

  • 成功率极低表现最好配置的成功率仅为 12% ,大多数任务以“实现不完整”告终,但测试通过率最高可到 28%,说明部分任务可以部分正确生成,但没能完全部署成功
  • 智能体架构十分重要 :同一个底层模型,在 Cursor 框架下的成功率为 12%,但在 OpenCode 下仅为 2%,智能体的工具调用、上下文管理等设计与模型本身同等重要
  • 商业模型占优:商业闭源智能体在处理大型代码库时的稳定性和正确性显著优于开源方案
  • 复杂度陷阱任务涉及 1-2 个文件时成功率为 18%,但当涉及 7 个以上文件时,成功率骤降至 2% ,显示出模型在跨文件长程推理方面的短板
  • “防御性编程”提示词更有效:研究发现,使用基于“防御性编程”(原则的简洁提示词,比复杂的提示词能让成功率提升 7.4%

对于失败,论文还针对失败类型归类:

  • 缺失关键功能标志位或 Feature Flag 是主要的失败原因
  • 其次是 数据模型缺失
  • 再者是 incomplete patch(文件覆盖不足)等问题

这些失败的类似,在一定程度上反映了智能体对真实工程流程、跨文件依赖、与视觉设计的理解严重不足,也就是这些问题是“工程级问题”,而不是“语言问题”:

所以哪怕换成 Android / Flutter,这类跨文件工程理解问题仍然存在。

基于这些数据,论文认为当前 LLM Agent 尽管在单一代码生成上有突破,但在端到端工程上下文(包含设计、代码库理解、工程流程)仍远未达到企业生产标准

另外,论文也有一个有趣的结论数据,主要统计了各 Agent + Model 的每任务成本(美元)和平均耗时(分钟),例如:

  • Cursor + Opus 4.5 : $3.50 / 15 min
  • Codex + GLM 4.6 : $1.30 / 13.3 min
  • OpenCode + GLM 4.6 : $0.13 / 32.5 min
  • OpenCode + Opus 4.5 : $9.33 / 8.2 min

对此可以看出来:

  • Codex + GLM 4.6 是性价比最高
  • OpenCode 极便宜但成功率低
  • OpenCode + Opus 4.5 是最贵但效果很差(2%)

最后,下图是论文的最终结果对比,例如在 Success 和 Pass 上:

  • Cursor + Opus 4.5 → 12% / 28.1%
  • Codex + GLM 4.6 → 12% / 19.6%
  • OpenCode + GLM 4.6 → 8%

这么看,OpenCode 的实际数据表现是真的一般。

这个在同一个模型,在不同 agent 上的成功率也有所体现,OpenCode 再一次被鞭尸:

所以,可以看出来,目前的 AI 智能体离独立完成中大型移动开发还有很大距离,主要瓶颈在于多模态理解、大规模代码导航和跨文件逻辑一致性等。

另外,SWE-Bench Mobile 采用了托管基准挑战(Hosted Benchmark)模式 ,不公开测试集答案,以防止数据泄露到未来的模型训练中。

最后,论文只针对原生 iOS 开发进行测试,没有测试 Android 原生、Flutter、RN 等其他情况,按照一般直觉,这些框架的 AI 表现应该会好于 iOS 原生,当然这也只是我的个人直觉,真实数据还是得有企业做过 Benchmark 才知道。

不过至少从目前看,在移动端开发领域写代码上,至少比前端安全性高一些?你怎么看?

❌