如何攔截 Android WebView 重導向中的自訂 URL Scheme

opoinstall
2026-09-21
5 min read

如何在 Android WebView 中處理深層連結(Deep Linking)與自訂 Scheme? 在 Android WebView 中處理深層連結需要擴充 WebViewClient,並覆寫 shouldOverrideUrlLoading 以檢查 isForMainFrame,同時針對白名單驗證自訂 Scheme,最後安全地啟動授權的外部 Intent,以防止非網頁端協定殘留在 WebView 的載入流程中。

Android WebView 深層連結攔截是一種程式設計實踐,透過覆寫自訂 WebViewClient 內的導航回呼(navigation callbacks)來捕獲非 HTTP 協定,例如自訂 URI Scheme 與 Chrome Intent URI。透過評估請求元數據與驗證目標封裝(payload),主應用程式可執行原生的 Intent 啟動,並防止未處理的協定導致崩潰。

術語 定義 相關實體 搜尋意圖角色
WebView 一種基於 Chromium 的嵌入式檢視元件,用於在 Android 應用程式內呈現網頁內容。 Android SDK 資訊性 / 商業
Custom URL Scheme 一種專有 URI 協定,允許網頁連結目標指向原生應用程式。 深層連結路由 技術性 / 資訊性
Web to App 引導使用者從網頁轉換至原生應用程式介面的過程。 行動深層連結 資訊性

在 Chromium 發生錯誤前,必須將自訂 Scheme 從 WebView 載入管道中移除。

為什麼 Android WebView 會拋出 net ERR UNKNOWN URL SCHEME 錯誤

Chromium 協定限制:為何 WebView 原生僅識別標準網頁 Scheme

Android WebView 元件由底層的 Chromium 渲染引擎驅動,該引擎主要配置為解析與顯示標準網頁協定:http://、https://、file://、data://、about: 與 javascript:。

預設情況下,若未指定自訂 WebViewClient,Android 框架會將未處理的 URL 委派給系統的 Activity Manager。然而,一旦應用程式提供自訂 WebViewClient,非 HTTP(S) 的 Scheme(例如 myapp://product/detail/1024、alipay:// 或 whatsapp://)會直接進入客戶端的導航回呼。如果主應用程式讓自訂 Scheme 保留在 WebView 的載入管道中而非進行攔截,Chromium 引擎會嘗試將其解析為一般的網頁資源。由於非標準協定無法透過 DNS 或網頁伺服器傳輸層進行解析,引擎會引發低層級的導航失敗。

net::ERR_UNKNOWN_URL_SCHEME 的崩潰剖析:從使用者點擊到資源載入失敗

從使用者互動到出現錯誤頁面的過程,通常遵循快速且確定的序列:

  1. 使用者觸發:使用者點擊嵌入式 H5 登陸頁面上的行動呼籲(CTA)按鈕或超連結。
  2. 導航請求:網頁引擎建構包含目標 URI 字串(例如 myapp://promo/summer)的導航請求。
  3. 管道評估:如果 shouldOverrideUrlLoading() 回傳 false 或未能攔截非 HTTP(S) 的 URI,Chromium 引擎會將此 URL 視為標準網頁資源進行載入。
  4. 錯誤分發:因找不到該協定的內部處理程式,引擎會觸發 onReceivedError() 並以預設的 Chromium 錯誤模板替換網頁檢視內容:net::ERR_UNKNOWN_URL_SCHEME。

此錯誤頁面會中斷使用者旅程,妨礙轉換追蹤,並降低主應用程式的可感知穩定性。

目標應用程式不存在時的 ActivityNotFoundException 崩潰風險

當開發者首次嘗試解決 net::ERR_UNKNOWN_URL_SCHEME 時,常見的錯誤是解析 URI 字串並在沒有足夠異常處理的情況下呼叫 context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))。

如果使用者裝置上未安裝目標應用程式,Android 作業系統將無法找到符合的 Activity 來解析該 Intent。在標準 Android 執行環境中,在沒有對應處理程式的情況下呼叫 startActivity() 會拋出未處理的 android.content.ActivityNotFoundException。若未在結構化的 try-catch 區塊內攔截此異常,整個主應用程式進程將突然終止,導致致命的崩潰。

shouldOverrideUrlLoading 如何攔截深層連結協定

