【鸿蒙】HarmonyOS 蓝牙/NFC 短距通信完全指南:从扫描到数据交换的全流程实战

HarmonyOS 蓝牙/NFC 短距通信完全指南:从扫描到数据交换的全流程实战

掌握 HarmonyOS NEXT 蓝牙(BLE/经典蓝牙)与 NFC(NDEF/卡模拟)核心 API,彻底解决设备发现、配对、数据收发、权限申请等高频开发痛点。

> 适用版本:HarmonyOS NEXT / API 12+

> 预计阅读时长:18 分钟


一、短距通信能力全景

短距通信在物联网、支付、设备协同场景中不可或缺。HarmonyOS NEXT 提供两条独立技术栈:

复制代码
┌────────────────────────────────────────────────────────┐

│                 HarmonyOS 短距通信栈                    │


├─────────────────────┬──────────────────────────────────┤


│      蓝牙子系统      │           NFC 子系统              │


│  ┌───────────────┐  │  ┌─────────────────────────────┐ │


│  │ 经典蓝牙(BR/EDR)│  │  │ NDEF 标签读写              │ │


│  │ - A2DP/AVRCP  │  │  │ - NdefMessage/NdefRecord   │ │


│  │ - SPP 串口     │  │  ├─────────────────────────────┤ │


│  ├───────────────┤  │  │ HCE 卡模拟                  │ │


│  │ BLE 低功耗     │  │  │ - CardEmulation             │ │


│  │ - GATT Server │  │  │ - AID 路由                  │ │


│  │ - GATT Client │  │  └─────────────────────────────┘ │


│  │ - 广播/扫描   │  │                                   │


│  └───────────────┘  │                                   │


└─────────────────────┴──────────────────────────────────┘

核心模块路径:

  • @ohos.bluetooth.ble --- BLE 广播/扫描/GATT

  • @ohos.bluetooth.connection --- 经典蓝牙连接管理

  • @ohos.nfc.tag --- NFC 标签读写

  • @ohos.nfc.cardEmulation --- HCE 卡模拟


二、权限申请:必须先搞定,否则一切白搭

2.1 所需权限清单

复制代码
// module.json5

"requestPermissions": [


{ "name": "ohos.permission.USE_BLUETOOTH" },


{ "name": "ohos.permission.DISCOVER_BLUETOOTH" },


{ "name": "ohos.permission.MANAGE_BLUETOOTH" },


{ "name": "ohos.permission.LOCATION" },


{ "name": "ohos.permission.APPROXIMATELY_LOCATION" },


{ "name": "ohos.permission.NFC_TAG" },


{ "name": "ohos.permission.NFC_CARD_EMULATION" }


]

2.2 动态申请(BLE 扫描场景)

复制代码
import { abilityAccessCtrl } from '@kit.AbilityKit';


async function requestBlePermissions(context: Context): Promise
   
     {
   


const atManager = abilityAccessCtrl.createAtManager();


const permissions = [


'ohos.permission.LOCATION',


'ohos.permission.APPROXIMATELY_LOCATION',


'ohos.permission.USE_BLUETOOTH',


];


const result = await atManager.requestPermissionsFromUser(context, permissions);


// result.authResults[i] === 0 表示已授权


return result.authResults.every(r => r === 0);


}

坑点 :BLE 扫描在 API 12 上依然需要位置权限( LOCATIONAPPROXIMATELY_LOCATION),缺少任意一个将导致 startBLEScan 静默失败,扫描结果永远为空,且不抛异常。


三、BLE 开发实战

3.1 BLE 扫描流程

复制代码
调用 startBLEScan()

│


▼


on('BLEDeviceFind') 持续回调


│


├─ 过滤 ServiceUUID / 设备名 / RSSI


│


▼


stopBLEScan()  ← 找到目标设备后立即停止,节省电量


│


▼


createGattClientDevice(deviceId)

完整扫描代码

复制代码
import ble from '@ohos.bluetooth.ble';

import { BusinessError } from '@ohos.base';



const TARGET_SERVICE_UUID = '0000FFF0-0000-1000-8000-00805F9B34FB';


let gattClient: ble.GattClientDevice | null = null;



