前四期里,DualCart 一直在一个购物主任务里推进:列表和详情组成平行视界,过渡页不占右栏,商品视频可以临时进入全屏横屏,比价则放进独立虚拟容器。

做到这里,页面已经能“同时看很多内容”,但仍然只有一条主购物任务。

这一篇开始处理平行视界官方示例里的最后一类大场景:平行世界场景下触发应用内分屏。

它和前面的双栏不是一回事。

平行视界双栏仍然属于一个 UIAbility / 一套主购物上下文;应用内分屏则会把另一个 UIAbility 作为独立窗口任务拉起来,让用户同时处理两件事。

DualCart 这次定义成:

EntryAbility
主购物任务:
ProductList + ProductDetail
当前商品 product_1047 / MateBook Air

CompareAbility
辅助任务:
商品对比
product_1047 vs product_1051 / MatePad Pro

本轮统一数据:

taskId:
parallel_20261002_05

scene:
APP_INNER_SPLIT

device:
Tablet · Landscape

viewport:
1024vp

splitRatio:
EQUAL 1:1

primaryAbility:
EntryAbility

secondaryAbility:
CompareAbility

primaryProduct:
product_1047 / MateBook Air / ¥7999

compareProduct:
product_1051 / MatePad Pro / ¥5999

cartCount:
2

favoriteCount:
5

launchCost:
71ms

syncCost:
12ms

closeCost:
44ms

status:
MULTIWINDOW_READY

一、平行视界双栏和应用内分屏,先在工程里彻底分开

第四篇的虚拟容器看起来已经很像“两个任务同时做”。

但本质上它仍然属于当前页面上下文:

ProductList | ProductDetail
+
VirtualCompareContainer

关闭容器,主路由继续。

应用内分屏则是:

UIAbility A
|
UIAbility B

两个窗口各自有页面生命周期和路由,但又属于同一个应用。

HarmonyOS 当前智慧多窗文档已经明确提供应用内分屏能力:声明支持 split 后,可以通过 startAbility() 携带 StartOptions 启动另一个 UIAbility;API 26 还支持 splitRatio 指定 EQUAL、PRIMARY_DOMINANT、SECONDARY_DOMINANT。官方同时提供 MultiWindowEntryInAPP 组件,专门承载同一应用多个窗口任务并行的入口。

所以我没有继续给 ParallelRouteManager 加“第二任务”逻辑,而是新建:

entryability/
├── EntryAbility.ets
└── CompareAbility.ets

managers/
└── AppInnerSplitCoordinator.ets

pages/
└── CompareWorkspacePage.ets

主路由和多窗口生命周期从这一篇开始分层。

二、先把 secondary UIAbility 拉起来,再谈状态共享

我先用显式 startAbility() 做调试链路,因为它能清楚控制窗口模式和分屏比例。

这段代码解决的是“辅助任务到底是不是独立 UIAbility”的问题:

import {
  AbilityConstant,
  common,
  StartOptions,
  Want
} from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'

export class AppInnerSplitCoordinator {
  private context:
    common.UIAbilityContext

  constructor(
    context: common.UIAbilityContext
  ) {
    this.context = context
  }

  async launchCompare(): Promise<void> {
    const want: Want = {
      bundleName:
        'com.example.dualcart',
      moduleName: 'entry',
      abilityName: 'CompareAbility',
      parameters: {
        taskId:
          'parallel_20261002_05',
        primaryProductId:
          'product_1047',
        compareProductId:
          'product_1051'
      }
    }

    const options:
      StartOptions = {
      windowMode:
        AbilityConstant.WindowMode
          .WINDOW_MODE_SPLIT,
      splitRatio:
        window.SplitRatioPreference.EQUAL
    }

    await this.context
      .startAbility(
        want,
        options
      )
  }
}

这里我没有把 PRIMARY_DOMINANT 硬写成所有设备默认。

