本文基于益康养老(文档中亦作“翼康养老”)物联网项目,详细阐述HarmonyOS APP中门禁设备模块的核心开发流程,聚焦于低功耗蓝牙(BLE)设备搜索流畅扫描动画的实现。内容严格遵循鸿蒙官方开发指南与三层架构设计规范,旨在为养老护理场景下的智能设备连接提供可靠、直观的交互方案。

1. 业务背景与核心需求分析

业务背景:在养老院场景下,护理员需通过APP快速绑定并控制门禁设备,以实现高效、安全的出入管理。这要求APP具备在复杂环境中精准、快速发现特定门禁设备,并通过直观的视觉反馈引导用户操作的能力。

核心需求分解

  • 精准过滤:从众多蓝牙设备中筛选出以 “JL-ITHEIMA” 开头的专属门禁设备。
  • 实时响应:搜索状态、结果列表需即时反映在用户界面上。
  • 友好引导:在搜索过程中提供流畅的动画,缓解用户等待焦虑,提升体验。
  • 权限与安全:妥善处理蓝牙权限申请,确保操作合规。

2. 模块架构设计与实现路径

门禁设备搜索功能深度集成于项目的features/device业务模块(HSP动态共享包)中。其实现依赖于公共能力层(common/basic HAR包)提供的网络、日志、组件等基础能力,并遵循清晰的逻辑流。

鸿蒙系统蓝牙能力 BluetoothManager 页面ViewModel 用户界面 鸿蒙系统蓝牙能力 BluetoothManager 页面ViewModel 用户界面 1. 检查/申请权限 2. 启动扫描动画 loop [设备过滤] 持续扫描... 停止扫描动画 点击“搜索设备” 调用 startScan() 调用 BLE 扫描 API 实时回调发现设备 筛选设备名称为 “JL-ITHEIMA*” 更新设备列表 (State变量) 界面刷新 (ArkUI声明式UI) 点击“停止”或超时 调用 stopScan() 停止扫描

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耦合度高 模块化、组件化,职责清晰,易于调试和复用

核心最佳实践总结

  1. 权限先行:在调用任何蓝牙API前,务必在配置文件中声明并在运行时动态申请权限。
  2. 资源管理:扫描是耗电操作,务必在页面离开(aboutToDisappear)或不需要时及时调用stopScan(),并清理定时器。
  3. 错误兜底:网络、蓝牙异常情况必须捕获,并通过友好文案(非技术术语)提示用户。
  4. 体验优先:即使扫描未立即发现设备,动画和状态提示也能让用户感知到应用在工作,避免误以为“无响应”。
  5. 符合规范:所有组件、接口命名需遵循ArkTS/鸿蒙开发规范,动画实现优先使用系统提供的隐式或属性动画API以获得最佳性能。
Logo

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

更多推荐