【HarmonyOS ArkUI】手机通讯录 -Case案例分享

185 阅读4分钟

Contacts record.gif

本篇将围绕一个完整的 HarmonyOS 通讯录应用,从 UI 结构设计核心功能逻辑性能优化策略模块化架构设计 进行全方位详细剖析,适合作为经典鸿蒙开发案例参考。


一、项目结构概览

1. 文件组织结构

路径功能描述
pages/Index.ets主页面入口,包含状态管理与事件绑定
components/index.etsUI 组件库,拆分为多个可复用组件
constant/index.ets静态数据源(联系人列表)
constant/typs.ets数据类型定义
utils/index.ets工具函数集合

二、核心功能详解

通讯录核心列表完整代码


import { getRandomHexColor } from '../utils'
import { IContactContent } from '../constant/typs'

@Component
struct ContactsList {
  @State backIconY: number = 100
  @State backIconX: number = 100
  @Watch('onLisChange')
  @Prop contactsData: IContactContent[]
  @State betIndexLis: string[] = this.contactsData.map(item => item.initial)
  @State selected: number = 0
  scroller: Scroller = new Scroller()

  onLisChange() {
    this.betIndexLis = this.contactsData.map(item => item.initial)
  }

  build() {
    Column() {
      Stack({ alignContent: Alignment.BottomEnd }) {
        // 联系人列表
        Stack({ alignContent: Alignment.End }) {
          List({ scroller: this.scroller }) {
            // 分组的联系人信息
            ForEach(this.contactsData, (item: IContactContent) => {
              ListItemGroup({ header: this.itemHead(item.initial), space: 10 }) {
                ForEach(item.nameList, (name: string) => {
                  // 循环渲染分组A的ListItem
                  ListItem() {
                    Contacts({ name })
                  }
                })
              }
            })
          }
          .sticky(StickyStyle.Header)
          .onWillScroll(() => {
            const scrollOffset = this.scroller.currentOffset()
            const yOffset = scrollOffset.yOffset
            this.backIconY = yOffset > 300 ? 0 : 100
            this.backIconX = yOffset > 300 ? -10 : 100
          })
          .onScrollIndex((start: number, end: number, center: number) => {
            this.selected = start
          })
          .height('100%')
        }
        .height('100%')

        // 字母索引
        AlphabetIndexer({ arrayValue: this.betIndexLis, selected: $$this.selected })
          .position({ top: '20%', right: 0 })
          .usingPopup(true)
          .onSelect((index: number) => {
            this.scroller.scrollToIndex(index)
          })

        // 返回按钮
        Image($r('app.media.toTop'))
          .width(35)
          .offset({ x: this.backIconX, y: this.backIconY })
          .animation({})
          .onClick(() => {
            this.scroller.scrollTo({ xOffset: 0, yOffset: 0, animation: true })
          })
      }
      .height('100%')

      if (this.contactsData.length === 0) {
        Text('暂无联系人')
          .fontColor(Color.Gray)
          .position({ top: '50%', left: '40%' })
      }
    }
    .layoutWeight(1)
  }

  @Builder
  itemHead(text: string) {
    // 列表分组的头部组件,对应联系人分组A、B等位置的组件
    Text(text)
      .fontSize(20)
      .backgroundColor('#fff1f3f5')
      .width('100%')
      .padding(10)
      .fontWeight(600)
  }
}

1. 联系人展示与分组显示

实现目标
  • 按照首字母进行分类。
  • 每个分组下展示对应的联系人列表。
  • 分组头部固定显示(sticky header)。
技术实现

使用 List + ListItemGroup 构建分组列表,并通过itemHead构造器自定义头部:

ForEach(this.contactsData, (item: IContactContent) => {
  ListItemGroup({ header: this.itemHead(item.initial), space: 10 }) {
    ForEach(item.nameList, (name: string) => {
      ListItem() {
        Contacts({ name })
      }
    })
  })
})
自定义头部组件:
@Builder itemHead(text: string) {
  Text(text)
    .fontSize(20)
    .backgroundColor('#fff1f3f5')
    .width('100%')
    .padding(10)
    .fontWeight(600)
}

2. 联系人动态查找(模糊搜索)

实现目标

输入关键字后,仅显示包含该关键字的联系人,同时保留原有首字母分组结构。

