【開発日誌 Day1】Captioが好きすぎて、もう一度"あの体験"を作りたくなった

Captioというアプリを知っているだろうか。

メモを書いて、送信ボタンを押す。それだけで、自分のメールアドレスにメモが届く。起動した瞬間にキーボードが表示され、書いたら送る、送ったら消える。余計な機能は一切ない。ただそれだけのアプリだった。

でも「ただそれだけ」が、とてつもなく心地よかった。

通勤電車の中で思いついたアイデア、会議中にふと浮かんだタスク、寝る前に頭をよぎった一言——Captioはそのすべてを、1秒以内にメールボックスに届けてくれた。メモアプリを開いて、フォルダを選んで、タイトルを付けて……という煩わしさが一切ない。その「摩擦ゼロ」の体験は、一度知ったら手放せなくなるものだった。

そのCaptioが、サービスを終了した。

代替アプリをいくつか試したが、どれもあの体験には程遠かった。起動が遅い、UIに余計な要素がある、送信後にメモが残る——些細な違いに見えるかもしれないが、Captioが作り上げた体験の本質は、まさにそういった「些細なこと」の積み重ねだった。

だから、自分で作ることにした。Captioの体験を、もう一度。

これは「Obsidian連携シンプルメモ」の開発日誌Day1。この記事では、Captioの体験を分解し、それを現代の技術スタックでどう再現するか、すべての技術的判断を記録する。

1. Captioの「何」が良かったのか——体験を分解してみた

Captioの体験を言語化するために、3つの要素に分解してみた。

要素1:起動即入力(Instant Launch-to-Input)

Captioを起動すると、即座にキーボードが表示され、テキスト入力が可能になる。スプラッシュスクリーンの後にホーム画面が表示されて、そこからメモ作成ボタンを押して……という手順がない。起動=入力。この「起動からテキスト入力可能になるまでの時間」を、私たちはTime-to-Textと呼んでいる。Captioのそれは、体感で0.5秒以下だった。

要素2:送ったらすぐ消える(Instant Clear After Send)

送信ボタンを押すと、画面は即座にクリアされる。「送信中…」のプログレスバーを見つめて待つ時間がない。書いたメモが消えた瞬間、「ああ、送れたな」という安心感とともに次の行動に移れる。この「送信ボタンを押してからUIがクリアされるまでの時間」を、私たちはSend-to-Resetと呼んでいる。目標はワンタップ以下だ。

要素3:余計なものが一切ない(Nothing Unnecessary)

フォルダ機能、タグ機能、リッチテキスト、マークダウン対応——Captioにはそのどれもなかった。あるのはテキスト入力欄と送信ボタンだけ。この潔さが、認知負荷をゼロに近づけていた。「何をすべきか」を考える必要がないアプリ。それがCaptioだった。

この3つの要素を、現代のiOS開発で再現する。それが「Obsidian連携シンプルメモ」のミッションだ。

2. アーキテクチャ:なぜSwiftUI「ではなく」UIKitなのか

2026年のiOS開発で、SwiftUIではなくUIKitを選ぶのは異端に見えるかもしれない。しかし、私たちの最優先目標は明確だった——Time-to-Text 500ms以下

SwiftUIの`body`再評価、`StateObject`の初期化、`@Environment`の解決——これらのオーバーヘッドは通常のアプリ開発では無視できる範囲だが、「起動即入力」を実現するには無視できない。1msでも速く、テキストフィールドにフォーカスを当てたい。

そこで採用したのが、Storyboardを使わないUIKit直接構築だ。

SceneDelegateでwindowを生成し、rootViewControllerに直接ComposeViewControllerを設定する。Storyboardのパース処理すらスキップする。

// SceneDelegate.swift
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = ComposeViewController()
    window.makeKeyAndVisible()
    self.window = window
}

そしてComposeViewControllerの`viewDidAppear`で、即座にテキストビューにフォーカスを当てる。

// ComposeViewController.swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    textView.becomeFirstResponder()
}

この2つのコードだけで、起動からキーボード表示までのパスが最短になる。

実機計測の結果は200〜300ms。目標の500msを大幅に下回った。SwiftUIで同等のことを実現しようとすると、`@FocusState`の遅延やbody再評価のタイミングの問題で、安定して500ms以下を達成するのは困難だった。

UIKitは「古い」かもしれない。しかし「速い」。この1点において、UIKitは今でも最良の選択肢だ。

