0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Last updated at Posted at 2026-02-13

Gemini_Generated_Image_twreyotwreyotwre.png

Captio式シンプルメモ開発日誌
2026年2月13日


昔、Captioというアプリがありました。

開いた瞬間にキーボードが立ち上がる。テキストを書く。送信を押す。それだけ。通知もない。設定もほぼない。「書いたものが自分のメールに届く」、ただそれだけのアプリでした。

でも、それがたまらなく好きだった。

メモアプリというのは不思議で、世の中には何千とあるのに、自分にとっての「これ」を見つけると、もう他が使えなくなる。Captioはまさにそういう存在でした。買い物リスト、思いついたアイデア、仕事で言い忘れたこと——全部、Captioに書いて自分に送っていた。

ある日、Captioがサービスを停止しました。

理由はよくわからない。ただ突然、あの「起動→書く→送る」のサイクルが消えた。それからずっと、似たものを探しては「違うな」と思って、結局やめる——を繰り返していました。

たぶん、世界中に同じ気持ちの人がいる。あの小さなアプリを愛用していた人が、きっといる。

だったら自分で作ろう。

そう思って始めたのが「Captio式シンプルメモ」です。

App Store:Captio式シンプルメモ


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

アプリを再現するなら、まず「何を再現するのか」を正確に理解しないといけません。Captioを使っていたとき、自分が感じていた快適さの正体はなんだったのか。

思い返してみると、3つの要素に分解できました。

1つ目は「起動即入力」という速度。 アプリを起動した瞬間、カーソルが入力欄にある。ホーム画面→アプリアイコン→テキスト入力開始、この流れに一切の待ち時間がなかった。0.5秒以内にキーボードが出ている。思考が途切れる前に指が動いている。

2つ目は「送ったらすぐ消える」という潔さ。 送信ボタンを押した瞬間、画面がクリアされる。送信中のインジケーターなんか待たない。「届いたかな」と心配させない。送ったものは自分のメールに届く。それだけ。この割り切りが、紙にメモを書いてポケットに入れるような安心感を生んでいた。

3つ目は「余計なものが一切ない」という設計哲学。 フォルダ分け、タグ付け、カレンダー連携、AI要約——そういう「便利そうな機能」が一つもなかった。だから迷わなかった。

この3つを、コードレベルで完全に再現する。それがこのプロジェクトのゴールです。


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

このアプリの設計で最初に決めたのは、SwiftUIを使わないということでした。

2026年の今、新規iOSアプリをUIKitで書くのは珍しいかもしれません。でも、このアプリにはSwiftUIでは満たせない要件がありました。

「Time-to-Text(起動から入力可能になるまで)500ミリ秒以内」

これがCaptioの核心であり、このアプリの絶対に譲れない性能目標です。

SwiftUIの@StateObjectの初期化、bodyの再評価、onAppearのタイミング——これらはAppleが最適化を進めているとはいえ、UIKitでviewDidAppearから直接becomeFirstResponder()を叩くほうが、確実にミリ秒単位で速い。

実際のコードを見てください。SceneDelegate.swiftで、Storyboardすら使わずにViewControllerを直接生成しています:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, 
           options connectionOptions: UIScene.ConnectionOptions) {
    PerformanceLogger.shared.signpostBegin(name: "SceneConnect")
    
    guard let windowScene = (scene as? UIWindowScene) else { return }
    
    let window = UIWindow(windowScene: windowScene)
    let composeVC = ComposeViewController()
    let navController = UINavigationController(rootViewController: composeVC)
    
    window.rootViewController = navController
    window.makeKeyAndVisible()
    
    PerformanceLogger.shared.signpostEnd(name: "SceneConnect")
}

Storyboardのパース時間はゼロ。ViewController間のセグエもゼロ。UIWindowの生成→ComposeViewControllerの直接初期化→キーボード表示、この最短経路だけが存在します。

そしてComposeViewControllerviewDidAppearで、この1行がすべてを完結させます:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    activateTextInput()
}

private func activateTextInput() {
    textView.becomeFirstResponder()
    PerformanceLogger.shared.endTimeToText()
}

この計測結果はos_signpostで記録され、Instrumentsでナノ秒単位で追跡できます。目標は500ms以下。実機では200〜300ms台で安定しています。


