用 ArkTS 做一个记账小工具

一、要做什么:一个不丢账的记账小工具

三句话讲完:一个 ArkTS 单机记账工具,记一笔(金额、类目、备注)、按月看流水、删错账;不登录、不联网,数据只活在本机。功能一分钟说完,但“账不能丢”这个约束会让每个看起来很小的决策都变重——没有云端兜底,持久化选错了、状态刷新漏了,丢的就是用户真金白银的记录。所以本文重心不在界面怎么摆(布局看上一篇《ArkUI 布局与导航》),而在三条决策链:本地持久化怎么选、状态管理用 V1 还是 V2、列表怎么在千条流水下不卡。

环境默认就绪(《鸿蒙环境搭建与工具链》),DevEco Studio 能真机或模拟器跑起应用。开工前把验收指标定死,每条带”怎么测”:

指标 目标 怎么测
数据持久 杀进程重开一条不丢 录 3 笔 → 从任务切换器划掉应用 → 重开核对
离线可用 无网状态功能完整 开飞行模式走完一轮记、查、删
输入校验 非法金额不落库 金额输 abc / 0 / -5 点记一笔:应弹提示且列表无新行
千条流畅 千条流水滚动无感知卡顿(定性目标) 灌 1000 条后用 release 包真机滚列表,DevEco Profiler 看帧率
汇总正确 分类合计 = 流水总和 对照页面上的分类汇总与手工加总

二、整体架构:三层就够,别上全家桶

分层一条线:

UI(@Entry 页面 + RecordRow 列表项)
 └─ 视图模型(页面内的 @Local 状态 + 数据源 RecordSource)  // 谁持有"正在显示什么"
     └─ 仓库层(LedgerStore,全应用唯一摸数据库的类)      // 查询、事务、游标全关在这层
         └─ 持久层(RelationalStore 存流水 + preferences 存偏好)

纪律只有一条:UI 不直接碰数据库连接,仓库层不 import 任何页面组件。这条线画清楚后,换状态方案、换存储方案,各层互不连坐。

克制建议也放这里:不要第一个项目就上分布式数据同步、全局 AppStorage 满天飞、自研 ORM。单机记账没有多端一致性需求,AppStorage 滥用等于把全局变量合法化;等真有跨设备诉求再上分布式 KV 不迟。瓶颈从来不在框架能力上限,在你对问题本身的理解。

三、决策链一:持久化,RelationalStore 还是 preferences

鸿蒙本地持久化两件套:RelationalStore(关系型数据库,底层 SQLite)与 preferences(用户首选项,键值对)。不是二选一,是各管一摊,先把边界摆开:

维度 preferences 首选项 RelationalStore 关系库
数据模型 Key-Value,值是 number/string/boolean 及数组 表、行、列,完整 SQL
内存行为 全量加载进内存,数据越多占用越大 按查询取用,游标逐行读
查询能力 只能按 key 取,没有条件查询 WHERE / 索引 / GROUP BY / 事务
量级建议 配置类小数据,社区共识别过万条 结构化业务数据,万级起步无压力
典型内容 深色模式、默认类目、上次选中月份 账单流水、聊天记录、订单

记账的主数据是流水:要按月过滤、按类目汇总、按时间倒序——这三件事 SQL 一句话的事,键值库要全量拉进内存自己筛。流水进 RelationalStore,偏好进 preferences,这个选型其实没有悬念。反模式点名:把流水存 preferences。它每条都是独立 key、无事务、全量驻内存,千条之后每次启动的加载和每次写入的 flush 都是成本,而且”记两笔字段更新到一半被杀”在无事务的存储里就是脏数据。

仓库层完整实现(可运行,注意看注释里的”为什么”):

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

export class LedgerRecord {
  id: number = 0;
  amount: number = 0;
  category: string = '';
  note: string = '';
  createdAt: number = 0;
}

export class LedgerStore {
  private rdb?: relationalStore.RdbStore;

  async init(context: common.UIAbilityContext): Promise<void> {
    this.rdb = await relationalStore.getRdbStore(context, {
      name: 'ledger.db',
      securityLevel: relationalStore.SecurityLevel.S1  // 本机私有数据,最低敏级即可
    });
    await this.rdb.executeSql(`CREATE TABLE IF NOT EXISTS records (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      amount REAL NOT NULL,
      category TEXT NOT NULL,
      note TEXT NOT NULL DEFAULT '',
      created_at INTEGER NOT NULL)`);
    await this.rdb.executeSql(
      'CREATE INDEX IF NOT EXISTS idx_created ON records(created_at)');  // 按月查全表扫描变索引定位
  }

  async add(amount: number, category: string, note: string): Promise<void> {
    await this.rdb!.insert('records', {
      amount: amount, category: category, note: note, created_at: Date.now()
    });
  }

  async deleteById(id: number): Promise<void> {
    const p = new relationalStore.RdbPredicates('records').equalTo('id', id);
    await this.rdb!.delete(p);
  }

