HarmonyOS保修凭证连载07:容器断点与附件主从布局【共创稿事节】
维修凭证管理到了附件预览这一步,界面宽度开始影响操作路径。手机上选中一张购机票据,再进入详情查看就很自然;窗口变宽后,如果仍把列表和详情拆成两次页面跳转,平板上的空白区域没有被利用,工作人员还要不断返回列表寻找下一张照片。问题的核心并不是多画一个面板,而是宽窄布局改变时,业务选择与解码资源应当保持独立。
前一篇完成了按需创建缩略图和离开页面时释放图像资源。本篇沿用凭证 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 如果由共同的页面级管理者持有,通常可以复用;如果由卡片独占,需要在卡片销毁时释放并重新获取。这两种策略都可行,关键是引用关系明确,避免一个分支释放另一个分支仍在使用的图像对象。对超大图片继续坚持缩略预览与按需加载原图,不因为平板有更大显示区域就默认解码完整分辨率。
本次改动的价值在于把业务选择、容器布局、异步结果和图像资源分成四条独立的状态线。手机、折叠屏和平板共用附件数据与选择逻辑,显示空间变了,凭证关系不变。下一篇可以继续补全附件浏览的检索、访问路径与异常恢复,形成完整的售后凭证工作台。
相关文档
更多推荐



所有评论(0)