从零到上线:用 ArkUI 打造 HarmonyOS 5.0 记账应用全流程实战

一、前言

说起记账这件事,大多数人都有过三分钟热度的经历------下载了好几个记账 App,记了几天就没了动力。我自己也是这样,后来索性想:干脆用 HarmonyOS 5.0 自己写一个,功能轻一点,用起来顺手就好。

这篇文章就是记录整个开发过程,从项目结构的规划,到数据持久化的实现,再到界面的搭建。用到的技术主要是 ArkUI 声明式框架和 HarmonyOS 提供的 Preferences 接口,没有引入任何第三方库,也没有依赖云端服务,所有数据都存在本地。

整体功能聚焦在三个页面:首页展示账单列表和收支总览、添加账单页面、以及一个简单的分类统计页。代码量控制在一个人可以几小时内读完的范围,适合正在学习鸿蒙开发的朋友作为上手练习项目。


二、工程准备

2.1 开发环境

开始之前,确保以下环境已就绪:

  • DevEco Studio NEXT(Build Version 5.0.5.200 或以上)
  • HarmonyOS SDK:API Level 12(对应 HarmonyOS 5.0.0)
  • 运行设备:Phone 类型模拟器或真机

在 DevEco Studio 的 SDK Manager 中确认已安装对应版本的 SDK,API Level 12 是 HarmonyOS 5.0.0 正式版对应的接口版本,本文所有代码均基于此版本验证。

2.2 新建工程

打开 DevEco Studio,选择「Create Project」→「Application」→「Empty Ability」,配置如下:

复制代码
Project name:BillBook
Bundle name:com.yourname.billbook
Compatible SDK:5.0.0(12)
Module type:Phone

工程创建完成后,默认会生成一个 EntryAbility 和一个 Index 页面,这是我们后续开发的起点。

2.3 工程目录结构

按照功能划分,整个项目的 ets 源码目录组织如下:

复制代码
entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets
├── model/
│   ├── BillItem.ets        // 账单数据模型
│   └── BillStorage.ets     // Preferences 存储封装
├── pages/
│   ├── Index.ets           // 首页(账单列表)
│   ├── AddBill.ets         // 添加账单
│   └── Statistics.ets      // 统计页面
└── components/
    ├── BillCard.ets        // 账单卡片组件
    └── CategoryTag.ets     // 分类标签组件

这样划分的好处是,model 层只管数据逻辑,pages 层只管页面展示,components 里放可复用的小组件。后期维护起来思路比较清晰,各层职责边界也很明确。


三、数据模型与持久化

3.1 账单数据结构

先定义账单的数据结构。一条账单记录包含:唯一 id、类型(收入/支出)、金额、分类、备注和时间戳。

这里有一个细节值得注意:金额统一用「分」作为存储单位(整数),而不是「元」(浮点数)。这样可以避免 JavaScript 浮点数运算带来的精度问题,比如 0.1 + 0.2 !== 0.3 这类经典问题在记账场景里是不能接受的。

复制代码
// model/BillItem.ets
export interface BillItem {
  id: string;
  type: "income" | "expense";   // 收入或支出
  amount: number;               // 金额(分为单位)
  category: string;             // 分类
  note: string;                 // 备注
  date: number;                 // 时间戳
}

// 支出分类
export const EXPENSE_CATEGORIES = [
  "餐饮", "交通", "购物", "娱乐",
  "医疗", "住房", "教育", "其他"
];

// 收入分类
export const INCOME_CATEGORIES = [
  "工资", "兼职", "投资", "礼金", "其他"
];

3.2 Preferences 存储封装

HarmonyOS 提供的 @ohos.data.preferences 是一个轻量的键值对本地存储方案,非常适合存储这类结构化程度不高、数据量不太大的数据。我们把所有账单序列化为 JSON 字符串,存在一个 key 下面。

封装一个 BillStorage 工具类,对外提供 init、loadAll、saveAll、addBill、deleteBill 几个静态方法:

复制代码
// model/BillStorage.ets
import dataPreferences from "@ohos.data.preferences";
import { BillItem } from "./BillItem";

const PREF_NAME = "bill_data";
const BILLS_KEY  = "bills";