核心逻辑分析

Index.ets中定义 filterTargetContactsData(value: string) 方法:

filterTargetContactsData = (value: string): IContactContent[] => {
  // 初始化一个新的联系人数据数组,用于存储过滤后的联系人信息
  let contactsData_new: IContactContent[] = []
  contacts.forEach((item: IContactContent, index: number) => {
    item.nameList.forEach((t: string) => {
      // 如果名称中包含输入值,则进行后续处理
      if (t.includes(value)) {
        // 创建当前联系人的深拷贝,用于修改而不影响原始数据
        let item_new: IContactContent = JSON.parse(JSON.stringify(item)) as IContactContent
        // 设置联系人的排序索引,以便于后续排序
        item_new.sort_i = index
        // 检查新联系人数据中是否已有相同首字母的联系人
        const isHaved = contactsData_new.findIndex(c => c.initial === item.initial)
        if (isHaved !== -1) {
          // 如果已存在相同首字母的联系人,则将当前名称添加到该联系人的名称列表中
          contactsData_new[isHaved].nameList = [...contactsData_new[isHaved].nameList, t]
        } else {
          // 如果不存在相同首字母的联系人,则将当前名称设置为新联系人的名称列表,并添加到新联系人数据数组中
          item_new.nameList = [t]
          contactsData_new = [...contactsData_new, item_new]
        }
      }
    })
  })
  // 返回过滤和重新组织后的联系人数据数组
  return contactsData_new
}

关键处理步骤:

  1. 深拷贝原始数据:避免直接修改原数据。
  2. 遍历匹配项:对每个名字进行关键字判断。
  3. 聚合相同分组:确保同一首字母只出现一次。
  4. 排序更新索引:按原始顺序重新排序,保持 AlphabetIndexer 正确联动。

3. 字母索引联动(AlphabetIndexer)

实现目标

右侧字母导航栏点击时,自动定位到对应分组位置。

技术实现

ContactsList中使用 AlphabetIndexer 控件并绑定当前所有首字母:

@State betIndexLis: string[] = this.contactsData.map(item => item.initial)

AlphabetIndexer({
  arrayValue: this.betIndexLis,
  selected: $$this.selected
})
.position({ top: '20%', right: 0 })
.usingPopup(true)
.onSelect((index: number) => {
  this.scroller.scrollToIndex(index)
})

滚动监听同步更新选中项:

.onScrollIndex((start: number, end: number, center: number) => {
  this.selected = start
})

4. 输入防抖优化(Debounce)

实现目标

防止频繁触发搜索请求,提升性能体验。

技术实现

父主页面中通过封装好的debounce防抖函数执行子组件传递的关键字搜索逻辑:

import { debounce } from '../utils'

@Entry
@Component
struct Index {
  @State contactsData: IContactContent[] = contacts
  // 查找检索目标联系人
  filterTargetContactsData = (value: string): IContactContent[] => {...}
  // 在组件内提前定义防抖函数
  debouncedSearch = debounce((value?: string) => {
    if (value) {
      // 查找检索关键字联系人
      const contactsData_new = this.filterTargetContactsData(value)
      // 将分组联系人进行排序 更新排序索引 AlphabetIndexer联动更新UI
      contactsData_new.sort((a: IContactContent, b: IContactContent) => a.sort_i! - b.sort_i!)
      // 排序后赋值更新UI
      this.contactsData = contactsData_new
    } else {
      // 当检索关键字清空后 列表赋值全部数据
      this.contactsData = contacts
    }
  }, 500)

  build() {
    Column() {
      // 顶部导航栏
      TopTitle()
      // 搜索栏
      SearchBar({
        searchInput: (value?: string) => {
          this.debouncedSearch(value)
        }
      })
      // 联系人列表
      ContactsList({ contactsData: this.contactsData })
    }
  }
}

子组件绑定至TextInput检索框的 onChange 事件,将用户输入的关键字通过自定义时间传递给父主页面:

@Component
struct SearchBar {
  searchInput = (value?: string) => {}

