Outboxアーキテクチャ:メッセージ取りこぼしゼロの設計

課題:送信即クリアなのにメッセージを失わない

Obsidian連携シンプルメモでは、ユーザーが送信ボタンをタップした瞬間に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. 失敗したら指数バックオフで自動リトライ
    ネットワークエラーやサーバーエラーの場合、指数バックオフ(1秒→2秒→4秒→8秒…)で自動リトライする。BGTaskSchedulerを使い、アプリがバックグラウンドでも確実にリトライを実行する。
  6. オフラインならNWPathMonitorで回線復帰を検知して自動再送
    送信時点でオフラインの場合、Network.frameworkのNWPathMonitorが回線状態を監視。Wi-Fiやセルラーが復帰した瞬間に自動的にOutbox内の全未送信メッセージを再送する。

SendManagerのコード

SendManagerのsend()関数は、Outboxパターンのエントリーポイントだ。まずOutboxに保存し、ネットワーク接続がなければキューイングし、接続があれば即座に送信を実行する。

func send(message: String, completion: @escaping (SendResult) -> Void) {
    // まずOutboxに保存(取りこぼしゼロを保証)
    let outboxMessage: OutboxMessage
    do {
        outboxMessage = try OutboxManager.shared.add(body: message)
    } catch {
        DispatchQueue.main.async { completion(.failure(error)) }
        return
    }

    // ネットワーク接続がない場合はキューに入れたことを通知
    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に安全に保存される。Keychainはデバイスのセキュアエンクレーブに紐づいており、他のアプリやバックアップからはアクセスできない。

データパスを Apple 純正のみで構成する意味

Outboxアーキテクチャで使用しているフレームワークはすべてAppleネイティブだ。

  • CryptoKit — AES-GCM暗号化。サードパーティの暗号化ライブラリは不要。
  • Network.framework — NWPathMonitorによる回線状態監視。Reachabilityライブラリは不要。
  • BackgroundTasks — BGTaskSchedulerによるバックグラウンドリトライ。

外部依存がゼロであることの意味は大きい。

  • 破壊的変更のリスクがない — OSアップデート時にサードパーティライブラリが壊れることがない。
  • セキュリティ — ユーザーのメモデータに触れるコードパスに、第三者のコードが一切含まれない。
  • アプリバイナリの軽量化 — 不要なフレームワークを含まないため、ダウンロードサイズが最小限。
  • ビルドの高速化 — 依存関係の解決が不要で、CIが速い。

UIクリアとネットワーク送信の分離

Outboxパターンの真の価値は、UIレイヤーとネットワークレイヤーの完全な分離にある。送信ボタンのタップハンドラを見てみよう。

@objc private func sendButtonTapped() {
    PerformanceLogger.shared.beginSendToReset()
    let message = textView.text ?? ""
    isSending = true
    sendBarButton.isEnabled = false

    // Animation → UI clear (does NOT wait for network!)
    performSendAnimation {
        self.clearTextView()
        PerformanceLogger.shared.endSendToReset()
    }

    // Send is async. Does not block UI.
    SendManager.shared.send(message: message) { [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()
        }
    }
}

performSendAnimationSendManager.shared.sendは完全に独立して実行される。アニメーションは0.25秒で完了し、テキストビューはクリアされる。ネットワーク送信はバックグラウンドで非同期に進行し、UIを一切ブロックしない。

Send-to-Resetの目標はワンタップ。アニメーション込みでも0.25秒。ユーザーは送信ボタンをタップした直後に、次のメモを書き始められる。これがCaptioスタイルのUXだ。

よくある質問

Q. オフラインで送信したメモはどうなりますか?

Outboxに暗号化保存され、回線復帰時にNWPathMonitorが検知して自動再送します。ユーザーが何もしなくても、メッセージは確実に届きます。

Q. アプリを閉じてもメモは失われませんか?

Outboxは永続ストレージです。アプリ終了・iPhone再起動しても失われません。次回アプリ起動時、またはバックグラウンドタスクにより自動的にリトライされます。

Q. なぜAES-GCMを選んだのですか?

Apple CryptoKitがネイティブサポートしており、認証付き暗号化(Authenticated Encryption)により改ざん検知も可能です。外部ライブラリ依存がゼロになる点も大きな利点です。

Q. 送信失敗時のリトライ回数は?

指数バックオフで自動リトライします。BGTaskSchedulerでバックグラウンドタスクとしてスケジュールされ、確実に送信完了するまで続けます。ユーザーのメモを絶対に取りこぼさない設計です。

関連記事

参考文献