👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
   我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
  
  🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
  🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
  💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
  
   如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀

前言

手机 App 的列表→详情跳转天经地义,但一旦搬到折叠屏展开态或平板上,同样的交互会让右侧出现大片空白。HarmonyOS 的 Navigation 组件原生支持三种导航模式,配合窗口断点,可以用同一份代码在手机上保持单栏跳转、在宽屏上呈现列表+详情双栏,而不需要维护两套页面逻辑。

一、为什么手机的"列表→详情"不适合直接搬到大屏

手机上的常规做法是:列表页 push 一个详情页,详情页覆盖整个屏幕。这套结构在窄屏下没问题,但宽屏的可用宽度往往超过 840vp,继续让详情页全屏覆盖,等于浪费了左侧可以同时展示列表的空间,用户也失去了"不离开列表就能切换条目"的操作效率。

传统解法是给平板单独写一套布局,维护两套路由逻辑,一旦页面增多,同步改动的成本很高。

Navigation 的 mode 属性提供了更干净的方案:交给系统根据当前窗口宽度自动决定用单栏还是双栏。

二、Navigation 的三种模式

根据 HarmonyOS ArkUI 官方文档,Navigation 组件的 mode 属性接受 NavigationMode 枚举,共三个值:

枚举值行为
NavigationMode.Stack始终单栏,子页面全屏覆盖,类似传统路由栈
NavigationMode.Split始终双栏,左侧为 NavBar 区域,右侧展示当前目标页
NavigationMode.Auto系统根据当前窗口宽度自动切换 Stack / Split;官方文档说明默认分界宽度为 600vp

Auto 模式是自适应分栏的核心。开发者不需要手动监听窗口宽度,系统会在窗口宽度变化时(包括折叠屏展开/折叠、分屏操作)自动调整布局模式。

版本说明:Navigation 组件及 NavigationMode 枚举自 API 9(对应 HarmonyOS 3.1/4.0 起)开始支持,NavPathStack 自 API 10 引入,用于替代旧的命令式路由接口。当前以 API 12(HarmonyOS 5.0.0)及以上作为推荐开发基准。

三、使用 NavPathStack 管理页面

NavPathStack 是 Navigation 的配套路由栈对象,负责管理所有子页面(NavDestination)的入栈、出栈和参数传递。它的核心特点是:

  • 与 Navigation 组件绑定,一个 Navigation 对应一个 NavPathStack 实例;
  • 通过 pushPathByName(name, param) 跳转,通过 pop() / popToName() 返回;
  • 在双栏模式下,push 不会覆盖列表,而是在右侧详情区展示目标页;
  • 参数通过 NavPathInfo 的 param 字段传入目标页,目标页通过 NavDestinationContext 接收。

这和手机单栏模式下的接口完全一致——路由调用代码不需要分支,系统根据当前模式决定展示行为。

四、搭一个最小实践场景

目标:一个新闻列表页,点击条目后显示详情。手机上详情页全屏覆盖列表,折叠屏展开或平板上列表和详情左右并排。

工程结构:

entry/src/main/ets/
├── pages/
│   └── Index.ets          // 入口页,包含 Navigation
├── views/
│   ├── ArticleList.ets    // 左侧列表内容
│   └── ArticleDetail.ets  // 右侧详情内容(NavDestination)

五、核心代码实现

5.1 入口页:绑定 NavPathStack,设置 Auto 模式

这段代码的作用是:创建路由栈实例,将其绑定到 Navigation,并通过 navDestination 构建器注册所有子页面。

// Index.ets
import { ArticleList } from '../views/ArticleList';
import { ArticleDetail } from '../views/ArticleDetail';

// 路由构建器,Navigation 内部通过 name 查找并渲染对应 NavDestination
@Builder
function PageMap(name: string, param: Object) {
  if (name === 'ArticleDetail') {
    ArticleDetail({ param: param as ArticleDetailParam });
  }
}

@Entry
@Component
struct Index {
  // NavPathStack 实例需要在顶层组件创建并向下传递
  @Provide('pageStack') pageStack: NavPathStack = new NavPathStack();

  build() {
    Navigation(this.pageStack) {
      // Navigation 的 content 区域:窄屏时这里是列表页,宽屏时这里是左栏
      ArticleList()
    }
    .mode(NavigationMode.Auto)   // 关键:交给系统自动切换单/双栏
    .navDestination(PageMap)     // 注册子页面构建器
    .hideTitleBar(true)
  }
}

