安卓原生蓝牙
这里展示的是 Android 原生壳的接入方式:WebView 承载 @ukeyfe/hardware-common-connect-sdk,JSBridge 负责跨层通信,Nordic BLE 则处理扫描、连接和数据收发。上层仍然写 SDK 调用,底层 BLE 细节交给 Android 原生层,再把结果桥接回 JS。
如果你还没有了解底层插件接口,建议先阅读 传输插件(Low-level),尤其是 BLE 消息头、通知重组和 receive() 返回完整消息的要求。
BLE流程
Android BLE 对连接顺序比较敏感。配对、GATT 连接、服务发现和通知开启之间都需要留出稳定时间;跳过其中某一步,可能不会抛出明显错误,但 SDK 会表现为找不到设备或长时间无响应。
| 步骤 | 触发点 | 说明 |
|---|---|---|
| 1 | ensureBonded() | 先准备系统配对状态,最长等待 45s。 |
| 2 | connect() | 建立 GATT 会话,连接阶段按 15s 控制。 |
| 3 | delay(500ms) | 连接成功后留半秒缓冲,让链路先稳定。 |
| 4 | discoverServices() | 枚举设备暴露出来的 BLE 服务。 |
| 5 | delay(500ms) | 服务发现结束后再短暂停顿,避免马上写入。 |
| 6 | startNotifications() | 最后开启通知订阅,失败时最多重试 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 JSBridge | com.smallbuer:jsbridge:1.0.7 |
| Nordic BLE Kotlin | no.nordicsemi.android.kotlin.ble:scanner:1.1.0no.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' 初始化,再通过桥接触发原生侧的 enumerate、connect、disconnect、send 和 receive。常规接入时,构建并复制 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 并写入会话上下文。 |
延展阅读
- 传输插件(Low-level) — BLE 协议、消息格式、UUID 参考