API 演進:比較 API 24+ 的 WebResourceRequest 多載與傳統字串回呼

Android 的 WebViewClient 提供了 shouldOverrideUrlLoading 回呼,允許主應用程式在網頁引擎執行之前檢查並攔截導航請求。此方法的簽章與功能在各個 Android API 層級中有所演進:

  • API 24+ (Android 7.0 及更高版本):覆寫 shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean。WebResourceRequest 介面提供豐富的環境元數據,包括目標 URL (request.url)、HTTP 請求方法 (request.method)、請求標頭,以及關鍵的框架層級標記(request.isForMainFrame, request.hasGesture())。
  • 傳統多載 (於 API 24 棄用):覆寫 shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean。此傳統回呼僅接收原始 URL 字串,不包含請求框架或使用者互動狀態的相關元數據。

根據 Android Developers 關於 WebViewClient shouldOverrideUrlLoading 的參考資料,現代應用程式必須實作 WebResourceRequest 多載。在支援舊版 Android 時,應審慎配置已棄用的字串回呼,以防止安全性繞過。

request.isForMainFrame 的安全性關鍵:封鎖子框架與 Iframe 劫持

主框架檢查可防止 iframe 導航啟動外部應用程式路由。

根據 Android Developers 關於 WebResourceRequest 的指南,shouldOverrideUrlLoading 回呼不僅會針對頂層文件導航觸發,亦會針對子框架、iframe 與後台資源重導向觸發。

強制執行 request.isForMainFrame 對應用程式安全性至關重要:

  • 框架邊界隔離:檢查 request.isForMainFrame 可確保子框架與 iframe 無法觸發外部應用程式啟動或自訂 Scheme Intent。若子框架嘗試導航,標準 HTTP(S) 載入應回傳 false 以繼續在框架內渲染,而非 HTTP(S) 的 Scheme 則必須回傳 true 以在不拋出錯誤的情況下抑制執行。
  • 使用者手勢區分:雖然 isForMainFrame 確認請求適用於主要文件,但它並不能證明使用者已進行啟動。如果您的架構需要驗證使用者手勢,請評估 request.hasGesture() 或強制執行應用程式層級的確認彈窗,並注意 Android 文件指出某些有效使用者流程中 hasGesture() 可能回傳 false。

區分正常網頁導航與深層連結轉交:何時回傳 false 與 true

shouldOverrideUrlLoading 的布林回傳值控制了 WebView 的渲染生命週期:

  • 回傳 false:告知 Android 框架主應用程式尚未處理該 URL。WebView 會繼續標準網頁導航,在其內部的 Chromium 容器內載入 URL。對於應在嵌入式瀏覽器中顯示的標準 http:// 與 https:// 網頁,必須回傳此值。
  • 回傳 true:告知框架主應用程式已攔截並處理該 URL。WebView 會立即取消其內部的載入流程。當啟動外部 Intent、路由至原生 Activity 或抑制未經授權的 Scheme 時,必須回傳此值。

在嵌入式容器內處理主應用程式已驗證的 App Links

