如何在 Android WebView 中拦截自定义 URL Scheme 重定向

opoinstall
2026-09-21
5 min read

如何在 Android WebView 中处理深度链接(Deep Link)和自定义 Scheme? 在 Android WebView 中处理深度链接,需要通过扩展 WebViewClient 并重写 shouldOverrideUrlLoading 方法来实现。核心逻辑包括:检查 isForMainFrame 标志,通过白名单校验自定义 Scheme,并安全地调起外部 Intent,从而防止非 Web 协议残留在 WebView 的加载管道中。

Android WebView 深度链接拦截是一种编程实践,通过在自定义 WebViewClient 中重写导航回调,捕获非 HTTP 协议(如自定义 URI Scheme 和 Chrome Intent URI)。通过评估请求元数据并验证目标载荷,宿主 App 可以在执行原生 Intent 跳转的同时,规避协议无法识别导致的崩溃。

术语 定义 关联实体 搜索意图角色
WebView 一种嵌入式基于 Chromium 的视图组件,用于在 Android App 内部渲染网页内容。 Android SDK 信息型 / 商业型
自定义 URL Scheme 一种专有的 URI 协议,允许 Web 链接定向到原生 App。 深度链接 (Deeplink) 技术型 / 信息型
Web to App 用户从网页平滑过渡到原生 App 界面的过程。 移动端深度链接 信息型

在 Chromium 报错之前,必须将自定义 Scheme 从 WebView 加载管道中移除。

为什么 Android WebView 会抛出 net ERR UNKNOWN URL SCHEME 错误

Chromium 协议限制:为什么 WebView 原生仅识别标准 Web 协议

Android 的 WebView 组件由底层的 Chromium 渲染引擎驱动,其默认配置仅用于解析和显示标准 Web 资源协议:http://、https://、file://、data://、about: 和 javascript:。

默认情况下,若未指定自定义 WebViewClient,Android 框架会将无法处理的 URL 委托给系统的 Activity 管理器。然而,一旦 App 提供了自定义的 WebViewClient,非 HTTP(S) 协议(如 myapp://product/detail/1024、alipay:// 或 whatsapp://)就会直接路由到客户端的导航回调中。如果宿主 App 没有拦截这些 Scheme,而是任由其留在 WebView 加载管道中,Chromium 引擎会尝试将其解析为普通 Web 资源。由于非标准协议无法通过 DNS 或 Web 服务器传输层进行解析,引擎便会触发底层的导航失败。

net::ERR_UNKNOWN_URL_SCHEME 的崩溃机制:从点击到资源加载失败

从用户交互触发到报错页面出现,通常遵循一个确定的逻辑序列:

  1. 用户触发:用户点击嵌入式 H5 落地页中的按钮或超链接。
  2. 导航请求:Web 引擎构建一个包含目标 URI 字符串(例如 myapp://promo/summer)的导航请求。
  3. 管道评估:如果 shouldOverrideUrlLoading() 返回 false 或未正确拦截非 HTTP(S) URI,Chromium 引擎会将该 URL 视为标准 Web 资源请求。
  4. 错误分发:由于找不到该协议的内部处理器,引擎触发 onReceivedError(),并用 Chromium 默认的错误模板替换 WebView 内容:net::ERR_UNKNOWN_URL_SCHEME。

该错误页面不仅中断了用户旅程,阻碍了转化追踪,还会降低宿主 App 的性能感知和稳定性。

目标 App 未安装导致的 ActivityNotFoundException 崩溃风险

开发者在尝试解决 net::ERR_UNKNOWN_URL_SCHEME 时,常犯的一个错误是:直接解析 URI 字符串并调用 context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))),而缺乏充分的异常处理。

如果用户设备未安装目标 App,Android 系统将无法找到符合该 Intent 的 Activity。此时调用 startActivity() 会抛出未处理的 android.content.ActivityNotFoundException。如果没有在 try-catch 代码块中进行兜底,App 进程就会直接崩溃。

shouldOverrideUrlLoading 如何拦截深度链接协议

API 演进:API 24+ 的 WebResourceRequest 重载与旧版 String 回调对比

Android 的 WebViewClient 提供了 shouldOverrideUrlLoading 回调,允许宿主 App 在 Web 引擎执行导航请求前进行检查和拦截。该方法的签名和能力随 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 开发者文档中关于 WebViewClient shouldOverrideUrlLoading 的说明,现代 App 必须实现 WebResourceRequest 重载。若需兼容低版本 Android,应谨慎配置已弃用的字符串回调,以防范安全漏洞。

request.isForMainFrame 的安全性:防范子框架与 Iframe 劫持

主框架检查可防止 iframe 导航启动外部 App 路由。

根据 Android 开发者指南中关于 WebResourceRequest 的描述,shouldOverrideUrlLoading 回调不仅会被顶层文档导航触发,也会被子框架、Iframe 以及后台资源重定向触发。

强制检查 request.isForMainFrame 对 App 安全至关重要:

  • 框架边界隔离:检查 request.isForMainFrame 可确保子框架和 Iframe 无法启动外部 App 或自定义 Scheme Intent。若子框架尝试导航,标准 HTTP(S) 加载应返回 false 以继续内部渲染,而非 HTTP(S) 协议则必须返回 true 以进行拦截,从而避免报错。
  • 用户手势区分:虽然 isForMainFrame 确认了请求源自主要文档,但它并不能完全证明用户进行了主动激活。如果业务架构要求验证用户手势,建议评估 request.hasGesture() 或实施 App 级别的确认模态框,需注意 Android 文档提到某些有效交互流中 hasGesture() 可能返回 false。

区分正常 Web 导航与深度链接路由:何时返回 false 与 true

shouldOverrideUrlLoading 的布尔返回值决定了 WebView 的渲染生命周期:

  • 返回 false:告知 Android 框架宿主 App **未**处理该 URL。WebView 将继续进行标准 Web 导航,在内部 Chromium 容器中加载该 URL。对于应在嵌入式浏览器中显示的常规 http:// 和 https:// 网页,必须返回此值。
  • 返回 true:告知框架宿主 App 已**拦截并处理**了该 URL。WebView 会立即取消其内部加载过程。在调起外部 Intent、路由到原生 Activity 或拦截未经授权的 Scheme 时,必须返回此值。

在嵌入式容器内处理已验证的 App Links

当 WebView 内部的链接指向宿主 App 已验证的 HTTPS 域名(例如 https://app.example.com/product/1024)时,执行默认导航会导致 WebView 加载产品详情的网页版。

为了提供更顺畅的原生体验,WebViewClient 应检查传入的 HTTPS URL。如果域名匹配,客户端应拦截请求,利用内部路由校验器 (AppLinkRouteValidator) 校验路径和查询参数,并将数据直接分发给原生路由 (internalRouter(validatedRoute)),同时返回 true 以取消冗余的网页渲染。

为自定义 Scheme 和 Intent URI 构建安全的 Intent 处理器

剖析 Chrome Intent URI 语法:Scheme、包名及 S.browser_fallback_url

在 Android 上,网页经常使用 Chrome Intent URI 来结构化原生深度链接,并内置浏览器兜底机制。Intent URI 使用 intent:// 方案,并将路由参数、目标包名和兜底 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 包含以下关键部分:

  • 主机和路径 (product/detail/1024):定义 App 内部的目的地。
  • scheme=myapp:指定底层的自定义协议。
  • package=com.example.app:将 Intent 解析严格限制在指定的 Android 包名范围内。
  • S.browser_fallback_url=...:可选的 String 扩展参数,定义了当目标 App 未安装时要加载的网页或应用商店 URL。

执行原始解析 Intent 的危险:规避 Intent 注入漏洞

一种常见的错误实践是使用 Intent.parseUri(url, Intent.URI_INTENT_SCHEME) 解析传入的 URI,并将生成的 Intent 直接交给 context.startActivity(intent)。

根据 OWASP 移动 App 安全测试指南中关于不安全深度链接的建议,直接执行解析后的 Intent 会导致 App 面临 **Intent 注入 (Intent Injection)** 攻击。攻击者可以操控网页,植入包含恶意组件目标 (component=com.example.app/.InternalAdminActivity)、敏感操作或权限提升参数 (S.is_admin=true) 的 Intent URI。WebView 在反序列化这些参数后,可能会调起未导出的内部 Activity,从而绕过身份验证机制。

解析后的 Intent URI 在分发给 Android 之前,必须进行验证和重构。

安全 Intent 重构:剥离组件并将操作限制为 ACTION_VIEW

为了消除 Intent 注入风险,宿主 App 绝不能直接调起原始反序列化的 Intent。相反,WebViewClient 必须解析 URI,提取经过验证的数据载荷,并重构出一个纯净的专用 Intent:

  1. 提取数据:解析 URI,提取经过验证的目标包名和 Data URI。
  2. 验证包名:校验声明的包名是否匹配授权的合作伙伴或内部 App 白名单。
  3. 重构:实例化一个新的 Intent(Intent.ACTION_VIEW, validatedDataUri)。
  4. 隔离:添加 intent.addCategory(Intent.CATEGORY_BROWSABLE) 以确保只有具备浏览能力的 Activity 能够响应该请求,设置显式标志 (FLAG_ACTIVITY_NEW_TASK),并确保 intent.component 和 intent.selector 保持为空。

强制执行严格的 Scheme、主机、路径及查询参数白名单

无论是自定义 Scheme (myapp://) 还是重构后的 Intent URI,在执行前都必须通过严格的校验器。

该参考实现专门处理宿主 App 自身的 myapp:// Scheme,并明确将 Intent 执行锁定在 com.example.app。对于多 App 合作伙伴生态系统,开发者应维护严格的 Scheme-包名映射或优先使用已验证的 HTTPS App Links。

校验器会检查:

  • Scheme:匹配授权的自定义 Scheme (myapp)。
  • 主机:匹配预定义的路由类别 (open, product, event)。
  • 路径前缀:符合预期的路由结构 (/detail/, /view/, /main/)。
  • 查询参数:强制使用白名单查询 Key (target, id, promo, utm_source),并验证参数值是否符合长度限制(例如 ≤128\le 128 字符)以及字母数字字符集。
[用户点击 Android WebView 中的链接]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[检查子框架 Scheme]         [检查 URL Scheme]
├─ HTTP(S) -> 返回 false      │
└─ 非 Web -> 返回 true      ┌────┴───────────────────────────┐
   (抑制错误)                ▼                                ▼
                     [HTTP / HTTPS]                 [非 HTTP(S) Scheme]
                            │                                │
                            ▼                                ▼
                   [检查主机域名]             [自定义 Scheme 校验器]
                   ├─ 内部 App Link           ├─ 合法 -> 启动锁定 Intent
                   │  -> 内部路由             └─ Intent -> 重构并兜底
                   └─ 外部 Web
                      -> 返回 false

如何处理 Android 11 包可见性与应用商店兜底

包可见性会影响预检查询,而启动失败时应使用已验证的兜底机制。

Android 11 包可见性变更:为什么预检查询需要声明

从 Android 11 (API level 30) 开始,Google 引入了 **包可见性 (Package Visibility)** 过滤机制以增强用户隐私。在该框架下,App 默认无法查看已安装的其他 App。

理解 Intent 启动与 Intent 查询的区别至关重要:

  • 直接启动 Intent:常规调用 context.startActivity(intent) **不**要求声明包可见性。只要目标 App 已安装,操作系统就能正确路由并成功启动它,无论该 App 对当前 App 是否“可见”。
  • 预检 Intent 查询:如果 WebViewClient 架构在启动前执行了预检逻辑——例如调用 packageManager.resolveActivity(intent, 0) 或 packageManager.queryIntentActivities(intent, 0) 来检查目标是否存在——除非在 AndroidManifest.xml 中声明了目标包名或 Scheme,否则查询结果将返回 null 或空列表。

在 AndroidManifest.xml 中声明 <queries> 元素

根据 Android 开发者指南中关于 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 代码块中捕获 ActivityNotFoundException 是一种与查询无关的稳健实现方案。

实现兜底链路:当目标 App 未安装时自动路由到应用商店

当拦截的自定义 Scheme 或 Intent URI 因用户未安装目标 App 而无法启动时,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. 网页版商店兜底:如果 Google Play 被禁用或未安装,捕获 Market Intent 引起的 ActivityNotFoundException,并加载 https://play.google.com/store/apps/details?id=com.example.app。

利用 OpoInstall 绑定兜底跳转中的上下文参数

当未安装 App 的用户被引导至 Google Play 时,静态商店链接会丢失动态参数。OpoInstall 作为移动归因和深度链接平台,可在重定向到商店前,通过归因后台捕获跳转 Token 和渠道上下文。请根据部署在生产环境中的 OpoInstall Android SDK 版本,验证延迟参数获取行为和初始化 API。

App 完成安装并首次启动后,OpoInstall Android SDK 将检索缓存的参数,使宿主 App 能够还原用户的目标场景。请查阅 SDK 集成文档,了解如何获取安装后的延迟参数。

生产环境下 WebViewClient 的安全 Scheme 拦截实现

在 Kotlin 中构建自定义 WebViewClient 架构

生产环境下的 WebViewClient 需要协同处理框架校验、Scheme 白名单、Intent 重构、兜底提取以及内部路由。实现时应将校验逻辑封装在专门的校验器对象(AppLinkRouteValidator 和 CustomSchemeValidator)中,以保持导航回调的整洁与可维护性。

实现 AppLinkRouteValidator 与 CustomSchemeValidator 模型

校验器对象强制执行严格的“安全关闭”策略:

  • AppLinkRouteValidator 校验内部 HTTPS 域名、合法的路径前缀以及授权的查询 Key (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">
        <!-- App 组件 -->
    </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

        // 安全要求:将外部 App 路由限制在仅主框架导航中
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // 允许标准 HTTP(S) 子框架加载,但抑制非 Web Scheme 以防止 iframe 劫持和错误
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "子框架/iframe 中抑制了非 Web 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 // 允许标准 Web 内容加载
        } 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 Link
        if (scheme == "http" || scheme == "https") {
            // 检查 URL 是否匹配宿主 App 自身的 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 原生加载标准 Web 内容
        }

        // 2. 使用严格白名单和包名锁定校验自定义 Scheme,防范劫持
        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app" // 显式锁定宿主 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", "拒绝了具有无效 Data URI 的 Intent: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "拒绝了缺乏 Data 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", "目标 App 未安装: ${intent.data}。触发兜底。")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "启动 Intent 时发生 SecurityException: ${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 Store App 缺失。正在加载网页商店 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 加载器无法处理非 Web Scheme 所有 API 级别 shouldOverrideUrlLoading 缺失或返回 false 重写代理,拦截 Scheme,返回 true
ActivityNotFoundException 设备未安装目标 App 所有 API 级别 未处理 context.startActivity() 捕获异常并执行 S.browser_fallback_url 或 Play Store 路由
Iframe Intent 劫持 子框架触发外部 Intent API 24+ 未检查 request.isForMainFrame 使用 if (!request.isForMainFrame) 防护,并抑制非 Web 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 加载管道中而不是被宿主 App 拦截时,会产生 `net::ERR_UNKNOWN_URL_SCHEME` 错误。在自定义的 `WebViewClient` 中,安全处理或抑制非 Web Scheme 后应返回 `true`;仅当 WebView 应继续正常的 Web 导航时才返回 `false`。如果目标 App 未安装,调用 `startActivity()` 而未添加 `try-catch` 代码块,则会导致未处理的 `ActivityNotFoundException` 从而引发崩溃。
如何安全处理 WebView 中的 intent:// URL,而不引入安全漏洞?
要安全地处理 `intent://` URI,应使用 `Intent.parseUri(url, Intent.URI_INTENT_SCHEME)` 解析 URI,验证目标包名和 Data 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,需要架起 Web 渲染沙箱与原生操作系统 Intent 路由之间的桥梁。依赖默认的 WebView 行为会导致未处理的协议错误、不佳的用户体验以及潜在的安全漏洞。

通过实现健壮的 WebViewClient,能够校验框架边界、对比白名单校验自定义 Scheme、重构安全的 ACTION_VIEW Intent 并执行优雅的商店兜底,工程团队可减少 WebView 方案处理失败,并支持更具韧性的导航。将可靠的客户端路由与完善的移动归因框架集成,可降低整个归因流中的跳转摩擦,并支持更连贯的用户旅程。

要深入了解原生 Android 深度链接和参数路由架构,请查阅 SDK 集成文档,从 OpoInstall SDK 下载中心 获取客户端库,探索 移动归因实施参考,或在 OpoInstall 开发者控制台 注册您的应用。

相关资料

Share this article