ArkUI + HarmonyOS 页面任务流:单例复用与参数更新(连载03/04)【共创稿事节】
任务列表里点击 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 跨模块路由说明。
更多推荐

所有评论(0)