當 WebView 內的連結指向主應用程式自有的已驗證 HTTPS 網域(例如 https://app.example.com/product/1024)時,執行預設導航會導致 WebView 載入該產品頁面的網頁版。

為了提供原生體驗,WebViewClient 應檢查傳入的 HTTPS URL。如果主機與應用程式的關聯網域匹配,客戶端應攔截請求,針對內部路由驗證器(AppLinkRouteValidator)驗證路徑與查詢參數,並將資料直接分發至原生路由器(internalRouter(validatedRoute)),最後回傳 true 以取消冗餘的網頁渲染。

為自訂 Scheme 與 Intent URI 建立安全的 Intent 處理常式

剖析 Chrome Intent URI 語法:Scheme、Package 與 S.browser_fallback_url

在 Android 上,網頁經常使用 Chrome Intent URI 來建構具有內建瀏覽器後備機制的原生深層連結。Intent URI 使用 intent:// Scheme,並將路由參數、目標包名與後備 URL 序列化為一個字串:

intent://product/detail/1024#Intent;scheme=myapp;package=com.example.app;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.example.app;end

根據 Chrome 開發者關於 Android Intent URI 的指南,該 URI 可拆解為不同元件:

  • Host 與 Path (product/detail/1024):定義內部應用程式目標。
  • scheme=myapp:指定底層的自訂協定。
  • package=com.example.app:將 Intent 解析限制為指定的 Android 包名。
  • S.browser_fallback_url=...:一個選擇性的 String Extra,定義當目標應用程式未安裝時所要載入的明確網頁或商店 URL。

執行原始解析 Intent 的危險:緩解 Intent 注入漏洞

常見的實作反模式是使用 Intent.parseUri(url, Intent.URI_INTENT_SCHEME) 解析傳入的 URI,並將產生的 Intent 直接傳遞給 context.startActivity(intent)。

根據 OWASP 行動應用程式安全性測試指南關於不安全深層連結的建議,執行原始解析的 Intent 會將應用程式暴露於 Intent 注入攻擊之下。控制網頁的攻擊者可以製作包含惡意明確元件目標 (component=com.example.app/.InternalAdminActivity)、敏感動作或特權 Intent Extras (S.is_admin=true) 的 Intent URI。WebView 會將這些參數反序列化並啟動未匯出或內部的 Activity,繞過驗證屏障。

解析後的 Intent URI 在 Android 分發前必須經過驗證與重建。

安全 Intent 重建:剝離元件並將動作限制為 ACTION_VIEW

為了消除 Intent 注入漏洞,主應用程式絕不能直接啟動原始反序列化的 Intent。相反地,WebViewClient 必須解析 URI,僅提取經過驗證的資料負載,並重建一個純淨的、專用的 Intent:

  1. 提取資料:解析 URI 並提取驗證後的目標包名與資料 URI。
  2. 驗證包名:核實聲明的包名是否符合已授權合作夥伴或內部包名的明確白名單。
  3. 重建:實例化一個新的 Intent(Intent.ACTION_VIEW, validatedDataUri)。
  4. 隔離:套用 intent.addCategory(Intent.CATEGORY_BROWSABLE) 以確保僅有可瀏覽的 Activity 能解析此請求,設定明確標記 (FLAG_ACTIVITY_NEW_TASK),並確保 intent.component 與 intent.selector 保持為 null。

強制執行嚴格的 Scheme、Host、Path 與查詢參數白名單

無論是自訂 Scheme (myapp://) 還是重建的 Intent URI,在執行前都必須通過嚴格的驗證器。

此參考實作專門處理主應用程式自有的 myapp:// Scheme,並明確將 Intent 執行限制在 com.example.app。對於多應用程式夥伴生態系統,開發者應維護嚴格的 Scheme-to-Package 對應,或優先使用已驗證的 HTTPS App Links。

驗證器會檢查:

  • Scheme:是否符合已授權的自訂 Scheme (myapp)。
  • Host:是否符合預定義的路由類別 (open, product, event)。
  • Path Prefix:是否符合預期的路由結構 (/detail/, /view/, /main/)。
  • 查詢參數:強制執行白名單內的查詢鍵 (target, id, promo, utm_source),並驗證參數值是否符合長度限制(例如 ≤128\le 128 字元)與英數字元集。
[使用者點擊 Android WebView 內的連結]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[檢查子框架 Scheme]     [檢查 URL Scheme]
├─ HTTP(S) -> 回傳 false       │
└─ 非網頁 -> 回傳 true   ┌────┴───────────────────────────┐
   (抑制錯誤)         ▼                                ▼
                     [HTTP / HTTPS]                 [非 HTTP(S) Scheme]
                            │                                │
                            ▼                                ▼
                   [檢查主機網域]              [CustomSchemeValidator]
                   ├─ 內部 App Link             ├─ 有效 -> 啟動釘選 Intent
                   │  -> 內部路由器            └─ Intent -> 重建與後備
                   └─ 外部網頁
                      -> 回傳 false

如何處理 Android 11 套件可見性與應用程式商店後備機制

套件可見性會影響預檢查詢,而啟動失敗則會使用已驗證的後備機制。

Android 11 套件可見性轉換:為何預檢查詢需要聲明

從 Android 11 (API 30) 開始,Google 引入了套件可見性(Package Visibility)篩選機制以增強使用者隱私。在此框架下,應用程式對其他已安裝應用程式的檢視預設受到限制。

理解 Intent 啟動與 Intent 查詢之間的區別至關重要:

  • 直接啟動 Intent:對 context.startActivity(intent) 的標準呼叫不需要套件可見性聲明。如果目標應用程式已安裝,作業系統會順利路由並啟動它,無論它對包名查詢是否可見。
  • 預檢 Intent 查詢:如果您的 WebViewClient 架構執行預檢檢查(例如在啟動前呼叫 packageManager.resolveActivity(intent, 0) 或 packageManager.queryIntentActivities(intent, 0) 來確認應用程式是否存在),除非在 AndroidManifest.xml 中聲明了目標包名或 Scheme,否則查詢會回傳 null 或空列表。

在 AndroidManifest.xml 中聲明 <queries> 元素以處理自訂 Scheme 與目標包名

根據 Android Developers 關於 Android 11 套件可見性的指南,在執行 Intent 前執行預檢檢查的應用程式應在 Manifest 中聲明目標包名或 Intent 簽章:

<manifest package="com.example.app">
    <!-- 針對明確包名發現的選用預檢查詢聲明 -->
    <queries>
        <package android:name="com.example.partner" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>
</manifest>

如果您的架構偏好標準啟動模式,在 try-catch 區塊中執行 startActivity() 並捕捉 ActivityNotFoundException 可提供具備彈性且無需查詢的實作方式。

實作後備鏈:當目標應用程式不存在時進行優雅的商店路由

當攔截到的自訂 Scheme 或 Intent URI 因裝置上未安裝目標應用程式而無法啟動時,WebViewClient 必須執行明確的後備鏈:

  1. 提取並繫結後備 URL:對於 intent:// URI,提取 S.browser_fallback_url 字串參數並驗證其是否指向授權目的地。對於 Google Play URL,驗證 id 參數是否嚴格符合目標包名 (id=targetPackage) 以防止開放重導向漏洞。
  2. Market URI 生成:如果沒有後備 URL,請建構 Market Intent:market://details?id=com.example.app。
  3. 網頁版 Play 商店後備:如果 Google Play 被停用或不存在,在 Market Intent 上捕捉 ActivityNotFoundException 並載入 https://play.google.com/store/apps/details?id=com.example.app。

透過 OpoInstall 跨後備重導向繫結上下文參數

當未安裝應用程式的使用者被導向 Google Play 時,靜態商店連結會遺失動態參數。OpoInstall 是一個行動歸因與深層連結平台,它透過在商店重導向前的歸因後端捕獲參照 Token 與行銷上下文來彌補這一差距。請針對生產環境部署的 OpoInstall Android SDK 版本驗證延遲參數(deferred-parameter)的檢索行為與初始化 API。

在隨後安裝並首次啟動應用程式時,OpoInstall Android SDK 會檢索快取參數,使主應用程式能恢復使用者的目標場景。請參閱 SDK 整合文件以獲取有關安裝後接收延遲參數的說明。

生產環境 WebViewClient 實作:安全 Scheme 攔截

在 Kotlin 中建構自訂 WebViewClient 架構

生產環境的 WebViewClient 會協調框架驗證、Scheme 白名單、Intent 重建、後備提取與內部路由。實作將驗證規則封裝在專用的驗證物件(AppLinkRouteValidator 與 CustomSchemeValidator)內,以保持導航回呼簡潔易維護。

實作 AppLinkRouteValidator 與 CustomSchemeValidator 模型

驗證物件強制執行嚴格的失效關閉(fail-closed)安全性:

  • AppLinkRouteValidator 驗證內部 HTTPS 網域、允許的路徑前綴與授權的查詢鍵 (target, id, promo, utm_source),並回傳結構化的 ValidatedAppRoute 資料類別。
  • CustomSchemeValidator 檢查授權的自訂 Scheme (myapp)、驗證允許的主機 (open, product, event)、強制執行路徑前綴,並針對英數字元集與長度限制(≤128\le 128 字元)清理查詢參數值。

下方的程式碼實作演示了如何配置選用的 AndroidManifest.xml 套件可見性聲明,並在 Kotlin 中實作執行緒安全且安全的 CustomWebViewClient 以及自動化的後備處理。認證後的 SDK 二進位檔案與整合封裝器可從 OpoInstall SDK 下載中心獲取。

<!-- AndroidManifest.xml: 針對預檢 Intent 查詢的選用套件可見性聲明 (Android 11+) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <!-- 僅在使用預檢 resolveActivity 查詢時聲明外部包名與自訂 Scheme -->
    <queries>
        <!-- 授權特定合作夥伴或目標包名的預檢發現 -->
        <package android:name="com.example.partner" />
        
        <!-- 授權自訂深層連結 Scheme 的預檢解析 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        
        <!-- 授權已驗證 HTTPS App Link 網域的預檢解析 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">
        <!-- 應用程式元件 -->
    </application>
</manifest>
// Android: 具備框架邊界安全性、白名單驗證與後備路由的自訂 WebViewClient
// 參考整合範例;請對照部署的 Android 架構驗證方法簽章。
package com.example.app.webview

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

data class ValidatedAppRoute(val path: String, val queryParams: Map<String, String>)

object AppLinkRouteValidator {
    private val allowedPrefixes = setOf("/open/", "/product/", "/promo/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): ValidatedAppRoute? {
        val path = uri.path ?: ""
        if (allowedPrefixes.none { path.startsWith(it) }) {
            return null
        }

        val sanitizedParams = mutableMapOf<String, String>()
        for (name in uri.queryParameterNames) {
            // 嚴格安全性原則:若存在未知查詢鍵則拒絕請求
            if (name !in allowedQueryKeys) {
                return null
            }
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length <= 64 && value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                sanitizedParams[name] = value
            } else {
                return null
            }
        }

        return ValidatedAppRoute(path, sanitizedParams)
    }
}

object CustomSchemeValidator {
    private val allowedSchemes = setOf("myapp")
    private val allowedHosts = setOf("open", "product", "event")
    private val allowedPathPrefixes = setOf("/detail/", "/view/", "/main/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): Uri? {
        val scheme = uri.scheme?.lowercase() ?: return null
        val host = uri.host?.lowercase() ?: return null
        val path = uri.path ?: ""

        if (!allowedSchemes.contains(scheme) || !allowedHosts.contains(host)) {
            return null
        }

        if (allowedPathPrefixes.none { path.startsWith(it) }) {
            return null
        }

        for (name in uri.queryParameterNames) {
            if (name !in allowedQueryKeys) return null
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length > 128 || !value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                return null
            }
        }

        return uri
    }
}

