记花 ——让花销变得更有计划
文章目录记花 — 自动记账 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 云端备份跨设备同步智能分析月度消费趋势预测、异常消费预警、理财建议

相关新闻

Elixir-Slack:构建实时Slack机器人的终极指南

Elixir-Slack:构建实时Slack机器人的终极指南

Elixir-Slack:构建实时Slack机器人的终极指南 【免费下载链接】Elixir-Slack Slack real time messaging and web API client in Elixir 项目地址: https://gitcode.com/gh_mirrors/el/Elixir-Slack Elixir-Slack是一个功能强大的Slack实时消息和Web API客户…

2026/8/6 21:59:28 阅读更多 →
2026年pdf拆分免费工具盘点:在线和本地这几款实测够用,无水印免注册也能稳妥拆分合并

2026年pdf拆分免费工具盘点:在线和本地这几款实测够用,无水印免注册也能稳妥拆分合并

报销材料要按顺序并成一份、几十页的合同只要抽中间几页、扫描件分了好几个文件要合起来交——上周我就遇到了这么一桩事。六份扫描件散在微信聊天记录里,财务要求按发票编号顺序合并成一个PDF上传系统,还得把其中三份各抽掉最后一页的空白页。我打开小程…

2026/8/6 21:59:28 阅读更多 →
LXGW WenKai TC v1.520更新日志:新增2899个BIG5汉字,全面提升兼容性

LXGW WenKai TC v1.520更新日志:新增2899个BIG5汉字,全面提升兼容性

LXGW WenKai TC v1.520更新日志:新增2899个BIG5汉字,全面提升兼容性 【免费下载链接】LxgwWenkaiTC The Traditional Chinese Edition of LXGW WenKai. 项目地址: https://gitcode.com/gh_mirrors/lx/LxgwWenkaiTC LXGW WenKai TC(霞鹜…

2026/8/6 21:59:28 阅读更多 →

最新新闻

济南各区小学上学期期中语文、数学、英语试卷及答案解析

济南各区小学上学期期中语文、数学、英语试卷及答案解析

2026/8/6 22:41:44 阅读更多 →
GridPlayer多视频网格播放器:如何快速掌握专业级同步播放解决方案

GridPlayer多视频网格播放器:如何快速掌握专业级同步播放解决方案

GridPlayer多视频网格播放器:如何快速掌握专业级同步播放解决方案 【免费下载链接】gridplayer Play videos side-by-side 项目地址: https://gitcode.com/gh_mirrors/gr/gridplayer GridPlayer是一款基于VLC引擎的免费开源多视频网格播放器,它能…

2026/8/6 22:41:44 阅读更多 →
TCP/IP协议栈详解:从分层原理到网络编程实践

TCP/IP协议栈详解:从分层原理到网络编程实践

1. TCP/IP协议栈全景透视:网络通信的基石2003年我在配置第一台Linux服务器时,因为不理解TCP三次握手原理,导致防火墙规则配置错误,服务器整整宕机了8小时。这次惨痛经历让我深刻意识到:理解TCP/IP协议栈不是学院派的理…

2026/8/6 22:41:44 阅读更多 →
AIGC异步回调系统设计与实践:解决长耗时任务管理难题

AIGC异步回调系统设计与实践:解决长耗时任务管理难题

1. 项目概述:AIGC异步回调系统的核心价值去年我在部署一个AI内容生成平台时,遇到过这样的场景:用户提交的图片生成任务需要3-5分钟处理,但HTTP连接30秒就会超时。这就是典型的异步任务场景,也是促使我深入研究回调系统…

2026/8/6 22:41:44 阅读更多 →
#GEO优化服务商甄别指南:自研技术、透明监测、全域协同,三个硬指标就够了

#GEO优化服务商甄别指南:自研技术、透明监测、全域协同,三个硬指标就够了

想用GEO让品牌出现在AI的回答里,第一步不是选哪家服务商,而是先搞清楚怎么判断一家GEO优化服务商靠不靠谱。市面上很多号称"GEO优化"的公司,拆开一看,底层还是传统SEO的那套逻辑——换了个说法继续卖关键词排名。真正能…

2026/8/6 22:41:44 阅读更多 →
【8月最新版】10款实测超好用的AI写小说神器(内含工具优缺点对比图)

【8月最新版】10款实测超好用的AI写小说神器(内含工具优缺点对比图)

作为一个全职码字五年的老作者,我太懂大家深夜盯着空白文档的绝望了。 脑子里明明有宏大世界观,可一敲键盘就卡文,尤其是网文黄金前三章,改了十几版还是被编辑拒签。很多新人经常私信问我新手如何开始写小说,或者求一…

