Android 开发4 min4 次浏览

码达 toCode:让取件码自己走进手机

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

码达 toCode:让取件码自己走进手机
目录(展开)

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

码达 toCode

项目背景

每个网购过的人都被快递通知折磨过:短信被淹没在验证码和营销消息里,取件时要在聊天记录里翻半天;同时收到五条不同驿站的通知,哪条对应哪个包裹全靠脑补;取完件,通知还得手动去删。

市面上的"取件码"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 前置存储)。

识别引擎是码达的灵魂,两层结构——关键词初筛与三级取件码抽取——值得单独一篇展开:一条通知里的取件码:码达的混合识别引擎。视觉层的液态玻璃实现见 液态玻璃与性能降级。

相 关 文 章

3 篇

已运行 —北京时间 --:--:--

© 2026 风起|永远相信美好的事情即将发生!