export class BillStorage {
  private static prefs: dataPreferences.Preferences | null = null;

  // 初始化 Preferences 实例
  static async init(context: Context): Promise<void> {
    BillStorage.prefs = await dataPreferences.getPreferences(context, PREF_NAME);
  }

  // 读取所有账单
  static async loadAll(): Promise<BillItem[]> {
    if (!BillStorage.prefs) return [];
    const raw = await BillStorage.prefs.get(BILLS_KEY, "[]") as string;
    return JSON.parse(raw) as BillItem[];
  }

  // 保存账单列表
  static async saveAll(bills: BillItem[]): Promise<void> {
    if (!BillStorage.prefs) return;
    await BillStorage.prefs.put(BILLS_KEY, JSON.stringify(bills));
    await BillStorage.prefs.flush();
  }

  // 新增一条账单
  static async addBill(item: BillItem): Promise<void> {
    const bills = await BillStorage.loadAll();
    bills.unshift(item);        // 最新的放在最前面
    await BillStorage.saveAll(bills);
  }

  // 删除指定 id 的账单
  static async deleteBill(id: string): Promise<void> {
    let bills = await BillStorage.loadAll();
    bills = bills.filter(b => b.id !== id);
    await BillStorage.saveAll(bills);
  }
}

需要特别说明的是 flush() 这个调用------preferences.put() 只是把数据写入内存,flush() 才会真正持久化到磁盘。如果应用意外崩溃而没有 flush,数据就会丢失,这是初学者很容易忽略的地方。

BillStorage.init() 需要在 EntryAbility 的 onWindowStageCreate 中调用,把 context 传进去:

复制代码
// EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage) {
  BillStorage.init(this.context);
  windowStage.loadContent("pages/Index");
}

这样能确保整个应用生命周期中只初始化一次,且在任何页面加载之前就已经完成。


四、页面实现

4.1 首页:账单列表与总览

首页分为上下两个区域:顶部是一个带渐变背景的总览卡片,展示当前的总收入和总支出;下方是账单列表,用 List + ForEach 渲染每一条记录。

页面每次进入时(onPageShow)都会重新从 Preferences 读取数据,这样添加完账单返回后能立即看到最新结果,不需要手动管理刷新状态。

复制代码
// pages/Index.ets
import router from "@ohos.router";
import { BillItem } from "../model/BillItem";
import { BillStorage } from "../model/BillStorage";
import { BillCard } from "../components/BillCard";

@Entry
@Component
struct Index {
  @State bills: BillItem[] = [];
  @State totalIncome: number = 0;
  @State totalExpense: number = 0;

  // 页面每次出现时刷新数据
  async onPageShow() {
    this.bills = await BillStorage.loadAll();
    this.calcSummary();
  }

  calcSummary() {
    this.totalIncome  = this.bills
      .filter(b => b.type === "income")
      .reduce((s, b) => s + b.amount, 0);
    this.totalExpense = this.bills
      .filter(b => b.type === "expense")
      .reduce((s, b) => s + b.amount, 0);
  }

  build() {
    Column() {
      // ---- 顶部总览卡片 ----
      Column() {
        Text("我的账本").fontSize(20).fontWeight(FontWeight.Bold).fontColor(Color.White)
        Row() {
          Column() {
            Text("收入").fontColor("#b3d8ff").fontSize(13)
            Text(`¥${(this.totalIncome / 100).toFixed(2)}`)
              .fontColor(Color.White).fontSize(22).fontWeight(FontWeight.Bold)
          }
          Divider().vertical(true).height(36).color("#ffffff44").margin({ left: 24, right: 24 })
          Column() {
            Text("支出").fontColor("#ffb3b3").fontSize(13)
            Text(`¥${(this.totalExpense / 100).toFixed(2)}`)
              .fontColor(Color.White).fontSize(22).fontWeight(FontWeight.Bold)
          }
        }
        .margin({ top: 16 })
      }
      .width("100%")
      .padding(24)
      .linearGradient({ angle: 135, colors: [["#3b82f6", 0], ["#6366f1", 1]] })

      // ---- 账单列表 ----
      if (this.bills.length === 0) {
        Text("暂无账单,点击右下角 + 添加")
          .fontSize(14).fontColor("#9ca3af").margin({ top: 60 })
      } else {
        List({ space: 8 }) {
          ForEach(this.bills, (item: BillItem) => {
            ListItem() {
              BillCard({ item: item, onDelete: async () => {
                await BillStorage.deleteBill(item.id);
                this.bills = await BillStorage.loadAll();
                this.calcSummary();
              }})
            }
          }, (item: BillItem) => item.id)
        }
        .width("100%")
        .layoutWeight(1)
        .padding({ left: 16, right: 16, top: 8 })
      }

      // ---- 悬浮添加按钮 ----
      Button("+").fontSize(28).fontColor(Color.White)
        .width(56).height(56).borderRadius(28)
        .backgroundColor("#3b82f6")
        .position({ x: "83%", y: "88%" })
        .onClick(() => {
          router.pushUrl({ url: "pages/AddBill" });
        })
    }
    .height("100%").backgroundColor("#f3f4f6")
  }
}

