《HarmonyOS技术精讲-窗口管理》第十篇:实战——分屏协作应用

分屏协作,不只是"开两个窗口"

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() 是异步的,需在回调中继续操作。
  • 子窗口的位置和大小需要通过 moveWindowToresize 手动控制。

第二步:实现拖拽交互(列表窗→详情窗)

列表窗口展示一个 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 方法中,使用 LayoutWeightPercent 布局,避免硬编码尺寸。

typescript 复制代码
build() {
  Column() {
    Text(this.receivedText)
      .fontSize(24)
      .width('100%')
      .height('100%') // 改为100%
      .textAlign(TextAlign.Center)
  }
  .width('100%')
  .height('100%')
}

同时,确保页面根节点使用 ColumnRow,而不是固定宽高的 Stack

最佳实践

  1. 不要在 onWindowSizeChange 中直接更新 @State 变量

    该事件频率高,直接更新会触发大量性能开销。建议使用防抖或节流,或者在事件中仅保存尺寸,然后在 build 中读取。

  2. 使用 AppStorage 共享子窗口引用,避免全局变量

    全局变量在多页面间不可靠,AppStorage 提供了跨页面的安全访问,且自动处理生命周。

  3. 拖拽数据格式优先用 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 中执行轻量操作,避免复杂计算。

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