〖共创稿事节〗HarmonyOS 7 新特性实战(56):阅读流全部进栈——七级返回矩阵、两种 NavDestination 模式与一次宽屏回归
# 〖共创稿事节〗HarmonyOS 7 新特性实战(56):阅读流全部进栈——七级返回矩阵、两种 NavDestination 模式与一次宽屏回归
改造前,「山海万灵」的返回键只有一个入口:@Entry 组件上的 onBackPress。里面按顺序判断一串布尔量——地图覆盖层开着吗、主题弹层开着吗、异兽详情开着吗、篇章停在封面还是正文……每加一层浮层就多一个 if,而且顺序相互耦合:谁先判,谁就能抢到这一次返回。
这套写法在只有两三层时还能靠记忆维护。等到阅读流变成「目录 → 封面 → 正文 → 异兽详情 → 篇章 → 再点异兽」这种可以任意叠压的结构,问题就压不住了。真实发生的一次:深链路径「文章 → 异兽 → 篇章」里按返回,先关掉的是壳层里那份看不见的异兽详情,篇章页反而留在屏幕上。
这次改造的核心,不是把浮层换个画法,而是把「谁消费返回键」从一堆布尔量的判断顺序,换成一条栈。

一、先把「按一次返回会发生什么」写成矩阵
栈能不能接管返回键,标准只有一个:每一层的返回结果能不能被写死。写不成表,就说明还有耦合没拆干净。
| 层级 | 屏幕最上层 | 谁消费这次返回 | 栈深变化 | 证据 |
|---|---|---|---|---|
| 1 | 主题外观弹层 | 弹层自己的 onBackPressed | 不变(弹层关) | m1-09 → m1-10 |
| 2 | 地图覆盖层 | 壳层 onBackPress | 不变(覆盖层收起) | m1-11 → m1-12 |
| 3 | 古籍总目录 | 目录 destination | −1(回「我的」) | m1-03 → m1-04 |
| 4 | 篇章正文 | 篇章 destination → 页内状态 | 不变(回封面) | m1-06 → m1-07 |
| 5 | 篇章封面 | 篇章 destination | −1(回目录) | m1-08 |
| 6 | 异兽详情 | 详情 destination | −1,且清壳层状态 | m1-02、m1-19 → m1-20 |
| 7 | 文章阅读 | 文章 destination | −1(回来源页) | m1-13 → m1-14 |
| 末行 | 首页(栈空) | 不消费,交给系统 | — | 现场验证 |
这张表里有两行是「不变」:第 1、2 行是浮层,第 4 行是页内两级状态。它们不是页面,硬塞进栈会把层级算错——第 4 行如果也 push 一层,正文按返回会先弹出一个「封面页」,再按返回才回目录,等于让用户多点一次。
于是壳层的返回逻辑被压缩到只剩两件事:
onBackPress(): boolean {
// 地图覆盖层最先响应:它在视觉上盖住一切,返回键就该先关掉它。
if (this.showWorldMap) {
this.showWorldMap = false;
return true;
}
// 主题弹层自 M2 起在栈上(DIALOG destination),返回键由它自己的 onBackPressed 接,
// 这里不再判 themeSheetVisible —— 否则同一个返回键会被两层各消费一次。
// 阅读流四页自 M1 起同样在栈上。只剩壳层内的异兽详情:栈空时它才是最上层。
// 栈非空时必须放行 —— 否则深链「文章→异兽→篇章」里按返回会先关掉壳层那份
// 看不见的详情,篇章页反而留在屏幕上。
if (this.selectedBeastId.length > 0 && this.pageStack.size() === 0) {
this.closeBeastDetail();
return true;
}
return false;
}
// 地图覆盖层压在路由页之上:返回键先关它,再谈出栈。
// destination 的 onBackPressed 与 Index.onBackPress 共用这一个判据 —— 系统返回键
// 先派发给谁是不确定的,两条入口行为必须一致。
private consumeOverlayBack(): boolean {
if (this.showWorldMap) {
this.showWorldMap = false;
return true;
}
return false;
}
pageStack.size() === 0 这个条件是整段的关键。它把「壳层详情」和「栈上详情」区分开了:栈上有一层时,用户看到的是栈顶那一页,壳层详情就算还挂在那里也不该抢返回。删掉这个条件,深链路径立刻复现上面那个 bug。
二、七个 destination,一张路由表
搬完之后,入栈页面一共七类:
// 阅读流四页
const READER_PAGE_CATALOG: string = 'ChapterCatalog'; // 古籍总目录
const READER_PAGE_CHAPTER: string = 'ChapterReader'; // 篇章阅读
const READER_PAGE_ARTICLE: string = 'ArticleReader'; // 专题阅读
const READER_PAGE_BEAST: string = 'BeastDetail'; // 异兽详情
// 两个弹层型满屏层(DIALOG 模式)
const READER_PAGE_THEME_SHEET: string = 'ThemeModeSheet';
const READER_PAGE_SUPER_RES: string = 'SuperResolution';
// 分栏态右栏的常驻页
const MAP_PAGE: string = 'MapPage';
路由表本身很短,但它的写法有一条硬约束:
// 路由表:navDestination 必须传**@Builder 方法引用**(官方范式),
// 不能传内联 lambda —— 实测 lambda 里调 @Builder 产不出节点,
// 结果是 destination 只剩系统默认标题栏、内容全空(2026-10-25 模拟器踩到)。
@Builder
AppPageMap(name: string, param: string) {
if (name === READER_PAGE_CATALOG) {
this.ChapterCatalogDestination();
} else if (name === READER_PAGE_CHAPTER) {
this.ChapterReaderDestination(param);
} else if (name === READER_PAGE_ARTICLE) {
this.ArticleReaderDestination(param);
} else if (name === READER_PAGE_BEAST) {
this.BeastDetailDestination(param);
} else if (name === READER_PAGE_THEME_SHEET) {
this.ThemeModeSheetDestination();
} else if (name === READER_PAGE_SUPER_RES) {
this.SuperResolutionDestination(param);
} else if (name === MAP_PAGE) {
this.MapDestination();
}
}
还有一条只有编译器会告诉你的事:param 的类型只能是 string。写 unknown 触发 arkts-no-any-unknown;不写类型标注,lambda 参数推断不出来;写 param?: string 又报 string | undefined 不可赋值。于是目录页这种无参页面也统一压一个空串进去,而不是省掉这个参数。
三、正文→封面不能 pop:用字节数验证栈深
第 4 行「栈深不变」是这张表里最容易做错的一条。做法是把「合书」当成页内状态:
// 篇章页的返回键分两级(2026-10-05 起的行为,M1 逐条保留):
// 正文里按返回 = 合上书回封面(消费事件,不出栈,栈深度不变);
// 封面上再按返回才出栈。
private chapterReaderBack(): boolean {
if (!this.chapterCoverVisible) {
this.chapterCoverVisible = true;
return true;
}
this.popReaderPage();
return true;
}
// 阅读流统一出栈口。所有 pop 都走这里,是为了在分栏态下补一次「右栏常驻页」:
// 出栈后栈可能变空,右栏会剩一片白,必须把地图补回去。
private popReaderPage(): void {
this.pageStack.pop(READER_PAGE_ANIMATED);
this.scheduleMapFloorRestore();
}
// 切 tab / 进馆长页时统一出栈,空栈时不动。
private clearReaderPages(): void {
if (this.pageStack.size() > 0) {
this.pageStack.clear(READER_PAGE_ANIMATED);
}
this.scheduleMapFloorRestore();
}
「栈深没变」不能靠肉眼判断,因为合书后的封面和进入正文前的那一帧本来就该长得一样。验收用的是截图字节:
| 截图 | 内容 | 字节数 | 判据 |
|---|---|---|---|
m1-05 | 进入篇章时的封面 | 157,403 | 基准 |
m1-07 | 正文按一次返回后的封面 | 157,403 | 与基准逐字节相同 ⇒ 没有 pop |
两张图完全相同,说明第一次返回只翻了页内状态;第二次返回(m1-08)才真正出栈回目录。如果这里多推了一层封面,m1-07 会是一张新渲染的页面,字节数必然不同。
「出栈统一走一个口」也不是洁癖。popReaderPage() 里除了 pop 还多补了一步:分栏态下栈可能被弹空,右栏会剩一片白,所以要排一次「把地图补回右栏」。如果各处直接调 pageStack.pop(),这条补救会漏掉一半路径——在全屏手机上完全看不出来,只在分栏时才暴露。
同一类问题还有一处:深链把 activeTab 直接写成 2 时绕过了 switchTab(),而清栈就在 switchTab() 里。结果是热启动深链被已压栈的文章页挡在后面。改回 this.switchTab(2) 之后,切 tab 与清栈重新是同一步。
四、两种 NavDestination 模式:页面用默认,弹层用 DIALOG
主题弹层和超分查看器改造前是 build() 里与沉浸框架同级的条件渲染层。这样写有一个好处:遮罩能盖住底栏,超分图能铺到屏幕最左边。搬进栈之后要保持同样的观感,就得用 NavDestinationMode.DIALOG:
@Builder
ThemeModeSheetDestination() {
NavDestination() {
ShanhaiThemeModeSheet({
isDarkMode: this.isDarkMode,
themeMode: this.themeMode,
onSelect: (mode: ShanhaiThemeMode) => {
this.selectThemeMode(mode);
},
onClose: () => {
this.closeThemeModeSheet();
}
})
}
// 默认透明底、栈操作不影响下层可见性 —— 这正是弹层要的语义。
.mode(NavDestinationMode.DIALOG)
.hideTitleBar(true)
.width('100%')
.height('100%')
.onBackPressed(() => {
this.closeThemeModeSheet();
return true;
})
}
这里有一个必须成对维护的东西:themeSheetVisible / superResViewerVisible 这两个布尔量没有删,而是降级成「显隐意图」镜像。渲染源是栈,但别的逻辑还要读它们——超分查看器的标志参与「底栏是否可见」,主题弹层的标志被 selectThemeMode 用来收弹层,分栏态下还要靠它们把导航栏收掉,否则 DIALOG 只铺满右栏。
private openThemeModeSheet(): void {
this.themeSheetVisible = true;
this.pageStack.pushPath({ name: READER_PAGE_THEME_SHEET, param: '' }, READER_PAGE_ANIMATED);
}
private closeThemeModeSheet(): void {
this.themeSheetVisible = false;
this.popReaderPage();
}
private openSuperResolution(beastId: string): void {
this.superResViewerVisible = true;
this.pageStack.pushPath({ name: READER_PAGE_SUPER_RES, param: beastId }, READER_PAGE_ANIMATED);
}
private closeSuperResolution(): void {
this.superResViewerVisible = false;
this.popReaderPage();
}
只要有一处「只翻标志位、不推栈」或者「只弹栈、不清标志位」,两层就会各说各话:标志位说弹层开着,栈里却没有那一页,返回键落到一个不存在的最上层。

