一个蓝牙接入页面最好能回答三个不同的问题:应用有没有获得访问许可,系统蓝牙当前是否可用,用户准备连接的外设是否存在。把三者压成一个“蓝牙异常”,后续扫描失败、连接超时和权限拒绝都会挤进同一条提示,工程人员也无法确定下一步该看哪里。BleWorkbench 从这三个问题的分层开始,只在用户主动操作后使用蓝牙,先完成准入检查,再逐步加入广播发现、GATT 服务和数据通知。

蓝牙接入的权限与开关分层

准入结果需要表达多少事实

这个系列使用 Stage 模型、ArkTS 和 API 12。蓝牙接口从 ConnectivityKit 导入,权限请求从 AbilityKit 导入;工程中的 EntryAbility 与默认 pages/Index 保持模板关系。页面暂时不接触扫描器,也不创建 GATT 客户端,所以首篇完成后能够明确说明接入条件,却不会偷偷搜集周边设备信息。

准入结果拆成权限文字、蓝牙状态和是否可以继续三个字段。权限成功不意味着开关已经打开,开关打开也不意味着某个外设可连接。布尔值用于控制下一步入口,两段文字用于解释原因;任何地方都不应该通过比较中文提示来决定是否开始扫描,否则修改文案就可能改变程序行为。

API 12 的访问条件应以对应接口说明核对。当前官方的 access 参考明确标出 getState 在 API 10 至 12 的权限要求,因此这里先申请 ACCESS_BLUETOOTH,再读取开关。不要因为新版本文档已经放宽部分查询条件,就把当前工程的权限代码直接删掉。接口“从某版本支持元服务”也不是该接口首次出现的版本,二者描述的范围不同。

状态枚举还包括正在开启、正在关闭以及其他状态。页面只有在收到 STATE_ON 时才认为蓝牙已准备好,过渡状态展示为等待条件。对于这个前台实验工具,系统设置负责打开蓝牙,应用负责说明状态;不在权限按钮里同时改变系统开关,可以让用户知道自己刚刚同意的究竟是什么。

准入函数还刻意不返回设备地址。地址属于后续发现过程,是否接受某个外设属于连接决策;让权限层同时夹带默认目标,会使按钮看起来只是检查,实际却隐含了连接动作。页面上每一种能触发无线通信的行为都应有清楚名称,而不是把授权、扫描和连接放进一个看似无害的初始化函数。这个分工也让代码审查能够逐层确认操作目的。

UIAbility 的上下文应来自当前页面所属宿主,而不是把任意 Context 强行转换后长期存储。权限弹窗需要正确的界面承载关系,公共模块因此只在调用期间接收上下文,不把它保存到静态字段。等到后续连接拥有者出现时,它管理的是蓝牙资源与普通数据,也不需要因此持有整个页面或 Ability 实例。

配置与授权放在明确入口

在 DevEco Studio 创建 Empty Ability 工程,命名为 BleWorkbench。把权限声明合并到 entry 模块的 module.json5 中,不要再包一层新的 module,也不要覆盖模板原有的 abilities。理由使用资源字符串,使用场景指向实际的 EntryAbility,只有需要连接自有测试外设时才调用检查按钮。

// entry/src/main/module.json5:合并到 module 对象内
"requestPermissions": [
  {
    "name": "ohos.permission.ACCESS_BLUETOOTH",
    "reason": "$string:ble_reason",
    "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
  }
]
// entry/src/main/resources/base/element/string.json:加入 string 数组
{ "name": "ble_reason", "value": "用于发现并连接您选择的蓝牙测试设备。" }

这里的资源条目放入现有 string 数组,字段名必须与权限理由一致。权限理由应解释用户能理解的用途,而不是写“应用运行所需”。前台场景和权限声明是工程约束,不能被当成整个生命周期已经自动清理蓝牙工作的保证;扫描和连接的关闭责任,会在后续组件中分别落实。

用户授权指导区分声明与运行时申请。声明使系统知道应用需要哪项能力,运行时申请让用户在具体上下文中作出决定。重复点击不能并发弹出多个申请流程,拒绝授权后也不能用定时器不断重试。页面可以保留再次检查入口,但是否再次触发始终由用户决定。

