HarmonyOS 7 平行视界:列表与详情状态同步(连载03-06)【共创稿事节】
一封消息在左侧列表里还是“未读”,右侧详情却已经显示“已读”,是大屏消息类应用中很容易被忽视的交互缺陷。问题并不在双栏排版,而在数据归属:列表组件自己保存一份消息,详情组件又复制一份,页面切换与异步刷新一交错,两个副本便可能产生分歧。
平行视界所追求的连续操作体验,对业务状态的一致性提出了直接要求。单栏时,列表和详情先后展示;分栏时,两者可能同时出现在屏幕上。布局变了,消息的选中项、已读状态、内容版本却不应产生两套标准。本文沿用前两篇的 Navigation 主从结构,把“从哪儿修改、谁负责通知、旧请求如何收尾”梳理成一条清楚的数据流。

先找真正的状态源
假设消息中心有三条记录:项目进度、版本迭代、设计评审。用户在左侧点开“版本迭代”,右侧渲染标题和正文;随后点击“标为已读”,左侧未读标志必须消失。若先进入详情,接着从列表选择另一封消息,详情内容也必须跟随选择变化。
这条链路可以拆成三个信息:selectedId 是当前选择;messages 是消息集合;revision 是业务数据更新序号。详情页面只保存标识,不私自维护另一份消息正文。 这样列表和详情始终从同一个集合取值。
| 信息 | 唯一维护位置 | 消费方 | 修改规则 |
|---|---|---|---|
selectedId | 主页面 @State | 列表高亮、详情查询 | 点击列表项时修改 |
messages | 主页面 @State | 列表标记、详情文字 | 根据消息 ID 替换对应对象 |
revision | 消息模型 | 冲突判断、更新追踪 | 每次有效业务修改递增 |
| 页面路径 | NavPathStack | Navigation | 控制详情是否在页面栈中 |
这里特别要分清 页面栈 和 数据状态。NavPathStack 决定当前有哪些页面、如何前进和返回,不负责保存完整业务数据。把消息内容直接塞入路径参数,容易得到一份生成时刻的旧数据。路径参数传 ID 足够,正文使用主状态实时查找。
用 ArkTS 模型固定更新规则
可以把模型与更新函数放入 ets/model/MailItem.ets,业务页面经相对路径引用。先定义业务对象。构造函数明确每个字段的初始值,避免多个组件各自猜测默认状态。revision 也不是界面刷新次数,而是消息记录自身的业务版本。
export class MailItem {
id: string;
title: string;
content: string;
read: boolean;
revision: number;
constructor(id: string, title: string, content: string,
read: boolean = false, revision: number = 1) {
this.id = id;
this.title = title;
this.content = content;
this.read = read;
this.revision = revision;
}
}
export class MailReducer {
static setRead(items: MailItem[], id: string, read: boolean): MailItem[] {
return items.map((item: MailItem): MailItem => {
if (item.id !== id || item.read === read) {
return item;
}
return new MailItem(item.id, item.title, item.content,
read, item.revision + 1);
});
}
}
这里没有在原对象上直接修改 read,而是返回新的数组。这样 @State 接收到明确的赋值,阅读代码的人也很容易确认本次修改涉及哪条记录。对于消息量大的应用,可以将同一思想延伸到按 ID 索引的数据仓库;本篇以小规模消息列表说明状态流转,不提前引入额外的全局状态框架。
Navigation 负责页面,父组件负责数据
下面把选中、阅读和路由收在一个入口组件里。NavigationMode.Auto 根据窗口条件选择显示形式;导航栏中的列表和目的页中的详情,通过父组件状态相连。第一次选择消息时推入详情路径;如果详情已经存在,更新 ID 即可,避免连续点击造成页面栈不断增长。
import { MailItem, MailReducer } from '../model/MailItem';
@Entry
@Component
struct MessageCenter {
private pathStack: NavPathStack = new NavPathStack();
@State selectedId: string = 'm01';
@State messages: MailItem[] = [
new MailItem('m01', '项目进度', '本周里程碑与任务分工。'),
new MailItem('m02', '版本迭代', '新版本已整理待发布功能。'),
new MailItem('m03', '设计评审', '交互稿等待最终确认。')
];
private choose(id: string): void {
this.selectedId = id;
if (this.pathStack.size() === 0) {
this.pathStack.pushPathByName('Detail', id);
}
}
private setRead(id: string, read: boolean): void {
this.messages = MailReducer.setRead(this.messages, id, read);
}
private currentTitle(): string {
const item: MailItem | undefined =
this.messages.find((row: MailItem): boolean => row.id === this.selectedId);
return item ? item.title : '消息已移除';
}
private currentContent(): string {
const item: MailItem | undefined =
this.messages.find((row: MailItem): boolean => row.id === this.selectedId);
return item ? item.content : '请返回列表重新选择';
}
@Builder
private detailView() {
Column({ space: 18 }) {
Text(this.currentTitle()).fontSize(26).fontWeight(FontWeight.Bold)
Text(this.currentContent()).fontSize(17)
Button('标为已读').onClick((): void => {
this.setRead(this.selectedId, true);
})
}
.padding(24)
.alignItems(HorizontalAlign.Start)
}
@Builder
private destination(name: string) {
if (name === 'Detail') {
NavDestination() {
this.detailView();
}.title('消息详情')
}
}
build() {
Navigation(this.pathStack) {
List() {
ForEach(this.messages, (item: MailItem) => {
ListItem() {
Row({ space: 12 }) {
Text(item.title).fontSize(18)
if (!item.read) {
Text('未读').fontColor('#1B70D8').fontSize(12)
}
}
.padding(16)
.backgroundColor(this.selectedId === item.id ? '#EAF3FF' : '#FFFFFF')
.width('100%')
.onClick((): void => this.choose(item.id))
}
}, (item: MailItem): string => item.id)
}
}
.title('协同消息')
.mode(NavigationMode.Auto)
.navDestination(this.destination)
}
}
这段代码有一个刻意的设计:detailView() 不接收 MailItem 副本,只依据 selectedId 查询 messages。因此更新 messages 后,无论当前是单栏还是分栏,都能通过同一份状态重建正确的展示内容。ForEach 使用稳定的业务 ID 作为键,也能避免仅按列表下标跟踪节点带来的错配问题。

