从参考 iCost 到做自己的 OneLedger:一个 Android 本地记账 App 的开发记录

项目地址:https://github.com/Sirens007/OneLedger-Android

当前版本:v0.1.0

技术栈:Kotlin、Jetpack Compose、Material 3、Room、Coroutines

记录时间:2026 年 7 月

最开始我只是想在 Android 上做一款类似 iCost 的记账软件。

真正开始写以后,我发现"能新增一笔支出"并不难,难的是让账单、账户余额、预算、日历和统计始终对得上;另一个难点是交互,特别是金额键盘、系统输入法和页面高度之间的关系,稍微处理不好就会跳动、闪烁,甚至同时出现两套键盘。

这个项目最后取名为 OneLedger。它目前仍是一个本地 MVP,不是已经完成的商业产品。这篇文章记录一下当前做到了什么、代码怎么组织,以及开发过程中几个比较折腾的问题。

先看现在的界面

OneLedger 的底部导航保留了四个入口:账本、资产、存钱、统计。整体使用深色背景、蓝绿主色和"票据轨道"式卡片,没有直接照搬 iCost 的页面布局。

首页主要回答两个问题:这个月还能花多少,以及最近的钱花到哪里了。账单按日期分组,预算卡可以继续进入总预算和分类预算页面。

快速记账页目前支持支出、收入和转账,也可以选择分类、账户、备注与发生时间。

收支日历会在有账单的日期下方显示红色支出和绿色收入;没有账单时显示中国节日或农历日期。月份可以通过按钮或左右滑动切换,过去和未来月份都能查看。

资产页的余额不是写死在页面上的,而是根据账户期初余额和收入、支出、转账记录聚合出来。

上面的图片来自 Compose 截图回归测试,里面使用的是固定测试数据,因此金额不代表应用首次安装后的默认数据。目前新账本的总预算默认是 ¥0.00。

项目现在用的技术

截至本文记录时,仓库中的主要版本如下:

项目 版本或方案
Kotlin 2.3.21
Android Gradle Plugin 9.3.0
Jetpack Compose BOM 2026.06.01
Material Material 3
Room 2.8.4,schema v3
Coroutines 1.10.2
minSdk / targetSdk 23 / 36
Java 17

工程仍然是单 app 模块。现在项目规模还没有大到必须拆成十几个 Gradle module,先按功能分包更容易维护:

text 复制代码
com.oneledger.app
├── data/local        Room Entity、DAO、Database、Migration
├── data/repository   Repository 接口与本地实现
├── domain/model      跨层使用的业务模型
├── ui/components     快速记账、键盘、通用组件
├── ui/screens        账本、预算、日历、资产、存钱、统计
├── ui/theme          颜色、排版和主题
└── util              金额、日期等纯函数

数据流保持得比较简单:

text 复制代码
Compose UI
    ↓ 用户操作
ViewModel / immutable UiState
    ↓
LedgerRepository
    ↓
Room DAO
    ↓
SQLite

UI 不直接调用 DAO。Repository 维护当前 activeBookId,账户、分类、账单、预算和存钱计划的查询都需要带上账本边界,避免以后增加多账本界面时出现串账。

本地优先不是一句口号

OneLedger 当前没有登录、云同步和网络请求,Manifest 中也没有申请 INTERNET 权限。应用的 allowBackup 目前设为 false,核心数据只保存在本机 Room 数据库。

数据库现在有六张核心表:

ledger_books:账本;

accounts:现金、储蓄卡、信用账户;

categories:收入和支出分类;

transactions:支出、收入和转账;

budgets:总预算和分类预算;

savings_plans:存钱计划。

我把金额统一保存为"分",类型使用 Long

kotlin 复制代码
data class TransactionEntity(
    val amountMinor: Long,
    // ...
)

例如 12.34 元 在数据库中保存为 1234。金融金额如果直接用 FloatDouble,迟早会遇到精度问题;展示时再把分值格式化成人民币字符串会稳妥很多。

账单和账户、分类之间也设置了外键。删除账单没有直接物理删除,而是写入 deletedAt 做软删除,这样才能实现 Snackbar 撤销。

