在这里插入图片描述在这里插入图片描述

基于 HarmonyOS ArkTS 的个人记账本应用:从零构建到功能完善的完整技术实践

摘要

本文深入剖析了一个基于 HarmonyOS ArkTS 开发的个人记账本应用,详细阐述了从项目架构设计、数据模型构建、UI 界面开发到数据持久化的完整技术实现路径。通过分析超过 900 行 ArkTS 代码的演进过程,本文揭示了 HarmonyOS 应用开发中的关键技术要点,包括状态管理、组件化 UI 设计、类型安全实践以及与底层数据存储的交互机制。文章不仅提供了具体的技术实现方案,还深入探讨了在严格的 ArkTS 类型系统约束下进行应用开发的策略与技巧,为 HarmonyOS 开发者提供了一份详实的技术参考。

关键词: HarmonyOS;ArkTS;个人记账本;状态管理;数据持久化;组件化开发


一、引言:鸿蒙生态与个人财务管理的新机遇

1.1 HarmonyOS 生态系统的崛起

随着华为 HarmonyOS(鸿蒙操作系统)的快速发展和生态建设的持续完善,越来越多的开发者开始关注这一新兴的移动操作系统平台。HarmonyOS 作为面向万物互联时代的全场景分布式操作系统,其设计理念与技术架构与传统的 Android 和 iOS 存在显著差异。HarmonyOS 采用统一的系统架构,支持多种终端设备,包括智能手机、平板电脑、智能穿戴设备、智慧屏以及车载系统等,这种"一次开发,多端部署"的能力为开发者提供了前所未有的机遇。

在 HarmonyOS 应用开发中,ArkTS(Ark TypeScript)作为官方推荐的开发语言,基于 TypeScript 进行扩展,提供了声明式 UI 开发范式和强大的状态管理能力。ArkTS 在保持 TypeScript 类型系统优势的同时,针对 UI 开发场景进行了专门优化,引入了诸如 @State@Builder@Entry@Component 等装饰器,使得开发者能够以更加简洁、直观的方式构建用户界面。

1.2 个人记账应用的价值与意义

个人财务管理是每个人日常生活中不可或缺的重要组成部分。在数字化时代,虽然各种支付工具提供了交易记录功能,但这些记录往往分散在不同的平台和应用中,难以形成统一的财务视图。一个功能完善、操作便捷的个人记账应用能够帮助用户:

第一,实现收支的精细化管理。 通过分类记录每一笔收入和支出,用户可以清晰地了解自己的资金流向,识别消费热点,从而制定更加合理的预算计划。

第二,培养理性的消费习惯。 定期的财务复盘能够让用户意识到不必要的开支,逐步建立起量入为出的消费观念,这对于年轻人的财务健康尤为重要。

第三,提供数据驱动的决策支持。 通过统计图表和趋势分析,用户可以基于历史数据做出更加明智的财务决策,例如调整储蓄比例、优化投资组合等。

第四,满足隐私保护需求。 相比将财务数据存储在第三方云服务中,本地化的记账应用能够更好地保护用户的隐私信息,避免敏感数据泄露的风险。

1.3 技术选型与项目目标

本项目选择 HarmonyOS ArkTS 作为开发技术栈,主要基于以下几方面的考量:

技术前瞻性: HarmonyOS 作为国产操作系统的代表,其生态正处于快速发展阶段。掌握 ArkTS 开发技术不仅具有实际的应用价值,也代表了技术发展的前沿方向。随着鸿蒙设备用户基数的持续增长,基于 HarmonyOS 的应用将拥有广阔的市场空间。

开发效率优势: ArkTS 的声明式 UI 开发范式极大地提高了界面开发效率。通过描述 UI 的状态和结构,框架会自动处理界面的更新和渲染,开发者无需手动操作 DOM 或处理复杂的 UI 更新逻辑。这种开发模式与 Flutter、SwiftUI 等现代 UI 框架类似,代表了移动应用开发的主流趋势。

类型安全保证: ArkTS 基于 TypeScript,提供了强大的静态类型检查能力。这在大型应用开发中尤为重要,能够在编译阶段发现大量潜在的错误,减少运行时异常,提高代码的可维护性和可靠性。

系统级能力集成: 作为原生开发框架,ArkTS 能够无缝集成 HarmonyOS 提供的系统级能力,包括数据存储、网络通信、多媒体处理等。特别是 @ohos.data.preferences 等系统 API 的调用,确保了应用能够高效、安全地处理本地数据。

本项目的目标是构建一个功能完整、界面美观、操作流畅的个人记账本应用,具体包括以下核心功能模块:

  • 首页概览: 展示总金额、本月收入、本月支出和结余等核心财务指标,以及最近的交易记录。
  • 记账功能: 支持选择收支类型(收入/支出)、分类(餐饮、交通、购物等),输入金额和备注,完成记账操作。
  • 明细查看: 按月份展示所有交易记录,支持月份切换和记录删除。
  • 统计分析: 展示支出分类占比和收支对比统计,帮助用户了解消费结构。

二、HarmonyOS ArkTS 开发基础

2.1 ArkTS 语言特性概述

ArkTS 是 HarmonyOS 应用开发的核心编程语言,它在标准 TypeScript 的基础上进行了扩展和增强,以更好地支持声明式 UI 开发和状态管理。理解 ArkTS 的核心特性是掌握 HarmonyOS 应用开发的基础。

类型系统的增强: ArkTS 继承了 TypeScript 强大的类型系统,并在此基础上增加了对 UI 开发的专门支持。与标准 TypeScript 相比,ArkTS 对类型使用有更加严格的限制,例如禁止使用 anyunknown 类型,这迫使开发者必须显式声明类型,从而在编译阶段就能够捕获大量类型错误。这种严格性虽然在初期会增加一定的开发成本,但从长远来看,它显著提高了代码的质量和可维护性。

