# 让 iPhone 拨打一张不在 iPhone 里的 SIM 卡（六）

- URL: https://wuming.si/blog/4007/
- Author: Aaron
- Published: 2026-09-16
- Language: zh-Hans
- Categories: Technology
- Originally published at: https://blog.wo.ai/archives/4007

> 上一篇：[插在 Mac 上](https://wuming.si/blog/4006) ｜ 下一篇：[安卓平板直接驱动](https://wuming.si/blog/4008)

前面几篇的成果是一台树莓派网关：上面插着几张 SIM 卡，能收发短信、能打电话、还能通过 Wi-Fi 连上运营商核心网。

但它是一台放在角落里的机器。真正想要的是：**手机上多出几条线路**，用起来和手机自己的号码一样——来电有全屏来电界面，锁屏能接，短信是熟悉的气泡会话，拨号时能用系统键盘输 IVR 的分机号。

iPhone 没有 USB Host 能力，插不了也驱动不了模组。所以这个 App 是网关的客户端。界面四个标签：电话、短信、录音、设置，导航栏里一个线路选择器。

## 不用 WebRTC，也不做 SIP 客户端

第一反应可能是在手机上实现一个 SIP 客户端，或者上 WebRTC。两条路都没必要——网关已经把难的部分做完了，手机只要会说 HTTPS 就够了。

| 手机需要 | 网关提供 |
|---|---|
| 线路列表 | `GET /api/lines`：编号、名称、号码、信号、能否拨号、进行中的通话。全部来自缓存，回到前台随便刷，不花模组一条 AT 命令 |
| 所有线路的事件 | `GET /api/events`：一条 SSE 流，通话状态变化和新短信，每条带线路标识，外加心跳 |
| 通话音频 | 一条双向 WebSocket，二进制帧，8 kHz 单声道 16 位 |
| 录音 | 列表加支持断点的下载 |
| 后台来电 | VoIP 推送 |

这些接口全部写进了一份 OpenAPI 描述（72 个路径）。其中语音 WebSocket 的帧格式，之前只有那个处理函数自己知道——**一个接口的格式如果只存在于代码里，它就还不算一个接口。**

通话用 **CallKit**，而不是 App 内部自己画的界面。CallKit 是 iOS 给第三方通话应用的系统框架，用它的好处很实在：来电界面能出现在锁屏上，通话在离开 App 之后继续，按键音用系统键盘（这样自动语音菜单才好用）。

音频这边要在 8 kHz 和手机硬件的 48 kHz 之间转换，并保持一个 **60 毫秒的抖动缓冲**。为什么需要缓冲：音频走的是 TCP，到达是一阵一阵的，而扬声器消耗是匀速的，没有缓冲每次突发之间都会留下一个空洞。但缓冲又必须浅，因为这里加的每一毫秒延迟都会被直接听成通话延迟。

## 装起来

```sh
cd ios/VoHivePhone
xcodegen generate           # 工程由配置生成，不手改工程文件
open VoHivePhone.xcodeproj  # 选开发团队，连真机运行
```

首次打开在设置里填网关地址。输入框接受人类会输入的任何形式——`192.168.1.20:7575`、主机名、完整 URL——其余部分自动补全。

网关地址是整个 App 的"根"：改掉它意味着重新登录、丢掉旧网关的缓存、重连事件流。这类"改一个设置要连带做五件事"的地方，最好一次写对，否则会留下各种看不见的残留状态。

## 网关先要补两个洞

为了支持一台常连着的手机，网关这边先补了两样东西。

### 服务刚启动时，来电没人知道

**原因**：模组的来电回调属于一个控制对象，而这个对象只在有客户端主动查询通话状态时才被创建。刚启动的服务上根本没有回调，来电在有人来问之前不会被注意到。

这在网页后台时代不是问题——人打开页面就会开始轮询。但一台一直开着事件流的手机，不能依赖"有人来问"。

**解决**：启动时就为每个设备安装一个事件监视器，随设备增减自动调整。

### 两个消费者，各看到一半事件

**原因**：事件是一个共享 channel，而话机桥已经在读它了。两个读者各拿到大约一半的状态变化。

这和第三篇"语音丢一半"是**完全一样的陷阱**：Go 的 channel 一次只把一个值送给一个接收者。同一个 bug 在这个项目里出现了三次，形式各不相同。

**解决**：改成订阅模式，每个消费者一个独立 channel。投递是有损的——发送发生在模组的通知路径上，绝不能被一个慢读者阻塞。

验证：从一张国内卡给网关上的另一条线路发短信，经 Wi-Fi 通话链路到达，从事件流里出来：

```json
{"type":"sms.received","line_id":"us","sms":{"sender":"+86138xxxxxxxx", ...}}
```

## App 这边的坑

### 短信会话打开是空的

**原因**：`URLComponents` 在查询参数里保留字面量的 `+`，而服务端按表单规则把 `+` 解码成空格。于是 `+86138…` 变成了 ` 86138…`，前面多一个空格，什么都匹配不上。

用线上网关验证了一遍：字面量形式返回空数组，百分号编码形式返回消息。

**解决**：所有要放进查询参数的电话号码，统一在一处编码，而不是在每个调用点各写一遍。这类"每个调用点自己处理"的约定，迟早会有人漏掉一个。

### 第二通电话被拒绝，错误码 7

**原因**：错误码 7 是"通话组数量达到上限"。CallKit 的 Provider 只允许一个通话组，而一个**没接通的拨号从未被报告为结束**，永远占着那个组。

本地状态清了，CallKit 没被告知——两边只清一边，系统就会一直相信有一通根本不存在的电话正在进行。

**解决**：每一条失败路径都要向 CallKit 报告通话结束；发起新通话前先清掉残留；既没接通也没被拒绝的拨号设一个 45 秒的截止时间。

**只清一边**是这类系统集成里最常见的错误形状：状态存在两个地方，代码只更新了自己那份。

### 每通电话都会没声音

这个 bug 是读代码时按时序推出来的，不是在真机上撞到的。

**原因**：CallKit 在拨号动作完成后**立刻**交出音频会话，而这发生在音频引擎被创建**之前**。于是"音频会话已激活"的回调里，对着一个 `nil` 调用了启动方法，什么也没发生，之后也没有任何人再启动它。

**解决**：把"音频会话激活"和"引擎已创建"当成两个顺序不定的事件，两者都去调用同一个"条件满足就启动"的函数，后到的那个生效。引擎自身的保护让重复调用无害。

这类竞态值得强调一句：`nil` 上的可选调用不会崩溃，也不会报错，它只是什么都不做。Swift 里 `try audio?.start()` 这行代码，在 `audio` 为 nil 时是一个完全合法、完全静默的空操作。

### App 在前台时，来电不响

**原因**：事件流明明告诉了 App"有电话在响"，App 什么都没做——报告来电的代码只写在推送回调里。

**解决**：前台收到的来电也报告给 CallKit。两个来源可能对同一通电话都触发，所以已经报告过的要跳过，否则屏幕上会出现两个来电界面。网关说通话结束时也要告诉 CallKit，否则来电界面会一直挂在那儿，而后面什么都不会发生。

### 第一次 TLS 握手就崩溃

**原因**：局域网网关用自签证书，需要在网络会话的代理方法里放行。而那个代理方法跑在网络库自己的队列上，代码从这里去访问主线程隔离的数据，触发了底层的队列断言，进程在第一次握手时直接挂掉。

**解决**：只对**配置的那个主机**放行证书，不是所有主机；持有例外的代理对象自己保存一份主机名快照，完全不去碰主线程的状态。

### VoIP 推送的两条硬规则

这两条不遵守，后果都不是报错，而是被系统惩罚：

- 报告新来电**必须在推送回调里同步调用**。否则 iOS 直接杀掉进程，并且会减少后续推送的投递。
- 音频引擎必须在 CallKit 的"会话已激活"回调里启动，而不是拨号时。否则它会绑定到错误的音频路由，第一通电话没声音。

## 网关侧的推送

- 用证书认证的 APNs，HTTP/2，没有引入新依赖。
- **一个很安静的坑**：Go 里一旦设置了 `TLSClientConfig`，自动 HTTP/2 就被关掉了，而 APNs 拒绝 HTTP/1.1。必须显式打开 `ForceAttemptHTTP2`。这个错误不会告诉你原因。
- 沙盒还是生产环境由客户端上报，不靠猜。
- APNs 报告永久失效的令牌直接删除，不要在以后每通电话都重试一遍。
- 推送和事件流里的"振铃"由**同一个状态转换**触发，保证前台的手机和睡着的手机不会被告知不同的事。
- 有一个状态接口，推送失败时能看见，而不是静默。

推送需要自己的 Apple VoIP 证书，配置好之前保持关闭，App 只在前台连着事件流时响铃。

## 测试

单元测试直接解码真实的网关 JSON 数据。任何一侧改了字段名，都会在测试里失败，而不是在界面上显示一片空白。

这是客户端加服务端项目里最省事的一种测试：不需要 mock，不需要跑服务，就是把真实响应存下来喂给解码器。

## 这一篇的 bug 都长一个样

| 问题 | 根源 |
|---|---|
| 会话为空 | `+` 在查询参数里被解码成空格 |
| 错误码 7 | CallKit 和本地状态只清了一边 |
| 没声音 | 音频会话激活和引擎创建的竞态 |
| 前台不响 | 只有推送路径报告了来电 |
| TLS 崩溃 | 在网络库的队列上访问主线程状态 |
| 事件丢一半 | 共享 channel，多个消费者 |

六个里有四个是**同一件事在两个地方各有一份状态，而代码只维护了一份**。写客户端和系统框架打交道时，这个模式值得随时提防。

最后一篇：一台安卓平板，唯一的 USB-C 口插着 dongle，没有电脑、没有 root，怎么把它变成一部电话。

## 全系列

1. [它到底是什么](https://wuming.si/blog/4002)
2. [树莓派上的短信与 eSIM 网关](https://wuming.si/blog/4003)
3. [从零实现 VoWiFi](https://wuming.si/blog/4004)
4. [USB 话机、浏览器通话与 ESP32](https://wuming.si/blog/4005)
5. [插在 Mac 上](https://wuming.si/blog/4006)
6. [iPhone 客户端](https://wuming.si/blog/4007)
7. [安卓平板直接驱动](https://wuming.si/blog/4008)

---

*这个系列是个人学习和技术研究记录。请遵守所在地法律法规和运营商服务条款。*
