跳到主要内容

安卓原生蓝牙

这里展示的是 Android 原生壳的接入方式:WebView 承载 @ukeyfe/hardware-common-connect-sdk,JSBridge 负责跨层通信,Nordic BLE 则处理扫描、连接和数据收发。上层仍然写 SDK 调用,底层 BLE 细节交给 Android 原生层,再把结果桥接回 JS。

如果你还没有了解底层插件接口,建议先阅读 传输插件(Low-level),尤其是 BLE 消息头、通知重组和 receive() 返回完整消息的要求。

BLE流程

Android BLE 对连接顺序比较敏感。配对、GATT 连接、服务发现和通知开启之间都需要留出稳定时间;跳过其中某一步,可能不会抛出明显错误,但 SDK 会表现为找不到设备或长时间无响应。

步骤触发点说明
1ensureBonded()先准备系统配对状态,最长等待 45s。
2connect()建立 GATT 会话,连接阶段按 15s 控制。
3delay(500ms)连接成功后留半秒缓冲,让链路先稳定。
4discoverServices()枚举设备暴露出来的 BLE 服务。
5delay(500ms)服务发现结束后再短暂停顿,避免马上写入。
6startNotifications()最后开启通知订阅,失败时最多重试 3 次。

先配对原因

UKey Wallet 设备的 BLE 通道依赖系统配对流程。未完成配对时,下面这些问题都可能出现:

  • startNotifications() 调用看似成功,但通知没有真正打开。
  • 原生层能看到设备,JS 侧却收到“设备未找到”。
  • 连接偶发断开,重试后又恢复,问题难以复现。

Kotlin 连接例

suspend fun connectAndEnableNotifications(macAddress: String) {
// 第 1 步:先确认设备已经完成配对
val bondedDevice = bluetoothAdapter.getRemoteDevice(macAddress)
if (bondedDevice.bondState != BluetoothDevice.BOND_BONDED) {
bondedDevice.createBond()
// 等待配对完成(期间需要用户在系统弹窗里确认)
delay(45000) // 也可以改成用 BroadcastReceiver 监听 BOND_BONDED
}

// 第 2 步:建立 GATT 会话
val bluetoothGatt = bondedDevice.connectGatt(context, false, bleGattCallback)

// 第 3 步:给连接一个短暂稳定时间
delay(500)

// 第 4 步:开始做服务发现(通常也会在 onConnectionStateChange 里触发)
bluetoothGatt.discoverServices()

// 第 5 步:服务发现后再稍等片刻
delay(500)

// 第 6 步:通过重试机制打开通知
enableNotificationsWithRetry(bluetoothGatt, retryLimit = 3)
}

suspend fun enableNotificationsWithRetry(bluetoothGatt: BluetoothGatt, retryLimit: Int) {
repeat(retryLimit) { retryIndex ->
try {
bluetoothGatt.setCharacteristicNotification(notificationCharacteristic, true)
// 通过写描述符把通知能力打开
val notifyDescriptor = notificationCharacteristic.getDescriptor(CLIENT_CONFIG_DESCRIPTOR_UUID)
notifyDescriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
bluetoothGatt.writeDescriptor(notifyDescriptor)
return // 这里表示已经成功
} catch (e: Exception) {
delay((retryIndex + 1) * 1000L) // 采用递增退避等待
}
}
throw Exception("$retryLimit 次尝试后启动通知失败")
}

超时建议

操作超时
配对 (用户确认)45 秒
GATT 连接15 秒
连接后延迟500ms
服务发现15 秒
发现后延迟500ms
通知设置15 秒
重试退避1s, 2s, 3s

BLE 依赖配置

Android 工程里需要同时接入 WebView 桥接库和 Nordic BLE Kotlin 组件。前者把 WebView 内的 SDK 请求送到原生层,后者承担扫描、连接、配对、服务发现与通知订阅。

组件依赖
WebView JSBridgecom.smallbuer:jsbridge:1.0.7
Nordic BLE Kotlinno.nordicsemi.android.kotlin.ble:scanner:1.1.0
no.nordicsemi.android.kotlin.ble:client:1.1.0