2026/8/6 22:40:44 阅读更多 →

日新闻

深入解析LimboAI C++内核:架构设计与性能优化实战

深入解析LimboAI C++内核:架构设计与性能优化实战

1. 项目概述:为什么我们需要深入LimboAI的C内核?如果你是一名使用Godot引擎的游戏开发者,尤其是对AI行为逻辑有较高要求的项目,那么LimboAI这个名字你大概率不会陌生。它作为Godot 4生态中一个备受瞩目的行为树与状态机插件&#…

2026/8/6 0:00:06 阅读更多 →
Unity 2D游戏敌人AI系统:基于PlayMaker状态机与2D Toolkit的实战开发

Unity 2D游戏敌人AI系统:基于PlayMaker状态机与2D Toolkit的实战开发

1. 项目概述与核心思路大家好,我是老张,一个在游戏开发一线摸爬滚打了十多年的老码农。今天咱们接着聊《空洞骑士》风格2D动作游戏的Demo制作。上一期我们搭好了基础框架,处理了角色移动和碰撞,这一期,我们要让游戏世界…

2026/8/6 0:00:06 阅读更多 →
被动防火门市场前景发展趋势

被动防火门市场前景发展趋势

被动防火门依靠材质结构、密闭构造阻隔烟火蔓延,无需电控启动,是建筑被动消防系统核心构件,行业依托新规管控、城市更新、工业安全升级迎来稳定扩容,整体朝着合规化、专项化、低碳化、智能化方向发展。现阶段 GB12955‑2024 新版国…

2026/8/6 0:00:06 阅读更多 →

周新闻

最大流算法详解:从水管网络到Ford-Fulkerson与Dinic实战

最大流算法详解:从水管网络到Ford-Fulkerson与Dinic实战

1. 从水管网络到最大流:一个核心问题的诞生想象一下,你是一个城市供水系统的总工程师。你的城市有多个水源(水库),需要通过一个复杂的地下管道网络,将水输送到各个居民区。每条管道都有其最大通水能力&…

2026/8/6 22:02:27 阅读更多 →
基于Springboot的企业门户网站(源码+LW+调试文档+讲解)

基于Springboot的企业门户网站(源码+LW+调试文档+讲解)

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台…

2026/8/6 22:02:27 阅读更多 →
MATLAB xcorr函数详解:从互相关原理到四大实战应用

MATLAB xcorr函数详解:从互相关原理到四大实战应用

1. 从一次信号“找茬”说起:为什么我们需要互相关几年前,我在处理一组声学传感器数据时遇到了一个棘手的问题。我有两个麦克风记录了一段相同的音频信号,理论上它们接收到的声音波形应该非常相似,只是由于麦克风位置不同&#xff…

2026/8/6 22:02:27 阅读更多 →

月新闻

免费解锁百度网盘SVIP加速:macOS用户必备的下载提速终极指南

免费解锁百度网盘SVIP加速:macOS用户必备的下载提速终极指南

免费解锁百度网盘SVIP加速:macOS用户必备的下载提速终极指南 【免费下载链接】BaiduNetdiskPlugin-macOS For macOS.百度网盘 破解SVIP、下载速度限制~ 项目地址: https://gitcode.com/gh_mirrors/ba/BaiduNetdiskPlugin-macOS 还在为百度网盘macOS版的龟速下…

2026/8/5 23:28:39 阅读更多 →
终极ncmdump指南:3分钟实现网易云NCM音乐解密与格式转换

终极ncmdump指南:3分钟实现网易云NCM音乐解密与格式转换

终极ncmdump指南:3分钟实现网易云NCM音乐解密与格式转换 【免费下载链接】ncmdump 项目地址: https://gitcode.com/gh_mirrors/ncmd/ncmdump 还在为网易云音乐下载的NCM格式文件无法在其他播放器播放而烦恼吗?ncmdump解密工具帮你轻松解决这个困…

2026/8/6 22:02:28 阅读更多 →
HarmonyOS 应用开发《掌上英语》第81篇: 智能体卡片:为英语学习 App 打造桌面级学习助手

HarmonyOS 应用开发《掌上英语》第81篇: 智能体卡片:为英语学习 App 打造桌面级学习助手

AgentCard 智能体卡片:为英语学习 App 打造桌面级学习助手适用平台:HarmonyOS 7.0 (API 26 Beta)一、引言 HarmonyOS 7.0(API 26 Beta)新增了 AgentCard 智能体卡片能力,这是继 HMAF(鸿蒙智能体框架&#x…

2026/8/5 23:46:51 阅读更多 →