function startScan(): void {


// 注册发现回调(必须在 startBLEScan 之前注册)


ble.on('BLEDeviceFind', (devices: Array
   
    ) => {
   


for (const device of devices) {


const hasTargetService = device.serviceUuids?.includes(TARGET_SERVICE_UUID);


if (hasTargetService) {


ble.stopBLEScan();          // 立即停止,避免持续扫描耗电


connectDevice(device.deviceId);


break;


}


}


});



const scanFilters: Array
   
     = [{ serviceUuid: TARGET_SERVICE_UUID }];
   


const scanOptions: ble.ScanOptions = {


interval: 0,


dutyMode: ble.ScanDuty.SCAN_MODE_LOW_LATENCY,


matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE,


};



try {


ble.startBLEScan(scanFilters, scanOptions);


} catch (e) {


const err = e as BusinessError;


console.error(`startBLEScan failed: ${err.code} ${err.message}`);


}


}

3.2 GATT 连接与特征读写

复制代码
function connectDevice(deviceId: string): void {

gattClient = ble.createGattClientDevice(deviceId);



gattClient.on('BLEConnectionStateChange', (state: ble.BLEConnectionChangeState) => {


if (state.state === ble.ProfileConnectionState.STATE_CONNECTED) {


// 连接成功后先发现服务,否则直接读写会报错


gattClient!.getServices().then((services: Array
   
    ) => {
   


const targetService = services.find(s => s.serviceUuid === TARGET_SERVICE_UUID);


const targetChar = targetService?.characteristics?.find(


c => c.characteristicUuid === '0000FFF1-0000-1000-8000-00805F9B34FB'


);


if (targetChar) {


gattClient!.readCharacteristicValue(targetChar).then(result => {


const value = new Uint8Array(result.characteristicValue);


console.info(`Read: ${Array.from(value).map(b => b.toString(16)).join(' ')}`);


});


}


});


}


});



gattClient.connect();


}

3.3 开启 BLE 通知(Notify)

复制代码
async function enableNotify(char: ble.BLECharacteristic): Promise
  
    {

   
// 步骤1:告知系统订阅通知


   
await gattClient!.setNotifyCharacteristicChanged(char, true);



   
// 步骤2:向 CCCD 描述符写入 0x0100,外围设备才会真正推送


   
const cccd: ble.BLEDescriptor = {


   
serviceUuid: TARGET_SERVICE_UUID,


   
characteristicUuid: char.characteristicUuid,


   
descriptorUuid: '00002902-0000-1000-8000-00805F9B34FB',


   
descriptorValue: new Uint8Array([0x01, 0x00]).buffer,


   
};


   
await gattClient!.writeDescriptorValue(cccd);



   
// 步骤3:注册数据变化回调


   
gattClient!.on('BLECharacteristicChange', (charChange: ble.BLECharacteristic) => {


   
const data = new Uint8Array(charChange.characteristicValue);


   
console.info(`Notify data: ${Array.from(data).map(b => b.toString(16)).join(' ')}`);


   
});


   
}

3.4 GATT Server(外围设备模式)

复制代码
let gattServer: ble.GattServer;


function startGattServer(): void {


gattServer = ble.createGattServer();



const service: ble.GattService = {


serviceUuid: TARGET_SERVICE_UUID,


isPrimary: true,


characteristics: [{


serviceUuid: TARGET_SERVICE_UUID,


characteristicUuid: '0000FFF1-0000-1000-8000-00805F9B34FB',


characteristicValue: new ArrayBuffer(0),


descriptors: [],


properties: 0x0A,  // READ | WRITE


}],


includeServices: [],


};



gattServer.addService(service);



gattServer.on('characteristicWrite', (req: ble.CharacteristicWriteRequest) => {


const data = new Uint8Array(req.value);


console.info(`Received: ${Array.from(data).map(b => b.toString(16)).join(' ')}`);


gattServer.sendResponse({


deviceId: req.deviceId,


transId: req.transId,


status: 0,


offset: 0,


value: new ArrayBuffer(0),


});


});



// 开始广播


ble.startAdvertising(


{ interval: 160, txPower: 0, connectable: true },


{ serviceUuids: [TARGET_SERVICE_UUID], manufactureData: [], serviceData: [] }


);


}

四、NFC 标签读写实战

4.1 NFC 标签分发机制

复制代码
设备靠近 NFC 标签

│


▼


NFC 服务识别标签类型


│


├─ NDEF 标签 → action: "ohos.nfc.action.TAG_DISCOVERED"


└─ ISO-DEP   → action: "ohos.nfc.action.TAG_DISCOVERED"


│


▼


匹配 module.json5 中的 skills.actions 配置


│


▼