声明式 UI 范式: ArkTS 采用声明式 UI 开发范式,开发者通过描述 UI 的"状态"和"结构"来构建界面,而不是通过命令式地操作 UI 元素。在这种范式下,当应用的状态发生变化时,UI 框架会自动计算并应用最小的界面更新,确保界面与数据状态保持同步。这种开发模式与 React、Vue 等前端框架的设计理念一脉相承,但 ArkTS 将其与静态类型系统深度融合,提供了更加可靠的开发体验。

装饰器机制: ArkTS 引入了多种装饰器来支持不同的开发场景。@Entry 装饰器标记应用的入口页面,@Component 装饰器定义可复用的 UI 组件,@State 装饰器声明组件内部的状态变量,@Builder 装饰器定义可复用的 UI 构建函数。这些装饰器构成了 ArkTS 应用开发的基础框架,使得代码结构清晰、职责分明。

2.2 状态管理机制

状态管理是声明式 UI 开发的核心。在 ArkTS 中,@State 装饰器用于声明组件内部的状态变量。当 @State 修饰的变量发生变化时,框架会自动触发依赖该状态的 UI 部分的重新渲染。

@State currentTab: number = 0;
@State transactions: TransactionModel[] = [];
@State totalAmount: number = 0;

上述代码展示了本应用中几个关键的状态变量。currentTab 控制底部导航栏的当前选中项,transactions 存储所有的交易记录,totalAmount 维护当前的账户总金额。当用户完成一笔记账操作后,这些状态变量会相应更新,界面会自动刷新以反映最新的数据状态。

需要注意的是,ArkTS 对状态变量的类型有严格要求。不支持使用 anyunknown 类型作为状态变量的类型,也不支持在 @Builder 方法中声明局部变量进行数据计算。这些限制要求开发者将数据处理逻辑从 UI 构建方法中分离出来,放到普通的方法中处理,然后通过参数传递给 @Builder 方法。这种设计虽然在一定程度上增加了代码的复杂度,但它确保了 UI 构建函数的纯粹性,使得 UI 逻辑更加清晰、易于维护。

2.3 组件化架构

ArkTS 采用组件化的架构设计,将界面拆分为多个独立、可复用的组件。每个组件由 @Component 装饰器定义,包含自己的状态、UI 结构和生命周期方法。

在本应用中,所有的 UI 都封装在一个 Index 组件中。虽然从严格意义上说,将如此多的功能集中在一个组件中并不符合组件化的最佳实践,但在小型应用或原型开发阶段,这种设计可以简化数据传递和状态共享的复杂度。通过 @Builder 装饰器,我们将不同的页面模块(首页、记账页、明细页、统计页)拆分为独立的构建函数,每个函数负责渲染特定页面的 UI 结构。

@Builder
buildHomePage(): void { /* 首页 UI */ }

@Builder
buildAddPage(): void { /* 记账页 UI */ }

@Builder
buildDetailPage(year: number, month: number, filtered: TransactionModel[]): void { /* 明细页 UI */ }

@Builder
buildStatsPage(income: number, expense: number, categoryStats: CategoryStat[]): void { /* 统计页 UI */ }

这种设计模式使得代码结构清晰,每个 @Builder 方法只关注特定页面的 UI 渲染,数据处理逻辑通过参数传入,实现了 UI 与业务逻辑的分离。

2.4 生命周期管理

ArkTS 组件提供了完整的生命周期方法,允许开发者在组件的不同阶段执行特定的逻辑。本应用主要使用了 aboutToAppear 生命周期方法,在组件即将显示时初始化数据存储并加载历史交易记录。

aboutToAppear(): void {
  this.initPreferences();
  this.loadData();
}

aboutToAppear 方法是组件生命周期的第一个重要节点,此时组件已经创建但尚未渲染到屏幕上。在这个方法中进行数据初始化操作,可以确保当用户看到界面时,所有的数据已经准备就绪,提供流畅的用户体验。


三、应用架构设计与数据模型

3.1 整体架构设计

本应用采用经典的 MVC(Model-View-Controller)架构模式的变体。在这种架构下,数据模型(Model)负责定义数据结构和业务逻辑,视图(View)负责渲染用户界面,而控制器(Controller)则处理用户输入并协调模型和视图之间的交互。

在 ArkTS 的实现中,这种架构体现为:

数据模型层:TransactionModelCategoryModelCategoryStatDateResult 等类以及 TransactionData 接口构成,定义了应用中所有的数据结构。

视图层: 由一系列 @Builder 方法构成,负责根据当前的状态渲染对应的用户界面。

业务逻辑层: 由普通的方法(非 @Builder 方法)构成,处理用户交互、数据计算、数据持久化等业务逻辑。

这种分层架构使得代码职责清晰,便于维护和扩展。当需要添加新功能时,开发者可以清晰地知道应该在哪个层次进行修改。

3.2 核心数据模型设计

数据模型的设计是应用架构的基础。合理的数据模型不仅能够准确地表达业务概念,还能够简化后续的业务逻辑实现。

3.2.1 交易记录模型

TransactionModel 是应用中最核心的数据模型,代表一笔交易记录:

class TransactionModel {
  id: number = 0;
  amount: number = 0;
  category: string = '';
  type: string = 'expense';
  remark: string = '';
  date: string = '';
  timestamp: number = 0;
}

该模型包含以下字段:

  • id: 交易的唯一标识符,使用 Date.now() 生成,确保唯一性。
  • amount: 交易金额,使用 number 类型存储。
  • category: 交易分类,如"餐饮"、"交通"等,使用字符串存储分类名称。
  • type: 交易类型,分为 ‘income’(收入)和 ‘expense’(支出),默认值为 ‘expense’。
  • remark: 交易备注,可选字段,用于记录交易的详细信息。
  • date: 交易日期,使用 ‘YYYY-MM-DD’ 格式的字符串存储,便于展示和按日期筛选。
  • timestamp: 交易时间戳,用于排序和精确的时间判断。

这种设计同时包含了日期字符串和时间戳两种时间表示方式,兼顾了展示需求(date 字段)和计算需求(timestamp 字段)。日期字符串的格式统一为 ‘YYYY-MM-DD’,便于按年、月、日进行解析和筛选。

