# 〖共创稿事节〗HarmonyOS 7 新特性实战(56):阅读流全部进栈——七级返回矩阵、两种 NavDestination 模式与一次宽屏回归

改造前,「山海万灵」的返回键只有一个入口:@Entry 组件上的 onBackPress。里面按顺序判断一串布尔量——地图覆盖层开着吗、主题弹层开着吗、异兽详情开着吗、篇章停在封面还是正文……每加一层浮层就多一个 if,而且顺序相互耦合:谁先判,谁就能抢到这一次返回。

这套写法在只有两三层时还能靠记忆维护。等到阅读流变成「目录 → 封面 → 正文 → 异兽详情 → 篇章 → 再点异兽」这种可以任意叠压的结构,问题就压不住了。真实发生的一次:深链路径「文章 → 异兽 → 篇章」里按返回,先关掉的是壳层里那份看不见的异兽详情,篇章页反而留在屏幕上。

这次改造的核心,不是把浮层换个画法,而是把「谁消费返回键」从一堆布尔量的判断顺序,换成一条栈。

古书封面:篇章 destination 的入口态,返回键在这里先合书、再出栈

一、先把「按一次返回会发生什么」写成矩阵

栈能不能接管返回键,标准只有一个:每一层的返回结果能不能被写死。写不成表,就说明还有耦合没拆干净。

层级屏幕最上层谁消费这次返回栈深变化证据
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();
}

只要有一处「只翻标志位、不推栈」或者「只弹栈、不清标志位」,两层就会各说各话:标志位说弹层开着,栈里却没有那一页,返回键落到一个不存在的最上层。

超分查看器搬成 DIALOG destination 后仍满屏,返回键由它自己消费

五、提根之后: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见第一节表格与对应截图
正文→封面栈深不变PASSm1-05 与 m1-07 逐字节相同
深链叠栈四连返回PASS正文→封面→异兽→文章→探索,栈序符合预期
三设备首页结构与基线一致m1-home-matepad-fixed、m1-home-matebook-fixed
DIALOG 弹层进出(主题 / 超分)PASSm2-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 组件参考。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