鸿蒙原生应用实战(五):功能打磨 — 个人中心与项目优化总结

本文是系列终篇,完成「心情日记」最后一个人中心页面(ProfilePage)的开发,并对整个项目进行全面的技术总结,包括连续签到算法分析、@Builder 复用模式、数据持久化方案、编码规范等实战经验。


一、个人中心页面(ProfilePage.ets)全面拆解

个人中心是用户管理数据和查看统计概览的页面,它包含:

  1. 用户信息卡片:应用名称与标语
  2. 统计卡片:总篇数 / 标签数 / 心情数
  3. 标签云:所有使用过的标签集合
  4. 功能菜单:导出日记 / 重置数据 / 关于应用

1.1 页面布局

┌──────────────────────────────────────────┐
│  < 返回                我的             │
├──────────────────────────────────────────┤
│  ┌──────────────────────────────────┐    │
│  │ 📖                              │    │
│  │  心情日记                        │    │ ← 用户信息
│  │  记录每一天的心情故事            │    │
│  └──────────────────────────────────┘    │
│                                          │
│  ┌────────┐ ┌────────┐ ┌────────┐       │
│  │  📝    │ │  🏷️   │ │  😊    │       │
│  │   15   │ │   6    │ │  9种   │       │ ← 统计卡片
│  │ 总篇数  │ │ 标签数 │ │ 心情数 │       │
│  └────────┘ └────────┘ └────────┘       │
│                                          │
│  ┌──────────────────────────────────┐    │
│  │  常用标签                         │    │
│  │  #工作  #家庭  #阅读  #生活       │    │ ← 标签云
│  │  #友情  #旅行  #感恩  #焦虑       │    │
│  └──────────────────────────────────┘    │
│                                          │
│  ┌──────────────────────────────────┐    │
│  │ 📤  导出日记                      │    │
│  │     将所有日记导出为文本    >     │    │ ← 功能菜单
│  ├──────────────────────────────────┤    │
│  │ 🔄  重置数据                      │    │
│  │     清除所有日记数据       >     │    │
│  ├──────────────────────────────────┤    │
│  │ ℹ️  关于应用                      │    │
│  │     心情日记 v1.0.0       >     │    │
│  └──────────────────────────────────┘    │
└──────────────────────────────────────────┘

1.2 状态变量

@State entries: DiaryEntry[] = [];   // 所有日记
@State totalCount: number = 0;       // 总篇数
@State tagCloud: string[] = [];      // 标签集合

相比其他页面,个人中心的状态变量较少,重在展示统计数据。

1.3 数据加载与标签提取

loadData(): void {
  let stored = AppStorage.get<DiaryEntry[]>('entries');
  this.entries = stored ? stored : [];
  this.totalCount = this.entries.length;

  // 提取所有标签(去重)
  let tagSet: string[] = [];
  for (let i = 0; i < this.entries.length; i++) {
    let tagsStr = this.entries[i].tags;
    if (tagsStr) {
      let parts: string[] = tagsStr.split(',');
      for (let j = 0; j < parts.length; j++) {
        let t = parts[j].trim();
        if (t && tagSet.indexOf(t) === -1) {
          tagSet.push(t); // indexOf === -1 确保去重
        }
      }
    }
  }
  this.tagCloud = tagSet;
}

标签提取算法

  1. 遍历所有日记
  2. 对每条日记的 tags 字段按逗号 , 分割
  3. 对每个标签 trim 去空格
  4. 使用 indexOf 检查是否已存在,实现去重

1.4 标签云 UI

if (this.tagCloud.length > 0) {
  Column() {
    Text('常用标签').fontSize(16).fontWeight(FontWeight.Bold)
      .width('100%').margin({ bottom: 10 })

    Flex({ wrap: FlexWrap.Wrap }) {  // 自动换行
      ForEach(this.tagCloud, (tag: string) => {
        Text('#' + tag)
          .fontSize(13).fontColor('#6C63FF')
          .backgroundColor('#EEEAFF')
          .padding({ left: 12, right: 12, top: 4, bottom: 4 })
          .borderRadius(12)
          .margin({ right: 6, bottom: 6 })
      }, (tag: string) => tag)
    }
    .width('100%')
  }
  .padding(14).backgroundColor('#FFFFFF').borderRadius(12)
}

Flex 换行布局要点

  • Flex({ wrap: FlexWrap.Wrap }):当一行放不下时自动换行
  • 每个标签使用胶囊样式(大圆角)
  • 标签前缀 # 增加可视性

