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

前言

把一个新闻应用装进折叠屏,打开来应该是什么样子?如果还是手机那套全屏列表跳全屏详情,屏幕展开之后宽出来的那一半空间等于白费了。这就是平行视界(Navigation Split 模式)真正要解决的问题:在大屏上把列表和详情并排显示,在手机上自动退回普通的栈式导航,一套代码,两种体验。

一、手机列表详情模式的问题

手机屏幕窄,列表和详情只能依次全屏展示。用户点击一条资讯,整个屏幕切换到详情页,返回后再看下一条,每次都要在两个页面之间来回切换。这在小屏上是合理的,但在折叠屏展开态或平板上就显得浪费了——宽度已经足够把两者并排放下,却仍然维持着单栏的交互逻辑。

Navigation 组件的 Auto 模式就是为了解决这个问题设计的。它根据当前窗口宽度自动决定用哪种布局:

  • 窗口宽度 ≤ 600vp:Stack 模式,页面栈式叠加,适合手机竖屏
  • 窗口宽度 > 600vp:Split 模式,左侧 NavBar + 右侧 NavDestination,适合大屏

切换完全由系统处理,开发者不需要手动判断设备类型。

二、平行视界的官方边界

先把几个关键条件确认清楚。

组件版本:Navigation 从 API Version 8 开始支持,带 NavPathStack 参数的构造函数在 API 12 引入,无参构造函数在 API 12 废弃。现阶段推荐写法是:

Navigation(this.pathStack) { ... }

模式配置:mode 属性从 API 12 开始支持,接受 NavigationMode 枚举,默认值是 NavigationMode.Auto。

Split 触发阈值:窗口宽度超过 600vp 时进入 Split 模式。这个值是系统默认值,可以通过 navBarWidthRange 和 minContentWidth 间接影响布局比例,但直接修改切换阈值需要使用 Auto 模式下的优先级配置。

NavBar 宽度控制:

优先级:navBarWidthRange > navBarWidth > 默认值(240vp)

navBarWidth 和 navBarWidthRange 仅在 Split 模式下生效。

模式变化回调:onNavigationModeChange 从 API 10 开始支持,每次 Stack ↔ Split 切换时触发。

三、搭一个最小实践

用一个新闻列表场景验证这套模式。目标:

  • 手机态:列表全屏,点击后详情全屏覆盖
  • 大屏态:左侧列表,右侧详情,同屏显示,点击左侧列表项右侧同步更新

工程结构安排如下:

entry/src/main/ets/
├── pages/
│   ├── Index.ets        # 入口页,承载 Navigation 和列表
│   ├── NewsDetail.ets   # 详情页 NavDestination
│   └── CommentPage.ets  # 三级页面 NavDestination
└── structure/
    └── NewsInfo.ets     # 数据模型

四、核心代码实现

4.1 数据模型

// entry/src/main/ets/structure/NewsInfo.ets
export interface NewsInfo {
  id: number;
  title: string;
  content: string;
}

export const NewsList: NewsInfo[] = [
  { id: 1, title: '今日头条:鸿蒙系统新版本发布', content: '详细内容...' },
  { id: 2, title: '科技新闻:人工智能突破', content: '详细内容...' },
  { id: 3, title: '产业动态:折叠屏销量创新高', content: '详细内容...' },
];

4.2 入口页:Navigation + 列表 + 选中状态

这段代码解决三个问题:构建 NavBar 列表、维护左侧选中高亮、Split 模式下自动填充右侧内容。

// entry/src/main/ets/pages/Index.ets
import { NavPathStack } from '@kit.ArkUI';
import { NewsInfo, NewsList } from '../structure/NewsInfo';

@Entry
@Component
struct Index {
  // NavPathStack 必须用 @Provide,让子组件可以通过 @Consume 拿到
  @Provide pathStack: NavPathStack = new NavPathStack();
  @State selectedId: number = -1;

  // PageMap 负责路由名称到组件的映射,所有 NavDestination 都在这里注册
  @Builder
  PageMap(name: string, param: Object) {
    if (name === 'NewsDetail') {
      NewsDetail()
    } else if (name === 'CommentPage') {
      CommentPage()
    }
  }

  build() {
    Navigation(this.pathStack) {
      List({ space: 12 }) {
        ForEach(NewsList, (item: NewsInfo) => {
          ListItem() {
            Text(item.title)
              .fontSize(16)
              .padding(12)
              .width('100%')
              .backgroundColor(
                this.selectedId === item.id ? '#E6F3FF' : Color.Transparent
              )
              .borderRadius(8)
          }
          .onClick(() => {
            this.selectedId = item.id;
            this.pathStack.pushPath({ name: 'NewsDetail', param: item });
          })
        })
      }
      .padding(16)
    }
    .title('新闻')
    .mode(NavigationMode.Auto)
    .navDestination(this.PageMap)
    .onNavigationModeChange((mode: NavigationMode) => {
      if (mode === NavigationMode.Split && this.selectedId === -1) {
        // 切换到大屏 Split 态且右侧还没有内容时,自动展示第一条
        const firstItem = NewsList[0];
        this.selectedId = firstItem.id;
        this.pathStack.pushPath({ name: 'NewsDetail', param: firstItem });
      }
    })
  }
}

