从参考 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。金融金额如果直接用 Float 或 Double,迟早会遇到精度问题;展示时再把分值格式化成人民币字符串会稳妥很多。

账单和账户、分类之间也设置了外键。删除账单没有直接物理删除,而是写入 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,不使用会逐行裁切按键的展开动画。

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

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项目地址

相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui