【时光清单|14】HarmonyOS ArkTS 主导航实战:统一页面入口、返回路径和参数校验

主导航最怕“能跳过去,但无法证明跳得对”。首页、全部列表和设置页各自拼路由字符串;详情页接收一段 JSON,解析失败后仍显示默认对象;筛选页把任意字符串当作类型;某些页面用共享 NavPathStack,另一些又创建新栈。单次点击可能看不出问题,等到系统返回、连续进入详情、页面恢复或参数结构升级时,导航状态就会分叉。

时光清单 的真实源码建立了一条统一主链:AppStore.bootstrap()AppStorage 中创建唯一 NavPathStackIndex.ets 用它构建根 NavigationRouteNames.ets 集中声明二级页面名称,RouteMap.ets 把名称映射到 NavDestination,各业务页面通过 pushPath() 发起导航,通过 pop() 返回。项目现有两种带参路径:详情页传 JSON.stringify(item),筛选页传纪念日类型字符串。

本文以当前仓库中的 ArkTS 源码为事实边界,先复核这条已经写入代码的导航链路,再把重点放在参数契约:为什么路由名集中后仍不等于类型安全,完整实体 JSON 为什么可能过期,筛选参数如何建立白名单,未知路由和空栈返回如何兜底,以及怎样在不推翻现有结构的前提下引入类型化导航封装。

时光清单主导航、返回路径与参数校验封面

本文将解决:

  1. Navigation、共享栈与 appRouter 如何连接。
  2. Tab 切换与二级页面入栈为什么不能混为一谈。
  3. RouteNames 能防止哪些错误,不能防止哪些错误。
  4. 详情 JSON 参数与筛选字符串的真实风险。
  5. 如何设计类型化路由请求、参数守卫和未知路由兜底。
  6. 如何验证系统返回、连续入栈、删除后返回和窗口恢复。

本文唯一标记:CSDN-SERIES:ALL-163208646

一、根导航只在 Index 绑定一次

真实入口页:

@Entry
@Component
struct Index {
  @StorageLink(StateKeys.NAV_STACK)
  pathStack: NavPathStack = new NavPathStack();

  @StorageLink(StateKeys.THEME_BG)
  themeBg: string = '#F5F0E8';

  build() {
    Navigation(this.pathStack) {
      MainTabShell()
    }
    .navDestination(appRouter)
    .hideTitleBar(true)
    .hideToolBar(true)
    .mode(NavigationMode.Stack)
    .backgroundColor(this.themeBg);
  }
}

NavigationMode.Stack 让二级页面覆盖主 Tab 壳;appRouter 统一解释入栈名称;pathStack 是所有页面操作的同一个共享栈。

入口页不直接写每个业务目的地,也不处理详情参数。这样新增路由主要修改 RouteNamesRouteMap,根页面保持稳定。

二、共享 NavPathStack 的创建时机

AppStore.bootstrap() 只在键未定义时创建:

if (AppStorage.get<NavPathStack>(StateKeys.NAV_STACK) === undefined) {
  AppStorage.setOrCreate<NavPathStack>(
    StateKeys.NAV_STACK,
    new NavPathStack()
  );
}

业务页面再用相同键链接:

@StorageLink(StateKeys.NAV_STACK)
pathStack: NavPathStack = new NavPathStack();

字段后的 new NavPathStack() 是声明时默认值;正常启动链已由 bootstrap 提供共享对象。关键是不在页面出现时主动覆盖 AppStorage 中的栈。

错误做法 后果
每页持有独立栈 push 后根 Navigation 无变化
配置变化时重建全局栈 当前详情和返回历史丢失
用字符串模拟栈层级 系统返回与 UI 不一致
把业务实体长期存在栈中 数据更新后参数过期

导航栈属于应用级基础设施,但页面仍只应通过明确方法表达导航意图。

历史证据:哪些结论能证明,哪些仍不能证明

