保修凭证的附件页经常需要在多种任务之间来回切换:服务人员先查看购机发票,再返回设备信息核对序列号;随后重新进入附件页,希望仍然停留在“票据”分类、保留刚才选中的发票,并在宽屏工作区直接显示详情。如果每次进入都从默认分类重新加载,操作人员得重复查找;更麻烦的是前一次加载尚未结束时切换了凭证,旧结果可能覆盖新页面。前七篇已经完成编号规范化、设备字段约束、重复登记、附件落盘、更换回收、缩略图解码以及容器布局,本篇把这些能力在页面恢复与异步竞态上收口。

附件浏览状态恢复与过期请求隔离

恢复的是浏览意图,不是图像资源

要保存的内容是当前凭证身份、筛选分类、选中附件ID与列表位置。像 PixelMap、ImageSource、文件描述符这样的资源具有生命周期,既不适合序列化,也不适合跟随跨页面导航长期驻留。存储层只保存少量可解释的值;重新进入页面时,再以当前附件清单为准修复选中项,随后按需重建缩略图。这样,删掉的附件不会因为旧状态而重新出现,图片释放也能继续复用第六篇的逻辑。

这里把列表的滚动位置抽象为第一个可见附件的稳定ID,而不是单纯保存像素偏移。容器从上下结构改为左右结构后,每行高度和列数可能变化,旧偏移所指的内容已经不同;锚点ID可以转换成当前布局下的新索引。筛选分类限定为 全部、票据、设备照片 和 维修记录,非法存储值回退为“全部”,避免配置升级后留下不可见的空列表。

工程职责分为四处:model/GalleryState.ets 管理快照校验,service/GalleryPrefs.ets 处理轻量持久化,service/AttachmentFeed.ets 负责附件获取,pages/GalleryRestorePage.ets 将最新清单、快照与页面状态合并。前三个模块与界面排版解耦;窄屏和宽屏继续共用第七篇的 selectedId,不会分别维护两份选择。

用纯函数修复过期的选中项

快照中的ID可能已经失效。例如更换附件后,原来的 receipt-old 已从主记录解除关联;当前清单只保留 receipt-new。恢复不能直接把旧ID交给图片加载服务,而要先过滤当前凭证,再按分类取得可用项。如果该分类已没有附件,页面保持分类标签并显示空状态,同时将选中项清空,不能悄悄切换到其他分类造成误读。

// model/GalleryState.ets
export type GalleryFilter = '全部' | '票据' | '设备照片' | '维修记录';
export interface GalleryItem {
  id: string;
  voucherId: string;
  category: GalleryFilter;
  name: string;
  localPath: string;
}
export interface GallerySnapshot {
  voucherId: string;
  filter: GalleryFilter;
  selectedId: string;
  anchorId: string;
}
export interface RestoredGallery {
  snapshot: GallerySnapshot;
  visible: GalleryItem[];
}
const FILTERS: GalleryFilter[] = ['全部', '票据', '设备照片', '维修记录'];
export function restoreGallery(voucherId: string, items: GalleryItem[],
  state: GallerySnapshot): RestoredGallery {
  const filter: GalleryFilter = FILTERS.includes(state.filter) ? state.filter : '全部';
  const owned = items.filter((row: GalleryItem) => row.voucherId === voucherId);
  const visible = filter === '全部' ? owned : owned.filter((row: GalleryItem) => row.category === filter);
  const selectedId = visible.some((row: GalleryItem) => row.id === state.selectedId)
    ? state.selectedId : (visible.length > 0 ? visible[0].id : '');
  const anchorId = visible.some((row: GalleryItem) => row.id === state.anchorId)
    ? state.anchorId : selectedId;
  return { snapshot: { voucherId, filter, selectedId, anchorId }, visible };
}

修复函数使用输入集合返回新对象,原数组不变。业务仓储应按凭证ID完成权限和归属检查,页面的过滤只承担显示职责。若同一附件ID出现在不同凭证中,不能靠文件名推测真实身份,更不能用图片路径作为业务主键。对空集合、筛选后无结果、旧选中项已删除以及凭证身份切换四类情形,都可以给这个函数提供确定的输入来检查返回状态。