1.5 重置数据功能

clearAllData(): void {
  AppStorage.set<DiaryEntry[]>('entries', []); // 清空数据
  this.loadData(); // 刷新页面
}

功能菜单使用 @Builder 复用:

@Builder menuRow(icon: string, title: string, desc: string, onClick: () => void) {
  Row() {
    Text(icon).fontSize(20).margin({ right: 10 })
    Column() {
      Text(title).fontSize(15).fontWeight(FontWeight.Medium)
      Text(desc).fontSize(12).fontColor('#999999').margin({ top: 1 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
    Text('>').fontSize(14).fontColor('#CCCCCC')
  }
  .width('100%')
  .padding({ top: 10, bottom: 10 })
  .onClick(onClick)
}

// 使用
Column() {
  this.menuRow('📤', '导出日记', '将所有日记导出为文本', () => {})
  this.menuRow('🔄', '重置数据', '清除所有日记数据', () => {
    this.clearAllData();
  })
  this.menuRow('ℹ️', '关于应用', '心情日记 v1.0.0', () => {})
}

二、@Builder 复用模式总结

在整个项目中,@Builder 被大量使用,形成了统一的组件复用模式:

2.1 @Builder 模式分类

页面 @Builder 名称 用途 参数
Index quickBtn 快捷操作按钮 icon, label, onClick
Index diaryRow 日记列表行 item: DiaryEntry
CalendarPage 使用 ForEach 直接渲染
StatsPage overviewBox 统计概览卡片 icon, label, value
ProfilePage roundedBox 统计卡片 icon, label, value
ProfilePage menuRow 功能菜单行 icon, title, desc, onClick

2.2 @Builder 设计模式

// 模式1:纯展示型 — 接收数据展示
@Builder diaryRow(item: DiaryEntry) { /* 展示 item 数据 */ }

// 模式2:动作型 — 接收回调
@Builder quickBtn(icon: string, label: string, onClick: () => void) {
  Column() {
    Text(icon).fontSize(26)
    Text(label).fontSize(12)
  }
  .onClick(onClick)
}

// 模式3:混合型 — 数据 + 回调
@Builder menuRow(icon: string, title: string, desc: string, onClick: () => void) {
  // 展示 icon/title/desc,点击触发 onClick
}

2.3 @Builder 的参数传递技巧

回调函数作为参数

// 定义时接收 () => void 类型
@Builder quickBtn(icon: string, label: string, onClick: () => void)

// 使用时传入箭头函数
this.quickBtn('✏️', '写日记', () => {
  router.pushUrl({ url: 'pages/WritePage' });
})

三、连续签到算法深度分析

3.1 算法回顾

let streakCount = 0;
let checkDate = new Date();

while (true) {
  // 构造日期字符串 YYYY-MM-DD
  let y = checkDate.getFullYear();
  let m = (checkDate.getMonth() + 1).toString().padStart(2, '0');
  let d = checkDate.getDate().toString().padStart(2, '0');
  let ds = `${y}-${m}-${d}`;

  // 查找当天是否有日记
  let found = false;
  for (let i = 0; i < this.entries.length; i++) {
    if (this.entries[i].date === ds) {
      found = true;
      break;
    }
  }

  if (found) {
    streakCount++;
    checkDate.setDate(checkDate.getDate() - 1); // 往前一天
  } else {
    break; // 断签,停止检查
  }
}
this.streak = streakCount;

3.2 时间复杂度分析

场景 检查天数 最坏复杂度
连续1天 1 O(1×n)
连续3天 3 O(3×n)
连续30天 30 O(30×n)
连续365天 365 O(365×n)

其中 n 为日记总数。对于日常使用(n < 1000),这个算法完全够用。

3.3 优化方案(供参考)

// 优化:使用 Set 存储所有有日记的日期,查找复杂度 O(1)
optimizedCalcStreak(): number {
  let dateSet = new Set<string>();
  for (let entry of this.entries) {
    dateSet.add(entry.date);
  }

  let streak = 0;
  let checkDate = new Date();
  while (true) {
    let ds = formatDate(checkDate);
    if (dateSet.has(ds)) {
      streak++;
      checkDate.setDate(checkDate.getDate() - 1);
    } else {
      break;
    }
  }
  return streak;
}

使用 Set 后,时间复杂度从 O(days×n) 降为 O(n + days),其中 days 是连续天数。

四、AppStorage 数据持久化思考

4.1 当前方案:内存存储

// 存
AppStorage.set<DiaryEntry[]>('entries', list);

// 取
let stored = AppStorage.get<DiaryEntry[]>('entries');

优点

  • 实现简单,无需文件操作
  • 响应式,自动触发 UI 更新
  • 适合演示项目

缺点

  • 应用被杀后数据丢失
  • 不支持持久化存储

4.2 升级方案:PersistentStorage

// 声明持久化键(在 Ability 中初始化)
PersistentStorage.persistProp('entries', []);

// 使用(与 AppStorage 一致)
AppStorage.get<DiaryEntry[]>('entries');
AppStorage.set<DiaryEntry[]>('entries', list);

4.3 更进一步:关系型数据库(RDB)

对于生产级应用,推荐使用鸿蒙的 RDB(关系型数据库):

import { relationalStore } from '@kit.ArkData';

// 创建数据库
const store = await relationalStore.getRdbStore(context, {
  name: 'Diary.db',
  securityLevel: relationalStore.SecurityLevel.S1
});

// 建表
await store.executeSql(
  'CREATE TABLE IF NOT EXISTS diary (' +
  'id TEXT PRIMARY KEY, ' +
  'date TEXT NOT NULL, ' +
  'mood TEXT NOT NULL, ' +
  'title TEXT, ' +
  'content TEXT, ' +
  'tags TEXT)'
);

// 增删改查
await store.insert('diary', {
  id: generateId(), date: '2025-01-20',
  mood: 'happy', title: '发年终奖了',
  content: '...', tags: '工作,家庭'
});

4.4 三种方案对比

方案 持久化 响应式 复杂度 适用场景
AppStorage 演示/快速原型
PersistentStorage ⭐⭐ 轻量数据存储
RDB ⭐⭐⭐ 复杂数据查询

五、项目技术全景

5.1 技术栈总结

┌──────────────────────────────────────────┐
│             心情日记 应用                  │
├──────────────────────────────────────────┤
│  框架:HarmonyOS Stage 模型               │
│  语言:ArkTS (API 23)                     │
│  构建:Hvigor                             │
│  状态管理:AppStorage                     │
│  导航:@ohos.router                       │
│  设备:Phone                              │
│  SDK:compatibleSdkVersion 23             │
│       targetSdkVersion 24                │
└──────────────────────────────────────────┘

5.2 页面功能矩阵

页面 核心功能 状态变量数 @Builder数 生命周期钩子
Index 今日心情、连续签到、快捷入口、最近列表 5 2 aboutToAppear + onPageShow
WritePage 日期显示、心情选择、标题/正文/标签输入、保存 6 0 aboutToAppear
CalendarPage 月历生成、月份切换、日期选中、日记详情、删除 7 0 aboutToAppear + onPageShow
StatsPage 总数统计、本月统计、心情分布柱状图、7天趋势 6 1 aboutToAppear + onPageShow
ProfilePage 统计卡片、标签云、功能菜单、数据重置 3 2 aboutToAppear + onPageShow

5.3 路由关系图

                   ┌──────────┐
                   │  Index   │ ← 首页(入口)
                   └────┬─────┘
                        │
          ┌─────────────┼─────────────┐
          │             │             │
    ┌─────▼────┐  ┌────▼────┐  ┌─────▼────┐
    │WritePage │  │Calendar │  │StatsPage │
    │ 写日记   │  │  日历   │  │  统计    │
    └──────────┘  └────┬────┘  └──────────┘
                       │
                  ┌────▼────┐
                  │Profile  │
                  │ 个人中心 │
                  └─────────┘

所有页面通过 router.pushUrl() 跳转,通过 router.back() 返回。

六、ArkTS 编码规范实战总结

6.1 命名规范

// 枚举:帕斯卡命名
enum MoodLevel { HAPPY = 'happy', CALM = 'calm' }

// 接口:帕斯卡命名
interface DiaryEntry { id: string; title: string; }

// 变量/函数:驼峰命名
let selectedMood: MoodLevel = MoodLevel.HAPPY;
function getMoodInfo(level: MoodLevel): MoodInfo { }

// 常量:全大写加下划线
const ALL_MOODS: MoodInfo[] = [];

// @Builder:驼峰命名
@Builder quickBtn(icon: string, label: string) { }

6.2 组件划分原则

// ✅ 好:每个页面一个文件,职责清晰
pages/
├── Index.ets          // 首页
├── WritePage.ets      // 写日记
├── CalendarPage.ets   // 日历
├── StatsPage.ets      // 统计
└── ProfilePage.ets    // 个人中心

// ✅ 好:数据层独立文件
models/
└── DiaryData.ets      // 模型+工具函数

6.3 状态管理规范

// 原则1:每个 @State 变量使用前必须初始化
@State entries: DiaryEntry[] = [];

// 原则2:onPageShow 中同步数据
onPageShow(): void { this.loadData(); }

// 原则3:修改数据后立即更新 AppStorage
AppStorage.set<DiaryEntry[]>('entries', newList);

6.4 生命周期使用规范

aboutToAppear(): void {
  // 1. 读取参数
  // 2. 初始化数据
  // 3. 调用 loadData
  this.loadData();
}

onPageShow(): void {
  // 1. 重新加载数据(确保与全局状态同步)
  this.loadData();
}

loadData(): void {
  // 1. 从 AppStorage 读取
  // 2. 更新 @State 变量
  // 3. 执行计算(如统计、日历构建)
}

七、构建与部署

7.1 完整构建命令

"D:\DevEco Studio\tools\node\node.exe" \
  "D:\DevEco Studio\tools\hvigor\bin\hvigorw.js" \
  --mode module \
  -p module=entry@default \
  -p product=default \
  -p requiredDeviceType=phone \
  assembleHap \
  --analyze=normal --parallel --incremental --daemon

7.2 生成产物

构建成功后,在 entry/build/default/outputs/default/ 下生成 .hap 文件,可直接安装到鸿蒙真机或模拟器。

八、踩坑记录全集

🕳️ 坑1:router 导入路径错误

问题:API 23 下 @kit.AbilityKit 不导出 router
解决:使用 import router from '@ohos.router'

🕳️ 坑2:ArkTS 严格模式对象字面量

问题arkts-no-untyped-obj-literals
解决:先定义 interface,再显式指定类型

🕳️ 坑3:数组字面量类型推断

问题arkts-no-noninferrable-arr-literals
解决let arr: DiaryEntry[] = [...]

🕳️ 坑4:ForEach key 重复

问题:CalendarPage 中空位日期没有唯一 key,导致渲染警告
解决:空位使用 Math.random().toString() 作为 key

🕳️ 坑5:getDay() 返回值

问题getDay() 返回 0=周日,而期望 7=周日
解决let startWeekday = firstDay.getDay() || 7;

🕳️ 坑6:app_name 重复定义

问题:在 entry 和 AppScope 中都定义了 app_name
解决:app_name 只需在 AppScope 中定义一次

九、项目扩展方向

本应用虽然功能完整,但仍有广阔的扩展空间:

9.1 数据持久化

// 升级到 RDB 数据库,支持数据持久化
// 支持数据导出为文件(txt/csv/json)
// 支持云备份

9.2 功能增强

// 图片附件:拍照/相册选择照片
// 地理位置:记录日记时的位置
// 语音输入:语音转文字
// 分享功能:将日记分享为图片
// 数据可视化:更多图表(年度报告、情绪曲线)
// 暗黑模式:适配深色主题

9.3 性能优化

// 使用 Set/Map 优化查找
// 大数据量时分页加载
// 使用 LazyForEach 优化长列表

十、系列总结

通过这五篇文章,我们从零到一完成了一个完整的鸿蒙原生应用开发:

博文 主题 核心知识点
第一篇 项目起航 Stage 模型、项目目录、路由注册、构建配置
第二篇 数据层设计 枚举、接口、AppStorage、模拟数据、工具函数
第三篇 UI 构建 首页卡片、快捷按钮、@Builder 复用、写日记表单
第四篇 交互进阶 月历算法、Grid 网格、柱状图、7天趋势
第五篇 功能打磨 标签云、数据管理、性能优化、编码规范

项目地址

框架:Stage 模型 + ArkTS
SDK:API 23

写在最后

鸿蒙原生开发虽然与传统 Android/iOS 开发有所不同,但 ArkTS 的声明式 UI 设计、强大的 @Builder 装饰器、灵活的 Grid/Layout 布局,都让开发体验非常顺畅。Stage 模型的模块化设计理念,更是为大型应用的开发提供了良好的架构基础。

希望本系列的五篇文章能为正在学习鸿蒙开发的你提供实际帮助。从搭建框架到数据层,从 UI 构建到交互实现,再到最终的功能打磨,每一个环节都是实际项目开发的真实经验。

如果你有任何问题或建议,欢迎在评论区留言交流!我们下个项目见!
在这里插入图片描述


本系列所有代码均来自真实的鸿蒙原生项目「心情日记」,如需完整源码,欢迎关注作者。

Logo

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

更多推荐