鸿蒙 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,询问一下各位大佬这个要如何实现这种围栏的效果。

需求本身很简单,但它背后藏着四个常见的失败路径,几乎每个刚接触的人都会踩到其中一个:

  1. 把「蓝牙连接 / 断开」当成进入 / 离开事件。Beacon 是靠广播工作的,围栏判断只需要收广播,不需要建连;很多 iBeacon 甚至不可连接,于是「连接失败」被误读成「离开了围栏」。
  2. 用单次 RSSI 阈值判断进出。实测里同一位置的 RSSI 会在 -65 → -78 → -82 → -70 之间跳,单阈值必然反复触发。
  3. 拿 ScanResult.deviceId 当信标身份持久化。未在过滤器里指定真实 MAC 时,拿到的是虚拟 MAC,设备重启蓝牙开关后会变,服务端记下的「同一个信标」第二天就对不上了。
  4. 在围栏回调里直接干重活。想弹 UI、想直接发网络请求,结果发现围栏扩展能力那边一引用就崩——官方对它能引用的模块有明确白名单限制。

还有一个前置误解值得先说清:华为生态里叫 BeaconBarrier、Awareness.getBarrierClient() 的那套是 HMS Core 情景感知服务(Android),不能照搬到纯血鸿蒙的 ArkTS 工程;而 FenceExtensionAbility 虽然名字里也有 Fence,但它服务的是位置围栏事件这一类能力,既承接 GNSS 地理围栏,也承接 API 20 起的 beacon 围栏。

细节解析

一、三条路线的定位

路线能力来源起始版本谁做去抖适合场景
A. 系统 beacon 围栏geoLocationManager.addBeaconFenceAPI 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/minor23 字节全 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_ENTER1从围栏外进入围栏内
GEOFENCE_TRANSITION_EVENT_EXIT2从围栏内退出到围栏外
GEOFENCE_TRANSITION_EVENT_DWELL4在围栏内持续徘徊超过 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——它有一个专门为围栏场景准备的模式:

枚举值说明(接口页语义)
NORMAL1常规模式,扫到符合过滤条件的广播立即上报
BATCH2批量模式,缓存 interval 后上报,显著降功耗
FENCE_SENSITIVITY_LOW10只在广播进入或离开围栏时上报;首次扫到即进入围栏触发一次上报,一段时间扫不到即离开围栏触发一次上报;适合信号强、广播多的环境
FENCE_SENSITIVITY_HIGH11语义同上,适合信号弱、广播少的环境

这正是「进入 / 离开各一次回调」,而且去抖由蓝牙子系统完成,不需要自己写「连续 N 次超过阈值 + 超时判定消失」的状态机。改造量只是把扫描参数里的 reportMode 换个值。

三条硬约束必须记住:

  1. 围栏模式下 filters 不能为 null,接口页原句是「该参数不可设置为 null,需传入非空过滤器」。
  2. 过滤器资源是全应用共享的,单个应用建议不超过 3 个,占满后开启扫描会返回 2900009(out of hardware resources)。
  3. 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) 这类不受系统时间影响的来源。

总结

把选型收敛成三句话:

  1. 只要「进 / 出各一次」且设备是 API 20+ 手机/平板 → 用 geoLocationManager.addBeaconFence,配一个 type: "fence" 的 FenceExtensionAbility 承接后台事件,回调里只做两件事:wantAgent 拉起 + notificationManager.publish。
  2. 已有扫描模块、想省电或多信标 → 把 ScanOptions.reportMode 换成 FENCE_SENSITIVITY_LOW(信号强)/ FENCE_SENSITIVITY_HIGH(信号弱),进入/离开的去抖交给蓝牙子系统,同时用 ScanFilter.manufactureId 在扫描层过滤。
  3. 要停留时长、距离估算、多次触发 → 在 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 围栏的难点从来不在算法,而在「配置的字节对不对」和「事件走哪条链路才能活到后台」。把这两件事定死,剩下的业务逻辑其实很薄。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