WebView与原生JS交互:JSBridge设计模式与安全实践

Android WebView深度探索系列 · 第4/5篇

从内核原理到工程实战,全面掌握WebView开发

第1篇:WebView内核原理:从Chromium到System WebView的架构全景

第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控

第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构

第4篇:WebView与原生JS交互:JSBridge设计模式与安全实践(本篇)

⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护

有一天我在排查一个线上 bug------H5 页面点了按钮没反应,原生客户端也没收到任何回调。抓包也没问题,JS 没报错,Log 里一片祥和。

最后定位的原因是:addJavascriptInterface 注入的对象在低版本系统上被回收了,而 JS 代码还在持有这个引用往上调。

那一刻我才真正意识到,JSBridge 这东西看起来简单,实际上坑得很深。这篇文章就来把这块彻底说清楚。

一、三种 JS-Native 通信方式

先把几种方式摆出来对比,再逐个深入讲。

方式 方向 核心原理 主要缺陷
addJavascriptInterface JS→Native 向JS环境注入Java对象 4.2以下安全漏洞
shouldOverrideUrlLoading JS→Native 拦截特定URL scheme 回调异步难,并发丢失
evaluateJavascript Native→JS 主线程执行JS字符串 必须主线程,API 19+

1.1 addJavascriptInterface 的工作原理

这个 API 做的事其实很直接:把一个 Java 对象注入到 WebView 的 JS 全局作用域里,JS 就可以直接通过对象名调用它的方法。

复制代码
// Native 侧:注入对象
webView.addJavascriptInterface(
    NativeBridge(),
    "NativeBridge"
)

// NativeBridge 定义
class NativeBridge {
    @JavascriptInterface
    fun showToast(msg: String) {
        Toast.makeText(
            ctx, msg,
            Toast.LENGTH_SHORT
        ).show()
    }
}

// JS 侧:直接调用
NativeBridge.showToast("Hello")

有几个细节必须知道:

@JavascriptInterface 注解是 API 17 加的------没有这个注解的方法,在 API 17+ 的设备上 JS 根本调不到。这是历史上那个远程代码执行漏洞(CVE-2012-6636)修复方案的一部分。

• JS 调用 Native 方法时,是在 WebView 的 JS 线程上执行的,不是主线程。所以在 Bridge 方法里操作 UI,必须切回主线程。

• 注入的 Java 对象有一个生命周期问题:WebView 持有的是弱引用。如果你把匿名内部类或 Lambda 传进去,GC 可能把它回收掉。

4.2 以下安全漏洞:addJavascriptInterface 在 Android 4.2(API 17)之前没有 @JavascriptInterface 限制,恶意 JS 可通过 getClass().getMethod() 拿到注入对象的全部方法,进而反射调用 Runtime.exec 执行任意命令。目前 4.2 以下基本可以不管,但如果你的 app 还要适配,严禁注入过度封装的对象。

1.2 URL Scheme 拦截

原理是让 JS 通过 location.href 或 iframe 触发一次特定格式的 URL 跳转,Native 侧在 shouldOverrideUrlLoading 里拦截并解析。

复制代码
// JS 侧,使用 iframe 触发
function callNative(action, params) {
  const iframe =
    document.createElement(
      'iframe'
    );
  const url =
    `myapp://bridge/${action}?`
    + JSON.stringify(params);
  iframe.src = url;
  iframe.style.display = 'none';
  document.body.appendChild(iframe);
  setTimeout(() => {
    document.body.removeChild(
      iframe
    );
  }, 300);
}

这个方案有几个硬伤:

• 多个 JS 调用快速触发时,WebView 会把 URL 变更合并,导致调用丢失

• URL 长度有限制,传大参数会被截断

• 无法直接同步返回值给 JS

所以 URL Scheme 方案现在基本只作为降级兼容手段,生产项目里很少单独使用。

1.3 evaluateJavascript

这个是 Native→JS 的主要方式,API 19(4.4)引入。

复制代码
// 必须在主线程调用
webView.evaluateJavascript(
    "window.onNativeCallback(" +
    "'$data')"
) { result ->
    // result 是 JS 执行的返回值
    // 在主线程回调
    Log.d(
        "Bridge",
        "JS returned: $result"
    )
}

注意事项:必须在主线程调用,否则会直接 crash。如果你在 Bridge 的回调线程里拿到 Native 数据想立刻回传给 JS,一定要用 Handler.postrunOnUiThread 切线程。

二、生产级 JSBridge 框架设计

说完基础 API,来讲真正值得参考的架构设计。我见过很多项目的 JSBridge 写得像一团乱麻------所有 handler 塞在一个大 switch-case 里,回调用全局 Map 管,超时没人处理,安全校验全靠注释里的"TODO"。

