26、WebKit 与原生交互:JSBridge 实现原理、消息队列与回调、同步与异步调用、安全性考量

好,我们进入第二十六讲。今天聊一个非常实战的话题——JSBridge。

说白了,JSBridge 就是一座桥。桥的一头是 JavaScript,另一头是原生代码(Java/Kotlin 或 Objective-C/Swift)。没有它,H5 页面就是个孤岛,调不了摄像头、拿不到设备信息、也做不了支付。

我在项目中见过太多人把 JSBridge 当成一个简单的“发消息”工具。其实它的设计深度,直接决定了整个混合应用的稳定性和性能。今天我们就把它拆开来看。

一、JSBridge 的核心原理

先问一个问题:为什么需要 JSBridge?

因为 WebKit 的 JS 引擎和原生代码运行在两个完全隔离的世界。JS 跑在 WebCore 的 JS 引擎里,原生代码跑在 ART 或 Dalvik 里。它们之间没有直接的内存共享,只能通过消息传递来通信。

核心思路:JSBridge 本质上是一个消息路由系统。JS 端发送一个请求,原生端接收并处理,然后返回结果。

我习惯把 JSBridge 的通信模型画成下面这张图:

JavaScript 侧 WebView 中的 H5 页面 JSBridge 注入对象 消息队列 + 回调管理 原生侧 Java/Kotlin 代码 JSBridge 接口实现 消息分发 + 回调处理 JSBridge 桥 JS 调用请求 原生回调结果 消息队列 { callbackId, action, params } 按顺序排队,等待处理 回调映射表 callbackId → JS 回调函数 原生处理完,触发回调

这张图我建议你多看两眼。它包含了 JSBridge 最核心的两个机制:消息队列回调映射表

二、消息队列与回调机制

JS 调用原生,不是直接调。它先把请求扔进一个队列里,然后通知原生来取。为什么这么设计?

你想想看,JS 是单线程的。如果每次调用都阻塞等待原生返回,页面直接就卡死了。所以 JSBridge 必须用异步的方式。

我的经验:早期我做过一个方案,JS 直接通过 prompt() 或 location.href 改变来触发原生调用。这种方式虽然简单,但问题很多——消息会丢失、回调无法保证顺序。后来我全部改成了基于消息队列的方案,稳定性提升了一个档次。

来看一个典型的消息队列实现:

// JS 侧:消息队列
window.JSBridge = {
  // 消息队列
  _queue: [],
  // 回调映射表
  _callbacks: {},
  // 自增 ID
  _callbackId: 0,

  // 调用原生方法
  call: function(action, params, callback) {
    var callbackId = ++this._callbackId;
    
    // 保存回调函数
    if (callback) {
      this._callbacks[callbackId] = callback;
    }

    // 构造消息
    var message = {
      callbackId: callbackId,
      action: action,
      params: params || {}
    };

    // 入队
    this._queue.push(message);

    // 通知原生取消息
    this._notifyNative();
  },

  // 通知原生(通过注入的 JS 接口)
  _notifyNative: function() {
    // 方式1:通过注入对象
    if (window.WebViewJavascriptBridge) {
      window.WebViewJavascriptBridge._fetchMessage();
    }
    // 方式2:通过 URL 拦截
    // location.href = 'jsbridge://_fetch_message';
  },

  // 原生处理完后,回调 JS
  _handleCallback: function(callbackId, data) {
    var callback = this._callbacks[callbackId];
    if (callback) {
      callback(data);
      delete this._callbacks[callbackId];
    }
  }
};

原生侧拿到消息后,解析 action,执行对应的原生方法,然后把结果通过回调 ID 返回给 JS。

// Android 原生侧:接收消息并处理
public class JSBridgeInterface {
    private WebView mWebView;
    private Handler mHandler = new Handler(Looper.getMainLooper());

    // JS 调用此方法来取消息
    @JavascriptInterface
    public void _fetchMessage() {
        // 从 JS 的消息队列中取消息
        mWebView.evaluateJavascript(
            "JSON.stringify(window.JSBridge._queue.shift())",
            new ValueCallback<String>() {
                @Override
                public void onReceiveValue(String json) {
                    if (json == null || json.equals("null")) return;
                    
                    // 解析消息
                    JSBridgeMessage msg = parseMessage(json);
                    
                    // 处理消息(异步执行,不阻塞 UI 线程)
                    mHandler.post(() -> {
                        Object result = handleAction(msg.action, msg.params);
                        
                        // 回调 JS
                        String callbackJs = String.format(
                            "window.JSBridge._handleCallback('%s', %s)",
                            msg.callbackId, new Gson().toJson(result)
                        );
                        mWebView.evaluateJavascript(callbackJs, null);
                    });
                }
            }
        );
    }
}

