《HarmonyOS技术精讲-窗口管理》第八篇:多窗口协作与跨窗口交互

多窗口协作,不只是分屏那么简单

HarmonyOS 的窗口管理能力里,多窗口协作算是比较实用但容易被低估的一个模块。很多人以为分屏就是把两个 WindowStage 放在一起跑,实际上要真正做到"两个窗口能互相拖拽数据、实时同步状态",涉及到的知识点比想象中多。

官方文档给出了拖拽和窗口通信的基础 API,但没有详细讲两个窗口之间如何做数据通道,也没有说明拖拽事件的焦点问题和生命周期冲突。这篇文章会用一个完整的示例,把分屏窗口对齐、跨窗口拖拽和窗口间数据同步串起来。

它解决什么问题

多窗口协作的核心需求有两个:

需求 说明 典型场景
跨窗口数据操作 用户可以在窗口A选中内容,直接拖拽到窗口B 笔记应用从摘录窗口拖拽内容到编辑窗口
窗口间状态同步 窗口A的操作实时影响窗口B的显示 购物应用拖拽商品到购物车窗口

不适用场景:高频实时同步(比如视频流共享),这种情况更适合媒体管道或分布式能力。

类似方案对比:

方案 跨窗口拖拽 状态同步复杂度 推荐场景
全局剪贴板 需要手动处理 简单文本复制
全局事件(EventHub) 不适用 命令式通知
DragController + 窗口通信 原生支持 内容拖拽 + 数据传递
ShareData 数据通道 不适用 大数据块或文件传输

实际开发中,跨窗口拖拽场景最推荐 DragController 配合 EventHub 窗口间通信的方式,可以做到即时同步且不引入额外依赖。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上

HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上

目标设备:平板(多窗口特性在手机上有限制)

核心实现:两个窗口并排,支持拖拽

第一步:创建主窗口并启动分屏

主窗口负责启动子窗口。初始化两个 WindowStage 并排显示。

typescript 复制代码
// MainAbility.ts
import { UIAbility, WindowStage, window } from '@kit.ArkUI';

export default class MainAbility extends UIAbility {
  private subWindow: window.Window | null = null;

  onWindowStageCreate(windowStage: WindowStage): void {
    // 加载主页面
    windowStage.loadContent('pages/Index', (err) => {
      if (err) {
        console.error('Failed to load main page', err);
        return;
      }
      // 创建子窗口
      this.createSubWindow(windowStage);
    });
  }

  private async createSubWindow(windowStage: WindowStage): Promise<void> {
    const mainWindow = windowStage.getMainWindowSync();
    const mainRect = mainWindow.getWindowProperties().windowRect;

    // 子窗口配置:占据屏幕右侧一半
    const subWindowConfig: window.Configuration = {
      name: 'subWindow',
      windowType: window.WindowType.TYPE_FLOAT,
      ctx: this.context,
    };

    this.subWindow = await window.Window.createWindow(subWindowConfig);
    await this.subWindow.setWindowLayoutMode(window.WindowLayoutMode.WINDOW_LAYOUT_MODE_DYNAMIC);
    await this.subWindow.setWindowRect(
      mainRect.left + mainRect.width / 2,
      mainRect.top,
      mainRect.width / 2,
      mainRect.height
    );
    await this.subWindow.loadContent('pages/SubPage', null);
    await this.subWindow.showWindow();
  }

  onWindowStageDestroy(): void {
    this.subWindow?.destroyWindow();
  }
}

这段代码做了三件事:加载主页面、计算子窗口位置(右侧一半)、显示子窗口。需要注意的是 WINDOW_LAYOUT_MODE_DYNAMIC,如果不设置,子窗口可能不会自适应分屏区域变化。

第二步:主窗口实现拖拽源

主窗口包含一个文本输入框和一个可拖拽的标签。用户输入文本后,可以拖拽到子窗口。

typescript 复制代码
// pages/Index.ets
import { dragController } from '@kit.ArkUI';
import { drag } from '@kit.ArkUI';

@Entry
@Component
struct MainWindow {
  @State dragText: string = '';