Preferences只记录轻量浏览快照

HarmonyOS 的 ArkData Preferences 适合持久化小量键值。使用 getPreferences 获取实例,将不同凭证的状态写入不同键;写入完成后调用 flush 使数据落到持久化文件。这里不把文件内容、图片字节或大批量附件清单存进去,所有业务附件仍由原有关系存储和私有文件目录管理。Preferences 获取到的值需要做类型和字段检查,解析失败时用默认快照恢复,而不是在页面创建阶段抛出异常中断布局。

// service/GalleryPrefs.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { GallerySnapshot } from '../model/GalleryState';

export class GalleryPrefs {
  constructor(private context: common.UIAbilityContext) {}
  private key(voucherId: string): string { return `gallery:${voucherId}`; }
  private defaultState(id: string): GallerySnapshot {
    return { voucherId: id, filter: '全部', selectedId: '', anchorId: '' };
  }
  async read(voucherId: string): Promise<GallerySnapshot> {
    try {
      const store = await preferences.getPreferences(this.context, { name: 'gallery_state' });
      const value = await store.get(this.key(voucherId), '');
      if (typeof value !== 'string' || value.length === 0) return this.defaultState(voucherId);
      const raw = JSON.parse(value) as GallerySnapshot;
      if (raw.voucherId !== voucherId || typeof raw.selectedId !== 'string' ||
          typeof raw.anchorId !== 'string' || typeof raw.filter !== 'string') {
        return this.defaultState(voucherId);
      }
      return raw;
    } catch (_) { return this.defaultState(voucherId); }
  }
  async write(state: GallerySnapshot): Promise<void> {
    const store = await preferences.getPreferences(this.context, { name: 'gallery_state' });
    await store.put(this.key(state.voucherId), JSON.stringify(state));
    await store.flush();
  }
}

实际保存时应在用户改变分类、附件选择或锚点后做适度合并,避免每次滚动都立即触发磁盘刷新。页面销毁时可以追加一次保存,但不能把唯一保存时机放在销毁回调中:用户快速离开、进程回收或页面重建都可能打断尚未完成的异步任务。对写入失败,页面仍可保持当前会话中的选择,只是在再次进入时按默认规则回退,并在合适位置提示“浏览位置未保存”。

用请求世代隔离晚到结果

凭证A的清单请求开始后,用户切换到凭证B;若A先发起而后返回,直接 this.rows = result 就会把A的附件绘制到B的标题下面。这里为每次加载发放递增的世代值,关闭页面或变更凭证时使旧世代失效。网络或数据库请求即便无法立即停止,晚到的数据也不会写入当前状态。加载中的状态也由最新世代负责收尾,旧请求不能把新请求的转圈提示提前关闭。

// service/AttachmentFeed.ets
import { GalleryItem } from '../model/GalleryState';
export interface AttachmentGateway {
  fetch(voucherId: string): Promise<GalleryItem[]>;
}
export class AttachmentFeed {
  private epoch: number = 0;
  constructor(private gateway: AttachmentGateway) {}
  invalidate(): void { this.epoch += 1; }
  async latest(voucherId: string): Promise<GalleryItem[] | null> {
    const epoch = ++this.epoch;
    try {
      const rows = await this.gateway.fetch(voucherId);
      return epoch === this.epoch ? rows : null;
    } catch (err) {
      if (epoch !== this.epoch) return null;
      throw err;
    }
  }
}

null 表示调用结果已经过期,不是“凭证没有附件”;空数组才是仓储明确返回的零条数据。区分这两种含义,可以避免在快速切换时短暂展示错误的空状态。此类世代计数只管理当前页面实例的结果接收,业务附件删除、版本更新仍要回到仓储层执行并发校验。

页面状态与生命周期闭环

