HarmonyOS Navigation 返回后资料页不刷新的故障复盘

摘要

这是一个简易的聊天室项目,涉及到的bug内容是用户修改自己的名称UI不会刷新。业务逻辑是从设置页面进入用户详情页面,从用户详情页面进入文字修改页面,文字修改页面退回用户详情页面,这样一个页面栈。

用户在 TextEditPage 修改名称并保存后,服务端、本地数据库和 ChatStore.state.currentUser 已经拿到新值,重新进入编辑页也能看到新名称,但返回现有的 UserDetailPage 时仍显示旧值。设备日志显示,onPop 回调在 UserDetailPage 进入 onWillShow 之前执行。最终修改使用 pop(result) 传回类型化结果,在回调中把 @State 更新延迟到本次出栈事务之后,同时让名称节点直接读取 this.userName

这次问题涉及三个边界:资料写入是否成功、返回页面何时恢复、恢复后的 UI 节点读取哪个状态。只检查其中一个边界,会得到「明明数据已经改了,页面为什么没变」的矛盾现象。

影响与现象

影响范围集中在当前用户资料编辑流程:

  1. UserDetailPage 打开名称编辑页。
  2. 修改名称并保存,保存成功提示正常出现。
  3. 编辑页退出后,原资料页继续显示旧名称。
  4. 再次进入编辑页时,输入框显示新名称。
  5. 退出并重新进入 UserDetailPage 后,资料页才显示新名称。

第 4 步和第 5 步构成了关键证据。它们表明新名称已经进入共享数据源,故障发生在「现有资料页实例恢复并重绘」这一段,而不是名称保存接口本身。

涉及的代码

entry/src/main/ets/
├── models/
│   ├── ChatModels.ets       # 编辑字段和返回结果类型
│   └── ChatStore.ets        # 服务端更新、本地持久化、共享状态
└── pages/
    ├── Index.ets            # Navigation、NavDestination 与共享 revision
    ├── TextEditPage.ets     # 保存资料并执行 pop(result)
    └── UserDetailPage.ets   # 接收结果并更新可观察 UI 状态

Index.ets 持有唯一的 NavPathStackUserDetailPageTextEditPage 都是该 Navigation 下的 NavDestination。编辑页入栈后,资料页没有销毁,只是处于隐藏状态;编辑页出栈时,框架恢复原来的资料页实例。

原始数据流

UserDetailPageNavPathStack本地数据库WebSocketTransportChatStoreTextEditPage用户UserDetailPageNavPathStack本地数据库WebSocketTransportChatStoreTextEditPage用户页面仍持有旧的 userName修改名称并点击保存updateCurrentUserName(input)updateUserName(normalizedName)返回 updated UserProfilesaveUser(updated)state.currentUser = updatedpop()恢复已有 NavDestination

这条链路中,ChatStore.state.currentUserUserDetailPage.userName 是两个不同层次的状态。前者是共享业务状态,后者是页面为了渲染深层资料字段而提取的本地 @State。保存流程只更新前者,现有页面还需要在合适的时机把新值同步到后者。

先排除持久化故障

文件:entry/src/main/ets/models/ChatStore.ets:495-510

const updated: UserProfile = await this.transport.updateUserName(normalizedName);
await chatDatabase.saveUser(updated);
this.state.currentUser = updated;

这段代码依次等待服务端返回、写入本地数据库,再替换共享的 currentUser。输入是去除首尾空格后的 string,输出是 Promise<void>;名称为空、Store 未初始化、没有当前用户或传输层未配置时会抛出错误。需要注意,只有前两次 await 成功后才会替换共享状态。

故障现场中,再次打开 TextEditPage 已能读到新名称。这与 ChatStore.ets:509 的状态替换相互印证,因此继续修改服务器拉取逻辑不会解决旧页面不刷新的问题。

排查过程

尝试一:在 aboutToAppear() 中重新读取 Store

UserDetailPage.ets:31-42 已经在组件出现时调用 syncFromStore()

aboutToAppear(): void {
  this.syncFromStore();
}

private syncFromStore(): void {
  const user: UserProfile | undefined = chatStore.state.currentUser;
  this.userName = user?.name ?? '';
}

这段代码在资料页组件创建时,把 UserProfile 转换成页面使用的四个字符串状态。输入来自 chatStore.state.currentUser,输出是 userNameuserSignatureuserAvataruserId 四个 @State 字段。问题在于本次返回恢复的是导航栈中已有的 NavDestination,没有重新创建 UserDetail 组件,因此不能把 aboutToAppear() 当成每次出栈返回都会执行的刷新钩子。

尝试二:用 profileRevision + @Watch 通知隐藏页面

@Consume('profileRevision') @Watch('syncFromStore') profileRevision: number;

Index.ets:49-51 提供 profileRevisionUserDetailPage.ets:29 通过 @Watch('syncFromStore') 监听它。编辑成功或目的地重新显示时递增 revision,设计目标是让资料页再次读取 Store。

这个方案仍未让现有名称节点稳定刷新。它把「共享数据已经变化」转换成了一次通知,但通知发生时目标 NavDestination 仍可能处于隐藏或恢复阶段。此次设备行为表明,通知执行与可见节点重绘之间仍缺少确定的时序关系。