  build() {
    Column() {
      TextInput({ placeholder: '输入要拖拽的文本', text: this.dragText })
        .onChange((value) => {
          this.dragText = value;
        })
        .width('80%')
        .margin({ bottom: 20 });

      Text(this.dragText)
        .padding(10)
        .border({ width: 1, color: Color.Blue })
        .draggable(true) // 启用拖拽
        .onDragStart((event: DragEvent) => {
          // 创建拖拽数据
          const dragData = new Map<string, string>();
          dragData.set('text/plain', this.dragText);
          const dragInfo = new drag.DragInfo();
          dragInfo.dragData = dragData;
          return dragInfo;
        })
        .onDragEnd((event: DragEvent) => {
          console.info('Drag ended', event.getResult());
        })
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .onDrop((event: DragEvent) => {
      // 主窗口也可接收拖拽
      const data = event.getDragData()?.get('text/plain');
      if (data) {
        this.dragText = data as string;
      }
    })
  }
}

关键点:

  • draggable(true) 让组件可拖拽
  • onDragStart 返回 DragInfo,其中 dragData 是键值对,键是 MIME 类型
  • 主窗口本身也监听了 onDrop,可以接收来自子窗口的拖拽

第三步:子窗口实现拖拽接收

子窗口只关注统一的全局事件,不直接接收拖拽数据。这样做的好处是避免了多个窗口同时监听同一个拖拽事件时的焦点冲突。

typescript 复制代码
// pages/SubPage.ets
import { commonEventManager } from '@kit.ArkUI';

@Entry
@Component
struct SubWindow {
  @State receivedText: string = '等待拖拽...';

  build() {
    Column() {
      Text(this.receivedText)
        .padding(10)
        .border({ width: 1, color: Color.Gray })
        .width('80%')
        .height(100)
        .margin({ bottom: 20 })
        .onDrop((event: DragEvent) => {
          // 直接从拖拽事件获取数据
          const data = event.getDragData()?.get('text/plain');
          if (data) {
            this.receivedText = data as string;
          }
        });
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .onDrop((event: DragEvent) => {
      // 容器也监听,多一层保障
      const data = event.getDragData()?.get('text/plain');
      if (data) {
        this.receivedText = data as string;
      }
    })
  }
}

为什么子窗口不需要额外通信?

因为 DragEvent 本身会发送到所有处于前台且可接收拖拽的窗口。如果子窗口的某个组件监听了 onDrop,它会直接收到数据。不需要 EventHub 二次中转。

第四步:窗口间通用状态同步(可选场景)

如果数据量大或需要批量同步,可以使用全局事件通道。但文本拖拽场景,直接用 DragEvent 就够。

typescript 复制代码
// 全局事件定义
// common/events.ts
export const EVENT_DRAG_TEXT = 'event_drag_text';
typescript 复制代码
// 主窗口发送事件(拖拽后追加通知)
@Entry
@Component
struct MainWindow {
  // ... 省略其他代码
  onDragEnd((event: DragEvent) => {
    commonEventManager.publish(EVENT_DRAG_TEXT, { text: this.dragText });
  })
}
typescript 复制代码
// 子窗口监听事件
aboutToAppear(): void {
  commonEventManager.subscribe(EVENT_DRAG_TEXT, (data) => {
    if (data) {
      this.receivedText = data.text;
    }
  });
}

什么时候需要?

当拖拽结束后,子窗口还需要根据拖拽内容做其他操作(比如更新列表项、触发动画)时,通过事件通知更可靠,因为 DragEvent 的生命周期在 drop 后很快结束。

踩坑记录

坑1:拖拽事件未触发,子窗口毫无反应

现象 :子窗口设置了 onDrop,但拖拽文本到子窗口上时没有任何输出,日志也不打印。

原因 :子窗口的 WindowType 如果设为 TYPE_FLOAT,且没有设置焦点模式,接收拖拽的窗口需要具备输入焦点。调试发现,浮窗默认不接受拖拽事件。

解法 :创建子窗口时,显式设置焦点模式为 FOCUS_AND_TOUCH

typescript 复制代码
// 在 createSubWindow 中添加
await this.subWindow.setWindowFocusable(true);
await this.subWindow.setWindowTouchable(true);

坑2:拖拽过程中子窗口闪退

现象 :拖拽操作一旦跨越窗口边界,子窗口立即 crash,日志显示 DragEvent dispatch failed

原因 :跨窗口拖拽时,子窗口对应的 UIAbility 必须处于 FOREGROUND 状态。如果子窗口被创建后没有及时 showWindow(),或者用户中途隐藏了子窗口,拖拽事件会找不到目标窗口。

解法 :确保子窗口 showWindow 之后再创建拖拽源。并且在子窗口 onWindowStageDestroy 时,取消拖拽监听。

typescript 复制代码
// 创建后等待 showWindow 完成
await this.subWindow.showWindow();
console.info('sub window shown');

另外,子窗口的 onBeforeShow 里不要做耗时操作,否则可能阻塞拖拽事件分发。

最佳实践

