维修凭证管理到了附件预览这一步,界面宽度开始影响操作路径。手机上选中一张购机票据,再进入详情查看就很自然;窗口变宽后,如果仍把列表和详情拆成两次页面跳转,平板上的空白区域没有被利用,工作人员还要不断返回列表寻找下一张照片。问题的核心并不是多画一个面板,而是宽窄布局改变时,业务选择与解码资源应当保持独立。

前一篇完成了按需创建缩略图和离开页面时释放图像资源。本篇沿用凭证 AB-2048-73、附件身份键与私有文件路径,新增一个可切换上下结构和左右结构的主从页面。选择哪张票据属于页面业务状态;怎样摆放列表和详情属于容器状态。把两者混在一起,窗口尺寸变化就可能清空选择、重复读取文件,甚至把已切换凭证的图片显示到当前凭证上。

保修凭证附件的宽窄容器布局

先明确布局依据

HarmonyOS 7 的 ArkUI ContainerReader 从 API 26.0.0 开始提供容器级断点。它与按整个应用窗口判断的方式不同:实际内容区域被侧栏、导航栏或浮窗占用后,容器可能变窄,即使屏幕仍然很宽,附件工作区也应及时转为上下排列。华为开发者文档要求尺寸与断点值使用状态变量,并在绑定时添加 !!;容器本身还需要有明确的父级尺寸。

这里选取 520vp 和 840vp 两道业务阈值:低于 520vp 时将列表与详情上下排列;520~840vp 时仍优先上下结构,但列表可以展示两列;达到 840vp 后启用左侧附件列表、右侧大图详情。阈值是这个工作区的设计选择,并非系统固定设备分类。窄窗口的折叠屏、桌面浮动窗口与横屏平板都按同一规则处理。

工程路径只保留和本问题有关的职责:model/AttachmentState.ets 定义附件记录与选中项修复,pages/AttachmentWorkspacePage.ets 负责容器响应,service/AttachmentFeed.ets 管理异步读取及取消结果接收。图片缓存沿用上一篇的缩略图服务,不在宽窄布局切换时直接销毁。

把选中状态从组件排列中抽离

列表元素必须使用稳定的附件 ID,而不是它在数组中的位置。附件替换后,即使列表排序变化,ID 仍能定位正确记录;如果选中的附件已被移除,才退回可用列表中的第一张。对于空列表,详情显示空状态,不尝试读取未知路径。

// model/AttachmentState.ets
export interface AttachmentItem {
  id: string;
  voucherId: string;
  fileName: string;
  privatePath: string;
  sizeBytes: number;
}

export function retainSelection(rows: AttachmentItem[],
  preferredId: string): string {
  if (rows.some((item: AttachmentItem) => item.id === preferredId)) {
    return preferredId;
  }
  return rows.length > 0 ? rows[0].id : '';
}

export const initialAttachments: AttachmentItem[] = [
  { id: 'receipt-01', voucherId: 'AB-2048-73',
    fileName: '购机发票.jpg', privatePath: 'receipts/01.jpg', sizeBytes: 1887436 },
  { id: 'box-01', voucherId: 'AB-2048-73',
    fileName: '包装盒.jpg', privatePath: 'receipts/02.jpg', sizeBytes: 2202010 },
  { id: 'serial-01', voucherId: 'AB-2048-73',
    fileName: '序列号.jpg', privatePath: 'receipts/03.jpg', sizeBytes: 1258291 },
  { id: 'service-01', voucherId: 'AB-2048-73',
    fileName: '维修单.jpg', privatePath: 'receipts/04.jpg', sizeBytes: 1572864 }
];

privatePath 保存的是应用内部可解析的相对路径;预览服务负责结合应用文件目录确定位置。界面不能从文件名猜路径,更不应把相册选择时的临时 URI 长期当作私有附件地址。上面数据用于完整展示状态组织方式,后续由仓储读取结果替换。

使用容器断点切换主从结构

