摘要: 相册里选照片要切出去看定位,修图要切出去看原图,来回复制粘贴切应用,体验割裂。HarmonyOS 7.0 的平行视界能力(ArkUI 多窗 + 平行世界 Stage 模型扩展)让相册应用在同一界面左右分屏跑两个独立任务实例,互不干扰。本文以相册应用为载体,从多窗配置、双实例数据隔离、跨窗拖拽传图、双窗状态同步到分屏降级全链路工程化实战,附 5 个真实踩坑解法与性能边界分析,区别于"开个分屏跑 Demo"的模板帖。

适用版本: HarmonyOS NEXT 7.x / API 26+ / DevEco Studio 5.0.7+(2026 年最新稳定版)

环境说明: 本文基于 DevEco Studio 5.0.7+ 模拟器验证多窗 API 链路,部分真机分屏性能数据需真机补充,涉及处已明确标注。

开篇:一边修图一边挑照片,只能来回切应用

“我要把这张图的滤镜参数复制到那张图上,能不能左右对照着调?”

2026 年 8 月的一个下午,设计师同学在用我的相册应用调色。她左边开着修图界面调滤镜参数,右边想看原图对照——但单窗应用切来切去,调一步切一步,效率低得她想砸手机。

我打开相册,发现问题更深:用户在相册里选照片想看拍摄地点,得切出去打开地图;想分享给朋友,得切出去打开聊天;想批量修图,单窗只能一张张处理,不能"左边挑图、右边预览效果"。

升级到 HarmonyOS 7.0 后,ArkUI 多窗能力(windowStage + UIContext 多实例)配合 Stage 模型扩展,支持真正的"平行视界"——同一应用在同一屏幕上左右分屏跑两个独立的 UIAbility 实例,各自有独立状态、独立导航栈、独立数据视图。我花了几周把它接进相册应用,从双窗启动、数据隔离、跨窗拖拽到分屏降级跑通全链路。

相册平行视界界面:左窗照片网格挑图,右窗修图预览,底部跨窗拖拽传图

这篇文章把整个工程化落地过程记录下来,包括我踩过的 5 个最耗时的坑和最终解法。如果你也在做相册、文档、笔记、聊天类需要"多任务并行"的应用,希望能帮你少走弯路。平行世界社区截至 8-18 仍为 0 篇蓝海方向,本文是首批工程化落地深度帖,聚焦双实例数据隔离与跨窗协作,区别于"开个分屏跑 Demo"的模板帖。


一、平行视界是什么:HarmonyOS 7.0 多窗多实例能力

先厘清一个认知前提:平行视界不是"系统分屏"(系统把两个应用并排显示),而是"应用内多窗多实例"——应用自己管理多个窗口,每个窗口跑一个独立的 UIAbility 实例。

同步

共享

相册应用
EntryAbility 主窗

左窗: 照片挑图
AlbumPicker 实例

右窗: 修图预览
PhotoEditor 实例

独立导航栈

独立导航栈

独立状态树

独立状态树

跨窗事件总线

相册数据源
只读共享

和系统分屏的本质区别:

维度系统分屏(Split-screen)HarmonyOS 7.0 平行视界(应用内多窗)
窗口管理系统窗口管理器应用自己管理(windowStage API)
实例关系两个独立应用进程同一应用多实例,共享数据源
跨窗通信跨进程 IPC,复杂应用内事件总线,简单高效
数据隔离进程级隔离,天然需显式隔离,否则状态串扰
适用场景任意两个应用并排同一应用多任务并行(如相册挑图 + 修图)

最后一点是平行视界的核心难点:同一应用的多个 UIAbility 实例默认共享全局状态,不显式隔离就会"左边选的照片右边也选中了",状态串扰。


二、多窗能力的 API 与工程前提

2.1 windowStage 多窗 API 概览

HarmonyOS 7.0 多窗能力通过 window 模块暴露,核心 API 四个:

import { window } from '@kit.ArkUI';

// 1. 创建子窗口(悬浮窗或分屏子窗)
const subWindow: window.Window = await windowStage.createSubWindow('album-picker');

// 2. 加载 UIAbility 实例到子窗口
await subWindow.setUIContent('pages/AlbumPickerPage');

// 3. 显示子窗口(可指定位置大小,实现分屏)
await subWindow.showWindow();

// 4. 销毁子窗口
await subWindow.destroyWindow();

多窗类型:

enum WindowType {
  WINDOW_TYPE_APP,        // 应用主窗
  WINDOW_TYPE_FLOAT,      // 悬浮窗
  WINDOW_TYPE_SPLIT_CHILD // 分屏子窗(平行视界用此类型)
}

2.2 module.json5 多窗配置

平行视界需要在 module.json5 声明多实例 UIAbility:

{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "startMode": "multiton",  // 关键:多实例启动模式
        "supportWindowMode": ["fullscreen", "split", "floating"]
      }
    ]
  }
}

关键配置项:

  1. startMode: "multiton":多实例启动模式,每次启动新建实例(默认 singleton 单实例会阻止平行视界)
  2. supportWindowMode:声明支持的窗口模式,split 是分屏平行视界的前提

避坑提示:startMode 不配 multiton,平行视界双窗只会显示同一个实例,左边操作右边同步响应,完全失去"平行"意义。这一坑在社区帖评论区被反复问及(“开了分屏但两边一样”),根因都在此。


三、第一步:把两个窗拉起来

3.1 平行视界布局策略

平行视界的核心是"同一屏幕两个独立窗口",布局策略决定体验:

是

否

屏幕宽度 W

W >= 600dp?

左右分屏
左 W/2 右 W/2

上下分屏
上 H/2 下 H/2

左窗: AlbumPicker

右窗: PhotoEditor

上窗: AlbumPicker

下窗: PhotoEditor

3.2 双窗启动封装

import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class ParallelViewManager {
  private leftWindow: window.Window | null = null;
  private rightWindow: window.Window | null = null;

  async startParallelView(windowStage: window.WindowStage): Promise<boolean> {
    try {
      // 创建左窗:照片挑图
      this.leftWindow = await windowStage.createSubWindow('album-picker-left');
      await this.leftWindow.setUIContent('pages/AlbumPickerPage');
      await this.leftWindow.showWindow();

      // 创建右窗:修图预览
      this.rightWindow = await windowStage.createSubWindow('photo-editor-right');
      await this.rightWindow.setUIContent('pages/PhotoEditorPage');
      await this.rightWindow.showWindow();

      // 布局:左 50% 右 50%
      await this.layoutSplit();
      hilog.info(0x0000, 'ParallelView', 'Parallel view started');
      return true;
    } catch (e) {
      const err = e as BusinessError;
      hilog.error(0x0000, 'ParallelView', `Start failed: ${err.code}`);
      return false;
    }
  }

  private async layoutSplit(): Promise<void> {
    const display = await window.getLastWindow(getContext());
    const screenWidth = display.getWindowProperties().windowRect.width;
    const screenHeight = display.getWindowProperties().windowRect.height;

    const isWide = screenWidth >= 600;  // 600dp 宽屏阈值
    if (isWide) {
      // 左右分屏
      await this.leftWindow?.resize(screenWidth / 2, screenHeight);
      await this.leftWindow?.moveWindowTo(0, 0);
      await this.rightWindow?.resize(screenWidth / 2, screenHeight);
      await this.rightWindow?.moveWindowTo(screenWidth / 2, 0);
    } else {
      // 上下分屏
      await this.leftWindow?.resize(screenWidth, screenHeight / 2);
      await this.leftWindow?.moveWindowTo(0, 0);
      await this.rightWindow?.resize(screenWidth, screenHeight / 2);
      await this.rightWindow?.moveWindowTo(0, screenHeight / 2);
    }
  }

  async stopParallelView(): Promise<void> {
    await this.leftWindow?.destroyWindow();
    await this.rightWindow?.destroyWindow();
    this.leftWindow = null;
    this.rightWindow = null;
  }
}

