熟悉购物比价应用的朋友一定知道,这类应用的消息通知量非常大:促销活动推送、物流状态更新、商家回复、系统公告……一天下来,右上角的红点数字轻松突破99+。用户想要清理这些未读消息,传统做法通常是提供一个“全部标记已读”按钮,一键清零。但这个方案有个致命缺陷——用户可能只想清除某些不重要的消息,而保留重要的订单通知。全部清除会误伤,逐条点击又太累。

我们之前做过一版消息列表,用的是普通的@State + 数组操作,每次清除都要遍历整个列表,UI重绘量大,而且清除动画生硬。后来我们重构了消息管理模块,基于HarmonyOS最新的@ObservedV2和@Trace装饰器,实现了两种清除未读消息的方式:单项清除批量选择性清除。这篇文章完整记录一下实现过程和踩坑经验。

功能设计

先说说预期效果。

用户在消息列表页面,可以看到每条消息左侧有一个复选框(多选模式)或右滑出现的“清除”按钮(单项模式)。用户可以选择一条或多条消息,点击“清除未读”按钮,被选中的消息从未读列表中移除,同时列表的未读计数实时更新。整个操作过程要有平滑的动画反馈,不能出现闪烁或卡顿。

核心目标:

  1. 单项清除:左滑某条消息,出现“清除”按钮,点击后该消息从未读列表消失,未读数减1。

  2. 批量选择性清除:进入多选模式,勾选多条消息,点击“清除所选”,所有选中消息同时移除,未读数批量减少。

  3. 性能要求:即使列表中有上千条消息,清除操作也不能卡住UI线程。

核心API

API/装饰器

说明

@ObservedV2

类装饰器,使类的属性变化能被观察,支持嵌套对象追踪

@Trace

属性装饰器,标记需要追踪的属性,配合@ObservedV2使用

List.onSwipeToAction

列表项的滑动操作事件

animateTo

显式动画接口,用于清除时的过渡效果

实现过程

数据模型改造

首先,我们需要一个可观察的消息模型。传统方式用@State装饰数组,但数组元素内部的属性变化无法被追踪。改用@ObservedV2@Trace后,每个消息对象的isReadisSelected等属性变化都能精确触发UI更新。

// model/MessageModel.ets
@ObservedV2
export class MessageItem {
  @Trace id: string = '';
  @Trace title: string = '';
  @Trace content: string = '';
  @Trace isRead: boolean = false;
  @Trace isSelected: boolean = false; // 多选模式用
  @Trace timestamp: number = 0;
}

// 消息列表管理类
@ObservedV2
export class MessageListModel {
  @Trace messages: MessageItem[] = [];
  
  get unreadCount(): number {
    return this.messages.filter(msg => !msg.isRead).length;
  }
  
  // 单项清除
  clearSingle(messageId: string): void {
    const index = this.messages.findIndex(msg => msg.id === messageId);
    if (index !== -1) {
      this.messages.splice(index, 1);
    }
  }
  
  // 批量清除选中的未读消息
  clearSelected(): void {
    this.messages = this.messages.filter(msg => !msg.isSelected);
  }
}

关键点:@ObservedV2装饰类,@Trace装饰每个需要追踪的属性。这样当messages数组的某个元素被删除时,UI只会更新受影响的部分,而不是整个列表重绘。

单项清除:左滑操作

左滑清除是购物比价应用中消息列表的标配交互。我们利用List组件的onSwipeToAction事件实现。

// view/MessageList.ets
@Component
export struct MessageList {
  @State messageModel: MessageListModel = new MessageListModel();
  
  build() {
    List() {
      ForEach(this.messageModel.messages, (item: MessageItem) => {
        ListItem() {
          MessageCell({ item: item })
        }
        .swipeAction({
          start: this.buildSwipeDeleteButton(item) // 右滑出现删除按钮
        })
      })
    }
  }
  
  @Builder
  buildSwipeDeleteButton(item: MessageItem) {
    Button('清除')
      .backgroundColor('#FF3B30')
      .width(80)
      .height('100%')
      .onClick(() => {
        animateTo({ duration: 200 }, () => {
          this.messageModel.clearSingle(item.id);
        });
      })
  }
}