这里不要降到 1.0.9 或更早的 Nordic BLE;扫描示例里会打开 BleScannerSettings.includeStoredBondedDevices,这个能力需要 1.1.0 起步。

服务 UUID、特征值 UUID 以及 64 字节帧格式集中放在 底层传输参考。实现原生层时,请先把通知分片重组成完整回包,再通过 receive() 交回 JS。

第1步:Gradle 与 Manifest

Gradle (app/build.gradle.kts)

dependencies {
implementation("com.smallbuer:jsbridge:1.0.7")
implementation("no.nordicsemi.android.kotlin.ble:scanner:1.1.0")
implementation("no.nordicsemi.android.kotlin.ble:client:1.1.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

Android 12+ 权限 (AndroidManifest.xml)

Manifest 里要同时写入旧版蓝牙权限,以及 Android 12 后拆分出的扫描、广播、连接权限。即便应用的最低系统版本已经较新,也建议保留带 maxSdkVersion="30" 的旧权限项,以免 Android 11 及以下机型无法扫描。

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>

运行时权限检查 (Kotlin)

只把权限写进 Manifest 还不够。开始扫描或连接前,原生层需要先检查运行时授权;发现缺失时先弹出系统授权请求,等回调确认后再继续 BLE 流程。

private const val BLE_PERMISSION_REQUEST_ID = 1002

private fun ensureBleRuntimePermissions(): Boolean {
val missingPermissions = mutableListOf<String>()

if (
ActivityCompat.checkSelfPermission(
this,
android.Manifest.permission.BLUETOOTH_SCAN,
) != PackageManager.PERMISSION_GRANTED
) {
missingPermissions += android.Manifest.permission.BLUETOOTH_SCAN
}

if (
ActivityCompat.checkSelfPermission(
this,
android.Manifest.permission.BLUETOOTH_CONNECT,
) != PackageManager.PERMISSION_GRANTED
) {
missingPermissions += android.Manifest.permission.BLUETOOTH_CONNECT
}

if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
ActivityCompat.checkSelfPermission(
this,
android.Manifest.permission.ACCESS_FINE_LOCATION,
) != PackageManager.PERMISSION_GRANTED
) {
missingPermissions += android.Manifest.permission.ACCESS_FINE_LOCATION
}

if (missingPermissions.isNotEmpty()) {
ActivityCompat.requestPermissions(
this,
missingPermissions.toTypedArray(),
BLE_PERMISSION_REQUEST_ID,
)
return false
}

return true
}

调用扫描、读取已配对设备或建立 GATT 连接前,都应先通过这个检查。否则在 Android 12+ 上可能直接抛出权限异常,或表现为扫描不到设备。

第2步:构建 Web

先从参考仓库生成 Web 端产物,再把构建结果放进 Android 工程的 assets 目录:

cd packages/connect-examples/native-android-example/web
yarn && yarn build
# 构建输出位于 web/web_dist/

把完整的 web/web_dist/ 目录放进 Android 工程的 app/src/main/assets/web_dist/。资源就位后,WebView 入口应指向 app/src/main/assets/web_dist/index.html

演示用 Web 包已经通过 env: 'lowlevel' 初始化,并在 JS 侧接好了底层适配器。原生侧只要补齐枚举、连接、断开、发送和接收等桥接处理,通常不用再改 WebView 里的业务 JS。

第3步:WebView桥

WebView 开始读取页面前,先把原生侧处理程序挂上:

class MainActivity : AppCompatActivity() {
lateinit var bridgeWebView: BridgeWebView
private var activeGattConnection: ClientBleGatt? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
bridgeWebView = findViewById(R.id.webview)

configureBridgeWebView()
registerBridgeHandlers()

bridgeWebView.loadUrl("file://android_asset/web_dist/index.html")
}
}

第4步:BLE 扫描

private val primaryServiceUuidString = listOf(
"00000001",
"0000",
"1000",
"8000",
"00805f9b34fb",
).joinToString("-")
private val primaryServiceUuid: UUID = UUID.fromString(primaryServiceUuidString)
private val ukeyBleScanner by lazy { BleScanner(this) }

