《HarmonyOS技术精讲-蓝牙》综合实战:构建智能家居控制应用

HarmonyOS技术精讲:从零构建智能家居蓝牙控制应用

HarmonyOS NEXT 开发中,蓝牙相关 API 经常被同时使用,但开发者容易混淆。一个是传统蓝牙(Classic Bluetooth),用于高带宽场景如音频传输;另一个是低功耗蓝牙(BLE),用于小数据量、低功耗的设备控制。很多人在实际开发中会面临一个典型问题:如何在一个应用里同时管理两种蓝牙协议,并处理它们复杂的重连和异常逻辑? 蓝牙音箱的播放和智能灯的控制看似是两个独立功能,但在一个智能家居场景里,它们需要协同工作。比如,检测到温度过高时,自动播放报警提示音。

这个功能本身不复杂,真正的难点在于生命周期管理状态同步 以及异常重连。官方示例大多只演示了单一功能的连接和通信,没有解释在真实项目中,当页面销毁、蓝牙连接断开、权限变化时,如何保证整个系统稳定运行。

本文目标很明确:通过一个完整的智能家居控制 Demo,体验如何在一个应用中融合传统蓝牙和 BLE,实现音箱播报和灯控、温度采集的联动。代码会完整贴出,并解释每一步的设计取舍。

它解决什么问题

蓝牙 API 主要分为两类场景:

场景 协议 特点 适合场景 不适合场景
音频播放 传统蓝牙 (BR) 高带宽,支持 A2DP/HSP/HFP 播放音乐、通话、语音播报 低功耗、小数据量设备控制
设备控制 BLE 低功耗,支持 GATT 协议,服务与特征 智能开关、传感器、穿戴设备 需要持续高带宽音频流
高带宽数据 传统蓝牙 (SPP) 串口模拟,双向通信 数据传输、外设控制 低功耗、广播

在智能家居场景中,一套音响系统通常使用传统蓝牙(A2DP)播放音频;而智能灯和温度传感器则基于 BLE 的 GATT 服务进行读写和控制。

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板(需支持蓝牙)

核心实现

项目结构如下:

复制代码
SmartHomeDemo/
├── entry/
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── Service/
│   │   │   │   ├── BluetoothService.ets        // 蓝牙封装核心类
│   │   │   │   ├── SpeakerConnector.ets         // 音箱连接器(传统蓝牙)
│   │   │   │   └── LightConnector.ets           // 灯控连接器(BLE)
│   │   │   ├── pages/
│   │   │   │   └── Index.ets                    // 主页面
│   │   │   └── model/
│   │   │       └── DeviceInfo.ets               // 设备数据类型
│   │   └── resources/
│   │       └── base/
│   │           └── profile/
│   │               └── main_pages.json

1. 权限配置

module.json5 中添加蓝牙和位置权限:

json 复制代码
"requestPermissions": [
  {
    "name": "ohos.permission.USE_BLUETOOTH"
  },
  {
    "name": "ohos.permission.DISCOVER_BLUETOOTH"
  },
  {
    "name": "ohos.permission.ACCESS_BLUETOOTH"
  }
]

这里需要注意:ACCESS_FINE_LOCATION 在新版本 API 中已不再强制要求,但若需要扫描 BLE 设备,仍需确保位置服务开启。

2. 蓝牙基础封装

我们创建一个单例 BluetoothService 管理全局蓝牙开关、配对、连接等核心生命周期。

typescript 复制代码
// BluetoothService.ets
import { bluetooth } from '@kit.ConnectivityKit';

class BluetoothService {
  private static instance: BluetoothService;
  private state: bluetooth.BluetoothState = bluetooth.BluetoothState.STATE_OFF;
  private connectedDevice: string = '';
  private central: bluetooth.BLE | null = null;

  static getInstance(): BluetoothService {
    if (!BluetoothService.instance) {
      BluetoothService.instance = new BluetoothService();
    }
    return BluetoothService.instance;
  }

  constructor() {
    this.init();
  }

  private init() {
    // 监听蓝牙状态变化
    bluetooth.on('stateChange', (data: bluetooth.BluetoothState) => {
      console.info(`蓝牙状态变化: ${data}`);
      this.state = data;
    });
  }

