HarmonyOS技术精讲-Connectivity Kit:实战——多屏协同与文件快传应用

HarmonyOS技术精讲-Connectivity Kit:实战------多屏协同与文件快传应用

从"能用"到"好用",跨设备通信的坑与解

HarmonyOS NEXT 的分布式能力,最吸引人的地方就是设备间协同。但很多人第一次接触 Connectivity Kit 时,会发现官方示例能跑,但一放到实际项目里,就会遇到各种问题:投屏突然断开、文件发送卡住不动、进度条不动、甚至设备都发现不了。

这些问题本质不是 API 不会用,而是生命周期管理和状态同步没处理好。这篇文章会从头搭建一个"多屏协同 + 文件快传"的完整应用,把分布式屏幕、Wi-Fi P2P、蓝牙 BLE 这三块能力整合到一起,重点讲清楚到底有哪些坑需要绕开。

这个应用解决了什么问题

首先得说清楚:为什么要自己写,而不是直接用 HarmonyOS 自带的"多设备协同"?

方案 优点 缺点
系统自带的"多设备协同" 无开发成本,交互统一 高度黑盒,无法定制 UI 和传输策略
使用 Connectivity Kit 自建 完全可控,可自定义文件传输进度、UI、策略 需要处理生命周期的细节,需要解决设备发现和连接稳定性问题
第三方方案(如自建局域网传输) 跨平台 无法利用 HarmonyOS 的分布式软总线优势,延迟高

推荐在以下场景自建应用:

  • 需要在特定应用内实现多屏协作,而不是系统级别的屏幕镜像
  • 需要实时回传文件传输进度、压缩质量、速度限制
  • 需要同时支持多个设备在同一应用内进行不同类型的传输(比如一个投屏,一个传文件)

不适合的场景:

  • 只做一次性的屏幕镜像,不需要控制传输细节
  • 不需要跨设备拖拽文件

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 + 平板(均需支持分布式软总线)

注意:模拟器不支持分布式屏幕和 Wi-Fi P2P,必须真机。

架构设计

整个应用分为两层:

  1. 协同层:负责设备发现(蓝牙 BLE + 分布式软总线)、设备连接管理、分布式屏幕投射
  2. 传输层:负责 Wi-Fi P2P 的设备连接、文件流的发送与接收、进度上报

核心实现

第一步:设备发现与连接

设备发现是首要前提。我们采用 蓝牙 BLE 广播 + 分布式软总线 双通道发现策略:

  • 蓝牙 BLE:用于发现附近的设备,触发连接请求
  • 分布式软总线:用于建立稳定的数据通道和控制通道
蓝牙 BLE 设备发现

这一部分主要在 Service 中进行,避免在页面销毁后扫描中断。

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

export class BleDiscoverService {
  public onDeviceFound: (deviceId: string, deviceName: string) => void = () => {};
  private scanId: number = 0;
  private discoveredDevices: Map<string, string> = new Map();

  async startScan(): Promise<void> {
    // 每次扫描前先清空缓存,避免重复回调
    this.discoveredDevices.clear();

    // HarmonyOS BLE 扫描需要指定 Service UUID 过滤器
    const filters: ble.BleScanFilter[] = [
      {
        serviceUuid: '00001800-0000-1000-8000-00805F9B34FB' // 示例服务UUID,请替换为实际
      }
    ];

    const scanOptions: ble.BleScanOptions = {
      interval: 100, // 扫描间隔,单位ms
      dutyMode: ble.ScanDutyMode.SCAN_MODE_LOW_POWER
    };

    try {
      this.scanId = ble.BLE.createScan(filters, scanOptions);
      ble.BLE.startScan(this.scanId, (err, data) => {
        if (err) {
          console.error(`BleDiscoverService: scan failed, ${err.code}, ${err.message}`);
          return;
        }
        if (data) {
          data.forEach(device => {
            const deviceId = device.deviceId;
            const deviceName = device.deviceName || '未知设备';
            // 避免重复触发,排除已发现的设备
            if (!this.discoveredDevices.has(deviceId)) {
              this.discoveredDevices.set(deviceId, deviceName);
              this.onDeviceFound(deviceId, deviceName);
            }
          });
        }
      });
    } catch (error) {
      console.error(`BleDiscoverService: startScan error, ${error}`);
    }
  }

