蓝牙接入(第 1 讲):HarmonyOS 权限与蓝牙状态的准入分层【共创稿事节】
一个蓝牙接入页面最好能回答三个不同的问题:应用有没有获得访问许可,系统蓝牙当前是否可用,用户准备连接的外设是否存在。把三者压成一个“蓝牙异常”,后续扫描失败、连接超时和权限拒绝都会挤进同一条提示,工程人员也无法确定下一步该看哪里。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
};
}

授权结果数组的长度也参与判断,是为了让代码只接受与本次单项请求相匹配的结果。拒绝授权属于可解释的业务分支,返回“未获授权”;调用本身抛错则进入页面的异常提示。两类结果分开后,测试记录不会把用户选择拒绝误写成接口故障。
页面保存快照,不缓存永久资格
把下面的内容放入 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 的准入顺序,后续升级若删除某项权限检查,应同时检查监听接口和真实扫描接口的条件,避免首页查询能用,却在下一步突然报权限错误。
这套设计并未为后台连接预留隐藏入口。当前工程承诺前台用户操作,因此无需为了未来可能的功能先增加后台任务和额外权限。后面若扩展到持续采集,应重新定义用户提示、运行范围和资源退出策略,而不是把前台代码移进一个永不结束的计时器。先收紧使用范围,能够让前六篇的连接契约保持可读和可复核。
参考资料
更多推荐

所有评论(0)