  build() {
    // 顶部区域
    Stack({ alignContent: Alignment.Start }) {
      Image($r('app.media.ic_public_search'))
        .width(20)
        .fillColor(Color.Gray)
        .offset({ x: 10 })
      TextInput({ placeholder: '搜索' })
        .borderRadius(0)
        .backgroundColor(Color.Transparent)
        .placeholderFont({ size: 18 })
        .padding({ left: 35 })
        .border({ width: { bottom: 1 }, color: "#eee" })
        .onChange((value: string) => {
          this.searchInput(value) // 使用预先定义好的防抖函数
        })
    }
  }
}

关键处理步骤:

  1. 遵循数据单向流原则:尽量避免在子组件直接对关键数据进行修改,提高可维护性。
  2. 避免频繁进行数据筛查: 对于频繁操作数据,耗费性能逻辑产生无用结果逻辑,增加函数防抖。
  3. 子父组件数据传递:子组件通过自定义事件向父组件传递查询关键字,父组件通过防抖对原始数据进行筛查,将筛查最终结果通过自定义参数传递给子组件,并引起子组件UI界面的更新。

5. 返回顶部按钮动画控制

实现目标

当用户滚动一定距离后显示返回顶部按钮,并带有动画效果。

技术实现

使用 onWillScroll 监听滚动偏移量,动态设置按钮位置:

.onWillScroll(() => {
  const scrollOffset = this.scroller.currentOffset()
  const yOffset = scrollOffset.yOffset
  this.backIconY = yOffset > 300 ? 0 : 100
  this.backIconX = yOffset > 300 ? -10 : 100
})

按钮绑定动画和点击事件:

Image($r('app.media.toTop'))
  .width(35)
  .offset({ x: this.backIconX, y: this.backIconY })
  .animation({})
  .onClick(() => {
    this.scroller.scrollTo({ xOffset: 0, yOffset: 0, animation: true })
  })

6. 空状态提示

实现目标

当没有联系人或搜索无结果时,显示“暂无联系人”提示。

技术实现

build() 中添加条件渲染:

if (this.contactsData.length === 0) {
  Text('暂无联系人')
    .fontColor(Color.Gray)
    .position({ top: '50%', left: '40%' })
}

三、模块化架构设计

1. 组件化设计

组件名功能说明
TopTitle顶部标题栏
SearchBar搜索输入框
ContactsList联系人主列表容器
Contacts单个联系人卡片组件

优势:

  • 易维护、易扩展
  • 可复用性强
  • 清晰的父子组件关系

2. 数据模型抽象

定义统一接口 IContactContent

interface IContactContent {
  initial: string
  sort_i?: number
  nameList: string[]
}

保证联系人数据结构清晰,便于后续扩展字段。


3. 工具函数封装

封装通用工具函数,提高代码复用性:

// 十六进制颜色生成器
const getRandomHexColor = (): string => {
  const randomColor = Math.floor(Math.random() * 0xffffff).toString(16);
  return `#${randomColor.padStart(6, '0')}`;
}
// 防抖函数
const debounce = (func: (v?: string) => void, delay: number) => {
  let timer: number;
  return (v?: string) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func(v)
    },delay)
  }
}

4. 常量集中管理

联系人数据统一存放于constant/index.ets方便替换和测试。


四、性能优化建议(进阶)

虽然当前已具备良好的基础性能,但仍可进一步优化:

优化点描述
虚拟滚动对超长联系人列表使用虚拟滚动技术减少 DOM 渲染数量
缓存机制对搜索结果缓存,避免重复计算
异步加载图片资源使用懒加载策略
首屏优化对非首屏组件使用异步加载(如路由懒加载)

五、总结:

特性描述
完整功能覆盖包括搜索、分组、索引联动、返回顶部等主流功能
高性能交互使用防抖、动画、滚动监听提升用户体验
高可维护性架构组件化+模块化设计,便于团队协作与后期扩展
良好编码规范接口定义清晰、逻辑分离、命名语义明确
适合教学与实战是初学者掌握 ArkTS 和 HarmonyOS 开发的理想入门案例

六、附录:部分关键代码引用

  • filterTargetContactsData联系人筛选逻辑
  • debounce:防抖函数封装
  • AlphabetIndexer:索引联动实现
  • onScrollIndex:滚动监听同步索引

结语:本案例不仅展示了 HarmonyOS 应用开发的基本流程,还涵盖了现代前端开发中的诸多高级技巧。对于希望深入理解 ArkTS、HarmonyOS 开发框架及模块化设计理念的开发者来说,是一个极具学习价值的经典案例。