原生 App 与 H5 通信原理与实战:从零设计一套 JSBridge
本文是笔者在混合开发项目中的实战总结:文中的协议设计与三端代码骨架,均来自一套已在生产环境稳定运行的桥接方案(项目信息已隐去)。
写作上兼顾入门:不讲黑话,从"为什么需要通信"讲到"一套完整的桥接协议怎么落地",并给出 iOS / Android / H5 三端可直接套用的核心代码。无论你是刚接触混合开发,还是想对照检视自己项目的桥接实现,都能有所收获。
目录
- 为什么 App 里要嵌 H5,为什么要通信
- 通信的底层原理:WebView 打开了哪扇门
- 设计一套通用通信协议
- H5 端:封装一个 Promise 风格的 Bridge
- iOS 端:注册、分发、回调三步走
- Android 端:另一种注入方式
- 一次完整调用的全景时序图
- 工程化避坑清单
- 总结:给新人的上手路线
1. 为什么 App 里要嵌 H5,为什么要通信
App 里嵌入 H5 页面是移动开发的主流方案,原因很简单:
- 发版自由:H5 改完即发布,不用等应用商店审核,适合活动页、帮助页、运营位;
- 跨端复用:一份 H5 代码,安卓、iOS、鸿蒙、公众号、浏览器都能跑;
- 成本低:复杂表单、富文本排版,H5 开发效率远高于原生。
但 H5 也有明显短板:它拿不到原生能力。H5 不知道用户是谁(登录态在原生里)、不能直接调用原生的拨号/相册/推送能力、关不掉自己所在的页面。于是就需要一条"H5 ⇄ 原生"的通信管道,业界统称 JSBridge。
一句话定义:JSBridge 是 WebView 环境里 JS 与原生代码互相调用的一套协议 + 实现。
2. 通信的底层原理:WebView 打开了哪扇门
要理解桥,先理解 WebView 给 JS 打开的两扇门——一门进,一门出。
2.1 第一扇门:H5 调用原生(JS → Native)
iOS(WKWebView) 提供了 WKScriptMessageHandler 机制:
- 原生先"报名"一个通道名,比如
AppBridge; - H5 里就可以通过
window.webkit.messageHandlers.AppBridge.postMessage(数据)发消息; - 原生在回调方法
didReceiveScriptMessage:里收到消息,做任何原生能做的事。
// H5 侧:一句话把数据送到原生
window.webkit.messageHandlers.AppBridge.postMessage({
method: "getUserInfo",
params: {},
})
// iOS 侧:收到 H5 的消息
- (void)userContentController:(WKUserContentController *)userContentController
didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"AppBridge"]) {
NSDictionary *body = message.body;
// 根据 body 里的 method 分发处理
}
}
2.2 第二扇门:原生调用 H5(Native → JS)
原生可以在任意时刻执行一段 JS 代码,这是第二扇门:
// iOS:原生执行任意 JS
[webView evaluateJavaScript:@"window.xxx('参数')" completionHandler:^(id result, NSError *error) {
// JS 执行完成
}];
// Android:同理
webView.evaluateJavascript("window.xxx('参数')") { result -> }
所以"原生调用 H5"的本质是:原生通过 evaluateJavaScript,去调用 H5 预先挂在 window 上的全局函数。
2.3 一句话总结原理
JS → Native 靠
postMessage(进 WebView 的门),Native → JS 靠evaluateJavaScript(出 WebView 的门)。桥的所有花样,都是在这两扇门之上约定报文格式。
3. 设计一套通用通信协议
两扇门只是"能通信",要做到好用,还需要约定一套报文协议。这里介绍工程上最经典的 callId + 回调配对 方案,它的好处是:H5 侧可以用 Promise 写业务,像调用本地函数一样调用原生。
3.1 请求报文(H5 → Native)
{
"callId": "1693651234567a1b2c3",
"method": "getUserInfo",
"params": { "needToken": true }
}
| 字段 | 含义 |
|---|---|
callId |
本次调用的唯一编号,H5 生成。作用类似快递单号:回执靠它对上号 |
method |
要调用的原生方法名 |
params |
参数对象,没有就传 {} |
iOS 上因为 postMessage 的 body 是对象,而统一协议更希望传 JSON 字符串(方便 Android 复用),所以多包一层:
window.webkit.messageHandlers.AppBridge.postMessage({
request: '{"callId":"...","method":"getUserInfo","params":{}}'
})
3.2 响应报文(Native → H5)
原生处理完,把结果包成 JSON 字符串,调用 H5 的全局函数 nativeCallback:
// 成功
{ "callId": "1693651234567a1b2c3", "status": "success", "data": { "userId": "10086", "name": "小明" } }
// 失败
{ "callId": "1693651234567a1b2c3", "status": "error", "errorMessage": "未登录" }
H5 收到后按 callId 找到当初的 Promise,success 就 resolve(data),否则 reject(errorMessage)。
3.3 为什么需要 callId
假设 H5 连续发起 3 个调用:取用户信息、取定位、拍照。原生的处理耗时各不相同,回包顺序是乱的。没有 callId,回包根本无法对应到请求——callId 就是给异步世界配对的"取件码"。
4. H5 端:封装一个 Promise 风格的 Bridge
协议有了,把它封装成 callNative(method, params) 工具函数。核心思路:
- 发请求前,生成 callId,把
(resolve, reject)存进全局字典pendingCallbacks[callId]; - 在
window上挂一个全局函数nativeCallback(response),作为所有原生回包的唯一入口; nativeCallback收到回包,按 callId 从字典取回调,执行后删除。
// bridge.js
/**
* 统一调用原生方法
* @param {string} method 方法名
* @param {object} params 参数
* @returns {Promise<any>}
*/
export function callNative(method, params) {
// 原生处理完会调用这个全局函数,把结果送回来。
// 每次调用都会重新赋值,但分发逻辑完全相同,靠 callId 路由,并发安全
window.nativeCallback = function (response) {
const res = JSON.parse(response)
const callback = window.pendingCallbacks[res.callId]
if (callback) callback(response)
}
return new Promise((resolve, reject) => {
const callId = Date.now() + Math.random().toString(36).slice(2) // 取件码
const request = { callId, method, params: params || {} }
const jsonRequest = JSON.stringify(request)
// 全局字典:按 callId 存回调
window.pendingCallbacks = window.pendingCallbacks || {}
window.pendingCallbacks[callId] = (response) => {
const res = JSON.parse(response)
if (res.status === 'success') {
resolve(res.data)
} else {
reject(res.errorMessage)
}
delete window.pendingCallbacks[callId] // 用完即焚
}
// 平台分派:三端共用一套协议,只换投递方式
if (window.androidBridge) {
// Android:原生注入的全局对象
window.androidBridge.handleRequest(jsonRequest)
} else if (window.harmonyBridge) {
// 鸿蒙 Next:同理
window.harmonyBridge.handleRequest(jsonRequest)
} else if (
window.webkit &&
window.webkit.messageHandlers &&
window.webkit.messageHandlers.AppBridge
) {
// iOS:WKWebView messageHandler
window.webkit.messageHandlers.AppBridge.postMessage({
request: jsonRequest,
})
}
// 注意:三种桥都不存在(纯浏览器)时,这里什么都没发出去,
// Promise 会一直挂起——业务侧请配合 8.2 的超时兜底使用
})
}
业务侧用法,读起来就像调本地函数:
const user = await callNative('getUserInfo')
console.log(user.name)
try {
await callNative('takePhoto', { quality: 0.8 })
} catch (e) {
console.error('拍照失败:', e)
}
环境检测(判断自己跑在哪个容器里,做降级):
export function detectEnv() {
if (window.androidBridge) return 'android'
if (window.harmonyBridge) return 'harmony'
if (window.webkit?.messageHandlers?.AppBridge) return 'ios'
return 'browser' // 纯浏览器,桥能力降级
}
export function closePage() {
if (detectEnv() === 'browser') {
location.href = '/' // 浏览器兜底
} else {
callNative('closeWeb') // App 内交给原生关闭
}
}
检测"桥对象是否存在"比解析 UserAgent 可靠:UA 字符串各厂商千奇百怪且可伪造,桥对象是原生注入的,存在即真实。
5. iOS 端:注册、分发、回调三步走
5.1 第一步:创建 WebView 并注册通道
// WebViewBridge.h
@interface WebViewBridge : NSObject <WKScriptMessageHandler>
/// 方法字典:H5 能调用的所有原生方法(method 名 → 处理 block)
@property (nonatomic, strong) NSMutableDictionary *methodDict;
@property (nonatomic, weak) WKWebView *webView;
- (void)setupWithWebView:(WKWebView *)webView;
@end
// WebViewBridge.m
static NSString * const kBridgeHandlerName = @"AppBridge"; // 业务通道名
static NSString * const kBridgeJSErrorName = @"jsError"; // JS 错误监控通道名
- (void)setupWithWebView:(WKWebView *)webView {
self.webView = webView;
// 注册名为 "AppBridge" 的消息通道
// 注意:不能直接传 self,必须用弱引用转发者包装,否则会循环引用(见 8.1)
WeakScriptMessageDelegate *weakDelegate = [WeakScriptMessageDelegate new];
weakDelegate.scriptDelegate = self;
[webView.configuration.userContentController addScriptMessageHandler:weakDelegate name:kBridgeHandlerName];
}
- (void)dealloc {
// 注销通道(配合弱引用转发者,此时 dealloc 才能被正常调用)
[self.webView.configuration.userContentController removeScriptMessageHandlerForName:kBridgeHandlerName];
[self.webView.configuration.userContentController removeScriptMessageHandlerForName:kBridgeJSErrorName];
}
⚠️ 内存泄漏陷阱:
userContentController会强引用注册的 handler 对象。如果直接把控制器 self 注册进去,就会形成WebView → userContentController → self → WebView的循环引用,页面永远释放不掉。解法见 8.1。
5.2 第二步:接收消息并分发
// WebViewBridge.m(续):消息进来后,一条主线走到底
- (void)userContentController:(WKUserContentController *)userContentController
didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:kBridgeJSErrorName]) {
// jsError 通道:JS 资源加载失败监控 → 展示异常兜底页
[self showLoadExceptionViewIfNeeded:message.body];
} else if ([message.name isEqualToString:kBridgeHandlerName]) {
// 业务通道:AppBridge
NSString *jsonString = message.body[@"request"]; // H5 约定:包一层 request
NSDictionary *jsonDict = [NSJSONSerialization JSONObjectWithData:[jsonString dataUsingEncoding:NSUTF8StringEncoding]
options:kNilOptions error:nil];
NSString *callId = jsonDict[@"callId"];
NSString *methodName = jsonDict[@"method"];
NSDictionary *params = jsonDict[@"params"];
BOOL handled = NO;
// 一级分发:方法字典命中 —— 95% 的常规业务走这里
if ([self.methodDict.allKeys containsObject:methodName]) {
handled = YES;
void(^block)(NSDictionary *body, NSDictionary *params, NSString *callId) = self.methodDict[methodName];
if (block) { block(jsonDict, params, callId); }
}
// 二级分发:delegate 兜底 —— 留给跨模块/动态方法的扩展点(delegate 为桥对象上的弱引用属性,声明略)
else if ([self.delegate respondsToSelector:@selector(bridge:didReceiveMethod:callbackId:body:params:)]) {
handled = [self.delegate bridge:self
didReceiveMethod:methodName
callbackId:callId
body:message.body
params:params];
}
// 三级兜底:未注册 → 必须回错误,绝不让 H5 干等
if (!handled) {
[self callbackToH5WithData:@{} callId:callId error:@"api_not_exist"];
}
}
}
5.3 第三步:注册原生方法 + 回包给 H5
// WebViewBridge.m(续)
/// 注册表:业务要写的全部代码,就是一张"菜单表"
/// 页面初始化时挂到桥实例上:self.methodDict = [self configMethodDict];
- (NSDictionary *)configMethodDict {
__weak typeof(self) weakSelf = self;
NSMutableDictionary *methodDict = [NSMutableDictionary new];
// block 签名统一为:(messageBody, params, callbackId)
// 业务实现完全不关心报文解析,只做三件事:取参数、干业务、回包
methodDict[@"getUserInfo"] = ^(NSDictionary *body, NSDictionary *params, NSString *callId) {
[weakSelf sendUserInfoWithCallId:callId];
};
methodDict[@"closeWeb"] = ^(NSDictionary *body, NSDictionary *params, NSString *callId) {
[weakSelf.navigationController popViewControllerAnimated:YES];
};
methodDict[@"toast"] = ^(NSDictionary *body, NSDictionary *params, NSString *callId) {
[ToastUtil showToast:params[@"message"]];
};
return methodDict;
}
- (void)sendUserInfoWithCallId:(NSString *)callId {
NSDictionary *userInfo = [UserCache loadUserInfo];
[self callbackToH5WithData:userInfo ?: @{} callId:callId error:nil];
}
// 回包:H5 的 nativeCallback 就是在这里被"调起来"的
- (void)callbackToH5WithData:(id)data callId:(NSString *)callId error:(NSString *)error {
NSMutableDictionary *dict = [@{ @"callId": callId } mutableCopy];
if (error.length) {
dict[@"status"] = @"error";
dict[@"errorMessage"] = error; // 注意:一定是 error 本身!
} else {
dict[@"status"] = @"success";
dict[@"data"] = data;
}
// 先序列化成 JSON,再内嵌为 JS 对象字面量,用 JSON.stringify 转回字符串传给 H5:
// 数据里无论含单引号还是双引号都不会截断(原理见 8.8)
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil];
NSString *json = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
NSString *js = [NSString stringWithFormat:@"nativeCallback(JSON.stringify(%@))", json];
[self.webView evaluateJavaScript:js completionHandler:nil];
}
至此 iOS 侧的桥就完整了:注册通道 → 收消息 → 查字典 → 业务处理 → evaluateJavaScript 回包,五步闭环。
5.4 值得加上的两个增值能力
JS 错误监控:在页面加载前注入一段脚本,捕获 H5 的 JS 加载失败并上报原生,弹出"页面异常"兜底页:
NSString *script =
@"document.addEventListener('error', function(event) {"
@" if (event.target.tagName === 'SCRIPT') {"
@" window.webkit.messageHandlers.jsError.postMessage('资源加载失败: ' + event.target.src)"
@" }"
@"}, true)";
WKUserScript *userScript = [[WKUserScript alloc] initWithSource:script
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:NO];
WKUserContentController *controller = webView.configuration.userContentController;
[controller addUserScript:userScript];
// 同样用弱引用转发者注册,防止循环引用(见 8.1)
WeakScriptMessageDelegate *weakDelegate = [WeakScriptMessageDelegate new];
weakDelegate.scriptDelegate = self;
[controller addScriptMessageHandler:weakDelegate name:@"jsError"];
H5 页面秒开:利用 WKWebView 自定义 URLScheme 拦截 handler:// 请求,把 JS/CSS/图片等静态资源缓存到本地,二次进入直接读本地文件,显著提升加载速度。原理一句话:WebView 每个子资源请求都会经过原生,原生先查本地缓存,没有再转发网络。
5.5 封装思想与简单使用:新增能力三步走
上面的"方法字典 + 三级分发"就是这套桥接的全部框架代码,拆开看四个设计点:
- 表驱动,新增能力零侵入:method 字符串 → block 的映射表。分发层(框架代码)永远不用改,新增能力只动"菜单表"。这是注册表模式的核心价值。
- 统一 block 签名
(body, params, callId):报文解析、字典查找、异常回包全部收敛在分发层;业务 block 里没有JSONSerialization、没有message.body,只有参数和业务。 - 三级分发,职责分明:一级字典管常规;二级 delegate 是"活口"——特殊场景(如方法需要由别的模块处理)不改框架也能扩展;三级兜底保证 H5 永远有回包,呼应第 8 节的超时坑:宁可回
api_not_exist,也不能让 Promise 悬着。 - 弱引用进 block:注册表被 WebView 持有,block 捕获
weakSelf,避免 WebView 与页面控制器互相拉拽造成泄漏。
简单使用:新增一个"获取定位"能力,三步搞定——
// 第一步:注册(往菜单表加一行)
methodDict[@"getLocation"] = ^(NSDictionary *body, NSDictionary *params, NSString *callId) {
[weakSelf getLocationWithCallId:callId];
};
// 第二步:实现
- (void)getLocationWithCallId:(NSString *)callId {
[LocationManager locateOnce:^(double lat, double lng) {
NSDictionary *loc = @{ @"lat": @(lat), @"lng": @(lng) };
// 第三步:回包
[self callbackToH5WithData:loc callId:callId error:nil];
}];
}
H5 侧一行即可调用:const loc = await callNative('getLocation')。框架一次写好,业务永远只是加一行菜单。
6. Android 端:另一种注入方式
Android 的思路与 iOS 对称,只是"门"不一样:addJavascriptInterface 直接给 H5 注入一个全局对象,H5 调用该对象的方法,相当于调原生方法。
// BridgeActivity.kt
class AndroidBridge(private val onRequest: (String) -> Unit) {
@JavascriptInterface
fun handleRequest(jsonRequest: String) {
onRequest(jsonRequest) // 拿到 {callId, method, params} JSON 字符串
}
}
// WebView 初始化
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(AndroidBridge { json ->
// 解析 json → 分发到方法表 → 处理完回调
val callId = extractCallId(json)
// 与 iOS 一致:JSON.stringify 内嵌,规避引号转义问题(见 8.8)
webView.evaluateJavascript(
"nativeCallback(JSON.stringify({\"callId\":\"$callId\",\"status\":\"success\",\"data\":{}}))",
null
)
}, "androidBridge")
注意 Android 的对应关系:
| 能力 | iOS | Android |
|---|---|---|
| H5 → 原生 | webkit.messageHandlers.X.postMessage |
注入对象 androidBridge.handleRequest() |
| 原生 → H5 | evaluateJavaScript |
evaluateJavascript |
| 通道注册 | addScriptMessageHandler |
addJavascriptInterface |
而 H5 侧完全不用改:协议是同一套,只换投递方式(见 4 中的平台分派)。
7. 一次完整调用的全景时序图
以 getUserInfo 为例,把全文串起来:
三步口诀:H5 发单(callId + method)→ 原生按单办事(方法字典)→ 原生回单(nativeCallback + callId)→ H5 对单(Promise resolve)。
8. 工程化避坑清单
以下每一项,都是线上环境实际踩过、修复后经过长期稳定运行验证的,照单规避即可少走弯路:
8.1 内存泄漏(最高频)
iOS 的 userContentController 会强引用 handler。解法:用一个"弱引用转发者"注册,由它转发给真正的 handler:
// 弱引用转发者:它才是真正被 userContentController 持有的对象
@interface WeakScriptMessageDelegate : NSObject <WKScriptMessageHandler>
@property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate;
@end
@implementation WeakScriptMessageDelegate
- (void)userContentController:(WKUserContentController *)userContentController
didReceiveScriptMessage:(WKScriptMessage *)message {
[self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}
@end
// 注册时传 WeakScriptMessageDelegate,dealloc 时再 remove
8.2 H5 侧无超时,Promise 永久 pending
原生代码总有 bug 或异常路径不回调。给每个请求加定时器,超时即 reject 并清理字典:
const timer = setTimeout(() => {
delete window.pendingCallbacks[callId]
reject(new Error(`callNative(${method}) timeout`))
}, 10000)
// 正常回调路径里,记得先 clearTimeout(timer) 再走原分发逻辑
8.3 错误分支手滑传了字面量(真实事故)
线上曾出过一个隐蔽问题:H5 调原生失败时,收到的 errorMessage 永远是一串无效内容,排查半天才发现是原生回包代码手滑——dict[@"errorMessage"] = error 写成了 dict[@"errorMessage"] = @"errStr",把错误变量名 errStr 当成字符串字面量原样发给了 H5,而真正的错误内容(api_not_exist、登录过期提示等)一次都没传出去。这类 bug 极难从现象定位,编码时就该瞪大眼睛。
8.4 callId 生成要够随机
Date.now() 同毫秒内会撞车,拼接随机串(如 toString(36) 随机后缀)即可;若业务并发量极大,可改用自增序号 + 随机前缀。
8.5 安全:白名单 + 只回调可信来源
- 域名白名单:
decidePolicyForNavigationAction里拦截非白名单域名的跳转; - messageHandler 只处理来源页面可信的消息,校验 body 类型、参数合法性;
- 线上关闭
inspectable/setWebContentsDebuggingEnabled调试口; - Android 同理:
addJavascriptInterface相当于把原生方法"租"给页面里的任意 JS,WebView 若允许跳转任意域名,等于把后门挂在门上,务必限制可加载的页面来源。
8.6 原生回调时机与页面生命周期
- H5 页面可能已跳转/销毁,
evaluateJavaScript前先判断 WebView 是否存活; - handler 的注销要选对时机:
userContentController会强持有 handler,若 handler 又持有 WebView,注册后不注销就会互相拉拽、页面永远释放不掉(这就是 8.1 用弱引用转发者的原因);若坚持直接注册self,必须在 WebView 销毁前主动removeScriptMessageHandler——不要指望dealloc里注销,循环引用下dealloc根本不会被调用。
8.7 三端协议一致性
新增桥方法时,必须同步确认:方法名、参数结构、回包结构在 iOS/Android/H5 三端完全一致。最好用接口文档或共享的类型定义约束,并在联调时逐方法过一遍。
8.8 拼 JS 字符串的引号坑
把 JSON 塞进 nativeCallback('...') 这种写法,数据里带单引号(如用户名 O'Brien)就会截断甚至报错。稳妥做法:JSON 本身就是合法的 JS 对象字面量,内嵌后套一层 JSON.stringify 转回字符串再传给 H5(见 5.3 的实现),从根上消灭引号转义问题。
9. 总结:给新人的上手路线
先说这套方案的验证情况:文中的协议与骨架已在生产项目长期运行,覆盖登录态获取、页面关闭、登录失效跳转、JS 异常兜底等高频场景,未出现过因桥接本身导致的线上故障——它不花哨,但足够稳。
回头看,原生与 H5 通信并没有魔法,拆开就四件事:
- 建通道:iOS
addScriptMessageHandler/ AndroidaddJavascriptInterface/ H5postMessage; - 定协议:
{callId, method, params}请求 +{callId, status, data/errorMessage}响应; - 写封装:H5 用 Promise + 全局回调字典;原生用方法字典分发 +
evaluateJavaScript回包; - 防坑:弱引用防泄漏、超时兜底、白名单防越权、三端协议对齐。
如果是从零开始,建议按这个顺序动手:
- 第一步:跑通最小闭环——H5 弹
alert不如调通callNative('toast'); - 第二步:加 callId 和 Promise 封装,业务侧体验立马上一个台阶;
- 第三步:补超时、错误回包、内存管理,再谈离线缓存和 JS 错误监控这类进阶能力。
掌握这套模式后,你会发现市面上所有混合开发框架(JSBridge、JsApi、乃至小程序的双线程通信)本质都是同一套思想:约定协议 + 双向通道 + 回调配对。万变不离其宗。
本文方案源自真实生产项目并已稳定运行(项目信息隐去),示例代码为可直接复用的最小骨架;接入自己的项目时,请根据业务补充安全校验与异常处理。


浙公网安备 33010602011771号