封装要点:

  1. createSubWindow 每个窗口独立 ID,避免冲突
  2. setUIContent 加载独立页面,双窗页面解耦
  3. layoutSplit 根据屏幕宽度自适应左右/上下分屏
  4. stopParallelView 显式销毁,避免窗口泄漏

四、第二步:两个实例怎么共享数据

窗口 B

窗口 A

禁止直接互改内存对象

Ability 实例 1

独立状态与数据源

Ability 实例 2

独立状态与数据源

共享存储层
数据库 / Preferences

变更回调刷新窗口 A

变更回调刷新窗口 B

4.1 状态串扰问题

平行视界最大的工程难点是状态隔离。同一应用的多个 UIAbility 实例默认共享全局状态:

// ❌ 全局状态:双窗共享,状态串扰
let currentSelectedPhoto: Photo | null = null;

// 左窗选中照片 A
currentSelectedPhoto = photoA;
// 右窗同时操作,currentSelectedPhoto 被污染

左窗选了照片 A 准备传给右窗修图,但右窗自己也在选照片 B,全局变量 currentSelectedPhoto 被两边同时改写,状态完全乱套。

4.2 实例级状态隔离方案

HarmonyOS 7.0 的 UIContext 提供实例级状态隔离能力。每个 UIAbility 实例有独立的 UIContext,状态绑定到 UIContext 而非全局:

import { UIContext } from '@kit.ArkUI';

// 实例级状态管理器:每个 UIAbility 实例独立
export class InstanceStateManager {
  private static instances: Map<UIContext, InstanceStateManager> = new Map();
  private state: Map<string, object> = new Map();

  static for(context: UIContext): InstanceStateManager {
    if (!this.instances.has(context)) {
      this.instances.set(context, new InstanceStateManager());
    }
    return this.instances.get(context)!;
  }

  set<T>(key: string, value: T): void {
    this.state.set(key, value as object);
  }

  get<T>(key: string): T | null {
    return (this.state.get(key) as T) || null;
  }
}

// 在页面中使用
@Entry
@Component
struct AlbumPickerPage {
  private uiContext: UIContext = this.getUIContext();

  build() {
    Column() {
      Text(InstanceStateManager.for(this.uiContext).get('selectedPhoto')?.name || '未选中')
      // ...
    }
  }
}

隔离要点:

  1. UIContext 作为实例标识,每个 UIAbility 实例独立
  2. 状态绑定 UIContext,左右窗互不干扰
  3. 共享只读数据源(相册数据)单独管理,不进实例状态

4.3 共享数据源:相册数据只读共享

// 相册数据源:全局单例,双窗只读共享
export class AlbumDataSource {
  private static instance: AlbumDataSource;
  private photos: Photo[] = [];

  static getInstance(): AlbumDataSource {
    if (!AlbumDataSource.instance) {
      AlbumDataSource.instance = new AlbumDataSource();
    }
    return AlbumDataSource.instance;
  }

  async loadPhotos(): Promise<void> {
    const helper = photoAccessHelper.getPhotoAccessHelper(getContext());
    const result = await helper.getAssets({ fetchColumn: ['title'] });
    this.photos = result.getAllObject().map(/* ... */);
  }

  getPhotos(): readonly Photo[] {
    return this.photos;  // 只读视图
  }
}

共享要点:

  1. 相册数据源是全局单例,双窗共享,避免重复加载
  2. 返回 readonly 视图,防止双窗同时修改数据源
  3. 写操作(如标记收藏)走事件总线统一处理

五、第三步:跨窗拖拽把图传过去

5.1 跨窗拖拽需求