本轮测试设备是横屏平板,采用 EQUAL 1:1,目的是让主商品和对比任务都保持足够可用宽度。

实际产品是否使用 1:1、1:2 或 2:1,需要结合设备形态和业务优先级再验证。

三、MultiWindowEntryInAPP 更适合做“用户可见入口”

显式 startAbility() 方便测试,但正式页面如果只是放一个普通按钮,会让用户很难预期“点下去会开启应用内多窗”。

当前 MultiWindowEntryInAPP 组件就是为这个场景准备的。

项目里把它放到商品详情的“多任务”入口:

import {
  MultiWindowEntryInAPP
} from '@kit.UIDesignKit'
import { Want } from '@kit.AbilityKit'

@Component
struct CompareMultiWindowEntry {
  private want: Want = {
    bundleName:
      'com.example.dualcart',
    moduleName: 'entry',
    abilityName: 'CompareAbility',
    parameters: {
      taskId:
        'parallel_20261002_05',
      primaryProductId:
        'product_1047',
      compareProductId:
        'product_1051'
    }
  }

  build() {
    MultiWindowEntryInAPP({
      want: this.want,
      isShowSubtitle: true
    })
      .size({
        width: 48,
        height: 48
      })
  }
}

HarmonyOS 当前 API 文档说明,这个组件依赖全景多窗特性,只有设备与屏幕状态支持时才可交互;支持的典型形态包括展开态双折叠、部分三折叠形态和平板横屏。

也就是说,入口是否可用本身就应该尊重系统能力,不要为了“所有设备都有同一个按钮”自己模拟一个假多窗。

四、两个 UIAbility 不能共享页面对象,只共享业务状态

真正拉起 CompareAbility 后,第一版最明显的问题是购物车数量不同步。

主窗口:

cartCount=2
favoriteCount=5

辅助窗口刚启动时还是:

cartCount=0
favoriteCount=0

原因很简单。

我之前的状态主要绑定在主页面 Store 上,CompareAbility 并没有自动拿到那一份页面实例。

所以第五篇新增 SharedShoppingState.ets。

它保存的是进程级业务数据:

export interface SharedShoppingSnapshot {
  primaryProductId: string
  compareProductId: string
  cartCount: number
  favoriteCount: number
}

export class SharedShoppingState {
  private static instance:
    SharedShoppingState

  private snapshot:
    SharedShoppingSnapshot = {
      primaryProductId:
        'product_1047',
      compareProductId:
        'product_1051',
      cartCount: 2,
      favoriteCount: 5
    }

  static shared():
    SharedShoppingState {
    if (!this.instance) {
      this.instance =
        new SharedShoppingState()
    }

    return this.instance
  }

  current():
    SharedShoppingSnapshot {
    return {
      ...this.snapshot
    }
  }

  updateCart(count: number): void {
    this.snapshot.cartCount = count
    SharedShoppingBus.emit(
      'cartChanged',
      count
    )
  }
}

这只是 DualCart 自己的业务层。

它不依赖平行视界配置,不持有 Window,也不持有 UIAbility 实例。

五、共享“值”而不是共享“页面”

这一条在多窗口里很重要。

两个 UIAbility 可以同时显示:

EntryAbility
CompareAbility

但我不会让 CompareAbility 直接引用主页面的:

ProductListPage
NavPathStack
Scroller
ProductDetail Component

它只读取:

primaryProductId
compareProductId
cartCount
favoriteCount

这样主窗口关闭、重新拉起,辅助任务不会因为主页面对象销毁就立刻失效。

如果以后需要跨进程或者跨 HAP,状态同步层还可以继续替换成持久化或 IPC 方案,页面无需改成“知道另一个窗口对象”。

六、CompareAbility 也有自己的 Navigation

辅助任务不是一张静态对比卡。

它后面可能继续:

CompareWorkspace
→ ProductSpec
→ CouponInfo

所以 CompareAbility 内部也使用 Navigation,而不是简单 Column()。

当前入口:

