【学习目标】

  • 掌握 双层 LazyForEach 分组架构(分组 + 列表项);
  • 实现 右侧字母索引栏与列表联动;
  • 掌握 侧滑删除、修改备注 列表交互;
  • 理解 @Observed + @ObjectLink 深层响应式;
  • 掌握 @Reusable 正确使用规则。

一、工程目录结构

WechatContactDemo/
├── entry/src/main/ets/
│   ├── components/
│   │   ├── ContactGroupComponent.ets    // 分组组件(不复用)
│   │   └── ContactItemComponent.ets     // 列表项组件(开启复用)
│   ├── datasource/
│   │   ├── BasicDataSource.ets          // 通用数据源基类
│   │   ├── ContactListDataSource.ets    // 联系人列表数据源
│   │   └── ContactGroupDataSource.ets   // 分组数据源
│   ├── model/
│   │   └── ContactModel.ets              // 数据模型
│   ├── utils/
│   │   └── JsonUtil.ets                  // JSON工具
│   └── pages/
│       └── Index.ets                     // 主页面

二、通用泛型数据源(基类)

// datasource/BasicDataSource.ets
/**
 * 通用列表数据源基类
 * 实现 IDataSource 接口,封装列表刷新、增删改查等通用逻辑
 * 所有列表都可以继承此类,减少重复代码
 */
export class BasicDataSource<T> implements IDataSource {
  // 数据源数组,存储列表真实数据
  protected dataArray: T[] = [];
  // 数据监听器,通知列表刷新
  private listeners: DataChangeListener[] = [];

  // 获取列表总数量
  totalCount(): number {
    return this.dataArray.length;
  }

  // 根据索引获取数据
  getData(index: number): T {
    return this.dataArray[index];
  }

  // 获取全部数据
  getAllData(): T[] {
    return this.dataArray;
  }

  // 注册列表数据监听
  registerDataChangeListener(listener: DataChangeListener): void {
    if (!this.listeners.includes(listener)) {
      this.listeners.push(listener);
    }
  }

  // 注销监听
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  // 通知列表:指定索引新增数据
  notifyDataAdd(index: number): void {
    this.listeners.forEach(l => l.onDataAdd(index));
  }

  // 通知列表:指定索引数据变化
  notifyDataChange(index: number): void {
    this.listeners.forEach(l => l.onDataChange(index));
  }

  // 通知列表:删除指定索引数据
  notifyDataDelete(index: number): void {
    this.listeners.forEach(l => l.onDataDelete(index));
  }

  // 通知列表:数据移动
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(l => l.onDataMove(from, to));
  }

  // 通知列表:全局刷新
  notifyDataReload(): void {
    this.listeners.forEach(l => l.onDataReloaded());
  }

  // 批量操作通知
  notifyDatasetChange(ops: DataOperation[]): void {
    this.listeners.forEach(l => l.onDatasetChange(ops));
  }

  // 末尾添加一条数据
  pushData(data: T): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  // 删除指定索引数据
  deleteData(index: number): void {
    if (index < 0 || index >= this.dataArray.length) return;
    this.dataArray.splice(index, 1);
    this.notifyDataDelete(index);
  }

  // 更新指定索引数据
  updateData(index: number, newData: T): void {
    if (index < 0 || index >= this.dataArray.length) return;
    this.dataArray[index] = newData;
    this.notifyDataChange(index);
  }

  // 移动数据位置
  moveData(from: number, to: number): void {
    if (from < 0 || from >= this.dataArray.length || to < 0 || to >= this.dataArray.length) return;
    const item = this.dataArray.splice(from, 1)[0];
    this.dataArray.splice(to, 0, item);
    this.notifyDataMove(from, to);
  }

  // 全量替换数据
  reloadData(newDataList: T[]): void {
    this.dataArray = newDataList;
    this.notifyDataReload();
  }
}

三、联系人数据模型

// model/ContactModel.ets
import { util } from "@kit.ArkTS";

/**
 * 联系人实体模型
 * 使用 @Observed 实现响应式,修改后自动刷新UI
 */
@Observed
export class ContactItem {
  name: string;       // 姓名
  avatar: string;     // 头像
  uuid: string;       // 唯一标识,作为列表Key,保证渲染稳定

  constructor(name: string, avatar: string, uuid: string = util.generateRandomUUID(true)) {
    this.name = name;
    this.avatar = avatar;
    this.uuid = uuid;
  }
}