4.2 账单卡片组件

BillCard 是一个可复用的自定义组件,负责渲染单条账单。通过长按手势触发删除确认弹窗,这比添加一个单独的删除按钮更节省空间,交互也更自然。

分类和对应 Emoji 图标的映射用一个简单的 Record 来维护,比写一大堆 if-else 要干净很多:

复制代码
// components/BillCard.ets
import { BillItem } from "../model/BillItem";

@Component
export struct BillCard {
  item: BillItem = {} as BillItem;
  onDelete: () => void = () => {};

  build() {
    Row() {
      // 分类图标
      Text(this.getCategoryIcon())
        .fontSize(24).width(44).height(44)
        .textAlign(TextAlign.Center)
        .backgroundColor("#eff6ff")
        .borderRadius(22)

      Column() {
        Text(this.item.category).fontSize(15).fontWeight(FontWeight.Medium)
        Text(this.item.note || "无备注").fontSize(12).fontColor("#9ca3af")
        Text(this.formatDate()).fontSize(11).fontColor("#d1d5db")
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })

      // 金额,收入绿色支出红色
      Text(`${this.item.type === "income" ? "+" : "-"}¥${(this.item.amount / 100).toFixed(2)}`)
        .fontSize(16).fontWeight(FontWeight.Bold)
        .fontColor(this.item.type === "income" ? "#22c55e" : "#ef4444")
    }
    .width("100%").padding(14)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .gesture(LongPressGesture({ duration: 600 }).onAction(() => {
      AlertDialog.show({
        title: "删除账单",
        message: "确定要删除这条账单吗?",
        confirm: { value: "删除", action: () => this.onDelete() }
      });
    }))
  }

  getCategoryIcon(): string {
    const map: Record<string, string> = {
      "餐饮": "🍜", "交通": "🚇", "购物": "🛍️", "娱乐": "🎬",
      "医疗": "💊", "住房": "🏠", "教育": "📚", "工资": "💰",
      "兼职": "💼", "投资": "📈", "礼金": "🎁"
    };
    return map[this.item.category] ?? "📝";
  }

  formatDate(): string {
    const d = new Date(this.item.date);
    return `${d.getMonth()+1}月${d.getDate()}日`;
  }
}

4.3 添加账单页

这个页面承担了主要的数据录入工作:选择收入/支出类型、输入金额、选择分类,以及填写备注。

类型切换通过 @State billType 控制,切换后 categories 的 getter 会自动返回对应的分类列表,ForEach 也跟着重新渲染,不需要任何额外操作------这就是声明式 UI 的优雅之处。

复制代码
// pages/AddBill.ets
import router from "@ohos.router";
import promptAction from "@ohos.promptAction";
import { BillItem, EXPENSE_CATEGORIES, INCOME_CATEGORIES } from "../model/BillItem";
import { BillStorage } from "../model/BillStorage";

@Entry
@Component
struct AddBill {
  @State billType: "income" | "expense" = "expense";
  @State amountText: string = "";
  @State selectedCategory: string = "餐饮";
  @State note: string = "";

