1
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?

UITextFieldの初回キーボードが遅い、起動時に暖めて消した【開発日誌 Day22】

1
Posted at

アプリのアイコンをタップしてから、カーソルが点滅して最初の一文字が打てるまで。その一瞬だけを、初めて真面目にログで測った。起動そのものは0.3秒に収めてあったのに、キーボードが出て入力を受け付けるまでを足すと、初回だけ体感がもたついていた。犯人はアプリの起動処理ではなく、iOSのキーボードが「その日はじめて」呼ばれるときのウォームアップだった。起動を速くしたつもりで、その先を測っていなかった、という話だ。

先に結論を置く。UITextFieldUITextView の最初の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()
        }
    }
}

考え方自体は昔からあって、becomeFirstResponderresignFirstResponder でキーボードを事前生成しておく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側の初回準備コストで、コードの綺麗さでは消えない。ただし becomeFirstResponderviewDidLoad で呼んでいると、そこに「効かない・遅れる」問題が上乗せされる。まずライフサイクルを 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

1
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
1
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?