class CustomWebViewClient(
    private val context: Context,
    private val internalRouter: (ValidatedAppRoute) -> Unit
) : WebViewClient() {

    override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        request ?: return false

        // 安全要求:限制外部應用路由僅能用於主框架導航
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // 允許標準 HTTP(S) 子框架載入,但抑制非網頁 Scheme 以防止 iframe 劫持與錯誤
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "已抑制子框架/iframe 中的非網頁 Scheme: $scheme")
                true // 消耗/取消而不啟動外部 Intent 或顯示 Chromium 錯誤
            }
        }

        val url = request.url.toString()
        return handleUrlNavigation(view, url)
    }

    @Deprecated("Deprecated in Java")
    override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
        if (url == null) return false

        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()

        // 傳統回呼缺乏可靠的 isForMainFrame 元數據;請勿路由外部 Scheme
        return if (scheme == "http" || scheme == "https") {
            false // 允許標準網頁內容載入
        } else {
            Log.w("WebViewClient", "在傳統回呼上封鎖了外部 Scheme: $scheme")
            true // 抑制未處理的 Scheme 錯誤而不啟動外部 Intent
        }
    }

    private fun handleUrlNavigation(view: WebView?, url: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase() ?: return false

        // 1. 處理 HTTP / HTTPS 與內部 App Links
        if (scheme == "http" || scheme == "https") {
            // 檢查 URL 是否符合主應用程式自身的 App Link 網域
            if (scheme == "https" && uri.host?.lowercase() == "app.example.com") {
                val validatedRoute = AppLinkRouteValidator.validate(uri)
                if (validatedRoute != null) {
                    Log.i("WebViewClient", "已驗證內部 HTTPS 路由。分發至內部路由器。")
                    internalRouter(validatedRoute)
                } else {
                    Log.w("WebViewClient", "拒絕格式錯誤或未經授權的內部 App Link: $uri")
                }
                return true // 內部處理;取消 webview 載入
            }
            return false // 允許 WebView 原生載入標準網頁內容
        }

        // 2. 使用嚴格白名單與包名釘選驗證自訂 Scheme 以防止劫持
        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app" // 明確釘選主應用程式包名
                addCategory(Intent.CATEGORY_BROWSABLE)
                flags = Intent.FLAG_ACTIVITY_NEW_TASK
            }
            return launchSafeIntent(pinnedIntent, fallbackAction = {
                handleStoreFallback(view, "com.example.app")
            })
        }

        // 3. 使用安全重建與後備提取來處理 Chrome Intent URI
        if (scheme == "intent") {
            return try {
                val parsed = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                val targetPackage = parsed.`package`
                val allowedPackages = setOf("com.example.app", "com.example.partner")
                val fallbackUrl = parsed.getStringExtra("browser_fallback_url")

                if (targetPackage != null && allowedPackages.contains(targetPackage)) {
                    val rawDataUri = parsed.data
                    if (rawDataUri != null) {
                        val validatedDataUri = CustomSchemeValidator.validate(rawDataUri)
                        if (validatedDataUri != null) {
                            // 重建一個純淨的、專用的 ACTION_VIEW Intent,而非執行原始解析物件
                            val safeIntent = Intent(Intent.ACTION_VIEW, validatedDataUri).apply {
                                `package` = targetPackage
                                addCategory(Intent.CATEGORY_BROWSABLE)
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK
                            }
                            launchSafeIntent(safeIntent, fallbackAction = {
                                handleIntentFallback(view, fallbackUrl, targetPackage)
                            })
                        } else {
                            Log.w("WebViewClient", "拒絕含有無效資料 URI 的 Intent: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "拒絕缺乏資料 URI 負載的 Intent")
                        true
                    }
                } else {
                    Log.w("WebViewClient", "拒絕針對未驗證包名的 Intent: $targetPackage")
                    true // 取消未驗證包名的 Intent
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "解析 Intent URI 失敗: $url", e)
                true // 抑制未處理的 Scheme 錯誤
            }
        }

        // 拒絕未知 Scheme 以避免 net::ERR_UNKNOWN_URL_SCHEME 錯誤
        Log.w("WebViewClient", "封鎖未驗證的 Scheme 導航: $scheme")
        return true
    }

    private fun launchSafeIntent(intent: Intent, fallbackAction: () -> Unit): Boolean {
        return try {
            context.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            Log.w("WebViewClient", "目標應用程式未安裝: ${intent.data}。觸發後備機制。")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "啟動 Intent 時發生安全性異常: ${intent.data}", e)
            true
        }
    }

    private fun handleIntentFallback(view: WebView?, fallbackUrl: String?, targetPackage: String) {
        if (!fallbackUrl.isNullOrBlank() && isAuthorizedFallbackUrl(fallbackUrl, targetPackage)) {
            Log.i("WebViewClient", "路由至已授權的瀏覽器後備 URL: $fallbackUrl")
            view?.loadUrl(fallbackUrl)
        } else {
            handleStoreFallback(view, targetPackage)
        }
    }

    private fun handleStoreFallback(view: WebView?, packageName: String) {
        val marketUri = Uri.parse("market://details?id=$packageName")
        val marketIntent = Intent(Intent.ACTION_VIEW, marketUri).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        try {
            context.startActivity(marketIntent)
        } catch (e: ActivityNotFoundException) {
            val webStoreUrl = "https://play.google.com/store/apps/details?id=$packageName"
            Log.i("WebViewClient", "Play 商店應用程式不存在。載入網頁版商店 URL: $webStoreUrl")
            view?.loadUrl(webStoreUrl)
        }
    }

    private fun isAuthorizedFallbackUrl(url: String, targetPackage: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()
        val host = uri.host?.lowercase()

        if (scheme != "https" || host == null) return false

        return when (host) {
            "play.google.com" -> {
                // 強制 Google Play 後備 URL 專門指向目標包名
                uri.path == "/store/apps/details" && uri.getQueryParameter("id") == targetPackage
            }
            "app.example.com", "www.example.com" -> {
                // 驗證自有網域後備路由
                AppLinkRouteValidator.validate(uri) != null
            }
            else -> false
        }
    }
}

