小程序蓝牙API能力探索 4——安卓端如何支持小程序能力?

本文主要以安卓为例,串联安卓与小程序 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() 扫描附近设备。

  1. JS 层调用 :开发者在小程序的 JavaScript 代码中调用 wx.startBluetoothDevicesDiscovery({ ... })。同时,注册 wx.onBluetoothDeviceFound() 回调函数来接收扫描到的设备信息。

  2. JSBridge 传输 :小程序运行时捕获到这个 API 调用,通过 JSBridge 将其转换为一个包含接口名称(startBluetoothDevicesDiscovery)、参数和回调信息的消息,发送给原生宿主 App。

  3. 原生层处理:

    1. 宿主 App 的原生模块接收并解析该消息。
    2. 它首先会检查是否已获得必要的安卓权限。对于蓝牙扫描,在 Android 12 之前需要定位权限 (ACCESS_FINE_LOCATION),在 Android 12 及之后则需要 BLUETOOTH_SCAN 权限。
    3. 如果权限不足,宿主 App 会向用户弹出系统权限请求对话框。
    4. 获得权限后,原生模块会获取 BluetoothAdapter 实例,并从中得到一个 BluetoothLeScanner
    5. 最后,调用 bluetoothLeScanner.startScan(scanCallback) 方法,启动原生扫描。
  4. 原生回调与数据回传:

    1. 当安卓系统扫描到附近的 BLE 设备时,会触发 scanCallback 中的 onScanResult(result) 方法。
    2. 宿主 App 的原生模块在 onScanResult 中获取设备信息(如设备名称、MAC 地址 deviceId、广播数据 advertisData 等)。
    3. 原生模块将这些数据打包,通过 JSBridge 回传给 JavaScript 层,并指定触发 onBluetoothDeviceFound 事件。
  5. 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)及以下 : 需要 BLUETOOTHBLUETOOTH_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 的心脏。它完成了三件重要的事情:

  1. 设置 WebView : 启用 JavaScript 并将我们创建的 WebAppInterface 实例注入到 WebView 中,命名为 AndroidBridge
  2. 实现蓝牙扫描: 包含启动和停止 BLE 扫描的完整原生逻辑。
  3. 处理运行时权限: 在执行扫描前,检查并向用户请求必要的蓝牙和定位权限。

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 中包含了 startBleScanstopBleScan 两个核心方法,它们由 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
    }
}
相关推荐
兮山与1 小时前
前端1.0
前端
王者鳜錸3 小时前
VUE+SPRINGBOOT从0-1打造前后端-前后台系统-邮箱重置密码
前端·vue.js·spring boot
独泪了无痕5 小时前
深入浅析Vue3中的生命周期钩子函数
前端·vue.js
小白白一枚1115 小时前
vue和react的框架原理
前端·vue.js·react.js
字节逆旅5 小时前
从一次爬坑看前端的出路
前端·后端·程序员
若梦plus6 小时前
微前端之样式隔离、JS隔离、公共依赖、路由状态更新、通信方式对比
前端
若梦plus6 小时前
Babel中微内核&插件化思想的应用
前端·babel
若梦plus6 小时前
微前端中微内核&插件化思想的应用
前端
若梦plus6 小时前
服务化架构中微内核&插件化思想的应用
前端
若梦plus6 小时前
Electron中微内核&插件化思想的应用
前端·electron