这次审计先查了项目源码和现有错误记录。当前能直接证明的事实是:根 Navigation、共享 NavPathStack、集中式 RouteNamesappRouter 映射以及多个页面中的 pushPath()pop() 调用都真实存在。它们共同说明项目已经选择“一个主栈承载二级页面”的结构,而不是每个页面各建一套导航容器。

错误记录中没有找到一条带日期、带复现步骤的“主导航故障已经修复”记录。仓库历史中出现过 assembleHap 成功证据,但对应的是其他功能修改,不能挪用为本文导航方案的构建证明。项目规则里还记录过“返回页面时不能只依赖 aboutToAppear 刷新数据”,这能支持“导航与数据刷新需要分离”的设计判断,却不能证明本文列出的系统返回、异常参数、进程恢复等场景已经执行过测试。

因此,后文把内容分为三类:第一类是可以从当前源码逐项定位的事实;第二类是从旧记录得到的有限历史证据;第三类是尚未落地的建议实现。文中不会把建议代码写成现有能力,也不会把过去某次无关构建成功描述为本次导航改造已经通过。读者在自己的项目里采用建议后,仍需要重新编译并执行对应的导航测试矩阵。

三、RouteNames:先消灭散落的魔法字符串

真实常量:

export class RouteNames {
  static readonly MAIN = 'main';
  static readonly DETAIL = 'detail';
  static readonly COUPLE = 'couple';
  static readonly DIARY = 'diary';
  static readonly HABIT = 'habit';
  static readonly HABIT_WALL = 'habitWall';
  static readonly WISH_LIST = 'wishList';
  static readonly ALBUM = 'album';
  static readonly FILTERED_LIST = 'filteredList';
  static readonly THEME_SETTINGS = 'themeSettings';
  static readonly MOOD_SETTINGS = 'moodSettings';
  static readonly PRIVACY_POLICY = 'privacyPolicy';
}

常量能防止 'detail''details' 这类拼写漂移,也便于全局搜索。但它只约束路由名,不能约束:

  • 该路由是否必须带参数。
  • 参数是什么类型。
  • 参数字段是否完整。
  • 当前栈是否允许进入该页面。

因此 RouteNames 是统一协议的第一层,不是完整类型系统。

四、RouteMap:目的地集中构建

appRouter 根据名称构造页面:

@Builder
export function appRouter(name: string, param: Object) {
  NavDestination() {
    if (name === RouteNames.DETAIL) {
      DetailView({ itemParam: param as string });
    } else if (name === RouteNames.COUPLE) {
      CoupleView();
    } else if (name === RouteNames.FILTERED_LIST) {
      FilteredListView({ filterType: param as string });
    }
  }
  .hideTitleBar(true)
  .padding({
    bottom: px2vp(
      AppStorage.get<number>(StateKeys.SAFE_AREA_BOTTOM) ?? 0
    )
  });
}

这里还统一隐藏目的地标题栏并添加底部安全区。页面不必重复构造 NavDestination 外壳。

页面入口到参数校验、目标构建与返回的导航流程

当前分支没有未知路由页面。若 name 不匹配,NavDestination 内容为空,可能表现为白页。生产环境应提供日志和安全兜底。

Navigation、NavPathStack、路由契约和页面分层结构

源码审计:现有集中路由仍有四个明确边界

第一,RouteNames 只集中名称,没有把“路由名”和“参数类型”绑定起来。调用方写对了 detail,仍可能漏传参数、传入筛选字符串,或把另一个对象强制断言成详情参数。集中常量解决的是拼写一致性,不是请求合法性。

第二,param as string 是编译期断言,不是运行时校验。appRouter 没有先判断 param 是否真的是字符串,也没有判断筛选值是否属于业务白名单。只要调用点绕过约束,目的地就会收到不符合预期的值。筛选页对未知值使用兜底标题,再按相等条件过滤数据,结果可能是“标题看起来正常,但列表为空”,这比直接报错更难定位。

