跳到主要内容

iOS原生蓝牙

在原生 iOS 应用里,可以让 WKWebView 承载 @ukeyfe/hardware-common-connect-sdk,再把 CoreBluetooth 的扫描、连接、写入和通知能力桥接给 JS。这样上层继续运行 JavaScript SDK,BLE 细节则由原生层处理并回传结果。

建议先阅读 传输插件(Low-level),了解插件接口、消息帧格式和 BLE 通知重组要求。

这个接入方式会用到两类原生能力:

  • CoreBluetooth:由系统提供,负责 iOS 侧蓝牙扫描、连接、写入和通知。
  • WKWebViewJavascriptBridge:通过 CocoaPods 引入,用来在 JS 与原生层之间传递消息。

请在 Info.plist 中补齐蓝牙用途说明:

  • NSBluetoothAlwaysUsageDescription
  • NSBluetoothPeripheralUsageDescription(旧版 iOS 仍可能读取)

UKey Wallet BLE 使用下面这组固定 UUID:

  • 服务 UUID:primaryServiceUUIDString
  • 写入特征:txCharacteristicUUIDString
  • 通知特征:rxCharacteristicUUIDString

第1步:Pod 与 Web

Podfile(引入桥接库):

platform :ios, '13.0'
use_frameworks!

target 'YourAppTarget' do
pod 'WKWebViewJavascriptBridge'
end

构建演示 Web 资源:

# 先进入 hardware-js-sdk 仓库目录
cd packages/connect-examples/native-ios-example/web
yarn && yarn build # 构建结果会输出到 web/web_dist/

把构建后的文件加入 App 资源时,可以按下面两种方式安排:

  • 保留 web_dist 目录层级(更省心):

    • 把完整的 web/web_dist/ 目录加入 Xcode 工程,例如建成名为 web/web_dist 的资源组,并确认它参与 "Copy Bundle Resources"。
    • WKWebView 读取 web/web_dist/index.html
  • 将产物平铺进资源目录:

    • web/web_dist/ 里的文件放到应用包根目录,或你指定的资源目录。
    • 对应入口通常就是 index.html

无论选择哪种方式,都要保证 HTML 中的 <script src="..."> 路径与实际资源位置一致。

第2步:状态与处理器

class UKeyBleBridgeViewController: UIViewController {
// 与 WebView / JSBridge 相关的对象
var embeddedWebView: WKWebView!
var jsBridge: WKWebViewJavascriptBridge!

// BLE 连接流程需要维护的状态
var centralManager: CBCentralManager!
var activePeripheral: CBPeripheral?
var txCharacteristic: CBCharacteristic?
var rxCharacteristic: CBCharacteristic?

// UKey Wallet BLE 常量
let primaryServiceUUIDString = ["00000001", "0000", "1000", "8000", "00805f9b34fb"].joined(separator: "-")
let txCharacteristicUUIDString = ["00000002", "0000", "1000", "8000", "00805f9b34fb"].joined(separator: "-")
let rxCharacteristicUUIDString = ["00000003", "0000", "1000", "8000", "00805f9b34fb"].joined(separator: "-")

// 暂存桥接层回调(例如设备枚举)
var pendingEnumerateCallback: ((Any?) -> Void)?
}

第3步:加载桥接

初始化顺序很重要。先创建 WebView 和桥接对象,再注册原生处理程序,最后加载 HTML。否则 JS 侧可能已经开始调用插件,而原生处理程序还没有准备好。

  • 创建 WKWebView。
  • 创建桥接对象。
  • 注册 enumerateconnectdisconnectsendmonitorCharacteristic 等处理程序。
  • 加载打包后的 web/web_dist/
import CoreBluetooth
import WebKit
import WKWebViewJavascriptBridge