執行緒安全執行:分發內部路由與管理生命週期狀態

當 AppLinkRouteValidator 確認為有效的內部路由時,UI 轉換必須在 Android 的主 UI 執行緒上執行。若 internalRouter 觸發 Fragment、Activity 轉換或 Compose 導航,請將分發呼叫包裹在 runOnUiThread 或主執行緒生命週期協程內,以防止執行緒崩潰。

Android WebView 重導向錯誤與診斷解決方案矩陣

全面的 Android WebView 深層連結疑難排解指南

下表詳細說明了在 Android WebView 中處理自訂 Scheme 時遇到的常見錯誤簽章、主要根本原因與工程補救措施:

錯誤簽章 / 症狀 主要根本原因 受影響 API 層級 診斷檢查點 建議補救措施
net::ERR_UNKNOWN_URL_SCHEME WebView 載入器中未處理非網頁 Scheme 所有 API 層級 shouldOverrideUrlLoading 缺失或回傳 false 覆寫委派、攔截 Scheme、回傳 true
ActivityNotFoundException 裝置上缺少目標應用程式 所有 API 層級 未處理的 context.startActivity() 捕捉異常並執行 S.browser_fallback_url 或 Play 商店路由
Iframe Intent 劫持 子框架觸發外部 Intent API 24+ 未檢查 request.isForMainFrame 使用 if (!request.isForMainFrame) 防護並抑制非網頁 Scheme
預檢查詢回傳 Null Android 11+ 套件可見性篩選 API 30+ (Android 11+) 未經 Manifest 聲明呼叫 resolveActivity 在 Manifest 中聲明 <queries> 或直接使用 startActivity try-catch
內部 App Link 重載循環 主 App Link 重導向至 WebView 所有 API 層級 委派中未處理主網域 攔截自有網域、驗證路由、分發至原生路由器

