はじめに
メグリのアプリではWebViewを多く使っています。先日、WebView周りの実装を検討する中でAndroidのWebViewClientとiOSのWKNavigationDelegateのドキュメントを読み比べる機会があったのですが、コールバックの数が多く、名前も似ているわりに、それぞれがページ読み込みのどの時点で、どういう条件で呼ばれるのかがドキュメントだけではなかなか掴めませんでした。
そこで、ChromiumとWebKitのソースコードまで遡って主要なコールバックの発火条件を調べ、ページ読み込みを1本の時間軸に見立てて両OSのコールバックを並べてみました。この記事はその整理結果を備忘録代わりにまとめたものです。
対象はAndroidのandroid.webkit.WebView(Chromiumベース)とiOSのWKWebView(WebKit)で、ページ読み込みに関係するコールバックに絞っています。
なお、本記事作成にあたってはClaude CodeにChromiumやWebKitのソースコード読みを手伝ってもらいました。
先に: コールバックが2系統に分かれている理由
AndroidにはWebViewClientとWebChromeClient、iOSにはWKNavigationDelegateとWKUIDelegateがあり、最初はどちらに何があるのか分かりづらいところです。ここでの「Chrome」はGoogle Chromeのことではなく、ブラウザ用語のchrome(ページの外側にあるUI。アドレスバーや進捗バー、タイトル、ダイアログなど)を指しているようです。
| 系統 | 担当 | Android | iOS |
|---|---|---|---|
| 読み込み系 | ページの中身の読み込みとナビゲーション | WebViewClient |
WKNavigationDelegate |
| UI系(chrome) | ブラウザならUIとして見せるもの(JSダイアログ、新規ウィンドウ、全画面動画、ファイル選択、権限など) |
WebChromeClient(進捗率とタイトルもこちら) |
WKUIDelegate(進捗率とタイトルはWKWebViewのKVOで、WKUIDelegateではない) |
ページ読み込みの5つの時点
コールバックを時系列に並べると、ページ読み込みは以下の5つに分けられます。以下は通常のメインフレームの異文書ナビゲーションを基本形として説明します。同一文書内の遷移、BFCacheからの復帰、204応答、ダウンロード、エラーなどには例外があります。
- ① ナビゲーション開始(応答前。画面は旧ページ、または白)
- ② 応答が届き始める=コミット(白または背景色だけ)。コミットとは、応答ヘッダが届いてブラウザが古い文書を捨てて新しい文書に切り替えると決めた時点で、それより前(provisional)なら遷移は取りやめられ、画面も旧ページのままです
- ③ 旧ページの描画が終わる(次のdrawから新ページ側になる。ただし背景色だけの可能性がある)
- ④ 内容が見える=FCP(文字や画像が見える)
- ⑤ 読み込み完了=loadイベント
④のFCP(First Contentful Paint)はW3CのPaint Timing仕様で定義されている計測点で、空でないテキストや読み込み済みの画像などが初めて描画された時点を指します。背景色を塗っただけではFCPにはなりません。
全体像
発火する順に並べたものが以下の表です。「発火条件」はドキュメントとソースコードから読み取ったものです。図の◇は許可判定(返り値で中断できるもの)、▼は通知を表しています。
| 時点 | Android | iOS | 発火条件 |
|---|---|---|---|
| ①の手前 | shouldOverrideUrlLoading |
decidePolicyFor navigationAction |
ナビゲーションの許可判定(返り値で中断でき、中断すると以降のコールバックは来ない)。AndroidはloadUrl()で始めたものには呼ばれず、ページ起点・リンクタップ・HTTPリダイレクトのときだけで、POSTでも呼ばれない。iOSはアプリ起点でも呼ばれる。どちらもリダイレクト時はリダイレクト先について①〜②の間でもう一度呼ばれる |
| ① | —(androidx: onNavigationStarted) |
didStartProvisionalNavigation |
ナビゲーションが始まった時点(応答前。ネットワークリクエストの送信前後)。画面はまだ旧ページ。現行実装ではWebViewClientにこの時点で発火するコールバックが無い |
| ①〜② | —(androidx: onNavigationRedirected) |
didReceiveServerRedirectForProvisionalNavigation |
サーバリダイレクトが起きたとき |
| ②の直前 | — | decidePolicyFor navigationResponse |
応答ヘッダを見て許可するか。HTTPステータスを見られるのはここ |
| ② | onPageStarted |
didCommit |
メインフレームの内容を受信し始め、新しい文書に切り替わった(コミット)。まだ何も描かれていない。AndroidのonPageStartedは公式APIの説明では「読み込み開始」だが、現行のChromium実装ではここで発火する(後述) |
| ③ | onPageCommitVisible |
— | 古いページの内容がもう描かれないことが保証された時点。次の描画は背景色だけの可能性がある |
| ①〜⑤ | onProgressChanged |
KVO estimatedProgress
|
読み込み活動の推定値。両OSで式が違う(後述) |
| ③〜④ | androidx: onPageDomContentLoadedEvent
|
— |
DOMContentLoaded。パース完了 |
| ④ | JS first-contentful-paint、androidx: onFirstContentfulPaintMillis
|
JS first-contentful-paint
|
文字か画像が実際に描かれた |
| ④以降 | androidx: onLargestContentfulPaintMillis
|
— | LCP(Largest Contentful Paint)。ビューポート内で最も大きい画像やテキストブロックが描かれた時点。FCPより「主要な内容が見えた」に近い。iOSはネイティブのコールバックは無いが、Safari 26.2(iOS 26.2)以降ならJSのPerformanceObserverで取れる(それより前のWebKitは未対応) |
| ⑤ |
onPageFinished、androidx: onPageLoadEvent
|
didFinish |
メインフレームのloadイベント。描画の反映は保証されない |
| ⑤の後 | postVisualStateCallback |
— | 呼び出し時点までのDOM更新が次のdrawに反映されることを明示的に保証するAPI(ユーザーの画面に提示済みという意味ではない) |
| 随時 | onReceivedTitle |
KVO title
|
タイトルが変わったとき |
| 随時 | — | KVO isLoading
|
読み込み中かどうか。load(_:)の呼び出しからdidFinishまたは失敗系のコールバックまでtrue |
Androidの各コールバックの発火条件
shouldOverrideUrlLoading
ページ側から始まったナビゲーション(リンクタップやJS、HTTPリダイレクト)を進めてよいかをアプリに問い合わせるコールバックで、loadUrl()で始めたものには呼ばれません。
JavadocにはloadUrl()によるナビゲーションでは呼ばれないこと、POSTでは呼ばれないこと、サブフレームやHTTP(S)以外のスキームでも呼ばれうることが書かれています。trueを返すとそのナビゲーションは中断され、以降のコールバックは来ません。リダイレクトが起きるとリダイレクト先について①〜②の間でもう一度呼ばれます。
onPageStarted
公式APIの説明は「ページの読み込み開始」ですが、現行のChromium実装では、リクエスト送信時ではなく応答が届いてナビゲーションがコミットした時点(②)で呼ばれます(通常のメインフレームの異文書ナビゲーションの場合。HTTPエラーページの互換処理などの例外はあります)。
Chromiumの実装(AwWebContentsObserver.didFinishNavigationInPrimaryMainFrame)を読むと、hasCommitted()を確認した後に呼び出されていました。ナビゲーション開始時点で呼ぶ古い経路(AwContents側)も残っていますが、AwComputedFlags.pageStartedOnCommitEnabledが通常のアプリでは常にtrueを返すため使われません。手元のWebView 151でも、JSのnavigationStartからonPageStartedまでに応答待ちぶんの時間差(テストページで約100ms、実サイトで約600ms)がありました。ただしこれはAPI契約ではなく実装の話で、この経路が入る前の古いWebViewでは開始時点で呼ばれていた可能性があります。現行実装の時間軸上では、iOSのdidCommitと同じ位置に来ます。Javadocのとおりメインフレームにつき1回で、フラグメント遷移(#id)では呼ばれません。
onPageCommitVisible
古いページの内容がもう描かれないことが保証された時点(③)で呼ばれますが、新しい内容が描かれたことまでは保証しません。
AwWebContentsObserver.didFinishNavigationInPrimaryMainFrameで、ナビゲーションがコミットした後にvisual state callbackを登録し、そのonCompleteで呼ばれていました。同一文書内の遷移(isSameDocument)では呼びません。Javadocには「The next draw will display either the background color of the WebView, or some of the contents of the newly loaded page.」とあり、続けてCSSや画像などのリンクされたリソースは利用できない可能性があると書かれています。
また、postVisualStateCallbackの条件がそのまま適用されるとも書かれています。WebViewをGONEで隠したまま待っている場合は、ビュー階層にattach済みであること、LayoutParamsの幅と高さが固定値であること、コールバック内でVISIBLEにすることが条件です。
onProgressChanged
読み込み活動の推定値(0〜100)で、10から始まり、優先度の高いリソースが読み終わるまで90で頭打ちになり、描画とはほぼ無関係です。
BlinkのProgressTracker(progress_tracker.cc。読んだのはコミット3132cd9時点のmainで、実測に使ったWebView 151と完全に同じ版ではありません)の値をAwWebContentsObserver.loadProgressChangedでMath.round(progress * 100)したものが渡ってきます。値はMaybeSendProgress()で以下の式のとおり決まっていました。
progress = 10(開始)
+ 10(コミット)
+ 10(パース完了)
+ 10(first contentful paint)
+ 50 × 受信バイト ÷ 追跡中リソースの推定バイト ← 上限90
パース・FCP・追跡対象の受信が揃うと100。load完了時はFCPの有無と無関係に100
追跡対象になるのは読み込み開始時点で優先度High以上のリソース(CSS、フォント、同期のscript、fetch/XHR)だけです。通常の画像やasync/deferのscriptは優先度Lowなので数えませんが、判定基準は種別ではなく優先度なので、可視領域の画像やfetchpriority="high"を付けたものはHighに上がって数えられることがあります。またパース完了とFCPの後に始まったリソースも追跡しません。「あとは残す」設計なので、追跡中のリソースが返ってこない限り90付近で頭打ちになります。一方で、今回確認した実装では100になる経路が2つあります。load完了時にProgressCompleted()からSendFinalProgress()を呼ぶ経路はFCPの有無と無関係に100を送り、逆にパースとFCPが済んで追跡対象の受信も終わった場合はloadイベントより前に100になり得ます。100をload完了やFCPの代替指標として扱うべきではありません。
onPageFinished
メインフレームのページ読み込み完了(⑤)を通知します。内部的にはloadイベントと読み込み停止の処理が関係しますが、コールバック時点で次の描画がDOMを反映していることは保証されません。
AwWebContentsObserverではdidFinishLoadInPrimaryMainFrame(loadイベント)でURLを控えておき、didStopLoadingでそのURLと一致したときに投げていました。エラーページや、重複ナビゲーションが無視されたときにも互換性のために合成して呼ばれることがあります。フラグメント遷移ではonPageStartedは来ませんがonPageFinishedだけは投げられます。Javadocには、このコールバックを受け取っても次に描くフレームがその時点のDOMを反映している保証はないと明記されています。
onLoadResource / shouldInterceptRequest
画像やscriptなどサブリソース1つごとに呼ばれるコールバックで、ページ全体の進み具合を知る用途には向いていません。
どちらもメインフレーム以外でも呼ばれます。onLoadResourceは「読みに行く」通知、shouldInterceptRequestは応答を差し替えるためのものです。
androidx.webkit の NavigationListener
androidx.webkitが提供する新しいリスナーで、ナビゲーションの開始・リダイレクト・完了に加えて、DOMContentLoaded、load、FCP、LCPをJS注入なしで受け取れます。
WebViewCompat.addNavigationListenerで登録し、WebViewFeature.NAVIGATION_LISTENERが必要です。androidx.webkit 1.15.0で追加され1.16.0でstableになったAPIで、FCP / LCPのコールバックは1.16.0で足されています。それより前のexperimental APIがWebNavigationClientでした。コールバックはonNavigationStarted / onNavigationRedirected / onNavigationCompletedのナビゲーション系と、onPageDomContentLoadedEvent / onPageLoadEvent / onFirstContentfulPaintMillis / onLargestContentfulPaintMillis(LCP=Largest Contentful Paint。最も大きい内容要素が描かれた時点)/ onPerformanceMarkMillisのページ系です。メインフレームのみで、同一文書内の遷移もonNavigationStartedの対象です。onNavigationCompletedは204やダウンロード、キャンセルでコミットしなかった場合にも呼ばれます。
Javadocに「Delivery of this event may be delayed due to batching with other events, so it may not be sent in real-time」とあるので、遅れることがある前提で使う必要があります。
iOSの各コールバックの発火条件
decidePolicyFor navigationAction
ナビゲーションを進めてよいかをアプリに問い合わせるコールバックで、アプリ起点のload(_:)でも呼ばれます。
AndroidのshouldOverrideUrlLoadingと違い、アプリが始めたナビゲーションでも毎回問い合わせが来ます。WebKitのDocumentLoader::willSendRequestは初回リクエストでもリダイレクトでもcheckNavigationPolicyを呼ぶので、リダイレクト時はリダイレクト先について①〜②の間でもう一度呼ばれます。
didStartProvisionalNavigation
ナビゲーションの許可が出て、応答が届く前の時点(①)で呼ばれます。画面はまだ旧ページです。
Appleのドキュメントには「after it receives provisional approval to process a navigation request, but before it receives a response to that request」とあります。
didReceiveServerRedirectForProvisionalNavigation
サーバリダイレクトを受けたときに呼ばれます(①〜②)。
decidePolicyFor navigationResponse
ナビゲーションのレスポンスを受け取った後、その内容を見てナビゲーションを進めてよいかを問い合わせるコールバックです。HTTPステータスを確認できるのはここです。時間軸上は通常コミットの直前(②の直前)に位置しますが、APIの目的は時点の保証ではなくポリシー判定です。
didCommit
応答が届いて新しい文書に切り替わった時点(②)で呼ばれますが、まだ何も描かれていません。
Appleのドキュメントには「has started to receive content for the main frame」「immediately before it starts to update the main frame」とあり、描画については何も書かれていません。現行実装のAndroidではonPageStartedとonPageCommitVisibleがこの付近に来ます。
didFinish
ナビゲーションが完了した時点(⑤)で呼ばれます。
Appleのドキュメントは「navigation is complete」とだけ書いています。AndroidのonPageFinishedと同じく、描画の反映は保証されません。
estimatedProgress(KVO)
読み込み活動の推定値(0.0〜1.0)で、全リソースの受信量と推定残量から段階的に進み、描画とは無関係です。Androidとは式が違います。
WebKitのProgressTracker.cppを読むと、Blinkと違って優先度でふるいにかけず、全リソースについて受信バイトと(未確定分は推定サイズを使った)残量から段階的に進めます。最初のレイアウトが終わるまでは上限が0.5で、1.0になるのは読み込み完了時でした。Appleのドキュメントも「based on the total number of bytes received, including the main document and all of its potential subresources」と説明しています。同じページでもAndroidのprogressとは同じ値になりません。
isLoading(KVO)
読み込み中かどうかの真偽値で、アプリがload(_:)などを呼んだ時点(①の手前)でtrueになり、didFinishか失敗系のコールバック(⑤)でfalseに戻ります。
WebKitのPageLoadStateを読むと、状態がprovisionalまたはcommittedの間、およびアプリからの読み込み要求が保留中の間はtrueを返していました。falseになるのはdidFinishの時点なので、FCP(④)より後です。読み込み率が要らないならestimatedProgressよりこちらで足りますが、描画の判定には使えません。
FCPについて
iOSにはFCPを直接返す公開APIが無いので、JSのPerformanceObserverで取ります。
WKNavigationDelegateにFCPに相当するコールバックはなく、_WKRenderingProgressEventはprivate APIです。WebKitのPaint Timing対応はSafari 14.1 / iOS 14.5以降で、first-paintはなくfirst-contentful-paintのみです(WebKitのSafari 14.1紹介記事はfirst-paintも挙げていますが、MDNの互換データではSafariのfirst-paintは未対応で、WebKit側の実装チケットBug 208499もFCPのみを対象にしています)。14.5未満ではPaint Timing自体が使えません。さらに古いWebKitではobserve()のtypeオプション自体が未対応で、entryTypesが必須だとしてTypeErrorになるバージョンもあります(WebKit Bug 209216。現行のWebKitでは未対応のtypeは黙って無視されます)。古いOSをサポートする場合は、feature detectionかtry-catchで保護した上で、FCPを待たずに済むフォールバック(didCommit+猶予など)を用意しておくのが安全です。
エラー系のコールバック
| Android | iOS | 条件 |
|---|---|---|
onReceivedError(WebView, WebResourceRequest, WebResourceError) |
didFailProvisionalNavigation / didFail
|
接続できない等。iOSはコミット前後で分かれる。Androidは区別がなく、iframeや画像などあらゆるリソースで呼ばれるのでrequest.isForMainFrameで絞る |
onReceivedHttpError |
decidePolicyFor navigationResponse |
HTTP 4xx / 5xx。どちらのOSも上の行のコールバックには来ない。onReceivedHttpErrorも画像やファビコンなどあらゆるリソースで呼ばれるので、ページ自体のエラーとして扱うならrequest.isForMainFrameで絞る |
onReceivedSslError(HTTP認証はonReceivedHttpAuthRequest) |
didReceive challenge |
AndroidはSSL証明書エラー専用で、回復可能なものだけ来る(回復不能なものはonReceivedError)。iOSのdidReceive challengeはサーバ証明書の信頼確認(protectionSpace.authenticationMethodがNSURLAuthenticationMethodServerTrustのとき)を含むURL認証チャレンジ全般で、HTTP認証も同じコールバックに来る。完全な対応関係ではない |
onRenderProcessGone |
webViewWebContentProcessDidTerminate |
レンダラ/コンテンツプロセスの終了。読み込み系のコールバックは何も来ないまま止まる |
| — | navigationResponse didBecome download |
応答をダウンロードとして扱った場合。以降はナビゲーションではなくWKDownload側で進む |
ページ内コンテンツの描画開始を判定するには
コミット系のコールバック(AndroidのonPageStartedとonPageCommitVisible、iOSのdidCommit)は白画面区間の入口であって、ページ内コンテンツが描画された根拠にはならず、onPageFinished / didFinishも描画を保証していません。「ページ内コンテンツの描画が始まった」ことの指標として両OSで使えるのはFCPで、AndroidはJS注入なしでNavigationListenerから取れ、iOSはJSで取ることになります。ただしFCPはWebViewの中でページが描画されたことを示すだけで、WebViewが画面外だったり他のViewに覆われていたりしても発生するので、ユーザーに実際に見えていることや、十分な内容が視認できることまでは保証しません。
progressの閾値を描画の代わりに使えるかも実機(Android1台)で確かめてみましたが、実サイト8件でFCPの瞬間のprogressは44から100までばらついていて、安全な閾値はありませんでした。前述のとおり100はload完了時にFCPと無関係に送られ得るので、100になった後にFCPが来ることも理論上はあり得ます。
おわりに
この記事では、AndroidとiOSのWebViewの主要なコールバックを1本の時間軸に並べて、それぞれの発火条件をドキュメントとソースコードから整理しました。名前だけ見ると対になっているように見えるコールバックでも、発火する時点や保証される状態が違っていることが分かりました。
参考
参照したのは主に以下です(2026年9月時点のmainブランチ)
- Android Javadoc(AOSP): WebViewClient、WebChromeClient、WebView#postVisualStateCallback
- Chromium(progress の式はコミット
3132cd9で確認): progress_tracker.cc @3132cd9、AwWebContentsObserver.java、AwContents.java、progress_tracker.cc、resource_fetcher.cc - androidx.webkit: NavigationListener、WebViewCompat.addNavigationListener
- WebKit: ProgressTracker.cpp、PerformanceObserver.cpp、PageLoadState.cpp
- Apple Developer Documentation: WKNavigationDelegate、WKWebView.estimatedProgress、WKWebView.isLoading
- MDN: PerformancePaintTiming の対応状況
- W3C: Paint Timing、Performance Timeline
- WebKit Blog: New WebKit Features in Safari 14.1
- WebKit Bugzilla: Bug 208499 – Implement FCP (first contentful paint)、Bug 209216 – PerformanceObserver should work with 'type'
- Apple: Safari 26.2 Release Notes(Largest Contentful Paint 対応)