  stopScan(): void {
    if (this.scanId > 0) {
      try {
        ble.BLE.stopScan(this.scanId);
        ble.BLE.destroyScan(this.scanId);
      } catch (error) {
        console.error(`BleDiscoverService: stopScan error, ${error}`);
      }
    }
  }
}

关键点:

  • BleScanFilter 里必须填真实的 Service UUID,否则扫描不到任何设备
  • onDeviceFound 回调在开发者模式下会比较频繁,但实际设备上间隔会大一些,不要依赖快速连续回调
  • destroyScan 容易被遗忘,但不调用会导致内存泄漏
分布式软总线设备发现

蓝牙 BLE 仅做"发现"用,实际的数据通道需要走分布式软总线。我们在发现 BLE 设备后,通过 deviceManager 去连接。

typescript 复制代码
// DeviceConnectionService.ets
import { deviceManager } from '@kit.DistributedServiceKit';

export class DeviceConnectionService {
  private devManager: deviceManager.DeviceManager | null = null;
  public onDeviceStatusChanged: (deviceId: string, online: boolean) => void = () => {};

  async init(): Promise<void> {
    try {
      this.devManager = deviceManager.createDeviceManager('com.example.multiscreen');
      // 注意:回调必须在 createDeviceManager 之后立即注册,否则可能丢失
      this.devManager.on('deviceOnline', (data) => {
        this.onDeviceStatusChanged(data.deviceId, true);
      });
      this.devManager.on('deviceOffline', (data) => {
        this.onDeviceStatusChanged(data.deviceId, false);
      });
    } catch (error) {
      console.error(`DeviceConnectionService: init failed, ${error}`);
    }
  }

  getTrustedDeviceList(): deviceManager.DeviceInfo[] {
    if (this.devManager) {
      return this.devManager.getTrustedDeviceListSync();
    }
    return [];
  }

  release(): void {
    // 必须取消注册所有监听,否则页面销毁后回调仍会触发
    if (this.devManager) {
      this.devManager.off('deviceOnline');
      this.devManager.off('deviceOffline');
      deviceManager.releaseDeviceManager(this.devManager);
      this.devManager = null;
    }
  }
}

注意:

  • createDeviceManagerbundleName 必须和应用的 App 包名 一致(不是 module 名),否则会报错
  • 回调注册必须在 createDeviceManager 之后立即进行,不要在异步回调里再注册,否则可能在注册之前就丢失了设备事件
  • getTrustedDeviceListSync 返回的列表需要进行权限判断,不是所有在线设备都可以直接连接

第二步:分布式屏幕投射

当两个设备通过分布式软总线建立连接后,就可以启动屏幕投射了。

我们使用 screenCapture API 和分布式 RemoteWindow 来实现。这里需要明确:不是通过"投屏"的系统能力,而是通过创建远程窗口的方式

