Skip to content

About

本地记账 Android 应用 —— 自动识别微信账单,数据只存手机本地、不申请网络权限。Kotlin + Jetpack Compose + Room。

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

7 Commits

Folders and files

Repository files navigation

记账本 · 安卓自动记账 App

自动识别微信账单的本地记账应用。数据只存在手机本地,不申请网络权限,不联网、不上传。

当前版本:1.84(versionCode 95)

v1.84 将底栏安全距离、键盘避让和筛选面板统一到液态玻璃布局:键盘弹出时底栏保持在窗口底部,筛选面板的手柄、标题和选项在同一滚动区域内,底栏会按手势/三键导航安全区自适应。


一、从源码构建

1. 环境要求

项 版本
JDK 17
Android SDK Platform 37 + Build-Tools 37.0.0
Gradle 9.7.1(用仓库自带的 wrapper,无需另装)

把 JAVA_HOME 指向 JDK 17。然后自己创建工程根目录的 local.properties, 指向本机 Android SDK(该文件不入库):

sdk.dir=/path/to/Android/Sdk

2. 编译与测试

./gradlew :app:assembleDebug        # 打 debug 包(自用推荐)
./gradlew :app:assembleRelease      # 打 release 包
./gradlew :app:testDebugUnitTest    # 跑单元测试

Windows 下把 ./gradlew 换成 gradlew.bat。

产物在 app/build/outputs/apk/<变体>/。

3. 关于签名

本仓库不包含任何签名密钥。app/build.gradle.kts 从工程根的 keystore.properties 读取凭据(该文件同样不入库):

storeFile=keystore/your-release.jks
storePassword=******
keyAlias=******
keyPassword=******

自己用的话直接打 debug 包即可;要正式签名就用 Android Studio 的 「Build → Generate Signed Bundle / APK」生成一个你自己的密钥。

没有 keystore.properties 时构建不会失败,只是产出的 release APK 未签名。

wrapper 的下载源默认指向华为云镜像。海外网络可把 gradle/wrapper/gradle-wrapper.properties 里的 distributionUrl 换成 https://services.gradle.org/distributions/gradle-9.7.1-bin.zip。


二、怎么装到手机上

先按上面构建出 APK。三种装法,任选一种:

  1. 用数据线装(推荐):手机打开「开发者选项 → USB 调试」,连上电脑,然后执行
    adb install -r app/build/outputs/apk/debug/app-debug.apk
    
  2. 传到手机装:把 APK 用微信/QQ/网盘发到手机,点开安装。系统会提示「未知来源应用」,允许即可。
  3. 用 Android Studio 直接运行:打开工程,点 Run。

三、自动识别微信账单的两个途径

安卓系统不允许第三方 App 读取微信的私有数据,所以「自动识别」靠下面两条互补的路径实现。

途径 A:通知监听(实时自动记账)

原理:微信付款/收款时会推送通知,系统允许记账本读取这些通知的文字,从中解析出金额、方向、对方。

开启步骤:

  1. 打开记账本 → 首次启动的引导页点「立即授权」;或到「设置 → 通知监听服务」。
  2. 系统会跳到「通知使用权」页面,找到「记账本」,把开关打开。
  3. 回到 App,「设置 → 通知监听服务」应显示绿色的「已开启」。

关键前提(这条不满足就识别不到金额):

  • 微信的通知必须是开着的;
  • 微信里不能设置「不显示消息详情」(我 → 设置 → 消息通知 → 关闭「显示消息详情」这个开关会隐藏金额)。

识别出来的记录默认进「待确认」队列,由你审核后再入账。如果你想让它自动入账,到「设置 → 自动入账」打开开关(可以设定只对高置信度的记录自动入账)。

途径 B:导入官方账单文件(补全历史)

原理:微信提供官方的账单导出功能,导出的是加密 ZIP 文件,记账本可以直接解析。

从微信导出账单:

  1. 微信 → 我 → 服务 → 钱包 → 账单
  2. 右上角「常见问题」 → 下载账单 → 用于个人对账
  3. 选择时间范围,填邮箱,微信会把加密 ZIP 发到你邮箱
  4. 邮件里有 6 位数字解压码
  5. 手机上打开记账本 → 设置 → 导入微信账单 → 选文件 → 输入解压码