最费时间的三个地方

1. 金额输入不是普通计算器

第一版数字键盘使用了常见的 previousValue + pendingOperator + currentValue 结构。问题是输入:

text 复制代码
12 + 1 -

程序会在点击减号时先算出 13,再进入下一次运算。记账场景下我更希望它像一个可编辑表达式:只有点击等号才计算。

后来我把金额状态改成 Token:

kotlin 复制代码
sealed interface ExpressionToken {
    data class NumberToken(val raw: String) : ExpressionToken
    data class OperatorToken(val operator: AmountOperator) : ExpressionToken
}

现在可以先输入:

text 复制代码
12 + 1 - 2

点击 = 后再得到 11,随后按钮才恢复为"完成"。连续点击运算符会替换末尾运算符,删除键也会按 Token 和字符逐级删除。真正计算时使用 BigDecimal,避免重新引入浮点误差。

这部分目前有 16 个 JVM 单元测试,覆盖小数、负数、连续运算符、删除、长表达式和跨越原九位数限制等情况。

2. 自定义数字键盘和系统输入法切换

金额使用自定义键盘,备注使用系统输入法。最初两套键盘分别控制显示和隐藏,结果出现过这些问题:

  • 点击备注后,自定义键盘还留在底部;
  • Activity 已经因为 IME 缩小,Compose 又额外添加 imePadding,键盘高度被计算两次;
  • 页面先经历一次"没有任何键盘"的布局,再弹出新键盘;
  • 分类区域被压扁,中间出现很大的空白;
  • 快速来回点击金额和备注时,页面上下抖动。

最后没有靠增加延迟遮住问题,而是把键盘状态收敛到一个互斥状态机:

kotlin 复制代码
enum class KeyboardMode {
    NONE,
    CUSTOM_NUMBER,
    SYSTEM_IME,
    CUSTOM_TO_SYSTEM,
    SYSTEM_TO_CUSTOM,
}

Activity 使用 adjustResize 处理系统 IME 高度,Compose 不再重复预留 IME 空间。自定义键盘保持固定完整高度,只做整体 translationY + alpha,不使用会逐行裁切按键的展开动画。

这部分最重要的经验是:输入焦点、键盘模式和底部占用高度必须由同一套状态驱动 。如果分别维护 showCustomKeyboardisImeVisiblefocusedField,很容易出现状态互相打架。

3. 日历要跟手,不能在每一帧做重活

收支日历一开始用自定义拖动和位移动画实现分页。慢慢拖还能用,快速连续滑动时就会有分段推进、标题与页面不同步的问题。

现在改成了 Compose 原生 HorizontalPager

  • currentPage 驱动月份标题;
  • settledPage 只在页面停稳后提交选中月份;
  • 相邻月份提前加载一页;
  • 每个月固定 42 个日期格,也就是六行,避免月份高度变化;
  • 日期格使用稳定 key;
  • 月份数组、节日和农历结果按 YearMonth 缓存;
  • 农历计算和交易汇总放到 Dispatchers.Default,不堵住拖动帧。

切换月份时会尽量保留原来的日号,例如从 1 月 31 日切到 2 月会选中 2 月最后一天,再切到 3 月仍可回到 31 日,而不是每次重置为 1 号。

数据库升级也需要认真对待

项目目前是 Room schema v3,并保留了显式迁移:

kotlin 复制代码
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)

v1 到 v2 增加了交易/预算关系约束和预算周期唯一索引。v2 到 v3 则处理了一个很小但实际会影响用户的细节:早期演示数据把月预算默认设成了 ¥5,000,后来恢复为 ¥0。

迁移没有简单地把所有 ¥5,000 预算清零,而是只更新"默认账本、旧演示 ID、总预算、金额为 500000 分、创建时间和更新时间相同"的记录。只要用户编辑过预算,updatedAt 就会变化,迁移会保留这条数据。

这种修改看上去很小,但如果直接写一句:

sql 复制代码
UPDATE budgets SET limitMinor = 0 WHERE limitMinor = 500000;

就会把用户自己设置的 ¥5,000 预算一起改掉。