页面开始时先设置标题与凭证ID,然后并行读取保存的快照和最新附件。二者都返回后调用 restoreGallery,统一设置分类、选中项和列表内容。恢复选中项后,才开始第六篇的图片解码;如果用户在恢复过程中又切换了凭证,相关结果将被世代检查拦住。离开页面时使加载失效并释放当前预览资源,保持图片生命周期与状态持久化彼此独立。

// pages/GalleryRestorePage.ets 中的核心状态组织
import { GallerySnapshot, GalleryItem, restoreGallery } from '../model/GalleryState';
@Entry
@Component
struct GalleryRestorePage {
  @State filter: string = '全部';
  @State selectedId: string = '';
  @State rows: GalleryItem[] = [];
  @State tip: string = '';
  private active: boolean = true;
  aboutToAppear(): void { this.active = true; }
  aboutToDisappear(): void { this.active = false; }
  applyData(voucherId: string, items: GalleryItem[], state: GallerySnapshot): void {
    if (!this.active) return;
    const restored = restoreGallery(voucherId, items, state);
    this.filter = restored.snapshot.filter;
    this.selectedId = restored.snapshot.selectedId;
    this.rows = restored.visible;
    this.tip = this.rows.length === 0 ? '该分类暂无附件' : '';
  }
  build() {
    Column({ space: 12 }) {
      Text('凭证附件').fontSize(24).fontWeight(FontWeight.Bold)
      Text(`当前分类:${this.filter}`)
      if (this.rows.length === 0) { Text(this.tip) }
      List() {
        ForEach(this.rows, (item: GalleryItem) => {
          ListItem() {
            Text(item.name).fontColor(item.id === this.selectedId ? Color.Blue : Color.Black)
              .onClick(() => { this.selectedId = item.id; })
          }
        }, (item: GalleryItem) => item.id)
      }
    }.width('100%').height('100%').padding(16)
  }
}

页面代码中的 applyData 由仓储接入层在快照和附件都准备好后调用,偏好保存逻辑则放在分类与选择动作的处理函数中。这样可保持页面结构清晰,也避免把异步调用塞进 build。完整工程接入时,建议按“进入A→切换B→A晚返回”“选中图片被替换”“过滤后无附件”“宽窄布局连续变化”“页面离开再进入”五条路径记录请求世代、凭证ID和选中ID,检查是否发生交叉赋值。HiLog输出以业务标识为主,不记录票据原图的路径或个人信息。

DevEco Studio中的附件状态与预览工作区

恢复筛选与选中附件后的手机界面

附件管理的终点不是把列表重新画出来,而是把有效业务状态与暂时的图像资源分开:Preferences保存浏览意图,仓储提供可信清单,纯函数修复已变化的选择,异步世代阻止过期结果写回。此时手机和平板的布局可以自由变化,旧凭证的附件不会因为请求顺序交错而进入当前视图。

把恢复问题归类为可诊断状态

恢复失败不应该统一显示“加载失败”。读取 Preferences 出错时,仍可正常打开最新附件清单,只是当前筛选和位置退回初始值;附件仓储返回失败时,需要提供重试入口,同时保留用户刚选择的分类,不能把数据库错误解释成没有数据;当请求世代失效时,则不显示额外的错误横幅,因为当前页面已经开始处理新的凭证。这样的分类让界面反馈与实际故障位置一致,也能避免反复弹出无关消息。

建议在调试阶段按“请求发起、快照返回、清单返回、选择修复、图片解码”五个节点记录同一会话内的世代值。尤其要观察快速切换 A 和 B 后,是否存在 A 的 finally 修改了 B 的加载标记。退出页面时保存的是文本快照,图像资源则按第六篇既定流程释放;无论恢复是否成功,都不应以持久化结果来决定资源是否可释放。缩略图缓存还要以凭证 ID 和附件版本共同作为键,避免附件替换后复用过期像素数据。对状态恢复来说,找回位置只是表层体验,更关键的是保证当前页面的身份、列表、缩略图和选择始终指向同一组有效附件。

相关开发文档

Logo

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

更多推荐