支持的格式:

  • 加密 .zip(微信邮件发来的原始文件,需要解压码)
  • 已解压的 .csv / .txt(自己解压过也能直接导入)
  • 编码自动识别:GBK 与 UTF-8 都能正确处理

导入过程:先显示预览(共解析多少条、新增多少、重复跳过多少、时间范围),确认后才写入。重复的记录会根据「微信交易单号」自动跳过,重复导入同一个文件不会产生重复账目。


四、功能一览

页面 功能
明细 按月查看,按日分组并显示当日收支小计;月份切换;搜索(对方/商品/备注);按类型、分类、账户筛选;点击编辑、可删除
记一笔 中式金额键盘;分类九宫格;指定账户;日期选择;备注;支持支出/收入/转账三种
统计 月度支出/收入/结余/日均/笔数;分类环形图 + 排行榜(点标题可在支出/收入构成间切换);近 6 个月收支趋势柱状图
待确认 通知识别出的记录,可改分类/账户后一键入账,或忽略;支持批量确认高置信度项;确认时会记住「交易对方 → 分类」,下次自动推荐
设置 通知监听开关与状态、自动入账及其置信度门槛、默认账户、通知诊断、导入账单、导出备份、分类管理、账户管理、主题(跟随系统/浅色/深色)、界面风格(经典/雅致)、清空数据

内置内容:18 个支出分类、8 个收入分类、5 个账户(微信零钱 / 零钱通 / 银行卡 / 支付宝 / 现金)、约 70 条「关键词 → 分类」规则(美团→餐饮、滴滴→交通、国家电网→生活缴费 等)。分类和账户都可以增删改。

深色模式:支持,可在设置里手动切换或跟随系统。

界面风格:与明暗模式独立,可在设置里随时切换。「雅致」是新安装的默认风格,使用更克制的墨绿与中性表面、8dp 圆角和更紧凑的间距;「经典」保留原有 16dp 卡片、配色和 emoji 空状态。已有安装在升级后保持经典,不会被动换肤。

状态栏与手势条:主界面开了 edge-to-edge,窗口铺到系统栏下方,底部手势条那一条由一层液态玻璃覆盖。内容本身仍然让出状态栏高度(contentHost 的 paddingTop),但底部刻意不让:导航栏那一条必须空着交给玻璃去盖 —— 一旦也给内容补上底部内边距,玻璃就又被挡回导航栏上方,那条位置上露出来的是窗口背景色,就是一块跟玻璃割裂的白色区域。玻璃栏按滚动方向驱动显隐(向下滚让位收起、向上滚立即恢复),从真实页面内容采样,且只改透明度、不改变任何页面的排版高度。

顶部状态栏那一层玻璃已在 v1.25 整层删除(下面两段是这件事的完整经过,保留下来是因为它是个值得记住的教训)。

顶栏渐隐的曲线形状:遮罩的不透明度沿「状态栏 → 渐隐带 → 全透明」变化,这条曲线用 smoothstep(两端导数都为 0),而不是两段直线。直线坡道走到终点时斜率仍是常数、紧接着突然归零,一阶导不连续,人眼会把坡道的末端看成一条横向分界线(马赫带效应)—— 真机上这条线正好压在月份切换卡的下边缘上。另外模糊矩形的下边界是一条几何硬边(模糊到边界就停),所以遮罩盒在渐隐带之下还多留一段 20dp 的全透明尾巴,把这条硬边埋进一个本来就什么都看不见的区域里,不让两个边界压在同一行。曲线本身抽在 ScrimMask.kt 的纯函数 scrimFadeAlpha() 里,由 GlassScrimMaskTest 直接断言导数。