  async listAll(): Promise<LedgerRecord[]> {
    const p = new relationalStore.RdbPredicates('records')
      .orderByDesc('created_at');  // 排序交给 SQL,别拉回来再 sort
    const rs = await this.rdb!.query(p, ['id', 'amount', 'category', 'note', 'created_at']);
    const list: LedgerRecord[] = [];
    while (rs.goToNextRow()) {
      const r = new LedgerRecord();
      r.id = rs.getLong(rs.getColumnIndex('id'));
      r.amount = rs.getDouble(rs.getColumnIndex('amount'));
      r.category = rs.getString(rs.getColumnIndex('category'));
      r.note = rs.getString(rs.getColumnIndex('note'));
      r.createdAt = rs.getLong(rs.getColumnIndex('created_at'));
      list.push(r);
    }
    rs.close();  // ResultSet 不关,游标句柄一直占着,列表页反复刷新就会累积
    return list;
  }

  async summaryByCategory(): Promise<Map<string, number>> {
    // 汇总这种事是 SQL 的主场:GROUP BY 在库里做完,只把几行结果带回内存
    const rs = await this.rdb!.querySql(
      'SELECT category, SUM(amount) AS total FROM records GROUP BY category');
    const map = new Map<string, number>();
    while (rs.goToNextRow()) {
      map.set(rs.getString(rs.getColumnIndex('category')),
        rs.getDouble(rs.getColumnIndex('total')));
    }
    rs.close();
    return map;
  }

  async seedForTest(n: number): Promise<void> {
    // 压测灌数据:事务包起来。逐条自动提交一千次,慢一个数量级还容易中途失败
    this.rdb!.beginTransaction();
    try {
      for (let i = 0; i < n; i++) {
        await this.add(10 + i % 90, i % 3 === 0 ? '餐饮' : (i % 3 === 1 ? '交通' : '购物'), '压测数据');
      }
      this.rdb!.commit();
    } catch (e) {
      this.rdb!.rollback();
      throw e;
    }
  }
}

preferences 这摊也给了,存”默认类目”这类真偏好:

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

const pf = await preferences.getPreferences(context, { name: 'ledger_prefs' });
await pf.put('defaultCategory', '餐饮');
await pf.flush();  // flush 之前数据只在内存,杀进程就丢——首选项最经典的坑
const saved = await pf.get('defaultCategory', '餐饮') as string;

四、决策链二:状态管理,V1 还是 V2

ArkTS 状态管理有两代并存,这是鸿蒙当前最需要”如实标注取舍”的选型。两边现状:

维度 V1(@State/@Prop/@Link/@Provide) V2(@Local/@Param/@Event/@ObservedV2/@Trace)
可用性 早期即有,全部版本可写 API 12 起提供并稳定
更新粒度 组件级,深层数组元素、嵌套属性变更常观察不到 属性级依赖追踪,@Trace 标到哪刷到哪
教程生态 存量博客、第三方示例大多还是 V1 官方主推但社区资料仍少
混用规则 同一组件内两代装饰器不能混用;组件树层面 V1/V2 组件可以互相嵌套

本项目的取舍:新工程、compileSdk 直接以当前 Release 的 API 12 为目标,整页统一 V2 写法(@Entry 搭 @ComponentV2 完全成立)。代价也如实说:网上搜来的示例九成是 @State 写法,你要会做”翻译”——@State 对应 @Local、父子传值从赋值变成 @Param + @Event。翻译规则就这两条,成本可控。

另一个取舍在细粒度上:V2 的正路是模型类挂 @ObservedV2 + @Trace 做属性级刷新,但那要求每处改数据都走模型方法。记账场景每次操作后都整表重读(refresh 全量替换数组引用),简单直接,千条量级毫无压力——我们选引用替换,放弃属性级追踪,这是有意识的简化而不是不会用。页面完整实现:

@Entry
@ComponentV2
struct IndexPage {
  @Local total: number = 0;               // @Local:V2 里替代 @State 的组件内部状态
  @Local input: string = '';
  @Local category: string = '餐饮';
  @Local summary: string = '';
  store: LedgerStore = new LedgerStore();
  source: RecordSource = new RecordSource();   // LazyForEach 的数据源,上一篇讲过

  aboutToAppear(): void {
    // 建库完成后再拉数据,顺序不能反:先查询会把"表还不存在"炸在你脸上
    this.store.init(getContext(this) as common.UIAbilityContext)
      .then(() => this.refresh());
  }

  refresh(): Promise<void> {
    return this.store.listAll().then(list => {
      this.source.reload(list);           // 数据源通知 LazyForEach 重载,页面随之重建
      return this.store.summaryByCategory();
    }).then(map => {
      let t = 0;
      let s = '';
      map.forEach((v: number, k: string) => { t += v; s += `${k} ¥${v.toFixed(2)}  `; });
      this.total = t;                     // 汇总也来自数据库,不来自前端累加,两边永远一致
      this.summary = s;
    });
  }

