鸿蒙原生ArkTS通讯录管家实战开发详解

一、项目概述

1.1 项目背景

在移动互联网时代,通讯录管理是每一部智能手机最基础也最核心的功能之一。从最简单的联系人与手机号映射,到支持分组管理、生日提醒、收藏标记、快速拨号,用户对通讯录应用的需求随着使用场景的丰富而不断升级。然而,系统自带的通讯录应用往往功能固化、交互形式单一,难以满足特定场景下的个性化需求。

正是在这一背景下,通讯录管家(AddressBook) 应运而生。这是一款基于鸿蒙原生系统、采用ArkTS语言开发的轻量级通讯录管理应用,致力于为用户提供简洁、高效、功能完整的联系人管理体验。应用充分利用了鸿蒙系统的关系型数据库(RDB)能力和声明式UI框架的响应式特性,在不到1200行代码的体量内,实现了涵盖联系人增删改查、分组管理、搜索过滤、生日提醒、收藏标记、一键拨号等完整功能。

1.2 技术亮点

通讯录管家项目虽然体量不大,但在技术上有多个值得关注的亮点:

  • 关系型数据库本地持久化:基于@ohos.data.relationalStore模块实现了完整的SQLite数据库操作,支持建表、增删改查、分组统计和条件筛选。
  • 单向数据流与响应式UI:ArkTS的@State装饰器与全局Builder函数的组合使用,构建了清晰的数据流动链路。
  • 全局Builder函数设计模式:所有UI组件均定义为全局@Builder函数而非组件内方法,通过参数回调彻底解除了对this的依赖,避免了ArkTS对Function.bind的限制。
  • 交互式表单校验:输入验证与实时错误提示联动,用户在提交前即可获得明确的修正指引。
  • 侧滑操作栏:通过Swiper组件实现联系人卡片的侧滑操作,提供收藏、编辑、拨号、删除四个快捷入口。

1.3 目标用户

通讯录管家面向的用户群体非常广泛:

  • 普通手机用户:需要一个轻量、清爽的通讯录管理工具,替代系统自带应用完成日常的联系人管理。
  • 商务人士:需要通过分组功能管理客户、同事关系,利用生日提醒功能维护重要的人际关系。
  • 鸿蒙应用学习者:希望通过一个完整的实战项目学习ArkTS的声明式UI开发、关系型数据库操作和组件化设计模式。
  • 开发者参考:需要快速了解鸿蒙RDB API与ArkTS Builder函数结合使用的开发范式的技术开发者。

二、项目架构总览

2.1 整体架构

通讯录管家采用"数据层-业务层-UI层"三层架构,层次清晰、职责分明:

数据层(ContactDatabase):封装了所有与关系型数据库相关的操作,包括数据库初始化、联系人增删改查、分组统计、今日生日查询等。该层对外暴露异步方法(返回Promise),与上层完全解耦。

业务层(AddressBook主组件):管理应用的核心状态(联系人列表、分组信息、表单状态等),协调数据层与UI层的交互,处理用户操作事件(搜索、分组切换、表单提交、收藏切换、删除等)。

UI层(全局Builder函数):由10个独立的@Builder函数组成,每个函数负责一个独立的UI区域。所有函数通过参数接收数据和回调,不持有任何状态,做到纯粹的展示层职责。

2.2 文件结构

通讯录管家的核心代码完全集中在一个文件中——AddressBook.ets,文件结构如下(按代码顺序):

├── 接口定义(Contact、GroupInfo、Colors、SwipeActionData)
├── 常量定义(GROUPS分组、COLORS配色、AVATAR_COLORS头像色)
├── 工具函数(getAvatarColor)
├── ContactDatabase 类(数据库CRUD封装)
│   ├── init()         → 初始化数据库
│   ├── getAllContacts() → 获取全部联系人
│   ├── getContactsByGroup() → 按分组获取联系人
│   ├── searchContacts() → 按关键字搜索
│   ├── addContact()    → 添加联系人
│   ├── updateContact() → 更新联系人
│   ├── deleteContact() → 删除联系人
│   ├── getGroupStats() → 分组统计
│   ├── getTodayBirthdayContacts() → 今日生日查询
│   └── parseResultSet() → 结果集解析
├── 输入验证函数(validateName、validatePhone、validateBirthday)
├── AddressBook 主组件
│   └── build() → 页面组装
└── 全局 @Builder 函数(10个)
    ├── buildHeader()
    ├── buildSearchBar()
    ├── buildGroupTabs()
    ├── buildBirthdayBanner()
    ├── buildLoadingView()
    ├── buildEmptyView()
    ├── buildContactCard() / buildSwipeActions() / buildSwipeButton()
    ├── buildContactList()
    ├── buildFloatingButton()
    ├── buildErrorToast()
    ├── buildFormDialog()
    └── buildDeleteConfirmDialog()

