【鸿蒙心迹】折叠屏适配踩坑实录:平行视界开发避坑全复盘
一、为什么选择平行视界:一次“抄近路”的决策
项目叫 MailDesk,是一个邮件聚合阅读应用。开发初期只适配了手机竖屏,直到用户反馈“展开折叠屏后,左右两侧大片留白,中间内容被拉伸得惨不忍睹”。全面重构分栏布局意味着将几十个页面的栅格、断点、间距全部推倒重来,对一个小团队来说是不可承受的成本。
平行视界(EasyGo)是华为给出的系统级兼容方案:一份 easy_go.json 配置文件,在宽屏窗口里把页面强制分成左右两页,左侧固定为主页、右侧承载路由跳转,不动业务页面代码。这看起来是一条完美的近路。
官方文档把接入过程描述得很克制:“在 profile 目录下创建配置文件,在 module.json5 中添加 easyGo 字段指向它,当前仅支持在 entry 模块下配置,配置后应用级生效。”我们照着做,模拟器上一切正常:列表在左、详情在右,1:1 分栏,点击切换流畅。
然后真机到了。展开折叠屏的那一刻,四个坑同时炸了。
二、坑一:homePage 识别失败,左右关系整个反了
现象
真机展开后,邮件列表跑到了右边,详情页被塞进了左边。模拟器上明明是对的。
排查过程
先看 easy_go.json 的配置。当时我们的配置是这样的:
json
{
“common”: {
“displayModeOptions”: {
“navigationSplitOptions”: {
“relatedPage”: “MailListPage”,
“mode”: 1
}
}
}
}
没有配 homePage。我们以为系统会智能识别——Navigation 的首页就是 navBar,这还需要配吗?
官方文档的原话是:默认的主页识别机制“在有些场景下不能准确识别”。哪些场景?典型的两类:Navigation 首页不是 navBar,而是某个 NavDestination 用 NavPathStack 直接推上去的;或者冷启动走了 relatedPage,路由栈初始态和系统认定的“主页”对不上。
MailDesk 恰好命中了第二类。冷启动时我们通过 relatedPage 直接进入了邮件列表页(MailListPage),路由栈里第一项不是 navBar,系统于是“猜”错了主页,把列表塞进了右侧。
模拟器为什么看不出来? 因为模拟器的窗口尺寸固定、路由栈干净,默认识别机制恰好能猜对。真机折叠展开、分屏切换、多实例这些时序一搅,识别逻辑就暴露了。
解法
homePage 永远显式配置,不赌默认识别。Navigation 首页是 navBar,就写 “homePage”: “navBar”;想让某个 NavDestination 当主页,就配它的 name。
json
{
“common”: {
“displayModeOptions”: {
“navigationSplitOptions”: {
“homePage”: “navBar”,
“relatedPage”: “MailListPage”,
“mode”: 1
}
}
}
}
教训:平行视界的“智能识别”是兜底能力,不是保证能力。任何依赖“系统会猜对”的设计,在真机上都会被打脸。
三、坑二:UI 截断,右半页装不下整屏布局
现象
主页识别修好之后,左右关系对了,但右侧详情页的邮件正文溢出屏幕——固定宽度的卡片被截断,绝对定位的标签飞到了屏幕外,硬编码的 16vp 左右边距在半个屏幕里显得荒谬地宽。
根因
平行视界把原来整屏的布局塞进“右半页”,窗口宽度直接砍半。你按整屏写的固定宽度元素、绝对定位、硬编码边距,在这个减半的容器里全部溢出。
这是官方“常见问题”的第一条,也是真机翻车率最高的一处。
解法:三板斧,按优先级来
第一斧:自适应布局(治本)。 用百分比、layoutWeight、折行容器重写右页布局。邮件正文区域改为 width(‘100%’),卡片改为 layoutWeight(1),标签使用 Flex({ wrap: FlexWrap.Wrap })。这是唯一不依赖开关的方案,也是长期正确的方向。
第二斧:虚拟容器兜底(性价比最高)。 官方提供了一种“虚拟容器”能力,让未适配的页面在分栏后自动缩放以适应右半页的宽度。对于来不及逐页改造的存量页面,这是最快的止血方案。
第三斧:配合断点做差异化适配。 不要用固定值判断,用 window.on(‘windowSizeChange’) 监听窗口变化后重新计算断点。在邮件正文页中,当断点从 lg 降到 md 时,自动隐藏右侧的邮件元信息面板,将正文区域铺满可用宽度。
typescript
// 在右页页面中监听窗口尺寸
window.on(‘windowSizeChange’, (size) => {
const breakpoint = getBreakpoint(size.width);
if (breakpoint === ‘md’) {
this.showMetaPanel = false; // 窄空间下隐藏元信息
} else {
this.showMetaPanel = true;
}
});
官方特别强调:折叠过程中主要监听 window.on(‘windowSizeChange’),不要在 foldDisplayModeChange 回调里立即读取窗口尺寸。我们最初犯了这个错——在折叠回调里读宽度,拿到的还是折叠前的值。
四、坑三:路由栈分裂,返回逻辑全面失联
现象
分栏正常后,我们在右栏点击了一封邮件的附件,附件以全屏方式打开。返回时,系统看起来回到了双栏状态,但业务路由栈已经丢了一层——再点一次返回,直接退出了邮件列表页,而不是回到上一封邮件。
根因
平行视界分栏后,系统只接管 NavPathStack 的路由栈。如果项目里混用了全局 router,router 的页面栈对系统完全不可见,导致返回逻辑、弹窗层级、页面跳转全部脱节。
MailDesk 的历史代码里有一部分页面用的是 router.pushUrl(),另一部分用了 NavPathStack。分栏之前两者各自为政,勉强能跑;分栏之后,系统接管了 NavPathStack,router 的页面栈变成了“隐形人”,返回时系统不知道该怎么处理。
解法:统一路由栈,弃用全局 router
规则一:全项目统一使用 NavPathStack。 把所有 router.pushUrl / router.back 全部迁移到 this.pathStack.pushPathByName / this.pathStack.pop。
规则二:分栏模式下,详情页的返回只弹右栏的栈,不退出左栏。 在 Navigation 的 onNavigationModeChange 回调中同步当前模式,根据模式决定 pop 行为:
typescript
.onNavigationModeChange((mode: NavigationMode) => {
this.actualNavMode = mode;
if (mode === NavigationMode.Split && this.pathStack.size() === 0) {
// Split 首次出现且右栏为空,默认推入第一封邮件
this.pathStack.replaceByName(‘MailDetail’, { id: this.mailList[0].id });
}
})
规则三:全局返回统一收口。 当右栏已经压入第二层详情后,用户执行全局返回,应该只退右栏,不应该把左边选中的邮件一起重置。在 Navigation 上使用 .hideBackButton(true) 隐藏左栏根节点的返回按钮,右栏的返回由 NavPathStack.pop() 控制层级。
五、坑四:多实例状态串台,选左改右
现象
平行视界分栏后,左栏的邮件列表和右栏的详情页在视觉上独立了,但状态是共享的。用户在左栏点击第二封邮件,右栏的详情切换了,但左栏的选中高亮也跟着变了——两栏的 selectedMailId 指向同一个全局状态。
根因
平行视界本质上是同一应用的多实例运行。多实例默认共享全局状态,不显式隔离就会出现“左边选的照片右边也选中了”的串台现象。
MailDesk 中,MailListPage 和 MailDetailPage 共用了同一个 AppStorage 中的 selectedMailId。分栏之前,两个页面互斥显示,共享状态没问题;分栏之后,两个页面同时存活,状态冲突立刻暴露。
解法:显式隔离状态树
将左栏和右栏的状态完全分离。左栏维护自己的 selectedMailId,右栏维护独立的 currentDetailId。两者通过事件总线通信,而不是直接共享状态:
typescript
// 左栏
@State leftSelectedId: string = ‘’;
// 右栏
@Local rightDetailId: string = ‘’;
// 左栏点击时,通过事件通知右栏,而不是直接改右栏的状态
onMailClick(mailId: string) {
this.leftSelectedId = mailId;
eventBus.emit(‘mailSelected’, { mailId });
}
关键原则:左栏的状态只归左栏管,右栏的状态只归右栏管,跨栏通信走事件总线,不走共享状态。
六、坑五:临时全屏恢复后,右栏路由栈丢了一层
现象
用户在右栏打开一封邮件的附件(PDF),附件以沉浸式全屏显示。关闭附件返回双栏后,右栏显示的是邮件列表页,而不是刚才那封邮件的详情页。用户需要重新点击邮件才能回到详情。
根因
平行视界把大屏应用拆成左右两栏后,路由的层级关系变得复杂。右栏打开全屏页面时,系统会将窗口从分栏模式临时切换为全屏模式;关闭全屏返回时,系统会恢复分栏,但右栏的路由栈状态可能已经在模式切换中丢失了一层。
官方示例把“路由跳转过程中请求全屏”列为典型场景之一,但文档没有给出状态恢复的完整方案。我们在 MailDesk 中摸索出的做法是:在请求全屏之前,先保存右栏的当前路由状态;全屏返回后,根据保存的状态恢复右栏内容。
typescript
// 打开附件前,保存右栏状态
this.savedDetailId = this.rightDetailId;
this.pathStack.pushPathByName(‘AttachmentViewer’, { url: attachmentUrl });
// 全屏返回后,恢复右栏
onPageShow() {
if (this.savedDetailId && this.pathStack.size() === 0) {
this.pathStack.replaceByName(‘MailDetail’, { id: this.savedDetailId });
}
}
更稳妥的方案是先 Push 过渡页,再请求沉浸式全屏。过渡页作为一个轻量的 NavDestination,在它内部请求全屏。这样全屏页面的路由层级是挂在过渡页下面的,返回时只需要 pop 过渡页,右栏的原始路由栈不会被触碰。
七、坑六:窄屏降级与自由多窗的边界
窄屏降级
平行视界有一条硬边界:窗口宽度小于 600vp 时不进入平行视界,按单页运行。 这意味着折叠屏在折叠态(外屏)下,应用会自动回到单栏模式。
我们的 Navigation 用了 NavigationMode.Auto,窄屏时自动变成 Stack 模式,列表和详情依次压栈。但问题出在状态恢复:用户在外屏上看了邮件详情,展开到内屏后,Auto 模式变成 Split,系统需要决定右栏显示什么。
如果右栏为空,用户看到的是左侧列表 + 右侧空白,体验很差。解法是在 onNavigationModeChange 中监听模式切换,当从 Stack 切到 Split 且右栏为空时,自动把当前选中的邮件推入右栏:
typescript
.onNavigationModeChange((mode: NavigationMode) => {
if (mode === NavigationMode.Split && this.pathStack.size() === 0 && this.currentMailId) {
this.pathStack.replaceByName(‘MailDetail’, { id: this.currentMailId });
}
})
自由多窗的边界
另一条硬边界:自由多窗模式(Free Multi-Window)下暂不支持平行视界。 如果应用同时声明了自由多窗能力,用户在自由窗口模式下拖拽缩放时,平行视界不会生效,页面会以普通单栏模式渲染。
这意味着你不能把平行视界当作唯一的宽屏适配方案。对于需要在自由窗口下正常工作的场景,仍然需要独立的响应式布局兜底。
横屏全屏的配置
还有一个容易忽略的配置项:supportLandscapeFullscreen。当应用请求横屏时,如果这个字段为 true,会退出分栏、全屏展示页面。我们最初没有配这个字段,用户在右栏打开横屏视频时,视频被压缩在半个屏幕里播放。配置后,横屏请求会触发全屏模式,退出分栏,视频铺满整屏。
八、选型决策:平行视界、自由多窗还是自写 Navigation
踩完这六个坑之后,我们重新审视了平行视界的适用范围。它不是一个“万能适配层”,而是一个针对特定场景的系统级兜底方案。
方案 适用场景 不适用场景
平行视界 EasyGo 列表→详情的频繁切换(邮件、电商、IM、内容阅读) 强自定义画布(绘图、视频编辑)、游戏
自写 Navigation Split 需要精确控制分栏比例、状态隔离、路由层级的场景 存量页面过多、短期无法重构的项目
自由多窗 需要与其他应用并排运行的多任务场景 平行视界不生效,需独立适配
判断标准很简单:如果你的应用的核心交互是“从列表进入详情再返回”,平行视界值得用。如果你的应用的核心交互是在一个连续画布上操作(绘图、编辑、时间线),平行视界会打断你的工作流。
九、复盘:平行视界开发的五条铁律
homePage 永远显式配置。 不赌默认识别,模拟器的“正确”不代表真机的正确。
全项目统一 NavPathStack。 混用 router 会让返回逻辑在分栏后彻底失联。
左栏和右栏的状态完全隔离。 共享状态在分栏后必然串台,跨栏通信走事件总线。
临时全屏先保存再恢复。 全屏返回后右栏路由栈会丢一层,必须手动恢复。
接受边界,准备降级。 窄屏不生效、自由多窗不生效、强画布类应用不适用——平行视界是兜底方案,不是万能方案。
十、写在最后
平行视界最大的陷阱,是它的“简单”制造了一种虚假的安全感。一份 JSON、一行注册,模拟器上跑通了,你以为适配完成了。真机展开的那一刻,主页识别反转、UI 截断、路由栈分裂、状态串台、全屏恢复丢栈——五个问题同时炸开。
官方文档写的是“能做什么”,踩坑实录写的是“配完之后什么会崩”。两者之间的距离,就是真机展开的那一秒钟。
别让模拟器骗你。在真机上展开、折叠、分屏、全屏、返回,走完每一条用户路径,再告诉自己“适配完成了”。
更多推荐



所有评论(0)