真正需要关注的是两点:NavPathStack 实例通过 @Provide 向下注入,子组件用 @Consume 取到同一个实例;navDestination 接收一个 @Builder 函数,Navigation 内部根据 push 时传入的 name 调用对应分支来渲染 NavDestination。

5.2 列表组件:触发跳转

// ArticleList.ets
export interface ArticleItem {
  id: number;
  title: string;
  summary: string;
}

export interface ArticleDetailParam {
  item: ArticleItem;
}

const MOCK_DATA: ArticleItem[] = [
  { id: 1, title: '鸿蒙折叠屏适配实践', summary: '本文介绍折叠态切换时的布局处理...' },
  { id: 2, title: 'ArkTS 状态管理深入', summary: '从 @State 到跨组件共享...' },
  { id: 3, title: 'Navigation 路由设计', summary: '单栏与双栏的统一路由模型...' },
];

@Component
export struct ArticleList {
  @Consume('pageStack') pageStack: NavPathStack;

  build() {
    List({ space: 8 }) {
      ForEach(MOCK_DATA, (item: ArticleItem) => {
        ListItem() {
          Column({ space: 4 }) {
            Text(item.title).fontSize(16).fontWeight(FontWeight.Medium)
            Text(item.summary).fontSize(13).fontColor('#666666').maxLines(2)
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(8)
          .onClick(() => {
            // pushPathByName:无论当前是单栏还是双栏,接口调用方式完全相同
            this.pageStack.pushPathByName('ArticleDetail', { item } as ArticleDetailParam);
          })
        }
      }, (item: ArticleItem) => item.id.toString())
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8 })
  }
}

5.3 详情页:NavDestination + 接收参数

// ArticleDetail.ets
import { ArticleDetailParam } from './ArticleList';

@Component
export struct ArticleDetail {
  param: ArticleDetailParam | undefined = undefined;

  build() {
    NavDestination() {
      if (this.param) {
        Column({ space: 12 }) {
          Text(this.param.item.title)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
          Text(this.param.item.summary)
            .fontSize(15)
            .lineHeight(24)
        }
        .width('100%')
        .padding(20)
        .alignItems(HorizontalAlign.Start)
      } else {
        // 双栏模式下,初始右侧为空状态,这里给出占位提示
        Column() {
          Text('请从左侧列表选择一篇文章').fontColor('#999999').fontSize(15)
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
      }
    }
    .title(this.param?.item.title ?? '详情')
  }
}

这里值得单独注意的是:双栏模式下,用户进入页面时右侧不会自动展示任何内容,param 为 undefined,需要显式处理空状态。如果忽略这一点,右侧会是一片空白,布局看起来像是没有完成。

六、几个关键点拆开看

6.1 Auto 模式的分界宽度与窗口断点的关系

NavigationMode.Auto 的 600vp 分界点是 Navigation 组件自身的内建逻辑,与应用层通过 GridRow/BreakpointSystem 设置的断点是两套独立机制。如果项目里同时使用了响应式栅格,要注意两者的切换阈值不一定对齐,列表和详情的布局可能在某个窗口宽度区间表现不符合预期。

6.2 返回栈在两种布局下的行为差异