  get categories() {
    return this.billType === "expense" ? EXPENSE_CATEGORIES : INCOME_CATEGORIES;
  }

  async save() {
    // 金额格式校验
    const valid = /^\d+(\.\d{0,2})?$/.test(this.amountText);
    if (!valid || this.amountText === "") {
      promptAction.showToast({ message: "请输入正确金额" });
      return;
    }
    const amount = Math.round(parseFloat(this.amountText) * 100);
    if (amount <= 0) {
      promptAction.showToast({ message: "金额必须大于 0" });
      return;
    }
    const item: BillItem = {
      id: Date.now().toString(),
      type: this.billType,
      amount,
      category: this.selectedCategory,
      note: this.note,
      date: Date.now()
    };
    await BillStorage.addBill(item);
    router.back();
  }

  build() {
    Column() {
      // 页面标题
      Text("记一笔").fontSize(20).fontWeight(FontWeight.Bold).margin({ top: 24, bottom: 24 })

      // 收入/支出切换
      Row() {
        ForEach(["expense", "income"] as ("expense" | "income")[], (t: "expense" | "income") => {
          Text(t === "expense" ? "支出" : "收入")
            .fontSize(15)
            .fontColor(this.billType === t ? Color.White : "#6b7280")
            .backgroundColor(this.billType === t
              ? (t === "expense" ? "#ef4444" : "#22c55e")
              : Color.Transparent)
            .padding({ left: 28, right: 28, top: 8, bottom: 8 })
            .borderRadius(20)
            .onClick(() => {
              this.billType = t;
              this.selectedCategory = this.categories[0];
            })
        })
      }
      .backgroundColor(Color.White)
      .borderRadius(24)
      .padding(4)

      // 金额输入
      Row() {
        Text("¥").fontSize(28).fontColor("#374151").margin({ right: 4 })
        TextInput({ placeholder: "0.00", text: this.amountText })
          .type(InputType.Number)
          .fontSize(36).textAlign(TextAlign.Start)
          .backgroundColor(Color.Transparent)
          .border({ width: 0 })
          .layoutWeight(1)
          .onChange(v => this.amountText = v)
      }
      .width("100%")
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
      .backgroundColor(Color.White)
      .borderRadius(12)
      .margin({ top: 20 })

      // 分类选择
      Text("选择分类").fontSize(13).fontColor("#9ca3af")
        .alignSelf(ItemAlign.Start).margin({ top: 20, bottom: 8 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.categories, (cat: string) => {
          Text(cat)
            .padding({ left: 16, right: 16, top: 6, bottom: 6 })
            .borderRadius(16)
            .fontSize(14)
            .margin(4)
            .backgroundColor(this.selectedCategory === cat ? "#dbeafe" : "#f3f4f6")
            .fontColor(this.selectedCategory === cat ? "#2563eb" : "#374151")
            .onClick(() => this.selectedCategory = cat)
        })
      }
      .width("100%")

      // 备注输入
      Text("备注").fontSize(13).fontColor("#9ca3af")
        .alignSelf(ItemAlign.Start).margin({ top: 20, bottom: 8 })
      TextInput({ placeholder: "添加备注(选填)" })
        .onChange(v => this.note = v)
        .backgroundColor(Color.White)
        .borderRadius(12)
        .width("100%")

      // 保存按钮
      Button("保存").width("100%").height(48)
        .backgroundColor("#3b82f6").fontColor(Color.White)
        .borderRadius(12).margin({ top: 32 })
        .onClick(() => this.save())
    }
    .padding(24).height("100%").backgroundColor("#f3f4f6")
    .alignItems(HorizontalAlign.Center)
  }
}

五、统计功能:Canvas 绘制柱状图

统计页面把支出按分类汇总,用横向柱状图展示每个分类的消费占比。HarmonyOS 的 Canvas 组件提供了一套接近 Web Canvas API 的绘图接口,用起来对前端开发者来说很容易上手。

先写一个统计计算方法,按分类汇总金额并降序排列:

复制代码
// Statistics.ets - 数据汇总方法
calcExpenseByCategory(): { category: string; total: number }[] {
  const map = new Map<string, number>();
  this.bills
    .filter(b => b.type === "expense")
    .forEach(b => {
      map.set(b.category, (map.get(b.category) ?? 0) + b.amount);
    });
  return Array.from(map.entries())
    .map(([category, total]) => ({ category, total }))
    .sort((a, b) => b.total - a.total);
}

Canvas 的绘制逻辑如下,需要注意的是绘图代码必须放在 onReady 回调里,否则此时 Canvas 的宽高还没有确定,绘制会异常:

复制代码
// Statistics.ets - Canvas 绘制部分
@State private settings: RenderingContextSettings = new RenderingContextSettings(true);
@State private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

// 在 build() 中
Canvas(this.ctx)
  .width("100%").height(280)
  .backgroundColor(Color.White)
  .borderRadius(12)
  .onReady(() => {
    this.drawBarChart(this.ctx, this.calcExpenseByCategory());
  })

// 绘图函数
drawBarChart(ctx: CanvasRenderingContext2D, data: { category: string; total: number }[]) {
  const maxVal = Math.max(...data.map(d => d.total));
  const barH = 28;
  const gap = 16;
  const labelW = 56;
  const chartW = ctx.width - labelW - 24;
  ctx.clearRect(0, 0, ctx.width, ctx.height);

  data.slice(0, 6).forEach((d, i) => {
    const y = i * (barH + gap) + 16;
    const barLen = maxVal > 0 ? (d.total / maxVal) * chartW : 0;

    // 分类标签
    ctx.fillStyle = "#374151";
    ctx.font = "13px sans-serif";
    ctx.fillText(d.category, 0, y + barH - 6);

    // 背景条
    ctx.fillStyle = "#f3f4f6";
    ctx.beginPath();
    ctx.roundRect(labelW, y, chartW, barH, 4);
    ctx.fill();

    // 数值条
    ctx.fillStyle = "#6366f1";
    ctx.beginPath();
    ctx.roundRect(labelW, y, barLen, barH, 4);
    ctx.fill();

    // 金额文字
    ctx.fillStyle = "#ffffff";
    ctx.font = "12px sans-serif";
    if (barLen > 50) {
      ctx.fillText(`¥${(d.total / 100).toFixed(0)}`, labelW + barLen - 48, y + barH - 8);
    }
  });
}

完整的 Statistics 页面还需要在 onPageShow 里加载账单数据,和首页保持一致,这里不再重复。


六、开发过程中踩的几个坑

6.1 Preferences 必须在页面加载前初始化

最开始我把 BillStorage.init() 放在 Index 页面的 aboutToAppear 里,结果 onPageShow 比 init 更早触发,第一次进入页面时 prefs 还是 null,数据读不出来,页面显示空列表。

正确做法是在 EntryAbility 的 onWindowStageCreate 里调用 init,确保整个应用生命周期中只初始化一次,且在任何页面加载之前就完成。这个时序问题不注意很容易踩。

6.2 ForEach 的 key 函数不能省

在账单列表的 ForEach 里,第三个参数 key 函数 (item: BillItem) => item.id 一定要写。如果省略,删除某一条记录后,框架可能无法准确判断哪个 ListItem 发生了变化,出现列表渲染错乱或者动画异常的问题。

复制代码
// 正确写法:提供唯一 key
ForEach(this.bills, (item: BillItem) => {
  ListItem() { BillCard({ item }) }
}, (item: BillItem) => item.id)   // ← 这个不能省

6.3 数字输入的边界处理

TextInput 设置了 InputType.Number,但某些输入法仍然可以绕过限制输入 "1.2.3" 这样不合法的字符串。建议在保存前统一用正则做一次校验,而不是只依赖 parseFloat 的结果:

复制代码
const valid = /^\d+(\.\d{0,2})?$/.test(this.amountText);
if (!valid || this.amountText === "") {
  promptAction.showToast({ message: "请输入正确金额" });
  return;
}

另外 Number("") 在 JavaScript 里返回 0 而不是 NaN,所以不能用它来判断用户是否没有输入,需要额外检查空字符串。

6.4 长按手势与滚动手势的冲突