3. 「送ったら消える」をワンタップで実現する設計

Captioの「送ったら消える」体験を再現するために、最も重要な設計判断がある。それは、送信アニメーションとUIクリアを、ネットワークレスポンスから完全に分離することだ。

一般的なアプリの送信フローはこうだ:

  1. 送信ボタンを押す
  2. ローディングインジケーターを表示
  3. サーバーからのレスポンスを待つ
  4. 成功ならUIをクリア、失敗ならエラーを表示

このフローでは、ネットワークの遅延がそのままUIの遅延になる。私たちのフローはこうだ:

  1. 送信ボタンを押す
  2. 即座にUIをクリア(アニメーション: 0.25秒)
  3. バックグラウンドでネットワーク送信
  4. 成功→静かにOutboxから削除 / 失敗→バックグラウンドでリトライ
// ComposeViewController.swift
private func performSendAnimation() {
    UIView.animate(withDuration: 0.25,
                   delay: 0,
                   options: .curveEaseOut) {
        self.textView.alpha = 0
        self.subjectField.alpha = 0
    } completion: { _ in
        self.textView.text = ""
        self.subjectField.text = ""
        self.textView.alpha = 1
        self.subjectField.alpha = 1
        self.textView.becomeFirstResponder()
    }
}

ユーザーにとっては、送信ボタンを押した瞬間にメモが「消えて」、次のメモを書ける状態になる。ネットワークの成功・失敗は、ユーザーの目に見えないところで処理される。

「でも、送信に失敗したらどうするの?」——その疑問に答えるのが、次のセクションのOutboxアーキテクチャだ。

4. 「取りこぼしゼロ」を実現するOutboxアーキテクチャ

送信UIをネットワークレスポンスから分離するということは、「UIはクリアされたけど、実はメール送信に失敗していた」という状況が起こり得るということだ。これは絶対に許容できない。ユーザーが「送った」と思ったメモが、実は送られていなかった——これはメモアプリにとって致命的な信頼の喪失だ。

そこで採用したのがOutboxパターンだ。

メッセージの送信フローは以下の通り:

  1. 送信ボタンタップ:メッセージをAES-GCM暗号化してOutbox(ローカルストレージ)に保存
  2. UIクリア:performSendAnimationで即座に画面をクリア
  3. バックグラウンド送信:Outboxからメッセージを取り出してRelay APIに送信
  4. 成功:Outboxからメッセージを削除
  5. 失敗:Exponential Backoffでリトライ(1秒→2秒→4秒→8秒…)
  6. オフライン:NWPathMonitorで接続を監視し、復帰時に自動再送

重要なのは、ネットワーク送信の前にOutboxに保存するという順序だ。これにより、アプリが突然クラッシュしても、端末の電源が落ちても、メッセージは失われない。

Outboxに保存されるメッセージは、CryptoKitを使ったAES-GCM暗号化で保護される。暗号化キーはKeychainに保存され、アプリのサンドボックス外からはアクセスできない。

// OutboxManager.swift
func enqueue(message: OutboxMessage) throws {
    let key = try KeychainHelper.getOrCreateSymmetricKey()
    let sealedBox = try AES.GCM.seal(
        message.plainData,
        using: key
    )
    let encrypted = EncryptedOutboxEntry(
        id: message.id,
        sealedData: sealedBox.combined!,
        createdAt: Date(),
        retryCount: 0
    )
    try persistenceStore.save(encrypted)
}

また、すべてを自前で実装しているため、外部ライブラリへの依存はゼロだ。CryptoKit、NWPathMonitor、URLSession——すべてApple純正のフレームワークで完結する。サードパーティライブラリのバージョン管理やセキュリティ監査から解放されるのは、小規模チームにとって大きなメリットだ。

5. Relay API:外部メールAPIへの依存からの脱却

Captioの送信の仕組みは公式には公開されていないが、端末から外部のメール送信に頼る方式だったと推測されている。一般論として、外部メールAPIのOAuth要件の変更やアクセス制限が重なると、この種の方式は持続可能でなくなりやすい。

私たちはCloudflare Workers + Resend APIという構成を選んだ。

Cloudflare Workersはエッジコンピューティング基盤で、世界中のユーザーに最も近いデータセンターでコードが実行される。日本からのリクエストは日本のエッジで処理され、アメリカからのリクエストはアメリカのエッジで処理される。これにより、従来のサーバーレス(AWS Lambda等)と比べて、コールドスタートの問題がほぼ解消される。