列表和详情不能分别维护两个选中项。两个分支复用同一个 selectedId,只是布局方向不同。为减轻频繁宽度变化带来的重排,页内不在断点事件中触发数据库查询;ContainerReader 更新断点状态后,ArkUI 负责重建相应的布局节点。

// pages/AttachmentWorkspacePage.ets
import { ContainerReader, Size } from '@kit.ArkUI';
import { AttachmentItem, initialAttachments,
  retainSelection } from '../model/AttachmentState';

@Entry
@Component
struct AttachmentWorkspacePage {
  @State area: Size = { width: 0, height: 0 };
  @State widthBp: WidthBreakpoint = WidthBreakpoint.WIDTH_MD;
  @State items: AttachmentItem[] = initialAttachments;
  @State selectedId: string = 'receipt-01';

  private choose(id: string): void {
    this.selectedId = retainSelection(this.items, id);
  }

  @Builder
  private attachmentList() {
    Grid() {
      ForEach(this.items, (item: AttachmentItem) => {
        GridItem() {
          Column({ space: 8 }) {
            Text('📄').fontSize(27)
            Text(item.fileName).fontSize(14).maxLines(1)
            if (item.id === this.selectedId) {
              Text('当前选中').fontColor('#1266CB').fontSize(12)
            }
          }
          .padding(10)
          .width('100%')
          .backgroundColor(item.id === this.selectedId ? '#EAF3FF' : '#FFFFFF')
          .onClick(() => this.choose(item.id))
        }
      }, (item: AttachmentItem) => item.id)
    }
    .columnsTemplate('1fr 1fr')
    .columnsGap(10)
    .rowsGap(10)
    .width('100%')
    .height('100%')
  }