/**
 * 联系人分组模型(A-Z)
 * 每个分组拥有独立的数据源,用于内层 LazyForEach
 */
@Observed
export class ContactGroup {
  initial: string;               // 分组字母(A/B/C...)
  list: ContactItem[];           // 本组联系人列表
  dataSource: ContactListDataSource; // 本组列表数据源(双层LazyForEach关键)
  uuid: string;                 // 分组唯一标识

  constructor(initial: string, list: ContactItem[], dataSource: ContactListDataSource, uuid: string = util.generateRandomUUID(true)) {
    this.initial = initial;
    this.list = list;
    this.dataSource = dataSource;
    this.uuid = uuid;
  }
}

四、分组数据源 & 列表数据源

// datasource/ContactListDataSource.ets
import { ContactItem } from "../model/ContactModel";
import { BasicDataSource } from "./BasicDataSource";

/**
 * 联系人列表数据源(内层 LazyForEach 使用)
 */
export class ContactListDataSource extends BasicDataSource<ContactItem> {

  constructor(initData: ContactItem[] = []) {
    super();
    this.dataArray = initData;
  }

  // 根据uuid查找索引
  indexOf(item: ContactItem): number {
    return this.dataArray.findIndex(i => i.uuid === item.uuid);
  }
}
// datasource/ContactGroupDataSource.ets
import { ContactGroup, ContactItem } from "../model/ContactModel";
import { BasicDataSource } from "./BasicDataSource";
import { ContactListDataSource } from "./ContactListDataSource";
import { JsonUtil } from "../utils/JsonUtil";

/**
 * 分组数据源(外层 LazyForEach 使用)
 * 管理 A-Z 所有分组
 */
export class ContactGroupDataSource extends BasicDataSource<ContactGroup> {
  private letterList: string[] = []; // 右侧索引字母列表

  constructor(initData: ContactGroup[] = []) {
    super();
    this.dataArray = initData;
    this.updateLetterList();
  }

  // 从本地JSON加载联系人
  async loadContacts(context: Context): Promise<void> {
    if (!context) return;
    try {
      const jsonStr = await JsonUtil.readRawFileJson(context, "contacts.json");
      const groups = JSON.parse(jsonStr);
      this.dataArray = [];

      // 遍历分组,为每个分组创建独立数据源
      for (const g of groups) {
        const dataSource = new ContactListDataSource();
        g.list.forEach(item => {
          dataSource.pushData(new ContactItem(item.name, item.avatar));
        });
        this.dataArray.push(new ContactGroup(g.initial, g.list, dataSource));
      }

      this.notifyDataReload();
      this.updateLetterList();
    } catch (err) {
      console.error("加载数据失败:", JSON.stringify(err));
    }
  }

  // 删除分组中的某一行,组为空则删除本组
  deleteContactForGroup(section: number, row: number) {
    const group = this.dataArray[section];
    if (!group) return;
    group.dataSource.deleteData(row);

    // 本组数据为空,删除分组
    if (group.dataSource.totalCount() === 0) {
      this.deleteData(section);
    }
    this.updateLetterList();
  }

  // 根据分组查找索引
  indexOf(group: ContactGroup): number {
    return this.dataArray.findIndex(i => i.uuid === group.uuid);
  }

  // 更新右侧索引字母
  private updateLetterList(): void {
    this.letterList = this.dataArray.map(g => g.initial);
  }

  // 获取字母表
  getLetterList(): string[] {
    return this.letterList;
  }
}

五、联系人列表项

// components/ContactItemComponent.ets
import { ContactItem } from '../model/ContactModel';

/**
 * 联系人列表项组件
 * 【性能关键】这里开启 @Reusable,因为 item 数量大、滑动频繁
 * 【不推荐嵌套】父组件不加 @Reusable
 */
@Reusable
@Component
export struct ContactItemComponent {
  // 响应式绑定联系人数据
  @ObjectLink item: ContactItem;

  // 删除回调
  onDelete?: (item: ContactItem) => void;

  // 修改备注回调
  onEditRemark?: (item: ContactItem) => void;

  /**
   * 组件复用时触发
   * 长列表优化核心:滑出屏幕的组件会被回收,滑入时直接复用更新数据
   * @ObjectLink 系统会自动更新不需要我们手动赋值
   */
  aboutToReuse(params: Record<string, Object | null | undefined>): void {
    console.info(`[组件复用] ContactItem -> ${this.item.name}`);
  }

