
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,必须真机。
架构设计
整个应用分为两层:
- 协同层:负责设备发现(蓝牙 BLE + 分布式软总线)、设备连接管理、分布式屏幕投射
- 传输层:负责 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;
}
}
}
注意:
createDeviceManager的bundleName必须和应用的 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,接收端为 GCgroupOwnerIntent越高,越有可能成为 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 获取渲染流。如果本地应用处于后台,或者被系统回收,绑定就会断开,导致目标设备上的窗口崩溃。
解决方案:
- 保持本地应用在前台 :在投屏期间,禁止应用切到后台,或者在
onForeground/onBackground生命周期里控制投屏的启停。 - 监控绑定状态 :注册远程窗口的
windowEvent监听,在目标设备窗口销毁时主动断开绑定。 - 使用
softbusAPI 替代 :如果bindWindow不稳定,可以改用softbusSDK 手动同步屏幕数据,但开发成本高很多。
坑 2:Wi-Fi P2P 连接成功后,Socket 发送文件时超时
现象 :文件发送到某个大文件(>100MB)时,send 方法无响应,超时后抛出异常。
原因 :TCPSocket.send 如果发送缓冲区满了,会阻塞等待对方接收。接收端如果没有及时读取缓冲区,发送端就会卡住。主要原因是接收端处理不当,或者网络抖动。
解决方案:
- 发送端设置 Socket 超时:
typescript
this.tcpSocket.setExtraOptions({
sendTimeout: 10000 // 10s 超时
});
-
接收端必须使用异步 I/O,且不要在主线程处理 :用
TaskPool或worker处理文件写入。 -
增加拥塞控制 :在发送循环里加入
await delay(10)让出资源。
typescript
await new Promise(resolve => setTimeout(resolve, 10));
坑 3:蓝牙 BLE 在升级到 API 12 后,扫描回调不触发
现象 :代码完全按照官方文档写,但 startScan 的回调一次也不触发。
原因 :API 12 起,BleScanFilter 里的 serviceUuid 不再是可选参数。如果 filters 数组为空数组,扫描不会启动。另外,需要检查是否申请了 ohos.permission.USE_BLUETOOTH 和 ohos.permission.BLUETOOTH_SCAN 权限。
解决方案:
filters必须包含至少一个有效 Service UUID- 检查
module.json5中的权限声明是否齐全
最佳实践
1. 不要把服务实例绑定在页面组件的生命周期上
很多人的做法是在 @Component 里 new 一个 BleDiscoverService,然后在 aboutToDisappear 里释放。问题在于:当用户切到其他页面再返回时,服务实例已经消失了。推荐改成在应用级(AppStorage 或 singleton)管理服务实例。
2. 使用 @ObjectLink 处理跨页面状态同步
文件传输进度、设备列表这些数据,如果在多个页面都需要展示,不要在每个组件的 @State 里缓存。用 @Observed 定义状态类,然后用 @ObjectLink 引用。这样更新一次,所有组件都刷新。
3. 资源释放的"三保险"
对于网络连接、Service 注册、扫描,提倡在以下三个地方都做释放:
- 页面
aboutToDisappear - 应用
onDestroy - 组件
disconnectTimer或错误回调里
因为 HarmonyOS 的生命周期在某些场景下(如直接杀进程)可能不会完整执行,所以需要多一层保护。