那个只在第二页失效的按钮

一个内嵌页面上线前,测试同学发现了一个很别扭的问题:第一页可以正常调用一项宿主能力,完成一次硬导航进入下一页后,同一个入口却提示“当前环境不支持”。返回第一页,能力又恢复了。

Android 日志里没有崩溃,WebView 也没有被重新创建。两个页面的资源和接口请求都正常完成。最初大家盯着网络面板,怀疑重定向、缓存甚至跨域;但真正有用的线索来自两行控制台输出:

// 页面 A
typeof window.hostTransport // "object"
typeof window.HostBridge    // "object"

// 完整导航后的页面 B
typeof window.hostTransport // "object"
typeof window.HostBridge    // "undefined"

原生通道还在,页面里的 JavaScript 门面却不见了。原因不是 WebView 丢失,也不是 HTTP 请求失败,而是页面 A 的 DocumentWindow 和 JavaScript 执行环境已经随导航销毁;之前动态执行的门面脚本只属于页面 A。

一句话结论:同一个 WebView,不等于同一个网页运行环境。原生桥注册一次,也不等于页面里的 JavaScript 对象可以跨导航永久存在。

这条结论既解释了按钮为什么只在第二页失效,也决定了 WebView 应该怎样选、怎样加载、怎样注入脚本,以及怎样划定安全边界。

先决定:这里真的需要 WebView 吗

Android 官方的应用内 Web 内容选型说明把应用内 Web 内容分成两种主要承载方式:

需求更适合的方式原因
展示自己控制、需要深度定制并可能和原生交互的 Web 内容WebView页面嵌在应用界面中,宿主可以控制加载、导航和交互
打开外部链接,提供接近完整浏览器的体验Custom Tabs复用用户默认浏览器的能力,登录状态和浏览体验更完整
第三方身份提供方登录Custom Tabs凭据与宿主应用隔离,边界更清晰
应用自己的登录能力优先考虑 Credential Manager不应因为“跨端页面好复用”就默认把认证塞进 WebView

这个选择很重要。WebView 不是一个缩小版浏览器,更不是任意网页的通用容器。只要页面需要地址栏、下载管理、站点权限、密码管理或自由跳转等浏览器能力,继续给 WebView 打补丁往往会把产品问题变成安全问题。

官方页面将 WebView 的典型价值概括为效率、集成和灵活性:复用已有 Web 技术、嵌入特定内容、无需发布新版本即可更新页面。它的前提也很明确:宿主对内容和交互边界有足够控制。

还有两个容易被忽略的分支:Compose 没有原生 WebView 可组合项,需要用 AndroidView 嵌入标准 WebView;如果只是把自己拥有的 PWA 全屏交给浏览器渲染,可以研究 Trusted Web Activity,并用 Digital Asset Links 证明应用和网站属于同一开发者。TWA 共享浏览器状态,但宿主不能直接读取其中的 Cookie、localStorage 或页面 DOM,因此它不是 JSBridge 的替代品。

WebView 到底承载了哪些东西

排查桥接问题时,最容易犯的错误是把下面几层都叫作“WebView”:

流程图 1流程图 1

它们的生命周期并不相同:

层级它是什么完整导航之后
WebView 实例Android 创建并持有的 View通常仍是同一个实例
原生通道注册宿主向 WebView 暴露的消息入口或对象同一实例且未移除时,通常仍由宿主提供;addWebMessageListener 会为后续匹配来源的文档重新注入对象
Document当前 HTML 文档和 DOM离开当前页面;通常被替换,也可能暂时进入 back-forward cache
Window / JS Realm当前文档的全局对象、函数、闭包和任务队列当前页面不再使用;返回时是否恢复取决于缓存和页面条件
JS 门面页面脚本创建的普通对象必须在新文档中重新创建
iframe子文档自己的运行环境生命周期独立于主文档

这里的“通常”不是平台承诺。WebView 被销毁、配置被替换、接口被主动移除,都会改变结果。判断依据应该是目标 Android System WebView 版本、AndroidX WebKit 能力和实际宿主配置,而不是一句“我们以前一直这样用”。

哪些操作会换掉 JavaScript 环境

页面动作是否创建新 Document页面全局变量是否保留
location.href = url
location.replace(url)
location.reload()
服务端 30x 重定向到新文档
history.pushState()通常否
history.replaceState()通常否
只修改 hash通常否
iframe 自己导航只替换该 iframe主文档保留,子文档重建

表格描述的是当前活动页面的语义;启用 WebView/Jetpack WebKit 的 back-forward cache 后,返回历史页面时旧页面可能从缓存恢复,页面仍应重新确认当前来源、transport 和 ready 状态。

页面可以在 pageshow 中检查 event.persisted;如果是从缓存恢复,至少重新做一次轻量握手和 capability 查询,不要直接复用导航前未完成的 Promise。

硬导航会让旧页面的 Promise、回调表、事件监听器、定时器和模块状态失去当前页面的可用性;如果内核把旧页面放进 back-forward cache,返回时部分状态可能恢复,但不能把这种恢复当作跨导航契约。SPA 路由通常不会重建 Document,但页面级监听器仍可能因为组件卸载而需要重新绑定。这两类问题表面相似,修复位置却完全不同。

Android WebView 的最小加载模型

远程内容首先需要网络权限:

<uses-permission android:name="android.permission.INTERNET" />

随后再配置 WebView。下面只展示与加载边界有关的部分:

val webView = findViewById<WebView>(R.id.webView)

webView.settings.apply {
    javaScriptEnabled = true
    mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
}

webView.webViewClient = WebViewClient()
webView.webChromeClient = WebChromeClient()
webView.loadUrl("https://docs.example.test/guide/")

需要注意四件事:

  1. JavaScript 默认关闭。只有页面确实需要时才开启,不要把它当作固定模板配置。
  2. 设置 WebViewClient 后,宿主才能接管页面导航、资源拦截和错误处理;外部链接仍应按来源规则决定留在 WebView 还是交给 Custom Tabs。
  3. WebChromeClient 负责进度、标题、JavaScript 对话框等更接近浏览器界面的能力,它不是 JSBridge。
  4. loadUrl() 只开始一次页面加载,不代表 DOM 完成,更不代表桥接门面已经 ready。

导航要有边界

Android 默认可能把 WebView 中点击的链接交给系统浏览器。若应用确实要在容器内继续加载,应通过 WebViewClient 明确允许的 host;其他 URL 交给 Custom Tabs 或默认浏览器:

class TrustedClient : WebViewClient() {
    private fun isTrustedOrigin(uri: Uri): Boolean {
        val usesDefaultHttpsPort = uri.port == -1 || uri.port == 443
        return (
            uri.scheme == "https"
            && uri.host == "docs.example.test"
            && usesDefaultHttpsPort
        )
    }

    private fun handleMainFrameNavigation(uri: Uri): Boolean {
        if (isTrustedOrigin(uri)) {
            return false // 让 WebView 使用默认实现继续加载
        }

        if (uri.scheme == "http" || uri.scheme == "https") {
            openInCustomTab(uri)
        }
        return true // 其他 scheme 直接拦截
    }

    override fun shouldOverrideUrlLoading(
        view: WebView,
        request: WebResourceRequest,
    ): Boolean {
        if (!request.isForMainFrame) {
            return !isTrustedOrigin(request.url)
        }
        return handleMainFrameNavigation(request.url)
    }

    @Suppress("DEPRECATION")
    override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
        // 旧回调无法区分 main frame,非受信来源按失败关闭处理。
        return !isTrustedOrigin(Uri.parse(url))
    }
}

新回调会用 isForMainFrame 区分导航:只有主 frame 的外部 HTTP(S) 链接才会打开 Custom Tab,子 frame 只允许受信来源。旧回调没有 frame 信息,本例选择拦截所有非受信导航;需要兼容旧 WebView 时,不要在带桥的页面中加载不受信 iframe。

shouldOverrideUrlLoading() 不是所有导航的唯一安全闸门,它不会覆盖所有 POST 或重定向路径。最终来源仍要在 onPageStarted/页面提交回调和桥消息处理层复核,openInCustomTab() 也只能接收经过 scheme 校验的 HTTP(S) URL。不要在回调里再调用 loadUrl()reload(),然后返回 true;官方文档明确提醒这会造成重复加载和低效。需要处理返回键时,先用 canGoBack() 判断,再调用 goBack(),不要把 Android Activity 返回栈和网页历史混为一谈。

除非确实实现了多窗口管理,否则应阻止 target="_blank" 和脚本弹窗。官方给出的安全做法是开启 setSupportMultipleWindows(true),但不实现 onCreateWindow(),让这些新窗口请求不被加载。

本地页面不要再依赖 file://

页面来自 APK 内的 assets 时,Android 官方推荐使用 AndroidX WebKit 的 WebViewAssetLoader,把本地文件映射到 HTTPS URL:

val assetLoader = WebViewAssetLoader.Builder()
    .addPathHandler(
        "/assets/",
        WebViewAssetLoader.AssetsPathHandler(this),
    )
    .build()

webView.webViewClient = object : WebViewClient() {
    override fun shouldInterceptRequest(
        view: WebView?,
        request: WebResourceRequest?,
    ): WebResourceResponse? {
        return request?.url?.let(assetLoader::shouldInterceptRequest)
    }
}

webView.loadUrl(
    "https://appassets.androidplatform.net/assets/index.html",
)

这样做不是为了让地址看起来更漂亮。HTTPS origin 能让相对资源、fetch、同源判断和安全策略遵循更接近普通网站的规则,也避免为了让 file:// 互相访问而打开过宽的文件权限。

如果 HTML 是 Native 动态生成的,优先用 loadDataWithBaseURL(),并给它一个 HTTPS baseUrl。直接用 loadData() 会得到 data: 不透明来源,fetch()XMLHttpRequest 等能力会受到限制。也不要为了“修好” file:// 而打开 setAllowFileAccessFromFileURLs(true)setAllowUniversalAccessFromFileURLs(true);官方建议在所有 API 级别都保持关闭,并避免 MIXED_CONTENT_ALWAYS_ALLOW

HTTP、跨域和 JSBridge 是三条不同的通道

WebView 中的页面仍然运行在 Web 安全模型里。页面发起 fetchXMLHttpRequest 时,协议、主机和端口共同决定 origin;跨源读取仍要经过 CORS。关于这部分基础,可以先看我之前整理的同源与跨域http协议

请求方式和请求头也会影响网络行为。例如 JSON 请求、自定义请求头或 PUTDELETE 等方法可能触发 CORS 预检。对应的报文结构可以结合http请求方式详解HTTP OPTIONS 请求详解一起看。

但有一件事很反直觉:

CORS 约束网页的跨源 HTTP 读取,不会替你保护原生桥。

下面三条路径必须分开观察:

流程图 2流程图 2
  • fetch 失败:看 Network、HTTP 状态码、重定向、Cookie、CORS 和 OPTIONS。
  • 页面调用原生失败:看桥对象、当前 URL、frame、注入时机、方法契约和 Native 日志。
  • Native 回调页面失败:看当前 Document 是否仍存活、回调 ID 是否属于当前导航、执行 JS 时页面是否已经切换。

如果 Native 提供一个“代替网页请求任意 URL”的能力,页面确实可以绕开浏览器 CORS,但这相当于主动拆掉浏览器的一道隔离墙。这样的通用代理会把内网访问、凭据泄漏和任意文件读取风险一起带进应用,不应作为跨域修复方案。

原生通道和 JS 门面为什么要分层

一个可维护的桥接通常至少有两层:

  1. Native transport:负责把消息送进 Android,再把结果送回当前 frame。
  2. JavaScript facade:负责 Promise、超时、能力检测、错误归一化和 ready 事件。

Android 官方的 JSBridge 指南目前列出三代通信 API,并把 addWebMessageListener() 标为推荐方案:

API通信特点来源控制适用判断
addWebMessageListener()异步、双向,页面通过 window.hostTransport 收发消息注册时提供 allowedOriginRules,回调给出 sourceOriginisMainFrame新项目优先评估
postWebMessage()异步、双向,可配合 WebMessageChannel发送端指定 targetOrigin,主要面向主 frame不支持上一项时的替代方案
addJavascriptInterface()同步、网页到 Native默认对所有 frame 可见,无法可靠判断调用 frame旧版本兼容,不作为现代默认方案

官方指南给出的参考门槛是:addWebMessageListener 需要 WebView 82 及 AndroidX WebKit 1.3.0,postWebMessage 需要 WebView 45 及 AndroidX WebKit 1.1.0;实际仍应以 WebViewFeature 检测结果为准。

推荐:先注册 addWebMessageListener

监听器必须在 loadUrl() 之前注册。这样,匹配允许来源的页面从开始加载时就能看到注入对象,页面脚本不需要等待一次晚到的 evaluateJavascript()

fun setupWebView(webView: WebView) {
    if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
        setupLegacyFallback(webView)
        return
    }

    val listener = WebViewCompat.WebMessageListener {
        view, message, sourceOrigin, isMainFrame, replyProxy ->
        if (isMainFrame && message.type == WebMessageCompat.TYPE_STRING) {
            val payload = message.data
            if (payload != null) {
                bridgeExecutor.execute {
                    val result = handleBridgeMessage(payload, sourceOrigin.toString())
                    view.post { replyProxy.postMessage(result) }
                }
            }
        }
    }

    WebViewCompat.addWebMessageListener(
        webView,
        "hostTransport",
        setOf("https://docs.example.test"),
        listener,
    )

    webView.loadUrl("https://docs.example.test/guide/")
}

上例中的 bridgeExecutor 是应用自己管理的后台执行器;handleBridgeMessage() 应先校验 sourceOrigin、消息大小和方法白名单,再执行具体工作,最后只把可序列化的结果交给 replyProxy。这个最小协议只接收 TYPE_STRING;如果启用 ArrayBuffer,必须先做特性检测,再按 message.type 分支读取 arrayBuffer。对二进制消息直接读取 data 会抛出 IllegalStateException

allowedOriginRules 按 scheme、主机和端口匹配,忽略路径。https://*.example.test 只匹配子域名,不匹配裸域名;裸域名和子域名需要分别列入集合。不要用 * 代替来源设计。

推荐 API 的网页侧入口是一个消息对象,而不是一组任意的原生方法:

window.hostTransport.onmessage = (event) => {
  console.log("Native reply:", event.data);
};

window.hostTransport.postMessage(JSON.stringify({
  id: "request-1",
  method: "getRuntimeInfo",
  params: {},
}));

监听器会对所有匹配来源的 frame 生效。如果协议只允许主 frame,就像上面的 Native 代码一样拒绝 isMainFrame == false 的消息;如果确实需要 iframe,则应为每个 frame 单独设计权限和回传规则。监听器回调在主线程运行,耗时工作必须转移,否则桥本身可能制造 ANR。JavaScriptReplyProxy 可以保留后异步回复;原 frame 已经导航或销毁时,发送会被静默忽略,所以页面仍需要调用 ID、超时和导航取消语义。

兼容方案:postWebMessage 和旧版接口

postWebMessage() 可以配合消息端口建立异步通道,但它主要针对主 frame,网页也不总能清楚区分消息来自应用还是其他 iframe。它适合兼容较低版本的受控场景,不应把 targetOrigin: "*" 当成默认配置。

addJavascriptInterface() 仍可用于非常老的 WebView:

class LegacyChannel(
    private val onMessage: (String) -> Unit,
) {
    @JavascriptInterface
    fun postMessage(payload: String) {
        onMessage(payload)
    }
}

webView.addJavascriptInterface(
    LegacyChannel(::handleLegacyMessage),
    "hostTransport",
)

它是同步调用,原生方法返回前会阻塞 JavaScript;方法在后台线程执行,Native 侧必须自行处理线程安全。更重要的是,它没有来源白名单,默认对所有 frame 可见,不能依赖 WebView.getUrl() 判断到底是哪一个 frame 发起调用。只有当 WebView 中的 HTML 和 JavaScript 完全受控,并且新 API 不可用时,才考虑采用它。

门面脚本仍然有价值

即便 transport 已由 addWebMessageListener 在文档开始阶段注入,页面仍可以用一层普通 JS 门面统一 Promise、超时、错误和能力版本:

(() => {
  if (window.HostBridge) return;

  let sequence = 0;
  const pending = new Map();

  function invoke(method, params = {}, timeoutMs = 3000) {
    return new Promise((resolve, reject) => {
      const id = `${Date.now()}-${++sequence}`;
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error(`Bridge timeout: ${method}`));
      }, timeoutMs);

      pending.set(id, { resolve, reject, timer });
      window.hostTransport.postMessage(
        JSON.stringify({ id, method, params }),
      );
    });
  }

  function settle(message) {
    let result;
    try {
      result = typeof message === "string"
        ? JSON.parse(message)
        : message;
    } catch {
      return;
    }
    if (!result || typeof result.id !== "string" || typeof result.ok !== "boolean") {
      return;
    }
    const task = pending.get(result.id);
    if (!task) return;

    clearTimeout(task.timer);
    pending.delete(result.id);
    result.ok ? task.resolve(result.data) : task.reject(result.error);
  }

  window.HostBridge = Object.freeze({
    version: "1.0",
    invoke,
    settle,
  });

  window.hostTransport.onmessage = (event) => {
    window.HostBridge.settle(event.data);
  };

  window.dispatchEvent(new CustomEvent("hostbridge:ready", {
    detail: { version: window.HostBridge.version },
  }));
})();

这里的 settle() 只演示最小的消息形状检查;生产协议还应限制错误字段、结果大小和重复响应,并在页面销毁时主动拒绝 pending 中的请求。

这段脚本一旦在页面 A 执行,pendingsequencewindow.HostBridge 就属于页面 A。页面 B 不会继承它们;但只要 listener 仍绑定在同一个 WebView 上,符合来源规则的新文档会重新得到 window.hostTransport。因此,推荐的修复不是把旧页面对象“保存起来”,而是让每次导航都重新执行门面并重新发出 ready。

为什么不能只判断对象是否存在

下面的调用存在竞态:

window.HostBridge.invoke("getRuntimeInfo")

门面脚本仍可能晚于页面业务代码执行,或者因为降级路径而尚未注入。更稳妥的做法是把 ready 变成协议:

function waitForHostBridge(timeoutMs = 3000) {
  if (window.HostBridge) return Promise.resolve(window.HostBridge);

  return new Promise((resolve, reject) => {
    const timer = setTimeout(
      () => reject(new Error("HostBridge is not ready")),
      timeoutMs,
    );

    window.addEventListener("hostbridge:ready", () => {
      clearTimeout(timer);
      resolve(window.HostBridge);
    }, { once: true });
  });
}

ready 不是“页面加载完毕”的别名。它至少应该表示:

  • 当前 Document 的原生 transport 已可用;
  • 当前 Document 的门面已经执行;
  • 宿主完成了来源和能力判断;
  • 页面拿到了协议版本或 capability 列表。

onPageFinished() 只能说明某个加载阶段结束,不能证明门面脚本、异步初始化和业务依赖都已经完成。HarmonyOS 的 onPageEnd 等类似回调也不能直接当作跨端统一的 bridge ready。

门面脚本应该在什么时候进入页面

常见方式没有一个可以无条件适配所有 WebView 版本:

方式优点风险
页面自己的同步 <script>执行顺序由 HTML 控制受 CSP、缓存、资源失败和页面版本影响
AndroidX WebKit Document Start 脚本能在页面业务脚本之前较早建立门面需要用 WebViewFeature 检测设备能力、限制允许的 origin,并明确脚本是否允许 frame
页面回调后 evaluateJavascript()宿主实现直接,兼容面较广可能晚于页面业务代码,只对当前 Document 生效
原生对象直接作为公共 API少一层门面注入平台细节暴露给 H5,版本演进和异步契约更难统一

支持相应 AndroidX WebKit 能力时,可以考虑 WebViewCompat.addDocumentStartJavaScript();它同样要配置允许来源,并确认脚本是否会进入匹配来源的 iframe。不支持时则需要明确的降级路径。无论选哪种方式,都要遵循同一条规则:每个新 Document 都要重新建立页面侧状态,并发出新的 ready。

一次完整导航的正确顺序应该能在日志中被还原:

流程图 3流程图 3

给每次主文档导航分配 navigationId 很有用。Native 回调前先核对 ID,可以避免页面 B 收到页面 A 的过期结果。

安全边界必须由 Native 守住

addJavascriptInterface() 的危险不在于方法写得多,而在于它把 Native 权限带进了网页环境。Android 官方文档把它列为旧版方案:注入对象默认对 WebView 的所有 frame 可见,应用无法仅凭该接口可靠判断调用来自哪个 frame。推荐的 addWebMessageListener() 虽然有来源规则,也不能替代对受信页面自身 XSS 的防护。

最低限度应做到:

  1. 只给受信内容开放桥。 只把明确的 HTTPS origin 放入 allowedOriginRules;主文档即将进入非允许来源时,停止加载、移除接口,或改用 Custom Tabs。
  2. 把 frame 规则写进协议。 addWebMessageListener() 会把匹配来源的 iframe 也纳入监听范围;不需要 iframe 时,Native 侧拒绝 isMainFrame == false
  3. Native 做能力白名单。 不接受任意方法名、任意 Intent、任意文件路径或任意网络地址。
  4. 校验每个参数。 包括类型、长度、枚举范围、URL scheme 和超时;不要只校验 JSON 能否解析。受信 origin 自身存在 XSS 时,来源规则也可能被攻击者利用。
  5. 不向网页返回长期凭据。 页面需要的应是完成某项操作后的最小结果,而不是 Native 保存的密钥或完整账号状态。
  6. 关闭不需要的能力。 JavaScript、文件访问、混合内容、地理位置、媒体权限都应按页面需求最小化开启。
  7. 保留 Safe Browsing。 WebView 默认会对已知危险网址给出警告;除非有充分理由,不要全局退出这项保护。
  8. 调用 Jetpack API 前做功能检测。 使用 WebViewFeature.isFeatureSupported(),为旧 WebView 保留可控降级。
  9. 调试能力只在调试包启用。
if (BuildConfig.DEBUG) {
    WebView.setWebContentsDebuggingEnabled(true)
}

来源规则、参数校验和调试开关不是互相替代的措施。桥接的最小攻击面应当在 Native 侧形成闭环,而不是靠页面把对象名称藏起来。

大数据不要塞进一条 JSON

桥接消息适合控制命令,不适合无上限地搬运文件。Android 官方建议:

  • 先检查 WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER;支持时再通过 WebMessageCompatArrayBuffer 传二进制,避免 Base64 带来的额外体积,同时仍要考虑进程间复制和内存峰值。
  • 对特别大的内容,网页发起一个受控的占位 URL,Native 在 shouldInterceptRequest() 中以 InputStream 流式返回,而不是把整个文件拼成一个字符串。占位 URL 应与页面保持同源,或返回精确的 CORS 响应头;不能因为请求由 Native 拦截就假设浏览器会跳过 CORS。
  • 每个能力都应限制载荷大小和并发数。超限要返回明确错误,而不是让渲染器或主进程在内存压力下失去响应。

“桥调用超时”有时不是网络慢,而是消息在 UI 线程排队、序列化过大或渲染器已经失效。把消息大小、序列化耗时和渲染器状态一起记录,才能看见真正的瓶颈。

Jetpack WebKit:不要用系统版本猜能力

Android 框架 API 固定在操作系统版本,而设备上的 Android System WebView 会独立更新。Jetpack WebKit 指南把它描述为兼容层;实现时优先使用 WebViewCompatWebSettingsCompat 等 API,并通过 WebViewFeature.isFeatureSupported() 判断当前 WebView APK 是否支持某项能力。

这会改变实现习惯:不要写“Android 版本大于某值就一定支持”,也不要为了触发初始化而读取一次 User-Agent。对现代桥接、Document Start、二进制消息、渲染器客户端等能力,都应遵循“检测、使用、降级”的顺序。

WebView 的首次启动可能在主线程隐式发生,造成明显卡顿甚至 ANR。官方的 startUpWebView() 需要 androidx.webkit:webkit:1.16.0 或更高版本。WebView 不在关键路径时,可以在应用早期启动并等成功回调后再触碰其他 WebView API;WebView 在关键路径时,应尽早启动,但到真正需要展示时可直接继续,不必停住界面等待回调。只是如果启动后立即调用其他 WebView API,主线程仍会等待初始化追上,性能收益会很有限。简单地开一个后台线程读取 User-Agent 属于旧的变通做法,不应继续扩散。

同理,prefetchprerender 和预连接只适合经过测量的高概率导航。它们会提前消耗网络、电量和渲染器资源,不能用来掩盖桥接初始化竞态。

WebView 也会崩:渲染器终止后的重建

网页内容运行在独立的渲染器进程中。系统回收内存或渲染器崩溃时,会触发 WebViewClient.onRenderProcessGone()。这个场景与“导航换了 Document”不同,但修复原则相似:旧状态不能继续使用。

如果选择让应用继续运行,恢复路径必须:

  1. 从视图层级移除失效的 WebView;
  2. 调用 destroy() 并清除 Activity/Fragment 对它的引用;
  3. 创建全新的 WebView,再重新配置来源规则、消息监听器、客户端回调和页面门面;
  4. 回调返回 true,并限制连续重建次数;不要对导致崩溃的同一页面无限重试。若产品明确选择让应用随渲染器终止,则可以返回 false,但这不是稳定的默认体验。

因此,桥接初始化不能只写在 Activity 的一次性构造代码里。它应当是一个可重复执行、可观测、可取消的 setupWebView() 流程。

页面显示正确,才算 WebView 集成完成

Web 内容被放进 View 后,还要面对屏幕、主题和输入法的差异:

  • 在 HTML 中设置 meta name="viewport",通常使用 width=device-width,再用 CSS 媒体查询适配不同宽度;不要把禁用缩放当作默认答案。
  • WebView 和父布局的尺寸优先使用 match_parent,避免 wrap_content 导致测量结果不稳定。
  • 第一方页面应实现 prefers-color-scheme,并提供 color-scheme 元标记;强制算法加深只适合作为没有深色主题时的兼容策略。
  • Android 的状态栏、刘海和输入法会影响 WebView 的视觉视口。原生层和网页层不要重复应用同一组 WindowInsets,否则会出现键盘收起后仍残留的“幽灵内边距”。
  • 监听 resize 时不要因为键盘出现就主动清除输入框焦点,否则容易形成“键盘出现 -> resize -> blur -> 键盘消失”的循环。

这些问题不会出现在桥接接口定义里,却会直接影响用户是否认为页面“坏了”。

调试要看两套日志

官方调试文档给出的路径可以组合成一条很短的闭环:

  1. 调试构建中开启 WebView.setWebContentsDebuggingEnabled(true),用 Chrome 的 chrome://inspect 连接 WebView。
  2. WebChromeClient.onConsoleMessage() 中把网页控制台的级别、消息和脱敏后的 origin/path 送到 Android 日志;生产环境不要记录完整 URL、payload、Cookie 或 token。
  3. 使用 WebView DevTools 检查崩溃、网络日志和实验性标志;需要复现本地页面时,可用 adb reverse 或 Chrome 端口转发连接开发服务器。
  4. 在目标 WebView Beta/Canary 渠道上提前验证,尤其是依赖 Jetpack WebKit 新特性的桥接和渲染器恢复逻辑。

WebView 自身的诊断与应用记录的业务日志是两条数据通道。按照官方的隐私说明,用户同意分享使用情况和诊断信息时,WebView 可上报使用统计和崩溃报告;使用统计在特定条件下可与应用包名关联,崩溃报告会清理堆栈内存中的字符串且不收集 URL。如果应用的隐私策略要求退出使用统计,可在 manifest 的 <application> 中配置:

<meta-data
    android:name="android.webkit.WebView.MetricsOptOut"
    android:value="true" />

这项配置不会关闭崩溃报告,也会降低 Google 提前发现特定应用 WebView 回归的能力,因此应把它当作隐私取舍,而不是一条无条件复制的模板配置。

日志建议使用以下字段,而不是把业务数据原样打印出来:

webviewId, navigationId, origin, isMainFrame,
transportReady, facadeReady, capabilityVersion,
methodName, payloadBytes, elapsedMs, resultCode

如果应用需要在没有可视 WebView 的情况下执行非交互式 JavaScript,也不要为了跑一段计算而创建完整 WebView。Jetpack JavaScriptEngine 提供 JavaScriptSandbox 和独立的 JavaScriptIsolate;先检查 JavaScriptSandbox.isSupported(),按组件生命周期关闭沙盒,并对脚本输入使用命名数据传递,避免拼接代码造成注入风险。

三个平台的共同点与差异

JSBridge 不是 Web 标准,它是宿主和页面共同定义的协议。三端公开能力可以这样对照:

平台H5 到 Native 的常见入口Native 到 H5早期脚本能力
Android WebViewAndroidX addWebMessageListener(推荐)、postWebMessage、旧版 addJavascriptInterfaceJavaScriptReplyProxyevaluateJavascriptAndroidX WebKit Document Start,需特性检测
iOS WKWebViewWKScriptMessageHandlerevaluateJavaScriptWKUserScript 的 Document Start/End,受 main frame 与 content world 配置影响
HarmonyOS ArkWebJS Proxy 或控制器提供的消息能力runJavaScript 等控制器 API取决于 ArkWeb SDK 版本和宿主配置

三端最值得统一的不是底层 API 名称,而是上层契约:

  • 每个新 Document 都重新初始化;
  • ready 事件包含协议版本和当前导航标识;
  • capability 明确告诉页面哪些能力可用;
  • 参数和结果使用稳定的 JSON schema;
  • 统一成功、失败、取消、超时和页面销毁语义;
  • 明确主 frame、iframe 和允许来源规则。

动态执行的 JavaScript 在三端都应该按“当前页面状态”理解。至于原生对象能否跨导航继续暴露、注入覆盖哪些 frame、脚本能多早执行,均受系统版本、内核版本和配置影响,应以目标 SDK 文档与实测为准。

一套从现象到根因的排查顺序

再遇到“页面能打开,但某个能力突然不能用”,按下面的顺序查,通常比从业务代码里盲目加重试快得多。

第一步:先判断是不是 HTTP 问题

  • Network 里有没有请求?
  • 是 DNS、TLS、30x、4xx/5xx,还是浏览器报告 CORS?
  • 是否出现 OPTIONS,响应的允许来源、方法和请求头是否匹配?
  • Cookie 是否因为 SameSite、Secure 或凭据模式没有发送?

如果根本没有网络请求,而错误发生在 window.HostBridge.invoke(),就不要继续调整 CORS 响应头。

第二步:观察当前页面,而不是只看 WebView

console.table({
  origin: location.origin,
  transport: typeof window.hostTransport,
  facade: typeof window.HostBridge,
  frame: window === window.top ? "main" : "iframe",
});

这几项可以立刻区分:原生通道没注册、门面没执行、页面跑在 iframe、或者导航已经换了 origin。

第三步:还原导航和注入时序

日志至少记录:

  • WebView 实例标识;
  • 主文档 origin、脱敏后的 path 和 navigationId
  • transport 注册或移除;
  • facade 注入开始、执行结果;
  • ready 版本与 capability;
  • 调用 ID、方法、耗时和最终状态。

不要记录完整请求体、Cookie、token 或页面个人数据。定位桥接时序不需要把敏感信息写进日志。

第四步:覆盖真正会出问题的导航

至少验证以下场景:

场景预期
冷启动加载首页ready 只触发一次,能力可调用
location.href 到下一页新导航重新 ready,旧回调失效
location.replace()行为与硬导航一致,不依赖浏览历史
刷新重新初始化门面
30x 重定向最终来源仍在允许列表,且只保留最终导航状态
pushState() 切路由不重复注册全局 transport,页面监听按组件生命周期处理
iframe 加载第三方内容推荐桥拒绝非主 frame;旧接口方案应直接避免在带桥页面加载第三方 iframe
点击外部链接转 Custom Tabs 或外部浏览器,不把桥带过去
页面销毁时仍有请求Promise 以明确错误结束,不把结果送到下一页

最后回到那个按钮

这次问题最终没有通过“多等一会儿”解决。宿主把动态执行一次的门面脚本改成了每个主文档都重新建立的初始化流程,页面只在收到带版本号的 ready 后调用能力;外部链接交给 Custom Tabs,HTTP 错误和桥接错误也使用不同的日志类别。

第二页的按钮恢复只是表面结果。真正有价值的变化,是团队终于把 WebView 看成了几个生命周期不同的对象,而不是一个装进去就永久有效的黑盒。

记住这条判断链即可:

先选对容器
  -> 再确认当前 Document
  -> 区分 HTTP 与 Native 通道
  -> 每次硬导航重建 JS 门面
  -> ready 后再调用
  -> 最后由 Native 校验来源和能力

参考资料