我在做一款资讯阅读应用时,真正让我返工的不是两栏布局,而是一个看起来不严重的现象:平板上从左侧列表点进右侧详情,再从通知打开同一篇文章,画面还是那篇文章,但按一次返回,竟然又出现一模一样的详情。回到手机单栏形态,问题更明显:明明只看了一篇文章,却得连续返回两次。

项目叫 ArticleDock。这轮拿文章 news_042 做稳定的调试样本,文章标题设为“HarmonyOS 7 带来的多设备协同体验”。本文关心的不是如何把两列摆在屏幕上,而是怎样让列表的选择、导航页面栈和通知深链用相同的文章身份说话;这是一个真实产品需要的工程边界。

一、视觉正确不代表路由正确:两个入口可能在堆叠同一页

最初 ArticleDock 的入口有两个。第一个是用户在列表里点击文章;第二个是系统通知带着 articleId 唤起应用。每个入口都很正常,开发者直觉上会写成“拿到 ID,调用 pushPath”。当时的版本确实很快跑通:手机上是列表再进入详情;大屏上列表和详情可以同时展示。

毛病出在把“打开文章”当成一种无状态动作。同一篇文章如果已经在详情栏显示,再执行一遍 push,就不是刷新,而是在栈顶压上一层。双栏下这两层显示相同内容,几乎看不出来;到了单栏,返回键的真实行为便暴露出来:先退掉第一层 NewsDetailPage,下面还是第二层 NewsDetailPage。用户会觉得返回键失灵。

这说明要分清三个名词。选中态用于列表高亮,可能随着数据更新变化;路由态决定返回、转场和可见目的地;内容态描述当前文章正文、评论和滚动位置。它们有关联,但并不是一个变量。把 selectedArticleId 当作路由存在的证据,和用 Navigation 的页面栈代替整个业务数据源一样,都会制造隐藏问题。

对于这轮 Demo,我先规定一个可检验的不变量:当 news_042 已经是栈顶详情时,再接收同一文章的打开请求,不允许新增详情目的地;接收新文章则允许替换或正常跳转,具体策略应由交互约定决定。我们现在选择同一文章去重,不同文章入栈,理由是用户从列表追看多篇文章时,还要能逐级返回。

二、Navigation 的 Auto 负责形态,业务仍然负责身份

HarmonyOS 的 Navigation 支持 Stack、Split、Auto 显示模式。适用于多形态的阅读应用通常从 Auto 开始,随着窗口宽度变化调整呈现方式;但这只是容器的自适应显示策略,不会替应用理解“同一文章不能重复入栈”。官方文档也推荐通过 NavPathStack 管理目的地,而不是把路由分散到很多页面内。把两者结合起来,才方便观察堆栈变化。

我将项目拆成三个主要文件:ArticleDockPage.ets 负责容器和列表,DetailRouteService.ets 负责路由策略,DetailPage.ets 负责渲染正文。另有 ArticleRepository.ets 管理文章数据。这里刻意不把网络请求放进导航服务:服务只决定“打开哪个文章”,并不负责下载文章正文。否则一个慢请求就会影响用户的返回响应。

第一段代码先建立 Navigation 的根容器。它解决的不是去重,而是确保所有文章入口使用同一个 NavPathStack 实例:

// ArticleDockPage.ets:省略 ArticleList、DetailPage 的独立组件定义
@Entry
@Component
struct ArticleDockPage {
  private pageStack: NavPathStack = new NavPathStack()
  @State selectedArticleId: string = 'news_042'

  @Builder
  pageMap(name: string, param: Object) {
    if (name === 'NewsDetailPage') {
      DetailPage({ articleId: (param as ArticleRouteParam).articleId })
    }
  }

  build() {
    Navigation(this.pageStack) {
      ArticleList({ onPick: (id: string) => {
        this.openArticle(id, 'list')
      } })
    }
    .mode(NavigationMode.Auto)
    .navDestination(this.pageMap)
    .title('ArticleDock')
  }
}

