当前位置: 首页 > news >正文

鸿蒙应用开发UI基础第三十二节:双层LazyForEach懒加载—— 微信联系人列表 - 鸿蒙

【学习目标】

  • 掌握 双层 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仿微信联系人

八、内容总结

  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 控制切换、页面缓存优化
  • 结合本节联系人列表,快速搭建仿微信主界面框架
http://www.jsqmd.com/news/539620/

相关文章:

  • 在给ppt接入扣子空间(Ai)/智能体,新玩法10分钟搞定说课,公开课AI互动!
  • SpringBoot WebSocket 客户端断线重连:从心跳检测到优雅恢复
  • 六自由度机械臂空间直线轨迹规划、机械臂运动+位置速度加速度程序
  • 听故事学中药爆款视频
  • 域组策略深度配置:RDP远程桌面安全加固与权限管理
  • 3大核心功能让你轻松掌握League-Toolkit英雄联盟辅助工具
  • 2026天津全网推广服务商TOP5测评,精准匹配企业需求
  • 如何在3分钟内为Axure RP配置中文界面:终极汉化指南
  • 魔兽地图格式革命:w3x2lni如何重新定义地图开发工作流
  • 实战级SQL注入测试技巧揭秘
  • 京东e卡回收哪家好?亲测两家平台真实对比,结果出乎意料 - 京回收小程序
  • 喵飞AI沙龙回顾|南开区本土AI赋能!OPC+OPEN CLAW本地部署圆满落幕
  • Cuvil编译器如何绕过CPython GIL实现真正的并行推理?——某自动驾驶公司实时感知模块迁移全记录(含perf火焰图对比)
  • Proxmox VE虚拟化实战:如何给MikroTik RouterOS配置PCI直通网卡(ROS 6.44.2实测)
  • # Trae IDE `settings.json` 配置详解与教学文档
  • 家里装了 OpenClaw,在公司也能随时管理——Shield CLI 远程访问方案
  • MinerU的正确使用方式:如何解析PDF成标准化向量数据,以供AI大模型等场景应用
  • YOLOv8-Pose部署避坑指南:从PyTorch模型到Windows端高效推理的完整流程
  • 学习代码过程中的一些有趣发现--学习代码的时间复杂度
  • SAR成像RD算法仿真:为什么你的点目标旁瓣降不下去?从原理到Matlab代码的深度调优
  • KV Server
  • 从零到一:在本地CentOS环境完整部署yshop-drink扫码点餐系统的实战指南
  • 告别Mac!在Windows电脑上用HBuilder X和Appuploader搞定iOS测试包(附7天免费证书申请)
  • 2026告别机考不适应:界面最还原雅思机考网站帮你熟悉考场 - 品牌2026
  • 201_深度学习的数学底座:PyTorch 线性代数与范数实战
  • 3大核心价值:Botty智能图像识别技术如何重塑暗黑破坏神2刷宝体验
  • League Akari:英雄联盟玩家的智能效率助手,提升90%游戏体验
  • 2026年进口渗透压仪哪个牌子好用?深度解析知名品牌与口碑推荐 - 品牌推荐大师
  • 分子动力学避坑指南:为什么你的NPT模拟总爆箱?详解GROMACS压力耦合中的compressibility陷阱
  • NCMDump解密工具:3步解锁网易云音乐加密文件,实现跨平台自由播放