这里有个细节:清除操作包裹在animateTo中,可以让列表项消失时有淡出或缩放动画,避免突兀消失。

批量选择性清除:多选模式

批量清除需要进入多选状态,用户勾选若干消息后点击“清除所选”。我们用一个@State isMultiSelect来控制是否显示复选框。

// view/MessageList.ets(续)
@State isMultiSelect: boolean = false;

build() {
  Column() {
    // 顶部操作栏
    if (this.isMultiSelect) {
      Row() {
        Button('取消').onClick(() => this.exitMultiSelect());
        Text(`已选 ${this.selectedCount} 条`);
        Button('清除所选').onClick(() => this.clearSelectedMessages());
      }
    }
    
    List() {
      ForEach(this.messageModel.messages, (item: MessageItem) => {
        ListItem() {
          if (this.isMultiSelect) {
            Checkbox()
              .select(item.isSelected)
              .onChange((val) => {
                item.isSelected = val;
              })
          }
          MessageCell({ item: item })
        }
        .onClick(() => {
          if (this.isMultiSelect) {
            item.isSelected = !item.isSelected;
          } else {
            // 进入消息详情
          }
        })
      })
    }
  }
}

clearSelectedMessages() {
  animateTo({ duration: 300 }, () => {
    this.messageModel.clearSelected();
  });
  this.exitMultiSelect();
}

注意:这里直接修改item.isSelected,由于@Trace的存在,UI会自动响应,无需手动刷新列表。

遇到的问题与解决方案

问题1:清除后列表闪烁

最初我们在clearSingle中使用splice删除元素,但发现列表会短暂闪烁。原因是splice会触发整个数组的重新排序,而ForEach的key没有正确指定。解决方案:给ForEach提供稳定的key生成器,使用消息的id

ForEach(this.messageModel.messages, 
  (item: MessageItem) => { /* ... */ },
  (item: MessageItem) => item.id  // 关键:用id作为key
)

问题2:批量清除时动画卡顿

当一次性清除上百条消息时,animateTo内部的数组过滤操作会导致大量UI更新堆积。我们采用了分帧处理:将清除操作拆分成小批次,每帧处理一部分,避免主线程阻塞。

async clearSelectedInBatches() {
  const toRemove = this.messageModel.messages.filter(msg => msg.isSelected);
  const batchSize = 20;
  for (let i = 0; i < toRemove.length; i += batchSize) {
    const batch = toRemove.slice(i, i + batchSize);
    batch.forEach(msg => {
      const idx = this.messageModel.messages.indexOf(msg);
      if (idx !== -1) {
        this.messageModel.messages.splice(idx, 1);
      }
    });
    await new Promise(resolve => setTimeout(resolve, 16)); // 每帧约16ms
  }
}

虽然代码看起来有点“土”,但在实际测试中,即使清除500条消息,用户也感觉不到卡顿。

问题3:未读计数更新不及时

未读计数是通过计算属性unreadCount得到的,但一开始我们没有标记它为@Computed,导致UI不会自动刷新。解决方案:使用@Computed装饰计算属性。

@ObservedV2
export class MessageListModel {
  @Trace messages: MessageItem[] = [];
  
  @Computed
  get unreadCount(): number {
    return this.messages.filter(msg => !msg.isRead).length;
  }
}

这样每当messages数组或其元素的isRead变化时,unreadCount会自动重新计算并通知UI。

总结

未读消息清除看起来是个小功能,但要做到体验流畅、性能优异,需要合理运用HarmonyOS的响应式编程能力。核心要点总结如下:

要点

实现方式

数据模型

@ObservedV2+ @Trace实现细粒度响应

单项清除

List.onSwipeToAction+ splice+ animateTo

批量清除

多选模式 + filter+ 分帧处理

性能优化

稳定key、分帧清除、@Computed计算属性

改完之后,消息清除的体验提升了一个档次。用户左滑即可清除单条,长按进入多选模式后可以批量清理,整个过程丝般顺滑,未读数实时更新。如果你也在开发购物比价类应用的消息模块,不妨试试这套方案。

Logo

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

更多推荐