profileRevision 适合表达「资料版本变化」,却不能单独证明目标页面已经恢复到可更新 UI 的阶段。

这个方案失败的原因可能是这样,我们使用了@state装饰姓名,但是@state装饰的数据传入的@builder组件是接受的普通字符串参数。

原始定义大致如下:

@Builder
private infoRow(label: string, value: string, editable: boolean) {
  Row() {
    Text(label);
    Text(value);
  }
}

名称行调用它时:

this.infoRow('Name', this.userName, true);

数据传递过程是:

@State userName
      ↓
this.infoRow('Name', this.userName, true)
                           ↓
                  普通 string 参数 value
                           ↓
                      Text(value)

第一次打开页面时,假设:

this.userName = '张三';

实际执行相当于:

this.infoRow('Name', '张三', true);

infoRow() 内部接收到的是当时的字符串值:

value = '张三';

随后创建:

Text('张三');
为什么可能继续显示旧名称

保存完成后,共享状态已经更新:

chatStore.state.currentUser.name = '李四';

页面还必须完成两步:

ChatStore 中的“李四”
  -> 同步到 @State userName
  -> 重新执行 infoRow('Name', this.userName, true)

只要其中一步没有完成,Text(value) 仍然显示原来传入的“张三”。

特别是原来的 Text 直接依赖的是普通参数:

Text(value)

而不是:

Text(this.userName)

当资料页处于隐藏或恢复阶段时,即使 this.userName 被修改,如果 ArkUI 没有重新执行 Builder 调用:

this.infoRow('Name', this.userName, true);

Builder 内部的 value 就仍然是上次获得的字符串,已有的 Text 也继续显示旧名称。

尝试三:给入栈操作注册 onPop 回调

资料页随后改用 pushPathByName(name, param, onPop),让编辑页返回时直接通知原页面实例。第一次实现仍使用无参数的 pop(),回调没有执行。

本机 SDK 的 navigation.d.ts:1347navigation.d.ts:1373 声明了两个不同重载:

pop(animated?: boolean): NavPathInfo | undefined;
pop(result: Object, animated?: boolean): NavPathInfo | undefined;

navigation.d.ts:1349-1373 对第二个重载的说明是:传入 result 后会调用 onPop 传递页面处理结果。输入是一个 Object 和可选动画标记,输出是弹出的 NavPathInfoundefined。容易遗漏的点是,注册了 onPop 不等于无参数 pop() 也会调用它。

尝试四:回调执行后立即修改 @State

将编辑页改成 pop(result) 后,日志已经打印「这个回调执行成功了」,但资料页仍显示旧名称。此时问题不再是回调有没有执行,而是回调执行在哪个生命周期阶段。

日志还原出的 8 毫秒

设备日志 pasted-text.txt:2-36 记录了以下顺序:

时间事件观察
10:45:18.013打印「这个回调执行成功了」onPop 已进入
10:45:18.016preStackSize: 2, newStackSize: 1Navigation 开始同步出栈结果
10:45:18.017UserDetailPage onWillShow原资料页开始恢复
10:45:18.021UserDetailPage onShown原资料页已显示
10:45:18.021UserDetailPage onActive原资料页重新激活

回调比 onWillShow 早约 4 毫秒,比 onShown 早约 8 毫秒。日志不能推出所有 ArkUI 隐藏页面都无法响应 @State,但它能证明本次立即赋值发生在原页面恢复之前。结合页面仍显示旧值的结果,回调时序成为当前实现的直接故障点。

还有一个次要风险:原来的名称行通过 infoRow('Name', this.userName, true)userName 作为普通 @Builder 参数传入。即使页面状态改变,已有 Builder 节点是否重新读取该参数还取决于父节点的重建过程。这个风险无法单独解释全部现象,但会扩大「状态已变、文本没变」的排查范围。

最终修改

1. 定义类型化返回结果

文件:entry/src/main/ets/models/ChatModels.ets:58-77

export class TextEditResult {
  public field: ProfileTextField = 'name';
  public value: string = '';

  constructor(field: ProfileTextField, value: string) {
    this.field = field;
    this.value = value;
  }
}

TextEditResult 把字段名和保存后的字符串放进同一个返回对象。输入是 'name' | 'signature'string,输出是可由 PopInfo.result 携带的对象。字段名不能省略,否则同一个编辑页复用到签名编辑时,上一页无法判断该更新哪个状态。

2. 保存完成后执行 pop(result)

文件:entry/src/main/ets/pages/TextEditPage.ets:35-58

const savedValue: string = this.field === 'name'
  ? chatStore.state.currentUser?.name ?? this.input.trim()
  : chatStore.state.currentUser?.signature ?? this.input.trim();

this.pathStack.pop(new TextEditResult(this.field, savedValue));

这段代码优先返回 Store 中已经规范化和确认的值,只有共享状态缺失时才回退到输入框内容。输入来自当前编辑字段和保存后的 Store,输出是一次带结果的出栈操作。需要注意,pop(result) 应放在 await chatStore.update... 之后,否则页面可能先返回,服务端失败却没有机会留在编辑页提示错误。

