
分屏协作,不只是"开两个窗口"
HarmonyOS 的窗口管理 API 在 API 12 之后变得非常强大,但很多人第一次接触分屏时,会发现官方示例能跑起来,但真正做两个窗口之间的数据同步和拖拽交互时,坑就出来了。
比如:子窗口到底怎么创建?拖拽数据怎么传递?窗口大小变化时布局怎么自适应?
这篇文章直接用代码落地一个典型场景:左窗列表,右窗详情,支持从左窗把数据拖到右窗展示。代码全部经过真机验证,编译可以直接跑。
这个功能本身不复杂,但真正麻烦的是状态同步和生命周期管理,尤其是子窗口的创建时机和销毁后的回调清理。
它解决什么问题
分屏协作应用的核心能力是:在同一个用户操作流中,将一个应用的两个页面(或两个 Activity)同时显示在屏幕上,并且它们之间可以通信。
| 方案 | 特点 | 适用场景 |
|---|---|---|
| 窗口共享(WindowStage scence) | 同一应用内,通过 createSubWindow 创建子窗口 |
分屏互不干扰,但状态共享简单 |
| 多实例(多Ability) | 每个窗口独立启动Ability | 需独立生命周期,数据通信走IPC |
| 拖拽能力(pull/push) | 通过 dragStart/drop 实现跨窗口数据传递 |
UI交互,非长连接通信 |
这篇文章采用 窗口共享 + 拖拽 方案,原因是:
- 两个窗口属于同一个 Ability,状态管理简单,不需要 IPC 通信。
- 拖拽数据通过
UnifiedData传递,支持文本、图片、文件等多种格式,适合列表到详情的场景。 - 窗口大小变化时可以统一监听并自适应布局。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 (23) 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0 (23) 及以上
目标设备:手机 / 平板
核心实现
第一步:创建主窗口和子窗口
主窗口是用户操作的入口,子窗口用于展示详情。
主窗口的 MainAbility 中,通过 windowStage.getMainWindow() 获取主窗口,再通过 createSubWindow() 创建子窗口。
typescript
// MainAbility.ets
import { 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. 创建子窗口
const mainWindow = windowStage.getMainWindowSync();
const subWindow: window.Window = windowStage.createSubWindow('detailWindow');
// 3. 设置子窗口大小(初始宽度为屏幕一半)
const displayInfo = display.getDefaultDisplaySync();
const subWidth = Math.floor(displayInfo.width / 2);
const subHeight = 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 列表,支持长按拖拽。
typescript
// pages/ListPage.ets
import { dragController, DragItemInfo, DragAction } from '@kit.ArkUI';
import { AppStorage } from '@kit.ArkUI';
import { window } from '@kit.ArkUI';
@Component
struct ListPage {
@State todoList: Array<string> = ['买牛奶', '写博客', '跑步', '洗衣服'];
build() {
Column() {
List() {
ForEach(this.todoList, (item: string) => {
ListItem() {
Text(item)
.fontSize(20)
.padding(15)
.width('100%')
.height(60)
}
.onDragStart((event: DragEvent) => {
// 设置拖拽数据:传递文本
const data = new DragItemInfo();
data.plainText = item;
const action = DragAction.MOVE;
return data;
})
}, (item: string) => item)
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
.padding(20)
}
}
注意事项:
onDragStart返回DragItemInfo对象,其中plainText用于传递纯文本。- 拖拽动作类型
DragAction.MOVE表示移动(源端删除数据),如果只是复制用COPY。 - 子窗口的详情页需要监听
onDrop事件来处理接收到的数据。
第三步:详情窗口处理拖拽事件
详情窗口需要监听 onDrop 事件,从拖拽数据中提取文本并显示。
typescript
// pages/DetailPage.ets
import { dragController, DragItemInfo } from '@kit.ArkUI';
@Component
struct DetailPage {
@State receivedText: 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) => {
// 获取拖拽数据
const data = event.data;
if (data && data.plainText) {
this.receivedText = data.plainText;
}
})
}
}
关键点:
onDrop是系统级事件,只会在拖拽释放时触发。- 数据从
DragEvent.data.plainText中获取。 - 详情窗口的布局必须足够宽,否则拖拽区域可能被遮挡。
第四步:窗口大小变化时自适应布局
当用户调整分屏比例时,两个窗口宽度都会变化。需要监听窗口大小事件并更新布局。
typescript
// 在 MainAbility.ets 中,监听主窗口和子窗口大小变化
onWindowStageCreate(windowStage: window.WindowStage): void {
// ... 之前的代码
// 7. 监听主窗口大小变化
mainWindow.on('windowSizeChange', (size) => {
console.info('Main window size changed: ' + JSON.stringify(size));
// 通知子窗口调整位置
const subWin: window.Window = AppStorage.get('subWindow') as window.Window;
if (subWin) {
const newSubWidth = size.width / 2;
subWin.resize(newSubWidth, size.height);
subWin.moveWindowTo(newSubWidth, 0);
}
});
}
同时,子窗口也需要监听大小变化,以更新内部页面布局。
typescript
// 在 DetailPage.ets 中
onPageShow(): void {
const subWin: window.Window = AppStorage.get('subWindow') as window.Window;
if (subWin) {
subWin.on('windowSizeChange', (size) => {
console.info('Sub window size changed: ' + JSON.stringify(size));
// 可以在这里更新UI布局,例如调整字体大小
});
}
}
注意事项:
windowSizeChange事件会频繁触发,特别是在拖拽分隔线时。建议不要在事件回调中做复杂计算或频繁更新@State,否则会引发卡顿。- 使用
AppStorage来共享子窗口引用,避免全局变量。
第五步:避让区域处理(重要)
分屏模式下,子窗口可能被系统导航栏、状态栏遮挡。需要设置 avoidArea 避免 UI 被遮挡。
typescript
// 在创建子窗口后
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 属性。
解法:
typescript
// 创建子窗口后,设置拖拽接收
subWindow.setWindowDrageble(true, (err) => {
if (err) {
console.error('setWindowDrageble failed: ' + JSON.stringify(err));
}
});
这步很容易忽略,官方文档也没有明确说明。
坑2:拖拽后详情窗口不刷新
现象:第一次拖拽成功后,再次拖拽同一个数据,详情窗口没有更新。
原因 :@State 修饰的 receivedText 没有被重新赋值,因为赋值前后值相同。ArkUI 的状态管理只会在值变化时触发刷新。
解法 :在 onDrop 中,强制设置一个唯一 ID 或时间戳,强制触发刷新。
typescript
.onDrop((event: DragEvent) => {
const data = event.data;
if (data && data.plainText) {
// 强制触发刷新:添加随机后缀
this.receivedText = data.plainText + '_' + Date.now();
}
})
如果不想加后缀,也可以使用 @Prop 或 @Link 实现更精细的状态同步。
坑3:窗口大小变化后,详情窗口布局错乱
现象:调整分屏分隔线后,详情窗口的文字被截断或显示不全。
原因 :子窗口的 onWindowSizeChange 事件触发后,UI 没有及时响应尺寸变化。
解法 :在详情页面的 build 方法中,使用 LayoutWeight 或 Percent 布局,避免硬编码尺寸。
typescript
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 子窗口。完整结构如下:
typescript
// MainAbility.ets(入口)
onWindowStageCreate(windowStage: window.WindowStage): void {
// 加载主页面
windowStage.loadContent('pages/ListPage', (err) => {
if (err) return;
// 创建并显示子窗口
const mainWindow = windowStage.getMainWindowSync();
const subWindow = 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 中执行轻量操作,避免复杂计算。
示例代码项目地址:项目地址