鸿蒙 Beacon 围栏的两条官方链路:23 个字节的 iBeacon 配置,和一条不用自己写状态机的扫描模式
鸿蒙 Beacon 围栏的两条官方链路:23 个字节的 iBeacon 配置,和一条不用自己写状态机的扫描模式
前言
「手机走进某个蓝牙信标附近就弹一次通知,走出去再弹一次」——这是 Beacon 围栏最朴素的需求,几乎每个做商场导览、展馆讲解、门店到店提醒的项目都会碰到。但真正落到 HarmonyOS NEXT 的 ArkTS 工程里,多数人会卡在三处:官网那套 beacon 围栏 API 到底怎么填、BeaconFence 里为什么找不到 UUID/major/minor、以及没有 FenceExtensionAbility 时进入/离开事件是不是就收不到了。
这篇把两条官方链路拆开讲清楚:一条是系统级的 geoLocationManager.addBeaconFence(),另一条是蓝牙扫描侧的围栏上报模式 ScanReportMode.FENCE_SENSITIVITY_LOW / HIGH。第二条经常被忽略,但它恰好把「进入 / 离开」的去抖做在了子系统里,对已经有扫描模块的工程来说改造成本最低。文中所有接口签名、枚举值、错误码均取自 Huawei 官方接口页(@ohos.geoLocationManager、@ohos.app.ability.FenceExtensionAbility、@ohos.bluetooth.ble),代码可在 API 20 工程里直接运行。
问题描述
一个典型的求助是这样的:
目前阶段已完成了蓝牙的扫描和连接模块。现在要实现当我进入了一个围栏,进入和出去都有一个通知或者回调。看官网上面有一个 beacon 的一套 API,询问一下各位大佬这个要如何实现这种围栏的效果。
需求本身很简单,但它背后藏着四个常见的失败路径,几乎每个刚接触的人都会踩到其中一个:
- 把「蓝牙连接 / 断开」当成进入 / 离开事件。Beacon 是靠广播工作的,围栏判断只需要收广播,不需要建连;很多 iBeacon 甚至不可连接,于是「连接失败」被误读成「离开了围栏」。
- 用单次 RSSI 阈值判断进出。实测里同一位置的 RSSI 会在 -65 → -78 → -82 → -70 之间跳,单阈值必然反复触发。
- 拿
ScanResult.deviceId当信标身份持久化。未在过滤器里指定真实 MAC 时,拿到的是虚拟 MAC,设备重启蓝牙开关后会变,服务端记下的「同一个信标」第二天就对不上了。 - 在围栏回调里直接干重活。想弹 UI、想直接发网络请求,结果发现围栏扩展能力那边一引用就崩——官方对它能引用的模块有明确白名单限制。
还有一个前置误解值得先说清:华为生态里叫 BeaconBarrier、Awareness.getBarrierClient() 的那套是 HMS Core 情景感知服务(Android),不能照搬到纯血鸿蒙的 ArkTS 工程;而 FenceExtensionAbility 虽然名字里也有 Fence,但它服务的是位置围栏事件这一类能力,既承接 GNSS 地理围栏,也承接 API 20 起的 beacon 围栏。
细节解析
一、三条路线的定位
| 路线 | 能力来源 | 起始版本 | 谁做去抖 | 适合场景 |
|---|---|---|---|---|
| A. 系统 beacon 围栏 | geoLocationManager.addBeaconFence | API 20+(Phone/Tablet) | 系统 | 只要「进/出各一次」,希望后台也有事件 |
| B. 蓝牙围栏上报模式 | ScanOptions.reportMode = FENCE_SENSITIVITY_* | API 18+ | 蓝牙子系统 | 已有扫描模块,想省电、多信标 |
| C. 自建状态机 | 自己维护 RSSI / 时间窗 | 任意 | 业务代码 | 要停留时长、距离估算、多次触发 |
三者不互斥。实践里最常见的组合是 B 省电 + C 补业务,或者在设备支持 API 20+ 时直接升到 A。
二、路线 A:addBeaconFence 的完整契约
接口签名与约束(接口页原文):
addBeaconFence(fenceRequest: BeaconFenceRequest): Promise<number>
removeBeaconFence(beaconFence?: BeaconFence): Promise<void>
isBeaconFenceSupported(): boolean
- 需要权限:
ohos.permission.LOCATION和ohos.permission.APPROXIMATELY_LOCATION,两个都要。 - 系统能力:
SystemCapability.Location.Location.Geofence。 - 单应用添加 beacon 围栏上限 10 个,超限抛
3501601。 removeBeaconFence不传参时,删除该应用的全部围栏——清理时非常方便。
数据结构只有三层,但字段很反直觉:
// BeaconFenceRequest:transitionCallback 与 fenceExtensionAbilityName 任选其一,都不填则参数无效
interface BeaconFenceRequest {
beacon: BeaconFence;
transitionCallback?: Callback<GeofenceTransition>; // 仅支持前台回调
fenceExtensionAbilityName?: string;
}
// BeaconFence:没有 uuid / major / minor 字段
interface BeaconFence {
identifier: string; // 你自定义的围栏标识
beaconFenceInfoType: BeaconFenceInfoType; // 当前仅有 BEACON_MANUFACTURE_DATA = 1
manufactureData?: BeaconManufactureData;
}
interface BeaconManufactureData {
manufactureId: number; // iBeacon 填 0x004C
manufactureData: ArrayBuffer; // 23 字节厂商数据
manufactureDataMask: ArrayBuffer; // 0xFF 全匹配,0x00 模糊匹配
}
BeaconFenceInfoType 只有一个枚举值 BEACON_MANUFACTURE_DATA = 1,官方说明是「beacon 围栏信息类型。当前仅支持设备制造商数据过滤」。也就是说,系统不做协议解析,只做字节比对,匹配条件必须由你自己拼成 iBeacon 广播的厂商数据。
这里是第一个高频翻车点:manufactureData 里放的正好是 23 字节,布局为官方注释的
01 byte type = 0x02
01 byte len = 0x15 (=21)
16 byte UUID
02 byte major
02 byte minor
01 byte tx power
它不包含公司 ID(公司 ID 走 manufactureId = 0x004C),也不包含 AD 结构的长度/类型字节。多塞两个字节进去不会报错,只会永远匹配不上,然后在「为什么收不到事件」上耗掉一整晚。
第二个翻车点是 mask 语义:
| 诉求 | manufactureDataMask 写法 |
|---|---|
| 精确匹配某个 major/minor | 23 字节全 0xFF |
| 只锁 UUID,任意 major/minor | 前 18 字节 0xFF(0x02 + 0x15 + UUID),后 5 字节 0x00 |
| 只锁厂商,不锁具体信标 | 只填 2 字节(0x02、0x15),mask 用 0xFF 0xFF |
事件对象与枚举:
interface GeofenceTransition {
geofenceId: number;
transitionEvent: GeofenceTransitionEvent;
beaconFence?: BeaconFence; // API 20 起,仅 beacon 围栏使用
}
| 枚举 | 值 | 含义 |
|---|---|---|
| GEOFENCE_TRANSITION_EVENT_ENTER | 1 | 从围栏外进入围栏内 |
| GEOFENCE_TRANSITION_EVENT_EXIT | 2 | 从围栏内退出到围栏外 |
| GEOFENCE_TRANSITION_EVENT_DWELL | 4 | 在围栏内持续徘徊超过 10 秒 |
注意 DWELL 是 4,不是 3。很多分享按「ENTER/EXIT/DWELL 顺序递增」猜成 3,结果「停留」分支永远进不去。另外 addBeaconFence 这条链路只会给 ENTER / EXIT,DWELL 属于 GNSS 围栏那套能力。
错误码几乎能覆盖所有「代码看起来完全正确却加不上」的场景:
| 错误码 | 含义 | 现场通常是什么 |
|---|---|---|
| 201 | 权限校验失败 | 两个位置权限没配齐,或用户没实际授权 |
| 801 | 设备能力不支持 | 没先查 isBeaconFenceSupported() |
| 3501100 | 位置开关关闭 | 系统定位总开关关了 |
| 3501101 | 蓝牙开关关闭 | 蓝牙没开——最常见却最不容易想到 |
| 3501601 | 围栏数超过上限 | 超过 10 个,且从未 removeBeaconFence |
| 3501603 | 重复的围栏信息 | 同一个 beacon 重复 add |
| 3501602 | 围栏信息不正确 | remove 时传的参数与 add 时不一致 |
三、transitionCallback 只在前台,后台必须靠 FenceExtensionAbility
BeaconFenceRequest.transitionCallback 的正式说明是「beacon 围栏事件信息。默认值为 undefined。仅支持前台回调」。用户按了 Home 键、应用被冻结后,这条回调就不再触发。要在后台也拿到事件,只能把 fenceExtensionAbilityName 填上,交给系统拉起扩展能力。
FenceExtensionAbility 的关键事实:
- 导入:
import { FenceExtensionAbility, geoLocationManager } from '@kit.LocationKit';,API 14+,仅 Stage 模型。 - 生命周期只有两个:
onFenceStatusChange(transition, additions)与onDestroy()。 module.json5里type必须是"fence"。- ⭐ 官方「附录」明确列出不支持引用的一批模块,包括 ArkUI 的
@ohos.window、UIAbilityContext、后台任务管理backgroundTaskManager、@ohos.request、音频/相机/账号管理等。
最后一条直接决定了架构:围栏回调里不能弹 UI、不能直接做上传下载。标准姿势只有两个动作——用 wantAgent 拉起目标 UIAbility 并把参数塞进 parameters,用 notificationManager.publish 发本地通知。官方示例本身就是这两步。
四、路线 B:蓝牙的围栏上报模式(最容易被忽略)
所有注意力都放在 addBeaconFence 上时,很容易漏掉 ScanOptions.reportMode 里的 ScanReportMode——它有一个专门为围栏场景准备的模式:
| 枚举 | 值 | 说明(接口页语义) |
|---|---|---|
| NORMAL | 1 | 常规模式,扫到符合过滤条件的广播立即上报 |
| BATCH | 2 | 批量模式,缓存 interval 后上报,显著降功耗 |
| FENCE_SENSITIVITY_LOW | 10 | 只在广播进入或离开围栏时上报;首次扫到即进入围栏触发一次上报,一段时间扫不到即离开围栏触发一次上报;适合信号强、广播多的环境 |
| FENCE_SENSITIVITY_HIGH | 11 | 语义同上,适合信号弱、广播少的环境 |
这正是「进入 / 离开各一次回调」,而且去抖由蓝牙子系统完成,不需要自己写「连续 N 次超过阈值 + 超时判定消失」的状态机。改造量只是把扫描参数里的 reportMode 换个值。
三条硬约束必须记住:
- 围栏模式下
filters不能为 null,接口页原句是「该参数不可设置为 null,需传入非空过滤器」。 - 过滤器资源是全应用共享的,单个应用建议不超过 3 个,占满后开启扫描会返回
2900009(out of hardware resources)。 BleScanner需要 API 15+;FENCE_SENSITIVITY_*需要 API 18+。BleScanner.on('BLEDeviceFind')的回调参数类型是ScanReport,与旧接口ble.on('BLEDeviceFind')的Array<ScanResult>不同,代码不要混抄。
减少功耗还有两个配套字段:ScanFilter 支持 manufactureId + manufactureData + manufactureDataMask(API 12+),可以在扫描层就把非目标信标滤掉,而不是全量扫上来在业务层丢弃;API 23+ 还提供 ScanFilter.rssiThreshold,直接把信号门限交给硬件匹配。
五、解析 iBeacon 广播:手工拆 AD 结构 vs 现成字段
广播报文是若干 AD 结构拼接:长度(1) | 类型(1) | 数据(长度-1)。iBeacon 用类型 0xFF(厂商自定义),数据部分是 公司ID(2) + 0x02 0x15 + UUID(16) + major(2) + minor(2) + txPower(1),所以长度字节是 0x1A(26)。
ScanResult.data 是「原始未解析的广播报文」,需要自己按上面的规则遍历;而从 API 22 起,ScanResult.manufacturerDataMap(Map<number, Uint8Array>,key 为厂商 ID)已经把厂商数据解析好了,可以直接省掉这层循环。
示例代码
以下代码均为 ArkTS,可在 API 20 工程直接使用。
1. 构造 23 字节 iBeacon 配置与 mask
// 01 byte type=0x02 + 01 byte len=0x15 + 16 byte UUID + 2 byte major + 2 byte minor + 1 byte txPower
export function buildIBeaconValue(uuid: number[], major: number, minor: number, txPower: number): ArrayBuffer {
const buf: Uint8Array = new Uint8Array(23);
buf[0] = 0x02;
buf[1] = 0x15;
buf.set(uuid, 2); // 16 字节 UUID(大端顺序即广播顺序)
buf[18] = (major >> 8) & 0xFF;
buf[19] = major & 0xFF;
buf[20] = (minor >> 8) & 0xFF;
buf[21] = minor & 0xFF;
buf[22] = txPower & 0xFF; // 校准发射功率,通常 -59
return buf.buffer;
}
// lockMajorMinor=false 时只锁 UUID,major/minor/txPower 模糊匹配
export function buildMask(lockMajorMinor: boolean): ArrayBuffer {
const mask: Uint8Array = new Uint8Array(23).fill(0xFF);
if (!lockMajorMinor) {
mask.fill(0x00, 18);
}
return mask.buffer;
}
2. 注册与清理 beacon 围栏(含支持探测与错误码分支)
import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError } from '@kit.BasicServicesKit';
const APPLE_COMPANY_ID: number = 0x004C;
const TX_POWER: number = -59;
export class BeaconFenceService {
private fenceIds: number[] = [];
async register(identifier: string, uuid: number[], major: number, minor: number): Promise<number | undefined> {
if (!geoLocationManager.isBeaconFenceSupported()) {
console.warn('this device does not support beacon fence');
return undefined;
}
if (this.fenceIds.length >= 10) { // 单应用上限 10
await this.clearAll();
}
const beacon: geoLocationManager.BeaconFence = {
identifier: identifier,
beaconFenceInfoType: geoLocationManager.BeaconFenceInfoType.BEACON_MANUFACTURE_DATA,
manufactureData: {
manufactureId: APPLE_COMPANY_ID,
manufactureData: buildIBeaconValue(uuid, major, minor, TX_POWER),
manufactureDataMask: buildMask(false)
}
};
const request: geoLocationManager.BeaconFenceRequest = {
beacon: beacon,
transitionCallback: (t: geoLocationManager.GeofenceTransition) => {
this.onTransition(t);
},
fenceExtensionAbilityName: 'MyFenceExtensionAbility' // 后台通知必须给
};
try {
const id: number = await geoLocationManager.addBeaconFence(request);
this.fenceIds.push(id);
console.info(`addBeaconFence ok, id=${id}`);
return id;
} catch (e) {
const err = e as BusinessError;
console.error(`addBeaconFence failed, code=${err.code}, msg=${err.message}`);
return undefined;
}
}
private onTransition(t: geoLocationManager.GeofenceTransition): void {
if (t.transitionEvent === geoLocationManager.GeofenceTransitionEvent.GEOFENCE_TRANSITION_EVENT_ENTER) {
console.info(`enter fence ${t.geofenceId}`);
} else if (t.transitionEvent === geoLocationManager.GeofenceTransitionEvent.GEOFENCE_TRANSITION_EVENT_EXIT) {
console.info(`exit fence ${t.geofenceId}`);
}
}
async clearAll(): Promise<void> {
try {
await geoLocationManager.removeBeaconFence(); // 不传参 = 删除该应用全部围栏
this.fenceIds = [];
} catch (e) {
const err = e as BusinessError;
console.error(`removeBeaconFence failed, code=${err.code}, msg=${err.message}`);
}
}
}
对应的 module.json5 片段:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:location_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:location_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
],
"extensionAbilities": [
{
"name": "MyFenceExtensionAbility",
"srcEntry": "./ets/fenceextensionability/MyFenceExtensionAbility.ets",
"description": "beacon fence event receiver",
"type": "fence",
"exported": false
}
]
}
}
3. 后台事件入口:FenceExtensionAbility
import { FenceExtensionAbility, geoLocationManager } from '@kit.LocationKit';
import { notificationManager } from '@kit.NotificationKit';
import { Want, wantAgent } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
export class MyFenceExtensionAbility extends FenceExtensionAbility {
onFenceStatusChange(transition: geoLocationManager.GeofenceTransition,
additions: Record<string, string>): void {
const isEnter: boolean =
transition.transitionEvent === geoLocationManager.GeofenceTransitionEvent.GEOFENCE_TRANSITION_EVENT_ENTER;
console.info(`fence event id=${transition.geofenceId} event=${transition.transitionEvent}`);
// 不能弹 UI、不能直接做上传下载:只能 wantAgent 拉起 + 本地通知
const info: wantAgent.WantAgentInfo = {
wants: [{
bundleName: 'com.example.beaconfence',
abilityName: 'EntryAbility',
parameters: {
'geofenceId': transition.geofenceId,
'transitionEvent': transition.transitionEvent
}
} as Want],
actionType: wantAgent.OperationType.START_ABILITY,
requestCode: 100
};
wantAgent.getWantAgent(info).then((agent: wantAgent.WantAgent) => {
const req: notificationManager.NotificationRequest = {
id: transition.geofenceId,
content: {
notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: isEnter ? '已进入围栏' : '已离开围栏',
text: `fence=${transition.geofenceId}`
}
},
notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
wantAgent: agent
};
notificationManager.publish(req).catch((err: BusinessError) => {
console.error(`publish failed, code=${err.code}, msg=${err.message}`);
});
}).catch((err: BusinessError) => {
console.error(`getWantAgent failed, code=${err.code}, msg=${err.message}`);
});
}
onDestroy(): void {
console.info('MyFenceExtensionAbility destroyed');
}
}
4. 路线 B:围栏上报模式 + 厂商数据过滤 + AD 解析
import { ble } from '@kit.ConnectivityKit';
import { BusinessError } from '@kit.BasicServicesKit';
const APPLE_COMPANY_ID: number = 0x004C;
// 只锁厂商 ID 与 0x02 0x15 两字节,minor 不同也能命中
const FENCE_FILTER: ble.ScanFilter = {
manufactureId: APPLE_COMPANY_ID,
manufactureData: new Uint8Array([0x02, 0x15]).buffer,
manufactureDataMask: new Uint8Array([0xFF, 0xFF]).buffer
};
const FENCE_OPTIONS: ble.ScanOptions = {
reportMode: ble.ScanReportMode.FENCE_SENSITIVITY_LOW, // API 18+
dutyMode: ble.ScanDuty.SCAN_MODE_LOW_POWER,
matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE
};
interface BeaconId {
major: number;
minor: number;
}
function parseIBeacon(data: ArrayBuffer): BeaconId | undefined {
const d: Uint8Array = new Uint8Array(data);
let i: number = 0;
while (i + 1 < d.length) {
const len: number = d[i];
if (len === 0) {
break;
}
const type: number = d[i + 1];
// 0xFF = 厂商自定义;len 26 表示 iBeacon 的完整数据段;公司 ID 0x004C 小端序为 4C 00
if (type === 0xFF && len >= 26 && i + 26 < d.length && d[i + 2] === 0x4C && d[i + 3] === 0x00) {
const base: number = i + 4; // base 指向 0x02
return {
major: (d[base + 18] << 8) | d[base + 19],
minor: (d[base + 20] << 8) | d[base + 21]
};
}
i += len + 1;
}
return undefined;
}
function onDeviceFound(results: Array<ble.ScanResult>): void {
for (const r of results) {
const id: BeaconId | undefined = parseIBeacon(r.data);
if (id !== undefined) {
console.info(`beacon major=${id.major} minor=${id.minor} rssi=${r.rssi}`);
}
}
}
export function startFenceScan(): void {
try {
ble.on('BLEDeviceFind', onDeviceFound);
ble.startBLEScan([FENCE_FILTER], FENCE_OPTIONS);
} catch (e) {
const err = e as BusinessError;
console.error(`startBLEScan failed, code=${err.code}, msg=${err.message}`);
}
}
export function stopFenceScan(): void {
try {
ble.stopBLEScan();
ble.off('BLEDeviceFind', onDeviceFound);
} catch (e) {
const err = e as BusinessError;
console.error(`stopBLEScan failed, code=${err.code}, msg=${err.message}`);
}
}
5. 需要「停留时长」时再叠一个轻量状态机
当业务要求「在围栏内待够 30 秒才发券」这类逻辑时,FENCE_SENSITIVITY_* 只给你进/出两次事件,停留判定还得自己做。这一层保持极薄即可:
export class DwellTracker {
private enterAt: number = 0;
onEnter(now: number = Date.now()): void {
this.enterAt = now;
}
/** 返回本次在围栏内停留的毫秒数;未在围栏内时返回 0 */
onExit(now: number = Date.now()): number {
if (this.enterAt === 0) {
return 0;
}
const stayed: number = now - this.enterAt;
this.enterAt = 0;
return stayed;
}
}
注意 Date.now() 会被用户改系统时间影响;对计时有严格要求的场景,改用 getUptime(TimeType.STARTUP) 这类不受系统时间影响的来源。
总结
把选型收敛成三句话:
- 只要「进 / 出各一次」且设备是 API 20+ 手机/平板 → 用
geoLocationManager.addBeaconFence,配一个type: "fence"的 FenceExtensionAbility 承接后台事件,回调里只做两件事:wantAgent拉起 +notificationManager.publish。 - 已有扫描模块、想省电或多信标 → 把
ScanOptions.reportMode换成FENCE_SENSITIVITY_LOW(信号强)/FENCE_SENSITIVITY_HIGH(信号弱),进入/离开的去抖交给蓝牙子系统,同时用ScanFilter.manufactureId在扫描层过滤。 - 要停留时长、距离估算、多次触发 → 在 1 或 2 之上叠一层薄状态机,不要在扫描回调里写复杂逻辑。
落地前建议按这张清单自检一遍:
-
isBeaconFenceSupported()返回 true 吗?false 就走路线 B。 -
LOCATION与APPROXIMATELY_LOCATION两个权限都申请且用户已授权? -
manufactureData是 23 字节、且不含公司 ID?mask 前 18 字节0xFF、后 5 字节0x00? - 蓝牙开关、定位开关都打开了吗(3501101 / 3501100)?
- 围栏数量没超过 10,退出页面时调用了
removeBeaconFence? -
module.json5里type写的是"fence",且扩展能力没有引用被禁用的模块? - 信标身份用的是 UUID+major+minor,而不是
deviceId? - 分清 ENTER=1、EXIT=2、DWELL=4,没有把 3 当成停留?
Beacon 围栏的难点从来不在算法,而在「配置的字节对不对」和「事件走哪条链路才能活到后台」。把这两件事定死,剩下的业务逻辑其实很薄。
更多推荐



所有评论(0)