第三,详情页解析失败时使用了空的 catch。当前组件已经有一个默认 Anniversary 对象,坏字符串不会必然导致页面崩溃,却可能让页面继续展示默认日期、空标题或不对应真实记录的状态。这里真正的风险不是只有异常退出,还包括“失败被吞掉后页面看似可用”。参数解析失败应进入明确的错误状态,不能把默认业务对象当作有效数据。

第四,未知 name 没有兜底分支。NavDestination 外壳仍会创建,但内部没有业务页面,用户可能看到空白内容。当前源码也没有针对未知名称的诊断信息。一个可维护的路由层需要把这种失败变成可观察、可返回的状态,同时避免记录完整业务参数。

完整对象 JSON 还有一层数据一致性问题:入栈参数代表当时的快照,不是仓库真源。事项被编辑或删除后,旧栈中的 JSON 不会自行变化;如果未来参数参与状态恢复,字段演进也会增加兼容负担。因此本文后面的“只传 ID”属于建议方案,不是对当前实现的描述。

排查这四类边界时,可以先沿调用链逐层缩小范围:入口是否使用共享栈,路由名称是否存在,参数是否通过守卫,目的地是否成功构建,返回是否操作同一栈。每一层只记录必要状态,不打印完整实体。这样即使最终现象都是“页面没有正常出现”,也能区分是入口、协议、参数、页面构建还是返回历史的问题,避免用增加延时或重复跳转掩盖根因。

五、主 Tab 与二级路由是两套状态

MainTabShellTabs 管理首页、全部、新建、组件和我的;它通过 currentIndex 切换。详情、情侣空间、日记、设置等使用 NavPathStack 入栈。

Tab 层
  首页 | 全部 | 新建 | 组件 | 我的

Navigation 栈
  MainTabShell
    -> FilteredList
      -> Detail

如果把 Tab 切换也写成 pushPath,连续切换会堆积多个主页面;如果用 Tab 索引打开详情,又无法获得自然的系统返回。真实源码把两层分开,这是合理的。

返回二级页时 pop(),不会改变 currentIndex,用户会回到发起导航的原 Tab。

六、无参数入口:页面只需要路由名

首页快捷入口:

this.pathStack.pushPath({
  name: RouteNames.DIARY
});

this.pathStack.pushPath({
  name: RouteNames.COUPLE
});

this.pathStack.pushPath({
  name: RouteNames.WISH_LIST
});

这些页面不需要定位具体实体,路由请求只包含名称。ProfileView 也复用同一常量进入主题、心情与隐私页面。入口来源不同,目的地协议一致。

无参数不等于任何调用都允许附带任意 param。统一封装可以主动拒绝多余参数,避免未来路由语义模糊。

七、筛选页参数:裸字符串需要白名单

首页传入:

this.pathStack.pushPath({
  name: RouteNames.FILTERED_LIST,
  param: 'countdown'
});

还会传 'memorial''love''salary'RouteMap 直接:

FilteredListView({
  filterType: param as string
});

类型断言只告诉编译器“把它当字符串”,不会验证值。若传入 'salaryy',筛选页可能得到空列表或错误标题。

可以复用业务联合类型:

export type FilterRouteType =
  | 'countdown'
  | 'memorial'
  | 'love'
  | 'salary'
  | 'custom';

function isFilterRouteType(value: Object): value is FilterRouteType {
  return value === 'countdown' ||
    value === 'memorial' ||
    value === 'love' ||
    value === 'salary' ||
    value === 'custom';
}

路由 Builder 在创建页面前检查,不合法时记录日志并展示可返回的错误状态。

八、详情参数:完整 JSON 能用,但会携带旧快照

首页和列表都这样进入详情:

this.pathStack.pushPath({
  name: RouteNames.DETAIL,
  param: JSON.stringify(item)
});

详情页出现时解析:

if (this.itemParam.length > 0) {
  try {
    this.item =
      JSON.parse(
        this.itemParam
      ) as Anniversary;
  } catch (_) {
  }
}