这种将完整应用写入单一文件的组织方式,在小体量项目中具有明显的优势:无需在多个文件间频繁跳转、所有状态和UI逻辑一目了然、降低了模块导入导出的心智负担。


三、数据模型与常量体系

3.1 Contact 接口

Contact是应用最核心的数据模型,定义了联系人的完整数据结构:

interface Contact {
  id: number;          // 数据库主键,自增
  name: string;        // 联系人姓名
  phone: string;       // 手机号
  group: string;       // 分组(家人/朋友/同事/同学/其他)
  birthday: string;    // 生日,格式为'MM-DD',空串表示未设置
  isFavorite: number;  // 是否收藏,值为0或1
  remark: string;      // 备注信息
}

这个模型的设计有几个值得关注的点:

  • id使用number类型:鸿蒙RDB的自增主键返回的是number类型,与JavaScript的Number类型一致。
  • birthday使用’MM-DD’格式:只存储月日而非完整日期。这是因为生日提醒只需要核对月日是否与当天匹配,不需要年份信息。这种设计既简化了存储,也避免了跨年问题。
  • isFavorite使用number而非boolean:这是为了与SQLite的存储格式一致——SQLite没有原生的布尔类型,使用0/1整数表示布尔值是最通用的做法。

3.2 GroupInfo 接口

interface GroupInfo {
  name: string;   // 分组名称
  count: number;  // 该分组下的联系人数量
}

用于从数据库的分组统计查询中承载结果数据。

3.3 Colors 配色系统

通讯录管家的配色系统采用全局常量的方式统一定义,共13个颜色变量覆盖了应用的完整视觉需求:

const COLORS: Colors = {
  primary: '#007AFF',       // 主色调——iOS风格的蓝色
  primaryLight: '#E8F2FF',  // 主色浅色——用于标签背景
  background: '#F2F4F8',    // 页面背景——浅灰色
  cardBg: '#FFFFFF',        // 卡片背景——纯白
  textPrimary: '#1A1A2E',   // 主文字——深蓝黑
  textSecondary: '#6B7280', // 次要文字——灰色
  textTertiary: '#9CA3AF',  // 辅助文字——浅灰色
  headerBg: '#FFFFFF',      // 头部背景——纯白
  shadow: 'rgba(0, 0, 0, 0.08)',   // 阴影色
  danger: '#FF3B30',        // 危险色——删除操作
  success: '#34C759',       // 成功色——拨号操作
  warning: '#FF9500',       // 警告色——收藏切换
  birthday: '#FF6B9D',      // 生日色——粉色横幅
};

这个配色方案采用了清新、明亮的蓝白基调,配合浅灰色页面背景和白色卡片,形成了典型的iOS风格视觉语言。关键操作按钮使用蓝、绿、橙、红等语义色,让用户通过颜色就能快速判断操作的类型。

3.4 头像颜色映射

const AVATAR_COLORS: Record<string, string> = {
  '家人': '#FF6B9D',   // 粉色——温馨
  '朋友': '#34C759',   // 绿色——活力
  '同事': '#007AFF',   // 蓝色——专业
  '同学': '#FF9500',   // 橙色——青春
  '其他': '#AF52DE',   // 紫色——中性
};

每个分组对应的头像颜色经过精心选择,符合该分组的视觉联想。例如家人使用温馨的粉色、同事使用专业的蓝色、朋友使用活力的绿色。颜色的差异化也帮助用户在浏览联系人列表时,通过头像色快速识别联系人所属的分组。


四、数据库层 ContactDatabase 深度解析

