HarmonyOS 7 DualCart 平行视界适配实录 05:MultiWindowEntryInAPP × UIAbility:应用内分屏任务并行与购物状态同步【鸿蒙心迹】
前四期里,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
更多推荐



所有评论(0)