冷启动 → onCreate(want)  /  热启动 → onNewWant(want)

module.json5 配置(两处都要处理)

复制代码
"skills": [{ "actions": ["ohos.nfc.action.TAG_DISCOVERED"], "uris": [] }]

// UIAbility 中同时处理冷启动和热启动

onCreate(want: Want): void { handleNfcWant(want); }


onNewWant(want: Want): void { handleNfcWant(want); }



function handleNfcWant(want: Want): void {


const tagInfo = tag.getTagInfo(want);


if (tagInfo.technology.includes(tag.NfcTechnology.NDEF)) {


readNdefTag(tagInfo);


}


}

4.2 读取 NDEF 标签

复制代码
import tag from '@ohos.nfc.tag';


async function readNdefTag(tagInfo: tag.TagInfo): Promise
   
     {
   


const ndefTag = tag.getNdef(tagInfo);


await ndefTag.connect();   // 必须先 connect



try {


const ndefMessage = await ndefTag.readNdef();


for (const record of ndefMessage.ndefRecords) {


if (record.tnf === tag.NfcTnfType.TNF_WELL_KNOWN) {


const payload = new Uint8Array(record.payload);


// Text Record: 第一字节为状态字节,后面是语言代码+文本


const langLen = payload[0] & 0x3F;


const text = new TextDecoder().decode(payload.slice(1 + langLen));


console.info(`NDEF text: ${text}`);


}


}


} finally {


await ndefTag.close();   // 必须 close,否则标签持续占用


}


}

4.3 写入 NDEF 标签

复制代码
async function writeNdefTag(tagInfo: tag.TagInfo, message: string): Promise
  
    {

   
const ndefTag = tag.getNdef(tagInfo);


   
await ndefTag.connect();



   
try {


   
const encoder = new TextEncoder();


   
const langCode = 'zh';


   
const langBytes = encoder.encode(langCode);


   
const textBytes = encoder.encode(message);


   
const statusByte = langCode.length & 0x3F;



   
const payload = new Uint8Array(1 + langBytes.length + textBytes.length);


   
payload[0] = statusByte;


   
payload.set(langBytes, 1);


   
payload.set(textBytes, 1 + langBytes.length);



   
const ndefMessage: tag.NdefMessage = {


   
ndefRecords: [{


   
tnf: tag.NfcTnfType.TNF_WELL_KNOWN,


   
rtdType: new Uint8Array([0x54]).buffer,  // 'T' = Text RTD


   
id: new ArrayBuffer(0),


   
payload: payload.buffer,


   
}]


   
};


   
await ndefTag.writeNdef(ndefMessage);


   
} finally {


   
await ndefTag.close();


   
}


   
}

五、HCE 卡模拟实战

HCE(Host Card Emulation)让手机模拟为 NFC 卡片,典型场景为门禁卡/交通卡。

复制代码
import cardEmulation from '@ohos.nfc.cardEmulation';


class MyHceService extends cardEmulation.HceService {


onCommand(apduData: number[]): void {


console.info(`APDU: ${apduData.map(b => b.toString(16)).join(' ')}`);


if (apduData[1] === 0xA4) {


this.sendResponse([0x90, 0x00]);  // SELECT AID 成功


} else {


this.sendResponse([0x6D, 0x00]);  // 不支持的指令


}


}


}

AID 注册(resources/base/profile/hce_service.json)

复制代码
{ "aids": ["A000000003000000"] }

六、错误写法 → 问题 → 正确写法

案例 1:BLE 扫描无结果

复制代码
// ❌ 错误:先 startBLEScan,再注册回调

ble.startBLEScan([], {});


ble.on('BLEDeviceFind', (devices) => { ... });


// 问题:回调注册晚于扫描启动,早期扫描结果丢失



// ✅ 正确:先注册回调,再启动扫描


ble.on('BLEDeviceFind', (devices) => { ... });


ble.startBLEScan([], {});

案例 2:未发现服务直接读特征

复制代码
// ❌ 错误:连接后直接读特征(报错:services not discovered)

gattClient.on('BLEConnectionStateChange', (state) => {


if (state.state === ProfileConnectionState.STATE_CONNECTED) {


gattClient.readCharacteristicValue(char);


}


});



// ✅ 正确:连接后先 getServices,从返回的 services 中取特征对象


gattClient.on('BLEConnectionStateChange', (state) => {


if (state.state === ProfileConnectionState.STATE_CONNECTED) {


gattClient.getServices().then(services => {


const char = services[0].characteristics[0];


gattClient.readCharacteristicValue(char);


});


}


});