  1. 不要在 onDragEnd 里做状态同步

    onDragEnd 在拖拽结束后触发,但此时 DragEvent 已经销毁。如果需要子窗口对拖拽结果做后续处理,请使用全局事件或 UIState 进行异步同步。

  2. 分屏窗口对齐尽量用百分比计算,避免硬编码像素

    不同设备屏幕尺寸差异很大。使用 windowRect.width / 2 动态计算子窗口宽度,比固定 600px 要稳定。

  3. 拖拽数据尽可能序列化为字符串

    跨窗口场景下,dragData 的键值对中值必须是可序列化的。建议统一用 JSON.stringify 处理复杂对象,接收方再 JSON.parse

typescript 复制代码
// 主窗口设置
const jsonData = JSON.stringify({ text: this.dragText, timestamp: Date.now() });
dragData.set('text/plain', jsonData);

// 子窗口解析
const raw = event.getDragData()?.get('text/plain');
if (raw) {
  const parsed = JSON.parse(raw as string);
  this.receivedText = parsed.text;
}

Demo 入口

typescript 复制代码
// entry/src/main/ets/entryability/EntryAbility.ts
import { UIAbility, WindowStage, window } from '@kit.ArkUI';

export default class EntryAbility extends UIAbility {
  onCreate() {
    console.info('EntryAbility onCreate');
  }

  onWindowStageCreate(windowStage: WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err) {
        console.error('Failed to load main page', err);
        return;
      }
      this.createSubWindow(windowStage);
    });
  }

  private async createSubWindow(windowStage: WindowStage): Promise<void> {
    const mainWindow = windowStage.getMainWindowSync();
    const mainRect = mainWindow.getWindowProperties().windowRect;

    const subWindowConfig: window.Configuration = {
      name: 'subWindow',
      windowType: window.WindowType.TYPE_FLOAT,
      ctx: this.context,
    };

    const subWindow = await window.Window.createWindow(subWindowConfig);
    await subWindow.setWindowLayoutMode(window.WindowLayoutMode.WINDOW_LAYOUT_MODE_DYNAMIC);
    await subWindow.setWindowFocusable(true);
    await subWindow.setWindowTouchable(true);
    await subWindow.setWindowRect(
      mainRect.left + mainRect.width / 2,
      mainRect.top,
      mainRect.width / 2,
      mainRect.height
    );
    await subWindow.loadContent('pages/SubPage', null);
    await subWindow.showWindow();
  }

  onWindowStageDestroy(): void {
    console.info('EntryAbility onWindowStageDestroy');
  }
}

示例代码项目地址:项目地址

FAQ

Q:为什么真机可以拖拽,但模拟器上子窗口收不到 drop?

A:模拟器对浮窗的拖拽事件支持有限制,建议在真机(平板)上验证。如果必须用模拟器,可以尝试在子窗口的 aboutToAppear 中手动注册拖拽事件监听。

Q:拖拽文本到子窗口后,子窗口刷新了但文字还是旧的?

A:检查 @State 是否在正确的组件层级。如果 onDrop 回调里直接修改 receivedText,但该变量定义在父组件里,子组件没有响应式绑定。推荐将接收逻辑放在最内层 Text 组件上。

Q:为什么子窗口显示后,无法再次拖拽到主窗口?

A:这是焦点问题。拖拽事件优先发送给当前聚焦的窗口。如果子窗口获得了焦点,主窗口的 onDrop 不会被触发。可以通过设置 window.focus() 在拖拽完成后手动切换焦点,或者两个窗口都监听 onDrop