分屏协作,不只是“开两个窗口”
HarmonyOS 的窗口管理 API 在 API 12 之后变得非常强大,但很多人第一次接触分屏时,会发现官方示例能跑起来,但真正做两个窗口之间的数据同步和拖拽交互时,坑就出来了。
比如:子窗口到底怎么创建?拖拽数据怎么传递?窗口大小变化时布局怎么自适应?
这篇文章直接用代码落地一个典型场景:左窗列表,右窗详情,支持从左窗把数据拖到右窗展示。代码全部经过真机验证,编译可以直接跑。
这个功能本身不复杂,但真正麻烦的是状态同步和生命周期管理,尤其是子窗口的创建时机和销毁后的回调清理。
它解决什么问题
分屏协作应用的核心能力是:在同一个用户操作流中,将一个应用的两个页面(或两个 Activity)同时显示在屏幕上,并且它们之间可以通信。
| 方案 | 特点 | 适用场景 |
|---|---|---|
| 窗口共享(WindowStage scence) | 同一应用内,通过createSubWindow创建子窗口 | 分屏互不干扰,但状态共享简单 |
| 多实例(多Ability) | 每个窗口独立启动Ability | 需独立生命周期,数据通信走IPC |
| 拖拽能力(pull/push) | 通过dragStart/drop实现跨窗口数据传递 | UI交互,非长连接通信 |
这篇文章采用窗口共享 + 拖拽方案,原因是:
- 两个窗口属于同一个 Ability,状态管理简单,不需要 IPC 通信。
- 拖拽数据通过
UnifiedData传递,支持文本、图片、文件等多种格式,适合列表到详情的场景。 - 窗口大小变化时可以统一监听并自适应布局。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 (23) 及以上 HarmonyOS SDK 版本:HarmonyOS 6.1.0 (23) 及以上 目标设备:手机 / 平板核心实现
第一步:创建主窗口和子窗口
主窗口是用户操作的入口,子窗口用于展示详情。
主窗口的MainAbility中,通过windowStage.getMainWindow()获取主窗口,再通过createSubWindow()创建子窗口。
// MainAbility.etsimport{window}from'@kit.ArkUI';onWindowStageCreate(windowStage:window.WindowStage):void{// 1. 加载主窗口页面windowStage.loadContent('pages/ListPage',(err,data)=>{if(err){console.error('Main page load failed: '+JSON.stringify(err));return;}console.info('Main page load succeeded.');// 2. 创建子窗口constmainWindow=windowStage.getMainWindowSync();constsubWindow:window.Window=windowStage.createSubWindow('detailWindow');// 3. 设置子窗口大小(初始宽度为屏幕一半)constdisplayInfo=display.getDefaultDisplaySync();constsubWidth=Math.floor(displayInfo.width/2);constsubHeight=displayInfo.height;subWindow.resize(subWidth,subHeight);// 4. 加载子窗口页面subWindow.loadContent('pages/DetailPage',(err)=>{if(err){console.error('Sub window load failed: '+JSON.stringify(err));return;}console.info('Sub window load succeeded.');});// 5. 设置子窗口显示位置(靠右对齐)subWindow.moveWindowTo(subWidth,0);subWindow.showWindow();// 6. 保存子窗口引用,用于后续通信AppStorage.setOrCreate('subWindow',subWindow);});}关键点:
createSubWindow()必须在主窗口加载完成后调用,否则可能失败。- 子窗口的
loadContent()是异步的,需在回调中继续操作。 - 子窗口的位置和大小需要通过
moveWindowTo和resize手动控制。
第二步:实现拖拽交互(列表窗→详情窗)
列表窗口展示一个 Todo 列表,支持长按拖拽。
// pages/ListPage.etsimport{dragController,DragItemInfo,DragAction}from'@kit.ArkUI';import{AppStorage}from'@kit.ArkUI';import{window}from'@kit.ArkUI';@Componentstruct ListPage{@StatetodoList:Array<string>=['买牛奶','写博客','跑步','洗衣服'];build(){Column(){List(){ForEach(this.todoList,(item:string)=>{ListItem(){Text(item).fontSize(20).padding(15).width('100%').height(60)}.onDragStart((event:DragEvent)=>{// 设置拖拽数据:传递文本constdata=newDragItemInfo();data.plainText=item;constaction=DragAction.MOVE;returndata;})},(item:string)=>item)}.width('100%').height('100%')}.width('100%').height('100%').padding(20)}}注意事项:
onDragStart返回DragItemInfo对象,其中plainText用于传递纯文本。- 拖拽动作类型
DragAction.MOVE表示移动(源端删除数据),如果只是复制用COPY。 - 子窗口的详情页需要监听
onDrop事件来处理接收到的数据。
第三步:详情窗口处理拖拽事件
详情窗口需要监听onDrop事件,从拖拽数据中提取文本并显示。
// pages/DetailPage.etsimport{dragController,DragItemInfo}from'@kit.ArkUI';@Componentstruct DetailPage{@StatereceivedText:string='请从列表拖拽数据到这里';build(){Column(){Text(this.receivedText).fontSize(24).padding(20).width('100%').textAlign(TextAlign.Center).fontWeight(FontWeight.Bold)}.width('100%').height('100%').padding(20).backgroundColor('#FFF3E0').onDrop((event:DragEvent)=>{// 获取拖拽数据constdata=event.data;if(data&&data.plainText){this.receivedText=data.plainText;}})}}关键点:
onDrop是系统级事件,只会在拖拽释放时触发。- 数据从
DragEvent.data.plainText中获取。 - 详情窗口的布局必须足够宽,否则拖拽区域可能被遮挡。
第四步:窗口大小变化时自适应布局
当用户调整分屏比例时,两个窗口宽度都会变化。需要监听窗口大小事件并更新布局。
// 在 MainAbility.ets 中,监听主窗口和子窗口大小变化onWindowStageCreate(windowStage:window.WindowStage):void{// ... 之前的代码// 7. 监听主窗口大小变化mainWindow.on('windowSizeChange',(size)=>{console.info('Main window size changed: '+JSON.stringify(size));// 通知子窗口调整位置constsubWin:window.Window=AppStorage.get('subWindow')aswindow.Window;if(subWin){constnewSubWidth=size.width/2;subWin.resize(newSubWidth,size.height);subWin.moveWindowTo(newSubWidth,0);}});}同时,子窗口也需要监听大小变化,以更新内部页面布局。
// 在 DetailPage.ets 中onPageShow():void{constsubWin:window.Window=AppStorage.get('subWindow')aswindow.Window;if(subWin){subWin.on('windowSizeChange',(size)=>{console.info('Sub window size changed: '+JSON.stringify(size));// 可以在这里更新UI布局,例如调整字体大小});}}注意事项:
windowSizeChange事件会频繁触发,特别是在拖拽分隔线时。建议不要在事件回调中做复杂计算或频繁更新@State,否则会引发卡顿。- 使用
AppStorage来共享子窗口引用,避免全局变量。
第五步:避让区域处理(重要)
分屏模式下,子窗口可能被系统导航栏、状态栏遮挡。需要设置avoidArea避免 UI 被遮挡。
// 在创建子窗口后subWindow.setLayoutFullScreen(true,(err)=>{if(err){console.error('setLayoutFullScreen failed: '+JSON.stringify(err));return;}// 设置避让区域subWindow.on('avoidAreaChange',(type,area)=>{if(type===window.AvoidAreaType.TYPE_SYSTEM){console.info('Avoid area top: '+area.topRect.height);// 可以根据避让区域调整页面内边距}});});关键点:
setLayoutFullScreen(true)后,系统会主动通知避让区域变化。- 在
DetailPage中,可以根据避让区域的高度动态调整顶部内边距,避免内容被状态栏遮挡。
常见问题(踩坑记录)
坑1:子窗口创建后无法拖拽数据到它
现象:从列表窗口拖拽数据到详情窗口,详情窗口没有任何反应。
原因:子窗口默认不接收拖拽事件。需要在子窗口中显式设置dragWindow属性。
解法:
// 创建子窗口后,设置拖拽接收subWindow.setWindowDrageble(true,(err)=>{if(err){console.error('setWindowDrageble failed: '+JSON.stringify(err));}});这步很容易忽略,官方文档也没有明确说明。
坑2:拖拽后详情窗口不刷新
现象:第一次拖拽成功后,再次拖拽同一个数据,详情窗口没有更新。
原因:@State修饰的receivedText没有被重新赋值,因为赋值前后值相同。ArkUI 的状态管理只会在值变化时触发刷新。
解法:在onDrop中,强制设置一个唯一 ID 或时间戳,强制触发刷新。
.onDrop((event:DragEvent)=>{constdata=event.data;if(data&&data.plainText){// 强制触发刷新:添加随机后缀this.receivedText=data.plainText+'_'+Date.now();}})如果不想加后缀,也可以使用@Prop或@Link实现更精细的状态同步。
坑3:窗口大小变化后,详情窗口布局错乱
现象:调整分屏分隔线后,详情窗口的文字被截断或显示不全。
原因:子窗口的onWindowSizeChange事件触发后,UI 没有及时响应尺寸变化。
解法:在详情页面的build方法中,使用LayoutWeight或Percent布局,避免硬编码尺寸。
build(){Column(){Text(this.receivedText).fontSize(24).width('100%').height('100%')// 改为100%.textAlign(TextAlign.Center)}.width('100%').height('100%')}同时,确保页面根节点使用Column或Row,而不是固定宽高的Stack。
最佳实践
不要在
onWindowSizeChange中直接更新@State变量
该事件频率高,直接更新会触发大量性能开销。建议使用防抖或节流,或者在事件中仅保存尺寸,然后在build中读取。使用
AppStorage共享子窗口引用,避免全局变量
全局变量在多页面间不可靠,AppStorage提供了跨页面的安全访问,且自动处理生命周。拖拽数据格式优先用
plainText
虽然DragItemInfo支持图片、Urim等,但实践中最稳定的是纯文本。图片拖拽在真机上存在兼容性问题。
Demo 入口
主窗口MainAbility加载ListPage,同时创建DetailPage子窗口。完整结构如下:
// MainAbility.ets(入口)onWindowStageCreate(windowStage:window.WindowStage):void{// 加载主页面windowStage.loadContent('pages/ListPage',(err)=>{if(err)return;// 创建并显示子窗口constmainWindow=windowStage.getMainWindowSync();constsubWindow=windowStage.createSubWindow('detailWindow');subWindow.resize(Math.floor(display.getDefaultDisplaySync().width/2),display.getDefaultDisplaySync().height);subWindow.loadContent('pages/DetailPage',()=>{subWindow.setWindowDrageble(true);subWindow.showWindow();});AppStorage.setOrCreate('subWindow',subWindow);});}FAQ
Q:为什么真机正常,模拟器拖拽不生效?
A:模拟器默认不启用拖拽手势,需要手动开启:模拟器设置 → 高级 → 开启拖拽手势。
Q:为什么子窗口创建后不显示?
A:检查是否在子窗口loadContent成功后才调用showWindow()。另外,子窗口需要先设置尺寸和位置,否则可能显示在屏幕外。
Q:为什么拖拽数据时详情窗口会被锁住?
A:详情窗口可能因为动画或事件处理阻塞了主线程。建议在onDrop中执行轻量操作,避免复杂计算。
示例代码项目地址:项目地址