常見問題集 (FAQ)

為什麼我的 Android WebView 在開啟自訂 URL Scheme 時會崩潰?
當 URL 保留在 WebView 載入管道中,而非被主應用程式攔截時,自訂 Scheme 會產生 `net::ERR_UNKNOWN_URL_SCHEME`。使用自訂 `WebViewClient` 時,在安全處理或抑制非網頁 Scheme 後回傳 `true`;僅在 WebView 應繼續正常網頁導航時回傳 `false`。如果目標應用程式不存在,在沒有 `try-catch` 區塊的情況下呼叫 `startActivity()` 會拋出未處理的 `ActivityNotFoundException`。
如何在 WebView 中安全處理 intent:// URL 而不產生安全性漏洞?
要安全處理 `intent://` URI,請使用 `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)` 解析 URI,驗證目標包名與資料 URI 是否符合嚴格白名單,並建構一個新的、純淨的 `Intent(Intent.ACTION_VIEW, safeDataUri)`。明確新增 `Intent.CATEGORY_BROWSABLE` 以確保僅有可瀏覽的 Activity 處理此 Intent,剝離危險元件,並實作綁定至目標包名的已驗證後備 URL 處理機制。
Android 11 套件可見性如何影響 WebView 中的深層連結攔截?
Android 11 套件可見性不會阻止 `startActivity()` 啟動符合條件的外部 Activity。然而,如果您的 `WebViewClient` 在嘗試啟動前使用 `resolveActivity()` 或 `queryIntentActivities()` 執行預檢檢查,則必須在 `AndroidManifest.xml` 檔案的 `<queries>` 元素內聲明目標包名或自訂 Intent Scheme。

總結與決策框架

在 Android WebView 中處理深層連結與自訂 Scheme,需要彌合網頁渲染沙盒與原生作業系統 Intent 路由之間的差距。過度依賴預設的 WebView 行為會導致未處理的協定錯誤、糟糕的使用者體驗與潛在的安全性漏洞。

透過實作穩健的 WebViewClient 來驗證框架邊界、針對明確白名單驗證自訂 Scheme、重建安全的 ACTION_VIEW Intent 並處理優雅的商店後備機制,工程團隊可以減少 WebView 的 Scheme 處理失敗,並支援更具彈性的導航。將可靠的客戶端路由與全面的行動歸因框架整合,可降低跨歸因流程的路由摩擦,並支援更一致的使用者旅程。

若要探索原生 Android 深層連結與參數路由架構,請查閱 SDK 整合文件,從 OpoInstall SDK 下載中心下載客戶端程式庫,探索 行動歸因實作參考,或在 OpoInstall 開發者控制台註冊您的應用程式。

相關資料

Share this article