「送ったら消える」を150ミリ秒で実現する設計

Captioの体験で2番目に重要なのは、「送ったら画面が即クリアされる」ということでした。

ここで多くの開発者が陥る罠があります。「送信完了を待ってからUIをクリアする」という設計です。ネットワークのレスポンスは300ms〜2秒かかる。その間ユーザーを待たせたら、もうCaptioの体験ではない。

Simple Memoでは、送信処理とUIクリアを完全に分離しています。

@objc private func sendButtonTapped() {
    // Send-to-Reset計測開始
    PerformanceLogger.shared.beginSendToReset()
    
    let message = textView.text ?? ""
    
    isSending = true
    sendBarButton.isEnabled = false
    
    // アニメーション → UIクリア(ネットワーク完了を待たない!)
    performSendAnimation {
        self.clearTextView()
        PerformanceLogger.shared.endSendToReset()
    }
    
    // 送信処理は非同期。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()
        }
    }
}

注目してほしいのは、performSendAnimationの完了ブロック内でclearTextView()が呼ばれている点です。ネットワーク通信の成功・失敗を一切待たずに、UIは0.25秒のアニメーション後にクリアされます。

「えっ、送信失敗したらメモが消えちゃうの?」と思うかもしれません。答えは「消えない」です。


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

ここがSimple Memoのアーキテクチャで一番こだわった部分です。

送信ボタンを押した瞬間、メッセージはネットワーク送信よりも前に、まず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, ...)
}

この設計の流れをまとめると、こうなります:

  1. 送信タップ → まずOutboxにAES-GCM暗号化して永続保存
  2. UIを即座にクリア(ユーザーは次のメモを書き始められる)
  3. バックグラウンドでRelay API経由でメール送信
  4. 成功したらOutboxから削除
  5. 失敗したら指数バックオフで自動リトライ
  6. オフラインならNWPathMonitorで回線復帰を検知して自動再送

つまり、送信ボタンを押した時点でメッセージの安全は保証されています。ネットワークがなくても、アプリを閉じても、iPhoneを再起動しても、メモは失われません。

Outboxの暗号化にはApple純正のCryptoKitを使用しています。鍵はKeychainに保存されるため、端末に紐づいた安全な管理が可能です:

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
    }
}

外部ライブラリには一切依存していません。CryptoKitNetwork.frameworkBackgroundTasks——すべてApple純正フレームワークだけで構成されています。依存が少ないということは、壊れにくいということです。


Relay API:Gmail依存からの脱却

Captioはメールを送るアプリですが、「メールを送る」という行為をiOSアプリから安全に行うのは、実はかなり難しい。

当初はGmail APIを使おうとしました。ですがGoogleのOAuth審査プロセスは、個人開発者にとっては険しい道です。未検証状態だと「このアプリは検証されていません」という警告画面が表示され、ユーザーは不安になる。

そこで設計し直したのが、Cloudflare Workers + Resend APIによるRelay APIアーキテクチャです。

iOSアプリ → Relay API (Cloudflare Workers) → Resend API → メール送信

サーバーサイドはTypeScriptで書かれたCloudflare Workersのワーカーです。コールドスタートがほぼゼロで、世界中のエッジロケーションから配信される。メモアプリに必要な「速さ」と「信頼性」の両方を、月額ほぼ無料で実現できます。

Relay APIは以下の悪用対策を多層で実装しています:

メールアドレス検証(6桁コード方式): メモの宛先として指定するメールアドレスには、事前に6桁コードによる検証を必須にしています。これにより、他人のメールアドレスへの無断送信を完全に防ぎます。

多層レート制限: デバイス単位、IP単位、グローバル単位で送信上限を設けています。1台のデバイスで1分間に30通以上送れない、1日200通が上限、といった具合です。

冪等性の保証: 各メッセージにはUUIDが割り当てられ、同じメッセージIDでの重複送信をサーバー側で防止します。ネットワーク不安定時にリトライが走っても、同じメモが2通届くことはありません。

サーバーサイドのレート制限ロジックはこのように実装されています:

const RATE_LIMITS = {
  devicePerMinute: 30,
  devicePerDay: 200,
  ipPerHour: 120,
  globalPerDay: 300,
};