  // 开启蓝牙
  async enableBluetooth(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      bluetooth.enableBluetooth().then(() => {
        console.info('蓝牙已开启');
        resolve(true);
      }).catch((err: Error) => {
        console.error('开启蓝牙失败: ' + JSON.stringify(err));
        reject(false);
      });
    });
  }

  // 检查蓝牙是否开启
  isBluetoothEnabled(): boolean {
    return this.state === bluetooth.BluetoothState.STATE_ON;
  }

  // 获取已配对设备列表
  getPairedDevices(): Array<bluetooth.BondState> {
    return bluetooth.getPairedDevices();
  }
}
export default BluetoothService;

设计意图BluetoothService 作为全局状态管家,任何页面都能获取到蓝牙状态。为什么不写在页面 @Component 里?因为蓝牙生命周期贯穿整个应用,页面销毁后蓝牙连接需要继续保持。

3. 传统蓝牙:连接音箱播放

传统蓝牙连接喇叭,这里使用 A2DP profile。完整连接流程是:先配对 → 再连接 A2DP。

typescript 复制代码
// SpeakerConnector.ets
import { bluetooth } from '@kit.ConnectivityKit';
import BluetoothService from './BluetoothService';

class SpeakerConnector {
  private a2dp: bluetooth.A2dpSourceProfile | null = null;

  async connectToSpeaker(deviceId: string): Promise<boolean> {
    // 1. 获取 A2DP profile
    return new Promise((resolve, reject) => {
      bluetooth.createProfileInstance(bluetooth.ProfileId.PROFILE_A2DP_SOURCE)
        .then((profile: bluetooth.A2dpSourceProfile) => {
          this.a2dp = profile;
          return this.a2dp.connect(deviceId);
        })
        .then(() => {
          console.info('音箱连接成功');
          resolve(true);
        })
        .catch((err: Error) => {
          console.error('连接音箱失败: ' + JSON.stringify(err));
          reject(false);
        });
    });
  }

  // 断开连接
  disconnect() {
    this.a2dp?.disconnect();
  }
}
export default SpeakerConnector;

为什么选择 A2DP 而不是 HSP?

A2DP 用于高质量的立体声音频流,适合播放提示音、音乐。HSP/HFP 主要用于通话,音质较低。在智能家居场景里,显然是 A2DP 更合适。

4. BLE:连接智能灯与传感器

BLE 连接核心是 GATT 通信。我们需要发现服务、然后读写特征。

typescript 复制代码
// LightConnector.ets
import { bluetooth } from '@kit.ConnectivityKit';

class LightConnector {
  private ble: bluetooth.BLE;
  private device: bluetooth.BLEDevice | null = null;
  private gattClient: bluetooth.GattClientDevice | null = null;

  constructor() {
    this.ble = bluetooth.getProfileInstance();
  }

  // 扫描并连接
  async connectToLight(deviceId: string): Promise<boolean> {
    return new Promise((resolve, reject) => {
      this.ble.on('BLEDeviceFind', (data: Array<bluetooth.ScanResult>) => {
        const device = data.find((item) => item.deviceId === deviceId);
        if (device) {
          this.ble.stopScan();
          this.device = device;
          // 连接 GATT
          bluetooth.connectGattClientDevice(device.deviceId)
            .then((client: bluetooth.GattClientDevice) => {
              this.gattClient = client;
              return this.gattClient.on('BLEConnectionStateChange', (state) => {
                console.info('BLE 连接状态变化: ' + JSON.stringify(state));
              });
            })
            .then(() => resolve(true))
            .catch((err: Error) => reject(err));
        }
      });
      this.ble.startScan();
    });
  }

  // 写入特征(控制灯开关)
  async writeCharacteristic(serviceUuid: string, characteristicUuid: string, data: ArrayBuffer): Promise<void> {
    if (!this.gattClient) {
      throw new Error('BLE 未连接');
    }
    return this.gattClient.writeCharacteristicValue(serviceUuid, characteristicUuid, data);
  }