真正需要关注的是三处:

  1. @Provide pathStack 的声明位置必须在 @Entry 组件里,否则子孙组件的 @Consume 找不到提供方;
  2. PageMap 是一个 @Builder,它的参数 name 直接对应 pushPath 时传入的 name 字段,两侧字符串必须一致;
  3. onNavigationModeChange 里的 pushPath 是在模式切换完成后执行的,不是在组件 build 阶段,所以不会引起构建阶段的状态竞争问题。

4.3 详情页:NavDestination 接收参数并跳转下一级

// entry/src/main/ets/pages/NewsDetail.ets
import { NavPathStack } from '@kit.ArkUI';
import { NewsInfo } from '../structure/NewsInfo';

@Component
export struct NewsDetail {
  @Consume pathStack: NavPathStack;
  @State newsInfo: NewsInfo = { id: 0, title: '', content: '' };

  build() {
    NavDestination() {
      Column() {
        Text(this.newsInfo.title)
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .padding({ top: 24, bottom: 12, left: 16, right: 16 })
          .width('100%')

        Text(this.newsInfo.content)
          .fontSize(16)
          .padding({ left: 16, right: 16 })
          .width('100%')
          .lineHeight(24)

        Button('查看评论')
          .margin({ top: 24, left: 16 })
          .onClick(() => {
            // 在 NavDestination 内部继续向路由栈推入新页面
            this.pathStack.pushPath({ name: 'CommentPage', param: this.newsInfo });
          })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
    }
    .title(this.newsInfo.title)
    .onReady((ctx: NavDestinationContext) => {
      // onReady 是接收导航参数的正确位置,先于子组件 build 执行
      this.newsInfo = ctx.pathInfo.param as NewsInfo;
    })
  }
}

参数通过 onReady 回调里的 ctx.pathInfo.param 取得,这是官方推荐的接收方式。onReady 在 NavDestination 子组件构建之前触发,所以可以在这里安全地初始化状态。

4.4 三级页面:CommentPage

// entry/src/main/ets/pages/CommentPage.ets
import { NavPathStack } from '@kit.ArkUI';
import { NewsInfo } from '../structure/NewsInfo';

@Component
export struct CommentPage {
  @Consume pathStack: NavPathStack;
  @State newsInfo: NewsInfo = { id: 0, title: '', content: '' };