@Entry
@Component
struct CompareWorkspacePage {
  @State
  private primaryId: string =
    'product_1047'

  @State
  private compareId: string =
    'product_1051'

  build() {
    Navigation(
      CompareRouteStore.shared()
        .pathStack()
    ) {
      CompareBoard({
        primaryId:
          this.primaryId,
        compareId:
          this.compareId
      })
    }
    .mode(
      NavigationMode.Stack
    )
  }
}

这里故意使用 Stack。

应用内分屏已经把屏幕拆成两个 Window,CompareAbility 自己再强行做大双栏,只会让每个区域更窄。

多窗口外层解决任务并行,内部 Navigation 解决单个任务流程。

七、状态同步必须区分“实时同步”和“启动参数”

Want.parameters 很适合告诉 CompareAbility:

启动时比较谁

但它不适合承担后续实时购物车同步。

所以当前边界是:

Want
→ 初始任务参数

SharedShoppingState
→ 同进程共享状态

Preferences
→ 需要跨生命周期保留的轻量状态

比如主窗口把购物车从 2 改成 3,CompareAbility 不需要重新启动,它只监听状态事件:

aboutToAppear(): void {
  this.cartCount =
    SharedShoppingState.shared()
      .current()
      .cartCount

  SharedShoppingBus.on(
    'cartChanged',
    this.onCartChanged
  )
}

aboutToDisappear(): void {
  SharedShoppingBus.off(
    'cartChanged',
    this.onCartChanged
  )
}

第五篇的 syncCost=12ms 就是本轮一次主窗口加购物车后,辅助窗口更新 UI 的工程观测值。

它不是 HarmonyOS 系统指标。

八、关闭辅助窗口后,主购物任务必须继续

应用内分屏最容易出现的误区,是把它当成“两个窗口必须一起活”。

DualCart 不是这样。

主任务:

EntryAbility

才是用户当前主要购物上下文。

辅助任务:

CompareAbility

可以随时关闭。

CompareAbility 的关闭流程只做:

保存必要 compare 状态
解除共享状态监听
terminateSelf

主窗口不重新加载,不重建 ProductList,也不改变:

selectedProduct=product_1047

本轮关闭辅助任务耗时:

closeCost=44ms

关闭后主窗口继续停在 MateBook Air 详情。

九、应用内分屏还要先声明支持 Split

代码能 startAbility() 不代表工程配置可以忽略。

当前 HarmonyOS 智慧多窗文档说明,可以在 module.json5 的 ability 配置中通过 supportWindowMode 声明支持 split;缺省值通常包含 fullscreen / split / floating,但正式工程仍然应该明确核对自己的 ability 配置和设备支持状态。

DualCart 会在发版检查里确认:

EntryAbility:
split support

CompareAbility:
split support

目标设备:
支持应用内分屏

屏幕形态:
满足当前能力约束

如果设备不支持,就回退到第四篇的虚拟比价容器,而不是按钮点了没反应。

十、DevEco 图里要看两个 Ability,而不是只看两栏 UI

开发图:

本轮 HiLog:

taskId=parallel_20261002_05

launch:
CompareAbility

split:
EQUAL

primary:
EntryAbility

secondary:
CompareAbility

shared:
product_1047
compare=product_1051
cart=2
favorite=5

syncCost=12ms
launchCost=71ms

closeCost=44ms

status:
MULTIWINDOW_READY

如果页面确实分成两半,但日志仍然只有一个 UIAbility,那它只是普通布局,不是第五篇要验证的应用内分屏。

十一、运行图:一屏两任务,但购物数据仍然是同一份

最终运行图:

左侧主任务:

EntryAbility
MateBook Air
product_1047
¥7999

右侧辅助任务:

CompareAbility
MateBook Air
VS
MatePad Pro

共享:

cartCount=2
favoriteCount=5

当前状态:

MULTIWINDOW_READY

