工单台从窄窗口拖到宽窗口,左侧仍然高亮第二条工单,右侧详情却跳回第一条——这样的错位比单纯“页面挤变形”更难发现。第一篇已经用 GridRow、GridCol 建好列表和详情共用的主从布局,并统一用 selectedId 表达当前工单。本篇不再重复介绍栅格怎么分列,而是解决两个新的问题:布局断点切换时不更改业务选择;页面被重新创建时,如何在合适范围内恢复选择。

需要先说清楚“恢复”到底指什么。这里处理的是应用进程仍在、页面组件被销毁后重新构造的场景,并不把内存状态存储说成进程被杀后的持久恢复。它们属于不同的生命周期边界,工程上不能混在一起。

工单台断点与选中态恢复技术封面

缩放是视觉事件,不是重新选择工单的理由

上一阶段的数据契约保持原样:WorkOrder 含 id、title、status、description 四个字段;selectedId 是唯一的当前工单标识。小窗口下 sm 总列数4,列表和详情分别占4列;中窗口 md 总列数8,列表占3列、详情占5列;大窗口 lg 总列数12,列表占4列、详情占8列。改变的是列数与空间关系,不是数据含义。

一个容易踩的坑,是在断点变化事件中写 this.selectedId = 1,表面上是“回到默认状态”,实际上是用窗口事件改写业务状态。同一用户只要拖一次窗口,就可能丢失正在查看的工单。还有一种隐藏得更深的写法:为 sm 和 lg 各创建一个不同页面,各自都放 @State selectedId = 1。两份局部状态没有自动同步关系,即使看起来是同一个页面,也容易在布局切换时彼此覆盖。

因此把系统拆成三条互不替代的链路:GridRow 负责栅格排布;onBreakpointChange 只记录断点信息,作为响应式辅助显示;selectedId 继续由点击或数据集合法性检查更新。onBreakpointChange 回调的参数是 sm、md、lg 等断点标识,不能把它当成完整的设备类型识别。官方跨设备布局示例也展示了这个回调的用法。

断点变化与业务选中态解耦流程图

这里有一个重要的工程判断: 视觉断点可以有多种来源,例如应用窗口、组件可用宽度以及布局容器的尺寸变化。本文仍使用与第一篇相同的 GridRow 内建断点,不引入新的窗口权限,也不根据手机、平板设备名称进行硬编码。在后续处理折叠和多窗口时,才把窗口回调及相关策略作为独立输入接入。

把短生命周期的 @State 改为应用级共享选择

@State 很适合维护当前组件的局部可变状态,但页面真正被销毁后,新实例会重新走字段初始值。为了让同一应用进程内重建的页面继续使用同一项选择,可以将这个标识交给 AppStorage,通过 @StorageLink 双向绑定。第一次使用对应键时,装饰字段的默认值为它提供初始内容;后续同一应用进程里创建的新页面则连接到存储中的值。

这里仍然只存一个业务标识,不复制整个工单对象。原因很简单:工单正文、状态都可能变化,若同时把完整对象存两份,恢复时不仅要判断“选中谁”,还得处理“两份对象谁更新”的一致性。唯一 id 作为链接值,可以始终通过业务数组查询当前对象。

与第一篇相比,改动集中在三个地方:首先将 @State selectedId 替换为 @StorageLink('workOrderSelectedId') selectedId;其次增加 breakpointName 用来呈现最近一次断点回调;最后增加一个数据集合法性检查,在所选工单不存在时给出确定性的回退选择。这种改造比把所有控件重新写一遍更容易评审。

下面是可替换 entry/src/main/ets/pages/Index.ets 的完整页面主体。它沿用前文的 WorkOrder 字段含义与三条工单数据,没有改动业务模型,也没有引入额外三方包。

interface WorkOrder {
  id: number;
  title: string;
  status: string;
  description: string;
}

@Entry
@Component
struct Index {
  @StorageLink('workOrderSelectedId') selectedId: number = 1;
  @State private breakpointName: string = '由栅格自动判断';
  @State private orders: WorkOrder[] = [
    { id: 1, title: '排查登录超时', status: '待处理',
      description: '核对登录请求链路与超时提示。' },
    { id: 2, title: '修复列表筛选', status: '处理中',
      description: '核对筛选条件及数据刷新范围。' },
    { id: 3, title: '梳理异常日志', status: '待处理',
      description: '为异常事件补充可检索的分类字段。' }
  ];

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

  private ensureValidSelection(): void {
    const exists: boolean = this.orders.some(
      (item: WorkOrder) => item.id === this.selectedId);
    if (!exists) {
      this.selectedId = this.orders.length > 0 ? this.orders[0].id : -1;
    }
  }

  private currentOrder(): WorkOrder | undefined {
    return this.orders.find(
      (item: WorkOrder) => item.id === this.selectedId);
  }

  private removeSelected(): void {
    this.orders = this.orders.filter(
      (item: WorkOrder) => item.id !== this.selectedId);
    this.ensureValidSelection();
  }

  @Builder
  private orderList() {
    Column({ space: 12 }) {
      Text('工单列表').fontSize(22).fontWeight(FontWeight.Bold)
      List({ space: 8 }) {
        ForEach(this.orders, (item: WorkOrder) => {
          ListItem() {
            Column({ space: 8 }) {
              Text(item.title).fontSize(16).fontWeight(FontWeight.Medium)
              Text(item.status).fontSize(13).fontColor('#64748B')
            }
            .alignItems(HorizontalAlign.Start)
            .width('100%').padding(14).borderRadius(12)
            .backgroundColor(item.id === this.selectedId ? '#DBEAFE' : '#F1F5F9')
            .onClick(() => { this.selectedId = item.id; })
          }
        }, (item: WorkOrder) => item.id.toString())
      }
      .width('100%').height(310)
    }
    .width('100%').padding(16).borderRadius(16)
    .alignItems(HorizontalAlign.Start)
    .backgroundColor(Color.White)
  }