用户在左窗选中照片,想直接拖到右窗修图。传统单窗应用不支持跨窗拖拽,HarmonyOS 7.0 的 draggable + onDragStart / onDrop API 支持:

// 左窗 AlbumPickerPage:照片可拖拽
@Entry
@Component
struct AlbumPickerPage {
  private photos: readonly Photo[] = AlbumDataSource.getInstance().getPhotos();

  build() {
    Grid() {
      ForEach(this.photos, (photo: Photo) => {
        GridItem() {
          Image(photo.uri)
            .draggable(true)  // 关键:允许拖拽
            .onDragStart((event) => {
              // 传递照片 URI
              event.extraParams = JSON.stringify({ uri: photo.uri });
            })
        }
      })
    }
  }
}

// 右窗 PhotoEditorPage:接收拖拽
@Entry
@Component
struct PhotoEditorPage {
  @State currentPhotoUri: string = '';

  build() {
    Column() {
      Image(this.currentPhotoUri).width('100%').aspectRatio(1)
    }
    .onDrop((event) => {
      // 接收拖拽数据
      const data = JSON.parse(event.extraParams);
      this.currentPhotoUri = data.uri;
    })
  }
}

拖拽要点:

  1. draggable(true) 启用拖拽源
  2. onDragStart 传递数据(照片 URI)
  3. onDrop 接收数据并更新右窗状态
  4. 跨窗拖拽数据走 JSON 序列化,避免引用共享

5.2 跨窗事件总线

除了拖拽,双窗还需要主动通信(如左窗选中照片,右窗自动加载预览)。HarmonyOS 7.0 的 EventHub 提供应用内事件总线:

import { common } from '@kit.AbilityKit';

export class CrossWindowBus {
  private eventHub: common.EventHub;

  constructor(ability: common.UIAbility) {
    this.eventHub = ability.context.eventHub;
  }

  emit(event: string, data: object): void {
    this.eventHub.emit(event, JSON.stringify(data));
  }

  on(event: string, callback: (data: object) => void): void {
    this.eventHub.on(event, (raw: string) => {
      callback(JSON.parse(raw));
    });
  }
}

// 左窗:选中照片后通知右窗
const bus = new CrossWindowBus(ability);
bus.emit('photo-selected', { uri: photo.uri });

// 右窗:监听选中事件
bus.on('photo-selected', (data) => {
  this.currentPhotoUri = data.uri;
});

事件总线要点:

  1. 数据走 JSON 序列化,避免引用共享导致状态串扰
  2. 事件名集中管理常量,避免拼写错误
  3. 组件销毁时取消监听,避免内存泄漏

六、5 个真实踩坑案例

1. 双窗显示同一实例,左右操作同步

现象:平行视界双窗启动后,左窗点照片,右窗也同步选中;右窗修图,左窗也跟着变。完全失去"平行"意义。

排查:检查 module.json5 的 startMode。默认 singleton 单实例模式,createSubWindow 创建的子窗口复用同一 UIAbility 实例,状态共享。

解决:startMode 改为 multiton,每个子窗口独立 UIAbility 实例:

{
  "abilities": [
    {
      "name": "EntryAbility",
      "startMode": "multiton",  // 关键:多实例
      "supportWindowMode": ["fullscreen", "split", "floating"]
    }
  ]
}

经验:startMode 是平行视界的"开关"。singleton 模式下双窗只是同一界面的两份镜像,必须 multiton 才有真正独立实例。

2. 左窗全局变量污染右窗状态

现象:左窗选中照片 A,右窗的"当前选中"也变成 A;右窗修图时,左窗的预览也跟着变。状态完全串扰。

排查:检查状态存储方式。我最初用全局变量 let currentSelectedPhoto,双窗共享同一变量,互相改写。

解决:状态绑定 UIContext,实例级隔离:

// ❌ 全局变量:双窗共享,串扰
let currentSelectedPhoto: Photo | null = null;