interface ArticleRouteParam {
  articleId: string
  origin: string
}

这个片段是说明组件职责的业务代码片段:ArticleList、DetailPage 由工程另行实现,不能把它误认为一份开箱即跑的完整页面。真正落地时,还要结合所用 SDK 的 @Builder 参数类型、路由表注册方式和预览器能力逐项编译核对。

有一个经常被混淆的地方:平行视界 EasyGo 的历史配置、Navigation 的自适应单双栏、以及高级导航组件,不应混成一套 API。不同系统版本和项目接入方式存在区别。ArticleDock 采用的是 ArkUI Navigation 的适配路径,目标是解决与平行视界体验相关的路由一致性,不宣称只加一份旧版 easygo.json 就完成了所有设备的适配。

三、去重逻辑放在路由入口,而不是详情页出现之后

收到第二次点击时,最省事的做法是在 DetailPage 的生命周期里检查:如果当前文章重复,就立刻 pop。可我不建议这样做。原因是此时页面已经构建,生命周期和动画都可能开始,甚至已触发一次数据请求。这不仅产生闪动,还会把去重结果交给目的地页面本身,业务链路很难解释。

更稳妥的选择是统一入口。在调用 pushPathByName 之前判断栈顶的路由名和参数。导航系统负责最终的栈状态,业务层只做与文章 ID 相关的幂等判断。代码如下:

// DetailRouteService.ets:核心去重逻辑
interface ArticleRouteParam {
  articleId: string
  origin: string
}

export class DetailRouteService {
  open(stack: NavPathStack, articleId: string, origin: string): string {
    const names: string[] = stack.getAllPathName()
    const topIndex: number = names.length - 1
    if (topIndex >= 0 && names[topIndex] === 'NewsDetailPage') {
      const top = stack.getParamByIndex(topIndex) as ArticleRouteParam
      if (top && top.articleId === articleId) {
        console.info(`ArticleDock duplicate push ignored: ${articleId}`)
        return 'DEDUP_OK'
      }
    }
    stack.pushPathByName('NewsDetailPage', { articleId, origin } as Object)
    console.info(`ArticleDock open article: ${articleId}, from=${origin}`)
    return 'DETAIL_ACTIVE'
  }
}

我们只检查栈顶同一文章,不是全栈搜索到同名目的地就强行回退。因为如果用户按“文章 A→文章 B→文章 A”阅读,历史保留两个 A 是可能合理的;全栈名字去重会破坏返回语义。选择同名不同 ID 正常入栈,也能保证从 A 跳到 B 的阅读路径可追踪。

还有一个容易忽略的细节:NavPathStack.getAllPathName() 返回目的地名称,不是文章 ID。若仅凭 NewsDetailPage 名称判断去重,用户根本打不开第二篇文章。因此参数比较不可省。真实工程还需要防抖和入口串行控制:同一帧内两个异步入口都判断为“未入栈”,仍可能各 push 一次。这里可增加 openInFlight 或把意图串行送入路由分发器,避免竞态。

在模拟样本中,通知两次携带 news_042,期望结果是:duplicate push ignored: news_042 出现一次,路由栈仍保持两层逻辑路径,不凭重复事件增长。接下来的 IDE 图用于说明该约束如何被检查。

图片是按项目字段生成的拟真 DevEco Studio 示意,而非经过真机采集的性能证据。右侧模拟器保持 news_042 和 DETAIL_ACTIVE,底部日志出现 open article: news_042、duplicate push ignored: news_042。要把这个现象变成真实可复现结论,开发者仍应使用设备 HiLog 与路由栈快照验证,不应把示意图当作压测报告。

四、通知深链的“入口状态”,不等于用户一定在首页

通知跳转又带来第二类问题:应用可能被冷启动,也可能正处于详情页,甚至处于大屏双栏。收到通知时,简单地先 clear() 再 push 详情固然能保证目标页正确,但会丢失原本浏览路径,用户返回就只能退出应用。另一种反例是无论什么状态都先 push 到列表,再 push 详情,这会凭空多一层不必要的导航页。