class UKeyBleBridgeViewController: UIViewController {
// 其余成员按需补充

override func viewDidLoad() {
super.viewDidLoad()

centralManager = CBCentralManager(delegate: self, queue: .main)

embeddedWebView = WKWebView(frame: view.bounds)
view.addSubview(embeddedWebView)

jsBridge = WKWebViewJavascriptBridge(webView: embeddedWebView)
registerNativeBridgeHandlers()

// 按方案一的目录层级读取打包后的 HTML
if let bundleHtmlPath = Bundle.main.path(forResource: "index", ofType: "html", inDirectory: "web/web_dist") {
embeddedWebView.load(URLRequest(url: URL(fileURLWithPath: bundleHtmlPath)))
}
}
}

第4步:桥接操作

extension UKeyBleBridgeViewController {
func registerNativeBridgeHandlers() {
// enumerate:执行 BLE 扫描,并把 [{ id, name }] 回给前端
jsBridge.register(handlerName: "enumerate") { [weak self] _, bridgeCallback in
guard let self = self else { return }
self.pendingEnumerateCallback = bridgeCallback
self.centralManager.scanForPeripherals(
withServices: [CBUUID(string: self.primaryServiceUUIDString)], options: nil
)
// 正式环境里通常要很快停扫;演示项目里可以看到去重和结果累积的写法
}

// establish connection:按指定外设 UUID 发起连接
jsBridge.register(handlerName: "connect") { [weak self] requestPayload, bridgeCallback in
guard
let self = self,
let peripheralUUID = requestPayload?["uuid"] as? String,
let peripheralIdentifier = UUID(uuidString: peripheralUUID)
else {
bridgeCallback?(["success": false, "error": "Invalid UUID"])
return
}

// 先看看系统能不能直接返回这台已知外设
if let discoveredPeripheral = self.centralManager.retrievePeripherals(withIdentifiers: [peripheralIdentifier]).first {
self.activePeripheral = discoveredPeripheral
self.centralManager.connect(discoveredPeripheral, options: nil)
} else {
// 如果拿不到,就退回到扫描流程里做匹配
self.centralManager.scanForPeripherals(
withServices: [CBUUID(string: self.primaryServiceUUIDString)], options: nil
)
}
bridgeCallback?(["success": true])
}

// 响应前端发起的断连请求
jsBridge.register(handlerName: "disconnect") { [weak self] _, bridgeCallback in
if let connectedPeripheral = self?.activePeripheral {
self?.centralManager.cancelPeripheralConnection(connectedPeripheral)
}
bridgeCallback?(["success": true])
}

// send:把十六进制内容写入设备特征值
jsBridge.register(handlerName: "send") { [weak self] requestPayload, bridgeCallback in
guard
let self = self,
let payloadHex = requestPayload?["data"] as? String,
let writableCharacteristic = self.txCharacteristic
else { bridgeCallback?(["success": false]); return }

var payloadBytes = [UInt8]()
var cursor = payloadHex.startIndex
while cursor < payloadHex.endIndex {
let nextCursor = payloadHex.index(cursor, offsetBy: 2)
if let byteValue = UInt8(payloadHex[cursor..<nextCursor], radix: 16) {
payloadBytes.append(byteValue)
}
cursor = nextCursor
}
self.activePeripheral?.writeValue(Data(payloadBytes), for: writableCharacteristic, type: .withoutResponse)
bridgeCallback?(["success": true])
}
}
}

第5步:扫描通知

extension UKeyBleBridgeViewController: CBCentralManagerDelegate, CBPeripheralDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
// 这里按需处理 .poweredOn 和其他状态;也可以顺便按 UUID 恢复已缓存设备
}

// 收集扫描结果,并回传给 JS
func centralManager(
_ central: CBCentralManager,
didDiscover discoveredPeripheral: CBPeripheral,
advertisementData: [String : Any],
rssi signalStrength: NSNumber
) {
let discoveredItem: [String: String] = [
"id": discoveredPeripheral.identifier.uuidString,
"name": discoveredPeripheral.name ?? ""
]
// 把当前发现的设备交给 enumerate 阶段保存下来的 JS 回调
pendingEnumerateCallback?([discoveredItem])
// 正式接入时记得做去重,并在超时或结果足够后结束扫描
}