好处是详情首帧不必再次读仓库;问题是参数只是入栈时的快照。其他页面修改或删除同一事项后,栈里的 JSON 不会自动更新。as Anniversary 同样不验证字段结构。

更稳的路由只传 ID:

export interface DetailRouteParam {
  id: string;
}

this.pathStack.pushPath({
  name: RouteNames.DETAIL,
  param: {
    id: item.id
  } as DetailRouteParam
});

详情页按 ID 从仓库读取最新对象,并处理“不存在或已删除”状态。

九、JSON.parse 成功不等于参数合法

下面的字符串能成功解析:

{"id":123,"title":[]}

但它不满足真实 Anniversary。应使用字段守卫:

function isDetailRouteParam(
  value: Object
): value is DetailRouteParam {
  const candidate =
    value as Record<string, Object>;
  return typeof candidate.id === 'string' &&
    candidate.id.length > 0;
}

ArkTS 对索引签名和宽泛对象有更严格限制,具体写法应按项目 SDK 编译规则调整。原则不变:先检查,再构建页面;断言不是校验。

十、类型化导航封装:让调用点不能传错

可以定义路由请求联合类型:

export type AppRouteRequest =
  | {
      name: typeof RouteNames.DETAIL;
      param: DetailRouteParam;
    }
  | {
      name: typeof RouteNames.FILTERED_LIST;
      param: FilterRouteType;
    }
  | { name: typeof RouteNames.DIARY }
  | { name: typeof RouteNames.COUPLE };

再包装导航:

export class AppNavigator {
  constructor(private readonly stack: NavPathStack) {}

  push(request: AppRouteRequest): void {
    this.stack.pushPath(request);
  }

  back(): void {
    this.stack.pop();
  }
}

调用详情却漏掉 param,或给 Diary 传筛选字符串时,编译阶段就能发现。封装不需要接管整个 ArkUI Navigation,只需守住应用协议。

分阶段改造:先让失败可见,再收紧参数

不建议一次性重写所有页面。第一阶段只建立边界:为详情、筛选和无参页面定义明确的请求类型;在 appRouter 入口检查名称和参数;解析失败时进入错误目的地。这个阶段仍可兼容旧的详情 JSON 字符串,但必须把“解析失败”和“字段不合法”变成可见状态。验收点是旧入口继续可用,坏参数不再静默生成默认业务对象。

第二阶段集中调用方法。把散落的 pushPath({ name, param }) 收拢为 openDetail(id)openFilteredList(type)openDiary() 等窄接口,并在接口内部创建请求。页面只表达“我要打开哪个业务目标”,不再知道底层参数包装形式。迁移时可逐页替换,每替换一个入口就搜索旧写法,避免新旧协议长期并存。

第三阶段把详情参数从完整实体改成稳定 ID。详情页收到 ID 后向仓库查询最新数据,并显式处理加载中、查询失败和记录不存在。删除记录后返回来源列表,来源页根据仓库或版本信号刷新,而不是依赖路由回传一份新数组。这个阶段需要确认仓库接口、页面状态和生命周期,不能只改一行 param 就宣称完成。

第四阶段补齐观察与恢复。未知路由显示可返回页面;日志只记录路由名、参数类别和校验结果;重复点击入口时确认是否允许连续入栈;配置变化或状态恢复时检查栈中 ID 是否仍对应有效记录。每一阶段都应单独编译、运行和回归,失败时可以回到上一阶段定位,而不是把类型、仓库和 UI 三类问题混在一次大改中。

这套顺序的核心是先消除静默失败,再改善类型体验,最后调整数据读取。若项目当前没有仓库按 ID 查询能力,先保留旧 JSON 兼容分支也比直接强转更稳妥。本文的示例只描述建议边界,是否能通过当前 ArkTS 编译器仍应由实际 SDK 和项目构建结果决定。