并发刷新时,先核对版本再覆盖
大屏交互还有一种更隐蔽的冲突:列表后台刷新完成时,用户刚刚在详情里把记录改为已读。若刷新响应携带旧版“未读”数据,直接替换整个数组就会使已读标志回退。处理办法不是禁止刷新,而是比较记录版本,拒绝旧数据覆盖较新的本地记录。
export class MailMerge {
static merge(current: MailItem[], incoming: MailItem[]): MailItem[] {
const next: MailItem[] = current.slice();
incoming.forEach((server: MailItem): void => {
const index: number = next.findIndex(
(item: MailItem): boolean => item.id === server.id);
if (index < 0) {
next.push(server);
} else if (server.revision > next[index].revision) {
next[index] = server;
}
});
return next;
}
}
这个合并函数建立在明确前提之上:客户端与服务端使用可比较的、由同一规则产生的版本序号。如果版本号来自互不相关的计数器,就不能直接比较大小。实际接入接口时,需要服务端提供版本戳或变更序列,并明确冲突时以哪个来源为准。只有当服务端接受读写操作并返回更新版本,客户端才应把本地临时修改判定为最终状态。
除了版本冲突,还要处理记录被删除、权限发生变化、切换账号等情形。选中记录已不存在时,详情使用空态提示;切换账号后应清空 selectedId 和相关路径,不能继续展示旧账号的消息内容。对于离线操作,可另外记录待提交变更,并在重新连接时按业务规则合并。
对单栏与分栏切换再加一道保护
大屏应用还有一个与普通手机不同的情况:用户在展开状态选中消息,随后调整窗口宽度,当前界面可能从分栏切换成单栏。此时应保留业务选中 ID 和消息集合,而不是在布局变化事件中重新创建整个数据仓库。换句话说,布局模式负责“放在哪里”,业务模型负责“是什么”。如果折叠和展开会重建某些组件,应将关键数据放在稳定的父级状态持有者中。
同时,选择 ID 与路由栈需要分别维护。如果页面回退到了列表,只需要让路径栈退出详情,不必强行将选中 ID 清空;这样下次再进入时可以恢复此前选择。反过来,切换用户或更换数据工作空间属于业务上下文改变,旧 ID 可能失效,就需要同步清空选中记录,并让列表恢复到符合业务规则的初始位置。这两种情况不能一律采用“返回就重置”,否则会把自然的阅读连续性破坏掉。
另外,不建议为了同步左右两栏而让列表组件直接调用详情组件的方法。那样组件会互相持有对方的行为入口,调试时难以判断谁先更新。统一向父级发送事件,再由父级修改状态,虽然多了一层调用,却使数据入口更容易审计。对于需要在多页面甚至多模块共享的消息中心,再考虑将这层父级职责抽离为专门的业务存储服务。
排查顺序比随意加日志更重要
出现“左侧未读、右侧已读”时,建议按照数据流逐段检查:点击事件是否使用了正确 ID;父组件是否只更新了一个数组;页面是否通过旧路径参数缓存整条对象;是否有网络响应覆盖了本地新版本。可以在事件入口与合并节点记录 selectedId、revision、变更来源和结果,不在日志中打印消息正文或其他敏感字段。
验收时覆盖四条操作路径:列表选择不同消息;详情中标记已读;同一条消息收到更新记录;从单栏返回列表后再次打开消息。每条路径分别观察列表高亮、详情文字、已读标志和页面栈长度。对大屏分栏还要确认反复选中并未重复压入目的页;对小屏则重点关注返回后重入详情的状态延续。

这套方案的价值不是减少几个 @State 字段,而是明确数据由谁拥有:导航结构可以按设备变化,业务记录只有一个可信来源。下一篇讨论分栏比例与响应式断点时,状态层不需要跟着重新设计,只需让布局成为这些状态的不同呈现方式。
参考文档
更多推荐
所有评论(0)