  /**
   * 组件首次创建时触发
   */
  aboutToAppear(): void {
    console.info(`[组件创建] ContactItem -> ${this.item.name}`);
  }

  /**
   * 组件被回收进缓存池时触发
   */
  aboutToRecycle(): void {
    console.info(`[组件回收] ContactItem -> ${this.item.name}`);
  }

  /**
   * 侧滑菜单:备注 + 删除
   */
  @Builder
  SwipeMenu() {
    Row() {
      // 备注按钮
      Text("备注")
        .backgroundColor("#007AFF")
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .layoutWeight(1)
        .height('100%')
        .onClick(() => {
           const item = this.item
          this.onEditRemark?.(item)
        });

      // 删除按钮
      Text("删除")
        .backgroundColor("#FF3B30")
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .height('100%')
        .layoutWeight(1)
        .onClick(() => {
          const item = this.item
          this.onDelete?.(item)
        });
    }.width(150);
  }

  build() {
    ListItem() {
      Row({ space: 15 }) {
        // 头像
        Image(this.item.avatar)
          .width(44)
          .height(44)
          .borderRadius(6);

        // 姓名
        Text(this.item.name)
          .fontSize(17)
          .fontColor('#000');
      }
      .width('100%')
      .padding({ left: 15, top: 12, bottom: 12 });
    }
    // 右侧侧滑菜单
    .swipeAction({ end: this.SwipeMenu() });
  }
}

六、分组组件

// components/ContactGroupComponent.ets

import { ContactGroup, ContactItem } from '../model/ContactModel'
import { ContactItemComponent } from './ContactItemComponent';

/**
 * 联系人分组组件
 * 包含 ListItemGroup + 内层 LazyForEach
 */
@Component
export struct ContactGroupComponent {

  @ObjectLink contactGroup: ContactGroup;
  onDelete?: (contactGroup: ContactGroup, item: ContactItem) => void;
  onEditRemark?: (contactGroup: ContactGroup, item: ContactItem) => void;

  /**
   * 分组标题构建
   */
  @Builder
  groupHeaderBuilder(title: string) {
    Text(title)
      .fontSize(16)
      .fontWeight(FontWeight.Medium)
      .width('100%')
      .padding({ left: 16, top: 8, bottom: 8 })
      .backgroundColor('#F7F7F7');
  }

  /**
   * 组件首次创建
   */
  aboutToAppear(): void {
    console.info(`[组件创建] ContactGroup -> ${this.contactGroup.initial}`);
  }

  /**
   * 组件消失
   */
  aboutToDisappear(): void {
    console.info(`[组件消失] ContactGroup -> ${this.contactGroup.initial}`);
  }

  build() {
    ListItemGroup({ header: this.groupHeaderBuilder(this.contactGroup.initial) }) {
      LazyForEach(
        this.contactGroup.dataSource,
        (item: ContactItem) => {
          ContactItemComponent({
            item: item,
            onDelete: (item: ContactItem) => {
              const group = this.contactGroup;
              this.onDelete?.(group, item);
            },
            onEditRemark: (item: ContactItem) => {
              const group = this.contactGroup;
              this.onEditRemark?.(group, item);
            }
          });
        },
        (item: ContactItem) => item.uuid
      );
    }
    .divider({ strokeWidth: 0.5, color: '#E5E5E5', startMargin: 74 });
  }
}

七、主页面(双层 LazyForEach + 索引联动 + 下拉刷新)

// pages/Index.ets
import { ContactGroupComponent } from '../components/ContactGroupComponent';
import { ContactGroup, ContactItem } from '../model/ContactModel';
import { ContactGroupDataSource } from '../datasource/ContactGroupDataSource';

@Entry
@Component
struct Index {
  private scroller: Scroller = new Scroller();
  private context: Context | undefined = this.getUIContext().getHostContext();
  private contactGroupDataSource: ContactGroupDataSource = new ContactGroupDataSource();

  @State isRefreshing: boolean = false;
  @State promptText: string = "下拉开始刷新...";
  @State letterList: string[] = [];

  async aboutToAppear() {
    if (this.context) {
      await this.contactGroupDataSource.loadContacts(this.context);
      this.letterList = this.contactGroupDataSource.getLetterList();
    }
  }