案例 3:NFC 标签未关闭连接

复制代码
// ❌ 错误:用完不 close → 标签持续被占用

const ndefTag = tag.getNdef(tagInfo);


await ndefTag.connect();


await ndefTag.readNdef();



// ✅ 正确:try/finally 确保 close


await ndefTag.connect();


try {


await ndefTag.readNdef();


} finally {


await ndefTag.close();


}

七、最佳实践

7.1 BLE 扫描找到目标立即停止

做法 :找到目标设备后立即调用 ble.stopBLEScan()原因SCAN_MODE_LOW_LATENCY 模式下持续扫描功耗约是 idle 的 5~10 倍。 不这样做会怎样 :耗电明显增加,部分系统还会在后台强制降级扫描频率。

7.2 GATT 连接复用,按指数退避重连

做法 :缓存 GattClientDevice 实例,监听连接状态,断开后以 1s → 2s → 4s 间隔重连。 原因 :每次 createGattClientDevice + connect 需要完整 BLE 握手,通常耗时 500ms~2s。 不这样做会怎样 :用户每次打开页面都经历秒级等待;高频重连可能触发对端设备防抖,被拒绝连接。

7.3 NFC 操作异步执行,勿阻塞主线程

做法 :将 connect/read/write/close 通过 async/await + TaskPool 与 UI 线程隔离。 原因 :NFC I/O 存在不确定延迟(10~200ms),主线程阻塞导致帧率抖动甚至 ANR。 不这样做会怎样 :UI 卡顿,严重时系统弹出"应用未响应"对话框。

7.4 监听蓝牙状态,就绪后再初始化

做法 :通过 bluetooth.on('stateChange', ...) 监听开关,在 STATE_ON 后再启动扫描/广播。 原因 :蓝牙开关切换是异步过程,立即调用 API 会抛 2900099(BT_ERR_INTERNAL_ERROR)不这样做会怎样 :应用启动时若蓝牙未就绪,初始化必然失败,错误信息不直观,排查困难。


八、核心坑点

坑点 1:BLE 扫描需要位置权限但静默失败

现象startBLEScan 调用后 BLEDeviceFind 回调始终为空,无任何报错日志。 原因 :API 12 中 BLE 扫描依赖位置能力,缺少 LOCATIONAPPROXIMATELY_LOCATION 时系统静默忽略扫描结果。 复现 :移除 module.json5 中位置权限后运行扫描,观察 BLEDeviceFind 从不触发。 解决 :在 startBLEScan 前先申请位置权限,确认授权后再启动扫描。


坑点 2:GATT 通知未写 CCCD 描述符

现象readCharacteristicValue 正常,但 on('BLECharacteristicChange') 回调永远不触发。 原因 :BLE Notify 要求客户端向 CCCD(UUID: 00002902-...)写入 0x0100 开启通知,缺少此步骤外围设备不会主动推送数据。 复现 :只调用 setNotifyCharacteristicChanged(char, true),不写 CCCD 描述符,监听无效。 解决setNotifyCharacteristicChanged + writeDescriptorValue(CCCD, [0x01, 0x00]) 必须同时执行(见第三节示例)。


坑点 3:NFC onNewWant 中 tagInfo 为 undefined

现象 :应用被 NFC 拉起后 tag.getTagInfo(want) 返回 undefined 或抛异常。 原因 :冷启动时标签信息在 onCreatewant 中,热启动时在 onNewWant 中------只处理其中一个导致特定场景 tagInfo 为空。 复现 :只在 onNewWant 处理,冷启动靠近标签时 tagInfo 为空。 解决onCreateonNewWant 共用同一处理函数,两处都调用 tag.getTagInfo(want)


九、总结

  1. 权限是基础 :BLE 扫描需位置权限,NFC 需 NFC_TAG/NFC_CARD_EMULATION,缺一且静默失败。

  2. BLE 流程有序 :先注册回调 → 再启动扫描;连接后先 getServices → 再操作特征。

  3. NFC 资源必须释放connect 后必须 closetry/finally 是标准写法。

  4. GATT 通知需双写setNotifyCharacteristicChanged + CCCD 描述符写入缺一不可。

  5. 核心结论 :BLE 和 NFC 开发最大陷阱在于"静默失败"------无报错无日志,必须通过 hilog 过滤 nfc/bluetooth tag 定位根因。


参考资料