为一篇技术教程文章绘制一张 sketch-notes 风格的手绘信息图,主题是“HarmonyOS7

前言

消息列表前十条怎么写都能跑,真正麻烦的是后面:分页、已读未读、删除、置顶、加载失败、空态一起出现。

HarmonyOS7 里的 List 很适合承载长内容,但前提是行数据、key 和状态更新方式要稳定。如果只是把一个大数组全量丢进 ForEach,再把加载逻辑写得七零八落,页面迟早会不好维护。

长列表的第一原则是行可复用、状态可追踪。

为一篇 HarmonyOS7 ArkUI 技术文章绘制一张 sketch-notes 风格的框架图,

这篇用一个消息中心做例子,看看 ListForEach、稳定 key 和分页按钮怎么组织。

为什么这个问题经常被写乱

List 长列表别再全量堆 ForEach 这类内容很容易被写成“代码能跑就算讲完了”,但对初学者来说,这恰恰是最不够的地方。真正让人卡住的,往往不是某个组件名记不住,而是不知道这段代码为什么要这样拆、状态为什么要这样放、以后需求变化时应该从哪里改。

所以这篇文章不只想给你一个能跑的例子,更想把背后的判断过程讲清楚。你只要把这个判断过程吃透,后面自己改页面、补需求、查问题时,心里会稳很多。

列表先别急着追求复杂

写长列表之前,我会先确认这几类状态:

为一篇讲解 HarmonyOS7 List 长列表的文章绘制一张 sketch-notes 风格的流

状态 页面表现 代码里怎么放
空列表 显示空态说明 rows.length === 0 判断
普通列表 展示消息行 每行都有稳定 id
加载更多 底部按钮或提示 单独的 loadingMore 字段
已读未读 红点、字体粗细 放在行数据 unread

这些状态先分清楚,后面扩展删除、置顶、分页失败才不会互相影响。

列表性能优化不是一开始就写最复杂,而是先别把身份和状态写乱。

先把列表身份和底部状态想清楚

写长列表时,我会先盯两件事:每一行靠什么确认身份,列表底部现在处在哪个阶段。前者决定 key 稳不稳定,后者决定“加载更多”“没有更多”“加载失败重试”这些状态会不会互相打架。

像消息中心这种页面,单条消息的 id 就是身份锚点,loadingMore 则是底部状态的最小起点。你先把这两类信息立住,后面再补空态、已读未读、分页失败时,思路会清楚很多,不容易把所有判断都塞进一个大数组里硬撑。

完整 ArkUI 示例

下面这个例子包含空态、消息行、已读更新和加载更多。为了聚焦页面状态,数据用本地数组模拟。

interface MessageRow {
  id: number
  title: string
  time: string
  unread: boolean
}

@Entry
@Component
struct MessageListPage {
  @State rows: MessageRow[] = [
    { id: 101, title: '订单已发货', time: '09:18', unread: true },
    { id: 102, title: '会员积分到账', time: '昨天', unread: false },
    { id: 103, title: '系统维护通知', time: '周一', unread: true }
  ]
  @State loadingMore: boolean = false
  private nextId: number = 200

  private loadMore(): void {
    if (this.loadingMore) {
      return
    }
    this.loadingMore = true
    const more: MessageRow[] = [
      { id: this.nextId, title: `历史消息 ${this.nextId}`, time: '更早', unread: false },
      { id: this.nextId + 1, title: `历史消息 ${this.nextId + 1}`, time: '更早', unread: false }
    ]
    this.nextId += 2
    this.rows = this.rows.concat(more)
    this.loadingMore = false
  }

  build() {
    Column() {
      Text('消息中心')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 8 })

      if (this.rows.length === 0) {
        Column({ space: 8 }) {
          Text('暂无消息').fontSize(16).fontColor('#666666')
          Text('订单、系统通知和活动提醒会显示在这里。').fontSize(12).fontColor('#999999')
        }
        .justifyContent(FlexAlign.Center)
        .height('80%')
        .width('100%')
      } else {
        List({ space: 8 }) {
          ForEach(this.rows, (item: MessageRow) => {
            ListItem() {
              Row({ space: 10 }) {
                Circle()
                  .width(8)
                  .height(8)
                  .fill(item.unread ? '#E53935' : '#D8D8D8')
                Column({ space: 5 }) {
                  Text(item.title)
                    .fontSize(16)
                    .fontWeight(item.unread ? FontWeight.Medium : FontWeight.Regular)
                  Text(item.time)
                    .fontSize(12)
                    .fontColor('#888888')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
              }
              .padding(14)
              .backgroundColor(Color.White)
              .borderRadius(10)
            }
            .onClick(() => {
              item.unread = false
              this.rows = this.rows.map((row: MessageRow) => row.id === item.id ? item : row)
            })
          }, (item: MessageRow) => item.id.toString())

          ListItem() {
            Button(this.loadingMore ? '加载中...' : '加载更多')
              .width('100%')
              .height(40)
              .enabled(!this.loadingMore)
              .onClick(() => this.loadMore())
          }
        }
        .padding(12)
      }
    }
    .backgroundColor('#F5F7FA')
    .height('100%')
  }
}

把关键代码一段段拆开

MessageRow.id 是列表项身份。这里不要用数组下标当 key,因为插入、删除、分页追加都会改变下标。key 不稳定,列表项复用和局部状态就容易出怪问题。

rows.concat(more) 会生成新数组,状态变化更明确。对新手来说,这种写法比直接 push 后再猜 UI 会不会刷新更直观。

点击消息行时,代码先把 item.unread 改成 false,再用 map 重新赋值给 this.rows。这样红点和字体粗细会立刻跟着变,页面反馈比较清楚。

加载更多按钮放在 ListItem 里,而不是放在列表外。这样它会跟着列表一起滚动,语义上也更像“列表底部状态”。

真实项目还要补什么

真实分页不会像示例里这样立刻返回数据。你需要区分 loadingMoreloadErrorhasMore 这几个状态。没有更多数据时,按钮文案应该变成“没有更多了”,而不是一直让用户点。

删除消息时,也要考虑撤销和刷新位置。特别是用户刚点完删除,列表突然跳动,会很容易误触下一项。

如果列表项很复杂,比如包含图片、音频、按钮、倒计时,再考虑更细的组件拆分。不要把所有行 UI 都写在 ForEach 回调里,否则 build() 会越来越难读。

新手最容易踩的坑

第一个坑,是直接拿数组下标当 key。列表一旦插入、删除或追加分页,行身份就会漂,很多“看起来很玄学”的刷新问题其实都从这里开始。

第二个坑,是只顾着把下一页拼进数组,却没有把 loadingMorehasMore、失败重试这些底部状态一起设计好。于是列表内容在变,底部提示也在变,但谁该在什么时候出现,页面自己都说不清。

第三个坑,是所有行内交互都直接写在 ForEach 回调里。短期能写,后面每行一多几个按钮、标签、图片,build() 很快就会被塞满。

放进真实项目还要补什么

真实项目里,消息列表通常还会补接口错误提示、下拉刷新、分页去重、置顶插入和批量已读。这些能力都在提醒同一件事:长列表不是一坨数据,而是一组有身份的行,加上一套持续变化的列表状态。

比较稳的做法是先把行身份和底部状态立住,再逐步接分页、错误处理和复杂行交互。这样页面不会只在演示时好看,后面需求加上来时也更容易接得住。

写在最后

长列表真正难的,从来不是把内容显示出来,而是让它在不断追加、刷新和修改时还能保持顺手。先把 key、行状态和底部状态写稳,后面很多问题都会少一半。

Logo

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

更多推荐