注意:@JavascriptInterface 注解在 Android 4.2 以上是必须的。没有这个注解,JS 无法调用原生方法。我曾经遇到过线上 bug,就是因为某个低版本手机没有加这个注解,导致整个 JSBridge 失效。

三、同步调用 vs 异步调用

JSBridge 的调用方式,分为同步和异步两种。很多人搞混了,我帮你理清楚。

特性 同步调用 异步调用
调用方式 JS 阻塞等待原生返回 JS 不阻塞,通过回调获取结果
实现原理 通过 prompt() 或 XMLHttpRequest 同步请求 消息队列 + 回调映射表
性能 差,会卡住 UI 线程 好,不阻塞页面渲染
适用场景 获取简单值(如设备 ID、token) 复杂操作(如拍照、支付、网络请求)
风险 超过 5 秒可能被系统 ANR 回调地狱,需要 Promise 封装

我个人习惯:能用异步就别用同步。同步调用虽然写起来简单,但代价太大了。你想想看,一个同步调用如果原生处理慢了,整个 WebView 都卡住,用户直接骂娘。

不过有一种情况我会用同步——获取一些轻量级的、必须立即返回的值。比如获取设备唯一标识:

// 同步获取设备 ID(通过 prompt 实现)
function getDeviceIdSync() {
  // prompt 会阻塞 JS 线程,直到原生返回
  var result = prompt('jsbridge://getDeviceId');
  return result;
}

// 异步获取设备 ID(推荐方式)
function getDeviceIdAsync() {
  return new Promise(function(resolve) {
    window.JSBridge.call('getDeviceId', {}, function(data) {
      resolve(data);
    });
  });
}

避坑指南:我曾经在一个金融 App 里用了同步调用获取用户信息,结果因为原生侧做了网络请求,导致页面卡死 3 秒多。后来改成异步 + 加载动画,体验好了很多。记住:任何可能耗时的操作,都不要用同步

四、安全性考量

JSBridge 的安全问题,是很多开发者容易忽略的。我见过太多 App 因为 JSBridge 被攻击,导致用户数据泄露。

主要的安全风险有这几个:

  • 接口暴露过多:JS 可以调用所有 @JavascriptInterface 方法,如果暴露了敏感接口,恶意网页就能直接调用
  • 参数注入:JS 传入的参数可能包含恶意代码,原生侧没有做校验就直接执行
  • URL 欺骗:通过修改 location.href 来伪造 JSBridge 调用
  • 回调劫持:恶意 JS 可以监听或篡改回调数据

我总结了一套安全实践,分享给你:

// 安全实践示例

// 1. 白名单机制:只允许特定域名调用
public class SafeJSBridge {
    private static final List<String> ALLOWED_ORIGINS = Arrays.asList(
        "https://app.example.com",
        "https://m.example.com"
    );

    @JavascriptInterface
    public void sensitiveOperation(String param) {
        // 检查来源 URL
        String url = mWebView.getUrl();
        if (!isAllowedOrigin(url)) {
            Log.w("JSBridge", "Blocked call from: " + url);
            return;
        }
        
        // 2. 参数校验:不要信任任何输入
        if (!isValidParam(param)) {
            sendError("Invalid parameter");
            return;
        }
        
        // 3. 执行操作
        doSensitiveOperation(param);
    }

    private boolean isAllowedOrigin(String url) {
        if (url == null) return false;
        for (String origin : ALLOWED_ORIGINS) {
            if (url.startsWith(origin)) return true;
        }
        return false;
    }
}

// 4. 回调数据加密(敏感数据)
function secureCallback(callbackId, data) {
  // 对敏感数据进行签名
  var signature = hmacSHA256(data, SECRET_KEY);
  window.JSBridge._handleCallback(callbackId, {
    data: data,
    signature: signature
  });
}

我曾经踩过的坑:有一个版本,我把文件读写接口暴露给了 JS。结果某个合作方的 H5 页面被注入了恶意脚本,直接通过 JSBridge 读取了用户的相册。从那以后,我所有的 JSBridge 接口都加了域名白名单和参数校验。这个教训,花了我两个通宵去修复。

另外,还有几个安全建议:

  • 最小权限原则:只暴露必要的接口,不要图方便把所有原生能力都开放
  • 参数类型校验:JS 传入的参数,原生侧一定要做类型和范围校验
  • 回调超时处理:设置回调超时时间,防止回调永远不触发导致内存泄漏
  • 日志审计:记录所有 JSBridge 调用日志,方便排查问题

好了,这一讲的内容就到这里。JSBridge 看起来简单,但要做好、做稳、做安全,需要花不少心思。希望今天的内容对你有帮助。

核心要点回顾:

  • JSBridge 是 JS 和原生之间的消息路由系统
  • 消息队列 + 回调映射表是异步调用的核心
  • 同步调用慎用,只适合轻量级、必须立即返回的场景
  • 安全是第一位的:白名单、参数校验、最小权限

公众号:蓝海资料掘金营,微信deep3321