Demo:PhotoBin|任务:PH-631|技术栈:ArkTS、ArkUI List、ForEach、@State
说明:页面及日志为设计演示,不是系统相册实际删除、DevEco编译通过或真机跑通的证据。

一、先从一个会让列表“选错图”的场景说起

搜索结果有八张图片,用户正在查看编号photo-025,又把前面一张photo-023从当前结果里移除。直觉上只少了一行,页面似乎不该发生什么。可若业务用数组下标记住“选中第几个”,被移除项之后的下标都会前移,详情栏就可能莫名跳向别的图片。列表继续显示,用户却会以为自己刚刚确认的照片被换掉了。

本文用一个较小的文搜图结果管理页解释这类问题。Demo叫PhotoBin,任务号PH-631,查询词固定为“雨后站台”。预设的八个结果使用photo-021到photo-028作为稳定ID;其中photo-023被从当前搜索结果视图中排除,因此画面显示七张可见,当前选择仍为photo-025。这里的“移除”不是向媒体库发起删除操作,更不会删除原始照片文件。

关键区别在于:结果列表的展示顺序是随时可能变化的,业务实体标识不能跟着变。ArkUI的List负责布局,ForEach负责为每个数据项创建组件;真正决定回收行为是否可靠的,是应用有没有给每条结果一个可持续识别的主键,并明确选择态随数据变化的规则。

1. 不要把数组下标当作照片身份

常见写法会保存selectedIndex=4,之后splice(2,1)再依据同一下标取值。虽然数组操作本身正确,但业务语义已经变了。另一种不稳定写法是在ForEach里把索引直接拼成组件键;元素删除后,后面多个组件的键会重排,列表复用和交互态就容易与原实体脱节。

本例只用两个原则约束视图:每张结果的id由搜索数据层产生,整个回收与撤销过程中不变;当前选中值始终存成selectedId,而不是可见序号。列表显示“第几张”只是计算出来的文案,不反过来参与业务选择。

二、模型简单一些,状态边界就清楚一些

1. 先确定演示的数据契约

为保证示例可读,将图片的真实媒体URI省略,只保留稳定的结果ID和可显示标题。下面代码解决“相同结果刷新后怎样继续识别同一张图片”的问题。假如接入真实文搜图服务,数据层还应携带图片来源、权限状态和失效标记;不能假定一次搜索得到的临时URI可以跨启动永久使用。

// entry/src/main/ets/model/PhotoItem.ets
export interface PhotoItem {
  id: string;
  title: string;
}
export const INITIAL_PHOTOS: PhotoItem[] = [
  { id: 'photo-021', title: '站台灯箱' },
  { id: 'photo-022', title: '雨后轨道' },
  { id: 'photo-023', title: '候车长椅' },
  { id: 'photo-024', title: '车门倒影' },
  { id: 'photo-025', title: '雨棚尽头' },
  { id: 'photo-026', title: '夜间站牌' },
  { id: 'photo-027', title: '远处列车' },
  { id: 'photo-028', title: '地面积水' }
];

此处八项初始数据是为了复现交互,不是模型识别的真实命中数量。PhotoItem是本项目自建的数据结构,不是华为系统返回类型。对于实际产品,同名图片并不保证指向同一实体,因此键不能用标题生成;用稳定结果ID或者与数据源保持一致的唯一媒体标识,才便于撤销和刷新。

2. 先更新数据,再处理选择态

业务动作只做视图排除:用户可以继续到详情页查看原图,原图存储也不受影响。我们给页面增加一份轻量的撤销记录,保存被移除的对象和原始位置。下面的方法解决“删除其他项时保留选中、删除当前项时有明确后备选择”的问题。

// PhotoBinPage.ets:以下成员写在同一个 @Component struct 内
@State visibleItems: PhotoItem[] = INITIAL_PHOTOS.slice();
@State selectedId: string = 'photo-025';
@State excludedId: string = '';
private lastRemoved: PhotoItem | null = null;
private lastIndex: number = -1;

removeFromResults(id: string): void {
  const index = this.visibleItems.findIndex((v: PhotoItem) => v.id === id);
  if (index < 0) return;
  this.lastRemoved = this.visibleItems[index];
  this.lastIndex = index;
  this.visibleItems = this.visibleItems.filter((v: PhotoItem) => v.id !== id);
  this.excludedId = id;
  if (this.selectedId === id) {
    const next = this.visibleItems[Math.min(index, this.visibleItems.length - 1)];
    this.selectedId = next ? next.id : '';
  }
  console.info(`PhotoBin PH-631 remove ${id} visible=${this.visibleItems.length}`);
}

undoRemove(): void {
  if (!this.lastRemoved) return;
  const items = this.visibleItems.slice();
  items.splice(this.lastIndex, 0, this.lastRemoved);
  this.visibleItems = items;
  this.lastRemoved = null;
  this.excludedId = '';
}

把数组重新赋值,是为了让状态更新发生在明确边界上。这里也没有在ForEach的绘制回调里直接修改数组,避免一次构建触发额外状态变化。lastRemoved仅保存一次撤销;如果要支持多步撤销、跨页面恢复或进程重启,应改为有上限的命令队列或持久化事件日志,并对重复执行设置幂等规则。