4.1 数据库初始化

class ContactDatabase {
  private store: relationalStore.RdbStore | null = null;
  private readonly DB_NAME: string = 'ContactManager.db';
  private readonly TABLE_NAME: string = 'contacts';

  async init(context: common.Context): Promise<void> {
    if (this.store) return;
    const config: relationalStore.StoreConfig = {
      name: this.DB_NAME,
      securityLevel: relationalStore.SecurityLevel.S1,
    };
    this.store = await relationalStore.getRdbStore(context, config);
    await this.store.executeSql(this.CREATE_TABLE_SQL);
  }
}

初始化过程做了两个关键操作:第一,通过getRdbStore获取或创建数据库实例;第二,执行建表SQL创建contacts表(如果表已存在则跳过)。securityLevel: S1表示安全等级为低级别,适用于不包含敏感个人数据的场景。

建表SQL如下:

CREATE TABLE IF NOT EXISTS contacts (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  name        TEXT    NOT NULL,
  phone       TEXT    NOT NULL,
  group_name  TEXT    DEFAULT '其他',
  birthday    TEXT    DEFAULT '',
  is_favorite INTEGER DEFAULT 0,
  remark      TEXT    DEFAULT ''
)

这里使用了id INTEGER PRIMARY KEY AUTOINCREMENT,确保每条记录拥有唯一的自增主键。group_name字段默认值为’其他’,简化了添加联系人时的默认分组逻辑。

4.2 查询方法

数据库层提供了四种查询方法,覆盖了通讯录的核心查询场景:

获取全部联系人getAllContacts()直接调用私有方法queryList(null),不传分组参数时查询所有记录并按姓名升序排列。

按分组获取联系人getContactsByGroup(group)通过RdbPredicatesequalTo方法过滤分组字段。对于’全部’分组有特殊处理——不会添加过滤条件。

关键字搜索searchContacts(keyword)是查询层最复杂的一个方法。它通过predicates.contains('name', keyword)predicates.or().contains('phone', keyword)构建了一个"姓名或手机号包含关键字"的复合查询条件,实现了类似系统通讯录的搜索体验——输入姓名或手机号的任意部分都能匹配到对应的联系人。

今日生日查询getTodayBirthdayContacts()使用JavaScript的Date对象获取当前月日,格式化为’MM-DD’字符串后,通过equalTo('birthday', monthDay)精确匹配数据库中的生日字段。这种方法简单直接,无需在SQL层面进行日期函数运算。

4.3 分组统计

async getGroupStats(): Promise<GroupInfo[]> {
  this.ensureReady();
  const sql: string = `SELECT group_name AS name, COUNT(*) AS count
    FROM ${this.TABLE_NAME}
    GROUP BY group_name ORDER BY group_name`;
  const resultSet = await this.store!.querySql(sql);
  // ...解析结果集...
  // 补充 count 为 0 的分组
  for (const g of GROUPS) {
    if (g === '全部') continue;
    let found: boolean = false;
    for (const item of groups) {
      if (item.name === g) { found = true; break; }
    }
    if (!found) {
      groups.push({ name: g, count: 0 });
    }
  }
  return groups;
}

这个方法使用SQL的GROUP BY语句进行分组计数。值得关注的是,它在数据库查询之后,还遍历了预定义的分组列表GROUPS,为没有任何联系人的分组补充count: 0的记录。这样确保UI层在展示分组标签时,每个分组都有对应的计数数据,不会出现"某个分组没有数据就不显示"的问题。

4.4 增删改操作

三种写操作的设计都遵循相同的模式:调用ensureReady()确保数据库已初始化→构造操作参数→执行对应方法→返回结果。

  • addContact:构造ValuesBucket对象,调用insert方法,返回新记录的自增ID。
  • updateContact:使用RdbPredicatesid定位记录,调用update方法批量更新所有字段。
  • deleteContact:使用RdbPredicatesid定位记录,调用delete方法删除。

这种"定位→操作"的范式是鸿蒙RDB的标准用法,与Android的SQLiteOpenHelper使用习惯类似,学习曲线平滑。


五、主组件 AddressBook 状态管理

5.1 状态变量设计