十一、返回路径:pop 之前先理解栈语义

真实二级页面普遍使用:

.onClick(() => {
  this.pathStack.pop();
})

详情删除成功后也 pop() 返回列表。系统返回与可见返回按钮都应作用于同一栈,避免一个改变页面状态,另一个退出 Ability。

需要覆盖的边界:

  • 栈只有根页面时,不应盲目 pop。
  • 保存弹层打开时,返回应先处理弹层或草稿确认。
  • 删除实体后返回,来源列表要重新加载。
  • 连续详情入栈时,一次返回只退一层。

可以包装安全返回:

function backOrStay(
  stack: NavPathStack
): boolean {
  const paths = stack.getAllPathName();
  if (paths.length === 0) {
    return false;
  }
  stack.pop();
  return true;
}

具体获取栈信息的 API 以当前 SDK 为准;不要凭记忆使用不存在的方法。

十二、未知路由不能静默生成空页面

当前 RouteMap 最后一项没有 else。演进时可以增加统一错误目的地:

} else {
  RouteErrorView({
    routeName: name,
    onBack: () => {
      const stack =
        AppStorage.get<NavPathStack>(
          StateKeys.NAV_STACK
        );
      stack?.pop();
    }
  });
}

错误页至少应包含:

  • 无法打开页面的短提示。
  • 返回按钮。
  • 不含敏感参数的路由名日志。
  • 不自动循环重试。

线上用户遇到未知路由时,可恢复比空白页更重要。

十三、安全区统一在 NavDestination 外壳处理

RouteMap 为所有二级页添加底部 padding:

.padding({
  bottom: px2vp(
    AppStorage.get<number>(
      StateKeys.SAFE_AREA_BOTTOM
    ) ?? 0
  )
});

统一外壳减少页面遗漏,但要防止业务页面自身又添加相同安全区,造成双倍空白。应定义清楚:二级页的系统底部避让由路由壳负责,页面只处理内容间距;或者改成页面根组件统一处理,二者择一。

如果安全区在窗口变化时更新,直接在 Builder 中调用 AppStorage.get() 是否触发重建也要验证。更响应式的方式是由宿主通过 StorageProp 读取后传入。

十四、导航参数不要携带敏感正文

当前详情参数序列化整个 Anniversary,可能包含备注、封面等字段。它仍在本地内存中,但调试日志、错误上报或未来状态恢复若打印路由参数,可能扩大敏感信息范围。

只传 ID 有三项收益:

  • 减少栈参数体积。
  • 避免旧快照。
  • 降低日志泄露正文的风险。

日志可以记录路由名和实体 ID 的脱敏片段,不应打印用户备注、日记或情侣留言。

工程上可以给导航日志设一条明确边界:允许输出 routeName、参数类型、校验结果和截断后的实体 ID,禁止输出序列化后的业务对象。这样既能定位“哪条路由参数不合法”,又不会把纪念日备注或图片路径带进日志。

hilog.info(0x0000, 'Router', 'route=%{public}s, id=%{public}s',
  RouteNames.DETAIL,
  anniversaryId.slice(0, 8))

这里的日志只服务于路由定位。若 ID 本身也具有业务敏感性,应改为不可逆摘要或完全不记录;导航模块不应因为调试方便而突破数据最小化原则。

十五、导航与数据刷新协作

新建事项保存后递增 DATA_VERSION,首页和列表可重新读取仓库。导航只负责回到来源页面,不负责携带更新后的完整数组。

AddView 保存
  -> Repository 写入
  -> DATA_VERSION + 1
  -> 相关页面重读

Detail 删除
  -> Repository 删除
  -> pop
  -> 来源页面刷新

把“导航完成”和“数据已更新”分开,可以避免通过复杂返回参数同步列表。路由传定位信息,仓库保存真数据,失效信号触发重读。

十六、导航测试矩阵

当前未验证项