3. 把状态更新排到本次出栈事务之后

文件:entry/src/main/ets/pages/UserDetailPage.ets:46-63

this.pathStack.pushPathByName(
  'TextEditPage',
  new TextEditRouteParams(field),
  (popInfo: PopInfo): void => {
    const result: TextEditResult = popInfo.result as TextEditResult;
    if (result.field === field) {
      setTimeout((): void => {
        this.userName = result.value;
      }, 0);
    }
  }
);

回调接收 PopInfo,校验返回字段后,把赋值排入后续任务。输入是 TextEditResult,输出是对当前资料页 @State 的更新。setTimeout(..., 0) 不表示等待固定的 0 毫秒,它表示当前同步调用栈结束后再执行;在本次日志对应的流程中,这使赋值越过了 onPop 的同步执行阶段。

实际代码还区分了 namesignature。上面的片段只保留名称分支以突出时序。回调使用类型断言,因此生产代码仍应防御 result 为空或结构不符合预期的情况,避免其他返回路径触发属性访问异常。

4. 让名称文本直接读取页面状态

文件:entry/src/main/ets/pages/UserDetailPage.ets:108-125

Row() {
  Text('Name').width(88);
  Text(this.userName)
    .maxLines(1)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
    .layoutWeight(1);
  Text('>');
}

名称行现在直接在 build() 中读取 this.userName,删除了名称值经过普通 Builder 参数的中间层。输入是页面本地 @State string,输出是名称文本节点。签名仍使用 infoRow(...),因此后续真机回归需要同时验证名称与签名,不能用名称通过来替代整个资料编辑流程通过。

修改后的完整顺序是:

TextEdit.save()
  -> 服务端确认更新
  -> 本地数据库保存 UserProfile
  -> ChatStore.currentUser 替换为新对象
  -> pop(TextEditResult)
  -> onPop 收到已保存值
  -> 当前出栈同步调用结束
  -> 延迟任务更新 UserDetailPage.userName
  -> Text(this.userName) 读取新状态

根因分层

层次当时状态结论
服务端更新updateUserName() 已返回新的 UserProfile保存链路成功
本地持久化chatDatabase.saveUser(updated) 已完成重进应用后有数据来源
共享运行时状态state.currentUser = updated 已执行新编辑页能读到新名称
页面本地状态userName 仍由旧页面实例持有需要显式同步
Navigation 时序onPop 早于 onWillShow/onShown立即赋值发生得过早
文本渲染名称曾通过 Builder 普通参数传递改为直接读取 @State

根因可以归纳为:UserDetailPage 缓存了一份用于渲染的本地名称,保存成功只更新了 Store;用于同步旧页面的回调又发生在该 NavDestination 恢复之前,名称节点还隔着普通 Builder 参数。三个条件叠加后,数据正确但现有页面保留了旧显示。

故障排查表

现象优先检查本次原因处理方式
保存提示成功,重开编辑页是新值Store 与数据库写入顺序写入已成功转查页面同步和渲染
aboutToAppear() 没有刷新返回页目标组件是否重新创建恢复的是已有 NavDestination使用返回结果或目的地生命周期
注册 onPop 后没有日志出栈是否携带 result使用了无参数 pop()改为 pop(result)
onPop 有日志但 UI 不变回调与 onWillShow/onShown 顺序回调先于页面恢复将赋值排到当前出栈调用之后
@State 已赋新值但文本仍旧Builder 参数和节点读取路径名称经过普通参数传递在文本节点直接读取 this.userName
退出重进应用才正常初始化读取与当前实例状态是否分离新实例重新执行同步修复现有实例的刷新路径

为什么早期修改没有解决

几次修改分别解决了不同问题,但没有一次覆盖完整链路:

  1. aboutToAppear() 解决首次创建时的数据装载,没有覆盖缓存目的地恢复。
  2. profileRevision + @Watch 提供变化通知,没有约束通知相对 Navigation 恢复的执行阶段。
  3. pushPathByName(..., onPop) 建立返回通道,无参数 pop() 却没有返回结果,也不会触发这条结果回调。
  4. pop(result) 让回调成功执行,立即更新仍发生在 onWillShow 之前。
  5. 延迟更新解决时序后,直接使用 Text(this.userName) 又缩短了状态到文本节点的依赖路径。

这也是本次排查中最容易误判的地方。看到回调日志只能证明控制流进入了回调,无法证明目标页面已经处于可见状态,更无法证明具体文本节点读取了新值。

结论

名称修改本身已经完成,旧页面未刷新来自另一个状态边界。ChatStore.currentUser 是保存后的共享数据,UserDetailPage.userName 是现有页面实例用于渲染的本地数据;Navigation 返回时需要在页面恢复之后把前者的结果交给后者。

本次修复使用 TextEditResult 明确返回字段和值,使用 pop(result) 触发返回回调,使用延迟任务越过同步出栈阶段,并让名称文本直接读取 @State。日志中的 onPop -> onWillShow -> onShown 顺序解释了为什么「回调执行成功」仍不足以让页面立即显示新值。

Logo

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

更多推荐