列表项上同时有 LongPressGesture 和 List 的滚动手势,快速滑动时偶尔会意外触发长按删除弹窗。解决方案是给 LongPressGesture 设置一个合理的触发时长阈值:

复制代码
// 600ms 才触发,正常滑动不会误触
LongPressGesture({ duration: 600 }).onAction(() => {
  // 弹出删除确认
})

600ms 是一个比较合适的阈值,用户有明确的长按意图才会触发,普通滑动和短按都不会误触。

6.5 Canvas 内容刷新的时机

统计页的柱状图数据是动态的,每次进入页面都可能不同。但 Canvas 不像普通组件那样跟着 @State 自动刷新,需要在数据更新后手动重新调用绘制方法:

复制代码
async onPageShow() {
  this.bills = await BillStorage.loadAll();
  // 数据更新后重新绘制
  this.drawBarChart(this.ctx, this.calcExpenseByCategory());
}

七、整体运行效果

在 DevEco Studio 中连接 Phone 模拟器(API Level 12),点击运行按钮,首次编译需要等待约一分钟。应用启动后可以走完如下完整流程:

  1. 首次打开:列表区域显示「暂无账单,点击右下角 + 添加」的引导文字,顶部收支均为 ¥0.00
  2. 添加支出:点击右下角 + 按钮,进入添加页,默认为支出模式,选择「餐饮」,输入 38.5,保存返回
  3. 列表更新:回到首页,账单卡片出现,顶部支出金额更新为 ¥38.50
  4. 切换收入:再添加一条「工资」收入,首页顶部收入金额同步显示
  5. 删除操作:长按某条账单超过 600ms,弹出确认弹窗,确认后账单消失,总额更新
  6. 统计页面:切换到统计标签,柱状图按支出金额降序展示各分类

整个交互流程比较流畅,Preferences 的读写速度在数据量不大的情况下基本感知不到延迟。


八、总结

做完这个小项目,最直观的感受是 HarmonyOS 5.0 的 ArkUI 框架已经很成熟。声明式 UI 的模式让界面逻辑变得清晰,@State 驱动刷新这套机制用顺手之后效率确实比传统命令式写法高不少,很多地方可以少写很多手动更新视图的代码。

Preferences 接口对于这种轻量的本地存储需求来说已经够用,API 简洁,异步操作规范。如果后续数据量增大,或者需要支持按日期范围查询、多条件过滤等复杂场景,可以考虑换用 relationalStore(关系型数据库)接口,鸿蒙的 API 设计风格保持一致,迁移成本不高。

整个项目的代码量大约在 400 行左右,涵盖了多页面跳转、自定义组件封装、状态管理、数据持久化、Canvas 绘图这几个常用的技术点。如果你正在学习 HarmonyOS 应用开发,这个项目可以作为一个不错的练手起点,在此基础上扩展月度统计、数据导出、多账本等功能也不会太复杂。

欢迎有问题在评论区交流,踩过的坑能少踩一个是一个。

相关推荐
程序猿追2 小时前
HarmonyOS 6.0 PC 实战:从零构建一款高性能 Markdown 生产力工具
华为·harmonyos
key_3_feng2 小时前
鸿蒙元服务ArkTS开发方案
华为·harmonyos
Gorit3 小时前
使用 AI + Flutter-OH 开发 HarmonyOS 应用
人工智能·flutter·harmonyos
程序猿追3 小时前
声影共振:深度解析 HarmonyOS PC 端高刷音频视觉化引擎的底层实现
华为·音视频·harmonyos
UnicornDev18 小时前
【HarmonyOS 6】空状态页面布局设计
华为·harmonyos·arkts·鸿蒙·鸿蒙系统
互联网散修1 天前
零基础鸿蒙应用开发第十九节:解锁灵活数据存储新技能Map/Set
harmonyos
枫叶丹41 天前
【HarmonyOS 6.0】ArkData 应用间配置共享:构建跨应用协作新范式
开发语言·华为·harmonyos
互联网散修1 天前
零基础鸿蒙应用开发第十八节:内置泛型工具类型应用
harmonyos
轻口味1 天前
HarmonyOS 6 自定义人脸识别模型8:MindSpore Lite框架介绍与使用
c++·华为·ai·harmonyos