  build() {
    Column({ space: 12 }) {
      Text(`合计 ¥${this.total.toFixed(2)}`).fontSize(18)
      Text(this.summary).fontSize(13).fontColor('#666')
      Row({ space: 8 }) {
        TextInput({ placeholder: '金额' })
          .type(InputType.NUMBER_DECIMAL)
          .onChange(v => this.input = v)
          .layoutWeight(1)
        Select([{ value: '餐饮' }, { value: '交通' }, { value: '购物' }])
          .selected(0)
          .onSelect((_idx: number, value: string) => this.category = value)
      }
      Button('记一笔')
        .width('100%')
        .onClick(() => {
          const amount = Number(this.input);
          if (Number.isNaN(amount) || amount <= 0) {
            // 校验挡在写入之前,库里就不会有脏数据;V2 用 UIContext 拿提示能力
            this.getUIContext().getPromptAction().showToast({ message: '金额不合法' });
            return;
          }
          this.store.add(amount, this.category, '').then(() => this.refresh());
        })
      List({ space: 8 }) {
        LazyForEach(this.source, (item: LedgerRecord) => {
          RecordRow({
            record: item,
            onRemove: (id: number) => {
              this.store.deleteById(id).then(() => this.refresh());
            }
          })
        }, (item: LedgerRecord) => item.id.toString())
      }
      .cachedCount(6)
      .layoutWeight(1)   // 吃掉剩余高度,列表区域有界才能触发懒加载
      .divider({ strokeWidth: 1, color: '#EEEEEE' })
    }
    .padding(16)
    .height('100%')
  }
}

@ComponentV2
struct RecordRow {
  @Param record: LedgerRecord = new LedgerRecord();  // @Param:父传子的只读入参
  @Event onRemove: (id: number) => void = () => {};  // @Event:子组件的回调出口
  build() {
    Row({ space: 8 }) {
      Column({ space: 2 }) {
        Text(this.record.category).fontSize(14)
        Text(this.record.note).fontSize(12).fontColor('#999')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text(`¥${this.record.amount.toFixed(2)}`).fontSize(14)
      Button('删')
        .controlSize(ControlSize.NORMAL)
        .onClick(() => this.onRemove(this.record.id))
    }
  }
}

反模式点名两处。其一:在 @ComponentV2 里写 @State、@Link——两代装饰器同组件混用直接编译不过,正确动作是整组件切换,嵌套时父 V2 子 V1(或反过来)可以,但要清楚数据只在引用变化时流动。其二:把查询写进 build 或.aboutToAppear 之外的重建路径——build 每次刷新都会跑,查询塞进去等于每个动画帧打一次数据库,正确位置是事件回调末尾统一走 refresh()。

五、决策链三:列表性能,千条是个分水岭

LazyForEach + cachedCount + 键值用业务主键,这套在上一篇已拆透,这里只讲本项目的取舍。千条流水:LazyForEach 足够,不需要组件复用。复用(V1 的 @Reusable、自带复用的 Repeat 组件)解决的是”列表项组件很重、反复创建销毁开销大”,记账的行组件就两个文本一个按钮,复用带来的收益低于引入的复杂度。先量化再优化:Profiler 里滚动帧率没问题,就不要为想象中的瓶颈写代码。真到十万条流水的量级,该做的是分页查询(LIMIT/OFFSET 或游标按月翻页),那也是数据库的活,不是列表组件的活。

六、验证:照表逐条过,别凭感觉

验收项 操作 预期
持久 录 3 笔 → 划掉进程 → 重开 3 笔全在,合计一致
校验 金额分别输 abc、0、-5 后记一笔 toast 提示,列表与合计无变化
删除 删 1 笔 列表移除,合计立即减去该笔
千条压测 调 seedForTest(1000) → release 包真机滚动 无白块、无感知卡顿(定性)
汇总正确 对照 summaryByCategory 与手工加总 两类数字一致

两个测试口径必须说死:一是性能一律用 release 包,debug 包的校验和日志会拖垮帧率,测出来的”卡”不是应用的卡;二是持久化测试必须杀进程,开发期的热重载保留页面状态,测的是内存不是磁盘。

七、总结与边界

本项目教会了三件事:持久化按数据性质分家(结构化流水进 RelationalStore,偏好进 preferences,判断标准是要不要查询与事务);状态管理新工程统一 V2,会做 V1 示例的翻译即可,粒度取舍跟着数据量走;列表性能先 LazyForEach 后量化,复用与分页都是”测出来需要”才上。边界也如实:这个工具不做多端同步与图表(那是下一个量级的事),千条以上要分页。下一篇《鸿蒙 ArkTS 学习路线》会把系列六篇串成一条线,走完这条线的下一个动作是脱离教程做个变体——读书清单、打卡器,决策链自己走一遍才算你的。API 细节随用随查《ArkTS 参考字典》。

版本核实(2025-01):HarmonyOS 当前商用版本为 HarmonyOS NEXT 5.0(API 12),relationalStore 与 preferences 自 API 9 起提供、状态管理 V2 装饰器(@ComponentV2/@Local/@Param/@Event/@ObservedV2/@Trace)自 API 12 起稳定;文中”V1/V2 同组件不可混用、组件树可嵌套”以华为官方状态管理文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