private fun scanNearbyDevices(onScanResult: (List<UKeyDeviceInfo>) -> Unit) {
val scanOptions = BleScannerSettings(
scanMode = BleScanMode.LOW_LATENCY,
filter = BleScanFilter(serviceUUIDs = listOf(FilteredServiceUuid(primaryServiceUuid))),
includeStoredBondedDevices = true, // 需要 Nordic BLE 至少升级到 1.1.0
)
// 其余逻辑按需补充
}

第5步:连接通知

bridgeWebView.registerHandler("connect", BridgeHandler { rawRequest, bridgeCallback ->
lifecycleScope.launch(Dispatchers.IO) {
val connectPayload = JsonParser.parseString(rawRequest).asJsonObject
val targetMacAddress = connectPayload.get("mac").asString
activeGattConnection = ClientBleGatt.getInstance(this@MainActivity, targetMacAddress)
// 读取特征并启动通知
notificationCharacteristic?.getNotifications()?.onEach { inboundPacket ->
val packetHexString = DataByteArray(inboundPacket.value).toHexString()
bridgeWebView.callHandler("monitorCharacteristic", packetHexString)
}?.launchIn(lifecycleScope)

withContext(Dispatchers.Main) {
bridgeCallback.onCallBack("{\"success\":true}")
}
}
})

第6步:发送与断连

send 处理器负责把 JS 侧传入的十六进制字符串写入 BLE 写特征;disconnect 处理器负责主动释放当前 GATT 连接。两者都要通过桥接回调明确告诉 JS 侧操作是否完成,否则上层 SDK 会一直等待结果。

bridgeWebView.registerHandler("send", BridgeHandler { rawRequest, bridgeCallback ->
val sendPayload = JsonParser.parseString(rawRequest).asJsonObject
val requestHex = sendPayload.get("data").asString
val outboundBytes = DataByteArray.from(requestHex).value

lifecycleScope.launch(Dispatchers.IO) {
writeCharacteristic?.write(outboundBytes)
withContext(Dispatchers.Main) {
bridgeCallback.onCallBack("{\"success\":true}")
}
}
})

bridgeWebView.registerHandler("disconnect", BridgeHandler { _, bridgeCallback ->
activeGattConnection?.disconnect()
activeGattConnection = null
bridgeCallback.onCallBack("{\"success\":true}")
})

如果写入失败或连接已断开,建议返回带错误信息的 JSON,并在 JS 侧转成 SDK 能识别的失败结果。这样应用层可以提示用户重新连接,而不是停留在无响应状态。

第7步:JS Web适配

演示 Web 工程已经把低层传输适配器接好:SDK 以 env: 'lowlevel' 初始化,再通过桥接触发原生侧的 enumerateconnectdisconnectsendreceive。常规接入时,构建并复制 web/web_dist/ 后加载 file://android_asset/web_dist/index.html 即可。

如果你需要改造适配器,核心原则保持不变:JS 侧只维护 SDK 调用和 UI 事件,真正的扫描、连接、写入、通知监听和消息重组都放在 Android 原生层完成。

第8步:UI事件

PIN、Passphrase 和确认类交互仍然由 JS 侧监听 UI_EVENT 后响应。原生层可以只负责 BLE,也可以额外提供原生弹窗,再把用户结果回传给 JS。

  • PIN 留在设备上:payload: '@@UKEY_INPUT_PIN_IN_DEVICE'
  • Passphrase 交给设备处理:{ passphraseOnDevice: true, value: '' }

事件监听和响应的完整写法请参考 事件说明。如果要做生产级弹窗,可复用 WebUSB说明中的最小交互模式,再替换为 Android 原生 UI。

第9步:收尾核对

核验项推荐做法
Nordic BLE 版本要求保持在 1.1.0 及以上,这样才能用到 includeStoredBondedDevices
桥接注册步骤先把原生桥接处理程序准备好,再去加载 HTML,避免前端比原生先发起调用。
Android 12+ 权限在扫描或连接之前先完成运行时授权。
设备标识留存首次连上后保留 connectId(多数场景下可直接使用 MAC 地址),再用 getFeatures(connectId) 读取 device_id 并写入会话上下文。

延展阅读