用户从“展览”切回“全部活动”,CityStage 的目录数据已经更新,列表却出现另一种难追踪的问题:图片卡片偶尔显示上一条活动的海报,滚动位置也因为数据更新突然跳动。它不是第04篇处理的“旧请求覆盖新请求”,而是数据快照正确后,渲染节点怎样认出自己的业务身份。本篇延续 CityStage 的活动 ID、目录缓存、收藏状态和 Navigation 详情入口,处理长列表节点的稳定身份、图片加载状态以及缺图时的可理解反馈。

CityStage 活动长列表技术封面

一、先把目录快照和组件生命周期分清

前文的四个稳定活动仍保留:11“光影摄影展”、12“爵士现场”、13“手作器物市集”、14“城市建筑摄影展”。EventItem 仍含 id、title、category、location;此轮根据展示需求扩展可选 posterUrl 字段,旧缓存没有图片地址时按缺图处理,不强制迁移全部收藏数据。活动源采用上一轮已验证的 catalog 数组,loadState 仍为 loading/fresh/cached/error。收藏仓储没有任何改动,仍以数字活动 ID 为键。

业务 ID 是服务器对一条活动的标识,不是它在数组中的位置。用户选择“只看收藏”后,原本第4条活动可能成为筛选结果的第1条;如果界面把数组下标当成键,旧第1条节点的内部图片加载态可能被下一条活动借用。改成 item.id.toString() 后,插入、排序和过滤不改变同一活动的身份。这一条规则要求后端目录不能含重复 ID;第04篇的 validateCatalog() 已经建立了这个检查,渲染层无需再次容忍重复键。

对于只有四个元素的列表,最直观的 ForEach 足够;但活动扩展到几百条后,全部创建节点会让图片与复杂卡片成为额外开销。华为官方 List 文档明确区分 ForEach 的全量创建与 LazyForEach 的按需创建。我们保留现有仓储,在它和 UI 之间新增一个仅管理渲染节点的数据源,避免把刷新、收藏和图片状态混进同一对象。

二、为 LazyForEach 建一份可更新的数据源

LazyForEach 不会因为给旧对象重新赋一个数组就自动完成正确的局部更新,它要求实现 IDataSource,并通过 DataChangeListener 通知数据变化。以下示例以“整个目录快照替换”作为最简单的安全边界:每次输入是经过校验的新快照;由于此次业务数据规模仍然有限,刷新时通知重载,后续真正引入分页时再细化为增删事件。

// entry/src/main/ets/common/EventFeedSource.ets
import { EventItem } from '../model/EventItem';

export class EventFeedSource implements IDataSource {
  private rows: EventItem[] = [];
  private listeners: DataChangeListener[] = [];

  totalCount(): number { return this.rows.length; }
  getData(index: number): EventItem { return this.rows[index]; }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const index = this.listeners.indexOf(listener);
    if (index >= 0) { this.listeners.splice(index, 1); }
  }

  replace(items: EventItem[]): void {
    this.rows = items.slice();
    this.listeners.forEach((listener: DataChangeListener): void => {
      listener.onDataReloaded();
    });
  }
}

这里的 replace() 不承担 API 结果的合法性校验:它只接收第04篇 validateCatalog() 已认可的数据。slice() 创建新的数组快照,防止调用方下一行对旧数组进行 push 后绕过通知。onDataReloaded() 能让 LazyForEach 重新核对键,但不等于必然销毁全部已有节点;真正可以复用的节点,由稳定键和框架的差异比较共同决定。若活动数量达到成千上万,还应限制单次响应大小并引入分页,而不是把整个网络目录一次性塞进内存。

根页面持有一份 EventFeedSource 实例,不在每次 build() 中 new。前文的网络更新成功时先得到 validatedItems,更新目录筛选,再调用 feed.replace(visibleItems);切换筛选时也只替换可见数据。不要在回调里创建新 EventFeedSource 再强行替换给 LazyForEach,官方指南指出这种做法可能导致组件异常。新数据到达时,列表可显示“已更新”,离线快照仍显示“离线目录”,不要因为本轮改成懒加载而取消数据新鲜度标记。