我在 ArticleDock 里把深链解析与路由提交分成两步。第一步只校验:意图确实是 view、文章 ID 匹配允许的格式、内容来源在业务名单内;第二步由统一路由服务选择打开方式。外部输入永远不是 NavPathStack 的完整快照,不能让通知携带“任意目的地名”直接控制路由。只从外部接受领域 ID,再映射到应用内部固定的 NewsDetailPage,边界会清楚得多。

第三段代码是深链入口归一化与冷、热启动的分流示例:

interface OpenArticleIntent {
  action: string
  articleId: string
  origin: string
}

function normalizeIntent(raw: OpenArticleIntent): OpenArticleIntent | null {
  if (raw.action !== 'view') { return null }
  if (!/^news_[0-9]{3}$/.test(raw.articleId)) { return null }
  if (raw.origin !== 'notification' && raw.origin !== 'internal') { return null }
  return raw
}

function dispatchArticleIntent(
  stack: NavPathStack,
  routeService: DetailRouteService,
  raw: OpenArticleIntent
): string {
  const intent = normalizeIntent(raw)
  if (intent === null) { return 'INVALID_INTENT' }
  return routeService.open(stack, intent.articleId, intent.origin)
}

这里没有直接展示 UIAbility.onNewWant 的绑定,因为通知唤起、Want 参数承载和 UIAbility 实例复用会受工程包结构影响,必须在应用入口按实际配置接线。关键设计是:把系统 Want 转换为业务意图,然后才交给页面栈。不要让页面同时监听全局通知、列表点击和页面恢复三个事件,并且各自维护一套导航分支。否则修好一个入口又会在另一个入口复发。

至于冷启动时没有已创建的 pageStack 怎么办?入口层先把合法意图放入待处理队列,待 Navigation 容器完成初始化后再消费一次。这个队列不是无限保存:消息要有唯一标识和消费状态,确保配置变更、组件重新创建时不会再次重放旧消息。

五、平行视界最难定位的 bug,常常发生在返回时

我做过一次刻意的复现:从大屏列表进入 news_042,再将窗口缩窄到单栏,按返回;之后扩宽窗口,再从通知打开同一篇。最后我发现真正有价值的不是“页面能不能显示”,而是以下三个观测点:

首先,列表的选中高亮是否来自当前路由参数。不要在窗口收窄后把 selectedArticleId 重置成列表第一条;UI 形态变化不是内容选择变化。其次,返回动作是否由 NavPathStack.pop() 统一触发,而不是单独使用一个布尔变量去隐藏详情组件。最后,返回后如果再次收到通知,路由入口是否仍能判断当前栈顶,不能因为 UI 恰好还渲染着旧内容就误判已经打开。

我给回归脚本规定了简单的验证序列:

  1. 从 Home 打开 news_042,观察状态 DETAIL_ACTIVE;
  2. 再推送同一条消息,记录 DEDUP_OK,逻辑栈深度保持 2;
  3. 折叠到单栏,触发一次返回,必须回到列表而不是相同详情;
  4. 展开到双栏,重新点开该文章,列表选中态与正文 ID 应一致;
  5. 人为模拟两个通知同时到达,验证串行路由入口没有竞态。

为什么强调“逻辑栈深度”?因为 Auto 模式下导航栏和目的地呈现可能根据窗口宽度改变,显示了几个栏位不能直接当成 NavPathStack 中有多少个目的地。尤其是大屏上同时看见列表与详情,不能由此推断列表也是一个可 pop 的普通目的地。

手机单栏详情画面保留了 news_042、DETAIL_ACTIVE 和来源为深链。顶部时间为 10:24,状态栏完整;这些是示意样本的字段,与实际设备读取值无关。对工程定位最有用的不是页面美观,而是 ID、状态、进入方式都可以被复核。出于可读性,示例页显示了少量诊断信息;正式产品应把路由内部字段收在开发者诊断入口里。