准入函数只负责一次请求和一次读取,返回一个普通对象,不保存页面引用。这样后续扫描页面也能使用相同规则,而不需要复制申请权限的分支。函数没有吞掉接口异常,调用方仍然能够把参数、权限或者系统服务错误与正常的拒绝结果分开处理。

// common/BleGate.ets
import { abilityAccessCtrl, common } from '@kit.AbilityKit';
import { access } from '@kit.ConnectivityKit';

export interface GateResult {
  permission: string;
  radio: string;
  ready: boolean;
}

export function radioText(state: access.BluetoothState): string {
  switch (state) {
    case access.BluetoothState.STATE_ON: return '已开启';
    case access.BluetoothState.STATE_OFF: return '已关闭';
    case access.BluetoothState.STATE_TURNING_ON: return '正在开启';
    case access.BluetoothState.STATE_TURNING_OFF: return '正在关闭';
    default: return '暂不可用';
  }
}

export async function ensureBle(context: common.UIAbilityContext): Promise<GateResult> {
  const result = await abilityAccessCtrl.createAtManager()
    .requestPermissionsFromUser(context, ['ohos.permission.ACCESS_BLUETOOTH']);
  const granted = result.authResults.length === 1 && result.authResults[0] === 0;
  if (!granted) {
    return { permission: '未获授权', radio: '未读取', ready: false };
  }
  const state = access.getState();
  return {
    permission: '已授权',
    radio: radioText(state),
    ready: state === access.BluetoothState.STATE_ON
  };
}

DevEco Studio 中的准入函数与连接前检查页面

授权结果数组的长度也参与判断,是为了让代码只接受与本次单项请求相匹配的结果。拒绝授权属于可解释的业务分支,返回“未获授权”;调用本身抛错则进入页面的异常提示。两类结果分开后,测试记录不会把用户选择拒绝误写成接口故障。

页面保存快照,不缓存永久资格

把下面的内容放入 pages/Index.ets。两个信息卡片显示当前检查结果,按钮只启动检查,底部说明给出设备条件。开始操作前显示“未检查”和“未读取”,这是合法的初始状态;把它们直接初始化成“已授权”和“已开启”,会掩盖真正需要运行时确认的部分。

// pages/Index.ets
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ensureBle } from '../common/BleGate';

@Entry
@Component
struct Index {
  @State permission: string = '未检查';
  @State radio: string = '未读取';
  @State status: string = '尚未检查';
  @State busy: boolean = false;
  private alive: boolean = true;
  private serial: number = 0;

  aboutToDisappear(): void { this.alive = false; this.serial++; }
  onPageHide(): void { this.serial++; }
  onPageShow(): void { this.busy = false; }

  private async check(): Promise<void> {
    if (this.busy) return;
    const ticket = ++this.serial;
    this.busy = true;
    try {
      const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      const result = await ensureBle(context);
      if (!this.alive || ticket !== this.serial) return;
      this.permission = result.permission;
      this.radio = result.radio;
      this.status = result.ready ? '可以进入设备发现' : '条件尚未满足';
    } catch (error) {
      if (this.alive && ticket === this.serial) {
        this.status = `检查失败:${(error as BusinessError).code}`;
      }
    } finally {
      if (this.alive && ticket === this.serial) this.busy = false;
    }
  }

  @Builder
  item(label: string, value: string) {
    Row() {
      Text(label).fontSize(16).fontColor('#64748B')
      Blank()
      Text(value).fontSize(18).fontColor('#14304B')
    }.width('100%').padding(20).backgroundColor(Color.White).borderRadius(16)
  }

  build() {
    Column({ space: 16 }) {
      Text('连接前检查').fontSize(28).fontWeight(FontWeight.Bold).fontColor('#14304B')
      Text('权限、开关分开确认').fontSize(14).fontColor('#64748B')
      this.item('蓝牙权限', this.permission)
      this.item('蓝牙状态', this.radio)
      Button('检查并授权').width('100%').height(44).backgroundColor('#176B87')
        .enabled(!this.busy).onClick(() => { this.check(); })
      Text(this.status).fontSize(16).fontColor('#176B87')
      Text('授权后仍需设备支持蓝牙').fontSize(13).fontColor('#64748B')
    }.width('100%').height('100%').padding({ top: 48, left: 24, right: 24 })
      .backgroundColor('#F1F7FA')
  }
}