  build() {
    NavDestination() {
      Column() {
        Text(`「${this.newsInfo.title}」的评论`)
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .padding(16)
          .width('100%')

        Text('暂无评论,快来抢沙发。')
          .fontSize(14)
          .fontColor('#999999')
          .padding({ left: 16 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Start)
    }
    .title('评论')
    .onReady((ctx: NavDestinationContext) => {
      this.newsInfo = ctx.pathInfo.param as NewsInfo;
    })
    .onBackPressed(() => {
      // 返回 false:交由系统处理默认出栈逻辑
      // 如果需要拦截(比如弹出"是否放弃编辑"确认框),返回 true
      return false;
    })
  }
}

五、几个关键点拆开看

左侧选中高亮与路由是两件事

很多开发者第一次写 Split 布局时,只处理了 pushPath,没有维护 selectedId,结果右侧内容更新了,左侧列表项没有任何选中反馈。这两件事需要分开处理:

.onClick(() => {
  this.selectedId = item.id;   // 驱动视觉高亮
  this.pathStack.pushPath(...); // 驱动右侧内容更新
})

selectedId 是 @State,它的变化会触发 ForEach 里每个 ListItem 的重新渲染,通过 backgroundColor 表达选中状态。

Split 模式初始化右侧内容

应用冷启动直接在折叠屏展开态时,onNavigationModeChange 不会触发(因为从来没有经历过切换),右侧会是空白的。这是一个值得注意的边界情况。

按照官方示例中的处理思路,可以在 onNavigationModeChange 之外,额外在组件的 onAppear 或初始化逻辑里检查当前窗口宽度,如果大于 600vp 则主动填充右侧内容。具体检查当前窗口宽度可以结合 window 模块或者响应式布局断点能力处理,本文暂不展开这一分支,实际项目中建议在目标设备上验证冷启动路径。

深层路由在 Split 下的行为

从 NewsDetail 再跳到 CommentPage,此时路由栈是 [NewsDetail, CommentPage]。在 Split 模式下,右侧内容区会叠加显示 CommentPage,左侧 NavBar 始终保持可见——这与手机 Stack 模式下 CommentPage 全屏覆盖整个界面的行为是不同的。

官方文档明确提到:在 Navigation 的 Split 模式下,路由入栈时 NavBar 不会触发 onHide,导航到下一级页面时 NavBar 依然保持可见。这是 Split 模式的设计预期,不是 bug。

六、容易踩坑的地方

NavPathStack 必须用状态管理传递

如果把 NavPathStack 定义为普通成员变量而不是 @State 或 @Provide,子组件中调用 pushPath 时路由操作不会触发 UI 刷新,页面跳转会静默失败。官方文档明确指出:NavPathStack 需要存储在父组件的状态中。

// 错误写法:普通变量,无法触发 UI 更新
pathStack: NavPathStack = new NavPathStack();

// 正确写法:@Provide 配合 @Consume
@Provide pathStack: NavPathStack = new NavPathStack();

PageMap @Builder 字符串必须与 pushPath 的 name 完全一致

PageMap 里用 if (name === 'NewsDetail') 做分支判断,而 pushPath({ name: 'NewsDetail', ... }) 的字符串必须完全相同,包括大小写。字符串写错时,路由不会报错,只是右侧内容区什么都不渲染,很难直接定位到原因。

onReady 是接收参数的正确位置,不是 aboutToAppear

aboutToAppear 在组件构建阶段执行,此时 NavDestinationContext 还未就绪,通过 ctx.pathInfo.param 取参数会拿到空值。参数应当在 onReady 回调里取,它在子组件构建之前触发,时机是正确的。

mode 属性从 API 12 开始支持

如果工程的 compileSdkVersion 低于 12,直接写 .mode(NavigationMode.Auto) 会有兼容性问题。写作本文时对应的 HarmonyOS NEXT 环境下,API 12 已是基准版本,但如果需要兼容更早的版本,这一点需要提前核实。

七、折叠回手机态后的表现

折叠屏从展开态折回手机态时,Navigation 从 Split 切换到 Stack 模式,onNavigationModeChange 触发,参数为 NavigationMode.Stack。

此时需要关注两点:

  1. 路由栈不会被清空。如果展开态时路由栈里有 [NewsDetail, CommentPage],折叠后 Stack 模式只显示栈顶的 CommentPage,用户继续点返回才能回到 NewsDetail,再返回到列表。这个行为是合理的,但如果业务上希望折叠后重置到列表,需要在 onNavigationModeChange 里主动调用 pathStack.clear()。

  2. 左侧高亮状态在 Stack 模式下不可见,但 selectedId 仍然存在于状态中。再次展开为 Split 时,高亮会自动恢复,这是期望行为。如果不希望保留上次的选中状态,可以在切到 Stack 时重置 selectedId。

.onNavigationModeChange((mode: NavigationMode) => {
  if (mode === NavigationMode.Stack) {
    // 按需决定是否重置选中状态
    // this.selectedId = -1;
  } else if (mode === NavigationMode.Split) {
    if (this.selectedId === -1) {
      const firstItem = NewsList[0];
      this.selectedId = firstItem.id;
      this.pathStack.pushPath({ name: 'NewsDetail', param: firstItem });
    }
  }
})

八、实际项目中怎么排查

当 Split 双栏布局没有如期出现或右侧内容区空白时,按以下顺序检查:

  1. 确认 API Level:Navigation(navPathStack) 和 mode 属性要求 API 12,onNavigationModeChange 要求 API 10,先确认工程 compileSdkVersion 是否满足。

  2. 确认窗口宽度是否超过 600vp:模拟器默认窗口宽度可能不足 600vp,Split 不会触发。换成折叠屏模拟器展开态或平板模拟器再测。

  3. 确认 NavPathStack 的传递方式:是 @Provide/@Consume 还是普通变量,普通变量无法驱动 UI 更新。

  4. 检查 PageMap @Builder 的注册:路由 name 是否与 PageMap 里的分支字符串完全一致。

  5. 检查参数接收位置:是在 onReady 里取参数,还是错误地放在了 aboutToAppear。

  6. 排查冷启动路径:应用直接在大屏打开时 onNavigationModeChange 不触发,需要额外处理初始化逻辑。

开发经验总结

  • Navigation 的 Auto 模式是实现平行视界的核心,不需要手动判断设备类型,阈值 600vp 是系统默认值。
  • 左侧选中高亮和路由跳转是两件事,需要同时维护 @State 和 pathStack.pushPath,缺一不可。
  • Split 模式下更深层的路由会叠加在右侧内容区,NavBar 始终可见,这是预期行为而非异常。
  • NavPathStack 必须通过 @Provide/@Consume 传递,定义为普通变量时路由调用会静默失败。
  • 折叠屏形态切换不会清空路由栈,按业务需要决定是否在 onNavigationModeChange 里重置状态。

如果你正在做类似资讯、邮件、笔记这类列表-详情结构的应用,可以重点测试一下折叠屏从展开态直接冷启动时右侧是否有默认内容——这条路径很容易在只测试"展开后再折叠"的场景时被遗漏。

📝 写在最后

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

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

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

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

Logo

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

更多推荐