// entry/src/main/ets/pages/EventListPage.ets 核心片段
private feed: EventFeedSource = new EventFeedSource();

build() {
  Column() {
    Text(this.loadState === 'cached' ? '离线目录' : '城市活动')
      .fontSize(22).fontWeight(FontWeight.Bold)
    List({ space: 12 }) {
      LazyForEach(this.feed, (item: EventItem) => {
        ListItem() {
          EventPosterCard({ item: item })
        }
      }, (item: EventItem) => item.id.toString())
    }
    .cachedCount(3)
    .layoutWeight(1)
  }.padding(16)
}

这里的代码节选保留第04篇页面的筛选栏、加载错误分支和 Navigation 入口;为了让关键关系清楚,只展示列表容器。cachedCount(3) 指预加载区域的建议行数,不是“保证三行一定缓存命中”,调优要在目标设备测量。对于插入和删减较频繁的目录,可以逐项发送增删通知;但必须先验证索引和事件顺序,否则少一次通知比全量刷新更容易制造诡异节点。

列表的稳定 ID、缓存状态与界面数据源结构

三、图片缺失不是一个异常弹窗

活动封面有三类情况:服务端暂时没提供图片 URL、URL 指向的图片加载失败、图片正在下载解码。如果把这三类情况都留给系统默认的空白区域,用户会误以为整个活动条目坏掉。CityStage 给卡片建立单独的展示状态 loading/ready/error,让文字信息和收藏按钮不依赖海报能否成功出现。网络图片的重试可以由用户点击或页面刷新明确触发,不应在 onError 中无上限地递归请求。

// entry/src/main/ets/components/EventPosterCard.ets
import { EventItem } from '../model/EventItem';

@Component
export struct EventPosterCard {
  @Prop item: EventItem;
  @State posterState: string = 'loading';

  build() {
    Row({ space: 12 }) {
      Stack() {
        if (this.item.posterUrl && this.posterState !== 'error') {
          Image(this.item.posterUrl)
            .width(96).height(88)
            .objectFit(ImageFit.Cover)
            .onComplete(() => { this.posterState = 'ready'; })
            .onError(() => { this.posterState = 'error'; })
        }
        if (!this.item.posterUrl || this.posterState !== 'ready') {
          Text(this.posterState === 'loading' && this.item.posterUrl
            ? '海报加载中' : '暂无海报')
            .fontSize(12).fontColor('#65758A')
        }
      }.width(96).height(88).backgroundColor('#EEF3F6')
      Column({ space: 6 }) {
        Text(this.item.title).fontSize(18)
        Text(this.item.location).fontSize(13).fontColor('#64748B')
        Text(this.item.category).fontSize(12).fontColor('#0A8E8E')
      }.alignItems(HorizontalAlign.Start)
    }.width('100%').padding(12).backgroundColor(Color.White)
  }
}

注意示例 EventItem 需要将 posterUrl?: string 加入模型;可选字段允许旧目录兼容,而不是假装所有旧缓存都已携带 URL。Image.onError 表示图像处理失败,不能据此笼统推断“用户无网络”,因为地址无效、格式不支持、解码失败也可能触发。图片组件显示失败时,文字和导航依旧可以工作;业务上的“活动不存在”必须由目录接口决定,而不是依据海报加载结果判断。

还有一个组件状态复用陷阱:如果同一个活动 ID 的海报地址确实更新,旧卡片内部可能保留 error。本轮的业务约定是更新图片内容时增加 posterRevision(图片资源版本)并在对应数据变更时重建该卡片;普通内容刷新仍保持稳定的 id。可以把渲染键设为 ${item.id}:${item.posterRevision ?? 0},但不要把数组索引、当前时间戳或整个 JSON 字符串放进键中。业务对象不变则键稳定,资源语义变更才改变资源版本,这比每次刷新全量重新生成图片组件更可预测。

CityStage 活动列表竖屏示例:海报加载与降级状态

四、让离线、刷新和滚动拥有明确的优先级

