码达 toCode:让取件码自己走进手机
监听系统通知栏的取件码工具:Channel 异步管道、10 分钟 LRU 去重、同码合并与智能导入恢复,Room 四索引支撑双列表查询,零原生依赖的极简技术栈。

本文是「码达 toCode」项目系列的第一篇:一款从系统通知里"偷"取件码的 Android 工具。先讲它解决什么问题、有哪些功能,架构与数据设计全貌。

项目背景
每个网购过的人都被快递通知折磨过:短信被淹没在验证码和营销消息里,取件时要在聊天记录里翻半天;同时收到五条不同驿站的通知,哪条对应哪个包裹全靠脑补;取完件,通知还得手动去删。
市面上的"取件码"App 大多依赖短信权限或官方对接,限制多、隐私敏感。码达换了一条路:监听系统通知栏。所有快递通知(短信 App、菜鸟、丰巢、快递 100 等发到通知栏的消息)都会经过它的识别引擎,取件码、快递公司、取件地点被自动提取、结构化入库。通知不该被复制粘贴,应该被"收走"。
项目为个人独立开发,kotlin 源码约 73 个文件,已迭代 10 个版本(v1.5),配套 6 个单测模块覆盖识别引擎与数据层。代码已开源:github.com/Fq77/toCode(MIT License)。
功能特性
- 全自动捕获:无需复制粘贴,通知到达即提取,取件成功一键划走;
- 智能识别:关键词 + 正则 + 可选 AI 三级识别(下篇详解),支持 21 家快递公司识别与取件地点解析;
- 待取 / 已完成双列表管理,取完的包裹自动归档,支持批量操作与一键清空;
- 全局搜索:取件码、快递公司、原始通知文本任意字段模糊搜索;
- 数据导出 / 导入:JSON 备份与恢复,恢复时按取件码智能合并(后述);
- 可自定义:自定义关键词、是否启用 AI、数字兜底开关、深浅色主题、悬浮底栏等十余项设置;
- 本地化:无账号、无云端,数据只留在设备里。
技术栈
| 分类 | 选型 |
|---|---|
| 语言 / UI | Kotlin + Jetpack Compose(compileSdk 37 / minSdk 24) |
| 组件库 | Miuix(MIUI 风格 Compose 组件)+ 自研液态玻璃特效层 |
| 数据 | Room + DataStore Preferences |
| 异步 | Kotlin Coroutines + Channel 生产者-消费者队列 |
| AI(可选) | 兼容 OpenAI Chat Completions 协议的任意端点,OkHttp-free(纯 HttpURLConnection) |
| 构建 | KSP、kotlinx.serialization、Gradle Version Catalog |
架构与数据流
核心数据流是一条精心设计的异步管道:
flowchart LR
A["系统通知<br/>NotificationListenerService"] -->|"trySend"| B["Channel 容量 64"]
B --> C["单协程消费 Worker"]
C --> D{"10 分钟窗口<br/>LRU 去重"}
D --> E{"关键词初筛"}
E --> F{"开启 AI?"}
F -- 是 --> G["AI 抽取"]
G -- 失败 --> H["本地正则兜底"]
F -- 否 --> H
H --> I{"同码已存在?"}
I -- 否 --> J["INSERT + 系统通知"]
I -- 是,内容有变 --> K["UPDATE 合并"]
I -- 是,无变化 --> L["静默丢弃"]
ExpressNotificationService 继承 NotificationListenerService,onNotificationPosted() 只做一件事:取标题与正文,trySend 进 Channel(64) 立即返回。真正的解析在独立的单协程 Worker 里逐条串行消费——监听回调要快(它是主线程回调),解析可以慢,两者用 Channel 解耦,突发一百条通知也不会阻塞系统。
生产端还有一层10 分钟 LRU 去重(LinkedHashMap(accessOrder=true) + 定期清理过期键):同一条通知在系统层的重复回调、驿站的重发提醒,都只处理一次。
数据设计
Room 实体 ExpressItem 围绕查询模式建了 4 个索引(code+is_completed、is_completed、completed_at、timestamp),列表页两种排序(待取按时间倒序、已完成按完成时间倒序)都走索引:
@Entity(tableName = "express_items", indices = [
Index(value = ["code", "is_completed"]), Index(value = ["is_completed"]),
Index(value = ["completed_at"]), Index(value = ["timestamp"])
])
data class ExpressItem(
val code: String, val courier: String, val rawText: String,
val location: String?, val sourcePackage: String?,
val recognizedBy: String = "local", // local | ai
...
)
同码合并是仓库层最有意思的逻辑(ExpressRepository.addItem):新通知的取件码如果已存在且未完成,不插入新记录,而是比对 rawText / location / courier / recognizedBy——有任何字段更新(比如驿站补发了更完整的地点信息)就原地 UPDATE,保留首次时间戳。用户看到的是"一个包裹一条记录",信息却始终是最新版。
导入恢复(restore)同样按取件码合并:本地与备份包按"最近活跃时间"(max(timestamp, completedAt))取新者,地点字段取非空者,createdAt 取更早者。换机迁移时新旧两台设备的数据不会产生重复行。
数据库已到 version 3,两次迁移(location 列、recognized_by 列)都是增量 ALTER TABLE,老用户升级无感。
依赖与工程化
刻意保持依赖极简:没有 Retrofit、没有 Coil、没有任何图片库,整个项目零原生依赖,R8 收完体积很小。AI 客户端用 HttpURLConnection + kotlinx.serialization 手写(50 行),换来的好处是任何"OpenAI 兼容"端点都能填进去用。DataStore 里存 AI Key 前,先过一遍 SecretCipher(AndroidKeyStore 硬件密钥 + AES/GCM,IV 前置存储)。
识别引擎是码达的灵魂,两层结构——关键词初筛与三级取件码抽取——值得单独一篇展开:一条通知里的取件码:码达的混合识别引擎。视觉层的液态玻璃实现见 液态玻璃与性能降级。