3.2.2 分类模型

CategoryModel 定义了交易分类的数据结构:

class CategoryModel {
  name: string = '';
  icon: string = '';

  constructor(name: string, icon: string) {
    this.name = name;
    this.icon = icon;
  }
}

每个分类包含名称(name)和图标(icon)两个属性。图标使用 Unicode Emoji 字符表示,这种设计简化了图标资源的管理,无需引入额外的图片资源文件。应用预定义了两组分类:

支出分类: 餐饮、交通、购物、学习、娱乐、医疗、住房、其他
收入分类: 工资、兼职、红包、理财

这种分类体系覆盖了日常生活中绝大多数的收支场景,用户可以快速找到对应的分类完成记账。

3.2.3 统计数据模型

CategoryStat 用于存储分类统计信息:

class CategoryStat {
  name: string = '';
  amount: number = 0;
  percent: string = '';

  constructor(name: string, amount: number, percent: string) {
    this.name = name;
    this.amount = amount;
    this.percent = percent;
  }
}

该模型包含分类名称、金额和占比百分比三个字段。百分比以字符串形式存储,保留了小数精度,便于直接展示在 UI 上。

3.2.4 日期解析模型

DateResult 用于存储日期解析的结果:

class DateResult {
  year: number = 0;
  month: number = 0;
  day: number = 0;

  constructor(year: number, month: number, day: number) {
    this.year = year;
    this.month = month;
    this.day = day;
  }
}

该模型将日期字符串解析为年、月、日三个独立的数字字段。值得注意的是,month 字段使用 0-11 的表示方式(JavaScript 的 Date.getMonth() 返回值),这与日常生活中的 1-12 月份表示有所不同,在进行月份比较时需要特别注意。

3.3 数据持久化策略

数据的持久化存储是记账应用的关键需求。用户记录的交易数据需要在应用关闭后仍然保存,并在下次打开应用时恢复。本应用采用 HarmonyOS 提供的 @ohos.data.preferences API 实现数据的本地持久化。

3.3.1 Preferences 存储机制

@ohos.data.preferences 提供了一种轻量级的键值对数据存储方案,类似于 Web 开发中的 localStorage。它适合存储结构化的配置信息和小规模的数据集合。在本应用中,我们将所有的交易记录序列化为 JSON 字符串,以 ‘transactions’ 为键存储到 Preferences 中。

import dataPreferences from '@ohos.data.preferences';

async initPreferences(): Promise<void> {
  try {
    this.preferences = await dataPreferences.getPreferences(this.context, 'accounting_data');
  } catch (e) {
    console.error('initPreferences failed:', e);
  }
}

dataPreferences.getPreferences 方法接收应用上下文和存储名称两个参数,返回一个 Preferences 实例。通过该实例,可以进行数据的读写操作。使用 await 关键字处理异步操作,确保在 Preferences 初始化完成后再进行后续的数据操作。

3.3.2 数据的序列化与反序列化

交易记录数组需要序列化为 JSON 字符串才能存储到 Preferences 中。在保存数据时:

async saveData(): Promise<void> {
  if (!this.preferences) {
    await this.initPreferences();
  }
  if (!this.preferences) {
    return;
  }

  try {
    let dataStr = JSON.stringify(this.transactions);
    await this.preferences.put('transactions', dataStr);
    await this.preferences.flush();
  } catch (e) {
    console.error('saveData failed:', e);
  }
}

JSON.stringify 将交易记录数组转换为 JSON 字符串,preferences.put 方法将数据写入存储,preferences.flush 方法确保数据立即持久化到磁盘。这种三步操作确保了数据的可靠保存。

在加载数据时,需要进行反序列化操作:

async loadData(): Promise<void> {
  if (!this.preferences) {
    await this.initPreferences();
  }
  if (!this.preferences) {
    this.initDefaultData();
    return;
  }

  try {
    let saved = await this.preferences.get('transactions', '');
    if (saved && saved !== '') {
      let parsed = JSON.parse(saved as string) as TransactionData[];
      this.transactions = this.parseTransactions(parsed);
      this.calculateTotalAmount();
    } else {
      this.initDefaultData();
      this.calculateTotalAmount();
      await this.saveData();
    }
  } catch (e) {
    console.error('loadData failed:', e);
    this.initDefaultData();
    this.calculateTotalAmount();
  }
}

反序列化过程中,JSON.parse 将 JSON 字符串转换为 JavaScript 对象。由于 JSON 解析的结果类型不确定,需要使用类型断言 as TransactionData[] 来指定类型。然后通过 parseTransactions 方法将解析后的数据转换为 TransactionModel 实例数组。

3.3.3 数据迁移与兼容性

parseTransactions 方法不仅进行数据转换,还处理了数据兼容性问题:

parseTransactions(data: TransactionData[]): TransactionModel[] {
  let result: TransactionModel[] = [];
  for (let i = 0; i < data.length; i++) {
    let item = data[i];
    let t = new TransactionModel();
    t.id = typeof item.id === 'number' ? item.id : Date.now();
    t.amount = typeof item.amount === 'number' ? item.amount : 0;
    t.category = typeof item.category === 'string' ? item.category : '其他';
    t.type = typeof item.type === 'string' ? item.type : 'expense';
    t.remark = typeof item.remark === 'string' ? item.remark : '';
    t.date = typeof item.date === 'string' ? item.date : this.getCurrentDateString();
    t.timestamp = typeof item.timestamp === 'number' ? item.timestamp : Date.now();
    result.push(t);
  }
  return result;
}

通过 typeof 类型检查,该方法确保每个字段都有合理的默认值。即使存储的数据格式发生变化(例如某些字段缺失或类型不匹配),应用也能够优雅地处理,避免崩溃。这种防御式编程策略在实际应用中非常重要,特别是当应用经历多次迭代更新后,存储的数据格式可能会有所变化。


四、用户界面设计与实现

4.1 整体布局设计