前一轮处理异步请求竞争的 queryRevision 仍是数据入口的裁判,只有最新请求有资格替换目录快照。本轮不会再开一个请求计数器。页面在刷新期间可以保持原有目录并展示轻量“更新中”,成功时替换数据源,失败时保留缓存并显示“离线目录”。这样列表无需因为一次请求开始就被清空到顶部,也减少图片组件反复加载。在需要完全重新筛选的动作上,可以主动回到列表顶部;而网络在后台自动更新时,应尽量保持用户正在浏览的位置,避免打断阅读。

可见状态至少有四种:①有效目录且图片全部成功;②目录有效、个别图片失败;③目录来自缓存;④目录为空或读取失败。第一种可以显示完整海报;第二种让单卡片退化为文字占位;第三种显示数据新鲜度标签但不影响本地收藏;第四种才展示整页空态或重试按钮。若把第二种错认为第四种,就会因为一张损坏图片使整页不可用。若把第三种写成“实时活动”,又会让用户误解缓存中的场次仍可购买。

滚动测试不应只看屏幕截图。准备包含 30 条不同 ID 的活动样本,在头部插入一条后核对剩余卡片内容,检查是否出现旧图片错位;从“全部”切到“展览”再切回,观察标题、图片和收藏 ID 是否对应;把其中一条海报指向无效资源,检查降级文案是否只在该卡片出现;让新请求比旧请求更快返回,最终页面应遵循 queryRevision 而不是回调先后。以上是可复现步骤与预期结果,不是已经量化的滚动帧率报告。

刷新、图片出错与用户可见反馈的判定流程

图像资源的治理还必须考虑文件本身。一个实际存在的 URL 可能返回尺寸极大的原图,导致多张海报同时解码时占用过多内存;正式项目应由服务端提供用于列表展示的缩略图尺寸,或者在下载层明确约束大小并采用适当缓存。网络图片与业务数据的缓存周期也不相同:活动目录更新不意味着所有图片需要重新下载,海报地址改变却必须允许旧图失效。不要把图片二进制直接序列化到前一轮的 Preferences 字符串中,因为用户首选项原本是为少量偏好信息设计的,既不适合做图库,也没有提供专用图片缓存的容量管理。

在可访问性上,图片失败时不能只把灰色方框涂成红色。图像下方的活动标题依旧需要可读,错误占位以“暂无海报”呈现明确文本,收藏入口则提供选中与未选中的可辨认状态。真实适配过程中还应检查大字体模式、深浅色切换及触摸目标面积;即使视觉设计可以重新调整,列表条目的稳定 ID 也不能随着字号变化而改变。这样才能保证 UI 改版不等同于丢失业务身份。

五、不要把节点复用当作数据一致性的替代品

LazyForEach 减少不必要的节点创建,但它不会替应用验证活动 ID、网络响应版本或收藏是否写盘成功。图片失败属于表现层,查询版本属于仓储与页面协作层,收藏属于用户偏好层,三者须分别负责自己的状态。将图片是否加载完成写回 EventItem 并缓存到 Preferences,往往只会把一次设备上的瞬时加载失败伪装成永久业务字段。更合适的做法是让卡片持有短期 UI 状态,并在资源版本变化时重置。

这一改造尤其适合图片较多、活动目录频繁变化、筛选切换明显的内容型应用;只包含三五项静态数据的页面可以继续使用 ForEach,不必为技术名称而增加 IDataSource 代码。发布前应在 DevEco 对具体 SDK 版本实际编译,对滑动场景录制可观测数据,再决定 cachedCount、图片资源尺寸和是否引入更先进的列表节点复用能力。没有真实测量时,只讨论 API 的机制和测试方法。

下一篇将把 CityStage 的目录新鲜度、收藏持久化、Navigation 路径与卡片异常处理放在同一条验收链中收尾。届时重点不是再增加一个效果,而是确保断网、错误数据和连续操作下仍能说清楚“用户看见什么、数据从何而来、哪些业务动作允许继续”。

官方资料:List 组件、LazyForEach 循环渲染、ForEach 稳定键、Image 组件。

Logo

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

更多推荐