本次源码审计没有执行新的 assembleHap,也没有在模拟器或真机上注入非法路由。系统返回手势、连续快速点击、详情中旋转或切换主题、进程被回收后的路径恢复、记录删除后的旧 ID、未知名称以及根栈返回,都属于待验证项。下面表格给出的是实施改造后的验收清单,不是已经取得的测试结果。

验证时应保留最小证据:使用的构建命令及退出码、设备或模拟器环境、入口路径、输入参数、实际页面和返回结果。只看到“点击后出现详情页”不能覆盖错误参数与返回链路;只看到构建成功也不能代替运行时导航验证。如果某项因设备、签名或 SDK 环境无法执行,应把它标记为未运行并记录原因,不应推断为通过。

场景 操作 期望
无参入口 首页进日记 正确创建页面
筛选入口 love 只显示恋爱类
非法筛选 传未知字符串 拦截并可返回
详情入口 传有效 ID 读取最新实体
详情缺失 ID 已删除 显示不存在状态
JSON 损坏 旧路径传坏字符串 不崩溃
连续入栈 列表进详情再进设置 返回逐层恢复
可见返回 点击左上角 pop 一层
系统返回 系统手势 与可见返回一致
根栈返回 主 Tab 按返回 不产生空白页
旋转/主题 详情中切换配置 栈保持
删除返回 删除详情项 来源列表刷新

导航测试不能只点一次入口。至少覆盖非法参数、第二次进入和返回后刷新。

十七、常见问题与修复

现象 根因 修复
点击入口无反应 页面使用了独立栈 共享 NAV_STACK
跳转后白页 未知路由无兜底 增加错误目的地
筛选页标题异常 裸字符串未校验 使用联合类型白名单
详情展示旧数据 参数传完整实体快照 只传 ID 并重读
JSON 解析后仍崩溃 类型断言代替校验 增加结构守卫
返回退出应用 页面与根栈不一致 统一 pop 语义
底部空白过大 安全区重复添加 明确单一所有者
配置变化丢详情 重建全局栈 bootstrap 只创建一次

定位导航问题时记录“当前路由名、参数验证结果、栈深度、来源页面”,不要打印完整业务正文。

十八、发布前核对

  • [ ] Index 绑定共享 NavPathStack 和 appRouter。
  • [ ] 路由名全部来自 RouteNames
  • [ ] RouteMap 覆盖所有公开二级入口。
  • [ ] 未知路由有日志和可返回页面。
  • [ ] 筛选类型经过白名单校验。
  • [ ] 详情优先传 ID,而不是长期保存实体快照。
  • [ ] JSON 解析后执行结构验证。
  • [ ] 可见返回和系统返回作用于同一栈。
  • [ ] 根页面返回不会生成空白 NavDestination。
  • [ ] 删除、保存后来源页面读取仓库真源。
  • [ ] 安全区只由一个层级负责。
  • [ ] 路由日志不包含用户私密正文。

十九、总结:导航的核心是协议,不是跳转 API

时光清单 已经具备统一主链:AppStorage 持有根 NavPathStackIndex 构建单一 NavigationRouteNames 集中名称,RouteMap 集中目的地,各页面统一 pushPathpop。Tab 状态与二级栈分开,入口和返回路径清晰。

下一步最重要的改造不是换一种跳转动画,而是强化参数协议。当前详情传完整 JSON 字符串,筛选页传裸类型字符串,二者都依赖运行时断言。通过类型化路由请求、白名单守卫、只传实体 ID、未知路由兜底和返回测试,可以把“能跳”升级为“可验证、可恢复、可演进”。这才是主导航长期稳定的基础。


AI 辅助声明: 本文由 AI 辅助整理,当前导航链路均依据 AppStore.etsIndex.etsRouteNames.etsRouteMap.etsHomeView.etsAllView.etsFilteredListView.etsDetailView.ets 的真实源码人工复核;类型化参数与错误目的地为明确标注的演进建议。

Logo

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

更多推荐