目录中连续点击同一篇文章,是大屏阅读应用经常遇到的一次交互。单次点击能够打开详情,并不意味着双击、触控抖动或列表刷新之后的重复触发也能得到稳定结果。假如每次都直接向 NavPathStack 压入页面,用户看见的还是“峡谷行记”,栈中却可能已经堆积了多个参数相同的 ReaderDetail。返回时需要退出多次,右侧高亮看似正确,实际路由历史却越来越长。

阅读工作台此前已经把目录记录与详情页关联到稳定的文章 id:canyon 对应“峡谷行记”,metro 对应“城市光谱”,cloud 对应“云端书签”。本次保留 Navigation 分栏首屏占位结构,只调整打开文章的入口,让同一详情的重复请求停留在当前页,切换不同文章时仍保留正常的阅读历史。目标不是屏蔽快速操作,而是让一次明确的选择只产生一次有意义的页面迁移。

文章路由去重与分栏阅读

一、看起来相同的详情,栈中未必相同

阅读页面中的 selectedId 与 NavPathStack 分工不同。前者负责给左侧目录添加选中样式,后者维护详情页的路由顺序。两个变量都可以显示“峡谷行记”,但仅从 selectedId 无法判断栈里究竟有几个 ReaderDetail。尤其是从其他入口打开文档,或者未来增加检索结果跳转后,单看列表状态会丢失路径信息。

一个典型的错误是先改高亮、再无条件压栈:点击 canyon 两次,selectedId 一直等于 canyon,右侧内容也没有变化,然而导航历史从一项变成两项。简单设置三百毫秒节流虽然能挡住部分连击,却会误拦截读者快速选择不同文章。节流还依赖时间窗口,恢复阅读、键盘操作和自动化事件都不一定遵守同样的触发间隔。

更可靠的判断应当围绕业务身份展开:查看当前栈顶是不是 ReaderDetail,再读取其文章 id,与本次请求的 id 比较。两者相同就保持原状;不同则按正常路由打开。这样可以把重复点击与快速切换区分开。若目标文章已经从目录中删除,则直接拒绝打开,并保持现有路由与选中状态不变。

这里的去重范围限定为栈顶同一详情。在不同的阅读路径上再次打开先前读过的文章,有时确实应形成新的历史记录;不能把整个栈中出现过的 id 全部视为重复,否则会破坏正常的前进与返回语义。把规则写清楚,比预先引入复杂的全局路由缓存更重要。

二、保留首屏数据结构,补充栈顶查询

此前目录已采用稳定的 ReaderArticle.id,详情路由只携带 ReaderRoute.id。这两个模型继续沿用,既不重新构造文章对象,也不在导航参数中塞进标题、正文等可变数据。新的栈顶查询函数接收 NavPathStack,先判断最后一个页面名称,再用页面索引取得参数;若栈为空或顶部是其他页面,返回空字符串。

export class ReaderRoute {
  constructor(public id: string) {}
}

export function currentReaderId(stack: NavPathStack): string {
  const names: string[] = stack.getAllPathName();
  const index: number = names.length - 1;
  if (index < 0 || names[index] !== 'ReaderDetail') {
    return '';
  }
  const route: ReaderRoute = stack.getParamByIndex(index) as ReaderRoute;
  return route?.id ?? '';
}

这里没有使用文章标题比较,是因为标题可以改名、同名内容也可能并存。getAllPathName() 提供当前路由名称序列,getParamByIndex() 取出对应位置的业务参数。两个调用属于 NavPathStack 路由信息查询,相关方法可以在华为的 Navigation 页面传参与返回说明中找到。当前实现只依赖 ReaderDetail 顶部参数,下一次迭代处理返回同步时仍能继续使用。

如果页面采用不同的路由构建方式,读取到的参数可能不是期望的 ReaderRoute。工程里应把入口路由统一为单一类型,并对迁移历史数据增加判空。查询函数返回空字符串意味着“当前没有可比较的阅读详情”,而不是某篇文章的合法 id,因此业务内容的 id 不能允许空字符串。

三、把打开动作改成有结果的业务操作

点击事件不应同时承担文章存在性检查、重复判定、路由执行和高亮修改。为减少分支扩散,可以用一个打开函数收拢这四步,先验证 id 是否存在,再检查栈顶,最终决定是否入栈。以下方法与原有 Index 页面中的 Navigation 和 pages() 构建器配合使用;catalog 仍是此前定义的三条文章目录。

import { catalog, ReaderArticle } from '../model/ReaderArticle';
import { ReaderRoute, currentReaderId } from '../model/ReaderRoute';

@Entry
@Component
struct Index {
  private pathStack: NavPathStack = new NavPathStack();
  @State selectedId: string = '';
  @State actionText: string = '从目录选择文章';

  private openReader(id: string): void {
    const exists: boolean = catalog.some((item: ReaderArticle) => item.id === id);
    if (!exists) {
      this.actionText = '文章暂不可用';
      return;
    }
    if (currentReaderId(this.pathStack) === id) {
      this.selectedId = id;
      this.actionText = '保持当前详情';
      return;
    }
    this.pathStack.pushPathByName('ReaderDetail', new ReaderRoute(id));
    this.selectedId = id;
    this.actionText = '打开文章详情';
  }