六、做一次路由回放,比对着两张截图猜问题有效

我习惯在难复现的问题里增加一份结构化的“路由回放记录”。每条记录至少包含 eventId、articleId、origin、action、beforeStack、afterStack 和时间。不要直接把整份业务对象序列化到日志中,那容易混入用户个人信息;文章 ID 和受控枚举足以完成大多数排障。

第四段代码模拟回放记录的最小快照方法:

interface RouteSnapshot {
  event: string
  articleId: string
  origin: string
  pathNames: string[]
  stackDepth: number
  at: number
}

function takeRouteSnapshot(
  stack: NavPathStack, event: string, articleId: string,
  origin: string
): RouteSnapshot {
  const names: string[] = stack.getAllPathName()
  return {
    event, articleId, origin,
    pathNames: names.slice(),
    stackDepth: names.length + 1, // 本 Demo 把 Navigation 根页计入逻辑深度
    at: Date.now()
  }
}

请注意代码里的 + 1:它是 ArticleDock 的业务统计口径,不是官方 getAllPathName() 的返回规则。因为根导航栏不在普通子页栈里,本 Demo 把它统计为逻辑路径的第一层,才会在诊断图中看到 Home, NewsDetail 且 stackDepth = 2。如果团队把栈深定义为真正的 NavDestination 个数,就应直接使用 names.length,并把图、代码和日志一起改掉。

与“记录每次页面是否出现”相比,这份快照更能回答关键问题:重复通知发生前后,pathNames 有什么变化?从展开切到紧凑时,是渲染模式变了,还是业务自己发起了新的 push?用户觉得返回无效,到底是栈多了一层,还是 onBackPressed 被某个页面截断?这些问题不需要猜,只需看事件前后差异。

这张调试页固定展示 news_042,重复事件次数为 1,单栏和展开态的逻辑栈都是 Home, NewsDetail,stackDepth = 2,状态 DEDUP_OK / DETAIL_ACTIVE。其中“返回目标页面”是回放脚本中的验证步骤;不要把回放日志误读为屏幕截图那一刻真实已经离开详情页。10:25:10 到 10:25:12 的时间序列用于说明事件分析的方法。

七、这四种异常,我会要求团队都能解释

重复事件:同一来源连点或连发通知。预期是同文章请求被去重,而不是页面闪一下再回退。测试时要覆盖单次、快速双击、并发进入三种路径,并区分“重复请求被忽略”和“异步内容刷新仍允许”。

已删除文章:深链指向 news_042,但仓库查不到记录。不能因为路由参数格式合法就保证正文存在。应该进入明确的“内容不可用”目的地或错误状态,保留返回路径,不要让详情空白。要验证退出这个错误页时,页面栈仍可正常回退。

窗口模式切换:页面从双栏到单栏,再恢复双栏。样式应变,文章身份不应变。这里还要关注横竖屏、分屏、键盘弹出等造成的窗口尺寸变化;如果代码把“窗口宽度大于阈值”误认为“物理设备类型一定是平板”,一些小窗场景会非常奇怪。

慢请求回写:用户在 A 文章发起请求后立刻跳 B,如果 A 的响应晚到,不能直接覆盖 B 的正文。我的做法是每个详情请求附带 articleId 与请求代次,回包前检查当前活动文章是否还一致。路由正确只保证到达页面正确,并不自动保证数据更新不串台。

这些情况和 UI 截图没有直接关系,却决定了产品交付质量。许多“适配问题”最终不是布局容器能力不足,而是入口幂等、异步归属和返回语义没定义清楚。

八、验收时必须保留的边界与取舍

这次 ArticleDock 的结论比较克制:Navigation 的 Auto 帮助解决了单双栏呈现,NavPathStack 帮助统一管理路由,但同一文章的业务去重、深链白名单、冷启动待处理意图和异步回包隔离,都仍然属于应用自己的职责。不要把官方组件的能力扩大解释成“自动修复所有导航逻辑”。

