自动识别微信账单的本地记账应用。数据只存在手机本地,不申请网络权限,不联网、不上传。
当前版本:1.84(versionCode 95)
v1.84 将底栏安全距离、键盘避让和筛选面板统一到液态玻璃布局:键盘弹出时底栏保持在窗口底部,筛选面板的手柄、标题和选项在同一滚动区域内,底栏会按手势/三键导航安全区自适应。
| 项 | 版本 |
|---|---|
| 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./gradlew :app:assembleDebug # 打 debug 包(自用推荐)
./gradlew :app:assembleRelease # 打 release 包
./gradlew :app:testDebugUnitTest # 跑单元测试Windows 下把 ./gradlew 换成 gradlew.bat。
产物在 app/build/outputs/apk/<变体>/。
本仓库不包含任何签名密钥。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。三种装法,任选一种:
- 用数据线装(推荐):手机打开「开发者选项 → USB 调试」,连上电脑,然后执行
adb install -r app/build/outputs/apk/debug/app-debug.apk - 传到手机装:把 APK 用微信/QQ/网盘发到手机,点开安装。系统会提示「未知来源应用」,允许即可。
- 用 Android Studio 直接运行:打开工程,点 Run。
安卓系统不允许第三方 App 读取微信的私有数据,所以「自动识别」靠下面两条互补的路径实现。
原理:微信付款/收款时会推送通知,系统允许记账本读取这些通知的文字,从中解析出金额、方向、对方。
开启步骤:
- 打开记账本 → 首次启动的引导页点「立即授权」;或到「设置 → 通知监听服务」。
- 系统会跳到「通知使用权」页面,找到「记账本」,把开关打开。
- 回到 App,「设置 → 通知监听服务」应显示绿色的「已开启」。
关键前提(这条不满足就识别不到金额):
- 微信的通知必须是开着的;
- 微信里不能设置「不显示消息详情」(我 → 设置 → 消息通知 → 关闭「显示消息详情」这个开关会隐藏金额)。
识别出来的记录默认进「待确认」队列,由你审核后再入账。如果你想让它自动入账,到「设置 → 自动入账」打开开关(可以设定只对高置信度的记录自动入账)。
原理:微信提供官方的账单导出功能,导出的是加密 ZIP 文件,记账本可以直接解析。
从微信导出账单:
- 微信 → 我 → 服务 → 钱包 → 账单
- 右上角「常见问题」 → 下载账单 → 用于个人对账
- 选择时间范围,填邮箱,微信会把加密 ZIP 发到你邮箱
- 邮件里有 6 位数字解压码
- 手机上打开记账本 → 设置 → 导入微信账单 → 选文件 → 输入解压码
支持的格式:
- 加密
.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 到底部逐点相同。既然背景处处一致,那「白」只能来自被叠加上去的颜色本身,而顶栏玻璃里恰好有三处会加白:
onDrawSurface画的?attr/colorSurface(#FFF7F2FA)底色 —— 它与页面背景色相不同(偏暖),铺上去不是「加玻璃」而是把那一条换成另一种颜色;vibrancy()效果 —— 它的作用就是提亮增饱和,与「去掉白」完全相反;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) ← 又是页面背景
三点结论:
- 不是色差。那条带子是
(238,233,241),页面背景是(246,242,249)—— 同色系、只暗了一点点,这点差值根本不足以被读成「一条白带」。 - 不是位置问题。v1.24 已经把它从「压着卡片边」改成「悬在卡片上方」,它还在。
- 是纹理差。状态栏那一块被
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),而它们全都在修同一个并不存在的问题。真正的问题不是「参数该取多少」,是「那层东西该不该存在」。
删干净之后用户问「那接下来该怎么改呢」。这时候才看清旧版失败的根本前提:
旧版一直有一个从未被注意到的错误前提 ——
contentHost带着paddingTop = 状态栏高,状态栏下面永远是空的(只有纯背景色)。而模糊一片纯色在数学上是恒等变换:那层玻璃采到的永远是没有纹理的空白,它除了把那一块抹得更平滑之外什么也没做。也就是说,旧版的纹理差不是「模糊的副作用」,而是「它无聊到只剩下副作用」。
于是 v1.26 改掉前提、再把它加回来:
- 内容可以滚到状态栏底下(
MainActivity.applySystemBarInsets里contentHost的上内边距从bars.top改成0)。让位改由各页自己的滚动容器提供(paddingTop+clipToPadding="false")—— 这样「留白」变成了滚动容器自己的一部分:静止时把内容推下来(有呼吸感),滚动时内容穿过 padding 区钻到状态栏底下(就是要的效果)。 - 记账页与统计页整页可滚。写法是把整页包进一个滚动容器,于是整张卡片(含上圆角)一起滚上去,而不是头部固定、列表在下面被裁。为了拿到滚动事件,新加了一个
ScrollAwareNestedScrollView:framework 的View.setOnScrollChangeListener对NestedScrollView不生效(它靠scrollTo移动内容),必须自己把滚动转出来。 - 玻璃层重新实现(
GlassStatusBarHost):状态栏那 48dp 内全强度模糊(保证系统图标可读),其下再溶一段 12dp 渐隐带。三条禁令一条都没破 —— 不叠任何颜色、采样前绝不垫色、静息时整层完全不可见(静止时内容还没穿进来,那一档采到的仍是纯背景,所以静息档必须为 0)。
一句话:同一层玻璃、同一套参数,v1.15 失败而 v1.26 可行,区别只在「下面有没有内容可模糊」。
备份与恢复:Android 云备份已关闭,数据不会上传。换机或备份请使用「设置 → 导出备份」生成 CSV 文件。CSV 是纯数据导出,不包含应用设置、关键词学习规则和通知日志。
微信通知的文案会随版本和场景变化,账单格式也可能调整。App 里为此准备了三层兜底:
1. 待确认队列——识别结果先进队列而不是直接入账,你可以核对、改分类、改账户后再确认。
2. 通知诊断页——「设置 → 通知诊断」会列出最近 200 条捕获到的微信原始通知,显示每条通知的完整原文、是否被识别、命中了哪条规则。
如果某笔消费没被识别到:
- 去诊断页找到那条通知,点右上角「复制」按钮
- 把复制到的内容(或提到的规则问题)提一个 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 编码识别、微信账单解析(表头识别、方向判定、坏行跳过、 手动列映射)、通知解析(付款/收款/转账/红包/退款/提现/还款、噪音过滤、可信来源过滤、 稳定指纹去重)、待确认入账校验(缺失金额/分类/账户、转账目标账户、重复确认防重)。
- 真机只完成基础冒烟验证:已在 MIUI / Android 17 真机安装、启动、截图并导出过 UI 层级,但该系统的安全策略拦截了
adb shell input,所以通知落账、待确认确认/忽略、转账、导入导出、引用删除、恢复出厂、双风格切换和深色模式等完整点击回归仍需手动验。首次运行如果发现布局问题,请提 Issue 说明具体页面。 - 微信通知文案依赖真实版本:通知解析规则按微信常见文案构造并测试,但微信会改版,真机上捕获到的原始通知格式仍需抽查。这正是「待确认 + 诊断页」存在的原因。
- 只支持微信:解析器结构上支持扩展,以后要加支付宝只需再写一个解析器。
- 转账类通知不会自动入账:因为涉及两个账户,需要你确认转入账户,所以即使开了自动入账也会进待确认队列。
- 恢复出厂不可撤销:设置里的「清空所有账目」是完整恢复出厂,会删除账目、待确认、通知日志、规则、分类、账户和全部应用设置,然后回到引导页。执行前请先导出 CSV。
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 处理好再逐个切换。