一个生产可用的 JSBridge 至少要解决这几个问题:消息协议、回调管理、异步调用、超时处理。

2.1 消息协议设计

先定一个统一的 JSON 消息格式,双端都遵守:

复制代码
// 请求协议
{
  "callId": "uuid-xxx",
  "action": "getUserInfo",
  "params": { ... },
  "timestamp": 1717040000000
}

// 响应协议
{
  "callId": "uuid-xxx",
  "code": 0,
  // 0=成功,其他=错误码
  "data": { ... },
  "msg": "success"
}

callId 是关键------它把请求和响应关联起来,支持多个并发调用同时在途而不串号。

2.2 回调管理与超时处理

复制代码
class CallbackManager {

  private val callbacks =
    ConcurrentHashMap<
      String,
      CallbackEntry
    >()

  private val handler =
    Handler(Looper.getMainLooper())

  fun register(
    callId: String,
    callback: BridgeCallback,
    timeoutMs: Long = 10_000L
  ) {
    val entry = CallbackEntry(
      callback,
      System.currentTimeMillis()
    )
    callbacks[callId] = entry

    // 超时自动清理
    handler.postDelayed({
      val cb =
        callbacks.remove(callId)
      cb?.callback.onError(
        -1, "timeout"
      )
    }, timeoutMs)
  }

  fun dispatch(
    callId: String,
    resp: BridgeResponse
  ) {
    val entry =
      callbacks.remove(callId)
        ?: return
    if (resp.code == 0) {
      entry.callback.onSuccess(
        resp.data
      )
    } else {
      entry.callback.onError(
        resp.code, resp.msg
      )
    }
  }
}

这里用 ConcurrentHashMap 是因为 addJavascriptInterface 的调用发生在 JS 线程,dispatch 也可能在其他线程触发,要保证线程安全。

2.3 Handler 注册与分发

用注解驱动的 Handler 注册,比手写 switch-case 优雅多了:

复制代码
// 定义注解
@Retention(
  AnnotationRetention.RUNTIME
)
@Target(
  AnnotationTarget.FUNCTION
)
annotation class BridgeHandler(
  val action: String
)

// Handler 实现
class UserBridgeHandler {

  @BridgeHandler("getUserInfo")
  fun getUserInfo(
    params: JSONObject,
    callback: BridgeCallback
  ) {
    val user =
      userRepo.getCurrentUser()
    callback.onSuccess(
      user.toJson()
    )
  }

  @BridgeHandler("logout")
  fun logout(
    params: JSONObject,
    callback: BridgeCallback
  ) {
    // ... 处理逻辑
  }
}

启动时通过反射扫描所有带 @BridgeHandler 注解的方法,建立 action→method 的映射表。运行时按 action 分发,代码清晰,新增 handler 只需加一个方法。

三、JS注入时机与常见的坑

很多人踩过这个坑:往 WebView 里注入初始化 JS,但 H5 页面加载完后发现这段代码根本没执行,或者执行了但方法调不到。

3.1 注入时机

WebView 加载流程中有几个关键时机:

URL 开始加载

onPageStarted

此时注入:DOM 未就绪,JS 方法可能找不到

onPageFinished

推荐注入时机:DOM 已就绪,可以安全执行 JS

onLoadResource(多次)

不要在此注入:会多次触发,重复执行副作用

3.2 注入 vs 页面加载的竞态

还有一个坑:如果 H5 页面加载很快(本地 assets 或缓存命中),onPageFinished 会在 Native 还没调 addJavascriptInterface 之前就触发------这种情况下 JS 调 Bridge 会找不到对象。

解法:在 WebView 创建时就立刻调用 addJavascriptInterface,不要等页面加载。这个 API 是 WebView 级别的,不是页面级别的,加载任何页面都会有效。

复制代码
//  正确:创建时就注入
val webView = WebView(context)
webView.addJavascriptInterface(
    bridge, "NativeBridge"
)
// 然后再 loadUrl
webView.loadUrl(url)

//  错误:在 onPageStarted 里注入
override fun onPageStarted(...) {
  webView.addJavascriptInterface(
    bridge, "NativeBridge"
  ) // 有竞态风险
}

四、安全防护:不能省的几道关卡

JSBridge 安全问题在混合 app 里被严重低估了。H5 业务代码往往来自多个团队甚至第三方,如果 Bridge 不做好防护,等于给了外部代码调用 Native 能力的通道。

4.1 域名白名单校验

最基础的一道防线------只允许白名单内的域名调 Bridge:

复制代码
class SecureBridge(
    private val webView: WebView,
    private val allowedHosts:
        Set<String>
) {

  private fun isUrlTrusted(): Boolean {
    val currentUrl =
      webView.url ?: return false
    return try {
      val host = URI(currentUrl).host
      allowedHosts.any { allowed ->
        host.endsWith(allowed)
      }
    } catch (e: Exception) {
      false
    }
  }

  @JavascriptInterface
  fun invoke(json: String) {
    if (!isUrlTrusted()) {
      Log.w(
        "Bridge",
        "Untrusted caller blocked"
      )
      return
    }
    // 正常处理
    dispatch(json)
  }
}

4.2 接口权限分级

不是所有 Bridge 接口都需要同等级别的信任。可以把接口分成三级:

公开级:任何域名都能调,比如获取设备信息、上报埋点

业务级:需要在白名单域名内,比如打开 Native 页面、调用支付

敏感级:需要额外 token 鉴权,比如获取用户敏感信息、调用危险能力

复制代码
enum class BridgeLevel {
  PUBLIC,    // 公开
  BUSINESS,  // 业务(域名校验)
  SENSITIVE  // 敏感(需Token)
}

@BridgeHandler(
  action = "getDeviceId",
  level = BridgeLevel.PUBLIC
)
fun getDeviceId(...) { ... }

@BridgeHandler(
  action = "getUserToken",
  level = BridgeLevel.SENSITIVE
)
fun getUserToken(...) { ... }

4.3 参数校验与防重放

所有从 JS 传来的参数,一律不要相信。特别是涉及 ID、金额、操作类型的字段,必须在 Native 侧做二次校验,不能只靠 JS 层的约束。

防重放攻击也值得考虑:请求里携带 timestamp,Native 侧判断时间窗口(比如只接受前后 30 秒内的请求),可以防止截获并重放的攻击。对于敏感操作,还可以加一次性 nonce。

五、高性能 JSBridge:减少序列化开销

做了几个项目之后,我发现 JSBridge 的性能问题主要集中在两个地方:JSON 序列化/反序列化的开销,以及线程切换的成本。

5.1 批量消息合并

H5 页面初始化时可能短时间内触发大量 Bridge 调用(初始化配置、获取用户信息、埋点上报......)。与其每次都单独一个 evaluateJavascript,可以引入一个消息队列,将短时间内积累的多条消息合并成一次 JS 执行:

复制代码
class BatchJsDispatcher(
    private val webView: WebView
) {
  private val pendingMsgs =
    ArrayDeque<String>()
  private val handler =
    Handler(
      Looper.getMainLooper()
    )
  private var scheduled =
    false

  fun post(jsCode: String) {
    pendingMsgs.add(jsCode)
    if (!scheduled) {
      scheduled = true
      handler.post { flush() }
    }
  }

  private fun flush() {
    scheduled = false
    if (pendingMsgs.isEmpty())
      return
    val batch =
      pendingMsgs.joinToString(
        separator = ";"
      )
    pendingMsgs.clear()
    webView.evaluateJavascript(
      batch, null
    )
  }
}

5.2 避免大对象序列化

如果需要传大量数据(比如把一个列表数据下发给 H5),不要把整个对象全部塞进 Bridge 消息里。更好的做法是 Native 侧把数据存到 WebView 可访问的 localStorage 或注入一个 lazy getter,H5 按需读取。

六、从零实现一个生产级 JSBridge SDK

说了这么多理论,来看一个完整的 SDK 骨架,把前面所有点串起来。

6.1 完整 SDK 结构

jsbridge-sdk/

JsBridge.kt --- 入口,WebView 绑定

BridgeHost.kt --- JS接口注入对象

BridgeDispatcher.kt --- action 分发

CallbackManager.kt --- 回调+超时

SecurityChecker.kt --- 安全校验

BatchDispatcher.kt --- 批量下发

handlers/ --- 各业务Handler

6.2 核心入口类

复制代码
class JsBridge private constructor(
    private val webView: WebView,
    private val config: BridgeConfig
) {
  private val dispatcher =
    BridgeDispatcher()
  private val callbackMgr =
    CallbackManager()
  private val security =
    SecurityChecker(config)
  private val batcher =
    BatchJsDispatcher(webView)

  fun bind() {
    webView.addJavascriptInterface(
      BridgeHost(
        dispatcher, callbackMgr,
        security, webView
      ),
      "NativeBridge"
    )
  }

  // Native 主动调用 JS
  fun callJs(
    method: String,
    params: JSONObject
  ) {
    val js =
      "window.JSBridge" +
      ".onNativeCall(" +
      "'$method'," +
      "$params)"
    batcher.post(js)
  }

  fun destroy() {
    callbackMgr.cancelAll()
    webView.removeJavascriptInterface(
      "NativeBridge"
    )
  }

  companion object {
    fun create(
      webView: WebView,
      block: BridgeConfig.Builder.() ->
        Unit
    ) = JsBridge(
        webView,
        BridgeConfig.Builder()
          .apply(block).build()
      ).also { it.bind() }
  }
}