Cloudflare KVにカウンターを保存し、時間枠ごとにリセットする仕組みです。エッジコンピューティングの利点を活かして、レスポンスは世界中どこからでも数十ミリ秒で返ります。


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

ここからが今日の開発日誌の本題です。

今日は大きな機能追加ではなく、体験を"静かに良くする"作業に集中しました。Captioが気持ちよかった理由って、派手さじゃなくて、迷わない・ストレスがないところだったと思うから。

★評価ダイアログを「押しつけがましくない」設計に作り替えた

アプリ内レビューの誘導って、多くのアプリが失敗しています。使い始めて3日目にいきなり「★5レビューお願いします!」と全画面ダイアログが出てくる——これが一番嫌われるパターンです。

Simple Memoでは、100回送信するごとにしか表示しないという設計にしました。しかも、前回表示から最低14日間は再表示しません。「後で」を押したら30日空きます。

private let milestoneInterval = 100   // 100回ごと
private let minimumDaysBetweenPrompts = 14  // 最低14日間隔
private let minimumDaysAfterDismiss = 30    // 「後で」後は30日

そして今日修正したのは、★アイコンのタップ判定と表示の問題でした。

問題1:★をタップしても反応しないことがあった。

原因はタップ領域が小さすぎたこと。Apple Human Interface Guidelinesでは、タップターゲットは最低44×44ptが推奨されています。★アイコンのサイズは26ptだったので、アイコン自体は小さくても、タップ領域は44ptに拡大しました:

private let starIconPointSize: CGFloat = 26
private let starTapSize: CGFloat = 44

// タップ領域は44×44pt、アイコン自体は中央に固定サイズで描画
NSLayoutConstraint.activate([
    button.widthAnchor.constraint(equalToConstant: starTapSize),
    button.heightAnchor.constraint(equalToConstant: starTapSize)
])

問題2:★アイコンが歪んで見えていた。

StackViewのdistribution.fillEquallyに設定されていたため、画面幅によっては★が横に引き伸ばされていました。contentMode.scaleAspectFitに設定し、アイコンの中央固定配置を明示的に指定することで解消しました:

button.imageView?.contentMode = .scaleAspectFit
button.contentHorizontalAlignment = .center
button.contentVerticalAlignment = .center

問題3:★5だけApp Storeレビュー、★4以下はお礼トーストで静かに終了。

以前は★4以下の場合にもフィードバックフォームを表示していましたが、これが「レビューを書かせようとしている」印象を与えてしまっていた。★4以下は軽いお礼メッセージだけ表示して、1.5秒で自動的にフェードアウトします:

private func handleSatisfactionRating(_ rating: Int, from viewController: UIViewController) {
    if rating == 5 {
        showPositiveFollowUp(from: viewController)
    } else {
        showThankYouToast(from: viewController)  // 静かにお礼だけ
    }
}

この「静かさ」がCaptio的な体験だと思っています。

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

メモを送信した後、履歴画面を開くと「送信中」のまま表示が止まることがありました。

原因は、SendManagerが履歴ステータスを更新する際に、Outboxの削除と履歴の更新のタイミングが噛み合っていなかったことでした。

修正後は、送信時に履歴エントリのIDを保持し、成功・失敗のコールバックで確実にIDベースで更新するようにしました:

// 履歴に未送信として追加し、エントリIDを保持
let historyEntryId = HistoryManager.shared.addEntry(body: message, status: .pending)

// ...送信成功時...
OutboxManager.shared.remove(id: outboxId)
HistoryManager.shared.updateStatus(id: historyEntryId, status: .sent)

以前は本文テキストで履歴を検索して更新していたため、同じ内容のメモが複数あると間違ったエントリを更新してしまうことがありました。ID指定に変更したことで、この問題は根本的に解消されています。


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

Captioが好きだった理由のもう一つに、**「このアプリは自分のメモを覗いていない」**という信頼がありました。

Simple Memoでは、プライバシー保護を設計の根幹に据えています。具体的な実装をいくつか紹介します。

アプリスイッチャーでのプライバシーオーバーレイ: iOSのマルチタスク画面で、メモの内容が他人に見えてしまうのを防ぎます。バックグラウンドに入る瞬間に、アプリアイコンとタイトルだけのオーバーレイを被せます:

func sceneWillResignActive(_ scene: UIScene) {
    showPrivacyOverlay()
}

func sceneDidEnterBackground(_ scene: UIScene) {
    showPrivacyOverlay()  // バックグラウンドでも確実に
}

URLSessionはephemeral構成: 通信セッションにキャッシュもCookieも残しません。

private let session: URLSession = {
    let config = URLSessionConfiguration.ephemeral
    config.httpCookieAcceptPolicy = .never
    config.httpShouldSetCookies = false
    config.urlCache = nil
    return URLSession(configuration: config)
}()

ログに機密情報を一切出さない: DEBUGビルドでも、メールアドレス・メモ本文・認証コード・URLをログに出力しません。エラーログではtype(of: error)だけを出力し、localizedDescriptionは使いません(機密情報を含む可能性があるため)。

func logError(name: String, error: Error) {
    #if DEBUG
    // エラーの種類のみ出力。localizedDescriptionは使わない。
    os_log("[%.2f ms] ERROR %@: %@", log: log, type: .debug, 
           elapsed, name, String(describing: type(of: error)))
    #endif
}

これは偏執的すぎるかもしれません。でも、メモアプリに書かれるものは、その人の頭の中身です。パスワードかもしれない。秘密の相談かもしれない。日記かもしれない。だから、過剰なくらいがちょうどいい。


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

Captioは世界中で使われていたアプリでした。だから、Simple Memoも最初から多言語対応しています。

日本語、英語、スペイン語、フランス語、ドイツ語、ポルトガル語、ロシア語、中国語(簡体字・繁体字)、アラビア語の10言語です。アラビア語はRTL(右から左への書字方向)にも対応しています。

言語切り替えはアプリ内で即時反映されます。ViewController全体を差し替えてクロスディゾルブすることで、自然な切り替え体験を実現しました。


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

メモアプリって、極端な話、紙でもいい。標準メモでも、Notionでも、Bearでも、Obsidianでもいい。

それでも「これを使い続ける」と決める瞬間があります。

その決め手は、機能の数ではない。安心感だと思います。

タップしたらちゃんと反応する。送ったら送れたと分かる。余計な圧がない。迷わない。データは暗号化されている。オフラインでも失われない。広告がない。気配を消している。

Captioがくれたのは、まさにこの安心感だった。

技術的に言い換えれば、「Time-to-Text 500ms以下」「Send-to-Reset 150ms以下」「Outboxによる取りこぼしゼロ保証」「AES-GCM暗号化」「外部依存ゼロ」——こういった設計判断の一つひとつが、ユーザーには「なんか安心して使える」という感覚として届くはずです。

派手さはない。でも、こういう小さな直しと設計判断が積み重なると、アプリは急に"信用できる側"に寄っていきます。


次回(Day2)予告:送信体験をもっと"自然に"する

Simple Memoは「書いて送る」が軸。次は送信体験をもっと静かに、もっと自然に整えます。

送信中/送信後の見せ方の改善。 現在のページめくりアニメーションは0.25秒で完結しますが、3D変換のパラメーターをもう少し調整して、紙が風に飛ばされるような軽やかさを出したい。

Historyの反映タイミングの最適化。 送信完了後にHistoryを開いたとき、ステータスの更新が一瞬遅れることがある。NotificationCenterのpostタイミングを見直して、画面を開いた瞬間に最新の状態が見えるようにします。

「伝わるけど邪魔しない」UI。 送信成功のトーストは2秒で消えるけど、本当に必要なのか。「消えたこと自体が送信成功のフィードバック」だとしたら、トーストすらいらないのかもしれない。


コメントで教えてください

Captioを使っていた人、いますか?

もし使っていたなら、何が一番好きでしたか?

逆に、メモアプリで一番イラつくポイントでもOKです。「こういうことされると使うのやめる」っていうの、教えてほしい。

一つひとつ、コードに反映していきます。


Captio式シンプルメモ
外部ライブラリ依存ゼロ。Swift + Apple純正フレームワークだけで作った、起動0.3秒のメモアプリ。

App Store:ダウンロードはこちら

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?