  • Stack 模式(窄屏):push 后详情页覆盖列表,用户按返回键,NavPathStack 执行 pop,回到列表。
  • Split 模式(宽屏):push 后详情页在右侧展示,NavPathStack 的栈里仍然有这条记录。此时如果用户缩窗(例如折叠屏由展开态折叠),系统切回 Stack 模式,返回栈里的页面会直接以全屏覆盖方式展示,无需额外处理。

也就是说,开发者不需要在窗口模式切换时手动清栈或重新 push,Navigation 的双向切换本身是安全的。

6.3 分屏/折叠状态下的布局重调

当设备从双栏切回单栏(如折叠屏合上),如果此时返回栈非空(用户已打开了某篇详情),用户会看到详情页全屏展示——这是预期行为,和手机上打开详情后的状态一致。

如果希望在切换时有更多控制,可以监听 onNavigationModeChange 回调(API 12 引入),在模式变化时做额外处理,比如在切回单栏时自动 pop 到列表。

Navigation(this.pageStack) {
  ArticleList()
}
.mode(NavigationMode.Auto)
.navDestination(PageMap)
.onNavigationModeChange((mode: NavigationMode) => {
  // mode 为当前实际生效的模式(Stack 或 Split)
  // 可在此处根据业务需要决定是否调整返回栈
  console.info('Navigation mode changed to: ' + mode);
})

6.4 参数传递的类型安全

pushPathByName 的第二个参数类型为 Object,接收方需要做类型断言。建议在项目中统一定义每个页面的 Param 接口,在 PageMap 构建器里做强制断言(如上面代码中的 param as ArticleDetailParam),这样至少在构建器层面有明确的类型预期,出错时比较好定位。

七、容易踩坑的地方

坑1:navDestination 构建器写在了组件内部

navDestination 接收的 @Builder 函数必须是全局的(用 @Builder 修饰的顶层函数),不能是 @Component 的成员方法。如果写成成员方法,编译时不会直接报错,但运行时无法正确渲染子页面。

坑2:双栏模式下没有处理右侧空状态

Navigation 在切入 Split 模式后,如果返回栈为空,右侧区域不展示任何内容。这不是 bug,但视觉上会显得布局不完整。标准做法是在右侧放一个默认的占位 NavDestination,或者在进入 Split 模式时自动 push 一个默认页。

坑3:混淆了 @Provide/@Consume 的作用域

NavPathStack 实例通过 @Provide 注入,必须在 Navigation 所在的组件树中向下传递,跨 Navigation 的组件树无法通过同一个 pageStack 实例通信。如果应用有多个 Navigation 嵌套,每个要用独立实例,别把外层栈传到内层去使用。

坑4:onNavigationModeChange 的 API Level

如果项目 minAPIVersion 低于 12,直接使用 onNavigationModeChange 会在低版本设备上运行报错。需要在 build-profile.json5 中确认 compileSdkVersion 和 minAPIVersion,或者改用手动监听窗口尺寸变化作为兜底。

八、实际项目中怎么排查

如果 Navigation 双栏没有生效,或者布局表现与预期不符,建议按下面顺序检查:

  1. 确认 API Level:NavPathStack 需要 API 10+,onNavigationModeChange 需要 API 12+,先看 build-profile.json5 中的 compileSdkVersion。

  2. 确认 mode 是否设置为 Auto:如果漏写了 mode 属性,默认行为不一定是 Auto,要显式声明。

  3. 确认窗口实际宽度:Previewer 的默认 Phone 尺寸可能不足 600vp,可以切换 Tablet 或 FoldablePhone 预设来触发 Split 模式。

  4. 确认 navDestination 构建器是全局函数:如果右侧一片空白但 push 没有报错,优先检查这里。

  5. 确认 NavPathStack 实例是否同一个:列表页触发 push 的栈和 Navigation 绑定的栈必须是同一个实例,通过 @Provide/@Consume 或直接传参都可以,但不能 new 了两个。

  6. 确认右侧空状态是否有处理:如果 Split 模式下进来就是空白,不一定是 bug,可能只是缺少空状态 UI。

开发经验总结

  • NavigationMode.Auto + NavPathStack 是目前官方推荐的多设备适配路由方案,核心优势在于路由调用代码不需要区分设备形态,布局切换由系统负责。
  • 双栏模式下右侧的空状态是个容易被忽略的细节,需要显式设计占位内容,否则宽屏体验会有明显割裂感。
  • 如果项目同时使用了断点系统或响应式栅格,要留意 Navigation 自身的 600vp 切换阈值与业务断点之间的配合,避免在某个宽度区间出现布局不一致。
  • 窗口模式变化时返回栈的处理是自动的,不需要特殊干预;如果有自定义的栈管理需求,onNavigationModeChange(API 12)是比较干净的介入点。
  • 折叠屏的展开/折叠、分屏操作都会触发窗口宽度变化,Auto 模式对这些场景天然支持,不需要额外监听折叠状态。

如果你正在做类似的适配,可以重点观察一下:当窗口宽度在 600vp 附近连续变化时(比如手动拖动分屏边界),列表和详情的状态是否保持一致——这是检验 NavPathStack 与 Auto 模式配合是否正确的一个直接方法。

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。

Logo

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

更多推荐