  @Builder
  private orderDetail() {
    Column({ space: 14 }) {
      Text('当前工单').fontSize(22).fontWeight(FontWeight.Bold)
      if (this.currentOrder()) {
        Text(this.currentOrder()!.title).fontSize(20)
        Text(this.currentOrder()!.status).fontColor('#2563EB')
        Text(this.currentOrder()!.description).fontSize(16)
      } else {
        Text('没有可查看的工单').fontColor('#B45309')
      }
      Button('移除当前工单')
        .enabled(this.currentOrder() !== undefined)
        .onClick(() => { this.removeSelected(); })
    }
    .width('100%').height(416).padding(20)
    .alignItems(HorizontalAlign.Start)
    .borderRadius(16).backgroundColor(Color.White)
  }

  build() {
    Scroll() {
      Column({ space: 18 }) {
        Text('团队工单台').fontSize(28).fontWeight(FontWeight.Bold)
        Text('当前布局提示:' + this.breakpointName)
          .fontSize(13).fontColor('#64748B')
        GridRow({
          columns: { sm: 4, md: 8, lg: 12 },
          gutter: { x: 12, y: 12 }
        }) {
          GridCol({ span: { sm: 4, md: 3, lg: 4 } }) {
            this.orderList()
          }
          GridCol({ span: { sm: 4, md: 5, lg: 8 } }) {
            this.orderDetail()
          }
        }
        .onBreakpointChange((value: string) => {
          this.breakpointName = value;
        })
        .width('100%')
      }
      .width('100%').padding(16)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%').height('100%')
    .backgroundColor('#F8FAFC')
  }
}

onBreakpointChange 更新的是 breakpointName,不是 selectedId。即便这个回调在某个容器初次渲染时没有立即提供一个可见值,布局本身仍由 GridRow 的断点规则计算;提示文案初始为“由栅格自动判断”,不会错误地声称当前一定是 sm。这也是 UI 辅助标记与真实布局机制应当分开的原因。

断点回调与StorageLink关键代码批注

页面销毁后如何恢复,数据删除后如何回退

试着把“恢复”分成两个动作。第一个是找回上次被选中的 id,由 @StorageLink 连到应用级 AppStorage 实现;第二个是确认这个 id 在当前业务列表里还有效,由 ensureValidSelection() 完成。如果只做前者,数据删除后会永久选中不存在的记录;如果只做后者,页面重建时又会丢失先前选择。

例如用户正在查看 id=2,然后页面组件被导航重建,但应用进程仍保持运行。新的 Index 连接到既有的 workOrderSelectedId,因此选中项仍指向2。再如用户点击“移除当前工单”,数组更新后 ensureValidSelection() 会把所选项切换到剩余第一条。如果列表空了,则改成 -1,详情显示空状态而不是直接解引用一个不存在的对象。该流程只使用现有字段,不需要为每个断点存一份选择。

窄屏与宽屏选择保留、记录删除回退对照图

还有两个容易忽略的约束。其一,存储键是业务契约。 如果下一篇擅自把 workOrderSelectedId 换成别的字符串,页面恢复行为就会断裂;因此后续系列继续沿用这个键。其二,AppStorage 不是数据库。 应用进程终止后能否恢复,不能依赖当前链接机制;如果产品要提供跨进程启动恢复,应另加合适的持久化策略,并决定是否允许保留用户上次查看的敏感记录。即使保存下来的 id 在新版本中仍然存在,也要重新验证当前用户是否还有访问权限。

关于 onBreakpointChange,也不要把回调当成“整个响应式系统”的全部。它报告的是栅格断点变更,既不是可靠的物理屏幕测量器,也不是折叠姿态传感器。假如产品将“显示详情”做成独立路由,就应该由业务导航状态确定打开哪个页面,而不是仅依赖 sm/md/lg 枚举推断用户意图。

一份覆盖真实失败路径的回归清单

可以围绕三组变化进行人工或自动化验收。先只改窗口:选择第二条,拖动窗口经过 sm→md→lg→sm,确认高亮与详情始终指向2;如果它们不同步,优先检查是否存在第二份局部 selectedId。再只改页面生命周期:在同一应用进程中退出并重新进入该页面,观察链接值是否仍保留;如果每次都跳回1,重点检查页面是否意外重建了应用级键或写回默认值。最后只改业务数据:删除当前选中项,确认回退到第一条;依次删除所有项,确认空态可读且“移除”操作不可用。

这些步骤给出的都是预期行为与检查方法,并非某台设备的已采集测量结果。遇到布局错乱时,可以先固定 selectedId 单独观察两栏排布,再固定窗口大小单独观察选中态更新,减少多个变量同时变化造成的排查噪声。若提示栏的断点值与视觉排布不一致,应把 GridRow 的实际容器约束、父组件宽度与回调触发时机一起检查,不能只看设备型号。

本篇完成的是一条小而稳的工程边界:窗口变化只影响布局,业务选择由唯一标识驱动,组件重建再通过应用级状态接续。下一篇将在这条边界之上加入工单筛选与详情导航,重点处理“筛选结果不含当前选择”以及详情路由回退后的状态一致性,避免用更多页面复制更多状态。

参考资料:

Logo

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

更多推荐