AddressBook主组件共定义了16个@State状态变量,管理着应用的全部运行时数据:

状态变量 类型 用途
contactList Contact[] 当前显示的联系人列表
groupStats GroupInfo[] 分组统计信息
selectedGroup string 当前选中的分组
searchText string 搜索输入框内容
isLoading boolean 数据加载状态
errorMsg string 错误提示信息
showFormDialog boolean 表单对话框显示/隐藏
isEditing boolean 当前是否为编辑模式
editingContact Contact/null 正在编辑的联系人
formName/Phone/… string 表单各字段值
formNameError/… string 表单字段校验错误信息
showDeleteConfirm boolean 删除确认对话框显示/隐藏
deletingContact Contact/null 待删除的联系人
birthdayContacts Contact[] 今日生日的联系人列表
showBirthdayBanner boolean 生日横幅显示/隐藏

这16个状态变量的设计遵循了"最小状态原则"——每个变量只管理一个独立的变化维度,没有冗余或推导出的状态。例如,是否在编辑模式下通过isEditing布尔值管理,而不是通过editingContact !== null来推导。

5.2 数据加载流程

应用的启动数据加载由aboutToAppear生命周期方法触发:

aboutToAppear(): void {
  const ctx: common.Context = getContext(this) as common.Context;
  this.initDB(ctx);
}

async initDB(ctx: common.Context): Promise<void> {
  this.isLoading = true;
  this.errorMsg = '';
  try {
    await this.db.init(ctx);
    await this.refreshView();
    const todayBirthdays: Contact[] = await this.db.getTodayBirthdayContacts();
    this.birthdayContacts = todayBirthdays;
    this.showBirthdayBanner = todayBirthdays.length > 0;
  } catch (err) {
    this.errorMsg = '数据加载失败: ' + (err as BusinessError).message;
  } finally {
    this.isLoading = false;
  }
}

加载流程分为三步:初始化数据库→加载联系人数据→检查今日生日。每一步都有完备的异常处理,任何失败都会通过errorMsg状态展示给用户,同时finally块确保加载状态被及时关闭。这种"先加载、后校验、终显示"的模式,在用户体验上表现为:用户打开应用时看到加载动画,随后迅速看到联系人列表,如果有今日寿星还会在列表中展示粉色的生日横幅。

5.3 数据刷新机制

refreshView方法是整个应用数据流动的核心枢纽:

async refreshView(): Promise<void> {
  let contacts: Contact[];
  if (this.searchText) {
    contacts = await this.db.searchContacts(this.searchText);
  } else {
    contacts = await this.db.getContactsByGroup(this.selectedGroup);
  }
  this.contactList = contacts;
  this.groupStats = await this.db.getGroupStats();
}

这个方法同时承担两个职责:加载联系人列表和刷新分组统计。联系人列表的加载策略根据搜索状态分为两条路径——有搜索关键字时走搜索查询,无关键字时按当前分组查询。每次contactList的更新都会触发ArkUI的响应式渲染,而groupStats的更新则驱动分组标签上的计数刷新。


六、UI 构建体系详解

6.1 页面整体布局

通讯录管家的主页面采用纵向滚动布局,从上到下依次排列10个UI区域:

Stack(层叠容器,承载所有UI层)
└── Column(主内容区,可滚动)
    ├── buildHeader()          → 标题栏(联系人数量 + 分组名称 + 加载指示器)
    ├── buildSearchBar()       → 搜索栏(搜索图标 + 输入框 + 清除按钮)
    ├── buildBirthdayBanner()  → 生日横幅(条件渲染,有今日寿星时显示)
    ├── buildGroupTabs()       → 分组标签(水平滚动标签栏,支持分组切换)
    ├── buildLoadingView()     → 加载视图(数据加载中时显示)
    ├── buildEmptyView()       → 空状态视图(列表为空时显示)
    ├── buildContactList()     → 联系人列表(核心内容区)
    ├── buildFloatingButton()  → 浮动按钮(右下角的"+"添加按钮)
    ├── buildErrorToast()      → 错误提示(条件渲染)
    ├── buildFormDialog()      → 添加/编辑表单(条件渲染 + Stack层叠)
    └── buildDeleteConfirmDialog() → 删除确认(条件渲染 + Stack层叠)

