【学习目标】
- 掌握 双层 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}`);}/*** 侧滑菜单:备注 + 删除*/@BuilderSwipeMenu() {Row() {// 备注按钮Text("备注").backgroundColor("#007AFF").fontColor(Color.White).textAlign(TextAlign.Center).layoutWeight(1).height('100%').onClick(() => {const item = this.itemthis.onEditRemark?.(item)});// 删除按钮Text("删除").backgroundColor("#FF3B30").fontColor(Color.White).textAlign(TextAlign.Center).height('100%').layoutWeight(1).onClick(() => {const item = this.itemthis.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.etsimport { 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;/*** 分组标题构建*/@BuildergroupHeaderBuilder(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 = 外层分组 + 内层列表,提升组件复用性能。
- @Reusable 只能给列表项使用,禁止嵌套使用。
- 嵌套复用会生成多个缓存池,导致内存增加、生命周期管理复杂、维护困难。
- 分组组件(A-Z)数量少,完全不需要复用。
- 唯一
uuid作为列表key,保证滑动不混乱、不闪烁。 @Observed + @ObjectLink实现深层响应式,修改数据自动局部刷新。
九、仓库代码
- 工程名称:WechatContactDemo
- 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git
十、下节预告
下一节我们将正式学习 鸿蒙官方标准导航组件:Tabs 选项卡,一次性掌握应用最核心的底部导航、顶部导航、侧边导航全套方案:
- 学会 Tabs + TabContent 基础结构与用法;
- 实现 底部导航、顶部导航、侧边导航 三种主流布局;
- 掌握 固定/滚动导航栏、禁止滑动、自定义TabBar;
- 学会 TabsController 控制切换、页面缓存优化;
- 结合本节联系人列表,快速搭建仿微信主界面框架。