这一步完成以后,DualCart 才真正从“单应用双栏”进入“单应用多任务并行”。

十二、第五篇最后做了四组反向测试

正常打开一次还不够。

我固定测了四组。

第一组,设备不支持应用内多窗时,入口不可交互,业务回退到虚拟容器。

第二组,CompareAbility 已经存在时再次触发入口,不能再创建第三个重复窗口。

第三组,辅助窗口修改购物车,主窗口在 12ms 左右完成状态同步,且 selectedProduct 不变。

第四组,关闭 CompareAbility 后检查:

主窗口仍在
ProductList / ProductDetail 状态未丢
cartCount 保持
compare listener 已解除

这四条都通过以后,05 才结束。

十三、下一篇不再加新窗口,而是把六类场景一起回归

到现在 DualCart 已经有六类很容易互相打架的路径:

PHONE_STACK
FOLDABLE_PAIRED
TRANSITION_BRIDGE
IMMERSIVE_VIDEO
VIRTUAL_COMPARE
APP_INNER_SPLIT

最后一篇不会再增加能力。

06 要做的是连续 20 轮跑完整场景,检查:

路由冲突
状态丢失
监听泄漏
重复窗口
切换耗时
内存增长

做到这一层,平行视界适配才从“功能展示”进入真正可以发版的工程状态。

十四、共享状态不能采用“谁后写谁赢”的无脑策略

两个 UIAbility 同时存在以后,状态冲突会变成真实问题。

比如主窗口在用户点击“加入购物车”时把:

cartCount
2 → 3

辅助窗口也可能正在执行:

取消一个对比商品

如果两边都把完整 SharedShoppingSnapshot 整体覆盖回去,就可能出现:

主窗口刚把 cartCount 改成 3
辅助窗口拿旧快照写回
cartCount 又变成 2

所以 DualCart 不允许“一次更新整个对象”。

共享状态改成字段级动作:

export type ShoppingAction =
  | {
      type: 'CART_COUNT'
      value: number
      source: string
    }
  | {
      type: 'FAVORITE_COUNT'
      value: number
      source: string
    }
  | {
      type: 'COMPARE_PRODUCT'
      value: string
      source: string
    }

export class SharedShoppingState {
  apply(action: ShoppingAction): void {
    switch (action.type) {
      case 'CART_COUNT':
        this.snapshot.cartCount =
          action.value
        break

      case 'FAVORITE_COUNT':
        this.snapshot.favoriteCount =
          action.value
        break

      case 'COMPARE_PRODUCT':
        this.snapshot.compareProductId =
          action.value
        break
    }

    SharedShoppingBus.emit(
      'stateChanged',
      action
    )
  }
}

这样每个动作只修改自己负责的字段。

这也是为什么 05 的共享状态不是“一个大 JSON 在两个窗口之间来回覆盖”。

十五、主任务和辅助任务还要定义谁拥有最终决策权

有些状态可以双向同步,例如购物车数量。

有些状态却不能让两个 Ability 随便改。

比如:

primaryProductId

它代表主购物任务当前正在看的商品。

CompareAbility 可以读取它,但不能因为用户在辅助窗口切了对比商品,就反过来把主商品也换掉。

所以我给字段增加 owner 规则:

primaryProductId
owner = EntryAbility

compareProductId
owner = CompareAbility / CompareSession

cartCount
owner = SHARED

favoriteCount
owner = SHARED

同步层收到不属于当前窗口所有权的写入时,只记录日志,不真正修改。

这种规则看起来有点“重”,但多窗口一旦允许两个窗口都写同一份状态,没有所有权边界很快就会出现诡异覆盖。

十六、辅助窗口重复启动,要靠任务 ID 做幂等

第五篇做压力测试时,连续点两次多窗入口,系统很快出现两个 CompareAbility 启动请求。

页面上第二个窗口不一定真的显示出来,但业务层已经产生两次:

register listener
load compare data
restore compare state