  // 读取特征(获取温度传感器数据)
  async readCharacteristic(serviceUuid: string, characteristicUuid: string): Promise<ArrayBuffer> {
    if (!this.gattClient) {
      throw new Error('BLE 未连接');
    }
    return this.gattClient.readCharacteristicValue(serviceUuid, characteristicUuid);
  }

  // 断开连接
  disconnect() {
    this.gattClient?.disconnect();
  }
}
export default LightConnector;

细节说明

  • startScan 需要在前置条件(蓝牙开启、位置权限、位置服务开启)下才能生效。很多人在真机上发现扫描不到设备,往往是因为位置服务没开。
  • 写特征前需要先发现服务,否则 GATT 读写会失败。这个步骤在 connectGattClientDevice 内部会自动完成,但在某些设备上可能有延迟,建议在连接状态回调完成后再进行读写。

5. 联动逻辑:温度过高自动播报

这个功能在主页面的 @Component 中实现。我们会在 BLE 读取温度后,根据阈值调用音箱播放音频。

typescript 复制代码
// Index.ets
@Entry
@Component
struct Index {
  @State isLightOn: boolean = false;
  @State temperature: number = 25.0;
  @State speakerConnected: boolean = false;
  private speakerConnector: SpeakerConnector = new SpeakerConnector();
  private lightConnector: LightConnector = new LightConnector();

  build() {
    Column() {
      // 蓝牙状态显示
      Text(`音箱状态: ${this.speakerConnected ? '已连接' : '未连接'}`)
      Button('连接音箱')
        .onClick(() => {
          this.speakerConnector.connectToSpeaker('音箱设备MAC地址')
            .then(() => {
              this.speakerConnected = true;
            }).catch(() => {
              // 连接失败处理
            });
        })

      // 灯控开关
      Button(this.isLightOn ? '关闭灯' : '打开灯')
        .onClick(() => {
          const data = new Uint8Array([this.isLightOn ? 0x00 : 0x01]).buffer;
          this.lightConnector.writeCharacteristic('灯的Service UUID', '开关特征UUID', data)
            .then(() => {
              this.isLightOn = !this.isLightOn;
            }).catch(() => {
              // 写入失败提醒
            });
        })

      // 温度显示与联动
      Text(`当前温度: ${this.temperature}°C`)
      Button('读取温度')
        .onClick(() => {
          this.lightConnector.readCharacteristic('温度Service UUID', '温度特征UUID')
            .then((buffer) => {
              this.temperature = new Float32Array(buffer)[0];
              // 联动逻辑:温度 > 30,播放警报
              if (this.temperature > 30 && this.speakerConnected) {
                this.playAlarmHint();
              }
            });
        })
    }
    .padding(16)
    .onPageShow(() => {
      // 页面显示时检查蓝牙状态
    })
  }

  async playAlarmHint() {
    // 这里可以调用系统媒体播放器播放预设音频
    // 或者通过 A2DP 传输音频流
    console.info('播放警报提示音');
  }
}

设计取舍

  • 为什么把蓝牙状态放在页面组件?因为 UI 交互需要实时绑定状态。但蓝牙连接的生命周期由 BluetoothService 管理,页面销毁后不影响蓝牙后台运行。
  • 为什么读取温度后直接联动?这属于业务逻辑,放在页面层方便管理,但要注意避免在异步回调里频繁更新 @State 导致性能问题。实际项目中最好将联动逻辑抽成独立 Service。

常见问题 1:传统蓝牙与 BLE 同时连接时的冲突

现象:当同时连接音箱(传统蓝牙)和智能灯(BLE)时,音箱播放音频后 BLE 连接自动断开。

原因:在某些设备上,传统蓝牙的音频流(A2DP)和 BLE 共存时,可能存在带宽竞争。尤其是音箱在播放高码率音频时,会占用大量蓝牙带宽,导致 BLE 连接不稳定。

解决方案

  • 在播放音频前,先确保 BLE 连接不在传输高负载数据。
  • 使用 bluetooth.getBluetoothState() 检查蓝牙状态,若正在播放音频,可考虑降低音频码率或调整 BLE 通信间隔。
  • BluetoothService 中维护一个 isAudioStreaming 状态,Ble 写入特征前检查该状态。

常见问题 2:页面返回后蓝牙状态丢失