这种布局方式有几个显著特点:

  • Stack作为根容器:允许表单对话框和删除确认对话框覆盖在主内容区之上,同时通过半透明遮罩层实现模态效果。
  • 条件渲染贯穿始终:生日横幅、加载视图、空状态、错误提示、表单对话框、删除确认对话框都是条件渲染的,只在对应的状态为true时才出现在UI树中。
  • Flex布局权重管理:联系人列表使用layoutWeight(1)自动填满剩余空间,确保不同屏幕尺寸下都能合理布局。

6.2 标题栏 buildHeader

标题栏采用Row + Column的组合布局,左侧显示应用名称和联系人统计信息,右侧显示加载指示器:

@Builder
function buildHeader(count: number, selectedGroup: string, loading: boolean) {
  Row() {
    Column() {
      Text('📞 通讯录管家')
        .fontSize(22).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(String(count) + ' 位联系人 · ' + selectedGroup)
        .fontSize(13).fontColor(COLORS.textSecondary)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    if (loading) {
      LoadingProgress().width(24).height(24).color(COLORS.primary)
    }
  }
  .width('100%')
  .padding({ left: 20, right: 20, top: 48, bottom: 16 })
  .backgroundColor(COLORS.headerBg)
}

这个Builder的设计体现了全局函数模式的核心思想:通过参数接收所有需要的数据(联系人数量、当前分组、加载状态),通过.layoutWeight(1)让左侧Column占据所有可用空间,右侧的加载指示器只有在加载状态为true时才渲染。这种"数据驱动视图"的理念贯穿整个应用。

6.3 搜索栏 buildSearchBar

搜索栏是典型的数据输入组件组合:

@Builder
function buildSearchBar(
  searchText: string,
  onChange: (val: string) => void,
  onClear: () => void
) {
  Row() {
    Text('🔍').fontSize(16).margin({ left: 12, right: 8 })
    TextInput({ placeholder: '搜索姓名或手机号...', text: searchText })
      .layoutWeight(1).fontSize(14)
      .fontColor(COLORS.textPrimary)
      .placeholderColor(COLORS.textTertiary)
      .backgroundColor(Color.Transparent).height(40)
      .onChange((val: string) => onChange(val))

    if (searchText.length > 0) {
      Text('✕').fontSize(16).fontColor(COLORS.textTertiary)
        .margin({ right: 12 })
        .onClick(() => onClear())
    }
  }
  .width('100%').height(44)
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .margin({ left: 16, right: 16, top: 12, bottom: 4 })
  .shadow({ radius: 4, color: COLORS.shadow, offsetY: 2 })
}

这里有三个设计细节值得关注:第一,搜索图标使用Emoji字符而非图片资源,减少了项目体积;第二,清除按钮(✕)只有在输入内容不为空时才渲染,这是通过if (searchText.length > 0)条件渲染实现的;第三,输入框设置为透明背景,将圆角和阴影效果交给外层Row组件,实现了"圆角搜索条"的视觉效果。

6.4 分组标签 buildGroupTabs

分组标签栏使用Scroll + Row实现水平滚动的标签组:

@Builder
function buildGroupTabs(
  groups: string[],
  selectedGroup: string,
  getCount: (group: string) => string,
  onSelect: (group: string) => void
) {
  Scroll() {
    Row() {
      ForEach(groups, (group: string) => {
        Column() {
          Text(group).fontSize(13)
            .fontColor(selectedGroup === group ? COLORS.primary : COLORS.textSecondary)
            .fontWeight(selectedGroup === group ? FontWeight.Medium : FontWeight.Regular)

          if (group !== '全部') {
            Text(getCount(group)).fontSize(10)
              .fontColor(selectedGroup === group ? COLORS.primary : COLORS.textTertiary)
              .margin({ top: 2 })
          }
        }
        .padding({ left: 16, right: 16, top: 8, bottom: 8 })
        .borderRadius(20)
        .backgroundColor(selectedGroup === group ? COLORS.primaryLight : 'transparent')
        .onClick(() => onSelect(group))
      })
    }
    .padding({ left: 8, right: 8 })
  }
  .scrollBar(BarState.Off)
  .width('100%')
}

这个组件的交互设计很细腻:选中的标签使用浅蓝色背景和蓝色文字,未选中的标签使用透明背景和灰色文字;每个分组标签下方显示该分组的联系人数量("全部"分组除外);通过Scroll容器支持标签水平滚动,当分组较多时不会挤压单个标签的空间。getCount回调函数由主组件传入,实现了从全局Builder函数到主组件状态的数据读取。

6.5 生日横幅 buildBirthdayBanner

生日横幅是应用中最具"温度感"的功能:

@Builder
function buildBirthdayBanner(
  birthdayContacts: Contact[],
  onDismiss: () => void
) {
  Row() {
    Text('🎂').fontSize(24).margin({ right: 12 })

    Column() {
      Text('🎉 今日寿星').fontSize(15)
        .fontWeight(FontWeight.Medium).fontColor('#FFFFFF')

      Text(birthdayContacts.map((c: Contact) => c.name).join('、')
        + ' — 送出生日祝福吧!')
        .fontSize(12).fontColor('rgba(255,255,255,0.85)')
        .margin({ top: 2 }).maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start)

    Text('✕').fontSize(14).fontColor('rgba(255,255,255,0.7)')
      .margin({ left: 8 }).onClick(() => onDismiss())
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 10, bottom: 10 })
  .backgroundColor('#FF6B9D')
  .margin({ left: 16, right: 16, top: 8 })
  .borderRadius(12)
}

