小程序蓝牙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
    }
}
相关推荐
圣光SG15 小时前
Java类与对象及面向对象基础核心详细笔记
java·前端·数据库
Jinuss15 小时前
源码分析之React中的useImperativeHandle
开发语言·前端·javascript
ZC跨境爬虫15 小时前
CSS核心知识点与定位实战全解析(结合Playwright爬虫案例)
前端·css·爬虫
Jinuss15 小时前
源码分析之React中的forwardRef解读
前端·javascript·react.js
mengsi5515 小时前
Antigravity IDE 在浏览器上 verify 成功但本地 IDE 没反应 “开启Tun依然无济于事” —— 解决方案
前端·ide·chrome·antigravity
Можно15 小时前
pages.json 和 manifest.json 有什么作用?uni-app 核心配置文件详解
前端·小程序·uni-app
hzhsec15 小时前
钓鱼邮件分析与排查
服务器·前端·安全·web安全·钓鱼邮件
#做一个清醒的人16 小时前
Electron 保活方案:用子进程彻底解决原生插件崩溃问题
前端·electron·node.js
四千岁16 小时前
Obsidian + jsDelivr + PicGo = 免费无限图床:一键上传,全平台粘贴即发
前端·程序员·github