Captio式シンプルメモ開発日誌
2026年5月15日
起動速度の話をしたい。
500msは速いと言われる。違う。実は500msは「ユーザーが意識的に待った」と感じる閾値だ。
だから本当の戦場は300ms以下にある。
前回(Day3)では、Outboxを書き直して送信失敗の再送漏れを消した話を書いた。今回はその一つ前、起動からテキスト入力可能までの時間(以下、Time-to-Text)の話だ。
Captio式シンプルメモが大切にしている数字は0.3秒。ユーザーがアプリアイコンをタップしてから、キャレットが点滅しはじめてキーボードが立ち上がるまで0.3秒。これを実現するのに何が効いて、何が効かなかったのか、3週間掛けて検証した記録を残す。
なぜ「Time-to-Text 500ms」は遅いのか
人間の知覚研究では、操作と反応の間が100ms以下なら「直結している」と感じる。200msから400msは「速いが、待っている」と感じる。500msを超えると「アプリが応答していない」と無意識に判断する。
これはNielsenの古典的なレスポンスタイム指標とほぼ一致する。ところがメモアプリの起動はWebレスポンスと違う。アイコンをタップした瞬間に、ユーザーは頭の中にすでに書きたい文字を持っている。500msあいだ画面を見つめると、その文字が逃げる。
ベータテスターから集めた起動速度のフィードバックも同じ方向を指していた。「速いね」と言われたビルドは平均330ms、「もたつく」と言われたビルドは平均520ms。160msの差が体感の境目になっていた。これは個別の主観ではない、12人の被験者で再現する閾値だった。
検索すれば「アプリ 起動速度 改善 Swift」で出てくる記事の多くは1秒〜2秒の話をしている。それは別の戦場だ。0.3秒〜0.5秒の領域は、メモ・ToDo・ジャーナル系のアプリにしか必要ない。逆に言えば、ここを詰めると差別化要素になる。
Captioの前身であるCapitoが愛されていたのは、まさにここだった。アイコンを押した次の瞬間にキャレットが点滅していた。だから「思いついた瞬間」をそのまま記録できた。これを2026年のSwiftで再現する。
計測の単位はどこからどこまでなのか
「起動速度」と一口に言っても、計測の起点と終点は人によって違う。Apple の Instruments の App Launch テンプレートは、main() から最初のフレーム描画までを測る。これは「Process Launch Time」と呼ばれる。
しかし、ユーザー体験から見るとこれだけでは足りない。本当に欲しいのは「指がタップしてから、キャレットが点滅してキーボードが立ち上がるまで」だ。これを Time-to-Text と呼ぶ。Time-to-Text は Process Launch Time + ViewController 構築 + becomeFirstResponder + キーボード表示アニメーションの合計になる。
Captio では os_signpost を使って4区間に分けて計測している。区間を分けないと、どこに時間が溶けているか分からない。
import os.signpost
let log = OSLog(subsystem: "app.captio", category: .pointsOfInterest)
let sid = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "TTT", signpostID: sid)
// ... 起動処理 ...
os_signpost(.end, log: log, name: "TTT", signpostID: sid)
Instruments で Points of Interest トラックに表示されるので、どの段階で何msかかったか視覚的に追える。これがないと最適化は感覚に頼ることになる。
なぜUISceneSessionから手を入れるべきだったのか
最初の実装は AppDelegate ベースだった。application(_:didFinishLaunchingWithOptions:) で UIWindow を作り、RootViewController を立ち上げ、その viewDidLoad で UITextView を生成し、becomeFirstResponder を呼ぶ。よくある形だ。
これで計測すると、Time-to-Text は約 720ms だった。理由は明確で、ライフサイクルが直列だからだ。
iOS 13 以降の UISceneSession アーキテクチャは、起動時にシーンの復元情報(NSUserActivity)を渡せる。これを使うと、ViewController の初期化を待たずに「前回終了時の編集状態」を復元できる。
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
let editor = EditorViewController()
// 復元情報を viewDidLoad より前に渡す
if let activity = session.stateRestorationActivity {
editor.preload(from: activity)
}
window.rootViewController = editor
window.makeKeyAndVisible()
self.window = window
}
}
ポイントは stateRestorationActivity を viewDidLoad より前、makeKeyAndVisible より前に preload に渡すこと。viewDidLoad の中で復元処理を走らせると、UITextView のレイアウトが2回走る。これだけで100msちかく溶ける。
ApplicationSceneManifest の正しい設定
Info.plist の UIApplicationSceneManifest > UISceneConfigurations > UIWindowSceneSessionRoleApplication で、UISceneClassName と UISceneDelegateClassName を明示する。これを書かないと、iOS がデフォルトの SceneDelegate を生成しようとして起動が遅延する。
特に注意すべきは UISceneStoryboardFile を絶対に指定しないこと。Storyboard を経由するとパース時間で60〜90ms 持っていかれる。UIKit を選んでいる以上、Storyboard は使わない。Day1でも触れた「外部依存ゼロ」の延長線上にある判断だ。
preload の中で何をするのか
preload に渡された NSUserActivity から、テキスト内容・キャレット位置・スクロール位置を読む。UITextView の本体はまだ存在しないので、文字列だけインスタンス変数に置く。
final class EditorViewController: UIViewController {
private var pendingText: String?
private var pendingCaret: Int = 0
func preload(from activity: NSUserActivity) {
pendingText = activity.userInfo?["text"] as? String
pendingCaret = activity.userInfo?["caret"] as? Int ?? 0
}
override func loadView() {
let tv = UITextView(frame: .zero)
tv.text = pendingText
tv.selectedRange = NSRange(location: pendingCaret, length: 0)
self.view = tv
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
view.becomeFirstResponder()
}
}
loadView の中で UITextView を生成し、その場で text を設定する。viewDidLoad は呼ばれるが、ここでは何もしない。becomeFirstResponder は viewDidAppear で呼ぶ。viewWillAppear で呼ぶとキーボードのアニメーションが二重に走るからだ。
この再設計だけで Time-to-Text は 720ms から 410ms に落ちた。310ms の短縮。あと110ms をどう削るかが本番だ。
キーボード予熱は本当に効くのか
「アプリ起動時にキーボードを事前にメモリにロードしておく」というテクニックがある。具体的には、起動直後にダミーの UITextField を生成し、becomeFirstResponder と resignFirstResponder を立て続けに呼ぶ。これで KeyboardServices 系のフレームワークをロードさせ、本番の UITextView がキーボードを呼ぶときの初期化コストを下げる、という理屈だ。
検証した。結果は「条件付きで効く」だった。
enum KeyboardWarmer {
static func warm(in window: UIWindow) {
let dummy = UITextField()
dummy.alpha = 0
window.addSubview(dummy)
dummy.becomeFirstResponder()
DispatchQueue.main.async {
dummy.resignFirstResponder()
dummy.removeFromSuperview()
}
}
}
これを SceneDelegate の willConnectTo の最後で呼ぶと、Time-to-Text は 410ms から 360ms に下がる。50ms の短縮。
ただし、コールドスタートでないと意味がない。バックグラウンドから復帰したときはキーボードプロセスは生きているので、warm() を呼んでも効果はない。むしろ warm 自体が 10ms ほど消費する。
そこで UIApplication.shared.applicationState で起動経路を判別し、コールドスタート時だけ warm を呼ぶようにした。
if UIApplication.shared.applicationState == .background {
KeyboardWarmer.warm(in: window)
}
ここまで詰めて、平均 Time-to-Text は 305ms。やっと0.3秒の壁を越えた。だがここから先の最適化は、ほぼ徒労に近かった。
効かなかった2つの最適化
3週間の検証で、効くと思っていたのに効かなかったものが2つある。記録に残しておく。
Image Asset の事前ロード
「起動直後に必要なアイコン群を UIImage(named:) で事前にロードしておけば、最初の表示が速くなる」と考えた。結果、Time-to-Text は逆に20ms 増えた。
理由は、Apple が iOS 15 以降で Asset Catalog のロードを十分最適化しているからだ。手動で UIImage(named:) を呼ぶと、内部キャッシュとは別の経路でロードが走り、二重コストになる。Asset Catalog を信用する、が結論だった。
Core Data の事前 fetch
「起動時に過去のメモ履歴を事前に fetch しておけば、History 画面の表示が速くなる」と考えた。これも Time-to-Text を 40ms 遅らせた。
History 画面は起動直後には開かれない。0.3秒以内の予算では、起動経路に関係ないものは一切載せない、が正解だった。Core Data の fetch は、History 画面の viewWillAppear で初めて行う。Day3で書いた Outbox のフラッシュ処理も、willConnectTo からは外して URLSession.shared.dataTask の dispatchQueue 経由で別スレッドに逃がしてある。
反論:「起動速度を最適化する3週間で、機能を1つ作るべきだったのでは」
ここまで読んで「3週間も起動速度に費やすなら、新機能を1つ作った方がよかったのでは」と思った人がいるはずだ。これはまっとうな反論だ。
答えは「メモアプリ・ジャーナル系では起動速度が機能になる」だ。
メモアプリは1日に5〜30回開かれる。1回あたり400msの短縮は、年間で約1時間〜6時間の節約になる。だが、本当に重要なのは時間ではない。「思いついた瞬間に書ける」体験が成立するかどうかだ。
500msの起動でも書けるが、書こうとしていた文章が頭から消えることがある。300msなら消えない。これは0/1の差だ。だから、起動速度はメモアプリにおいて「機能」と同列に扱う。
ただし、ToDo管理・カレンダー・写真整理アプリでは話が違う。これらは1回ごとの操作時間が長いので、起動の100ms差は誤差だ。アプリの種類で「起動速度の重み」を変えるべき、というのが3週間の結論。
それでももう一段の反論がある。「3週間の最適化は、リリース後に Xcode のテンプレートが進化したら無効化されるのでは」というものだ。これは半分当たっている。実際、UIScene まわりは iOS のメジャーアップデートで挙動が変わることがある。だからこそ、今回の作業はコード差分として残し、毎年の WWDC 後に再計測する運用にしてある。手数はかかる。それでも、起動速度を「ブランドの一部」にすると決めた以上、メンテナンス対象から外さない。
0.3秒に届くまでの実装チェックリスト
実装の順番が大切だ。以下の順で着手すると、最小コストで0.3秒に届く。
- AppDelegate ベースから UISceneSession ベースへ移行する(必須)
- Info.plist の UIApplicationSceneManifest で SceneDelegate を明示する
- UISceneStoryboardFile は絶対に指定しない(Storyboard禁止)
- 起動経路の状態復元は stateRestorationActivity 経由で渡す
- ViewController の loadView 内で UI を構築し、viewDidLoad は空にする
- becomeFirstResponder は viewDidAppear で呼ぶ(viewWillAppear では呼ばない)
- コールドスタート判定でキーボード予熱を有効化する
- Asset の事前ロードはしない(Apple のキャッシュを信頼する)
- 起動経路に関係ない Core Data fetch は遅延させる
- Instruments の App Launch テンプレートで毎回計測する
最後の項目が最重要だ。「速くなった気がする」は信用しない。Xcode の Organizer の Launches を見て、リリース後のユーザー実機での起動時間も追跡する。aes-gcm の暗号化処理を起動経路に入れたい誘惑があるが、それも遅延させてある(これは別の回で書く)。
今日の学び:起動最適化は「足し算」より「引き算」だった
3週間の検証でいちばん大きな発見は、効いた手段の多くが「何かを足す」ではなく「何かを止める」だったことだ。
Storyboardをやめる。viewDidLoadで何もしない。Imageの事前ロードを止める。Core Dataのfetchを起動経路から外す。これらは全て「やらない」選択だった。
実装解剖というと、新しい仕組みを足すイメージで語られがちだ。実際は逆だった。起動経路から「不必要なもの」を1つずつ剥がしていく作業が9割を占めた。
これはおそらく、起動速度に限った話ではない。ユーザー体験の最適化全般に通用する。Day1で書いた「外部ライブラリ依存ゼロ」も、結局は「足さない」という設計判断の延長線上にある。
次回(Day5)予告:「送ったら消える」を支えるアニメーション設計
Day5では、メモを送信した瞬間にエディタが空になる0.25秒の3D変換と、その後の0.4秒の余韻について書く予定だ。具体的には以下の3点:
- CATransaction と UIView.animate の使い分け
- ページめくりアニメーションのカーブ選定(curveEaseOut vs curveEaseInOut)
- 連続送信時のアニメーション衝突をどう防ぐか
「送信完了の見せ方」は Day1 で予告してから2回触れてきたが、Day5でアニメーションの中身を完全に解剖する。
コメントで教えてください
メモアプリやテキスト入力系のアプリで「起動速度に救われた」「もしくは起動が遅くて使わなくなった」という体験があれば、コメントで教えてほしい。具体的なアプリ名と、その時にどんな文章を書こうとしていたかを書いてくれると、次回以降の開発で参考にする。
iOSに限らず、AndroidやMac/Windowsアプリでも歓迎する。「captio 代替 ios」「outbox swift 実装」のような検索でここに辿り着いた人の体験談を、率直に読みたい。
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