本应用采用经典的底部 Tab 导航布局,包含四个主要页面:首页、记账、明细、统计。底部导航栏固定在屏幕底部,用户可以通过点击不同的 Tab 切换页面。这种布局模式在移动应用中非常常见,用户熟悉度高,操作便捷。

build() {
  Column() {
    this.buildTabContent();
    this.buildTabBar();
    
    if (this.showAmountDialog) {
      this.buildAmountDialog();
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5F5F5')
}

根布局采用 Column 组件,垂直方向排列内容区域和底部导航栏。内容区域使用 layoutWeight(1) 占据剩余空间,导航栏固定在底部。#F5F5F5 的浅灰色背景色为整个应用提供了统一的视觉基调。

4.2 首页设计与实现

首页是用户打开应用后看到的第一个页面,承担着信息概览和快速入口的重要职责。

4.2.1 核心指标展示

首页顶部展示"总金额"指标,这是用户最关心的财务数据:

Column() {
  Text('总金额')
    .fontSize(14)
    .fontColor('#999999')
    .padding({ top: 16, left: 16 })
  Text('¥' + this.formatNumber(this.totalAmount))
    .fontSize(36)
    .fontWeight(FontWeight.Bold)
    .fontColor(this.totalAmount >= 0 ? '#27AE60' : '#E74C3C')
    .padding({ left: 16 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.margin({ left: 16, right: 16 })
.borderRadius(12)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.05)' })

总金额使用 36 号大字体突出显示,颜色根据金额正负动态变化:正值显示绿色(#27AE60),负值显示红色(#E74C3C)。这种颜色编码直观地传达了财务状况的好坏。卡片采用白色背景、圆角边框和轻微阴影,营造出悬浮的视觉效果,提升了界面的层次感。

在总金额下方,使用三个等宽的统计卡片展示本月收入、本月支出和结余:

Row({ space: 12 }) {
  this.buildStatCard('本月收入', this.getMonthIncome())
  this.buildStatCard('本月支出', this.getMonthExpense())
  this.buildStatCard('结余', this.getMonthIncome() - this.getMonthExpense())
}
.padding({ left: 16, right: 16 })

三个卡片横向等分排列,space: 12 设置了卡片之间的间距。每个卡片的高度固定为 80,使用白色背景和圆角设计,与总金额卡片保持一致的视觉风格。

4.2.2 最近记录列表

首页底部展示最近的交易记录,帮助用户快速回顾近期的收支情况:

List({ space: 8 }) {
  ForEach(this.getRecentTransactions(), (item: TransactionModel) => {
    ListItem() {
      this.buildTransactionItem(item)
    }
  })

  if (this.transactions.length === 0) {
    ListItem() {
      Text('暂无记录')
        .fontSize(14)
        .fontColor('#999999')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 30, bottom: 30 })
    }
  }
}
.width('100%')
.padding({ left: 16, right: 16 })

使用 List 组件展示交易记录,ForEach 遍历最近的交易数据。每条记录使用 buildTransactionItem 方法渲染,包含分类图标、分类名称、日期和金额等信息。当没有交易记录时,显示"暂无记录"的提示文本。

4.2.3 交易记录项设计

交易记录项采用卡片式布局,清晰展示交易的关键信息:

Row({ space: 12 }) {
  Text(this.getCategoryIconByName(item.category))
    .fontSize(28)

  Column({ space: 4 }) {
    Row({ space: 8 }) {
      Text(item.category)
        .fontSize(14)
        .fontColor('#333333')
      if (item.remark.length > 0) {
        Text(item.remark)
          .fontSize(12)
          .fontColor('#999999')
      }
    }
    Text(item.date)
      .fontSize(12)
      .fontColor('#999999')
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Start)

  Row({ space: 0 }) {
    Text(item.type === 'income' ? '+' : '-')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(item.type === 'income' ? '#27AE60' : '#E74C3C')
    Text(this.formatNumber(item.amount))
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(item.type === 'income' ? '#27AE60' : '#E74C3C')
  }
}

左侧显示分类的 Emoji 图标(28号字体),中间区域展示分类名称、备注和日期,右侧显示金额。收入使用绿色前缀"+“,支出使用红色前缀”-",颜色编码与首页的总金额保持一致,形成了统一的视觉语言。layoutWeight(1) 使中间区域占据剩余空间,确保金额始终右对齐。

4.3 记账页面设计

记账页面是应用的核心功能入口,设计目标是让用户以最少的操作步骤完成记账。

4.3.1 收支类型切换

页面顶部提供支出/收入类型切换按钮:

Row({ space: 16 }) {
  this.buildTypeButton('支出', 'expense')
  this.buildTypeButton('收入', 'income')
}
.padding({ top: 20, left: 16, right: 16 })
.justifyContent(FlexAlign.SpaceBetween)

两个按钮横向排列,分别占据约 45% 的宽度。选中状态的按钮使用蓝色背景(#3498DB)和白色文字,未选中状态使用浅蓝色背景(#E8F4FC)和蓝色文字,形成清晰的视觉对比。

4.3.2 分类网格选择

分类采用网格布局,每行显示 4 个分类:

Grid() {
  ForEach(this.getCurrentCategories(), (cat: CategoryModel) => {
    GridItem() {
      Column({ space: 4 }) {
        Text(cat.icon)
          .fontSize(28)
        Text(cat.name)
          .fontSize(12)
          .fontColor('#666666')
      }
      .width('100%')
      .height(64)
      .backgroundColor('#FFFFFF')
      .borderRadius(8)
      .justifyContent(FlexAlign.Center)
      .onClick(() => {
        this.selectedCategory = cat.name;
        this.selectedCategoryIcon = cat.icon;
        this.showAmountDialog = true;
      })
    }
  })
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(12)
.columnsGap(12)

Grid 组件配合 columnsTemplate('1fr 1fr 1fr 1fr') 实现四列等宽布局。每个分类项显示 Emoji 图标和分类名称,采用白色背景卡片设计。点击分类项后,设置选中的分类信息并弹出金额输入弹窗。

4.3.3 金额输入弹窗

弹窗采用模态对话框的形式,覆盖在整个页面上方:

@Builder
buildAmountDialog(): void {
  Column() {
    Column() {
      Text('记一笔')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .padding({ top: 20, bottom: 16 })
      
      Row({ space: 8 }) {
        Text(this.selectedCategoryIcon)
          .fontSize(24)
        Text(this.selectedCategory)
          .fontSize(16)
          .fontColor('#666666')
      }
      .padding({ bottom: 20 })
      
      Text('金额')
        .fontSize(14)
        .fontColor('#999999')
        .width('100%')
        .padding({ left: 16 })
        .textAlign(TextAlign.Start)
      
      Row({ space: 8 }) {
        Text('¥')
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        TextInput({ placeholder: '0.00', text: this.dialogAmount })
          .type(InputType.Number)
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .width('100%')
          .onChange((val: string) => {
            this.dialogAmount = val;
          })
      }
      .width('100%')
      .height(56)
      .backgroundColor('#F5F5F5')
      .borderRadius(12)
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .margin({ left: 16, right: 16, top: 8 })
      
      Text('备注(可选)')
        .fontSize(14)
        .fontColor('#999999')
        .width('100%')
        .padding({ left: 16, top: 16 })
        .textAlign(TextAlign.Start)
      
      TextInput({ placeholder: '添加备注', text: this.dialogRemark })
        .fontSize(14)
        .fontColor('#333333')
        .width('100%')
        .height(44)
        .backgroundColor('#F5F5F5')
        .borderRadius(12)
        .padding({ left: 16, right: 16 })
        .margin({ left: 16, right: 16, top: 8 })
        .onChange((val: string) => {
          this.dialogRemark = val;
        })
      
      Row({ space: 12 }) {
        Button('取消')
          .width('45%')
          .height(48)
          .backgroundColor('#E0E0E0')
          .fontColor('#666666')
          .fontSize(16)
          .borderRadius(24)
          .onClick(() => {
            this.showAmountDialog = false;
            this.dialogAmount = '';
            this.dialogRemark = '';
          })
        
        Button('确认')
          .width('45%')
          .height(48)
          .backgroundColor(this.dialogAmount.length > 0 ? '#3498DB' : '#B0C4DE')
          .fontColor('#FFFFFF')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .borderRadius(24)
          .enabled(this.dialogAmount.length > 0)
          .onClick(() => {
            this.confirmTransaction();
          })
      }
      .padding({ top: 24, left: 16, right: 16, bottom: 20 })
    }
    .width('85%')
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(0,0,0,0.5)')
  .justifyContent(FlexAlign.Center)
}

弹窗设计遵循以下原则:

信息清晰: 顶部明确显示已选择的分类图标和名称,让用户确认当前正在记录的交易类型。

输入便捷: 金额输入框使用数字键盘(InputType.Number),大字体显示(28号),配合人民币符号"¥",输入体验流畅。

操作明确: 取消和确认按钮并排显示,确认按钮在未输入金额时置灰(enabled(false)),避免误操作。

视觉层次: 弹窗使用白色背景卡片,外部覆盖半透明黑色遮罩(rgba(0,0,0,0.5)),聚焦用户注意力到弹窗内容上。

4.4 明细页面设计

明细页面按月份展示所有交易记录,支持月份切换和记录删除。

4.4.1 月份导航

页面顶部提供月份切换控件:

Row({ space: 16 }) {
  Button() {
    Text('◀')
      .fontSize(20)
      .fontColor('#333333')
  }
  .width(44)
  .height(44)
  .backgroundColor('#FFFFFF')
  .borderRadius(22)
  .onClick(() => {
    this.detailDate = new Date(year, month - 1, 1);
  })

  Text(`${year}${month + 1}`)
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
    .fontColor('#333333')
    .layoutWeight(1)
    .textAlign(TextAlign.Center)

  Button() {
    Text('▶')
      .fontSize(20)
      .fontColor('#333333')
  }
  .width(44)
  .height(44)
  .backgroundColor('#FFFFFF')
  .borderRadius(22)
  .onClick(() => {
    let now = new Date();
    let nextDate = new Date(year, month + 1, 1);
    if (nextDate.getTime() <= now.getTime()) {
      this.detailDate = nextDate;
    }
  })
}

左右箭头按钮分别用于切换到上一月和下一月。月份文本居中显示,使用粗体突出。切换到下一月时增加了边界检查,防止切换到未来的月份。

4.4.2 交易列表与删除

交易列表与首页的最近记录类似,但增加了删除功能:

ForEach(filtered, (item: TransactionModel) => {
  ListItem() {
    Row({ space: 8 }) {
      this.buildTransactionItem(item)

      Button('删除')
        .width(60)
        .height(32)
        .backgroundColor('#E74C3C')
        .fontColor('#FFFFFF')
        .fontSize(12)
        .borderRadius(8)
        .onClick(() => {
          this.deleteTransaction(item.id);
        })
    }
    .width('100%')
  }
})

每条记录右侧添加红色的"删除"按钮,点击后调用 deleteTransaction 方法删除该记录。删除操作会同步更新交易数组和总金额,并持久化到本地存储。

4.5 统计页面设计

统计页面提供支出分类占比和收支对比的可视化展示。

4.5.1 支出分类占比
@Builder
buildExpenseCategoryStats(categoryStats: CategoryStat[]): void {
  Text('支出分类占比')
    .fontSize(16)
    .fontWeight(FontWeight.Bold)
    .fontColor('#333333')
    .padding({ left: 16 })
    .width('100%')
    .textAlign(TextAlign.Start)

  Column({ space: 8 }) {
    ForEach(categoryStats, (stat: CategoryStat) => {
      Row({ space: 8 }) {
        Text(this.getCategoryIconByName(stat.name))
          .fontSize(16)
        Text(stat.name)
          .fontSize(14)
          .fontColor('#333333')
          .layoutWeight(1)
        Text(stat.percent + '%')
          .fontSize(14)
          .fontColor('#666666')
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
    })
  }
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ left: 16, right: 16 })
  .padding({ top: 12, bottom: 12 })
}

分类占比以列表形式展示,每个分类显示图标、名称和占比百分比。使用白色背景卡片包裹列表内容,视觉层次清晰。

4.5.2 收支对比
Row({ space: 32 }) {
  Column({ space: 8 }) {
    Text('收入')
      .fontSize(14)
      .fontColor('#999999')
    Text('¥' + this.formatNumber(income))
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#27AE60')
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)

  Column({ space: 8 }) {
    Text('支出')
      .fontSize(14)
      .fontColor('#999999')
    Text('¥' + this.formatNumber(expense))
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#E74C3C')
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
}

收入和支出并排显示,收入使用绿色,支出使用红色,形成鲜明的视觉对比。layoutWeight(1) 使两列等分宽度,alignItems(HorizontalAlign.Center) 使内容水平居中。


五、业务逻辑实现

5.1 交易记录的创建与确认

当用户在弹窗中输入金额并点击"确认"后,触发 confirmTransaction 方法:

confirmTransaction(): void {
  if (this.dialogAmount.length === 0) {
    return;
  }
  
  let amountValue = parseFloat(this.dialogAmount);
  if (isNaN(amountValue) || amountValue <= 0) {
    return;
  }

  let newTransaction = new TransactionModel();
  newTransaction.id = Date.now();
  newTransaction.amount = amountValue;
  newTransaction.category = this.selectedCategory;
  newTransaction.type = this.addType;
  newTransaction.remark = this.dialogRemark;
  newTransaction.date = this.getCurrentDateString();
  newTransaction.timestamp = Date.now();

  this.transactions.unshift(newTransaction);
  
  if (this.addType === 'income') {
    this.totalAmount += amountValue;
  } else {
    this.totalAmount -= amountValue;
  }
  
  this.saveData();

  this.dialogAmount = '';
  this.dialogRemark = '';
  this.showAmountDialog = false;
  this.currentTab = 0;
}

该方法的处理流程如下:

参数校验: 首先检查金额是否为空,然后使用 parseFloat 将字符串转换为数字,并检查是否为有效数值且大于零。这种多层校验确保了数据的合法性。

创建交易对象: 使用 Date.now() 生成唯一 ID 和时间戳,使用 getCurrentDateString() 获取当前日期字符串,构建完整的交易记录对象。

更新状态: 将新交易添加到交易数组的开头(unshift),以便在列表中优先显示。同时更新总金额,收入增加金额,支出减少金额。

持久化数据: 调用 saveData() 将更新后的交易数组保存到本地存储。

重置状态: 清空弹窗输入,关闭弹窗,自动切换到首页(currentTab = 0),让用户立即看到更新后的数据。

5.2 日期处理与解析

日期处理是记账应用中的常见需求,包括获取当前日期、解析日期字符串、按月份筛选等。

5.2.1 日期格式化
getCurrentDateString(): string {
  let now = new Date();
  let year = now.getFullYear();
  let month = now.getMonth() + 1;
  let day = now.getDate();
  
  let monthStr = month < 10 ? '0' + month : month.toString();
  let dayStr = day < 10 ? '0' + day : day.toString();
  
  return year + '-' + monthStr + '-' + dayStr;
}

该方法生成 ‘YYYY-MM-DD’ 格式的日期字符串。对于月份和日期小于 10 的情况,在前面补零,确保字符串长度统一。这种格式化的日期字符串便于展示和按日期筛选。

5.2.2 日期解析
parseDate(dateStr: string): DateResult {
  let parts = dateStr.split('-');
  return new DateResult(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
}

将日期字符串解析为 DateResult 对象。注意月份需要减 1,因为 JavaScript 的 Date.getMonth() 返回 0-11 的月份值,而日期字符串中的月份是 1-12。

5.3 统计计算

5.3.1 月收入与支出计算
getMonthIncome(): number {
  let now = new Date();
  let year = now.getFullYear();
  let month = now.getMonth();
  let income = 0;

  for (let i = 0; i < this.transactions.length; i++) {
    let t = this.transactions[i];
    let d = this.parseDate(t.date);
    if (d.year === year && d.month === month && t.type === 'income') {
      income += t.amount;
    }
  }

  return income;
}

遍历所有交易记录,筛选出当前年份和月份的收入交易,累加金额。支出计算逻辑类似,只是筛选条件为 t.type === 'expense'

5.3.2 分类占比计算
getCategoryStats(): CategoryStat[] {
  let now = new Date();
  let year = now.getFullYear();
  let month = now.getMonth();
  let expense = this.getMonthExpense();
  
  let categoryMap: Record<string, number> = {};
  for (let i = 0; i < this.expenseCategories.length; i++) {
    categoryMap[this.expenseCategories[i].name] = 0;
  }

  for (let i = 0; i < this.transactions.length; i++) {
    let t = this.transactions[i];
    let d = this.parseDate(t.date);
    if (d.year === year && d.month === month && t.type === 'expense') {
      if (categoryMap[t.category] !== undefined) {
        categoryMap[t.category] += t.amount;
      }
    }
  }

  let result: CategoryStat[] = [];
  for (let i = 0; i < this.expenseCategories.length; i++) {
    let name = this.expenseCategories[i].name;
    let amount = categoryMap[name];
    if (amount > 0) {
      let percent = expense > 0 ? this.formatPercent((amount / expense) * 100) : '0';
      result.push(new CategoryStat(name, amount, percent));
    }
  }

  return result;
}

分类占比的计算逻辑如下:

  1. 初始化分类映射表,将所有支出分类的金额置为 0。
  2. 遍历交易记录,累加每个分类的支出金额。
  3. 遍历分类映射表,计算每个分类的占比百分比。
  4. 仅保留金额大于 0 的分类,避免显示没有交易的分类。

百分比计算使用了自定义的 formatPercent 方法,保留一位小数:

formatPercent(num: number): string {
  let str = num.toString();
  let dotIndex = str.indexOf('.');
  if (dotIndex === -1) {
    return str;
  }
  if (dotIndex + 2 >= str.length) {
    return str;
  }
  return str.substring(0, dotIndex + 2);
}

该方法通过字符串操作实现百分比格式化,避免了使用 toFixed 方法(ArkTS 限制标准库的使用)。

5.3.3 总金额计算
calculateTotalAmount(): void {
  let total = 0;
  for (let i = 0; i < this.transactions.length; i++) {
    let t = this.transactions[i];
    if (t.type === 'income') {
      total += t.amount;
    } else {
      total -= t.amount;
    }
  }
  this.totalAmount = total;
}

总金额通过遍历所有交易记录计算得出,收入加、支出减。该方法在数据加载和记录删除时被调用,确保总金额的准确性。

5.4 交易记录的删除

deleteTransaction(id: number): void {
  let index = -1;
  for (let i = 0; i < this.transactions.length; i++) {
    if (this.transactions[i].id === id) {
      index = i;
      break;
    }
  }
  if (index !== -1) {
    let deletedAmount = this.transactions[index].amount;
    let deletedType = this.transactions[index].type;
    this.transactions.splice(index, 1);
    if (deletedType === 'income') {
      this.totalAmount -= deletedAmount;
    } else {
      this.totalAmount += deletedAmount;
    }
    this.saveData();
  }
}

删除操作的处理逻辑:

  1. 通过 ID 查找要删除的记录索引。
  2. 记录删除前的金额和类型。
  3. 从数组中移除该记录。
  4. 反向更新总金额:删除收入则减去对应金额,删除支出则加上对应金额。
  5. 持久化更新后的数据。

5.5 数字格式化

由于 ArkTS 限制标准库的使用,不能使用 toFixed 方法,因此需要自定义数字格式化方法:

formatNumber(num: number): string {
  let str = num.toString();
  if (str.indexOf('.') === -1) {
    str += '.00';
  } else {
    let parts = str.split('.');
    if (parts[1].length === 1) {
      str += '0';
    } else if (parts[1].length > 2) {
      str = parts[0] + '.' + parts[1].substring(0, 2);
    }
  }
  return str;
}

该方法将数字格式化为保留两位小数的字符串:

  • 如果没有小数部分,添加 “.00”。
  • 如果只有一位小数,补零到两位。
  • 如果小数部分超过两位,截断到两位。

六、开发过程中的技术挑战与解决方案

6.1 ArkTS 类型系统的严格约束

ArkTS 对类型系统有严格的约束,禁止使用 anyunknown 类型,这在与 JSON 数据打交道时带来了挑战。在解析从 Preferences 中读取的数据时,原始的 JSON 解析结果类型不确定,需要使用显式类型声明:

解决方案: 定义 TransactionData 接口,明确声明数据的类型结构,然后使用类型断言将解析结果转换为指定类型:

interface TransactionData {
  id: number;
  amount: number;
  category: string;
  type: string;
  remark: string;
  date: string;
  timestamp: number;
}

let parsed = JSON.parse(saved as string) as TransactionData[];

parseTransactions 方法中,使用 typeof 进行运行时类型检查,为每个字段提供默认值,确保数据的完整性:

t.id = typeof item.id === 'number' ? item.id : Date.now();
t.amount = typeof item.amount === 'number' ? item.amount : 0;

6.2 @Builder 方法的纯 UI 约束

ArkTS 要求 @Builder 方法中只能包含 UI 组件代码,不能包含变量声明和数据处理逻辑。这在需要将数据传递给子组件时带来了困难。

解决方案: 将数据处理逻辑从 @Builder 方法中移出,放到普通的方法中执行,然后将处理结果通过参数传递给 @Builder 方法:

// 在 build() 或普通方法中处理数据
let income = this.getMonthIncome();
let expense = this.getMonthExpense();
let categoryStats = this.getCategoryStats();

// 将处理结果通过参数传递给 @Builder 方法
this.buildStatsPage(income, expense, categoryStats);

@Builder
buildStatsPage(income: number, expense: number, categoryStats: CategoryStat[]): void {
  // 纯 UI 代码
}

6.3 状态变量不能声明在 build() 方法中

ArkTS 不允许在 build() 方法中声明局部变量,这限制了在该方法中进行复杂的数据准备。

解决方案: 将数据准备工作封装在独立的普通方法中(如 getCurrentYear()getCurrentMonth()),然后在 build() 方法中直接调用这些方法:

getCurrentYear(): number {
  return this.detailDate.getFullYear();
}

getCurrentMonth(): number {
  return this.detailDate.getMonth();
}

或者在 @Builder 方法中直接使用内联表达式替代变量声明:

@Builder
buildStatCard(title: string, value: number): void {
  Column({ space: 8 }) {
    Text(title)
    Text('¥' + this.formatNumber(value))
      .fontColor(value >= 0 ? '#27AE60' : '#E74C3C')
  }
}

6.4 数据持久化的异步处理

Preferences API 的操作是异步的,需要使用 async/await 处理。在 aboutToAppear 生命周期方法中调用异步方法时,需要注意异步操作的执行时机。

解决方案:aboutToAppear 中同时调用 initPreferencesloadData,由于 loadData 内部已经包含了 Preferences 初始化的检查,即使 initPreferences 的异步操作尚未完成,loadData 也会尝试重新初始化:

aboutToAppear(): void {
  this.initPreferences();
  this.loadData();
}

loadDatasaveData 方法中,首先检查 Preferences 是否已初始化,如果没有则调用 initPreferences 进行初始化,然后继续执行后续操作。

6.5 弹窗的状态管理

弹窗的显示和隐藏通过 @State 变量控制,但弹窗内部的状态(如输入的金额和备注)需要与外部状态隔离,避免在未确认的情况下影响主界面的数据。

解决方案: 使用独立的 @State 变量管理弹窗状态:

@State showAmountDialog: boolean = false;
@State dialogAmount: string = '';
@State dialogRemark: string = '';

弹窗内部的操作只修改这些独立的状态变量,只有在点击"确认"按钮后,才将 dialogAmountdialogRemark 的值用于创建交易记录。点击"取消"按钮时,直接清空这些临时状态并关闭弹窗,不会影响主界面的数据。


七、应用价值与使用场景

7.1 个人财务管理的价值

个人财务管理是现代人生活中不可或缺的一部分。通过本应用,用户可以实现:

收支可视化: 通过首页的总金额、月收入、月支出和结余等指标,用户可以直观地了解自己的财务状况。收支对比和分类占比统计帮助用户发现消费热点,制定合理的预算计划。

消费习惯培养: 定期的记账行为本身就是一种消费反思。当用户需要手动记录每一笔支出时,会更加谨慎地对待每一次消费决策。长期坚持记账,能够有效培养量入为出的消费习惯。

历史数据查询: 明细页面支持按月份浏览历史交易记录,用户可以回顾过去的消费情况,分析消费趋势,为未来的财务规划提供数据支持。

7.2 教育价值

对于 HarmonyOS 开发者而言,本项目具有较高的学习和参考价值:

ArkTS 实战范例: 项目涵盖了 ArkTS 的核心特性,包括状态管理、组件化开发、声明式 UI、生命周期管理等,为初学者提供了完整的实战范例。

类型安全实践: 项目严格遵循 ArkTS 的类型系统约束,展示了如何在强类型环境下进行应用开发,包括接口定义、类型断言、运行时类型检查等技术。

数据持久化方案: 项目展示了如何使用 @ohos.data.preferences 进行本地数据存储,包括数据的序列化、反序列化、错误处理等关键环节。

7.3 扩展性分析

本项目的设计具有良好的扩展性,可以在此基础上添加更多功能:

数据图表: 可以集成 HarmonyOS 提供的图表组件,将收支数据以柱状图、饼图、折线图等形式展示,提升数据的可读性。

预算管理: 可以为每个分类设置月度预算,当某类支出接近或超过预算时给出提醒,帮助用户控制消费。

数据导出: 可以将交易数据导出为 CSV 或 Excel 文件,便于用户在电脑上进行更复杂的分析。

多账户支持: 可以扩展为多账户模式,支持现金、银行卡、支付宝、微信等多种支付方式的独立记账。

云同步: 可以集成华为账号服务,实现数据的云端同步,确保数据在多个设备之间保持一致。


八、总结与展望

8.1 项目总结

本文详细介绍了一个基于 HarmonyOS ArkTS 开发的个人记账本应用的技术实现。从数据模型设计到用户界面开发,从业务逻辑实现到数据持久化,涵盖了应用开发的完整生命周期。

在技术层面,本项目展示了如何在 ArkTS 的严格类型约束下进行应用开发,如何设计合理的数据模型和状态管理机制,如何实现流畅的用户交互体验。特别是在处理 @Builder 方法的纯 UI 约束、状态变量的类型限制、异步数据操作等 ArkTS 特有的问题时,项目提供了实用的解决方案。

在功能层面,应用实现了收支记录、分类统计、明细查询、数据持久化等核心功能,满足了个人财务管理的基本需求。界面设计简洁美观,操作流程清晰流畅,为用户提供了良好的使用体验。

8.2 技术展望

随着 HarmonyOS 生态的不断完善,ArkTS 开发框架也在持续演进。未来可以期待以下技术发展方向:

更丰富的组件库: HarmonyOS 将持续丰富官方组件库,提供更多开箱即用的 UI 组件,降低界面开发的工作量。

更强大的状态管理: 除了 @State,ArkTS 可能会引入更多高级状态管理方案,如跨组件状态共享、状态持久化等,满足复杂应用的需求。

更完善的开发工具: DevEco Studio 作为 HarmonyOS 的官方 IDE,将持续优化开发体验,提供更智能的代码提示、更便捷的调试工具和更完善的性能分析能力。

更广泛的多端部署: HarmonyOS 的分布式能力将进一步增强,开发者可以更轻松地实现应用在手机、平板、智慧屏、车机等多种设备上的无缝部署和协同工作。

8.3 结语

HarmonyOS 作为中国自主研发的操作系统,正以其独特的技术优势和生态价值吸引着越来越多的开发者。ArkTS 作为 HarmonyOS 应用开发的核心语言,融合了声明式 UI 和强类型系统的优势,为开发者提供了高效、可靠的开发体验。

本项目的实践表明,基于 ArkTS 开发功能完善、体验优秀的移动应用是完全可行的。虽然在开发过程中会遇到类型约束、异步处理等技术挑战,但通过合理的设计和最佳实践,这些挑战都可以得到有效解决。

对于有志于投身 HarmonyOS 生态建设的开发者而言,掌握 ArkTS 开发技术不仅是一种技术储备,更是参与国产操作系统生态建设、推动技术自主可控的重要一步。随着 HarmonyOS 生态的蓬勃发展,ArkTS 开发技能将拥有越来越广阔的应用前景和市场价值。


附录:完整代码目录结构

entry/src/main/ets/pages/
└── Index.ets          # 主页面,包含所有 UI 和逻辑

entry/src/main/resources/base/profile/
└── main_pages.json    # 页面路由配置

参考文献

  1. 华为开发者联盟. HarmonyOS 应用开发文档. https://developer.harmonyos.com/
  2. 华为开发者联盟. ArkTS 语言快速入门. https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-get-started-0000001637740666-V3
  3. 华为开发者联盟. 声明式 UI 开发指南. https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/arkts-declarative-ui-description-0000001524535265-V3
  4. TypeScript 官方文档. https://www.typescriptlang.org/docs/
  5. 华为开发者联盟. 数据管理开发指南. https://developer.harmonyos.com/cn/docs/documentation/doc-guides-V3/data-persistence-preferences-0000001505555321-V3

本文基于 HarmonyOS ArkTS 个人记账本应用的技术实践撰写,旨在为 HarmonyOS 开发者提供技术参考和学习资料。文中涉及的代码和技术方案均经过实际验证,可作为类似应用开发的参考实现。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