记花 ——让花销变得更有计划 文章目录记花 — 自动记账 App一、项目背景1.1 需求分析1.2 技术选型1.2.1 跨端框架1.2.2 原生模块1.2.3 项目结构二、核心实现2.1 通知自动记账核心卖点2.1.1 正则模板引擎2.1.2 通知解析主函数2.1.3 通知去重机制2.1.4 关键词过滤2.2 数据库层2.2.1 Schema 定义与增量迁移2.2.2 Zustand 状态管理2.3 周期记账2.3.1 自动补记逻辑2.4 数据可视化2.4.1 Skia 自绘环形图2.5 数据导出导入2.5.1 CSV 导出兼容 Excel 中文2.5.2 JSON 导入跨设备迁移2.6 深色模式2.7 预算管理三、原生模块Kotlin3.1 厂商 ROM 适配3.2 通知服务重启绑定四、数据库设计五、核心亮点与设计思考5.1 通知监听的自愈机制5.2 支付推送解析Normalize → 多规则 → 优先级5.3 厂商 ROM 适配逐级 Fallback 策略5.4 Skia 环形图极小占比的视觉保底5.5 周期记账确定性主键 守卫式补记5.6 数据库并发安全Promise 缓存而非实例缓存六、总结与展望6.1 项目成果6.2 未来优化方向记花 — 自动记账 App整理时间2026-08-05一、项目背景1.1 需求分析日常记账最大的痛点不是想记而是忘了记。每次消费后打开 App、选分类、填金额、写备注整个过程至少 10 秒——多一步就多一分放弃的可能。记花的核心思路让系统替你记你只管确认。通过监听支付宝/微信/银行的支付推送通知自动解析金额、商户、分类弹出确认 Sheet用户一键确认即可完成记账。将记账成本从 10 秒降到 2 秒。1.2 技术选型1.2.1 跨端框架技术说明React Native 0.86 Expo 57Android 优先Expo 简化原生模块开发TypeScript全量 TS 编写类型安全expo-router基于文件的路由expo-sqlite本地 SQLiteWAL 模式Zustand3 个 Store记录/应用/主题shopify/react-native-skia自绘 DonutChartvictory-native趋势折线图gorhom/bottom-sheet确认记账 Sheetreact-native-reanimated流畅手势动画react-native-mmkv主题/设置轻量存储expo-file-system expo-sharingCSV/JSON 导出导入1.2.2 原生模块技术说明KotlinExpo Module通知监听、悬浮窗、保活、厂商适配NotificationListenerService系统级通知监听AlarmManager精确定时任务System Alert Window悬浮窗速记1.2.3 项目结构payRecord/ ├── app/ # 页面expo-router 文件路由 │ ├── _layout.tsx # 根布局Bottom Tabs │ ├── index.tsx # 首页仪表盘 │ ├── add.tsx # 手动记账 │ ├── search.tsx # 搜索 │ ├── budget.tsx # 预算管理 │ ├── recurring.tsx # 周期记账 │ ├── report.tsx # 月度报告 │ ├── settings.tsx # 设置 │ └── [id].tsx # 记录详情 ├── components/ # 公共组件 │ ├── DonutChart.tsx # Skia 环形图 │ ├── ConfirmSheet.tsx # 记账确认弹窗 │ ├── DraggableFab.tsx # 可拖拽浮动按钮 │ └── EasterEggModal.tsx # 彩蛋弹窗 ├── modules/ │ ├── db/ # 数据库层 │ │ ├── schema.ts # Schema 迁移 │ │ ├── records.ts # 记录 CRUD │ │ ├── recurrings.ts # 周期记录 │ │ └── budgets.ts # 预算 │ ├── notification/ # 通知监听JS 桥接层 │ ├── parser/ # 推送解析引擎 │ │ ├── index.ts │ │ └── templates.ts # 正则模板定义 │ └── recurring/ # 周期记账逻辑 ├── stores/ # Zustand Store │ ├── useRecordStore.ts # 记录状态 │ ├── useAppStore.ts # 应用设置 │ └── useThemeStore.ts # 主题 ├── constants/ # 常量 │ ├── categories.ts # 分类定义图标/颜色 │ └── colors.ts # 主题配色 ├── utils/ │ ├── date.ts # 日期工具 │ └── export.ts # CSV/JSON 导出导入 └── android/ # Kotlin 原生模块 └── PayRecordNotificationModule.kt二、核心实现2.1 通知自动记账核心卖点流程用户支付 → 支付宝/微信/银行推送通知 → App 的 NotificationListenerService 捕获 → JS 层正则模板引擎解析 → 弹出确认 Sheet金额/商户/分类预填→ 用户确认 → 存入 SQLite2.1.1 正则模板引擎/** * 解析模板定义 * 每个模板包含名称、可选 appId、一组正则规则 * 每条规则定义正则表达式、金额捕获组序号、类型、商户/渠道捕获组 */ export interface ParseTemplate { name: string; appId?: string; patterns: { regex: string; flags?: string; amountGroup: number; type: expense | income; merchantGroup?: number; channelGroup?: number; }[]; } export const DEFAULT_TEMPLATES: ParseTemplate[] [ { name: 支付宝支出, appId: com.eg.android.AlipayGphone, patterns: [ { regex: 支付宝.*?付款([\\d,.])元.*?(?:给|向|在)(.?)(?:|\\.|$), flags: s, amountGroup: 1, type: expense, merchantGroup: 2 }, { regex: 支出([\\d,.])元.*?[-—](.?)(?:|\\.|$), flags: s, amountGroup: 1, type: expense, merchantGroup: 2 }, ], }, { name: 微信支付, appId: com.tencent.mm, patterns: [ // 红包 { regex: 微信红包.*?([\\d,.])(?:元|块钱), flags: s, amountGroup: 1, type: expense }, // 微信支付合并通知如 [4条]微信支付已支付21.12 { regex: 微信支付.*?(?:已支付|支付)¥([\\d,.]), flags: s, amountGroup: 1, type: expense }, { regex: 微信支付.*?¥([\\d,.]), flags: s, amountGroup: 1, type: expense }, // 带商户名的支付 { regex: 微信支付.*?([\\d,.])(?:元|块钱).*?(?:给|向|转出|转账|收款方)(.?)(?:|\\.|$), flags: s, amountGroup: 1, type: expense, merchantGroup: 2 }, // 微信收款/收红包 { regex: 微信收款.*?([\\d,.])(?:元|块钱), flags: s, amountGroup: 1, type: income }, { regex: (?:收到|入账)([\\d,.])(?:元|块钱), flags: s, amountGroup: 1, type: income }, // ... 共 12 条规则覆盖微信全场景 ], }, // 银行消费、美团/饿了么、通用转账/转出、通用退款等模板 ];2.1.2 通知解析主函数export function parseNotification(raw: string, sourceApp?: string): ParsedNotification | null { // 1. Normalize全角→半角¥多空白→单空白 const normalized raw.replace(/[¥]/g, ¥); const text normalized.replace(/\s/g, ).trim(); // 2. 按模板顺序匹配首个命中即返回 for (const template of DEFAULT_TEMPLATES) { // 如果模板指定了 appId只处理对应 App 的通知 if (template.appId sourceApp template.appId ! sourceApp) continue; for (const pattern of template.patterns) { const regex new RegExp(pattern.regex, pattern.flags ?? ); const match text.match(regex); if (!match) continue; // 3. 提取金额 const amount parseAmount(match[pattern.amountGroup]); if (amount 0) continue; // 4. 提取商户名和渠道可选 const merchant pattern.merchantGroup ! null ? match[pattern.merchantGroup]?.trim() : undefined; const channel pattern.channelGroup ! null ? match[pattern.channelGroup]?.trim() : template.name; return { amount, type: pattern.type, merchant, channel, sourceApp, raw, matchedRule: template.name, confidence: 0.8, // 正则匹配 高置信度 }; } } return null; // 无匹配 }2.1.3 通知去重机制// 去重同一条通知在 5 秒内被系统重复投递时自动过滤 const recentNotifications new Mapstring, number(); const DEDUP_WINDOW_MS 5000; const DEDUP_MAX_SIZE 50; function getDedupKey(payload: NotificationPayload): string { // 复合 keypackageName timestamp text // 连续两笔同文案消费的 postTime 不同不会被误杀 return ${payload.packageName}:${payload.timestamp}:${payload.text}; } function isDuplicate(payload: NotificationPayload): boolean { const key getDedupKey(payload); const lastTime recentNotifications.get(key); if (lastTime ! null Math.abs(payload.timestamp - lastTime) DEDUP_WINDOW_MS) { return true; } // LRU 淘汰超过 50 条时清理过期条目 if (recentNotifications.size DEDUP_MAX_SIZE) { const now Date.now(); for (const [k, t] of recentNotifications) { if (now - t DEDUP_WINDOW_MS * 2) recentNotifications.delete(k); } } recentNotifications.set(key, payload.timestamp); return false; }2.1.4 关键词过滤// 收付款关键词命中即解析覆盖银行场景 const PAYMENT_KEYWORDS [ 付款, 支付, 收款, 到账, 转账, 红包, 消费, 支出, 转出, 扣款, 汇款, 取款, 存入, ]; // 忽略关键词即使命中支付词也跳过 const IGNORE_KEYWORDS [ 发起, 处理中, 充值成功, 到账提醒, 话费, 流量, 缴费成功, 自动充值, 余额, 已确认收款, 对方已收款, 还款日, 账单日, 待还款, 还款提醒, 信用卡, 额度, 积分, 优惠券到期, 卡包, ]; export function handleNotification(payload: NotificationPayload) { if (isDuplicate(payload)) return; const content ${payload.title} ${payload.text}; const hitPay PAYMENT_KEYWORDS.some((k) content.includes(k)); const hitIgnore IGNORE_KEYWORDS.some((k) content.includes(k)); if (!hitPay) return; // 非支付通知忽略 if (hitIgnore) return; // 命中忽略词跳过 const parsed parseNotification(content, payload.packageName); if (parsed) { listener?.(parsed); // 正则匹配成功弹窗预填 } else { // 无金额匹配构造兜底对象金额留空让用户填 listener?.({ amount: null, type: expense, merchant: payload.title || 未知来源, confidence: 0.3, // 低置信度 } as ParsedNotification); } }2.2 数据库层2.2.1 Schema 定义与增量迁移import * as SQLite from expo-sqlite; const DB_NAME payrecord.db; // 缓存 Promise 而不是实例保证并发调用只打开一次数据库 let dbPromise: PromiseSQLite.SQLiteDatabase | null null; export async function getDb(): PromiseSQLiteDatabase { if (!dbPromise) { dbPromise (async () { const database await SQLite.openDatabaseAsync(DB_NAME); await migrate(database); return database; })(); } return dbPromise; } // 检测表是否有某列用于增量迁移判断 async function hasColumn(database: SQLite.SQLiteDatabase, table: string, column: string): Promiseboolean { const rows await database.getAllAsync{ name: string }(PRAGMA table_info(${table})); return rows.some((r) r.name column); } async function migrate(database: SQLite.SQLiteDatabase) { await database.execAsync( PRAGMA journal_mode WAL; CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY NOT NULL, amount REAL NOT NULL, type TEXT NOT NULL, category_id TEXT NOT NULL, merchant TEXT, channel TEXT, source_app TEXT, raw_notification TEXT, note TEXT, created_at INTEGER NOT NULL, confirmed_at INTEGER, status TEXT NOT NULL DEFAULT confirmed, expired_at INTEGER, related_id TEXT, platform TEXT ); CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at); CREATE INDEX IF NOT EXISTS idx_records_category ON records(category_id); CREATE INDEX IF NOT EXISTS idx_records_status ON records(status); CREATE INDEX IF NOT EXISTS idx_records_expired ON records(expired_at) WHERE status pending; CREATE INDEX IF NOT EXISTS idx_records_type_status ON records(type, status); ); // 增量迁移新字段用 ALTER TABLE 追加不丢数据 if (!(await hasColumn(database, records, expired_at))) { await database.execAsync(ALTER TABLE records ADD COLUMN expired_at INTEGER;); } if (!(await hasColumn(database, records, platform))) { await database.execAsync(ALTER TABLE records ADD COLUMN platform TEXT;); } // 旧版本检测驼峰字段名说明是旧结构需要重建 if (await hasColumn(database, budgets, categoryId)) { await database.execAsync(DROP TABLE IF EXISTS budgets;); } await database.execAsync( CREATE TABLE IF NOT EXISTS budgets ( id TEXT PRIMARY KEY NOT NULL, category_id TEXT, amount REAL NOT NULL, period TEXT NOT NULL ); ); }2.2.2 Zustand 状态管理import { create } from zustand; import * as db from ../modules/db; interface RecordState { records: PayRecord[]; todayExpense: number; todayIncome: number; monthExpense: number; monthIncome: number; loading: boolean; addRecord: (record: PayRecord) Promisevoid; updateRecord: (record: PartialPayRecord { id: string }) Promisevoid; removeRecord: (id: string) Promisevoid; refreshDashboard: () Promisevoid; } export const useRecordStore createRecordState((set, get) ({ records: [], todayExpense: 0, todayIncome: 0, monthExpense: 0, monthIncome: 0, loading: false, addRecord: async (record) { await db.insertRecord(record); await get().refreshDashboard(); // 插入后自动刷新仪表盘 }, updateRecord: async (record) { await db.updateRecord(record); await get().refreshDashboard(); }, removeRecord: async (id) { await db.deleteRecord(id); await get().refreshDashboard(); }, refreshDashboard: async () { const now Date.now(); const [todayExp, todayInc, monthExp, monthInc] await Promise.all([ db.getTotalByRange(startOfDay(now), endOfDay(now), expense), db.getTotalByRange(startOfDay(now), endOfDay(now), income), db.getTotalByRange(startOfMonth(now), endOfMonth(now), expense), db.getTotalByRange(startOfMonth(now), endOfMonth(now), income), ]); set({ todayExpense: todayExp, todayIncome: todayInc, monthExpense: monthExp, monthIncome: monthInc }); }, }));2.3 周期记账支持 8 种周期选项每天、每周、每2周、每月、每2月、每3月、每半年、每年。2.3.1 自动补记逻辑import { getRecurrings, updateNextTrigger, calcNextTrigger } from ../db/recurrings; import { insertRecord } from ../db/records; import type { PayRecord } from ../record/types; /** * 检查所有启用的周期记录为到期的记录自动创建记账 * 返回创建的记录数量 */ export async function checkAndCreateRecords(): Promisenumber { const recurrings await getRecurrings(true); // 只查启用的 const now Date.now(); let created 0; for (const rec of recurrings) { if (rec.periodValue 0) continue; // 防死循环守卫 // 循环补齐漏掉的周期App 长时间未打开时每期都补记一笔 let trigger rec.nextTrigger; while (trigger now) { const record: PayRecord { id: rec_${rec.id}_${trigger}, // 确定性主键防重复 amount: rec.amount, type: rec.type, categoryId: rec.categoryId, merchant: rec.merchant, channel: 周期记账, note: rec.note ?? [周期] ${ rec.periodType daily ? 每日 : rec.periodType weekly ? 每周 : 每月 }, createdAt: trigger, confirmedAt: trigger, status: confirmed, }; await insertRecord(record); trigger calcNextTrigger(rec.periodType, rec.periodValue, trigger); created; } // 更新下次触发时间 if (trigger ! rec.nextTrigger) { await updateNextTrigger(rec.id, trigger); } } return created; }2.4 数据可视化2.4.1 Skia 自绘环形图import { Canvas, Path, Skia } from shopify/react-native-skia; interface DonutChartProps { data: { categoryId: string; total: number }[]; size?: number; strokeWidth?: number; label?: string; } export function DonutChart({ data, size 160, strokeWidth 22, label 本月支出 }: DonutChartProps) { const total data.reduce((sum, item) sum item.total, 0); const innerRadius size / 2 - strokeWidth / 2; const gapAngle data.length 1 ? 2 : 0; // 扇区间隔 2° // 计算每个分类的弧形路径 const paths: { path: any; color: string }[] []; let startAngle -90; // 从 12 点方向开始 for (const item of data) { const sweep total 0 ? (item.total / total) * 360 : 0; const actualSweep Math.max(0, sweep - gapAngle); // 减去间隔 const category getCategoryById(item.categoryId); const path Skia.Path.Make(); path.addArc( { x: strokeWidth / 2, y: strokeWidth / 2, width: size - strokeWidth, height: size - strokeWidth }, startAngle gapAngle / 2, actualSweep ); paths.push({ path, color: category.color }); startAngle sweep; } return ( View style{styles.container} Canvas style{{ width: size, height: size }} Path path{Skia.Path.Make().addArc(/* ... */, 0, 360)} color{colors.border} stylestroke strokeWidth{strokeWidth} / {paths.map((p, index) ( Path key{index} path{p.path} color{p.color} stylestroke strokeWidth{strokeWidth} strokeCapround / ))} /Canvas View style{[styles.center, { width: innerRadius * 2, height: innerRadius * 2 }]} Text style{styles.totalLabel}{label}/Text Text style{styles.totalValue}{formatMoney(total)}/Text /View /View ); }2.5 数据导出导入2.5.1 CSV 导出兼容 Excel 中文import * as Sharing from expo-sharing; import { Paths, Directory, File, EncodingType } from expo-file-system; const CSV_HEADER 日期,类型,分类,金额,商户,渠道,备注,状态; function escapeCSV(value: string): string { if (value.includes(,) || value.includes() || value.includes(\n)) { return ${value.replace(//g, )}; } return value; } export function recordsToCSV(records: PayRecord[]): string { const lines [CSV_HEADER]; for (const r of records) { const date formatDateTime(r.createdAt); const type r.type expense ? 支出 : 收入; const amount r.amount.toFixed(2); const status r.status confirmed ? 已确认 : 待确认; lines.push([date, type, r.categoryId, amount, r.merchant ?? , r.channel ?? , r.note ?? , status].map(escapeCSV).join(,)); } return \uFEFF lines.join(\n); // BOM for Excel CJK compatibility } export async function shareCSV(records: PayRecord[]): Promisevoid { const csv recordsToCSV(records); const cacheDir new Directory(Paths.cache, exports); if (!cacheDir.exists) await cacheDir.create(); const file cacheDir.createFile(智能记账_${new Date().toISOString().slice(0, 10)}.csv, text/csv); await file.write(csv, { encoding: EncodingType.UTF8 }); if (await Sharing.isAvailableAsync()) { await Sharing.shareAsync(file.uri, { mimeType: text/csv, dialogTitle: 导出记账数据 }); } }2.5.2 JSON 导入跨设备迁移export async function importJSON(): Promisenumber { const result await import(expo-document-picker).then(m m.default.getDocumentAsync({ type: application/json, copyToCacheDirectory: true, })); if (result.canceled || !result.assets?.length) return 0; const file new File(result.assets[0].uri); const content await file.text(); const records: PayRecord[] JSON.parse(content); const valid records.filter(r r.id r.amount r.type r.categoryId r.createdAt); if (valid.length 0) throw new Error(JSON 格式不匹配); const ddb await getDb(); let imported 0; for (const r of valid) { try { // INSERT OR REPLACE主键冲突时覆盖避免重复导入 await ddb.runAsync( INSERT OR REPLACE INTO records (id, amount, type, category_id, merchant, channel, source_app, raw_notification, note, created_at, confirmed_at, status, expired_at, related_id, platform) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), r.id, r.amount, r.type, r.categoryId, r.merchant ?? null, r.channel ?? null, r.sourceApp ?? null, r.rawNotification ?? null, r.note ?? null, r.createdAt, r.confirmedAt ?? null, r.status ?? confirmed, r.expiredAt ?? null, r.relatedId ?? null, r.platform ?? null ); imported; } catch {} } return imported; }2.6 深色模式三种模式亮色 / 暗色 / 跟随系统全组件自适应配色useThemedStyles AppColors 主题对象主题偏好持久化到 MMKV2.7 预算管理总预算按月设置总支出上限分类预算每个分类独立设置预算额度进度展示进度条实时显示已用/剩余占比着色预警通知达 80% 黄色提醒超 100% 红色警告 超支百分比通知栏推送每分类每 3 天最多 1 次三、原生模块Kotlin自研 Expo 原生模块PayRecordNotification核心能力能力实现通知监听NotificationListenerServiceEventEmitter 推送 JS悬浮窗速记System Alert Window 权限QuickAddOverlay 弹窗队列同步consumeQuickAddQueue()App 回前台时同步悬浮窗录入前台服务保活最低优先级通知华为/荣耀默认开启每日提醒AlarmManager 精确调度默认 21:00晚间汇总AlarmManager 精确调度默认 23:00预算预警通知栏推送每分类每 3 天最多 1 次权限检测通知监听/悬浮窗/电池优化/无障碍/自启动权限引导跳转系统设置页 厂商定制 Intent3.1 厂商 ROM 适配export async function openAutoStartSettings(): Promisevoid { if (Platform.OS ! android) return; const manufacturer (Platform.constants?.Manufacturer || ).toLowerCase(); const intents: string[] []; // 根据厂商跳转不同的自启动设置页面 if (manufacturer.includes(xiaomi) || manufacturer.includes(redmi)) { intents.push(intent:#Intent;actionmiui.intent.action.OP_AUTOSTART;end); } else if (manufacturer.includes(huawei) || manufacturer.includes(honor)) { intents.push(intent:#Intent;actionhuawei.intent.action.HSM_BOOTAPP_MANAGER;end); } else if (manufacturer.includes(oppo) || manufacturer.includes(realme) || manufacturer.includes(oneplus)) { intents.push(intent:#Intent;actionandroid.settings.APPLICATION_DETAILS_SETTINGS;S.android.intent.extra.PACKAGE_NAMEcom.coloros.safecenter;end); } else if (manufacturer.includes(vivo) || manufacturer.includes(iqoo)) { intents.push(intent:#Intent;actioncom.vivo.abe.uni.action.EXTERNAL_ACCESS;end); } // 通用 fallback应用详情页 intents.push(intent:#Intent;actionandroid.settings.APPLICATION_DETAILS_SETTINGS;...;end); // 逐级尝试 for (const intent of intents) { try { await Linking.openURL(intent); return; } catch {} } try { await Linking.openSettings(); } catch {} }3.2 通知服务重启绑定export async function startNotificationListener(): Promiseboolean { if (Platform.OS ! android || !NativeModule) return false; try { // 注册事件监听 if (!eventSubscription) { const emitter new EventEmitter(NativeModule); eventSubscription (emitter as any).addListener(onNotification, (event: NotificationPayload) { handleNotification(event); }); } await NativeModule.startListener(); // 已授权但服务未绑定如重启后时主动请求重新绑定 try { const granted NativeModule?.isNotificationAccessGranted ? await NativeModule.isNotificationAccessGranted() : false; const running NativeModule?.isListening ? await NativeModule.isListening() : false; if (granted !running) { await NativeModule?.requestListenerRebind?.(); } } catch {} started true; return true; } catch (e) { started false; return false; } }四、数据库设计SQLite Schema4 张表-- 记录表 records ( id TEXT PRIMARY KEY, amount REAL, type TEXT, category_id TEXT, merchant TEXT, channel TEXT, source_app TEXT, raw_notification TEXT, note TEXT, created_at INTEGER, confirmed_at INTEGER, status TEXT DEFAULT confirmed, -- confirmed/pending/ignored expired_at INTEGER, related_id TEXT, platform TEXT ) -- 索引created_at, category_id, status, expired(部分), typestatus -- 分类表 categories (id TEXT PK, name TEXT, icon TEXT, color TEXT, sort_order INTEGER) -- 预算表 budgets (id TEXT PK, category_id TEXT, amount REAL, period TEXT) -- 周期记录表 recurrings ( id TEXT PK, amount REAL, type TEXT, category_id TEXT, merchant TEXT, note TEXT, period_type TEXT, period_value INTEGER, next_trigger INTEGER, enabled INTEGER DEFAULT 1, created_at INTEGER ) -- 索引next_trigger, enabled增量迁移机制每次启动检测旧字段hasColumn查PRAGMA table_info存在驼峰命名字段时 DROP 重建新字段用ALTER TABLE ADD COLUMN追加。保证版本升级不丢数据。五、核心亮点与设计思考5.1 通知监听的自愈机制Android 重启后NotificationListenerService虽有权限但未绑定监听静默失效——用户完全无感知。我的方案是让 App 每次启动时做主动健康检查权限在但服务没跑就调requestListenerRebind()请求系统重绑定。覆盖「重启后首次打开」和「长时间后台后恢复」两种场景const granted await NativeModule.isNotificationAccessGranted(); const running await NativeModule.isListening(); if (granted !running) { await NativeModule.requestListenerRebind(); }5.2 支付推送解析Normalize → 多规则 → 优先级同一支付渠道的推送格式千奇百怪——全角和半角¥混用、跨行通知、合并通知[4条]微信支付…。我的解析引擎分三步走Normalize 预处理raw.replace(/[¥]/g, ¥)全角→半角replace(/\s/g, )多空白→单空白flags: ‘s’启用 DOTALL 模式匹配跨行内容多规则 优先级微信支付单独写了 12 条正则分别覆盖红包、转账、扫码、收款等子场景按模板顺序匹配首个命中即返回保证精确匹配优先于通用匹配这种「宽进严出」的策略让引擎对格式变化有很强的容错性——即使推送文案微调只要金额和关键词在仍然能解析。5.3 厂商 ROM 适配逐级 Fallback 策略华为/小米/OPPO/vivo 各家自启动、电池优化、悬浮窗设置入口完全不同。我按Platform.constants.Manufacturer检测厂商预设各厂商的 Intent 跳转路径然后逐级尝试——厂商专用路径优先失败后 fallback 到通用应用详情页最终 fallback 到系统设置。用户始终能到达正确的设置页面只是可能多跳一次。5.4 Skia 环形图极小占比的视觉保底用 Skia 自绘环形图时某分类占比极小 1%会导致弧度精度丢失出现闪烁或不显示。我的处理是设置最小弧度间隔gapAngle 2°用Math.max(0, sweep - gapAngle)保底。同时处理 total 0 的空状态——画一个完整底色圆环而不是空白。这些边界处理让图表在任何数据分布下都稳定渲染。5.5 周期记账确定性主键 守卫式补记周期记账的补记逻辑容易出问题periodValue为 0 时while (trigger now)永不退出。我的防御策略是三层前置守卫if (rec.periodValue 0) continue从源头拒绝无效周期确定性主键rec_${rec.id}_${trigger}即使补记逻辑重复执行也不会插入重复记录状态推进补记完成后立即更新nextTrigger下次启动只处理新的到期记录这种「守卫 幂等 推进」的三重保障让补记逻辑在面对各种边界条件时都不会出问题。5.6 数据库并发安全Promise 缓存而非实例缓存多个组件并发调用openDatabaseAsync()会产生多个原生句柄导致prepareAsync抛出 NPE。解决方案是缓存数据库打开的 Promise而不是实例——Promise 只会 resolve 一次后续并发调用拿到的是同一个已 resolve 的 Promise保证了全局只有一个数据库句柄let dbPromise: PromiseSQLiteDatabase | null null; export async function getDb(): PromiseSQLiteDatabase { if (!dbPromise) { dbPromise (async () { const db await SQLite.openDatabaseAsync(payrecord.db); await migrate(db); return db; })(); } return dbPromise; }这个模式在 React Native 中非常实用——任何需要全局单例的异步初始化都可以用不限于数据库。六、总结与展望6.1 项目成果实现了基于通知监听的自动记账将记账成本从 10 秒降到 2 秒自研 Kotlin 原生模块覆盖通知监听、悬浮窗速记、前台保活 3 大核心能力正则模板引擎覆盖支付宝/微信/银行 6 大分类 20 条规则支持全角/半角/跨行/合并通知适配华为/小米/OPPO/vivo 4 大厂商 ROM逐级 Fallback 保证设置页可达SQLite 4 表 WAL 模式 增量迁移版本升级零数据丢失6.2 未来优化方向解析引擎升级从正则模板迁移到 LLM 语义解析支持未知渠道自动适配数据同步iCloud/Google Drive 云端备份跨设备同步智能分析月度消费趋势预测、异常消费预警、理财建议