任务列表里点击 tk-101,详情页出现了“核对设计稿”;再从另一个入口点击 tk-102,导航动画结束,标题居然还是“核对设计稿”。刷新列表也没用,仓储中的第二条任务分明存在。问题看起来像数据缓存,实际却藏在导航栈里:页面被复用了,原先只在创建阶段读取一次参数的逻辑没有再次运行。

页面任务流单实例复用技术封面

前两篇已经明确了这套工程的业务边界。任务统一使用 TaskItem(id,title,status,owner),由 TaskRepository 按稳定的 taskId 检索和修改;首页通过唯一的 NavPathStack 打开名为 TaskDetail 的子页面。上一章约定 TaskChangeResult{taskId,action} 用于返回操作事实,changed、unchanged 和 invalid 不携带完整任务对象。这些契约不需要重做。我们这次只研究同一张详情页面再次接收不同任务 ID 时,怎样保证内容及时、准确地切换。

一次看似正常的返回,为什么会留下旧标题

复现这个现象不需要网络请求,也不需要复杂数据库。先在首页打开 tk-101,让详情页面驻留在路由栈中;随后从某个快捷入口导航至同名 TaskDetail,参数换成 tk-102,并指定单实例启动模式。如果代码只在 NavDestination.onReady 内执行 this.task = TaskRepository.find(taskId),它依赖的是首次创建页面的时机。复用的是既有实例时,页面并不会重新走一遍创建流程。

这是两个容易混淆的概念:页面显示了不等于页面刚创建,路由栈改变了也不等于仓储数据更新了。在标准入栈模式下,每次新建详情实例,onReady 读取参数的实现通常能够工作;改为单例复用后,同样的代码便出现旧状态残留。不能把“刷新页面时调用一次仓储”当成普适的参数更新机制,因为重入场景的关键是路由参数本身已经变化。

华为的 NavDestination 文档明确区分了 onReady 与 onNewParam:后者用于已存在的目的页通过 LaunchMode.MOVE_TO_TOP_SINGLETON 或 LaunchMode.POP_TO_SINGLETON 移至栈顶时接收新参数。两种模式对页面栈上其他页面的处理不同,因此业务应先确定返回路径,再选择复用策略,不能只为了少建一个组件就改变用户的返回体验。

目的页创建、复用与参数更新的状态分支

不复制旧任务,改为按 ID 完整重取

最危险的修补方式,是仅把 taskId 字段更新,再继续显示旧的 title、status、owner。这样界面会出现任务 ID 属于 tk-102、标题却属于 tk-101 的混搭状态。下一次操作若只按新 ID 提交,还可能让用户误以为操作的是旧任务。因此先清空旧详情,再验证新 ID,最后从仓储生成新的显示状态,要成为一套不可拆分的切换操作。

沿用前文 TaskRouteParams{taskId} 的字段约定。路由参数是跨页面输入,不要把它当成已经校验的内部对象。下面的工具函数只接受 tk- 加三位数字这一示例 ID 形式,并对空值、非字符串和多余字符直接返回空串。生产项目应采用自己的业务 ID 规范;格式检验只能阻止明显错误,权限和记录是否存在仍由服务层或仓储负责。

// model/TaskRouteGuard.ets
export interface TaskRouteParams {
  taskId: string;
}

export function safeTaskId(raw: object | undefined): string {
  if (!raw) { return ''; }
  const params = raw as TaskRouteParams;
  if (typeof params.taskId !== 'string') { return ''; }
  const id: string = params.taskId.trim();
  return /^tk-[0-9]{3}$/.test(id) ? id : '';
}

详情的“可见模型”刻意独立于仓储里的可变实体。TaskDetailView 只负责此时要显示的四个字段,切换参数时创建新对象而不是修改原对象的属性;这能让 ArkUI 的状态更新边界更清晰。前文仓储方法 TaskRepository.find(id) 可以继续使用,不需要再写一套来源不明的列表缓存。

// model/TaskDetailView.ets
import { TaskItem } from './TaskModel';

export class TaskDetailView {
  id: string;
  title: string;
  status: string;
  owner: string;

  constructor(item: TaskItem) {
    this.id = item.id;
    this.title = item.title;
    this.status = item.status;
    this.owner = item.owner;
  }
}

真正重要的代码不是 find,而是顺序。applyRoute 被两个事件复用,进入方法第一步就清除旧对象;即使传来的 ID 不合法,也只呈现明确的错误状态,而不是继续展示上次打开的任务。空态、找不到任务、正常显示是互斥的业务结果,不能只用一个布尔变量混在一起。

// view/TaskDetailPage.ets:详情组件的核心成员
import { TaskRepository } from '../model/TaskModel';
import { TaskDetailView } from '../model/TaskDetailView';
import { safeTaskId } from '../model/TaskRouteGuard';

@Component
export struct TaskDetailPage {
  @State private currentId: string = '';
  @State private detail: TaskDetailView | undefined = undefined;
  @State private message: string = '正在读取任务';
  private stack: NavPathStack = new NavPathStack();

