🌙 基于 Schema 协议的扩展设计(与 JSBridge 协同)
URL Scheme(简称 Schema)是一种通过自定义 URL 格式实现原生应用与 H5 或其他应用通信的机制(例如 myapp://action?params=xxx)。原生应用可以通过拦截 WebView 中的 URL 跳转请求,解析 Schema 中的协议、路径和参数,从而执行对应的原生逻辑。 它与 JSBridge 的核心区别在于:JSBridge 是「双向调用的方法级通信」(适合复杂交互、带回调的场景),而 Schema 是「基于 URL 拦截的指令级通信」(适合简单指令、页面跳转、跨应用调用等场景)。
🌙 一、Schema 协议规范
为保证一致性,Schema 协议需遵循统一格式,与 JSBridge 形成互补。
🌙 1. 基本格式
[协议名]://[动作路径]?[参数键值对]&sign=[签名]
1
| 组成部分 | 说明 | 示例 |
|---|---|---|
| 协议名 | 自定义唯一标识(如 myapp),避免与其他应用冲突 | myapp:// |
| 动作路径 | 描述具体操作(支持多级路径,如 page/profile 表示打开个人页) | page/pay |
| 参数键值对 | 传递参数(URL 编码,支持字符串、数字等简单类型) | orderId=123&amount=99.9 |
| 签名(可选) | 用于校验参数完整性(防篡改),算法:sign = md5(参数拼接 + 密钥) | sign=abc123def456 |
🌙 2. 白名单机制(安全限制)
原生端维护 Schema 白名单,仅允许指定动作路径被执行,格式如下:
// Android:res/values/schema_whitelist.xml
<string-array name="schema_whitelist">
<item>page/profile</item>
<item>page/pay</item>
<item>action/close</item>
</string-array>
// iOS:SchemaWhitelist.plist(数组类型)
["page/profile", "page/pay", "action/close"]
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
🌙 3. 错误处理(与 JSBridge 统一错误码)
| 场景 | 处理方式 | 对应错误码 |
|---|---|---|
| Schema 协议名错误 | 忽略(非本应用协议) | - |
| 动作路径不在白名单 | 原生日志记录,不执行操作 | 403 |
| 参数缺失/格式错误 | 原生通过 JSBridge 发送错误事件给 H5 | 400 |
| 签名校验失败(防篡改) | 原生通过 JSBridge 发送错误事件给 H5 | 401 |
🌙 二、三方实现示例(Schema 拦截与处理)
🌙 1. H5 端(Next.js):触发 Schema 调用
封装工具函数,简化 Schema 生成与调用,并处理回调(结合 JSBridge 事件)。
// utils/schema.ts
import { jsBridge } from './jsBridge';
class SchemaHandler {
private protocol = 'myapp'; // 协议名
private lastCallTime = 0;
private minInterval = 500; // 防止 500ms 内重复调用(避免 WebView 异常)
// 生成 Schema URL
private generateSchema(actionPath: string, params: Record<string, any> = {}): string {
// 1. 拼接参数(URL 编码)
const paramStr = new URLSearchParams(
Object.entries(params).map(([k, v]) => [k, String(v)])
).toString();
// 2. 生成签名(示例:实际项目需与原生约定密钥)
const sign = this.generateSign(params);
// 3. 拼接完整 Schema
return `${this.protocol}://${actionPath}?${paramStr}&sign=${sign}`;
}
// 生成签名(简单示例:实际需更复杂算法)
private generateSign(params: Record<string, any>): string {
const sortedParams = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('');
const secret = 'myapp_schema_secret_2025'; // 与原生约定的密钥
return btoa(`${sortedParams}_${secret}`).slice(0, 16); // 简化处理
}
// 触发 Schema 调用(通过 window.location.href)
call(actionPath: string, params: Record<string, any> = {}): Promise<any> {
return new Promise((resolve, reject) => {
// 防重复调用
const now = Date.now();
if (now - this.lastCallTime < this.minInterval) {
reject(new Error('调用过于频繁,请稍后再试'));
return;
}
this.lastCallTime = now;
// 生成唯一标识(用于关联回调)
const callbackId = `schema_${Date.now()}`;
const schemaParams = { ...params, callbackId }; // 携带回调 ID
// 监听原生通过 JSBridge 返回的结果
const listener = (result: any) => {
if (result.callbackId === callbackId) {
if (result.code === 200) resolve(result.data);
else reject(new Error(result.error));
jsBridge.off(`schema:${actionPath}`, listener); // 移除监听
}
};
jsBridge.on(`schema:${actionPath}`, listener);
// 触发 Schema 跳转
const schemaUrl = this.generateSchema(actionPath, schemaParams);
window.location.href = schemaUrl;
// 超时处理(5秒未响应则失败)
setTimeout(() => {
reject(new Error(`调用 ${actionPath} 超时`));
jsBridge.off(`schema:${actionPath}`, listener);
}, 5000);
});
}
}
export const schemaHandler = new SchemaHandler();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
🌙 2. Android 端:拦截并处理 Schema
通过 WebViewClient 拦截 URL 跳转,解析 Schema 并执行对应逻辑,结果通过 JSBridge 返回。
// SchemaInterceptorWebViewClient.kt
class SchemaInterceptorWebViewClient(
private val webView: WebView,
private val jsBridge: JsBridgeInterface
) : WebViewClient() {
// 拦截 WebView 中的 URL 跳转
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return false
return handleSchemaUrl(url) // 处理 Schema,返回 true 表示已拦截
}
// 解析并处理 Schema
private fun handleSchemaUrl(url: String): Boolean {
// 1. 校验协议名
if (!url.startsWith("myapp://")) return false
try {
val uri = Uri.parse(url)
val actionPath = uri.host + if (uri.path.isNullOrEmpty()) "" else uri.path // 例如 "page/pay"
val params = uri.queryParameterNames.associateWith { uri.getQueryParameter(it) ?: "" }
// 2. 校验白名单
val whitelist = webView.context.resources.getStringArray(R.array.schema_whitelist)
if (!whitelist.contains(actionPath)) {
sendSchemaResult(params, 403, "无权限执行:$actionPath")
return true
}
// 3. 校验签名(与 H5 约定算法)
if (!verifySign(params)) {
sendSchemaResult(params, 401, "签名校验失败")
return true
}
// 4. 执行对应动作
when (actionPath) {
"page/profile" -> {
val userId = params["userId"] ?: return sendSchemaResult(params, 400, "缺少 userId")
openProfilePage(userId) // 打开原生个人页
sendSchemaResult(params, 200, "个人页已打开")
}
"page/pay" -> {
val orderId = params["orderId"] ?: return sendSchemaResult(params, 400, "缺少 orderId")
val amount = params["amount"] ?: return sendSchemaResult(params, 400, "缺少 amount")
startPayment(orderId, amount) // 发起支付
// 支付结果将在回调中通过 JSBridge 发送(见下方)
}
"action/close" -> {
(webView.context as Activity).finish() // 关闭当前页面
sendSchemaResult(params, 200, "页面已关闭")
}
}
return true
} catch (e: Exception) {
// 解析失败
sendSchemaResult(emptyMap(), 500, "处理失败:${e.message}")
return true
}
}
// 校验签名(与 H5 逻辑一致)
private fun verifySign(params: Map<String, String>): Boolean {
val sign = params["sign"] ?: return false
val paramsWithoutSign = params.filterKeys { it != "sign" }
// 排序参数并生成签名
val sortedParams = paramsWithoutSign.keys.sorted().joinToString("") { "$it=${paramsWithoutSign[it]}" }
val secret = "myapp_schema_secret_2025"
val expectedSign = Base64.encodeToString("${sortedParams}_$secret".toByteArray(), Base64.NO_WRAP).substring(0, 16)
return sign == expectedSign
}
// 通过 JSBridge 向 H5 发送 Schema 处理结果
private fun sendSchemaResult(params: Map<String, String>, code: Int, error: String? = null, data: JSONObject? = null) {
val callbackId = params["callbackId"] ?: return // 必须携带 callbackId 才能回调
val actionPath = params["actionPath"] ?: return
val result = JSONObject().apply {
put("callbackId", callbackId)
put("code", code)
put("error", error ?: "")
put("data", data ?: JSONObject())
}
// 通过 JSBridge 发送事件(H5 监听 "schema:actionPath")
jsBridge.sendEventToJs("schema:$actionPath", result)
}
// 支付示例:支付完成后回调 H5
private fun startPayment(orderId: String, amount: String) {
// 模拟支付异步回调
GlobalScope.launch(Dispatchers.Main) {
delay(3000) // 模拟支付过程
val payResult = JSONObject().apply {
put("orderId", orderId)
put("success", true)
put("tradeNo", "PAY${System.currentTimeMillis()}")
}
// 从参数中获取 callbackId(实际需存储在支付回调中)
val callbackId = "schema_${System.currentTimeMillis()}" // 实际应从发起支付的 params 中获取
sendSchemaResult(mapOf("callbackId" to callbackId, "actionPath" to "page/pay"), 200, data = payResult)
}
}
// 打开原生个人页
private fun openProfilePage(userId: String) {
val intent = Intent(webView.context, ProfileActivity::class.java)
intent.putExtra("userId", userId)
webView.context.startActivity(intent)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
WebView 配置(在之前的 BridgeActivity 中补充):
// 替换默认 WebViewClient 为 Schema 拦截器
webView.webViewClient = SchemaInterceptorWebViewClient(webView, jsBridge)
1
2
2
🌙 3. iOS 端:拦截并处理 Schema
通过 WKNavigationDelegate 拦截 URL 跳转,解析 Schema 并处理,结果通过 JSBridge 返回。
// SchemaInterceptorNavigationDelegate.swift
import WebKit
class SchemaInterceptorNavigationDelegate: NSObject, WKNavigationDelegate {
private weak var webView: WKWebView?
private weak var jsBridge: JsBridgeHandler?
private let schemaProtocol = "myapp"
private let secret = "myapp_schema_secret_2025" // 与 H5 约定的密钥
init(webView: WKWebView, jsBridge: JsBridgeHandler) {
self.webView = webView
self.jsBridge = jsBridge
super.init()
}
// 拦截 WebView 中的 URL 跳转
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url, url.scheme == schemaProtocol else {
decisionHandler(.allow) // 非 Schema 协议,允许跳转
return
}
// 处理 Schema,拦截跳转
handleSchemaUrl(url)
decisionHandler(.cancel)
}
// 解析并处理 Schema
private func handleSchemaUrl(_ url: URL) {
guard let actionPath = url.host else { return }
let params = url.queryItems?.reduce(into: [String: String]()) { $0[$1.name] = $1.value ?? "" } ?? [:]
// 1. 校验白名单
guard isInWhitelist(actionPath) else {
sendSchemaResult(params: params, code: 403, error: "无权限执行:\(actionPath)")
return
}
// 2. 校验签名
guard verifySign(params: params) else {
sendSchemaResult(params: params, code: 401, error: "签名校验失败")
return
}
// 3. 执行对应动作
switch actionPath {
case "page/profile":
guard let userId = params["userId"] else {
sendSchemaResult(params: params, code: 400, error: "缺少 userId")
return
}
openProfilePage(userId: userId)
sendSchemaResult(params: params, code: 200, data: ["message": "个人页已打开"])
case "page/pay":
guard let orderId = params["orderId"], let amount = params["amount"] else {
sendSchemaResult(params: params, code: 400, error: "缺少 orderId 或 amount")
return
}
startPayment(orderId: orderId, amount: amount)
case "action/close":
(webView?.viewController as? BridgeViewController)?.dismiss(animated: true)
sendSchemaResult(params: params, code: 200, data: ["message": "页面已关闭"])
default:
sendSchemaResult(params: params, code: 404, error: "动作路径不存在:\(actionPath)")
}
}
// 校验是否在白名单
private func isInWhitelist(_ actionPath: String) -> Bool {
guard let path = Bundle.main.path(forResource: "SchemaWhitelist", ofType: "plist"),
let whitelist = NSArray(contentsOfFile: path) as? [String] else {
return false
}
return whitelist.contains(actionPath)
}
// 校验签名(与 H5 逻辑一致)
private func verifySign(params: [String: String]) -> Bool {
guard let sign = params["sign"] else { return false }
var paramsWithoutSign = params
paramsWithoutSign.removeValue(forKey: "sign")
// 排序参数并生成签名
let sortedKeys = paramsWithoutSign.keys.sorted()
let sortedParams = sortedKeys.map { "\($0)=\(paramsWithoutSign[$0] ?? "")" }.joined()
let expectedSign = "\(sortedParams)_\(secret)".data(using: .utf8)?.base64EncodedString()?.prefix(16)
return sign == expectedSign
}
// 通过 JSBridge 发送处理结果
private func sendSchemaResult(params: [String: String], code: Int, error: String = "", data: [String: Any] = [:]) {
guard let callbackId = params["callbackId"], let actionPath = params["actionPath"] else { return }
let result: [String: Any] = [
"callbackId": callbackId,
"code": code,
"error": error,
"data": data
]
jsBridge?.sendEventToJs(eventName: "schema:\(actionPath)", params: result)
}
// 打开原生个人页
private func openProfilePage(userId: String) {
let profileVC = ProfileViewController()
profileVC.userId = userId
webView?.viewController?.navigationController?.pushViewController(profileVC, animated: true)
}
// 支付示例:支付完成后回调 H5
private func startPayment(orderId: String, amount: String) {
// 模拟支付异步过程
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self = self else { return }
let payResult: [String: Any] = [
"orderId": orderId,
"success": true,
"tradeNo": "PAY\(Date().timeIntervalSince1970)"
]
// 从参数中获取 callbackId(实际需存储)
let callbackId = params["callbackId"] ?? "" // 实际应从发起支付的 params 中获取
self.sendSchemaResult(
params: ["callbackId": callbackId, "actionPath": "page/pay"],
code: 200,
data: payResult
)
}
}
}
// 扩展 WKWebView 获取所在的 ViewController
extension WKWebView {
var viewController: UIViewController? {
var responder: UIResponder? = self
while responder != nil {
if let vc = responder as? UIViewController {
return vc
}
responder = responder?.next
}
return nil
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
WKWebView 配置(在之前的 BridgeViewController 中补充):
// 替换导航代理为 Schema 拦截器
webView.navigationDelegate = SchemaInterceptorNavigationDelegate(webView: webView, jsBridge: jsBridge)
1
2
2
🌙 三、Schema 与 JSBridge 协同场景示例
🌙 1. H5 调用 Schema 打开原生支付页,支付结果通过 JSBridge 返回
// H5 代码
const handlePay = async () => {
try {
// 1. H5 通过 Schema 调用原生支付
const result = await schemaHandler.call('page/pay', {
orderId: 'ORDER123456',
amount: '99.9',
actionPath: 'page/pay' // 用于 H5 监听事件名
});
// 3. 接收支付结果(原生通过 JSBridge 事件返回)
if (result.success) {
alert(`支付成功,交易号:${result.tradeNo}`);
// 刷新订单状态(通过 JSBridge 调用原生接口)
await jsBridge.callNative('refreshOrder', { orderId: 'ORDER123456' });
}
} catch (e) {
console.error('支付失败', e);
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
🌙 2. 外部唤醒 App 并通过 Schema 传递参数,原生处理后通知 H5
// iOS:AppDelegate 处理外部唤醒
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
if url.scheme == "myapp" {
let actionPath = url.host ?? ""
let params = url.queryItems?.reduce(into: [String: String]()) { $0[$1.name] = $1.value ?? "" } ?? [:]
// 跳转到指定页面
if actionPath == "page/activity" {
let activityVC = ActivityViewController()
activityVC.activityId = params["id"] ?? ""
UIApplication.shared.keyWindow?.rootViewController?.present(activityVC, animated: true)
// 通过 JSBridge 通知 H5(如果 H5 已加载)
if let webView = activityVC.webView, let jsBridge = activityVC.jsBridge {
jsBridge.sendEventToJs(eventName: "externalWakeup", params: ["page": "activity", "id": params["id"]])
}
}
return true
}
return false
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
🌙 四、最佳实践总结
分工明确:
- Schema:负责页面跳转、跨应用调用、外部唤醒、无复杂回调的简单操作。
- JSBridge:负责复杂参数传递、带回调的方法调用、双向事件监听。
安全加固:
- 所有 Schema 动作必须加入白名单,禁止未授权调用。
- 敏感操作(如支付、登录)必须校验签名,防止参数被篡改。
体验优化:
- H5 调用 Schema 时添加防抖(避免短时间内重复触发)。
- 原生处理 Schema 后,通过 JSBridge 事件返回结果(解决 Schema 无回调的问题)。