Captio式シンプルメモ開発日誌
2026年5月12日
「送信ボタンを押した後」はユーザーに見せるUIではない。バックグラウンドの話でもない。実は「アプリが落ちても、機内モードでも、明日の朝でも、必ず届く」という契約の話だ。前回(Day2)では送信成功トーストを消すまでを書いた(Day2の記事)。今回はその一歩裏側、Outboxアーキテクチャを3回書き直した記録になる。
なぜ"送信"はそんなに難しいのか
送信処理は、見た目より遥かに難しい。理由は3つある。
第一に、iOSはアプリをすぐsuspendする。送信中にホームに戻られると、URLSessionのcontinuationがそのまま消える可能性がある。Background URLSessionに切り替えるなら別だが、軽量メモアプリでBackground URLSessionを選ぶと、daemonとの通信オーバーヘッドで起動が0.5秒以上重くなる。だからフォアグラウンドで完結させたかった。
第二に、ネットワークが揺れる。地下鉄、エレベーター、混雑したカフェ。送信タップから1秒後に圏外になる確率は無視できない。実測ではメトロ車内の送信失敗率が平日朝で12%程度あった。
第三に、Captio式シンプルメモ特有の事情がある。このアプリは「送ったら画面から消える」というデザインだ。テキストは0.25秒のページめくりアニメと共にビューから抜ける。にもかかわらず「実は送信失敗していました」と後から言われるのは、想定する体験の真逆だ。送ったテキストは、たとえアプリが落ちようと、必ずいつかどこかに届かないと、信頼そのものが崩れる。
「outbox swift 実装」で検索すると、業務系バックエンドの解説は無数に出てくるが、iOSローカルでの実装例はほとんど出てこない。だから自分で書くしかなかった。最初の実装は2週間で出したが、結局3回書き直すことになる。
Outboxパターンとは何で、なぜiOSに必要なのか
Outboxパターンとは、業務系のバックエンドで使われる「DBに永続化したアウトプットを別ワーカーが拾って外部に送る」設計だ。元はマイクロサービス間の「メッセージとDB更新の二相性問題」を解くために生まれた。Pat Hellandの2007年の論文を起源にする説が有力で、2010年代に分散DB界隈で広まった。
これをiOSのローカルに持ち込む。Captio式シンプルメモのOutboxはこう動く。
- ユーザーが送信タップ
- Core Dataに
OutboxItemを即座に永続化 - UIはすぐに次の状態(空のメモ画面)に戻る
- バックグラウンドワーカーがpendingなOutboxItemを順にRelay APIに送る
- 成功したら
state = .sent、失敗したらattemptCount += 1と指数バックオフで再送
ポイントは「UIの反応速度」と「送信の確実性」を別のレイヤーで独立に最適化できることだ。送信が遅くてもUIは速く、送信が失敗してもユーザーは知らずに次のメモを書ける。逆にこの2つを同じ画面処理で扱おうとすると、必ず破綻する。送信完了を待つUIはネットワーク次第で2秒〜10秒の間で揺れ、ユーザーの集中を破壊する。
ところがOutboxを挟むと、UIの速さは「Core Dataにinsertした瞬間」で決まる。SQLiteのinsertは数msで終わるので、UIは常に150ms以内に戻れる。送信の成否は背後のRunnerが面倒を見る。これは2層構造の一種で、業務系のCQRSにも似ているが、iOSではもっと素朴に「保存」と「送る」を分ける形になる。
Core Dataとactor-isolationはどう組み合わせるのか
Swift Concurrencyが標準化されてから、Core Dataの扱い方は大きく変わった。NSManagedObjectContext.performをネストする旧スタイルから、@ModelActorや独自actorで包む新スタイルへ移行している。Swift 6のstrict concurrencyを有効にすると、旧スタイルの多くは警告かエラーになる。
Captio式シンプルメモのOutboxはこう書いてある。
import CoreData
actor OutboxStore {
private let container: NSPersistentContainer
private let context: NSManagedObjectContext
init(container: NSPersistentContainer) {
self.container = container
self.context = container.newBackgroundContext()
self.context.automaticallyMergesChangesFromParent = true
self.context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
func enqueue(body: String, locale: String) async throws -> UUID {
let id = UUID()
try await context.perform {
let item = OutboxItem(context: self.context)
item.id = id
item.body = body
item.locale = locale
item.state = OutboxState.pending.rawValue
item.attemptCount = 0
item.nextAttemptAt = Date()
item.createdAt = Date()
try self.context.save()
}
return id
}
func pending(limit: Int = 16) async throws -> [OutboxSnapshot] {
try await context.perform {
let req = OutboxItem.fetchRequest()
req.predicate = NSPredicate(
format: "state == %@ AND nextAttemptAt <= %@",
OutboxState.pending.rawValue, Date() as NSDate
)
req.sortDescriptors = [NSSortDescriptor(key: "createdAt", ascending: true)]
req.fetchLimit = limit
let items = try self.context.fetch(req)
return items.map { OutboxSnapshot(from: $0) }
}
}
}
ここで重要なのは2点ある。
1つ目はactorでOutboxStore全体を包んだこと。これでCore DataのMOCを別スレッドから誤って触る事故が、コンパイル時に防がれる。Swift 5.5以前はランタイムで「This NSManagedObjectContext's coordinator has no persistent stores」のようなクラッシュが頻発していたが、actorで囲うとそもそも誤った呼び出しが書けなくなる。
2つ目はOutboxSnapshotという値型をCore Dataの外に返していること。NSManagedObjectはcontextに強く紐づくので、actorの外に渡すと使えなくなる。だから「IDと内容だけを持つstruct」へ変換してから返す。これはSwift Sendableの制約上、必要な変換だ。OutboxSnapshotはSendableにしておくと、actor境界を越えて安全に持ち運べる。
nextAttemptAtで再送を制御する
nextAttemptAtというフィールドが、地味だが効くミソだ。今すぐ送るアイテムだけをpendingとして取り出すのではなく、「nextAttemptAt <= now」のものだけ取り出す。失敗したらこの値を未来に動かせば、自動的にバックオフが効く。
func markFailed(id: UUID, error: Error) async throws {
try await context.perform {
let req = OutboxItem.fetchRequest()
req.predicate = NSPredicate(format: "id == %@", id as CVarArg)
guard let item = try self.context.fetch(req).first else { return }
item.attemptCount += 1
let backoff = self.backoffSeconds(for: Int(item.attemptCount))
item.nextAttemptAt = Date().addingTimeInterval(backoff)
item.lastError = String(describing: error).prefix(512).description
if item.attemptCount >= 10 {
item.state = OutboxState.failed.rawValue
}
try self.context.save()
}
}
private func backoffSeconds(for attempt: Int) -> TimeInterval {
let base = pow(2.0, Double(min(attempt, 10))) // 2, 4, 8, 16, ...
let jitter = Double.random(in: 0...(base * 0.2))
return min(base + jitter, 3600) // 上限1時間
}
指数バックオフ+ジッタは分散システムの常識だが、iOSのローカルでも効く。理由は「圏外復帰時に、複数のOutboxItemが全部同時に再送を試みる」現象を回避できるからだ。10件まとめて即時再送すると、Relay API側で429を返されることがある。最初に書いたコードはjitter無しの純粋な指数バックオフだったが、地下鉄を出た瞬間に5件のメモが同時送信されて、サーバーが3件ほど失敗で返してきた。
ジッタを入れた瞬間にこの現象は消えた。20%という数字は経験則で、最小5%でも効くが、安全側で20%にした。
再送時の冪等性をどう担保するか
再送する以上、サーバー側で重複が発生する可能性がある。だからOutboxItemのidをそのままIdempotency-Keyヘッダに乗せてRelay APIに送る。サーバー側は同じidを受けたら、過去のレスポンスをそのまま返す。
func send(_ snapshot: OutboxSnapshot) async throws {
var request = URLRequest(url: relayURL)
request.httpMethod = "POST"
request.setValue(snapshot.id.uuidString, forHTTPHeaderField: "Idempotency-Key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
let payload = RelayPayload(body: snapshot.body, locale: snapshot.locale)
request.httpBody = try JSONEncoder().encode(payload)
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw RelayError.badStatus
}
}
これだけで、ネットワーク不安定で同じメモが2通届く事故がほぼ消えた。Relay API側はRedisで24時間idを保持するだけの簡素な実装で済む。サーバー側の重複検知はOutbox側に責任を持たせる、というのが分担として正しい。クライアントだけで二重送信を防ごうとすると、Local State機の管理が際限なく増える。
なぜactorで包むのか
Outboxの処理は本質的に「シリアル」だ。同じOutboxItemを2つのworkerが同時に拾うと、二重送信が起きる。だから「拾う→送る→更新する」を直列化する必要がある。
ロックでも書けるが、Swift Concurrencyのactorは「アクター内のメソッドは同時に1つしか実行されない」という性質をコンパイラレベルで保証してくれる。デッドロックの原因になりやすいNSLockやDispatchQueue.syncを排除できる。
actor OutboxRunner {
private let store: OutboxStore
private let api: RelayAPI
private var isRunning = false
init(store: OutboxStore, api: RelayAPI) {
self.store = store
self.api = api
}
func tick() async {
guard !isRunning else { return }
isRunning = true
defer { isRunning = false }
guard let snapshots = try? await store.pending() else { return }
for snap in snapshots {
do {
try await api.send(snap)
try? await store.markSent(id: snap.id)
} catch {
try? await store.markFailed(id: snap.id, error: error)
}
}
}
}
isRunningフラグで「tickの二重実行」も防いでいる。actor内では並行アクセスがないので、フラグの読み書きにロックが要らない。これはactorの旨味そのものだ。
しかもtick()は外から非同期で何度呼ばれても良い。たとえばアプリ起動時、フォアグラウンド復帰時、ネットワーク復帰のNWPathMonitor通知時、Timerからの定期実行時。全部から呼んでも、最初の1つしか実体は動かない。
3回書き直した変遷:何が壊れたから今の形になったのか
最初から今の設計になったわけではない。ここに変遷を残しておくのは、同じ落とし穴を踏む人を1人でも減らすためだ。
バージョン1(2025年12月):DispatchQueueの直列キュー
最初の実装はSwift Concurrency以前の作法で書いていた。DispatchQueue(label: "outbox", qos: .utility)の直列キューに送信タスクを積み、Core DataはmainContextを直接触っていた。
// 古いバージョン1の抜粋(今は使っていない)
let queue = DispatchQueue(label: "outbox", qos: .utility)
queue.async {
let context = persistentContainer.viewContext
context.performAndWait {
// 失敗パターン:viewContextをbackgroundで触っている
}
}
これは2週間で破綻した。理由は2つある。1つはperformAndWaitの中でURLSessionを待つと、ブロックが10秒スタックすることがあった。URLSession.shared.dataTaskのcompletion handlerはmainで返ってくるので、待ち合わせがすぐ破綻する。2つ目はviewContextを別スレッドから触る違反でクラッシュレポートが週に3〜5件届いた。
教訓は「viewContextは絶対にbackgroundに渡さない」「DispatchQueueとURLSessionの混合は最終的に詰む」だった。
バージョン2(2026年1月):actorだけ入れて満足してしまった
次に書いたのがOutboxStore actorだ。これは安全になった。クラッシュは消えた。しかし速度を失った。
問題は「全てのCore Data操作をactorに集約した結果、UIの描画フローまでactor境界を越えるようになった」ことだった。送信タップの瞬間にawait store.enqueue(...)がメインスレッドからactorへのhopを発生させる。1回のhopは1msだが、actorが他の長時間処理(バックグラウンドのsend)でbusyだと、UIスレッドが100ms近く待たされる。
// バージョン2でやってしまった失敗
@MainActor
func didTapSend(_ text: String) async {
await outboxStore.enqueue(body: text, locale: locale)
// ↑ ここがOutboxRunner.tick()と直列になる
dismissEditor()
}
enqueueとpendingはDB操作だけなので別々のキューに分けるべきだった。OutboxStoreのactorを「書き込み専用」と「読み出し+状態更新専用」の2つに割ることで解決した。Core Dataのcontextも2つに分け、SQLite側で読み書きを並列化する。
バージョン3(2026年4月、今の形):write-actorとwork-actorに分割
最終形はOutboxStoreを2つのactorに分けた。OutboxWriterはUIから呼ばれるenqueue専用。OutboxRunnerが裏で読み出し・送信・状態更新を担当する。Core Data contextは2つだが、automaticallyMergesChangesFromParentを有効にして互いに変更を反映させる。
actor OutboxWriter {
private let context: NSManagedObjectContext
init(container: NSPersistentContainer) {
self.context = container.newBackgroundContext()
self.context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
func enqueue(body: String, locale: String) async throws -> UUID {
let id = UUID()
try await context.perform {
let item = OutboxItem(context: self.context)
item.id = id
item.body = body
item.locale = locale
item.state = OutboxState.pending.rawValue
item.nextAttemptAt = Date()
item.createdAt = Date()
try self.context.save()
}
return id
}
}
これでUIのdidTapSendは最短で10ms、最長でも20msに収まるようになった。Runner側がどれだけ遅くてもUIは止まらない。バージョン2と3の体感差は明確で、テスター3人が「Day1→Day2より、Day2→Day3の方が違いが分かる」と言ってきた。
Outboxは過剰設計ではないのか
ここまで読んで「個人開発のメモアプリでOutbox?」と思うエンジニアは多い。実際、Hacker NewsやRedditのr/iOSProgrammingで同じ議論を何度か見かけた。
カウンターの主張はこうだ。「送信失敗時にトーストを出して、ユーザーに再送ボタンを押させればいい。Outboxは複雑性を持ち込みすぎる」。確かに本数で見るとOutbox関連のコードは追加で約500行ある。テストは別途200行。これを「メモアプリ1本のために書く価値があるか」と問われたら、答えは設計思想に依存する。
これは半分正しい。シンプルなアプリならその通りだ。しかしCaptio式シンプルメモは「送ったら忘れる」体験を売っている。送信失敗トーストを出した瞬間、その世界観は崩れる。「ユーザーに気づかせない」ことが商品価値の中核にあるなら、Outboxは装飾ではなくコア仕様だ。
逆に、ユーザーが送信状態を能動的に確認したいアプリ(メッセンジャー、メール、Slackなど)ではOutboxよりも明示的なステータス表示の方が正解だろう。設計判断は「ユーザーに何を見せ、何を隠したいか」で決まる。
だからこそ、Outboxを使うか否かは技術選定ではなく、プロダクト判断だと考えている。Captioで「captio 代替 ios」を探しているような人は、たぶん同じ判断を期待しているはずだ。
Outbox実装チェックリスト
iOSでOutboxを導入するときに、自分が踏んだ落とし穴をリスト化しておく。3回書き直した結果として、確信のある項目だけ並べる。
- Core Data contextは
newBackgroundContext()を1つだけ作ってactor内に保持する。viewContextは触らない - pendingを取得するクエリに
fetchLimitを必ず付ける。10万件溜まったときに死ぬ -
attemptCountはInt64で持つ。Int32だと2^31で溢れる(実際は起きないが、リミットなしの再送ループで一度フリーズした) -
lastErrorは512バイトで切る。NSErrorのuserInfoを雑に文字列化するとCore Dataのsqliteが肥大化する - ジッタは最低でも20%入れる。指数バックオフだけだと圏外復帰でthundering herdが起きる
- 再送上限は明示する。Captio式シンプルメモは10回失敗で
failed状態に遷移させ、ユーザーが手動で確認できるリストに出す - Idempotency-Keyはサーバー側で必ず受ける。クライアントだけで送っても効果なし
- アプリ起動時、フォアグラウンド復帰時、
NWPathMonitorの.satisfied通知時の3点でtick()を呼ぶ - Background Refreshには載せない。電池消費とユーザー体感の比でほぼ割に合わない
今日の学び:UIの速さは"確実性"の上にだけ立つ
最初は「Outboxは分散システムの大袈裟な道具」だと思っていた。違った。Outboxは「UIを速くするための仕掛け」だ。永続化と送信を分離すれば、UIは永続化だけ待てば良くなる。残りはバックグラウンドが面倒を見る。
Captio式シンプルメモのText-to-Send 150msとTime-to-Text 500msという数字も、Outboxの上にだけ成立する。送信完了を待つ設計だと、地下鉄に乗った瞬間にUIが止まる。送信を「永続化したら成功」と再定義することで、初めて150msが実現する。
Day1で書いた「速さは設計の話」というのは、こういうことだった。技術選定が体験の質を決め、体験の質がユーザーの信頼を決める。だからOutboxは"裏方"だが、UIの主役の支え棒として存在している。
次回(Day4)予告:起動0.3秒を支える実装
次は起動速度の解剖に入る。
- UISceneSessionとstateRestorationActivityの使い分け:cold startを0.3秒に収めるための初期化順序
- キーボード予熱:UITextViewを画面外に1個置いておくテクニックの効果検証と落とし穴
- UIKitを選んだ判断は本当に正しかったのか:SwiftUIで同じ起動速度が出せるかの実験ログ
技術寄りの記事が続くので、もし「個人開発 メモアプリ 起動速度」「uikit swift 起動 高速化」あたりで困っているエンジニアの参考になれば嬉しい。
コメントで教えてください
iOSアプリでOutboxパターンを使った経験のある人、もしくは「Outboxは要らない、こう書けば十分」という反対意見、両方とも聞きたい。Core Dataの代わりにSQLiteを直接叩いた話や、GRDB・SQLite.swiftを使った話もあれば是非。@ModelActorのSwiftDataで書き直した話も興味がある。
Captio式シンプルメモ
外部ライブラリ依存ゼロ。Swift + Apple純正フレームワークだけで作った、起動0.3秒のメモアプリ。
App Store:https://apps.apple.com/jp/app/captio%E5%BC%8F%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%83%A1%E3%83%A2/id6749649498