五、提根之后:336px 被裁掉,以及一条被注释截断的属性链
M1 只是把阅读流搬进栈,Navigation 还包在框架里。M2 要开分栏,必须让 Navigation 成为根容器——因为分栏的语义是「navBar 是左栏、destination 是右栏」,壳层必须在 navBar 里,否则左栏没有背景层。
提根之后立刻出现一个布局问题:首页内容只占屏幕上部约 81%,下面一片白,底栏比基线高出一大截。布局转储给出的数字很直接:
Navigation [0,117][1320,2120]
NavBar [0,117][1320,2120]
NavBarContent [0,117][1320,1784] ← 底部少 336px,且 clip:true
改造前框架在 Navigation 之外,直接吃满窗口,所以没这问题;提根后框架成了 navBar 的内容,被 Navigation 的布局安全区裁掉。两次试错都失败:在 navBar 里再包一层 Stack 加扩展安全区——被父层的 clip:true 挡住;直接在 Navigation 上加忽略布局安全区——仍无效。
正解是三件事一起写,而且官方文档把前提说得很清楚:要让组件伸进非安全区,标题栏与工具栏必须隐藏,或者设成 STACK 模式。
Navigation(this.pageStack) {
// 这层 Stack 不是多余的:①给框架一个确定的 100% 尺寸基准(navBar 槽位本身
// 没有显式高度);②承载扩展安全区 —— Navigation 会把 navBar 内容裁到
// 「状态栏下沿 ~ 手势区上沿」,不扩的话底图铺不满、屏幕最下面露出窗口底色。
Stack() { /* 沉浸框架 + 壳层 */ }
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
// 布局层忽略安全区。官方前提:标题栏与工具栏必须隐藏,否则本属性不生效。
.hideTitleBar(true)
.hideToolBar(true)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.navDestination(this.AppPageMap)
改完 NavBarContent 变成 [0,117][1320,2120],滚动高度从 1667 回到 2003,首页与基线一致。还有一个正向副作用:平板首屏原本被挤到折叠线以下的「今日路线 / 馆长建议」两张卡,重新回到首屏可见。
顺带记一个编译期坑:属性链中间不能插注释。在组件块结束和 .expandSafeArea(...) 之间写一段注释,编译报 Declaration or statement expected 加 Cannot find name 'expandSafeArea'——注释把链式调用截断了。属性要挂在容器上,注释写在容器外面。
六、一次宽屏回归:Auto 分栏把壳层压成 240vp
M1 刚搬完就遇到一个只在平板上出现的问题:左侧竖排导航退化成底部横向 tab 栏,正文被压成屏幕左侧一条窄列,右侧大片空白。手机完全正常。
定位时在尺寸回调里临时打日志,平板量到 area vp = 240 × 848。240vp 正是 Navigation 分栏模式 navBar 的默认宽度。原因是 NavigationMode.Auto 的规则:窗口宽超过断点就转分栏,而分栏模式下第一个子节点会被当作 navBar 塞进侧栏。壳层因此只拿到 240vp,应用自己的宽屏判定(840×520vp 阈值)恒假,整套宽屏布局失效。
M1 的修法是显式钉死 NavigationMode.Stack。这个修法在 M1 成立,因为那时应用的宽屏布局是自己算的;但到了 M3,Stack 的字面意思就是「不分栏」,而 Auto 的断点又会在手机横屏把界面劈成两栏,两头都不能用。最后的解法是按应用自己的断点二选一:
// 不能钉死 Stack(Stack 就是「不分栏」,手机横屏会被 Auto 劈成两栏);
// 也不能留给 Auto(它的断点与应用自己的宽屏断点不同)。
// 所以按应用判定显式二选一。
.mode(this.shouldSplitLayout() ? NavigationMode.Split : NavigationMode.Stack)
这条回归值得单独记,因为它揭示了一个通用陷阱:组件的默认模式可能悄悄改变你的布局基准。Navigation 默认 Auto,在窄屏上看起来完全正常;一旦窗口变宽,它不仅换了布局方式,还重新解释「第一个子节点是谁」。应用层量到的宽度从「整窗」变成「侧栏」,所有基于这个宽度的判定一起失效,而且没有任何错误日志。
七、验收:七级矩阵、两个模式与两条未覆盖
改造后的验收按里程碑分两批,判定口径一致:每一层都要有「按一次返回后停在哪」的可核对证据。
| 验收项 | 结果 | 证据 |
|---|---|---|
| 七级返回矩阵第 1–7 行 | 全部 PASS | 见第一节表格与对应截图 |
| 正文→封面栈深不变 | PASS | m1-05 与 m1-07 逐字节相同 |
| 深链叠栈四连返回 | PASS | 正文→封面→异兽→文章→探索,栈序符合预期 |
| 三设备首页结构 | 与基线一致 | m1-home-matepad-fixed、m1-home-matebook-fixed |
| DIALOG 弹层进出(主题 / 超分) | PASS | m2-05 → m2-06、m2-07 → m2-08 |
| 提根后安全区裁剪修复 | PASS | 布局转储 [0,117][1320,2120],滚动高度 2003 |
| 全量构建检查 | 42/42 PASS | 两轮改动后重跑,0 失败 |
构建检查里唯一需要改写的是超分查看器那组断言:它原本钉的是「条件渲染 + 直接翻标志位」的旧接法;M2 之后改成钉「开:翻镜像 + 推栈;关:清镜像 + 弹栈」。这条改动本身也是个提醒——断言钉的是接法,接法变了就必须同步改断言,否则脚本会用旧结构判新代码。
两项没有覆盖,都写进了记录而不是含糊过去:
openChapterBeast的一条分支(文章→异兽→篇章→再点异兽,走pop+ 替换栈顶):需要在篇章正文翻到末页才能触发,《轩辕本纪》157 页,模拟器逐页翻的成本过高。栈序按「异兽不重复入栈」的前提推演过,属于推演而非实测。- 深链冷热启动:模拟器的卡片深链不可用(官方最小探针同样失败),只验了冷启动本身。
还有一条观察项,不是这次引入的:文章页长标题换行会撑高页头,与「地域专卷」印章重叠。两轮改造都没碰这段布局,先记着。
八、这一轮真正值钱的地方
三条,都不是「某个组件怎么用」:
1. 返回键的归属必须能写成表。 判断顺序是隐式契约,栈是显式契约。写不出「每一层返回后停在哪」的表,就说明还有耦合没拆干净;写得出表,就能把壳层那段 if 压缩到只剩两个条件。2. 「没变」要有可核对的判据。 「正文→封面栈深不变」这种不变量,肉眼看不出来,但截图字节数能。157,403 这个数字比一句「已验证」有用得多——因为如果哪天有人多推了一层,字节数会立刻不等。3. 组件默认值会改变你的布局基准。 Navigation 的默认 Auto 在窄屏一切正常,宽屏却把「第一个子节点」重新解释成侧栏,应用层量到的宽度从整窗变成 240vp。遇到「只有宽屏坏、窄屏全对」时,先看组件的默认模式是不是跟着窗口变了。
官方对 Navigation 与 NavDestination 的说明见 Navigation 组件参考。
更多推荐
所有评论(0)