6.3 调用示例

复制代码
// 初始化
val bridge = JsBridge.create(
  webView
) {
  allowedHosts(
    "example.com",
    "h5.myapp.com"
  )
  timeout(10_000L)
  registerHandlers(
    UserHandler(),
    PaymentHandler(),
    RouterHandler()
  )
}

// 主动下发数据给 H5
bridge.callJs(
  "onUserLogin",
  JSONObject().apply {
    put("userId", user.id)
    put("name", user.name)
  }
)

// 销毁时清理
override fun onDestroy() {
  bridge.destroy()
  webView.destroy()
  super.onDestroy()
}

七、实战踩坑合集

最后聊几个真实项目里遇到的问题,比枯燥的文档有用多了。

坑1:Bridge 对象被 GC 回收

症状是随机出现 JS 调用没反应,复现不稳定。原因前面提到了:传进 addJavascriptInterface 的对象如果是局部变量,很容易被回收。

解法:把 Bridge 对象保存为 Activity/Fragment 的成员变量,或者用 WebView 的 tag 持有它,保证生命周期一致。

坑2:JS 在 evaluateJavascript 之前就执行了

场景:页面 onPageFinished 后立刻 callJs,但偶发 H5 报 window.JSBridge is undefined

原因:onPageFinished 只代表 HTML 主文档加载完,不代表所有 JS 文件执行完毕。解法是 H5 初始化完成后主动调一个 Bridge(比如 NativeBridge.ready()),Native 收到信号后再开始 callJs。

坑3:WebView 销毁后 callback 仍在执行

用户快速退出页面,Bridge 请求还没完成,callback 里的 evaluateJavascript 在已销毁的 WebView 上执行,轻则静默失败,重则 crash。在 onDestroy 里调用 bridge.destroy() 清掉所有待处理回调是必须的。

坑4:Bridge handler 里直接做耗时操作

有同学在 Bridge handler 里直接做网络请求或数据库查询------因为调用是在 JS 线程发起的,这会阻塞 WebView 渲染。正确做法是立即切到后台线程异步处理,完成后通过 callback 回调。

用了这套框架之后,我们项目的 Bridge 相关 crash 从一个版本里 7-8 个降到了基本为零,JS 调用超时的问题也从偶发变成了可被捕获和上报。关键不是哪个具体的技巧,而是把这些容易出问题的点都纳入了系统性管理。

Android WebView深度探索系列 · 第4/5篇

从内核原理到工程实战,全面掌握WebView开发

第1篇:WebView内核原理:从Chromium到System WebView的架构全景

第2篇:WebView白屏检测与解决方案:从原因分析到工程化监控

第3篇:WebView代理方案实现:拦截请求、注入资源与离线包架构

第4篇:WebView与原生JS交互:JSBridge设计模式与安全实践(本篇)

⏳ 第5篇:WebView性能优化与稳定性治理:预热、复用池与崩溃防护

下一篇预告

第5篇将进入 WebView 系列的收官篇------性能优化与稳定性治理:WebView 预热方案、复用池设计、内存泄漏排查,以及线上 crash 和 ANR 的防护体系。如果你的 App 里 WebView 页面打开慢、偶发崩溃,下一篇应该会有你要找的东西。

相关推荐
吃好睡好便好1 小时前
矩阵的左除和右除
人工智能·学习·线性代数·算法·矩阵
John_ToDebug1 小时前
Claude Code Agent 使用最佳实践与底层机制全解
人工智能·经验分享·ai
阿部多瑞 ABU1 小时前
一次针对大语言模型的“虚构历史前提注入”红队测试实录:当AI相信了不存在的对话历史
网络·人工智能·安全
lili00121 小时前
AI编程三件套CI集成与质量门禁:从“看起来对“到“证据确凿“
java·人工智能·python·ci/cd·ai编程
Rocktech_ruixun1 小时前
智慧餐饮新机遇:全场景无人化升级,破解餐饮业降本增效难题
人工智能·嵌入式硬件·ai·机器人
是烨笙啊1 小时前
AI编程:项目管理
ide·人工智能·ai编程
@国境以南,太阳以西1 小时前
【无标题】
人工智能
小黑雷1 小时前
新手如何创建第一个AI智能体(一)
人工智能
追光者♂1 小时前
【测评系列6】CSDN AI数字营销实测体验官——OpenClaw 数据采集工具新手入门指南
人工智能·深度学习·机器学习·ai·大模型·openclaw·前沿科学