“益康养老”HarmonyOS APP门禁设备模块开发:蓝牙设备搜索与扫描动画实现
·
本文基于益康养老(文档中亦作“翼康养老”)物联网项目,详细阐述HarmonyOS APP中门禁设备模块的核心开发流程,聚焦于低功耗蓝牙(BLE)设备搜索与流畅扫描动画的实现。内容严格遵循鸿蒙官方开发指南与三层架构设计规范,旨在为养老护理场景下的智能设备连接提供可靠、直观的交互方案。
1. 业务背景与核心需求分析
业务背景:在养老院场景下,护理员需通过APP快速绑定并控制门禁设备,以实现高效、安全的出入管理。这要求APP具备在复杂环境中精准、快速发现特定门禁设备,并通过直观的视觉反馈引导用户操作的能力。
核心需求分解:
- 精准过滤:从众多蓝牙设备中筛选出以 “JL-ITHEIMA” 开头的专属门禁设备。
- 实时响应:搜索状态、结果列表需即时反映在用户界面上。
- 友好引导:在搜索过程中提供流畅的动画,缓解用户等待焦虑,提升体验。
- 权限与安全:妥善处理蓝牙权限申请,确保操作合规。
2. 模块架构设计与实现路径
门禁设备搜索功能深度集成于项目的features/device业务模块(HSP动态共享包)中。其实现依赖于公共能力层(common/basic HAR包)提供的网络、日志、组件等基础能力,并遵循清晰的逻辑流。
3. 核心代码实现:蓝牙设备搜索
3.1 权限声明与模块依赖
首先,在module.json5配置文件中声明必要的蓝牙权限,这是功能实现的前提。
// features/device/src/main/module.json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.ACCESS_BLUETOOTH",
"reason": "$string:bluetooth_permission_reason",
"usedScene": {
"abilities": ["DeviceAbility"],
"when": "always"
}
},
{
"name": "ohos.permission.DISCOVER_BLUETOOTH",
"reason": "$string:discover_bluetooth_reason",
"usedScene": {
"abilities": ["DeviceAbility"],
"when": "always"
}
}
]
}
}
在业务模块的oh-package.json5中,需引入蓝牙和公共基础模块的依赖。
// features/device/oh-package.json5
{
"dependencies": {
"@elderly/basic": "file:../../common/basic", // 公共工具、日志
"@ohos/bluetooth": "^2.0.0" // 鸿蒙蓝牙Kit
}
}
3.2 蓝牙设备管理类封装
在BluetoothManager.ets中封装核心搜索逻辑,遵循单一职责原则,便于复用和维护。
// features/device/src/main/ets/bluetooth/BluetoothManager.ets
import { ble } from '@ohos.bluetooth';
import { Logger } from '@elderly/basic'; // 从基础HAR引入日志工具
export class BluetoothManager {
private scanCallback: (device: Array<ScanResult>) => void;
private isScanning: boolean = false;
public static readonly TARGET_DEVICE_PREFIX: string = 'JL-ITHEIMA';
// 单例模式,确保全局唯一管理
private static instance: BluetoothManager;
public static getInstance(): BluetoothManager {
if (!this.instance) {
this.instance = new BluetoothManager();
}
return this.instance;
}
/**
* 开始扫描门禁设备
* @param onDeviceFound 发现设备时的回调函数
* @param duration 扫描时长(毫秒),默认10秒
*/
public async startScan(
onDeviceFound: (device: Array<AccessControlDevice>) => void,
duration: number = 10000
): Promise<void> {
if (this.isScanning) {
Logger.warn('BluetoothManager', 'Scan is already in progress.');
return;
}
// 1. 检查并申请权限(此处简化,实际需完整流程)
// 2. 开始扫描
try {
this.isScanning = true;
Logger.info('BluetoothManager', 'Starting BLE scan for access control devices...');
// 注册蓝牙扫描回调
ble.startBLEScan({
interval: 0, // 立即开始
dutyMode: ble.ScanDuty.SCAN_DUTY_LOW_POWER, // 低功耗模式
matchMode: ble.MatchMode.MATCH_MODE_AGGRESSIVE // 积极匹配
});
// 监听设备发现事件(此为示意,实际API调用方式请参考最新官方文档)
// 伪代码逻辑:当系统发现设备时,会触发此回调
this.scanCallback = (scanResults: Array<ScanResult>) => {
const filteredDevices = this.filterDevices(scanResults);
if (filteredDevices.length > 0) {
onDeviceFound(filteredDevices);
}
};
// 设置扫描超时
setTimeout(() => {
this.stopScan();
}, duration);
} catch (error) {
Logger.error('BluetoothManager', `Start scan failed: ${error.message}`);
this.isScanning = false;
throw new Error(`蓝牙扫描启动失败: ${error.code}`);
}
}
/**
* 停止扫描
*/
public stopScan(): void {
if (!this.isScanning) {
return;
}
try {
ble.stopBLEScan();
Logger.info('BluetoothManager', 'BLE scan stopped.');
} catch (error) {
Logger.error('BluetoothManager', `Stop scan failed: ${error.message}`);
} finally {
this.isScanning = false;
// 清理回调
this.scanCallback = undefined;
}
}
/**
* 过滤出门禁设备
* @param scanResults 原始扫描结果
* @returns 过滤后的门禁设备列表
*/
private filterDevices(scanResults: Array<ScanResult>): Array<AccessControlDevice> {
return scanResults
.filter(result =>
result.deviceName && result.deviceName.startsWith(BluetoothManager.TARGET_DEVICE_PREFIX)
)
.map(result => ({
deviceId: result.deviceId,
deviceName: result.deviceName,
rssi: result.rssi,
// 可根据需要添加其他字段,如广播数据
}));
}
}
// 设备数据类型定义
export interface AccessControlDevice {
deviceId: string;
deviceName: string;
rssi: number;
}
4. 流畅扫描动画的实现
动画是提升用户体验的关键。我们采用ArkUI的声明式动画API,实现一个平滑的旋转加载动画。
4.1 可复用动画组件
在common/basic公共HAR包中创建可复用的加载动画组件ScanningAnimation.ets。
// common/basic/src/main/ets/components/ScanningAnimation.ets
@Component
export struct ScanningAnimation {
@State private rotateAngle: number = 0;
private animationTimer: number | undefined;
// 控制动画启停
public controlAnimation(start: boolean): void {
if (start && !this.animationTimer) {
this.startAnimation();
} else if (!start && this.animationTimer) {
this.stopAnimation();
}
}
private startAnimation(): void {
// 实现每秒60帧(约16.7ms/帧),每次旋转5度的流畅动画
const FRAME_DURATION: number = 16.666;
const ANGLE_PER_FRAME: number = 5;
this.animationTimer = setInterval(() => {
this.rotateAngle = (this.rotateAngle + ANGLE_PER_FRAME) % 360;
}, FRAME_DURATION);
}
private stopAnimation(): void {
if (this.animationTimer) {
clearInterval(this.animationTimer);
this.animationTimer = undefined;
}
}
// 组件销毁时清理资源
aboutToDisappear(): void {
this.stopAnimation();
}
@Builder
BuildAnimationContent() {
Column({ space: 10 }) {
// 外层脉冲圆环
Stack({ alignContent: Alignment.Center }) {
Circle({ width: 80, height: 80 })
.fill(Color.Transparent)
.strokeWidth(3)
.stroke('#409EFF')
.opacity(this.getPulseOpacity())
// 中心旋转图标
Image($r('app.media.ic_bluetooth_search')) // 资源需预先放入media目录
.width(40)
.height(40)
.rotate({ angle: this.rotateAngle, centerX: '50%', centerY: '50%' })
}
Text('正在搜索门禁设备...')
.fontSize(14)
.fontColor('#606266')
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
// 计算脉冲透明度,增加动态感
@AnimatableExtend(Text) function getPulseOpacity(): number {
// 使用内置animateTo或自定义插值计算
// 此处为简化示例,实际可结合时间函数实现
return 0.6 + 0.4 * Math.sin(Date.now() / 500); // 500ms周期
}
build() {
this.BuildAnimationContent()
}
}
4.2 在搜索页面集成动画与逻辑
在设备搜索页面AccessControlScanPage.ets中,整合蓝牙搜索与动画控制。
// features/device/src/main/ets/pages/AccessControlScanPage.ets
import { BluetoothManager, AccessControlDevice } from '../bluetooth/BluetoothManager';
import { ScanningAnimation } from '@elderly/basic';
import { Router } from '@elderly/basic';
@Component
export struct AccessControlScanPage {
@State deviceList: Array<AccessControlDevice> = [];
@State isScanning: boolean = false;
@State scanStatusText: string = '点击开始搜索';
private bluetoothManager: BluetoothManager = BluetoothManager.getInstance();
private scanningAnimation: ScanningAnimation = new ScanningAnimation();
// 开始搜索
private async startSearching(): Promise<void> {
if (this.isScanning) {
return;
}
this.deviceList = [];
this.isScanning = true;
this.scanStatusText = '正在搜索...';
this.scanningAnimation.controlAnimation(true); // 启动动画
try {
await this.bluetoothManager.startScan((devices: Array<AccessControlDevice>) => {
// 去重并更新列表
this.updateDeviceList(devices);
this.scanStatusText = `已发现 ${this.deviceList.length} 个设备`;
}, 15000); // 扫描15秒
// 扫描结束后更新状态
setTimeout(() => {
if (this.deviceList.length === 0) {
this.scanStatusText = '未发现门禁设备,请重试';
}
this.isScanning = false;
this.scanningAnimation.controlAnimation(false);
}, 15000);
} catch (error) {
Logger.error('AccessControlScanPage', `搜索出错: ${error.message}`);
this.scanStatusText = '搜索失败,请检查蓝牙权限和开关';
this.isScanning = false;
this.scanningAnimation.controlAnimation(false);
}
}
// 更新设备列表(去重逻辑)
private updateDeviceList(newDevices: Array<AccessControlDevice>): void {
const existingIds = new Set(this.deviceList.map(d => d.deviceId));
const uniqueNewDevices = newDevices.filter(d => !existingIds.has(d.deviceId));
this.deviceList = this.deviceList.concat(uniqueNewDevices);
}
// 停止搜索
private stopSearching(): void {
this.bluetoothManager.stopScan();
this.isScanning = false;
this.scanningAnimation.controlAnimation(false);
this.scanStatusText = '已停止';
}
// 连接选中的设备(后续流程)
private connectToDevice(device: AccessControlDevice): void {
Logger.info('AccessControlScanPage', `尝试连接设备: ${device.deviceName}`);
this.stopSearching();
// 跳转到设备连接/配对页面,并传递设备信息
Router.navigateTo({ url: 'pages/DeviceConnectPage', params: { device } });
}
build() {
Column({ space: 20 }) {
// 1. 动画展示区域
Column() {
this.scanningAnimation.BuildAnimationContent()
}
.height(200)
.width('100%')
.justifyContent(FlexAlign.Center)
// 2. 状态与按钮区域
Text(this.scanStatusText)
.fontSize(16)
.fontColor(this.isScanning ? '#409EFF' : '#909399')
Row({ space: 30 }) {
Button(this.isScanning ? '停止搜索' : '开始搜索')
.width(120)
.backgroundColor(this.isScanning ? '#F56C6C' : '#409EFF')
.fontColor(Color.White)
.onClick(() => {
if (this.isScanning) {
this.stopSearching();
} else {
this.startSearching();
}
})
Button('手动刷新')
.width(120)
.type(ButtonType.Normal)
.enabled(!this.isScanning)
.onClick(() => this.startSearching())
}
// 3. 设备列表区域
if (this.deviceList.length > 0) {
List({ space: 10 }) {
ForEach(this.deviceList, (device: AccessControlDevice) => {
ListItem() {
DeviceItemCard({ device: device, onConnect: () => this.connectToDevice(device) })
}
}, (device: AccessControlDevice) => device.deviceId)
}
.layoutWeight(1)
.width('100%')
} else if (!this.isScanning) {
Text('暂无设备,请确保门禁设备已开启并在范围内。')
.fontSize(14)
.fontColor('#C0C4CC')
.margin({ top: 50 })
}
}
.padding(20)
.width('100%')
.height('100%')
.backgroundColor('#F5F7FA')
}
}
5. 效果对比与最佳实践
实现效果对比:
| 特性 | 基础实现 (仅列表刷新) | 优化实现 (本文方案) |
|---|---|---|
| 用户感知 | 静态等待,易产生卡顿感 | 动态动画,明确提示搜索进行中 |
| 状态反馈 | 仅最终结果 | 实时状态文本,设备发现即时通知 |
| 性能影响 | 主线程阻塞可能导致UI不更新 | 异步操作 + State响应式更新,UI流畅 |
| 代码维护 | 逻辑与UI耦合度高 | 模块化、组件化,职责清晰,易于调试和复用 |
核心最佳实践总结:
- 权限先行:在调用任何蓝牙API前,务必在配置文件中声明并在运行时动态申请权限。
- 资源管理:扫描是耗电操作,务必在页面离开(
aboutToDisappear)或不需要时及时调用stopScan(),并清理定时器。 - 错误兜底:网络、蓝牙异常情况必须捕获,并通过友好文案(非技术术语)提示用户。
- 体验优先:即使扫描未立即发现设备,动画和状态提示也能让用户感知到应用在工作,避免误以为“无响应”。
- 符合规范:所有组件、接口命名需遵循ArkTS/鸿蒙开发规范,动画实现优先使用系统提供的隐式或属性动画API以获得最佳性能。
更多推荐


所有评论(0)