  build() {
    RelativeContainer() {
      // 顶部标题
      Text("微信通讯录")
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .textAlign(TextAlign.Center)
        .padding(12)
        .backgroundColor('#FFF')
        .id('constant_title')
        .alignRules({
          top: { anchor: '__container__', align: VerticalAlign.Top },
          left: { anchor: '__container__', align: HorizontalAlign.Start },
          right: { anchor: '__container__', align: HorizontalAlign.End }
        });

      // 下拉刷新
      Refresh({ refreshing: $$this.isRefreshing, promptText: this.promptText }) {
        List({ scroller: this.scroller }) {
          LazyForEach(
            this.contactGroupDataSource,
            (group: ContactGroup) => {
              ContactGroupComponent({
                contactGroup: group,
                onEditRemark:(contactGroup: ContactGroup, item: ContactItem)=>{
                  item.name = "新名字"
                  const section = this.contactGroupDataSource.indexOf(contactGroup)
                  const row =  contactGroup.dataSource.indexOf(item)
                  if (row < 0 || section < 0) {
                    return
                  }
                  this.contactGroupDataSource.notifyDataChange(section)
                },
                onDelete: (contactGroup: ContactGroup,item: ContactItem)=> {
                  const section = this.contactGroupDataSource.indexOf(contactGroup)
                  const row =  contactGroup.dataSource.indexOf(item)
                  if (row < 0 || section < 0) {
                    return
                  }
                  this.contactGroupDataSource.deleteContactForGroup(section, row);
                  const letterList= this.contactGroupDataSource.getLetterList();
                  this.letterList = letterList
                }
              });
            },
            (group: ContactGroup) => group.uuid);
        }
        .sticky(StickyStyle.Header)
        .scrollBar(BarState.Off)
        .cachedCount(5)
        .width('100%')
        .layoutWeight(1);
      }
      .alignRules({
        top: { anchor: 'constant_title', align: VerticalAlign.Bottom },
        left: { anchor: '__container__', align: HorizontalAlign.Start },
        bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
        right: { anchor: '__container__', align: HorizontalAlign.End }
      })
      .onStateChange((state: RefreshStatus) => {
        switch (state) {
          case RefreshStatus.Inactive:
            this.promptText = '';
            break;
          case RefreshStatus.Drag:
            this.promptText = '下拉刷新';
            break;
          case RefreshStatus.OverDrag:
            this.promptText = '松手立即刷新';
            break;
          case RefreshStatus.Refresh:
            this.promptText = '正在刷新...';
            break;
          case RefreshStatus.Done:
            this.promptText = '刷新完成';
            break;
          default:
            this.promptText = '';
        }
      })
      .onRefreshing(() => {
        setTimeout(async () => {
          if (this.context) {
            await this.contactGroupDataSource.loadContacts(this.context);
            const letterList= this.contactGroupDataSource.getLetterList();
            this.letterList = letterList
          }
          this.isRefreshing = false;
        }, 1500);
      });

      AlphabetIndexer({
        arrayValue: this.letterList,
        selected: 0
      })
        .color(Color.Black)
        .selectedColor(Color.White)
        .selectedBackgroundColor('#007AFF')
        .onSelect((index: number) => {
          if (index >= 0 && index < this.letterList.length) {
            this.scroller.scrollToIndex(index);
          }
        })
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          right: { anchor: '__container__', align: HorizontalAlign.End }
        })
        .margin({ right: 5 });
    }
    .width('100%')
    .height('100%');
  }
}

运行效果

双LazyForEach仿微信联系人

八、内容总结

  1. 双层 LazyForEach = 外层分组 + 内层列表,提升组件复用性能。
  2. @Reusable 只能给列表项使用,禁止嵌套使用。
  3. 嵌套复用会生成多个缓存池,导致内存增加、生命周期管理复杂、维护困难。
  4. 分组组件(A-Z)数量少,完全不需要复用
  5. 唯一 uuid 作为列表 key,保证滑动不混乱、不闪烁。
  6. @Observed + @ObjectLink 实现深层响应式,修改数据自动局部刷新。

九、仓库代码

  • 工程名称:WechatContactDemo
  • 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git

十、下节预告

下一节我们将正式学习 鸿蒙官方标准导航组件:Tabs 选项卡,一次性掌握应用最核心的底部导航、顶部导航、侧边导航全套方案:

  • 学会 Tabs + TabContent 基础结构与用法;
  • 实现 底部导航、顶部导航、侧边导航 三种主流布局;
  • 掌握 固定/滚动导航栏、禁止滑动、自定义TabBar
  • 学会 TabsController 控制切换、页面缓存优化
  • 结合本节联系人列表,快速搭建仿微信主界面框架
Logo

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

更多推荐