创建远程窗口
typescript 复制代码
// ScreenProjectionManager.ets
import { window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

export class ScreenProjectionManager {
  private localWindow: window.Window | null = null;
  private remoteWindow: window.Window | null = null;

  async startProjection(targetDeviceId: string, localWindowStage: window.WindowStage): Promise<void> {
    try {
      // 1. 获取当前应用的本地窗口,用于后续创建 MirrorWindow
      this.localWindow = await localWindowStage.getMainWindow();

      // 2. 创建远程窗口,指向目标设备的窗口管理器
      // 注意:这里直接使用分布式窗口 API,不属于 Connectivity Kit,但属于分布式能力
      this.remoteWindow = await window.Window.createWindow('remoteScreen', 
        window.WindowType.TYPE_FLOAT, 
        window.WindowMode.MODE_FLOATING,
        { displayId: 0, deviceId: targetDeviceId } // 指定目标设备
      );

      // 3. 绑定本地窗口的内容到远程窗口
      // 实际上是创建一个新的 Surface,将本地窗口渲染内容同步过去
      await this.remoteWindow.bindWindow(this.localWindow);

      // 4. 显示远程窗口并根据需求调整布局
      await this.remoteWindow.showWindow();
      await this.remoteWindow.resize(500, 400); // 设置尺寸
      await this.remoteWindow.moveWindowTo(100, 100); // 在目标设备上的位置
    } catch (error) {
      console.error(`ScreenProjectionManager: start failed, ${error}`);
      throw error;
    }
  }

  async stopProjection(): Promise<void> {
    try {
      if (this.remoteWindow) {
        await this.remoteWindow.hideWindow();
        await this.remoteWindow.destroyWindow();
        this.remoteWindow = null;
      }
      this.localWindow = null;
    } catch (error) {
      console.error(`ScreenProjectionManager: stop failed, ${error}`);
    }
  }

  release(): void {
    this.stopProjection();
  }
}

问题点:

  • window.Window.createWindow 的第三个参数 deviceId 是必须的,如果传空字符串,会在本设备创建,而不是远程设备
  • bindWindow 只支持绑定到 TYPE_FLOAT 类型的窗口,如果类型不对会报错
  • 在目标设备上,远程窗口的移动和缩放需要权限,有些设备可能不支持

第三步:Wi-Fi P2P 文件传输

屏幕投射搞定后,文件传输我们需要一个高速、低延迟的通道。这里选择 Wi-Fi P2P,而不是蓝牙,因为文件体积通常较大(几十 MB 到几百 MB),蓝牙速度不够。

P2P 连接
typescript 复制代码
// WifiP2pManager.ets
import { wifiManager } from '@kit.ConnectivityKit';

export class WifiP2pManager {
  public onConnectionChanged: (connected: boolean, groupOwner: boolean) => void = () => {};
  private p2pListener: number = 0;

  async startDiscovery(): Promise<void> {
    try {
      // 先注册 P2P 状态监听,否则可能错过连接事件
      this.p2pListener = wifiManager.on('p2pConnectionChanged', (data) => {
        if (data) {
          const isConnected = data.isGroupOwner != null; // 代表已有连接
          this.onConnectionChanged(isConnected, data.isGroupOwner);
        }
      });
    } catch (error) {
      console.error(`WifiP2pManager: startDiscovery error, ${error}`);
    }
  }

  async connectToDevice(deviceMac: string): Promise<void> {
    try {
      const config: wifiManager.WifiP2PConfig = {
        deviceMacAddress: deviceMac,
        netRole: wifiManager.P2pNetRole.GO, // 设置为 GO 端,作为服务器
        groupOwnerIntent: 7 // 优先为 GO
      };
      await wifiManager.p2pConnect(config);
    } catch (error) {
      console.error(`WifiP2pManager: connectToDevice error, ${error}`);
    }
  }

  async disconnectAll(): Promise<void> {
    try {
      if (this.p2pListener > 0) {
        wifiManager.off('p2pConnectionChanged', this.p2pListener);
      }
      await wifiManager.p2pCancelConnect();
    } catch (error) {
      console.error(`WifiP2pManager: disconnectAll error, ${error}`);
    }
  }
}

关键点:

  • netRole 的配置需要两个设备协商好。我们这里约定发送端为 GO,接收端为 GC
  • groupOwnerIntent 越高,越有可能成为 Group Owner;但两个设备都设为 15 会导致冲突
  • 连接成功后,需要获取 GO 的 IP 地址,然后通过 Socket 直接传输。这部分不在 Connectivity Kit 范围内,但需要配合使用
文件发送流实现(发送端)
typescript 复制代码
// FileSender.ets
import { socket } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';

export class FileSender {
  private tcpSocket: socket.TCPSocket | null = null;
  private isSending: boolean = false;

  async sendFile(serverIp: string, serverPort: number, fileUri: string, 
    onProgress: (sentBytes: number, totalBytes: number) => void): Promise<void> {
    if (this.isSending) {
      console.warn('FileSender: already sending');
      return;
    }
    this.isSending = true;

    try {
      // 1. 创建 TCP Socket
      const netAddress: socket.NetAddress = {
        address: serverIp,
        port: serverPort,
        family: 1 // IPv4
      };
      this.tcpSocket = socket.constructTCPSocketInstance();
      await this.tcpSocket.connect(netAddress);

      // 2. 打开文件
      const file = await fileIo.open(fileUri, fileIo.OpenMode.READ_ONLY);
      const fileSize = fileIo.statSync(fileUri).size;

      // 3. 发送文件头信息(文件名、大小)
      const header = JSON.stringify({
        fileName: fileUri.split('/').pop() || 'unknown',
        fileSize: fileSize
      });
      const headerBytes = new TextEncoder().encode(header);
      // 先发送头部长度(4 字节小端序),再发送头部内容
      const headerLenBuffer = new ArrayBuffer(4);
      new DataView(headerLenBuffer).setUint32(0, headerBytes.byteLength, true);
      await this.tcpSocket.send({ data: headerLenBuffer });
      await this.tcpSocket.send({ data: headerBytes.buffer });

      // 4. 分块发送文件内容
      const chunkSize = 1024 * 1024; // 1MB 块
      let totalSent = 0;
      const buffer = new ArrayBuffer(chunkSize);

      while (totalSent < fileSize) {
        const bytesRead = await fileIo.read(file.fd, buffer, { offset: totalSent, length: chunkSize });
        if (bytesRead <= 0) break;
        const chunk = buffer.slice(0, bytesRead);
        // 大文件发送时需要加上超时处理,避免单次 send 一直卡住
        await this.tcpSocket.send({ data: chunk });
        totalSent += bytesRead;
        onProgress(totalSent, fileSize);
      }

      // 5. 关闭文件和 Socket
      await fileIo.close(file);
      await this.tcpSocket.close();
      this.tcpSocket = null;
    } catch (error) {
      console.error(`FileSender: sendFile error, ${error}`);
      throw error;
    } finally {
      this.isSending = false;
    }
  }

  abort(): void {
    if (this.tcpSocket) {
      // 直接关闭,会抛出异常,但上层可以通过异常判断中止
      this.tcpSocket.close();
      this.tcpSocket = null;
    }
    this.isSending = false;
  }
}

如何实现进度条 UI?

进度条显示依赖于一个 @State 变量 progress,由文件发送的回调更新。需要注意的是:不要在回调里直接 setState,因为回调可能在 I/O 线程触发。ArkUI 的 UI 更新必须在主线程进行。

typescript 复制代码
// 在页面组件中
import { taskpool } from '@kit.ArkTS';

// 使用 taskpool 确保回调在 UI 主线程执行
fileSender.sendFile(ip, port, fileUri, (sent, total) => {
  taskpool.executeOnMainThread(() => {
    this.sendProgress = sent / total;
  });
});

踩坑记录

坑 1:分布式屏幕投影时,目标设备闪退

现象 :调用 bindWindow 后,目标设备上的应用直接无响应或闪退。

原因bindWindow 在绑定后,会在目标设备上创建一个新的 Surface,这个 Surface 需要从 localWindow 获取渲染流。如果本地应用处于后台,或者被系统回收,绑定就会断开,导致目标设备上的窗口崩溃。

解决方案

  1. 保持本地应用在前台 :在投屏期间,禁止应用切到后台,或者在 onForeground / onBackground 生命周期里控制投屏的启停。
  2. 监控绑定状态 :注册远程窗口的 windowEvent 监听,在目标设备窗口销毁时主动断开绑定。
  3. 使用 softbus API 替代 :如果 bindWindow 不稳定,可以改用 softbus SDK 手动同步屏幕数据,但开发成本高很多。

坑 2:Wi-Fi P2P 连接成功后,Socket 发送文件时超时

现象 :文件发送到某个大文件(>100MB)时,send 方法无响应,超时后抛出异常。

原因TCPSocket.send 如果发送缓冲区满了,会阻塞等待对方接收。接收端如果没有及时读取缓冲区,发送端就会卡住。主要原因是接收端处理不当,或者网络抖动。

解决方案

  1. 发送端设置 Socket 超时
typescript 复制代码
this.tcpSocket.setExtraOptions({
  sendTimeout: 10000 // 10s 超时
});
  1. 接收端必须使用异步 I/O,且不要在主线程处理 :用 TaskPoolworker 处理文件写入。

  2. 增加拥塞控制 :在发送循环里加入 await delay(10) 让出资源。

typescript 复制代码
await new Promise(resolve => setTimeout(resolve, 10));

坑 3:蓝牙 BLE 在升级到 API 12 后,扫描回调不触发

现象 :代码完全按照官方文档写,但 startScan 的回调一次也不触发。

原因 :API 12 起,BleScanFilter 里的 serviceUuid 不再是可选参数。如果 filters 数组为空数组,扫描不会启动。另外,需要检查是否申请了 ohos.permission.USE_BLUETOOTHohos.permission.BLUETOOTH_SCAN 权限。

解决方案

  • filters 必须包含至少一个有效 Service UUID
  • 检查 module.json5 中的权限声明是否齐全

最佳实践

1. 不要把服务实例绑定在页面组件的生命周期上

很多人的做法是在 @Componentnew 一个 BleDiscoverService,然后在 aboutToDisappear 里释放。问题在于:当用户切到其他页面再返回时,服务实例已经消失了。推荐改成在应用级(AppStoragesingleton)管理服务实例。

文件传输进度、设备列表这些数据,如果在多个页面都需要展示,不要在每个组件的 @State 里缓存。用 @Observed 定义状态类,然后用 @ObjectLink 引用。这样更新一次,所有组件都刷新。

3. 资源释放的"三保险"

对于网络连接、Service 注册、扫描,提倡在以下三个地方都做释放:

  • 页面 aboutToDisappear
  • 应用 onDestroy
  • 组件 disconnectTimer 或错误回调里

因为 HarmonyOS 的生命周期在某些场景下(如直接杀进程)可能不会完整执行,所以需要多一层保护。

相关推荐
ldsweet18 小时前
《HarmonyOS技术精讲-Basic Services Kit》划词服务:构建系统级文本选取能力
华为·harmonyos
tyqtyq2220 小时前
HarmonyOS AI 应用开发实战:简历项目经历改写系统
人工智能·学习·华为·生活·harmonyos
2301_7681034921 小时前
HarmonyOS趣味相机实战第16篇:实时识别采样、Busy锁与Generation并发治理
harmonyos·arkts·并发控制·camerakit·corevisionkit
xd18557855521 小时前
表情包配文-基于鸿蒙的AI表情包配文生成应用开发实践
人工智能·华为·harmonyos·鸿蒙
AD02271 天前
39-设置开关退出就复原-用偏好写队列和回滚状态兜住
harmonyos·arkts·鸿蒙开发
不羁的木木1 天前
HarmonyOS技术精讲-Connectivity Kit:蓝牙基础——经典蓝牙与BLE入门
华为·harmonyos
不羁的木木1 天前
HarmonyOS技术精讲-Connectivity Kit:自动设备发现与一键配网
华为·harmonyos
雪芽蓝域zzs1 天前
HarmonyOS开发 指纹验证和ESDSA加密
华为·harmonyos
念雨思1 天前
HarmonyOS AI 应用开发实战:短视频选题灵感 —— AI 驱动的内容创作引擎
人工智能·学习·华为·harmonyos·鸿蒙