  private applyRoute(raw: object | undefined): void {
    this.detail = undefined;
    this.currentId = '';
    const nextId: string = safeTaskId(raw);
    if (nextId.length === 0) {
      this.message = '任务参数无效,请从列表重新打开';
      return;
    }
    this.currentId = nextId;
    const found = TaskRepository.find(nextId);
    if (!found) {
      this.message = '任务不存在或已移除';
      return;
    }
    this.detail = new TaskDetailView(found);
    this.message = '';
  }

  build() {
    NavDestination() {
      Column({ space: 16 }) {
        if (this.detail) {
          Text(this.detail.title).fontSize(24).fontWeight(FontWeight.Bold)
          Text(`负责人:${this.detail.owner}`)
          Text(`状态:${this.detail.status}`)
          Text(`任务编号:${this.currentId}`)
        } else {
          Text(this.message).fontColor(Color.Gray)
          Button('返回任务列表').onClick(() => this.stack.pop())
        }
      }.padding(24).width('100%')
    }
    .title('任务详情')
    .onReady((ctx: NavDestinationContext) => {
      this.stack = ctx.pathStack;
      this.applyRoute(ctx.pathInfo.param as object | undefined);
    })
    .onNewParam((param: object) => {
      this.applyRoute(param);
    })
  }
}

这里的组件直接写出了完整的 NavDestination 生命周期连接点。项目若使用系统路由表,则要将 TaskDetail 映射到上述组件;若沿用第一篇的自定义 navDestination Builder,则保持原来的 Builder 映射,不要两种注册方式同时重复注册。关键在于入栈使用的名字与目的页必须一致。

入栈模式决定回调能不能收到

让旧实例真正接收新参数,需要在希望复用的入口显式指定 LaunchMode,不能只在详情里加一个回调就认为问题已经解决。下面针对“从快捷入口重新打开已经在栈里的详情”这一明确场景使用 MOVE_TO_TOP_SINGLETON。如果用户要求回到详情时清除其上方所有中间页面,可评估 POP_TO_SINGLETON,但应把返回栈变化纳入产品设计与回归检查。

// pages/Index.ets:已有 this.taskStack: NavPathStack
private reopenDetail(taskId: string): void {
  if (!/^tk-[0-9]{3}$/.test(taskId)) { return; }
  this.taskStack.pushPath(
    { name: 'TaskDetail', param: { taskId: taskId } },
    { launchMode: LaunchMode.MOVE_TO_TOP_SINGLETON }
  );
}

若首次点击时详情还不存在,系统会创建新实例,此时应从 onReady 取得参数;只有目标实例已经存在且发生单例重用,才走 onNewParam。这两个入口要汇入同一套 applyRoute,否则“第一次打开正常、第二次打开错误”的缺陷会反复出现。更重要的是,前篇 onPop 中的 TaskChangeResult 与本篇是不同方向的数据流:前者描述完成操作后返回了什么,后者描述打开详情时要展示哪条任务。二者不要互相替代。

批量复用目的页还需注意回调绑定。前篇是在入栈操作时为 onPop 绑定了 expectedId,单实例复用可能改变当前页面代表的任务;如果仍依赖首次入栈时闭包捕获的旧 ID,就会把合法的返回结果误判为不匹配。较稳妥的策略是为不同入口设计一致的返回协议,或在需要稳定逐次返回回调时继续使用标准实例模式。本篇不把原来的闭包强行复用于所有单例场景,避免为修复旧内容引入新的结果回传错误。

参数变化、数据清空和结果回传的排查分界

一张能够逼出错误的验证清单

验证时不要只点一次详情。应按顺序观察:首次打开 tk-101 是否显示正确;重入已有 TaskDetail 并传 tk-102 是否同时更新 ID、标题、状态及负责人;重复打开相同 ID 是否保持一致;传空对象或不存在的 tk-999 是否进入空态;执行 TaskRepository.complete() 后再进详情,是否从仓储取得新状态。接着在手机单栏和大屏双栏下重复这些动作,确保布局变化不影响任务身份。

日志建议记录“导航动作类型、目标 taskId、实际展示 taskId、仓储命中情况”,不要记录个人敏感数据或无关页面详情。出现旧内容时,先确认 pushPath 是否确实带了新参数,再确认 launchMode 是否符合预期,然后核对 onNewParam 有没有进入、applyRoute 是否清理旧状态。不要一开始就清空全局缓存,因为那样会掩盖事件没有触发的真正原因。测试时还要核查返回栈深度,特别是不同单实例模式移动或移除中间页面后的系统返回行为。

还要区分“任务确实被删除”与“路由参数被错误转换”两种空态。前者可以提供返回列表和刷新数据的入口;后者应提示调用方修正传参,而不是静默挑选列表中的第一条任务。对于详情已完成却再次打开的情况,应展示仓储中的完成状态,不应擅自把它重置为待处理。审核日志中的 taskId 可以用于定位调用链,但日志不应包含负责人联系方式或不必要的业务正文。这样做使错误恢复有边界,既照顾用户的可理解性,也便于开发人员确认是否真正修正了问题。

这个阶段的成果,是让详情页把“任务身份”当作每次导航都要重新确认的输入,而不是组件创建时的一次性初始化数据。下一篇会收拢路由规范、结果回传协议和回归用例,完成页面任务流系列的工程验收。技术参考:华为 NavDestination 生命周期与 onNewParam、华为 Navigation 跨模块路由说明。

Logo

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

更多推荐