目前做了哪些测试

我不敢说现在测试已经很完整,但项目不是只靠手点:

  • 38 个 JVM 单元测试,当前失败数为 0;
  • 14 个 Compose 截图回归场景,覆盖四个主页面、预算、日历、快速记账、表达式、深浅主题和日期时间选择;
  • Room Migration 和 DAO 使用 Android instrumentation test;
  • Lint、Debug 构建和 Android test 源码编译作为提交前门禁。

JVM 测试现在主要覆盖:

  • 金额格式化和表达式状态机;
  • 自定义键盘与系统 IME 的互斥状态;
  • 日期、跨月日历和农历标签;
  • 净资产是否排除不计入净值的账户;
  • 账单修改、转账和数据校验。

截图测试使用固定数据,所以界面变化会直接反映在基准图中。只有确认视觉修改正确后才更新基准,不会为了让测试通过直接覆盖图片。

如何运行

克隆仓库:

bash 复制代码
git clone https://github.com/Sirens007/OneLedger-Android.git
cd OneLedger-Android

用 Android Studio 打开,等待 Gradle Sync 完成后运行 app。项目要求 JDK 17。

Windows 下可以执行:

powershell 复制代码
.\gradlew.bat assembleDebug testDebugUnitTest lintDebug validateDebugScreenshotTest compileDebugAndroidTestKotlin

涉及 Room Migration 或 DAO 时,还需要连接允许安装测试 APK 的设备:

powershell 复制代码
.\gradlew.bat connectedDebugAndroidTest

还没有做完的部分

这部分我想写得直接一点。当前 v0.1 已经形成记账、编辑、删除、预算、日历、资产和统计的基本闭环,但离可以长期替代成熟记账软件还有距离:

  • 账户新增、编辑、归档和账户详情还没有完整做完;
  • 分类管理 UI 还没有开放;
  • 首页虽然有搜索入口,完整筛选查询还在后续计划中;
  • 存钱页目前更接近可交互原型,还缺少每次存款的流水表;
  • 多账本的数据边界已经预留,但创建和切换 UI 尚未完成;
  • CSV/JSON 导入导出、加密备份、应用锁还没有实现;
  • 统计还有一部分聚合需要继续下沉到 DAO;
  • 真机性能、TalkBack、平板和横屏适配仍需系统测试;
  • 当前没有云同步、OCR 和 AI 自动入账。

下一阶段会先把核心回归测试和性能门禁补齐,再按独立分支完成账户、分类、搜索、导入导出和存钱流水,不准备一次塞进一个大分支。

最后

OneLedger 最开始确实参考了 iCost 的功能思路,但做了一段时间后,我更在意的是数据边界、输入效率和 Android 自己的交互体验,而不是把另一个产品的页面复制出来。

这个项目现在大约有 29 个主源码 Kotlin 文件、6800 多行 Kotlin 代码,规模不算大,但已经碰到了金额精度、数据库迁移、输入法、分页性能、截图回归这些真实问题。对我来说,它比再写一个简单 CRUD Demo 更有学习价值。

仓库使用 Apache-2.0 License。如果你也在学习 Kotlin、Jetpack Compose 或 Room,欢迎查看代码、提交 Issue,或者一起讨论更合适的实现方式:

OneLedger项目地址

相关推荐
qq_448011161 小时前
C语言中的变量和函数的定义与声明
android·c语言·开发语言
码农coding5 小时前
android 12 中的VSYNC的请求
android
阿pin5 小时前
Android随笔-Activity
android·activity
Hrain-AI5 小时前
2026 企业 AI 编程智能体实战:Codex 与 Claude Code
开发语言·人工智能·kotlin
YF02115 小时前
详解Android所有文件访问权限
android
时间的拾荒人7 小时前
MySQL C语言连接 - 从入门到实战
android·c语言·mysql
Meteors.7 小时前
Android性能优化:01. 指标体系 + 分析工具链
android
jike_20268 小时前
安卓平台免费录音转文字工具深度测评:5 款 APP 功能对比与选型指南
android·智能电视
Code Man9 小时前
Windows 下使用 Appium
android·windows·appium