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?

【開発日誌 Day4】起動0.3秒を越えるのに、結局UISceneの再設計が一番効いた

0
Posted at

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

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?