// ✅ 实例级状态:每个 UIAbility 实例独立
const state = InstanceStateManager.for(this.getUIContext());
state.set('selectedPhoto', photoA);

经验:平行视界的状态管理是工程化的核心。全局变量、全局单例的写状态都会串扰,必须实例级隔离。共享只读数据源(如相册数据)可以全局单例,但写操作必须实例隔离。

3. 跨窗拖拽传递引用导致状态泄漏

现象:左窗拖拽照片到右窗,右窗修改照片属性后,左窗的照片数据也被改了,导致左窗显示异常。

排查:检查 onDragStart 传递的数据。我最初直接传递 Photo 对象引用:

event.extraParams = JSON.stringify(photo);  // 序列化整个对象

但 Photo 对象内部有引用类型字段(如 tags: string[]),JSON 序列化后右窗修改 tags,左窗的 tags 也被改了(共享引用)。

解决:跨窗只传递必要标识(URI),不传递整个对象引用:

// ✅ 只传 URI,右窗根据 URI 重新加载数据
event.extraParams = JSON.stringify({ uri: photo.uri });

// 右窗 onDrop
const data = JSON.parse(event.extraParams);
this.currentPhotoUri = data.uri;  // 只用 URI
// 不直接修改 photo 对象,通过事件总线通知左窗更新

经验:跨窗数据传递要走值拷贝,不走引用共享。传递最小必要信息(URI、ID),接收方根据标识重新加载,避免对象引用泄漏导致状态串扰。

4. 分屏下输入框焦点冲突

现象:平行视界双窗都有输入框(如左窗搜索框、右窗修图备注框),左窗输入时右窗输入框也获得焦点,光标乱跳。

排查:HarmonyOS 7.0 多窗默认共享输入法焦点,双窗输入框同时获焦导致冲突。

解决:窗焦点管理 + 输入框失焦策略:

// 窗口获焦时,通知当前窗输入框获焦
this.leftWindow?.on('windowEvent', (event) => {
  if (event === window.WindowEvent.WINDOW_ACTIVE) {
    // 左窗获焦,右窗输入框失焦
    bus.emit('window-focus-changed', { windowId: 'left' });
  }
});

// 右窗监听焦点变化,失焦时输入框 blur
bus.on('window-focus-changed', (data) => {
  if (data.windowId !== 'right') {
    this.rightInputController.blur();
  }
});

经验:多窗输入焦点是平行视界的高频坑。窗焦点变化时主动失焦非当前窗的输入框,避免焦点冲突。这一坑在社区帖评论区被反复问及(“分屏输入框乱跳”),根因都在未做窗焦点管理。

5. 窄屏设备分屏体验崩塌

现象:在窄屏手机(屏幕宽度 < 600dp)上启用平行视界,左右分屏每边只有 180dp 宽,照片网格挤成一团,体验崩塌。

排查:分屏布局未根据屏幕宽度自适应。固定 50/50 左右分屏在窄屏上每边宽度不足,内容无法正常展示。

解决:根据屏幕宽度自适应分屏策略(见第 3.1 节布局策略):

const isWide = screenWidth >= 600;  // 600dp 宽屏阈值
if (isWide) {
  // 左右分屏
} else {
  // 上下分屏 or 降级到单窗 + Tab 切换
}

进一步降级:窄屏设备直接降级到单窗 + Tab 切换模式,不做分屏:

if (screenWidth < 600) {
  // 降级:单窗 + Tab 切换(挑图 / 修图)
  await this.startSingleWindowWithTabs();
  return;
}
// 宽屏:启用平行视界
await this.startParallelView(windowStage);

经验:平行视界不是所有设备都适合。窄屏设备分屏后每边宽度不足,体验反而比单窗差。必须根据屏幕宽度自适应分屏策略,窄屏降级到单窗 + Tab 切换。