生日横幅使用粉色背景(与家庭分组颜色一致),左侧显示蛋糕Emoji,中间显示寿星姓名和祝福语,右侧提供关闭按钮。横幅的内容是动态生成的——birthdayContacts.map(c => c.name).join('、')将多个寿星的姓名用顿号连接。如果当天有多个联系人过生日,横幅会一并展示所有寿星姓名,如"张三、李四、王五——送出生日祝福吧!"。

6.6 联系人卡片 buildContactCard

联系人卡片是应用中最复杂的UI组件,包含了头像、姓名、收藏标记、生日标记、手机号、备注、分组标签和拨号按钮:

@Builder
function buildContactCard(
  contact: Contact,
  onEdit: (c: Contact) => void,
  onDelete: (c: Contact) => void,
  onFavorite: (c: Contact) => void,
  onCall: (phone: string) => void
) {
  Row() {
    // 头像:取姓名首字 + 分组颜色
    Text(contact.name.charAt(0))
      .fontSize(18).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
      .width(44).height(44).textAlign(TextAlign.Center)
      .borderRadius(22)
      .backgroundColor(getAvatarColor(contact.group))
      .margin({ right: 12 })

    // 中间信息区
    Column() {
      Row() {
        Text(contact.name).fontSize(16).fontWeight(FontWeight.Medium)
          .fontColor(COLORS.textPrimary).maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        if (contact.isFavorite) { Text('⭐').fontSize(12).margin({ left: 4 }) }
        if (contact.birthday) { Text('🎂').fontSize(12).margin({ left: 4 }) }
      }
      .alignItems(VerticalAlign.Center)

      Text(contact.phone).fontSize(14).fontColor(COLORS.textSecondary)
        .margin({ top: 3 })

      if (contact.remark) {
        Text(contact.remark).fontSize(12).fontColor(COLORS.textTertiary)
          .margin({ top: 2 }).maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
    }
    .layoutWeight(1).alignItems(HorizontalAlign.Start)

    // 分组标签(非"其他"分组时显示)
    if (contact.group !== '其他') {
      Text(contact.group).fontSize(11).fontColor(COLORS.primary)
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        .backgroundColor(COLORS.primaryLight).borderRadius(10)
        .margin({ right: 8 })
    }

    // 拨号按钮
    Text('📞').fontSize(18).width(36).height(36)
      .textAlign(TextAlign.Center).borderRadius(18)
      .backgroundColor(COLORS.primaryLight)
      .onClick(() => onCall(contact.phone))
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 12 })
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .margin({ left: 16, right: 16, top: 6, bottom: 6 })
  .shadow({ radius: 3, color: COLORS.shadow, offsetY: 1 })
}

