本文主要以安卓为例,串联安卓与小程序 jsapi 之间的关系。
概述
小程序能够调用原生的蓝牙功能,其核心依赖于一套精心设计的 JavaScript Bridge (JSBridge) 机制。由于小程序本身的逻辑层(JavaScript)运行在一个独立的沙箱环境(如 V8 或 JSCore 引擎)中,而 UI 渲染层则由 WebView 处理,这两者都无法直接访问操作系统底层的原生 API。JSBridge 正是为了打破这层壁垒而生。
基本原理 :JSBridge 在小程序的 JavaScript 环境和原生安卓环境(由宿主 App,如微信提供)之间建立了一个双向通信通道。当小程序调用一个 wx.someApi()
这样的接口时,实际上是触发了 JSBridge 的通信协议。JSBridge 会将这个调用请求(包括接口名、参数和回调函数 ID)打包、序列化,并传递给原生层。原生层接收到请求后,会解析并执行对应的原生代码(例如,调用安卓的蓝牙 API),操作完成后再通过 JSBridge 将结果(成功或失败信息)以及回调 ID 回传给 JavaScript 层,从而触发相应的回调函数。
1. 关键组件剖析
整个架构由以下几个关键部分协同工作:
-
小程序运行时 (Mini Program Runtime) : 这是小程序得以运行的环境,由宿主 App 提供。它通常采用双线程模型:
- 逻辑层 (Logic Layer) : 一个独立的 JavaScript 引擎(如 V8)线程,负责执行小程序的业务逻辑、数据处理和 API 调用。
- 视图层 (View Layer) : 一个或多个 WebView 线程,负责渲染小程序的用户界面。逻辑层通过 JSBridge 将数据和渲染指令传递给视图层。
-
原生宿主 App (Native Host App) : 这是承载小程序运行的容器应用,例如微信或支付宝。它扮演着至关重要的角色:
- 提供 运行时环境:加载和管理小程序的生命周期。
- 实现 JSBridge:提供 JavaScript 与原生代码通信的具体实现。
- 封装 原生能力:将安卓系统的原生功能(如蓝牙、定位、支付)封装成标准化的接口,供 JSBridge 调用。
- 权限代理:代表小程序向用户请求和管理必要的系统权限。
-
WebView: 主要作为小程序 UI 的渲染引擎。它本身不直接参与蓝牙的数据通信,但它渲染的界面元素(如按钮)会触发逻辑层中的 JavaScript 事件,从而启动蓝牙相关的 API 调用。
-
安卓蓝牙 APIs (Android Bluetooth APIs) : 这是安卓操作系统提供的原生接口,用于实现所有蓝牙功能。宿主 App 的原生模块会直接调用这些 API,例如
BluetoothAdapter
,BluetoothLeScanner
, 和BluetoothGatt
等,来完成设备的扫描、连接和数据交换。
2. API 映射与工作流
让我们以一个典型的蓝牙设备发现流程为例,追踪一次完整的调用链路:
场景:小程序调用 wx.startBluetoothDevicesDiscovery()
扫描附近设备。
-
JS 层调用 :开发者在小程序的 JavaScript 代码中调用
wx.startBluetoothDevicesDiscovery({ ... })
。同时,注册wx.onBluetoothDeviceFound()
回调函数来接收扫描到的设备信息。 -
JSBridge 传输 :小程序运行时捕获到这个 API 调用,通过 JSBridge 将其转换为一个包含接口名称(
startBluetoothDevicesDiscovery
)、参数和回调信息的消息,发送给原生宿主 App。 -
原生层处理:
- 宿主 App 的原生模块接收并解析该消息。
- 它首先会检查是否已获得必要的安卓权限。对于蓝牙扫描,在 Android 12 之前需要定位权限 (
ACCESS_FINE_LOCATION
),在 Android 12 及之后则需要BLUETOOTH_SCAN
权限。 - 如果权限不足,宿主 App 会向用户弹出系统权限请求对话框。
- 获得权限后,原生模块会获取
BluetoothAdapter
实例,并从中得到一个BluetoothLeScanner
。 - 最后,调用
bluetoothLeScanner.startScan(scanCallback)
方法,启动原生扫描。
-
原生回调与数据回传:
- 当安卓系统扫描到附近的 BLE 设备时,会触发
scanCallback
中的onScanResult(result)
方法。 - 宿主 App 的原生模块在
onScanResult
中获取设备信息(如设备名称、MAC 地址deviceId
、广播数据advertisData
等)。 - 原生模块将这些数据打包,通过 JSBridge 回传给 JavaScript 层,并指定触发
onBluetoothDeviceFound
事件。
- 当安卓系统扫描到附近的 BLE 设备时,会触发
-
JS 层接收 :小程序的 JavaScript 逻辑层接收到来自 JSBridge 的消息,执行
wx.onBluetoothDeviceFound()
中注册的回调函数,并将设备信息作为参数传入。至此,开发者便在小程序中获取到了扫描到的蓝牙设备。
这个流程清晰地展示了小程序 API 如何被精确地映射到具体的安卓原生调用上,而 JSBridge 在其中扮演了不可或缺的翻译和调度角色。
3. 权限管理模型
小程序的权限管理是一个代理模型,宿主 App 是小程序的权限代理人。
-
权限声明 : 宿主 App(如微信)的
AndroidManifest.xml
文件中已经预先声明了所有可能用到的权限,包括BLUETOOTH_SCAN
,BLUETOOTH_CONNECT
,ACCESS_FINE_LOCATION
等。 -
权限请求 : 小程序自身无法直接调用安卓的
requestPermissions()
API。当小程序调用一个需要敏感权限的 API 时(如蓝牙扫描),会由宿主 App 的原生层进行拦截和处理。 -
代理流程:
- 小程序调用
wx.startBluetoothDevicesDiscovery()
。 - 宿主 App 的原生代码检查自身是否已被授予
BLUETOOTH_SCAN
权限。 - 如果尚未授权,宿主 App 会调用安卓系统的权限请求 API,向用户显示一个系统级的授权对话框(例如,"允许'微信'查找、连接和确定附近设备的相对位置")。
- 用户做出选择(允许或拒绝)。
- 宿主 App 接收到用户的选择结果。如果用户授权,则继续执行蓝牙扫描的原生代码;如果用户拒绝,则通过 JSBridge 向小程序返回一个权限错误(如
errCode: 10001
)。
- 小程序调用
-
权限查询与引导 : 小程序平台通常提供
wx.getSetting()
或wx.getAppAuthorizeSetting()
等 API,让开发者可以查询小程序是否已获得特定功能的授权(如scope.bluetooth
)。如果未授权,开发者可以引导用户通过wx.openSetting()
或wx.openAppAuthorizeSetting()
打开小程序的授权管理页面,手动开启权限。
Demo
理论结合实践是掌握技术的最佳途径。本章节将详细拆解一个概念验证(PoC)安卓应用,该应用模拟了小程序通过 JSBridge 调用原生 BLE 扫描功能的全过程。
1. 权限声明 (AndroidManifest.xml)
任何需要与系统硬件交互的应用,第一步都是在 AndroidManifest.xml
中声明所需的权限。这是向安卓系统"报备",告知用户我们的应用需要访问哪些敏感功能。
对于蓝牙功能,权限要求随着安卓版本的迭代而变化:
- Android 11 (API 30)及以下 : 需要
BLUETOOTH
和BLUETOOTH_ADMIN
权限,以及用于设备发现的ACCESS_FINE_LOCATION
权限。 - Android 12 (API 31)及以上 : 引入了更精细化的蓝牙权限,需要
BLUETOOTH_SCAN
用于扫描和BLUETOOTH_CONNECT
用于连接。ACCESS_FINE_LOCATION
依然可能需要,取决于扫描是否需要获取物理位置。
我们的 Demo 为了兼容不同版本,声明了所有相关权限。
xml
<!-- app/src/main/AndroidManifest.xml -->
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Needed for Bluetooth scanning on Android 12 and above -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Needed for connecting to paired Bluetooth devices on Android 12 and above -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Needed for finding devices on Android 6.0 and above. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- WebView requires internet permission -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Declare that the app uses BLE hardware -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
2. 前端"小程序"实现 (index.html)
我们的"小程序"是一个简单的本地 HTML 文件,它被加载到安卓的 WebView 中。这个页面包含两个按钮,分别用于触发开始和停止扫描的操作。按钮的 onclick
事件直接调用了 JavaScript 函数,这些函数将尝试通过名为 AndroidBridge
的全局对象与原生代码通信。
html
<!-- app/src/main/assets/index.html -->
<body>
<h1>JSBridge BLE Demo</h1>
<p class="log">Click buttons to interact with native BLE API.</p>
<button onclick="startBleScan()">Start BLE Scan</button>
<button id="stopBtn" onclick="stopBleScan()">Stop BLE Scan</button>
<script type="text/javascript">
function startBleScan() {
// Check if the AndroidBridge interface is available
if (typeof AndroidBridge !== 'undefined') {
console.log("Calling native startBleScan...");
// 调用原生接口
AndroidBridge.startBleScan();
} else {
alert("AndroidBridge is not available.");
}
}
function stopBleScan() {
// Check if the AndroidBridge interface is available
if (typeof AndroidBridge !== 'undefined') {
console.log("Calling native stopBleScan...");
// 调用原生接口
AndroidBridge.stopBleScan();
} else {
alert("AndroidBridge is not available.");
}
}
</script>
</body>
关键点 : AndroidBridge.startBleScan()
和 AndroidBridge.stopBleScan()
是 magic 发生的地方。AndroidBridge
这个对象并非 JavaScript 原生所有,它是由安卓原生代码"注入"到 WebView 的 JavaScript 上下文中的。
3. JSBridge 接口定义 (WebAppInterface.kt)
为了让 JavaScript 能够调用原生代码,我们需要创建一个 Java/Kotlin 类作为桥梁。这个类中的方法使用 @JavascriptInterface
注解进行标记,表明它们可以被 WebView 中的 JavaScript 调用。
WebAppInterface.kt
就是我们的桥梁实现。它接收 JavaScript 的调用请求,然后将这些请求委托给 MainActivity
来执行实际的蓝牙操作。
kt
// app/src/main/java/com/example/jsbridgeble/WebAppInterface.kt
package com.example.jsbridgeble
import android.content.Context
import android.webkit.JavascriptInterface
import android.widget.Toast
class WebAppInterface(private val context: Context, private val activity: MainActivity) {
/**
* 此方法由 JavaScript 调用,用于启动 BLE 扫描。
* 它将操作委托给 MainActivity。
*/
@JavascriptInterface
fun startBleScan() {
// 在主线程中执行,以便与UI和Activity交互
activity.runOnUiThread {
Toast.makeText(context, "原生层: 已收到开始扫描指令", Toast.LENGTH_SHORT).show()
activity.startBleScan()
}
}
/**
* 此方法由 JavaScript 调用,用于停止 BLE 扫描。
*/
@JavascriptInterface
fun stopBleScan() {
activity.runOnUiThread {
Toast.makeText(context, "原生层: 已收到停止扫描指令", Toast.LENGTH_SHORT).show()
activity.stopBleScan()
}
}
}
关键点 : @JavascriptInterface
注解是 JSBridge 机制的核心。任何被此注解标记的 public
方法都将暴露给 WebView。
4. 原生核心逻辑 (MainActivity.kt)
MainActivity.kt
是我们 Demo 的心脏。它完成了三件重要的事情:
- 设置 WebView : 启用 JavaScript 并将我们创建的
WebAppInterface
实例注入到 WebView 中,命名为AndroidBridge
。 - 实现蓝牙扫描: 包含启动和停止 BLE 扫描的完整原生逻辑。
- 处理运行时权限: 在执行扫描前,检查并向用户请求必要的蓝牙和定位权限。
4.1 WebView 设置与 JSBridge 注入
在 onCreate
方法中,我们初始化 WebView,并通过 addJavascriptInterface
方法将 WebAppInterface
的一个实例绑定到 JavaScript 的 window
对象上,名称为 AndroidBridge
。这就是前端 index.html
中能够调用 AndroidBridge.startBleScan()
的原因。
kt
// app/src/main/java/com/example/jsbridgeble/MainActivity.kt
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebView() {
// 1. 启用 JavaScript
webView.settings.javaScriptEnabled = true
// 2. 注入 JSBridge 接口,命名为 "AndroidBridge"
webView.addJavascriptInterface(WebAppInterface(this, this), "AndroidBridge")
// 3. 加载本地 HTML 文件
webView.loadUrl("file:///android_asset/index.html")
}
4.2 蓝牙扫描实现
MainActivity
中包含了 startBleScan
和 stopBleScan
两个核心方法,它们由 WebAppInterface
调用。这两个方法直接操作安卓系统的 BluetoothLeScanner
。扫描结果通过 ScanCallback
异步返回。
kt
// app/src/main/java/com/example/jsbridgeble/MainActivity.kt
// 由 WebAppInterface 调用
fun startBleScan() {
// ... 权限检查逻辑 ...
if (checkPermissions()) {
if (!isScanning) {
isScanning = true
foundDevices.clear()
updateScanResults("开始扫描...")
bleScanner.startScan(null, scanSettings, scanCallback)
}
}
}
// 由 WebAppInterface 调用
fun stopBleScan() {
if (isScanning) {
isScanning = false
updateScanResults("停止扫描...")
bleScanner.stopScan(scanCallback)
}
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val deviceName = result.device.name ?: "Unnamed"
val deviceAddress = result.device.address
val displayText = "$deviceName - $deviceAddress"
if (foundDevices.add(displayText)) { // 利用 Set 去重
updateScanResults(displayText) // 在原生UI上显示结果
}
}
override fun onScanFailed(errorCode: Int) {
updateScanResults("扫描失败,错误码: $errorCode")
isScanning = false
}
}
4.3 运行时权限处理
在调用敏感的 startBleScan
之前,必须先检查权限。checkPermissions
方法会根据安卓版本确定需要哪些权限,并向用户发起请求。这是小程序权限代理模型的原生实现。
kt
// app/src/main/java/com/example/jsbridgeble/MainActivity.kt
private fun checkPermissions(): Boolean {
val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
val missingPermissions = requiredPermissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
return if (missingPermissions.isEmpty()) {
true
} else {
// 请求缺失的权限
ActivityCompat.requestPermissions(this, missingPermissions.toTypedArray(), PERMISSION_REQUEST_CODE)
false
}
}