アプリのアイコンをタップしてから、カーソルが点滅して最初の一文字が打てるまで。その一瞬だけを、初めて真面目にログで測った。起動そのものは0.3秒に収めてあったのに、キーボードが出て入力を受け付けるまでを足すと、初回だけ体感がもたついていた。犯人はアプリの起動処理ではなく、iOSのキーボードが「その日はじめて」呼ばれるときのウォームアップだった。起動を速くしたつもりで、その先を測っていなかった、という話だ。
先に結論を置く。UITextField や UITextView の最初の1回のフォーカスは、OSがキーボード関連のリソースをまとめて準備するぶんだけ、2回目以降より明確に遅い。手元の検証機では、初回のフォーカスから実際に入力できるまでが約380ms、2回目以降は約40msで、同じ操作なのに1桁近い差が出た。対策は2つに落ち着いた。ひとつ、becomeFirstResponder() は viewDidLoad ではなく、ビューが画面に乗り切ったあと(viewDidAppear 相当)で呼ぶ。ふたつ、起動直後の空き時間に、画面外のダミー入力欄を一瞬だけ becomeFirstResponder() → resignFirstResponder() して、キーボードを「暖めて、すぐ消す」。この2手で、ユーザーが最初に触れる入力欄のもたつきは、体感でほぼ見えなくなった。
「起動0.3秒」の内訳に、キーボードは入っていなかった
このアプリは、開いた瞬間に入力欄へフォーカスが当たり、そのまま書き始められることを最優先に作ってある。だから起動速度には以前から神経を使っていて、起動を0.3秒に詰めた回では、UIScene の構成や初期化の順番を削って数字を出した。共有シートを経由しない設計でメモ開始を1.2秒速くした回も、根っこは同じ「書き始めるまでの時間」への執着だ。
ところが、そこで測っていた0.3秒の終点は「画面が見えた」であって、「文字が打てる」ではなかった。人間が入力を始められるのは、キーボードが立ち上がってカーソルが点滅してからだ。画面が出た時刻ではなく、最初のキーストロークを受け付けられる時刻を終点に置き直したとき、初めて数字がずれた。速いと思っていた入口に、測っていない区間が残っていた。
初回だけ遅い、を数字にする
体感を数字に変えないと直せない。まず、フォーカスを要求した瞬間と、キーボードが実際に出た瞬間の差を測った。becomeFirstResponder() を呼ぶ直前に時刻を取り、keyboardDidShowNotification が飛んできた時刻との差を出す、という素朴なやり方だ。
private var focusRequestedAt: CFTimeInterval = 0
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
focusRequestedAt = CACurrentMediaTime()
textView.becomeFirstResponder()
}
// AppDelegate かどこか一箇所で購読しておく
NotificationCenter.default.addObserver(
forName: UIResponder.keyboardDidShowNotification,
object: nil, queue: .main
) { [weak self] _ in
guard let self, self.focusRequestedAt > 0 else { return }
let ms = (CACurrentMediaTime() - self.focusRequestedAt) * 1000
os_log("keyboard show latency: %.0f ms", ms)
self.focusRequestedAt = 0
}
通知の時刻は必ずしも「入力できるようになった瞬間」と一致しないので、より正確に区間を切り出すなら os_signpost で挟むのが確実だった。Instrumentsのタイムラインで、フォーカス要求からキーボード出現までがひとつの帯として見える。
import os.signpost
let log = OSLog(subsystem: "app.capture", category: "keyboard")
func focusWithSignpost(_ view: UIView) {
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: "first-focus", signpostID: id)
view.becomeFirstResponder()
// keyboardDidShow の購読側で .end を打つ
pendingSignpost = (log, id)
}
これで、起動直後の初回フォーカスと、いったんキーボードを閉じてからの再フォーカスを、それぞれ十数回ずつ測った。検証機はミドルレンジのiPhoneで、OSはiOS 26。1回だけの計測は当てにならないので、中央値で見る。結果は次のとおりだった。数字そのものより、初回と2回目以降の「差」が安定して出ることが重要だ。
| 状況 | フォーカス→入力可能までの中央値 |
|---|---|
| 起動後、初回のフォーカス | 約380ms |
| 同一起動内、2回目以降のフォーカス | 約40ms |
| アプリ再起動後の初回 | 約360ms |
| プリウォーム実施後の初回 | 約70ms |
初回だけが飛び抜けて遅い。しかもアプリを再起動すると、また遅い初回が戻ってくる。つまりこれはアプリ内の状態ではなく、プロセスをまたいで毎回発生する「最初の1回」の費用だった。絶対値は端末とOSで動くが、初回と2回目以降で1桁近く違う、という構図はどの端末でも再現した。低電力モードのときは初回・2回目とも一律に伸びたが、両者の比は変わらなかった。だから狙うべきは「初回を2回目に近づける」ことだと絞り込めた。ここで初めて、直すべき対象が起動処理ではなくキーボードの初回準備だと確信できた。測って切り分けるまでは、ずっと UIScene の初期化を疑っていた。見当違いの場所を何度も速くしようとしていた、というのが正直なところだ。
なぜ最初のキーボードだけ遅いのか?
キーボードは、見た目こそただのキーの並びだが、初回表示時に準備するものが多い。入力モードの解決、予測変換や自動修正の下準備、レイアウトの生成、各種プロパティのキャッシュ。これらが「最初の1回」に集中し、2回目以降はキャッシュが効いて安くなる。だから初回だけ遅い。
これは新しい発見ではなく、古くから知られた挙動だ。Appleの開発者フォーラムにもキーボードの初回表示が遅いという報告が繰り返し立っているし、古い端末では初回に0.5〜0.9秒かかるという記録も残っている。近年のSDKでも、初回の keyboardWillShow が以前より遅れて飛ぶ、という声は途切れない。要するに「初回は重い」はOS側の性質で、こちらのコードの綺麗さでは消せない。消せないなら、ユーザーがそれを踏む前に、こちらから先に踏んでおくしかない。
becomeFirstResponderはviewDidLoadで呼んでいいのか?
もうひとつ、初回のもたつきを増やしていた自分のミスがあった。フォーカスを viewDidLoad で呼んでいたのだ。結論から言うと、これは良くない。viewDidLoad の時点では、ビューはまだウィンドウ階層に乗っていないことがある。この段階で becomeFirstResponder() を呼ぶと、アニメーションの有無や表示経路によっては、無視されたり、遅れて効いたりする。「becomeFirstResponder が効かない」で検索すると、同じ罠を踏んだ記録がいくらでも出てくる。
正しくは、ビューがウィンドウに乗り、表示のアニメーションまで終わった viewDidAppear で呼ぶ。ここなら第一応答者になる条件がそろっている。
final class CaptureViewController: UIViewController {
private let textView = UITextView()
override func viewDidLoad() {
super.viewDidLoad()
// ここでフォーカスを取らない。準備だけ。
textView.autocorrectionType = .default
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// ビューがウィンドウに乗り切ってから第一応答者になる
if !textView.isFirstResponder {
textView.becomeFirstResponder()
}
}
}
viewWillAppear でも動く場合はあるが、遷移アニメーションとキーボードの立ち上がりが重なって、動きがぎこちなくなることがある。安定を取るなら viewDidAppear が無難だった。ライフサイクルのどこで呼ぶかは、速さの問題であると同時に、確実に効くかどうかの問題でもある。
起動直後にキーボードを「暖めて消す」
初回が重いのが避けられないなら、その重い1回を、ユーザーが待っていない時間に前倒しする。起動直後、画面が描かれてひと段落した頃に、画面外へ置いたダミーの UITextField を一瞬だけ第一応答者にして、すぐ辞めさせる。これでOSはキーボードの初回準備を済ませ、本命の入力欄にフォーカスが当たるときには2回目以降の速さになっている。
final class KeyboardWarmer {
private let dummy = UITextField()
func warm(in window: UIWindow) {
dummy.frame = .zero
dummy.isHidden = true
window.addSubview(dummy)
dummy.becomeFirstResponder()
// 目に見える前にすぐ辞めさせる
DispatchQueue.main.async { [dummy] in
dummy.resignFirstResponder()
dummy.removeFromSuperview()
}
}
}
考え方自体は昔からあって、becomeFirstResponder と resignFirstResponder でキーボードを事前生成しておくUIResponder-KeyboardCacheのような実装が知られている。やっていることは同じで、初回コストを任意のタイミングに移すだけだ。前掲の表のとおり、このプリウォームを起動直後に一度だけ挟むと、本命の初回フォーカスは約380msから約70msまで下がった。ゼロにはならないが、体感のもたつきが消える水準には入る。
実装で気をつけた点は3つある。ひとつは、1回の起動につき一度しか走らせないこと。フォアグラウンド復帰のたびに暖め直すと、無駄な明滅の原因になる。ふたつは、暖めるのは最初の入力欄が表示されたあとにすること。表示前に走らせると、本命のフォーカスと取り合いになる。みっつは、ダミーを画面外かつ非表示に置き、キーボードのアニメーションが目に見えないうちに畳むこと。次のように、一度だけ走る保証を足しておくと安全だった。
private var didWarm = false
func warmOnceAfterFirstFrame(in window: UIWindow) {
guard !didWarm else { return }
didWarm = true
// 最初のフレームが出た次のロータイミングで走らせる
DispatchQueue.main.async {
self.warmer.warm(in: window)
}
}
暖めるタイミングを間違えると、起動が遅くなる
この手は、置き場所を間違えると逆効果になる。プリウォームはタダではなく、初回キーボード生成のコストそのものだ。もし application(_:didFinishLaunchingWithOptions:) の中で同期的に呼べば、そのコストは起動時間に足し戻され、せっかく詰めた0.3秒が台無しになる。前倒しは、起動の主要な描画が終わって、最初のフレームが出たあとの空き時間にやる。自分は最初の入力欄が表示された直後、1フレーム待ってから走らせる形にした。ユーザーが最初の文字を打ち始めるまでの数百ミリ秒の隙に紛れ込ませる、というのが狙いだ。
正直に言えば、この対策に懐疑的な立場もありうる。初回の380msなど誰も気にしない、無視して素の実装のままにしろ、という考えだ。実際、通知アプリやたまにしか入力欄を出さないアプリなら、それが正しい。だがこのアプリは、開いてすぐ書くことだけを仕事にしている。入口の1回目こそが体験の第一印象になる。だからここでは、たとえ数百ミリ秒でも先に潰す価値がある、と判断した。どのアプリにも効く話ではなく、「最初の入力の速さ」を売りにするアプリでだけ割に合う投資だ。
手がふさがっている場面なら音声入力という逃げ道もあるが、両手が空いていて速く打ちたいときの主役はやはりキーボードだ。だから初回のもたつきは、体験の入口として無視しにくい。
直したあとに残った、正直な限界
プリウォームは初回コストを移すだけで、消すわけではない。OSやメモリの状況によっては、暖めたあとでも初回が想定より重く出ることがあった。近年はiOS 18で最初のタップ後に極端な遅延が出るという報告もあり、初回キーボードまわりの挙動はOSバージョンごとに揺れる。予測変換やディクテーションの初回ロードなど、こちらから前倒ししきれない部分も残る。だから「必ず70msになる」とは言えない。言えるのは、素のままより中央値が明確に下がり、最悪値のばらつきも縮んだ、という測定結果だけだ。効果はうたい文句ではなく、自分の端末で出た数字の範囲で語るのが誠実だと思う。
SwiftUIでフォーカスを当てるなら、どこで呼ぶか
このアプリの入力画面はUIKitだが、SwiftUIでも初回コストの構図は変わらない。SwiftUIでは @FocusState と .focused でフォーカスを制御するが、ここでも「いつ当てるか」が効く。onAppear の同期処理の中で当てると、画面の生成とキーボードの立ち上がりが重なってぎこちなくなることがある。ワンテンポ遅らせて、画面が出てから当てるほうが安定した。
struct CaptureView: View {
@FocusState private var focused: Bool
@State private var text = ""
var body: some View {
TextEditor(text: $text)
.focused($focused)
.onAppear {
// 生成と同フレームで当てず、ひと呼吸置く
DispatchQueue.main.async { focused = true }
}
}
}
プリウォームも同じ発想で足せる。UIKitのダミー入力欄を、SwiftUIのライフサイクルの外側(UIApplicationDelegate やシーンの立ち上がり後の空き時間)で一度だけ走らせればいい。SwiftUIかUIKitかは、初回コストの話にはほとんど関係がなかった。暖めるのはあくまでOSのキーボードで、どちらのフレームワークから触っても暖まる先は同じだからだ。
よくある疑問(FAQ)
Q. 最初のキーボード表示が遅いのは、自分のコードのせい?
A. 多くはOS側の初回準備コストで、コードの綺麗さでは消えない。ただし becomeFirstResponder を viewDidLoad で呼んでいると、そこに「効かない・遅れる」問題が上乗せされる。まずライフサイクルを viewDidAppear に直し、それでも初回が重ければプリウォームを足す、の順が良い。
Q. プリウォームは起動時間を増やさない?
A. 置き場所しだいで増やす。didFinishLaunching で同期的に走らせると起動に足し戻る。最初のフレームが出たあとの空き時間に、一度だけ非同期で走らせれば、起動の数字はほぼ動かない。
Q. UITextView でも UITextField でも同じ?
A. 初回コストはキーボード側の話なので、どちらでも起きる。プリウォーム用のダミーはどちらか軽い方(UITextField)で構わない。本命が UITextView でも、暖まるのはキーボード本体なので効く。
Q. SwiftUIの .focused でも起きる?
A. 起きる。内部で第一応答者になる仕組みは同じで、初回コストも同じように乗る。SwiftUIの場合はフォーカスを当てる TextField の登場タイミングを、画面が出たあとに寄せるのがまず先。
Q. 効果はどのくらい確実?
A. 端末・OSで変わる。手元では初回約380ms→約70msだったが、環境次第で差は縮む。導入前に自分のアプリで初回と2回目を測り、差が大きいときだけ入れるのが妥当だ。
起動を速くするというのは、画面を早く出すことだと思い込んでいた。実際に測ると、速さの終点はいつも「ユーザーが次の操作を始められた時刻」だった。今回で言えば、最初の一文字が打てた時刻だ。次は、まだ前倒しできていないディクテーション初回ロードの費用を、キーボードと同じやり方で測ってみたい。もし読者の中で、この「初回だけ暖める」を別のUI部品(カメラやマップの初回起動など)に応用している人がいたら、どこまでを前倒しして、どこからは諦めているのか、その線引きを聞いてみたい。
Obsidian連携シンプルメモ
外部ライブラリを足さず、Swiftと純正フレームワークだけで組んだ、開いてすぐ書ける起動0.3秒のメモアプリ。
App Store:https://apps.apple.com/jp/app/id6758438948