现象:用户从主页面跳转到其他页面,再返回时,蓝牙连接显示断开,但实际上蓝牙还连着。

原因 :页面组件的 @State 在页面销毁时重置,重新创建时变量恢复默认值。蓝牙 Service 虽然是单例,但页面并没有自动同步。

解决方案 :使用 @Link 或全局 Store 共享蓝牙状态。推荐在 BluetoothService 中增加一个 @State 引用,页面通过 @Watch 订阅变化。

typescript 复制代码
// 在 BluetoothService 中新增
private _stateEmitter: (state: boolean) => void = () => {};

setStateChangeCallback(cb: (state: boolean) => void) {
  this._stateEmitter = cb;
}
// 在 Index.ets 中
aboutToAppear() {
  BluetoothService.getInstance().setStateChangeCallback((connected: boolean) => {
    this.speakerConnected = connected;
  });
}

最佳实践

  1. 管理蓝牙连接生命周期 :不要在 @Componentbuild() 中频繁调用 connect/disconnect,应该使用单例 Service 统一管理。页面仅做 UI 和事件触发。

  2. 封装异步回调 :蓝牙 API 大量使用回调,容易造成回调地狱。推荐使用 Promise + async/await 包装。但要注意 Promise 的错误处理,尤其是连接超时场景。在 connect 调用上建议增加超时处理。

  3. 异常重连机制 :蓝牙连接时常不稳定。可以在 BluetoothService 中实现重连策略:

    • 断开后等待 3 秒尝试重连。
    • 连续失败 3 次后暂停。
    • 重连次数过多时通知用户检查设备。

FAQ

Q:真机上扫描不到 BLE 设备,但模拟器可以?

A:模拟器通常不支持 BLE 广播,需要真机。真机扫描失败的原因主要是:

  • 没有开启位置服务(蓝牙扫描需要精细位置权限)。
  • 蓝牙未开启。
  • 设备处于不可发现模式。

Q:为什么写入 BLE 特征后 UI 没更新?

A:UI 更新需要通过 @State 变量触发。如果写入成功回调里直接赋值,注意上下文中的 this。使用箭头函数或 bind(this)

Q:传统蓝牙断开后如何自动重连?

A:在 bluetooth.on('stateChange')bluetooth.on('bluetoothDeviceFind') 回调中加入重连逻辑。但注意不要无限重试,应设定最大重试次数和间隔。

结语

在 HarmonyOS 中同时管理传统蓝牙和 BLE,核心挑战不是调用 API,而是理解生命周期设计稳定的状态同步机制。把蓝牙状态集中到一个 Service 里处理,页面只关注 UI 展示和事件触发,这样你的应用才能在各种异常场景下连续工作。官方文档对蓝牙 API 描述得比较抽象,建议结合真机调试一步一步验证。如果你也遇到类似的问题,可以重点检查是否遗漏了位置服务权限或状态同步逻辑。

相关推荐
国服第二切图仔1 小时前
HarmonyOS APP《画伴梦工厂》开发第58篇-互动卡片——摇一摇触发动态效果
华为·harmonyos
最爱老式锅包肉3 小时前
《HarmonyOS技术精讲-蓝牙》传统蓝牙实战:音频播放与文件传输
华为·音视频·harmonyos
山内桜良13 小时前
HarmonyOS中,html 与 ets 桥接沟通
华为·html·harmonyos
心中有国也有家4 小时前
AtomGit Flutter 鸿蒙客户端:初始化流水线
学习·flutter·华为·harmonyos
在书中成长4 小时前
HarmonyOS 小游戏《对战五子棋》开发第30篇 - TwoPlayerPage双人对战(二):状态栏与玩家指示器
harmonyos
爱吃大芒果4 小时前
面向未来的大模型原生应用 (AI-Native) 架构解析与演进路线图 (Roadmap)
华为·信息可视化·架构·harmonyos·ai-native
爱吃大芒果4 小时前
ArkUI V2 状态管理极简教程,手写待办清单 Demo
华为·harmonyos
水龙吟啸5 小时前
华为2026.6.17机考选择题+编程题【速刷敲黑板】
人工智能·深度学习·算法·华为