  @Builder
  private attachmentDetail() {
    Column({ space: 12 }) {
      Text('附件详情').fontSize(20).fontWeight(FontWeight.Bold)
      if (this.selectedId.length > 0) {
        Text(this.items.find((item: AttachmentItem) =>
          item.id === this.selectedId)?.fileName ?? '')
        Text(`选中附件:${this.selectedId}`).fontSize(13)
      } else {
        Text('当前凭证还没有附件')
      }
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }

  build() {
    Column({ space: 12 }) {
      Text('保修凭证附件').fontSize(24).fontWeight(FontWeight.Bold)
      ContainerReader({ size: this.area!!, widthBreakpoint: this.widthBp!! }) {
        if (this.widthBp === WidthBreakpoint.WIDTH_XS ||
            this.widthBp === WidthBreakpoint.WIDTH_SM) {
          Column({ space: 12 }) {
            Column() { this.attachmentList() }.height('53%').width('100%')
            Column() { this.attachmentDetail() }.layoutWeight(1).width('100%')
          }
        } else {
          Row({ space: 16 }) {
            Column() { this.attachmentList() }.width('40%').height('100%')
            Column() { this.attachmentDetail() }.layoutWeight(1).height('100%')
          }
        }
      }
      .breakpointConfig({ width: [520, 840] })
      .layoutWeight(1)
      .width('100%')
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }
}

界面还有键盘与辅助访问两个容易遗漏的细节。宽度缩小时,右侧详情可能被移到列表下方,如果焦点还留在已经销毁的详情按钮上,键盘用户需要重新寻找操作入口。可以把“预览”“替换”和“移除”的操作顺序固定在详情区域内部,并为选中卡片提供可读的选中状态,不用背景色承担全部含义。切换布局时焦点如何迁移,应按正在操作的控件确定,不能把焦点一律重置到页面顶部。对于票据数量很多的情况,Grid 容器本身应限定高度并允许滚动;主从页面要避免外层与内部同时抢占竖向滚动。

这里使用 Grid 布置附件卡片;实际接入缩略图后,可以在卡片内显示缓存中的 PixelMap。ContainerReader 负责的是容器宽度感知,不负责图片缓存的所有权。特别要注意,主从结构两个分支各自的组件可能被销毁或重新创建,因此不要把当前票据 ID 只保存在某一个附件卡片的局部状态中。

白色主题开发环境中的容器断点与附件列表

防止布局改变触发过期数据回写

还有一种更隐蔽的情况:工作人员正在查看一张票据,后台重新读取附件列表;这个请求未返回时,他切换到了另一张凭证。旧请求稍后完成,如果直接赋值,会把上一张凭证的附件覆盖到当前界面。这不是宽度问题,而是异步结果缺乏归属校验。可以给每轮读取分配递增的世代值,并让仓储接口按凭证 ID 读取;页面退出后拒收迟到的结果。

// service/AttachmentFeed.ets
import { AttachmentItem, retainSelection } from '../model/AttachmentState';

export interface AttachmentRepository {
  listByVoucher(voucherId: string): Promise<AttachmentItem[]>;
}

export class AttachmentFeed {
  private generation: number = 0;
  private closed: boolean = false;

  async refresh(repository: AttachmentRepository, voucherId: string,
    previousSelection: string,
    deliver: (rows: AttachmentItem[], selectedId: string) => void,
    report: (message: string) => void): Promise<void> {
    const token = ++this.generation;
    try {
      const rows = await repository.listByVoucher(voucherId);
      if (this.closed || token !== this.generation) { return; }
      const owned = rows.filter((item: AttachmentItem) => item.voucherId === voucherId);
      deliver(owned, retainSelection(owned, previousSelection));
    } catch (error) {
      if (!this.closed && token === this.generation) {
        report(`附件读取失败:${String(error)}`);
      }
    }
  }

  invalidate(): void { this.generation++; }
  dispose(): void { this.closed = true; this.invalidate(); }
}

页面的 aboutToAppear 发起读取,aboutToDisappear 调用 dispose,切换凭证时调用 invalidate 后读取新凭证。采用 deliver 回调的好处是仓储服务不用认识 ArkUI 的 @State,页面依然自行决定如何更新。实际清理缩略图时,应遵循上一篇明确的引用所有权:页面真正离开才释放独占资源,单纯从上下布局切换到左右布局不能当作页面退出。

附件记录本身仍然需要归属校验。在不同宽度下显示同一份数组,意味着数据源也可能被其他页面修改。即使界面只显示缩略图,也不能把附件 ID 作为直接打开任意私有路径的许可:仓储查询应绑定当前凭证主键,预览前再次确认附件属于这张凭证。删除关联后页面选择回退,缩略图缓存也应按附件 ID 与内容版本共同定位,避免新照片沿用旧缓存图像。这些检查都属于数据层,和 ContainerReader 的布局分支无关。

调试抓住三个状态,而不是屏幕外观

建议分别准备 390vp、720vp、960vp 的可用容器宽度,并让操作顺序固定:选中 receipt-01、缩小窗口、选中 serial-01、恢复宽窗口、移除被选中的记录。关注的是 widthBp、selectedId 和 items.length 三个值能否符合预期。宽度变化前后 selectedId 不应变化;删除选中项后应落到现存附件;数据全部清空时详情区域显示空状态。

第二组检查与异步有关:在前一个凭证请求还未完成时切换到下一凭证,确认晚返回的记录不会混进新列表。HiLog可以记录凭证 ID、请求世代、当前断点和选中 ID;避免打印完整私有文件路径和票据上可识别的个人信息。若出现重复读取,先检查请求是否写在 build() 或断点更新链路,而不是盲目提高缓存容量。

窄屏附件列表和当前选中票据详情

最后也要检查图片缓存。当宽窄分支切换时,预览的 PixelMap 如果由共同的页面级管理者持有,通常可以复用;如果由卡片独占,需要在卡片销毁时释放并重新获取。这两种策略都可行,关键是引用关系明确,避免一个分支释放另一个分支仍在使用的图像对象。对超大图片继续坚持缩略预览与按需加载原图,不因为平板有更大显示区域就默认解码完整分辨率。

本次改动的价值在于把业务选择、容器布局、异步结果和图像资源分成四条独立的状态线。手机、折叠屏和平板共用附件数据与选择逻辑,显示空间变了,凭证关系不变。下一篇可以继续补全附件浏览的检索、访问路径与异常恢复,形成完整的售后凭证工作台。

相关文档

Logo

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

更多推荐