异步请求返回时,页面不一定还在原来的位置。序号用于区分一次检查是否仍然有权更新当前页面,普通 alive 字段表示组件是否已经销毁。页面隐藏时递增序号,返回页面后恢复按钮可用;已经失效的授权结果不会反过来把新页面标记为就绪。这里没有取消系统授权弹窗,取消的是旧结果对本页的更新资格。

销毁回调只修改普通字段,不写入状态变量。组件已经退出时,继续写忙碌标志没有展示价值,也可能违反组件生命周期要求。按钮触发的正常检查则由 finally 恢复忙碌状态;读取状态失败时,原有两项快照仍然可见,同时失败信息明确表明这次检查没有完成,不能把旧快照当成新结论。

准入结果不是一张永久通行证。用户可以在系统设置中修改权限,也可以从控制中心关闭蓝牙。后续每次发起真实扫描或连接之前仍会重新检查条件,接口调用本身也必须处理权限变化。保留这一层防线,比在首页缓存一个“曾经成功过”的布尔值可靠。

维护这份状态时还要区分“没有读取”与“读取后已关闭”。前者表示信息尚不存在,后者表示获得了一项确定的系统状态。把二者都映射成 false 会使界面在刚打开时就要求用户去打开蓝牙,而实际原因可能只是尚未授权。普通字符串在这个小页面中足够清楚;业务扩大后可以引入有明确定义的状态模型,但不能让一个布尔值承担所有含义。

错误处理也不应把所有分支吞成同一个返回对象。授权拒绝具有正常结果,接口抛错具有异常来源,两者保留区别之后,自动化验证才能分别断言。比如配置漏掉权限理由,与用户在弹窗中点击拒绝,虽然最终都不能继续扫描,修复位置却完全不同。前者需要检查工程,后者需要尊重选择,不能靠重复调用来解决。

按边界组织验证顺序

先检查静态配置:权限理由资源能够解析,abilities 中的名字与模板一致,入口页引用的是新建的公共模块。再在具备蓝牙能力的设备上进入页面,确认初始界面没有自动发起扫描,点击按钮才出现授权流程。拒绝授权时,权限项应显示未获授权,蓝牙项保持未读取,继续入口不会被判定为可用。

授权之后分别观察蓝牙关闭、正在切换和已经开启的情况。状态文字来自枚举映射,不依赖固定等待秒数。若系统调用抛出错误,保留错误码用于对照官方说明,不把所有错误都解释成“设备不支持”。权限错误、服务状态错误和能力缺失需要不同的操作路径,开发时可以记录错误来源,正式页面避免堆满底层报错细节。

还要检查重复点击与退出过程:连续触发按钮只能存在一次有效检查;打开授权流程后返回或离开当前页面,迟到结果不会更新已经离开的组件;重新进入后仍能由用户再次检查。此处的验证目标是控制流程和结果归属,不是测量蓝牙耗时,也没有必要拿一组人为等待数字当作设备性能指标。

权限、开关与扫描连接的分层关系

工程上可以把准入层理解为一份短期快照:它描述“现在具备发起操作的条件”,不保证接下来一定找到设备或建立连接。下一篇把扫描放到独立拥有者中,限定广播过滤条件和扫描时长;第三篇再处理连接请求与连接事实。保持这三个边界,后面的故障信息才会指出真正失败的阶段。

最后保留一个容易忽略的检查:没有测试外设时,这一篇仍然可以验证权限与系统状态,不需要制造一条假的设备记录凑出成功界面。没有外设是接入环境的事实,只有扫描阶段开始以后才影响设备列表。把环境条件放在正确位置,最小工程就不会依赖偶然遇到的周边设备才能解释自己的行为。

版本升级时建议维护一张简短的接口对照表:工程最低支持版本、编译所用 SDK、该方法的首次公开版本、当前版本需要的权限。它们回答不同问题,不能互相替代。本文保留 API 12 的准入顺序,后续升级若删除某项权限检查,应同时检查监听接口和真实扫描接口的条件,避免首页查询能用,却在下一步突然报权限错误。

这套设计并未为后台连接预留隐藏入口。当前工程承诺前台用户操作,因此无需为了未来可能的功能先增加后台任务和额外权限。后面若扩展到持续采集,应重新定义用户提示、运行范围和资源退出策略,而不是把前台代码移进一个永不结束的计时器。先收紧使用范围,能够让前六篇的连接契约保持可读和可复核。

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