七、性能数据与适用边界

7.1 性能数据(模拟器 + 工程估算,真机数据待补充)

指标模拟器真机预估说明
双窗启动耗时~800ms300-500ms创建两个子窗口 + 加载页面
双窗内存占用增量~120MB~80MB第二个 UIAbility 实例开销
跨窗拖拽响应延迟~50ms<30ms拖拽数据序列化 + 事件传递
跨窗事件总线延迟~5ms<3ms应用内事件,无 IPC
双窗同时渲染帧率50-55fps60fps双窗共享渲染管线

数据说明:模拟器渲染性能偏弱,双窗帧率略低于真机。以上真机预估值基于 HarmonyOS 7.0 多窗架构特性与社区帖评论区讨论推算,实际数据需真机实测后补充。

7.2 平行视界 vs 单窗 + Tab 切换对比

平行视界 vs 单窗 Tab 切换:延迟对比柱状图与体验维度雷达图

维度平行视界(多窗多实例)单窗 + Tab 切换
多任务并行效率高(同屏双任务)中(切换有成本)
内存占用高(双实例)低(单实例)
状态隔离复杂度高(需显式隔离)低(单实例天然隔离)
窄屏适配差(分屏后太窄)好(Tab 切换不受屏宽限)
跨窗协作强(拖拽 + 事件总线)弱(无跨 Tab 拖拽)

7.3 适用边界

适合:

  • 宽屏设备(平板、折叠屏展开态、屏宽 ≥ 600dp)
  • 多任务并行场景(相册挑图 + 修图、文档对照、笔记 + 浏览)
  • 跨窗协作需求强(拖拽传图、实时状态同步)

不适合:

  • 窄屏手机(屏宽 < 600dp)——降级到单窗 + Tab
  • 内存敏感设备(低端机双实例 OOM 风险)
  • 简单单任务场景(只是浏览照片,无需多任务并行)

八、小结:多窗带来的效率与代价

相册平行视界工程化落地,核心不是 windowStage.createSubWindow 那一行调用,而是它周围的工程化决策:多实例启动模式、双窗状态隔离、跨窗拖拽传图、窗焦点管理、窄屏降级策略。这些环节在"开个分屏跑 Demo"的模板帖里看不到,却是真实相册应用上线必须趟过的坑。

HarmonyOS 7.0 把多窗多实例能力下沉到系统层(ArkUI windowStage + UIContext 多实例),对相册、文档、笔记类需要"多任务并行"的应用是实实在在的红利——同屏双任务、跨窗拖拽协作、独立状态隔离。后续我会继续探索平行视界与文搜图的联动(左窗文搜图结果、右窗照片详情预览),以及折叠屏铰链动态分屏策略,欢迎关注专栏持续跟进。


边界与已知限制

限制项具体表现规避方式
窗口数量多窗实例数量受系统与内存约束控制同时打开的实例数
数据隔离双实例共享存储,改内存对象不互通变更走存储层 + 回调刷新
拖拽格式跨窗拖拽支持的数据类型有限用标准格式或 Uri 传递
机型支持部分机型/形态不支持多窗能力检测后降级为单窗
尺寸下限窗口过小时多窗布局退化设定最小宽度,低于阈值回单窗
焦点管理双窗同时活跃时焦点判断复杂明确焦点窗口并同步高亮
生命周期一个窗口关闭需通知另一个刷新用存储变更回调而非直接互调

小结

平行视界带来的是**"同屏对照"的效率**,但工程代价是把原本单实例内的状态共享,换成跨实例的存储 + 回调。想明白这一点,双窗数据不同步的问题就解决了一大半。

落地顺序建议:先做双窗启动与布局 → 再做数据隔离(存储共享) → 最后做跨窗拖拽。拖拽是最依赖机型能力的一环,放最后做可以随时降级。

你的相册应用支持平行视界吗?双窗分屏体验如何?评论区交流。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