HarmonyOS技术精讲-Connectivity Kit:实战——多屏协同与文件快传应用
从“能用”到“好用”,跨设备通信的坑与解
HarmonyOS NEXT 的分布式能力,最吸引人的地方就是设备间协同。但很多人第一次接触 Connectivity Kit 时,会发现官方示例能跑,但一放到实际项目里,就会遇到各种问题:投屏突然断开、文件发送卡住不动、进度条不动、甚至设备都发现不了。
这些问题本质不是 API 不会用,而是生命周期管理和状态同步没处理好。这篇文章会从头搭建一个“多屏协同 + 文件快传”的完整应用,把分布式屏幕、Wi-Fi P2P、蓝牙 BLE 这三块能力整合到一起,重点讲清楚到底有哪些坑需要绕开。
这个应用解决了什么问题
首先得说清楚:为什么要自己写,而不是直接用 HarmonyOS 自带的“多设备协同”?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 系统自带的“多设备协同” | 无开发成本,交互统一 | 高度黑盒,无法定制 UI 和传输策略 |
| 使用 Connectivity Kit 自建 | 完全可控,可自定义文件传输进度、UI、策略 | 需要处理生命周期的细节,需要解决设备发现和连接稳定性问题 |
| 第三方方案(如自建局域网传输) | 跨平台 | 无法利用 HarmonyOS 的分布式软总线优势,延迟高 |
推荐在以下场景自建应用:
- 需要在特定应用内实现多屏协作,而不是系统级别的屏幕镜像
- 需要实时回传文件传输进度、压缩质量、速度限制
- 需要同时支持多个设备在同一应用内进行不同类型的传输(比如一个投屏,一个传文件)
不适合的场景:
- 只做一次性的屏幕镜像,不需要控制传输细节
- 不需要跨设备拖拽文件
环境说明
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 中进行,避免在页面销毁后扫描中断。
// BleDiscoverService.etsimport{ble}from'@kit.ConnectivityKit';exportclassBleDiscoverService{publiconDeviceFound:(deviceId:string,deviceName:string)=>void=()=>{};privatescanId:number=0;privatediscoveredDevices:Map<string,string>=newMap();asyncstartScan():Promise<void>{// 每次扫描前先清空缓存,避免重复回调this.discoveredDevices.clear();// HarmonyOS BLE 扫描需要指定 Service UUID 过滤器constfilters:ble.BleScanFilter[]=[{serviceUuid:'00001800-0000-1000-8000-00805F9B34FB'// 示例服务UUID,请替换为实际}];constscanOptions:ble.BleScanOptions={interval:100,// 扫描间隔,单位msdutyMode: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=>{constdeviceId=device.deviceId;constdeviceName=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去连接。
// DeviceConnectionService.etsimport{deviceManager}from'@kit.DistributedServiceKit';exportclassDeviceConnectionService{privatedevManager:deviceManager.DeviceManager|null=null;publiconDeviceStatusChanged:(deviceId:string,online:boolean)=>void=()=>{};asyncinit():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){returnthis.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返回的列表需要进行权限判断,不是所有在线设备都可以直接连接
第二步:分布式屏幕投射
当两个设备通过分布式软总线建立连接后,就可以启动屏幕投射了。
我们使用screenCaptureAPI 和分布式RemoteWindow来实现。这里需要明确:不是通过“投屏”的系统能力,而是通过创建远程窗口的方式。
创建远程窗口
// ScreenProjectionManager.etsimport{window}from'@kit.ArkUI';import{BusinessError}from'@kit.BasicServicesKit';exportclassScreenProjectionManager{privatelocalWindow:window.Window|null=null;privateremoteWindow:window.Window|null=null;asyncstartProjection(targetDeviceId:string,localWindowStage:window.WindowStage):Promise<void>{try{// 1. 获取当前应用的本地窗口,用于后续创建 MirrorWindowthis.localWindow=awaitlocalWindowStage.getMainWindow();// 2. 创建远程窗口,指向目标设备的窗口管理器// 注意:这里直接使用分布式窗口 API,不属于 Connectivity Kit,但属于分布式能力this.remoteWindow=awaitwindow.Window.createWindow('remoteScreen',window.WindowType.TYPE_FLOAT,window.WindowMode.MODE_FLOATING,{displayId:0,deviceId:targetDeviceId}// 指定目标设备);// 3. 绑定本地窗口的内容到远程窗口// 实际上是创建一个新的 Surface,将本地窗口渲染内容同步过去awaitthis.remoteWindow.bindWindow(this.localWindow);// 4. 显示远程窗口并根据需求调整布局awaitthis.remoteWindow.showWindow();awaitthis.remoteWindow.resize(500,400);// 设置尺寸awaitthis.remoteWindow.moveWindowTo(100,100);// 在目标设备上的位置}catch(error){console.error(`ScreenProjectionManager: start failed,${error}`);throwerror;}}asyncstopProjection():Promise<void>{try{if(this.remoteWindow){awaitthis.remoteWindow.hideWindow();awaitthis.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 连接
// WifiP2pManager.etsimport{wifiManager}from'@kit.ConnectivityKit';exportclassWifiP2pManager{publiconConnectionChanged:(connected:boolean,groupOwner:boolean)=>void=()=>{};privatep2pListener:number=0;asyncstartDiscovery():Promise<void>{try{// 先注册 P2P 状态监听,否则可能错过连接事件this.p2pListener=wifiManager.on('p2pConnectionChanged',(data)=>{if(data){constisConnected=data.isGroupOwner!=null;// 代表已有连接this.onConnectionChanged(isConnected,data.isGroupOwner);}});}catch(error){console.error(`WifiP2pManager: startDiscovery error,${error}`);}}asyncconnectToDevice(deviceMac:string):Promise<void>{try{constconfig:wifiManager.WifiP2PConfig={deviceMacAddress:deviceMac,netRole:wifiManager.P2pNetRole.GO,// 设置为 GO 端,作为服务器groupOwnerIntent:7// 优先为 GO};awaitwifiManager.p2pConnect(config);}catch(error){console.error(`WifiP2pManager: connectToDevice error,${error}`);}}asyncdisconnectAll():Promise<void>{try{if(this.p2pListener>0){wifiManager.off('p2pConnectionChanged',this.p2pListener);}awaitwifiManager.p2pCancelConnect();}catch(error){console.error(`WifiP2pManager: disconnectAll error,${error}`);}}}关键点:
netRole的配置需要两个设备协商好。我们这里约定发送端为 GO,接收端为 GCgroupOwnerIntent越高,越有可能成为 Group Owner;但两个设备都设为 15 会导致冲突- 连接成功后,需要获取 GO 的 IP 地址,然后通过 Socket 直接传输。这部分不在 Connectivity Kit 范围内,但需要配合使用
文件发送流实现(发送端)
// FileSender.etsimport{socket}from'@kit.NetworkKit';import{fileIo}from'@kit.CoreFileKit';exportclassFileSender{privatetcpSocket:socket.TCPSocket|null=null;privateisSending:boolean=false;asyncsendFile(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 SocketconstnetAddress:socket.NetAddress={address:serverIp,port:serverPort,family:1// IPv4};this.tcpSocket=socket.constructTCPSocketInstance();awaitthis.tcpSocket.connect(netAddress);// 2. 打开文件constfile=awaitfileIo.open(fileUri,fileIo.OpenMode.READ_ONLY);constfileSize=fileIo.statSync(fileUri).size;// 3. 发送文件头信息(文件名、大小)constheader=JSON.stringify({fileName:fileUri.split('/').pop()||'unknown',fileSize:fileSize});constheaderBytes=newTextEncoder().encode(header);// 先发送头部长度(4 字节小端序),再发送头部内容constheaderLenBuffer=newArrayBuffer(4);newDataView(headerLenBuffer).setUint32(0,headerBytes.byteLength,true);awaitthis.tcpSocket.send({data:headerLenBuffer});awaitthis.tcpSocket.send({data:headerBytes.buffer});// 4. 分块发送文件内容constchunkSize=1024*1024;// 1MB 块lettotalSent=0;constbuffer=newArrayBuffer(chunkSize);while(totalSent<fileSize){constbytesRead=awaitfileIo.read(file.fd,buffer,{offset:totalSent,length:chunkSize});if(bytesRead<=0)break;constchunk=buffer.slice(0,bytesRead);// 大文件发送时需要加上超时处理,避免单次 send 一直卡住awaitthis.tcpSocket.send({data:chunk});totalSent+=bytesRead;onProgress(totalSent,fileSize);}// 5. 关闭文件和 SocketawaitfileIo.close(file);awaitthis.tcpSocket.close();this.tcpSocket=null;}catch(error){console.error(`FileSender: sendFile error,${error}`);throwerror;}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 更新必须在主线程进行。
// 在页面组件中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 超时:
this.tcpSocket.setExtraOptions({sendTimeout:10000// 10s 超时});接收端必须使用异步 I/O,且不要在主线程处理:用
TaskPool或worker处理文件写入。增加拥塞控制:在发送循环里加入
await delay(10)让出资源。
awaitnewPromise(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 的生命周期在某些场景下(如直接杀进程)可能不会完整执行,所以需要多一层保护。