所以 AppInnerSplitCoordinator 增加任务级幂等:

export class SplitTaskRegistry {
  private activeTaskId: string = ''

  tryAcquire(
    taskId: string
  ): boolean {
    if (
      this.activeTaskId ===
      taskId
    ) {
      return false
    }

    this.activeTaskId = taskId
    return true
  }

  release(taskId: string): void {
    if (
      this.activeTaskId === taskId
    ) {
      this.activeTaskId = ''
    }
  }
}

parallel_20261002_05 已经 ACTIVE 时,再触发同一任务就不会重复初始化。

窗口关闭以后才 release。

这样“重复点击入口”不会变成“重复创建状态监听”。

十七、辅助 Ability 被系统回收以后,也要能重新拿到关键上下文

虽然这一轮主要测试同进程多窗,但我不把 CompareAbility 的全部状态只放在内存里。

至少下面这些值得持久化:

taskId
primaryProductId
compareProductId
category

因为辅助 Ability 如果被重新创建,用户不应该看到一个空的 CompareWorkspace。

页面自己的:

activeTab
galleryIndex
临时动画进度

可以不持久化。

恢复优先级:

Want.parameters
→ SharedShoppingState
→ Preferences fallback

三层里只要拿到同一个 taskId,就能恢复这次辅助任务。

如果 taskId 已经过期,则直接回 Compare 首页,而不是拿上一轮商品继续显示。

十八、关闭 CompareAbility 时,先断开订阅,再结束窗口

第一版关闭顺序是:

terminateSelf()
→ aboutToDisappear()
→ off listener

实际测试里偶尔会在窗口已经退出过程中,再收到一次 cartChanged。

虽然 UI 已经看不到,但日志会出现:

CompareAbility received event after closing

现在顺序固定为:

mark CLOSING
→ off SharedShoppingBus
→ save compare state
→ terminateSelf
→ SplitTaskRegistry.release

页面的事件处理器也先判断:

state === ACTIVE

才更新 UI。

这让 closeCost=44ms 的含义更完整:不是动画结束,而是辅助任务真正完成收口。

十九、应用内分屏并不意味着两个窗口必须共享同一个 NavigationPathStack

这一点我在第五篇里反复确认。

两个 UIAbility 的业务目标不同:

EntryAbility:
浏览 / 详情 / 购物车

CompareAbility:
比价 / 规格 / 优惠

如果为了“状态同步”让它们共享同一个 PathStack,任何一边 push 页面都会影响另一边。

所以共享的是:

商品 ID
购物车
收藏
轻量任务状态

不共享:

NavigationPathStack
Scroller
页面生命周期
Window 对象

同步业务状态,不同步 UI 实例,这是本篇最核心的工程边界之一。

二十、我最后用六组验收确认它真的是两个任务,而不是一张双栏页面

最终测试固定为六组。

第一组,平板横屏点击入口后,确认同时存在 EntryAbility 和 CompareAbility。

第二组,主窗口加入购物车,辅助窗口 12ms 左右同步到 cartCount=2 的当前基线更新路径。

第三组,辅助窗口切换 compareProduct,不允许覆盖主窗口 primaryProductId=product_1047。

第四组,连续点击入口 5 次,只允许一份 parallel_20261002_05 任务处于 ACTIVE。

第五组,关闭 CompareAbility,确认:

split secondary window=0
listener=0
primary window 继续

第六组,在不支持当前应用内多窗形态的设备上,入口降级,不制造一个假分屏。

这六项都通过,最终才记录:

status=MULTIWINDOW_READY

这也是 06 会继续拿来回归的第六个场景。

参考资料

  • 平行视界官方示例:https://developer.huawei.com/consumer/cn/samples/
  • 应用声明支持智慧多窗:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/multi-window-support
  • MultiWindowEntryInAPP:https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ui-design-multiwindowentryinapp-api
  • Navigation 分栏开发:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/arkts-navigation-split-mode
Logo

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

更多推荐