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決定)。 無法傳送的備忘錄不會從等待傳送中刪除,而是重新傳送,但自動重新傳送有次數上限。

相關報道

參考文獻