状态栏那层「白」来自被加进去的颜色,不是来自参数:用户连续反馈「14:03 和 9月之间有一块白色区域」「整条状态栏都发白」「没有模糊感」。前两版(v1.21 调低玻璃底色不透明度、v1.22 把 FADE_HEIGHT 从 28dp 收到 12dp)都没解决,原因是它们只在调参,从没质疑过那层底色该不该存在。真机上量到的数据其实早就否定了「某处变亮」的假设 —— 页面背景全屏恒为 (245,242,249),最左列与最右列从 y=0 到底部逐点相同。既然背景处处一致,那「白」只能来自被叠加上去的颜色本身,而顶栏玻璃里恰好有三处会加白:

  1. onDrawSurface 画的 ?attr/colorSurface(#FFF7F2FA)底色 —— 它与页面背景色相不同(偏暖),铺上去不是「加玻璃」而是把那一条换成另一种颜色;
  2. vibrancy() 效果 —— 它的作用就是提亮增饱和,与「去掉白」完全相反;
  3. blur —— 看起来最无辜,但 backdrop 在采样前先 drawColor(colorSurface) 垫了一层不透明纯色,于是模糊采到的永远是这层纯色。模糊一个常量在数学上是恒等变换,屏幕上什么都不会发生 —— 这就是「看不出模糊」的根因。

v1.23 因此把这三处一起删掉,顶栏玻璃只剩真实模糊 + alpha 遮罩两件事:遮罩负责「实 → 虚」的过渡(即用户要的「模糊渐变」),不再叠加任何颜色,状态栏那一条自然回归页面自己的底色。FADE_HEIGHT 保持 12dp(对「遮罩从哪开始溶」而言这个值本身没问题,终点 48+12 = 60dp 正好停在月卡上缘)。

那条「白带」的真正主体:首卡的 marginTop 被 ScrollView 吞了(v1.24 修复)。删掉底色后用户仍说「还是没有改掉」,于是直接抓了一张设备原始分辨率截图(adb exec-out screencap,1268×2756)并配合 uiautomator dump 读真实 View 边界,问题一次看清:

fragmentContainer bounds=[0,156][1268,2756]   ← contentHost 的 paddingTop=48dp 生效了
月份卡 CardView    bounds=[39,156][689,280]   ← top 也是 156,与容器顶完全重合

首卡本该在「状态栏 48dp + 上间距」处,实测却紧贴状态栏下沿,间距一点没剩。原因:统计页的根布局是 NestedScrollView,ScrollView 会把首个子 View 的 layout_marginTop 整个吞掉(记账页的根是 LinearLayout,所以只有统计页出问题)。后果不只是「贴得近」—— 顶栏玻璃遮罩覆盖到 80dp,卡片顶到了 48dp,卡片的上圆角(48..64dp)整段落在遮罩里被磨平,放大图里就是「卡片被拦腰切了一刀、顶上多出一条横带」。

修法:把首卡上间距移到 NestedScrollView 自己的 android:paddingTop(它保留自己的 padding;clipToPadding="false" 保证滚动时内容仍能贴到状态栏下面),并删掉首卡的 layout_marginTop —— 留着不但无效,还会让下次排查误以为间距由它提供。新增断言在 verify_glass_edges.py §7e。

这条带子最终是靠「删掉整层」解决的(v1.24 归零 → v1.25 删除)。v1.24 把首卡修到 64dp、并把静息显现度归零之后,用户反馈是「变淡了,但那条还在」。于是直接在真机上做像素剖面,才拿到决定性证据:

x=60,从上往下逐行(无弹窗、静息态)
y=145  (246,242,249)   ← 状态栏之上,就是页面背景
y=156  (237,232,240)   ← 状态栏下沿,突然变暗
y=228  (243,239,246)
y=230  (255,255,255)   ← 首卡的下边缘白描边
y=248  (246,242,249)   ← 又是页面背景

三点结论:

  1. 不是色差。那条带子是 (238,233,241),页面背景是 (246,242,249) —— 同色系、只暗了一点点,这点差值根本不足以被读成「一条白带」。
  2. 不是位置问题。v1.24 已经把它从「压着卡片边」改成「悬在卡片上方」,它还在。
  3. 是纹理差。状态栏那一块被 blur 抹平了纹理,成了一眼均匀的平滑区;它的上下都是纯背景(本来就没有纹理)。眼睛在没有细节的地方,会把「一块更平滑的区域」读成「有一层东西盖着」—— 这就是「白纱」的真正来源。

纹理差改颜色、改位置都消不掉,它只要那层玻璃有任何非零显现度就必然存在。所以最终处理是整层删除:

  • GlassStatusBarHost 保留类名(MainActivity 还持有 binding.glassStatusBar 引用),但里面只剩空壳:不测量(高度压 0)、不绘制(setWillNotDraw(true))、不消费触摸(dispatchTouchEvent 恒 false)。contentView / scrimProgress 两个属性仍在,被赋值后不产生任何效果。
  • ScrimMask.kt 与它的单测 GlassScrimMaskTest.kt 一并删除 —— 曲线手法本身没错,但它已经不该用在这里,留着会让人以为还在生效。
  • MainActivity 里的滚动监听保留(底栏的收起/恢复仍需要它们)。

一句话总结这段历史:这条带子从 v1.15 引入到 v1.25 删除,跨了 8 个版本、动过 6 个可调参数(静息不透明度 0.45 → 0.10 → 0.35 → 0、渐隐带 18 → 28 → 12dp、遮罩曲线直线 → smoothstep),而它们全都在修同一个并不存在的问题。真正的问题不是「参数该取多少」,是「那层东西该不该存在」。

v1.26:把状态栏玻璃重新加回来(前提已修正)

删干净之后用户问「那接下来该怎么改呢」。这时候才看清旧版失败的根本前提:

旧版一直有一个从未被注意到的错误前提 —— contentHost 带着 paddingTop = 状态栏高,状态栏下面永远是空的(只有纯背景色)。而模糊一片纯色在数学上是恒等变换:那层玻璃采到的永远是没有纹理的空白,它除了把那一块抹得更平滑之外什么也没做。

也就是说,旧版的纹理差不是「模糊的副作用」,而是「它无聊到只剩下副作用」。

于是 v1.26 改掉前提、再把它加回来:

  1. 内容可以滚到状态栏底下(MainActivity.applySystemBarInsets 里 contentHost 的上内边距从 bars.top 改成 0)。让位改由各页自己的滚动容器提供(paddingTop + clipToPadding="false")—— 这样「留白」变成了滚动容器自己的一部分:静止时把内容推下来(有呼吸感),滚动时内容穿过 padding 区钻到状态栏底下(就是要的效果)。
  2. 记账页与统计页整页可滚。写法是把整页包进一个滚动容器,于是整张卡片(含上圆角)一起滚上去,而不是头部固定、列表在下面被裁。为了拿到滚动事件,新加了一个 ScrollAwareNestedScrollView:framework 的 View.setOnScrollChangeListener 对 NestedScrollView 不生效(它靠 scrollTo 移动内容),必须自己把滚动转出来。
  3. 玻璃层重新实现(GlassStatusBarHost):状态栏那 48dp 内全强度模糊(保证系统图标可读),其下再溶一段 12dp 渐隐带。三条禁令一条都没破 —— 不叠任何颜色、采样前绝不垫色、静息时整层完全不可见(静止时内容还没穿进来,那一档采到的仍是纯背景,所以静息档必须为 0)。

一句话:同一层玻璃、同一套参数,v1.15 失败而 v1.26 可行,区别只在「下面有没有内容可模糊」。

备份与恢复:Android 云备份已关闭,数据不会上传。换机或备份请使用「设置 → 导出备份」生成 CSV 文件。CSV 是纯数据导出,不包含应用设置、关键词学习规则和通知日志。


五、识别不准怎么办(重要)

微信通知的文案会随版本和场景变化,账单格式也可能调整。App 里为此准备了三层兜底:

1. 待确认队列——识别结果先进队列而不是直接入账,你可以核对、改分类、改账户后再确认。

2. 通知诊断页——「设置 → 通知诊断」会列出最近 200 条捕获到的微信原始通知,显示每条通知的完整原文、是否被识别、命中了哪条规则。

如果某笔消费没被识别到:

  1. 去诊断页找到那条通知,点右上角「复制」按钮
  2. 把复制到的内容(或提到的规则问题)提一个 Issue,规则表在 app/src/main/java/com/jizhang/app/notify/WeChatNotificationParser.kt

3. 列映射兜底——如果账单文件的表头格式变了导致自动识别失败,导入时会弹出「列映射」对话框,让你手动指定哪一列是时间、金额、收/支。


六、开发信息

技术栈:

  • Kotlin 2.3.21 + AGP 9.3.0 + Gradle 9.7.1
  • 主界面为经典 View(XML + ViewBinding)+ Material 3
  • 状态栏遮罩与液态玻璃底栏用 Jetpack Compose + kyant0/backdrop 2.0.0
  • Room 2.7.2 + KSP 2.3.6
  • zip4j 2.11.5 解压加密账单

其他:

  • minSdk 26(Android 8.0)/ targetSdk 34 / compileSdk 37
  • 图表(环形图、柱状图)用 Canvas 自绘,没有引入图表库
  • 没有用导航库和 DI 框架,界面切换是 Fragment 显隐 + 独立 Activity,依赖靠手写的 AppContainer
  • 不需要任何运行时权限(不联网、不读存储、不读联系人),只需要用户在系统设置里手动授予「通知使用权」

目录结构:

app/src/main/java/com/jizhang/app/
├── data/
│   ├── db/            Room 数据库、实体、DAO、类型转换器
│   ├── repo/          仓库层(事务/分类/账户/待确认)
│   ├── prefs/         设置项(SharedPreferences)
│   └── SeedData.kt    内置分类、账户、关键词规则
├── money/Money.kt     金额工具(内部以「分」为单位存 Long,无浮点误差)
├── util/Dates.kt      日期工具
├── importbill/        账单解析(CSV 解析器、编码识别、微信账单解析、导入用例)
├── notify/            通知监听服务与解析规则表
├── ui/                界面(明细/记一笔/统计/待确认/设置/导入/诊断/引导/分类/账户)
│   └── glass/         液态玻璃:状态栏遮罩宿主、底栏宿主、LiquidBottomBar、ScrimMask(遮罩曲线)
└── widget/            自绘图表控件

单元测试:当前 127 个用例全部通过,覆盖金额解析与键盘状态机、日期解析、 CSV 引号/逗号处理、GBK/UTF-8 编码识别、微信账单解析(表头识别、方向判定、坏行跳过、 手动列映射)、通知解析(付款/收款/转账/红包/退款/提现/还款、噪音过滤、可信来源过滤、 稳定指纹去重)、待确认入账校验(缺失金额/分类/账户、转账目标账户、重复确认防重)。


七、已知限制

  1. 真机只完成基础冒烟验证:已在 MIUI / Android 17 真机安装、启动、截图并导出过 UI 层级,但该系统的安全策略拦截了 adb shell input,所以通知落账、待确认确认/忽略、转账、导入导出、引用删除、恢复出厂、双风格切换和深色模式等完整点击回归仍需手动验。首次运行如果发现布局问题,请提 Issue 说明具体页面。
  2. 微信通知文案依赖真实版本:通知解析规则按微信常见文案构造并测试,但微信会改版,真机上捕获到的原始通知格式仍需抽查。这正是「待确认 + 诊断页」存在的原因。
  3. 只支持微信:解析器结构上支持扩展,以后要加支付宝只需再写一个解析器。
  4. 转账类通知不会自动入账:因为涉及两个账户,需要你确认转入账户,所以即使开了自动入账也会进待确认队列。
  5. 恢复出厂不可撤销:设置里的「清空所有账目」是完整恢复出厂,会删除账目、待确认、通知日志、规则、分类、账户和全部应用设置,然后回到引导页。执行前请先导出 CSV。
  6. targetSdk 设为 34 而非 35/36:Android 15 起对 targetSdk ≥ 35 的应用强制 edge-to-edge 布局。本项目暂不整体接受这套默认值 —— 全局边到边会让窗口不再被输入法顶起(adjustResize 失效),带输入框的屏幕必须先替 WindowInsetsCompat.Type.ime 做好处理。所以改成按屏 opt-in:ThemedActivity.edgeToEdge 默认 false(保持系统栏内缩的旧排版),只有主界面打开它,因为它的液态玻璃底栏必须真的一直铺到屏幕最底边、把导航栏那一条盖住;主界面自己用 WindowInsetsCompat.Type.systemBars() 给 contentHost 补内边距,并且故意不补底部(那一整条要留给玻璃)。其余屏幕维持现状,等各自的输入法 inset 处理好再逐个切换。

About

本地记账 Android 应用 —— 自动识别微信账单,数据只存手机本地、不申请网络权限。Kotlin + Jetpack Compose + Room。

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages