Outbox架构:为了不漏掉信息而设计

本页面由日语原文机器翻译而成。 阅读日语原文

课题:即使发送即清除,也不会漏掉信息

在SimpleMemo中,用户点击发送按钮的瞬间UI被清除,可以马上开始写下一个备忘录。 这就是Captio风格的根基UX。

但是现实的网络有300ms~2秒的延迟,也会失败,可能本来就离线。 很多应用程序在等待网络响应后才清除UI。 虽然安全,但是UX不好。 相反,如果立即清除的话,有丢失信息的风险。

Outbox模式同时解决了这个相反的要求。 在发送点击的瞬间将信息永久保存在本地的加密存储(Outbox)中,立即清除UI,在后台进行网络发送。 发送成功后从Outbox中删除,失败后自动重试。 因此将未发送的信息留在终端上并重新发送,体感延迟接近零。

Outbox图案的设计流程

以下是从点击发送按钮到发送信息的全部流程。

  1. 发送点击→首先Outbox对AES-GCM进行加密并永久保存
    用户点击发送按钮的瞬间,信息被AES-GCM 256bit加密,永久保存到设备的本地存储中。 在这个步骤完成之前,不会进行UI清除。 也就是说,Outbox保存成为防止溢出的第一个防波堤。
  2. 立即清除UI(用户可以开始写下一个备忘录)
    Outbox保存成功时,清除文本视图,播放发送动画。 从用户的角度来看,与“发送了”是同义。 完全没有等待网络响应。 Send-to-Reset的目标是感觉不到等待的短(包括动画0.25秒)。
  3. 在后台通过Relay API发送邮件与
    UI清除并行,SendManager向Cloudflare Workers上的Relay API发送HTTP请求。 这个处理不阻止主线程的完全异步。
  4. 成功后从Outbox中删除
    Relay 从API收到200响应后,从Outbox安全地删除对应信息。 这样信息的生命周期就完成了。
  5. 失败后自动重新发送
    网络错误或服务器错误时,留待发送,线路恢复时、打开应用程序时、后台处理(BGTaskScheduler。 执行的时机由iOS决定)为契机重新发送。 自动重新发送有次数上限。
  6. 离线时用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决定)。 无法发送的备忘录不会从等待发送中删除,而是重新发送,但自动重新发送有次数上限。

相关报道

参考文献