メール送信にはResend APIを使用する。Resendはデベロッパーフレンドリーなメール送信APIで、高い到達率と安定性を提供する。

メール認証フロー

ユーザーが宛先メールアドレスを設定する際、6桁の認証コードによるメール認証を行う。これにより、第三者のメールアドレスへの不正送信を防ぐ。

多層レート制限

不正利用を防ぐために、多層のレート制限を実装している。

// Cloudflare Worker - Rate Limit Configuration
const RATE_LIMITS = {
  devicePerMinute: 30,    // 1デバイスあたり1分30回
  devicePerDay: 200,      // 1デバイスあたり1日200回
  ipPerHour: 120,         // 1IPあたり1時間120回
  globalPerDay: 300       // サービス全体で1日300回(調整可能)
};

デバイス単位、IP単位、グローバル単位の3層でレート制限をかけることで、単一デバイスの暴走もボットネット攻撃もブロックできる。

冪等性の保証

ネットワークの不安定さによる重複送信を防ぐため、各メッセージにUUIDを割り当て、Relay API側で重複チェックを行う。同じUUIDのメッセージが再送された場合、2回目以降はメール送信を行わずに成功レスポンスを返す。これにより、Outboxパターンのリトライ機構と組み合わせても、ユーザーが同じメモを二重に受信することはない。

6. 今日やったこと:小さな違和感を、ひとつずつ消す

Day1の実装作業の多くは、星評価ダイアログの見直しに費やした。App Storeの評価はアプリの生命線だが、ユーザー体験を損なう形で評価を求めてはいけない。

表示条件の再設計

星評価ダイアログの表示条件を以下のように設定した:

  • 100通ごとに表示:十分にアプリを使い込んだユーザーにのみ表示
  • 最低14日間の間隔:前回表示してから14日以上経過していること
  • 閉じた後は30日間表示しない:ユーザーが閉じた場合、30日間は再表示しない

星アイコンのタップ領域修正

Apple Human Interface Guidelinesでは、タッチターゲットのサイズは最低44x44ptが推奨されている。星アイコンのタップ領域がこの基準を満たしていなかったため修正した。

// StarRatingView.swift
private func createStarButton() -> UIButton {
    let button = UIButton(type: .custom)
    button.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
        button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
    ])
    return button
}

星アイコンの歪み修正

UIStackViewの`.fillEqually` distributionが原因で星アイコンが歪んでいた問題を修正。`contentMode`を`.scaleAspectFit`に設定し、StackViewのdistributionを`.equalSpacing`に変更した。

評価後のフロー

  • 5つ星:App Storeのレビュー画面に遷移(`SKStoreReviewController`)
  • 4つ星以下:「ありがとうございます」のトーストを静かに表示して終了。ネガティブなレビューをApp Storeに誘導しない。

7. Historyの表示ステータス不整合を解消した

送信履歴画面(History)で、あるバグに気づいた。送信が完了しているにも関わらず、ステータスが「送信中」のまま変わらないメッセージがあった。

原因を調査した結果、ステータス更新のロジックに問題があることが判明した。従来の実装では、メッセージのテキスト内容をキーにしてステータスを更新していた。しかし、同じ内容のメモを複数回送信した場合、テキストベースの検索では正しいメッセージを特定できない。

修正は単純明快だった。テキストベースの検索をIDベースのステータス更新に変更した。各メッセージにはOutbox保存時にUUIDが割り当てられているため、このIDを使ってHistoryのステータスを更新する。

// HistoryManager.swift
// Before: テキストベースの検索(バグの原因)
// func updateStatus(forText text: String, status: SendStatus)

// After: IDベースのステータス更新
func updateStatus(forMessageId id: UUID, status: SendStatus) {
    guard let index = entries.firstIndex(where: { $0.messageId == id }) else {
        return
    }
    entries[index].status = status
    persistEntries()
}

この修正により、同じ内容のメモを何度送っても、それぞれのステータスが正しく表示されるようになった。

8. プライバシーへの偏執的なこだわり

メモアプリにはプライベートな情報が書き込まれる。パスワード、個人的な悩み、ビジネスアイデア——ユーザーが何を書くかは予測できない。だからこそ、プライバシー保護は「十分」ではなく「過剰」なくらいがちょうどいい。