  @Builder
  pages(name: string, param: object) {
    if (name === 'ReaderDetail') {
      ReaderDetail({ articleId: (param as ReaderRoute).id });
    }
  }

  build() {
    Navigation(this.pathStack) {
      List({ space: 8 }) {
        ForEach(catalog, (item: ReaderArticle) => {
          ListItem() {
            Column({ space: 6 }) {
              Text(item.title).fontSize(18)
              Text(item.summary).fontSize(13)
            }
            .padding(16)
            .backgroundColor(this.selectedId === item.id ? '#E8F0FF' : '#FFFFFF')
            .onClick(() => this.openReader(item.id))
          }
        }, (item: ReaderArticle) => item.id)
      }
      Text(this.actionText).fontSize(13)
    }
    .title('阅读工作台')
    .navDestination(this.pages)
    .mode(NavigationMode.Split)
  }
}

代码仍保留 NavDestination 的独立实现与首屏的占位结构。在原工程中,这一段更新的是打开入口和状态提示,原有 .splitPlaceholder(...) 可以照常附着在 Navigation 上。这里的 actionText 用于帮助读者理解操作结果,不作为是否打开文章的判据;真正的判断始终围绕文章集合和路由参数。

目录事件与栈顶识别代码

需要注意 UI 高亮的更新时机。示例把存在性检查放在修改选中态之前,避免内容已下架却提前高亮。倘若项目改用异步的目的地构建或网络数据加载,还应把“发起打开”和“详情就绪”分开管理,给失败回调保留状态恢复入口。业务层的入栈成功不能等同于文章内容已经加载完毕。

四、用一条可重放的输入序列审视结果

为了检查判定规则,选择四次有明确先后顺序的输入:先打开 metro,再打开 canyon,随后再次请求 canyon,最后尝试不存在的 missing。期望得到两次 OPEN、一次 KEEP、一次 INVALID;栈中仅保留前两次有效打开,末尾仍然是 canyon。下面是与 UI 判断同一原则的 Node.js 辅助检查代码,便于在提交交互变更时对照输出。

const catalog = new Set(['canyon', 'metro', 'cloud']);
const stack = [];
function open(id) {
  if (!catalog.has(id)) return 'INVALID';
  if (stack.at(-1) === id) return 'KEEP';
  stack.push(id);
  return 'OPEN';
}
for (const id of ['metro', 'canyon', 'canyon', 'missing']) {
  console.log(id, open(id), stack.join(' > '));
}

这一小段只计算业务决策,不依赖 UI 绘制或路由组件。它重点检查顺序、身份和栈长度三项不变量:无效 id 不改变栈;重复栈顶 id 不新增页面;不同 id 正常追加阅读历史。若某次改动把判断替换成按点击时间截断,该输入序列能及时暴露把快速切换误判为重复的问题。

输入文章判断最终栈变化
城市光谱 metroOPENmetro
峡谷行记 canyonOPENmetro > canyon
峡谷行记 canyonKEEP不新增
不存在的文章INVALID不新增

阅读工作台当前详情与去重状态

上表中的 OPEN、KEEP、INVALID 是应用约定的业务结果,并非系统路由回调名称。界面可以把 KEEP 显示为“保持当前详情”,把 INVALID 显示为“文章暂不可用”。使用可理解的提示语,能避免把技术状态直接暴露给读者。

五、异常路径比连续点击更容易暴露不一致

实际页面常见的异常并不只有连续触控。目录从远端更新后,有可能删除当前文章;导入新目录时,也可能改变文章顺序。如果判断条件使用索引,列表重排就会把错误的详情认作当前页面。通过稳定 id 做比较,可以保证目录展示顺序改变时路由身份仍然可靠。但数据被彻底删除时,已有详情如何退出,需要额外的失效通知与回退路径,不能让去重函数替代数据更新处理。

若应用支持键盘操作与无障碍焦点,按钮激活可能来自非触控事件。所有入口都应调用 openReader(),避免点击事件有去重而快捷键入口没有。搜索结果跳转也一样:它可以携带相同的 id,但必须共享一个路由判定流程。多个页面分散实现相似逻辑,会在后续需求变化时出现不同的判定标准。

NavPathStack 持有导航历史,页面消失时不应由无关子组件擅自清空它。页面的选中提示属于 ArkUI 状态,详情内容的数据订阅则属于详情页生命周期。当前这一步只处理入栈,返回同步会在下一阶段根据可见目的地与路径变化调整高亮,因此此处不会通过监听整个导航树反复写入 selectedId。

六、让一条文章历史拥有清楚的身份

在分栏工作台中,真正值得稳定保存的是文章 id,而不是点击次数或数组下标。把栈顶查询、文章合法性检查和打开动作收拢为一个入口之后,连续点击可以得到确定结果,同时不会削弱正常的多篇文章切换。首屏占位与详情渲染仍沿用已有结构,工程改动集中在路由边界上。

进一步的工作是处理返回后的目录高亮、窗口形态切换时的可见页面同步,以及长期阅读位置的恢复。它们都依赖本阶段建立的稳定身份关系。华为开发者文档可参阅 Navigation 参数与返回、Navigation 分栏开发 与 NavDestination 页面生命周期。

Logo

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

更多推荐