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

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

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

时光清单 的真实源码建立了一条统一主链:AppStore.bootstrap() 在 AppStorage 中创建唯一 NavPathStack,Index.ets 用它构建根 Navigation,RouteNames.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 是所有页面操作的同一个共享栈。

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

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、集中式 RouteNames、appRouter 映射以及多个页面中的 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 内容为空,可能表现为白页。生产环境应提供日志和安全兜底。

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

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

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

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

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

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

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

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

MainTabShell 用 Tabs 管理首页、全部、新建、组件和我的;它通过 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();
    }
  });
}

错误页至少应包含:

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

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

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 持有根 NavPathStack,Index 构建单一 Navigation,RouteNames 集中名称,RouteMap 集中目的地,各页面统一 pushPath 和 pop。Tab 状态与二级栈分开,入口和返回路径清晰。

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


AI 辅助声明: 本文由 AI 辅助整理,当前导航链路均依据 AppStore.ets、Index.ets、RouteNames.ets、RouteMap.ets、HomeView.ets、AllView.ets、FilteredListView.ets 与 DetailView.ets 的真实源码人工复核;类型化参数与错误目的地为明确标注的演进建议。

相关推荐
马剑威(威哥爱编程)30 分钟前
【共创稿事节】HarmonyOS 7 Agent 开发实战 02:Agent 工程脚手架与对话 UI 骨架
华为·harmonyos
打工仔折腾 AI2 小时前
鸿蒙平板远控电脑实测:ToDesk蓝牙键鼠、双模式鼠标与跨端剪贴板落地记录
人工智能·后端·python·性能优化·计算机外设·电脑·harmonyos
打工仔折腾 AI2 小时前
鸿蒙端ToDesk远控电脑实测:蓝牙键鼠、双模式鼠标与跨端剪贴板实践
后端·python·性能优化·计算机外设·电脑·harmonyos·ai agent 实战
OH_TPC2 小时前
【鸿蒙优选三方库】@ohos/axios:前端转鸿蒙,网络层几乎不用改
前端·华为·harmonyos·鸿蒙
m0_738185822 小时前
Flutter 鸿蒙化实战:simple_circular_progress_bar 适配 OpenHarmony,环形进度条
flutter·华为·harmonyos·鸿蒙
jsky_studio2 小时前
鸿蒙应用图标圆角与透明像素:上架驳回的排查与修法
harmonyos·鸿蒙
程序猿追18 小时前
一次右尾概率实验:statrs4cj 的 CJMP 鸿蒙接入与双端实测
华为·harmonyos
大雷神19 小时前
从一道24点到同屏双人派对:用鸿蒙开发助手起步,复用已有App开发手机元服务
华为·智能手机·harmonyos
song50119 小时前
React Native for OpenHarmony 实战:三方库 react-native-parallax-scroll-view 的鸿蒙化适配指南
分布式·react native·react.js·transformer·harmonyos
贾伟康19 小时前
HarmonyOS ArkUI 日历范围选择不歧义:本地日期键、闭区间与闰日校验
harmonyos·状态机·arkui·闰年·日期范围