Outbox架构:为了不漏掉信息而设计
本页面由日语原文机器翻译而成。 阅读日语原文
2026年2月13日
课题:即使发送即清除,也不会漏掉信息
在SimpleMemo中,用户点击发送按钮的瞬间UI被清除,可以马上开始写下一个备忘录。 这就是Captio风格的根基UX。
但是现实的网络有300ms~2秒的延迟,也会失败,可能本来就离线。 很多应用程序在等待网络响应后才清除UI。 虽然安全,但是UX不好。 相反,如果立即清除的话,有丢失信息的风险。
Outbox模式同时解决了这个相反的要求。 在发送点击的瞬间将信息永久保存在本地的加密存储(Outbox)中,立即清除UI,在后台进行网络发送。 发送成功后从Outbox中删除,失败后自动重试。 因此将未发送的信息留在终端上并重新发送,体感延迟接近零。
Outbox图案的设计流程
以下是从点击发送按钮到发送信息的全部流程。
- 发送点击→首先Outbox对AES-GCM进行加密并永久保存
用户点击发送按钮的瞬间,信息被AES-GCM 256bit加密,永久保存到设备的本地存储中。 在这个步骤完成之前,不会进行UI清除。 也就是说,Outbox保存成为防止溢出的第一个防波堤。 - 立即清除UI(用户可以开始写下一个备忘录)
Outbox保存成功时,清除文本视图,播放发送动画。 从用户的角度来看,与“发送了”是同义。 完全没有等待网络响应。 Send-to-Reset的目标是感觉不到等待的短(包括动画0.25秒)。 - 在后台通过Relay API发送邮件与
UI清除并行,SendManager向Cloudflare Workers上的Relay API发送HTTP请求。 这个处理不阻止主线程的完全异步。 - 成功后从Outbox中删除
Relay 从API收到200响应后,从Outbox安全地删除对应信息。 这样信息的生命周期就完成了。 - 失败后自动重新发送
网络错误或服务器错误时,留待发送,线路恢复时、打开应用程序时、后台处理(BGTaskScheduler。 执行的时机由iOS决定)为契机重新发送。 自动重新发送有次数上限。 - 离线时用NWPathMonitor检测线路恢复并自动重新发送
发送时离线时,Network.framework的NWPathMonitor监视线路状态,检测到Wi-Fi和蜂窝网络恢复时,自动重新发送Outbox的未发送信息(和上面的5一样,自动重新发送有次数上限)。
SendManager的代码
SendManager的send()函数是Outbox模式的入口点。 首先保存在Outbox,没有网络连接就排队,有连接就立即执行发送。
// 保存完了と送信結果を分ける説明用の簡略API
@MainActor
func send(message: String, onSaved: () -> Void,
completion: @escaping (SendResult) -> Void) {
// まずOutboxに保存(クラッシュや圏外で失わないように)
let outboxMessage: OutboxMessage
do {
outboxMessage = try OutboxManager.shared.add(body: message)
} catch {
DispatchQueue.main.async { completion(.failure(error)) }
return
}
// 同期保存に成功した場合だけ、メインスレッドでUIへ通知する
onSaved()
// ネットワーク接続がない場合はキューに入れたことを通知
guard NetworkMonitor.shared.isConnected else {
BackgroundTaskManager.shared.scheduleRetryTask()
DispatchQueue.main.async { completion(.queued) }
return
}
// 送信処理を実行
performSend(message: message, outboxId: outboxMessage.id, ...)
}
重要的一点是,OutboxManager.shared.add(body:)同步写入永久存储。 这个调用成功的时候,信息会安全地保存在终端上。 无论网络发送结果如何,信息都会一直留在Outbox。
AES-GCM加密的实施
Outbox中保存的信息是用户的私人备忘录。 虽说是本地存储,但不能用平文保存。 使用Apple CryptoKit的AES-GCM,在加密方面完全不通过外部SDK的实现。
- Apple CryptoKit — iOS 13以后可以使用。 加密处理不通过外部SDK(仅限Apple纯正)。
- 256-bit对称键 — 保存在Keychain中。
- 认证加密 — AES-GCM同时保证数据的机密性和完整性。 被篡改的数据在解密时会被检测到。
enum OutboxEncryption {
private static var encryptionKey: SymmetricKey {
if let existingKey = KeychainHelper.load(key: "outbox_encryption_key") {
return SymmetricKey(data: existingKey)
}
let newKey = SymmetricKey(size: .bits256)
let keyData = newKey.withUnsafeBytes { Data($0) }
KeychainHelper.save(key: "outbox_encryption_key", data: keyData)
return newKey
}
static func encrypt(_ plainText: String) throws -> Data {
guard let data = plainText.data(using: .utf8) else {
throw OutboxError.encryptionFailed
}
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
guard let combined = sealedBox.combined else {
throw OutboxError.encryptionFailed
}
return combined
}
}
钥匙在第一次需要时自动制作,并保存在Keychain中。 保存的设置是“只有这个终端・第一次解锁后才能读取”(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly),不能从其他应用程序读取,也不会从备份转移到其他终端。 重启后解锁一次,从锁定中的后台处理中也能读取。 另外,上面的代码例子是写报道时的简略版,现在的代码在钥匙链读取失败时,不会处理“没有钥匙”来制作新的钥匙。
数据路径只由Apple纯正构成的意义
Outbox架构中使用的框架都是Apple原生的。
- CryptoKit — AES-GCM加密。 不需要第三方的加密库。
- Network.framework — 通过NWPathMonitor监控线路状态。 不需要Reachability库。
- BackgroundTasks — BGTaskScheduler的后台重试。
等待发送的路径中没有外部库,有以下含义。
- 不受外部更新的影响 — 等待发送的处理不会因为第三方库的更新或提供终止而停止运行。
- 安全 — 接触等待发送的备忘录正文的处理(加密、保存、发送)中,第三方的代码无法进入。
另外,整个应用程序也使用外部的SDK(Google登录、Firebase、AppsFlyer)(参考隐私第一设计)。 这里所说的“只有Apple纯正”是关于等待发送的数据路径。
UI清除和网络发送的分离
Outbox模式的真正价值在于UI层和网络层的完全分离。 看看发送按钮的点击处理程序吧。
@objc private func sendButtonTapped() {
PerformanceLogger.shared.beginSendToReset()
let message = textView.text ?? ""
isSending = true
sendBarButton.isEnabled = false
SendManager.shared.send(message: message, onSaved: { [weak self] in
// 端末への保存成功後にだけ消す。ネットワーク応答は待たない。
self?.performSendAnimation { [weak self] in
guard let self = self else { return }
// アニメーション中に編集された入力は残す。
if self.textView.text == message {
self.clearTextView()
}
PerformanceLogger.shared.endSendToReset()
}
}) { [weak self] result in
self?.isSending = false
self?.updateSendButtonState()
switch result {
case .success:
PerformanceLogger.shared.logEvent(name: "SendSuccess")
case .failure(let error):
self?.handleSendError(error)
case .queued:
self?.showQueuedFeedback()
}
}
}
在这个简化示例中,只有成功保存到设备后才调用 onSaved,然后播放动画并清空输入。保存失败时保留输入。completion 是另一个通知,用于报告发送结果或已加入队列;清空界面不等待网络响应。
Send-to-Reset的目标是感觉不到等待的短。 包括动画也是0.25秒。 用户在点击发送按钮后,就可以开始写下一个备忘录。 这就是Captio风格的UX。
常见问题
问。 离线发送的备忘录会怎么样?
Outbox加密保存,线路恢复(NWPathMonitor检测)・打开应用程序时・以后台处理为契机,自动重新发送。 自动重新发送有次数上限,达到上限的备忘录在“履历”中长按并选择“重新发送”。
问。 关闭应用程序也不会丢失备忘录吗?
Outbox是永久存储。 即使关闭应用程序,重启iPhone也会保留。 下次启动应用程序时,或者通过后台任务自动重试。
问。 为什么选择AES-GCM?
Apple CryptoKit原生支持,通过认证加密(Authenticated Encryption)也可以检测篡改。 处理加密不需要外部库这一点也是优点。
问。 发送失败时的重试次数是多少?
自动重新发送。 契机是,线路恢复时,打开应用程序时,后台处理(BGTaskScheduler。 执行的时间由iOS决定)。 无法发送的备忘录不会从等待发送中删除,而是重新发送,但自动重新发送有次数上限。
相关报道
参考文献
- Apple Developer — AES.GCM (CryptoKit) — Outbox用于数据加密的带有AES-GCM认证的加密的官方参考
- Apple Developer — NWPathMonitor (Network Framework) — 用于离线检测和网络状态监视
- Apple Developer — BGTaskScheduler — 用于后台的再发送处理调度
- Chris Richardson — Pattern: Transactional outbox (microservices.io) — 交易Outbox模式的解说(发送的消息在同一处理中写在等待发送的表中,其他处理发送)