func centralManager(_ central: CBCentralManager, didConnect connectedPeripheral: CBPeripheral) {
connectedPeripheral.delegate = self
connectedPeripheral.discoverServices([CBUUID(string: primaryServiceUUIDString)])
}

func peripheral(_ connectedPeripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let primaryService = connectedPeripheral.services?.first else { return }
let txCharacteristicUUID = CBUUID(string: txCharacteristicUUIDString)
let rxCharacteristicUUID = CBUUID(string: rxCharacteristicUUIDString)
connectedPeripheral.discoverCharacteristics([txCharacteristicUUID, rxCharacteristicUUID], for: primaryService)
}

func peripheral(_ connectedPeripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
service.characteristics?.forEach { characteristic in
if characteristic.uuid == CBUUID(string: txCharacteristicUUIDString) { txCharacteristic = characteristic }
if characteristic.uuid == CBUUID(string: rxCharacteristicUUIDString) {
rxCharacteristic = characteristic
connectedPeripheral.setNotifyValue(true, for: characteristic)
}
}
}

func peripheral(_ connectedPeripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let characteristicData = characteristic.value else { return }
// 先转成 hex,再交给 JS;Web 包会负责重组帧并解析 receive()
let packetHexString = characteristicData.map { String(format: "%02x", $0) }.joined()
jsBridge.callHandler("monitorCharacteristic", data: packetHexString)
}
}

第6步:JS 适配器

演示用 Web 项目(native-ios-example/web)已经通过 env: 'lowlevel' 初始化 SDK,并把底层调用转接到原生桥。通常只要构建这份 Web 包,再把 web/web_dist/ 放进应用资源即可。

如果你要改造自己的适配器,请保持同一模式:使用 env: 'lowlevel' 初始化,并把 enumerateconnectdisconnectsendreceive 映射到原生桥接能力。

第7步:原生UI桥

如果你希望 PIN、确认弹窗等交互使用原生 UI,而不是在 WebView 中展示,可以额外暴露处理程序。JS 发起 UI 请求,原生层展示界面,然后把用户操作结果回传给 JS。

// 参考写法:桥接原生 PIN 输入处理(精简版)
jsBridge.register(handlerName: "requestPinInput") { [weak self] _, bridgeCallback in
// 这里拉起你自己的原生 PIN 输入界面。
// 返回空字符串时,JS 会切回“在设备上输入 PIN”的流程,
// 也可以回传转换后的 PIN 串(盲键盘序列),让软件侧继续输入。
// 这里演示的是直接强制走设备输入:
bridgeCallback?("")
}

// 参考写法:桥接原生确认弹窗
jsBridge.register(handlerName: "requestButtonConfirmation") { _, bridgeCallback in
// 弹出原生确认框,并按结果回传 "ok" 或 "cancel"。
bridgeCallback?("ok")
}

jsBridge.register(handlerName: "closeUIWindow") { _, bridgeCallback in
// 把当前原生覆盖层或弹窗收起来。
bridgeCallback?("closed")
}

如果采用原生 UI,JS 层只负责把事件转发给这些桥接处理程序;不需要原生弹窗时,也可以留在 JS 内监听 UI_EVENT,再通过 ukeySdk.uiResponse 回写结果。事件类型和响应方式见 事件说明

第8步:收尾核对

核验项推荐做法
桥接注册先把所有桥接处理程序挂好,再去加载 HTML。
扫描范围用服务 UUID 限定扫描,并在合适的时间窗口内停下来。
会话标识把当前会话的 connectId 保存起来,首连后再通过 getFeatures(connectId) 读取 device_id
UI 监听一直订阅 UI_EVENT,避免 PIN、Passphrase 或确认事件没人接。
资源打包确认 web/web_dist/ 已随应用资源一起打包,且 WKWebView 的加载路径与真实目录一致。

延展阅读