这个卡片的设计充满了细节:

  • 头像:使用联系人姓名的第一个字符作为头像文字,圆形背景的颜色根据分组自动匹配。这是"无头像联系人"场景下最优雅的占位方案,无需用户上传头像也能提供差异化的视觉标识。
  • 信息区:姓名最大显示一行,超出部分使用省略号截断。收藏标记和生日标记通过条件渲染显示在姓名右侧。手机号和备注分别使用不同字号和颜色形成清晰的视觉层次。
  • 分组标签:只有在分组不是默认的"其他"时才显示,避免所有卡片都带标签造成视觉冗余。标签使用浅蓝色背景和蓝色文字,与蓝白配色方案保持一致。
  • 拨号按钮:点击后通过call.makeCall系统能力发起拨号请求。注意这里的onCall函数是作为参数传入的,Builder函数本身不持有任何能力。

6.7 联系人列表 buildContactList

联系人列表使用List + ForEach + ListItem的组合构建高效的滚动物化列表:

@Builder
function buildContactList(
  contacts: Contact[],
  onEdit: (c: Contact) => void,
  onDelete: (c: Contact) => void,
  onFavorite: (c: Contact) => void,
  onCall: (phone: string) => void
) {
  List() {
    ForEach(contacts, (contact: Contact) => {
      ListItem() {
        buildContactCard(contact, onEdit, onDelete, onFavorite, onCall)
      }
    })
  }
  .width('100%')
  .layoutWeight(1)
  .margin({ top: 4, bottom: 80 })
}

使用List而非Scroll + Column的主要优势在于性能:List组件内置了列表项的回收复用机制,当列表项数量较大时(如数百个联系人),只有可见区域的列表项会被渲染,大幅减少了内存占用和渲染开销。bottom: 80的下边距为底部的浮动按钮预留了空间,避免最后一个联系人被浮动按钮遮挡。

6.8 浮动按钮 buildFloatingButton

浮动按钮(FAB)使用绝对定位固定在页面右下角:

@Builder
function buildFloatingButton(onClick: () => void) {
  Column() {
    Text('+').fontSize(28).fontColor('#FFFFFF').fontWeight(FontWeight.Lighter)
  }
  .width(56).height(56)
  .backgroundColor(COLORS.primary).borderRadius(28)
  .shadow({ radius: 8, color: 'rgba(0,122,255,0.4)', offsetY: 4 })
  .position({ bottom: 32, right: 24 })
  .onClick(() => onClick())
}

圆形按钮使用borderRadius(28)实现(宽高56的一半),配合position绝对定位固定在右下角。阴影使用了带透明度的蓝色(与主色一致),模拟了按钮浮在页面之上的视觉效果。FontWeight.Lighter让加号看起来更纤细、更精致。

6.9 错误提示 buildErrorToast

错误提示使用条件渲染和自动消失的模式:

@Builder
function buildErrorToast(msg: string, onDismiss: () => void) {
  Column() {
    Text(msg)
      .fontSize(14).fontColor('#FFFFFF')
      .padding({ left: 16, right: 16, top: 10, bottom: 10 })
      .backgroundColor('rgba(0,0,0,0.8)')
      .borderRadius(8)
  }
  .width('100%')
  .padding({ left: 24, right: 24 })
  .position({ top: 100 })
  .onClick(() => onDismiss())
}

错误提示显示在页面上部(position({ top: 100 })),使用半透明黑色背景的圆角文字块。用户点击即可关闭。虽然当前版本没有实现自动定时关闭,但这种设计已经能够满足错误信息的展示需求。

6.10 表单对话框 buildFormDialog

表单对话框是通讯录管家中最复杂的UI组件,包含姓名、手机号、分组、生日、备注五个输入字段和两个操作按钮:

@Builder
function buildFormDialog(
  isEditing: boolean,
  formName: string,
  formPhone: string,
  formGroup: string,
  formBirthday: string,
  formRemark: string,
  formNameError: string,
  formPhoneError: string,
  formBirthdayError: string,
  onNameChange: (v: string) => void,
  onPhoneChange: (v: string) => void,
  onGroupChange: (g: string) => void,
  onBirthdayChange: (v: string) => void,
  onRemarkChange: (v: string) => void,
  onSubmit: () => void,
  onCancel
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