若项目采用独立的 EasyGo 配置、系统推荐的平行视界示例或高级多栏导航组件,某些路由显示策略和回调时机可能不同。必须结合目标 HarmonyOS 版本的官方样例验证,不能把旧版 Android activityPairs 的配置示例机械照搬到 ArkTS Navigation 工程。这也是为什么本文把重点放在通用的“文章身份与栈策略”,而不是凭空编造一个一劳永逸的分栏配置。

落地时我还会提醒团队,诊断图里 news_042、进度与事件时刻都是约定的演示值;要得到真实证据,应该在对应真机或模拟器上采集导航日志,记录系统版本、窗口尺寸和进入方式。测试覆盖至少包括手机单栏、折叠展开、横向大屏、小窗、冷启动通知以及热启动通知。回归中任何一个路径导致文章 ID 不一致、栈深意外增长或单次返回无法到列表,都算失败。

九、复盘:把“多栏体验”从外观做到行为

做完这轮,我最愿意保留的其实不是两栏截图,而是三条规则。第一,页面身份必须稳定:文章 ID 决定内容,不由当前容器长什么样来决定。第二,导航动作必须集中:列表、通知、外部链接都进入同一处分发入口。第三,返回行为必须可解释:每一次 push、忽略或 pop 都应能从有限、脱敏的日志里复原。

回到最初的那个 bug:用户点开同一篇资讯,连续按两次返回才回到列表。只修样式很容易掩盖问题;把文章 ID、栈顶参数和重复通知放在同一个系统里,才能让单栏与平行视界保持一致的交互语义。到这里,ArticleDock 才算从“能在折叠屏上显示”向“真正可维护的多形态阅读系统”迈了一步。

十、灰度上线时,不要用“打开成功率”掩盖错误路径

到了灰度阶段,我会把 ArticleDock 的路由健康度拆成几项独立指标,而不是笼统统计文章“打开成功”。第一项是重复意图率,即每千次合法打开意图中,有多少次被判断为相同文章的重复请求。它上升不一定代表产品出错,也可能来自通知平台重复投递,必须结合 origin 分组分析。第二项是异常栈增长次数:同一用户操作结束后,逻辑深度是否超出预期。如果用户从 Home 打开一篇详情,栈深无故从 2 变成 3,这比页面截图更能提示风险。

第三项是回退闭环率,专门记录从文章详情按一次返回后,是否真的回到合理的上一级。它不能简单通过 pop() 函数返回了一个对象来断言,还应等目的地切换完成后检查路由状态。第四项是深链冷启动失败率,重点区分参数无效、内容已经下架、应用初始化尚未完成和真正的路由构建错误。不同原因必须有不同处理方式,不能都归到“页面打不开”。

采集这些指标时,我不会上传完整页面栈参数,更不会把通知附带的原始 Want 全量写进埋点。只保留匿名事件 ID、来源枚举、目标文章是否有效、处理结果和路径深度即可。线上采样可以低频进行;发现异常时再引导测试人员通过开发版打开更详细的路由回放工具。这样既保留问题证据,也避免诊断系统反过来成为新的隐私和性能负担。

还有一个非常实际的版本管理问题:导航策略调整后,老版本已经保存的回放记录不应默认被新版本解释。记录里应加入应用版本和路由协议版本,比如 routeSchema=2。一旦改动了“逻辑栈深”的计算口径,报表必须同步迁移,否则上线前后的数字根本无法比较。可观察性不是多打日志,而是让每一个数字在不同版本里都有明确含义。

资料边界:本文涉及的 Navigation 模式、NavPathStack 及其查询和入栈方法可参考官方文档:Navigation 开发指导。与平行视界相关的官方示例可从示例代码中心核对目标 SDK。文中自定义 DetailRouteService、去重策略和诊断字段属于应用层示意实现,不是 HarmonyOS 新增系统 API。配图全部是拟真示意,并未经过设备实测。

Logo

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

更多推荐