アプリスイッチャーでのプライバシーオーバーレイ

iOSのアプリスイッチャーでは、アプリの画面がサムネイルとして表示される。メモの内容が第三者に見られるリスクを排除するため、アプリがバックグラウンドに移行する際にプライバシーオーバーレイを表示する。

Ephemeral URLSession

通常のURLSessionは、キャッシュ、クッキー、認証情報をディスクに保存する。私たちは`URLSessionConfiguration.ephemeral`を使用することで、これらの情報がディスクに一切残らないようにしている。メモの内容がキャッシュとしてストレージに残るリスクをゼロにする。

ログの無害化

開発中のデバッグログには注意が必要だ。「送信したメモの内容をログに出力する」という一見無害な処理が、セキュリティホールになり得る。私たちのポリシーは明確だ:

  • メモの内容は、DEBUGビルドであっても一切ログに記録しない
  • エラーログでは`localizedDescription`を使わず、`type(of: error)`のみを記録
  • 理由:`localizedDescription`にはユーザー入力が含まれる可能性があるため
// NetworkManager.swift
// BAD: エラーの詳細をログに出力
// Logger.error("Send failed: \(error.localizedDescription)")

// GOOD: エラーの型のみをログに出力
Logger.error("Send failed: \(type(of: error))")

偏執的に見えるかもしれない。しかし、メモアプリを信頼して使ってもらうためには、この程度の偏執さが必要だと考えている。

9. 10言語対応:世界中のCaptioユーザーに届けたい

Captioは英語圏を中心に世界中にユーザーがいた。その元ユーザーたちに「Obsidian連携シンプルメモ」を届けるために、初回リリースから10言語に対応した。

対応言語:日本語、英語、スペイン語、フランス語、ドイツ語、イタリア語、ポルトガル語、韓国語、中国語(簡体字)、アラビア語。

特にアラビア語のRTL(Right-to-Left)対応は技術的な挑戦だった。テキストの方向だけでなく、UIレイアウト全体をミラーリングする必要がある。Auto Layoutの`leading`/`trailing`制約を一貫して使うことで、RTL環境でも自然なレイアウトを実現している。

アプリ内言語切り替え

iOS 16以降のアプリ内言語切り替えに加え、独自の言語切り替えメカニズムも実装している。言語が切り替わった際は、ViewControllerを`cross-dissolve`トランジションで差し替えることで、スムーズな切り替え体験を提供する。

// LanguageManager.swift
func applyLanguageChange() {
    guard let window = UIApplication.shared.connectedScenes
        .compactMap({ $0 as? UIWindowScene })
        .first?.windows.first else { return }

    let newVC = ComposeViewController()
    newVC.view.frame = window.bounds
    UIView.transition(with: window,
                      duration: 0.3,
                      options: .transitionCrossDissolve,
                      animations: {
        window.rootViewController = newVC
    })
}

10. 今日の学び:プロダクトは「安心感」の設計

Day1を終えて思うのは、このプロダクトの本質は「安心感」の設計だということだ。

技術的な数値をまとめてみる:

  • Time-to-Text:500ms以下(実測200〜300ms)
  • Send-to-Reset:ワンタップ以下
  • メッセージの取りこぼし:ゼロ(Outboxパターン)
  • 暗号化:AES-GCM(CryptoKit)
  • 外部ライブラリ依存:ゼロ

しかし、これらの数値はすべて「安心感」という一つの目標に集約される。

起動が速い→「いつでもすぐ書ける」安心感。
送ったら消える→「処理された」安心感。
取りこぼしゼロ→「絶対に届く」安心感。
暗号化→「誰にも見られない」安心感。

Captioが愛されたのは、機能が優れていたからではない。「安心して使える」体験を、徹底的に磨き上げていたからだ。それを引き継ぎたい。

11. 次回(Day2)予告

Day2では、以下のテーマに取り組む予定だ:

  • 送信アニメーションの洗練:現在の0.25秒フェードアウトから、より心地よいアニメーションへ
  • Historyステータス更新のタイミング最適化:リアルタイム更新 vs バッチ更新の比較検討
  • 成功トーストは本当に必要か?:送信成功のフィードバックについて、「表示しない」という選択肢の検討

Captioの「何もない」潔さを目指して、Day2へ続く。

参考文献