在本例中移除的是photo-023,而selectedId本来就指向photo-025,所以它不会变化;剩余七项依旧按原顺序显示。若被移除的恰好就是选中图,则按照被移除位置选择其后一张,位于末尾时退到上一张;列表为空时用空字符串表示“没有可选择项目”。这些都是业务取舍,不是ArkUI系统强加的规则。

三、渲染层只认ID,不认旧下标

1. List与ForEach的协作

ArkUI文档说明,List能够和ForEach组合使用,不过ForEach会创建全部子组件。对于本文八项的小列表很合适;真正上千条的文搜图结果,应比较LazyForEach或Repeat的按需创建、内存占用及列表滚动表现,不能直接把这段小列表代码当作性能方案。

下面代码解决的是“组件复用时怎样把选择态准确绑定到实体ID”。它与上一段方法属于同一个页面组件,导入INITIAL_PHOTOS与PhotoItem后即可组合。

// entry/src/main/ets/pages/PhotoBinPage.ets(同一组件的 build 方法)
build() {
  Column({ space: 12 }) {
    Text('文搜图结果管理').fontSize(25).fontWeight(FontWeight.Bold)
    Text('任务 PH-631 · 雨后站台')
    Text(`可见 ${this.visibleItems.length} 张 · 已选 ${this.selectedId}`)
    List({ space: 8 }) {
      ForEach(this.visibleItems, (item: PhotoItem) => {
        ListItem() {
          Row() {
            Text(item.title).layoutWeight(1)
            Text(item.id).fontSize(12)
          }.padding(16)
           .backgroundColor(this.selectedId === item.id ? '#DDEBFF' : '#FFFFFF')
        }.onClick(() => { this.selectedId = item.id; })
      }, (item: PhotoItem) => item.id)
    }.layoutWeight(1)
    Button(this.excludedId ? '撤销结果移除' : '移除 photo-023')
      .onClick(() => {
        if (this.excludedId) this.undoRemove();
        else this.removeFromResults('photo-023');
      })
  }.width('100%').height('100%').padding(16)
}

ForEach的第三个参数是键生成函数,此处返回实体id,这才是代码的关键。selectedId变化只影响对应项目的呈现,不要求重新分配其他项的业务身份。开发者仍要注意:如果服务端复用相同ID指向不同照片,前端再稳定也无济于事。真正的可靠性来自全链路对唯一键的共同约定。

图片中的DevEco工作区属于演示示意:左侧是PhotoBin工程、中央是当前代码,右侧预览七项结果和选中photo-025,底部HiLog示意移除事件。它说明预期状态分布,绝不代表系统媒体库真的执行过删除,或者已通过编译与设备测试。

四、如何判断修复是否真正有用

这类问题不能只靠“视觉上好像没跳动”判断。可以在纯业务模型层做可重复演练:初始数组8项,选中photo-025;从结果视图移除photo-023后,数组变为7项,选中仍是photo-025;撤销时把原对象插回第三个位置,数组恢复为8项,ID顺序不变。这三个断言比滚动截屏更能够验证数据规则。

手机纯屏配图展示的是执行“移除结果”之后的快照:查询词“雨后站台”,任务PH-631,可见七张,当前选择photo-025,被排除的是photo-023。这种演示既能让非研发同事理解差异,也能辅助测试团队整理场景,但不能替代实际控件自动化和多设备触摸验证。

1. 把容易遗漏的逆向操作也纳入检查

除了正常回收,还要覆盖重复点击同一个移除按钮、在异步刷新过程中撤销、列表中最后一项被删除、当前选中项被删除以及服务器下发同一实体两次等情况。当前Demo对不存在的ID选择无操作,对连续撤销只恢复一次;实际产品还应确认后台刷新是否会再次把已排除项加入结果。否则视图今天能恢复,下一次搜索仍可能反复出现它。

日志要记录业务事件和稳定ID,而不是仅记录“第3个元素删掉了”。序号只能说明当时的显示位置,无法唯一定位对象;photo-023却能在搜索结果、撤销记录和投诉反馈之间建立可追溯联系。日志中涉及隐私或真实媒体标识时应按产品要求最小化记录,本示例使用的全是假ID。

五、适用边界和下一次演进

本篇刻意不引入系统照片删除接口,也不模拟用户授权。List + ForEach处理的是应用结果数据的显示与选择,不是文件回收站,更不是跨设备媒体同步。如果要真正删除系统相册资源,必须另行研究MediaLibraryKit官方授权和删除流程,不能把业务数组filter()冒充资源已删除。

与此前偏重语义搜索、筛选保存、无障碍朗读的实现不同,这里聚焦的是结果集合发生结构性变更之后,如何守住选中实体身份。方案的好处是细节可见、成本小、易于写断言;局限在于演示只处理单页八项和一次撤销,没有分页、服务端竞态、资源回收以及复杂生命周期恢复。正式落地时,这些约束应进入状态模型,而不是依赖UI临时补丁。

真正值得保留的经验是:页面顺序属于展示层,稳定ID属于业务层,二者不要混用。只要这条界线清楚,后续换成Grid、LazyForEach甚至多窗口视图,也更容易保住同一张照片的选择状态。设备实测、辅助功能和性能结果仍待接入真实项目后再单独验收,本文没有虚构完成情况。

官方核对入口: 华为ArkUI List组件文档:https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-container-list ;ArkUI渲染控制ForEach参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-rendering-control-foreach 。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