DriftLedger/docs/modal-keyboard-guide.md
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform)
  - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction)
  - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取
  - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦

  ### 微信账单抓取:节点混淆绕过(核心突破)
  - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务
  - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验
  - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动
  - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错)
  - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录)

  ### 新 UI 组件体系
  - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失)
  - ErrorBoundary:React class 错误边界(降级 UI + 重试)
  - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker
  - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让
  - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField)
  - 新增 PeriodSwitcher、RangeStatsCard 独立组件

  ### 新 Hooks & 工具
  - useBottomInset — 统一底部安全区留白
  - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案)
  - sanitize.ts — 日志脱敏工具提取

  ### 账本增删改增强
  - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set
  - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean)
  - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」
  - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误)
  - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致

  ### OCR 原生模块增强
  - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」
  - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload
  - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别)
  - 图片缩放策略重命名(scaleDownForOcr → capLongEdge)

  ### OCR 模型按需下载
  - 移除了启动时自动下载 ~30MB OCR 模型的逻辑
  - 改为首次使用 OCR 时才触发下载

  ### 设置页重设计
  - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头)
  - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片

  ### 首页优化
  - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条)
  - 日期/金额格式化增加 locale 感知(zh/en)

  ### 通知管道增强
  - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除
  - 增加 debug 日志输出(过滤原因/包名)

  ### ESLint
  - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集)
  - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies

  ### 文档
  - accessibility-wechat-guide.md — 微信无障碍伪装完整方案
  - modal-keyboard-guide.md — 弹窗键盘避让方案
  - ocr-pipeline-guide.md — OCR 三层层级管线
  - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

153 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 弹窗与键盘避让设计指南 (Modal & Keyboard Avoidance)
本项目React Native + Expo所有底部弹窗FormModal / BottomSheet / NumpadSheet都基于 RN 原生 `<Modal>`。Modal 会带来两个棘手问题:**底部安全区丢失** 和 **键盘遮挡**。本篇记录反复踩坑后总结的正确模式,避免重蹈覆辙。
参考实现:`reference_project/BeeCount`Flutter`AnimatedPadding + viewInsets.bottom` 方案)。
---
## 1. 核心认知:`<Modal>` 脱离主窗口 context
> **这是所有问题的总根源,必须牢记。**
RN 的 `<Modal>` 会在原生层创建一个**独立的新窗口**,它**脱离了主窗口的 React 树**。两个直接后果:
1. **`react-native-safe-area-context` 失效**:主窗口里 expo-router 自动注入的 `SafeAreaProvider` 不会延伸到 Modal 内部。在 Modal 内直接调 `useSafeAreaInsets()` 会返回 `{ top: 0, bottom: 0, ... }`(全零),拿不到手势条高度。
2. **`KeyboardAvoidingView` 不可靠**:在 Modal 的独立窗口里,`behavior="height"` 在 Android 上键盘关闭后可能残留 padding表现为"关闭键盘后弹窗底部有留白"`behavior="padding"` 对 Modal 内的布局响应也不稳定。
---
## 2. 正确模式Modal 内补 SafeAreaProvider + 响应式 paddingBottom
### 2.1 SafeAreaProvider 补救(解决底部安全区丢失)
每个 `<Modal>` 内部**重新包一层 `<SafeAreaProvider>`**,让 context 在弹窗内恢复有效:
```tsx
// ❌ 错误Modal 内直接用 useSafeAreaInsets(),返回全零
export function FormModal(props) {
const insets = useSafeAreaInsets(); // bottom === 0 永远
return <Modal>...</Modal>;
}
// ✅ 正确Modal 内补 SafeAreaProvider拆成外层 + Content
export function FormModal(props) {
return (
<Modal visible={props.visible} transparent animationType="slide">
<SafeAreaProvider>
<FormModalContent {...props} />
</SafeAreaProvider>
</Modal>
);
}
function FormModalContent(props) {
const insets = useSafeAreaInsets(); // 现在 bottom 是真实手势条高度
// ...
}
```
> **为什么必须拆两个组件?** `useSafeAreaInsets()` 必须在 `<SafeAreaProvider>` 的**子组件**里调用。同一个组件既渲染 Provider 又调 hook 会拿到全零hook 在 Provider 上方执行)。
### 2.2 响应式 paddingBottom解决键盘遮挡绝不残留
**核心思路(学 BeeCount 的 `AnimatedPadding + viewInsets.bottom`):键盘高度做成 paddingBottom而不是位移 sheet。**
```tsx
// useKeyboardHeight hook见 src/hooks/useKeyboardAvoiding.ts
const kbHeight = useKeyboardHeight();
// sheet 的 paddingBottom = 基础留白 + 键盘高度
<Pressable style={{
paddingBottom: Math.max(insets.bottom, 24) + kbHeight,
}}>
{/* 内容(含确认/取消按钮)会被 padding 顶起,始终在键盘上方 */}
</Pressable>
```
**为什么这个模式正确:**
- 键盘弹出 → `paddingBottom` 增大 → 内容被推到键盘上方,**且 sheet 仍贴底,不会露出遮罩缝**
- 键盘关闭 → `paddingBottom` 精确回到 `Math.max(insets.bottom, 24)`**无残留**
- 初始(键盘未弹)→ 只有基础留白 → **不会一打开就有大留白**
---
## 3. ❌ 已验证的失败方案(不要再尝试)
### 3.1 `KeyboardAvoidingView` 包裹 sheet
```tsx
// ❌ Modal 内 KAV 不可靠
<Modal>
<SafeAreaProvider>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<Sheet/>
</KeyboardAvoidingView>
</SafeAreaProvider>
</Modal>
```
**问题**Android `behavior="height"` 键盘关闭后残留 padding"关闭键盘后有留白"`behavior="position"` 在 Modal 内也不稳定。
### 3.2 `Animated` translateY 位移整个 sheet
```tsx
// ❌ 位移会露出遮罩缝 + Modal 内键盘事件易误触发
const translateY = useKeyboardAvoiding(); // 返回 Animated.Value
<Animated.View style={{ transform: [{ translateY }] }}>
<Sheet/>
</Animated.View>
```
**两个致命问题**
1. 位移后 sheet 原位置空出来,露出遮罩色 → **"键盘和弹窗之间有留白"**
2. Modal 首次渲染时若 `keyboardDidShow` 被残留焦点事件误触发translateY 变负 → **"一打开就有留白"**
> 这正是本项目踩过的坑translateY 方案让用户看到"所有弹窗底部都有留白"。改用响应式 padding 后彻底解决。
---
## 4. 底部留白值的正确计算
```tsx
// ❌ 错误:安全区 + 固定值叠加(会过大)
paddingBottom: 36 + insets.bottom // 全面屏手势机 bottom≈48 → 总 84px太多
// ✅ 正确:取较大值,不叠加
paddingBottom: Math.max(insets.bottom, 24)
```
**为什么不能叠加?** 安全区(`insets.bottom`)本身已经覆盖了手势条区域。再叠加一个固定的 36会在底部堆出 80+px 的空白,视觉上是"一大块留白"。两者应取**较大值**——安全区大时取安全区,安全区为 0如 MIUI 全面屏手势机)时取最小视觉留白。
---
## 5. 项目中三个公共弹窗的实现
| 组件 | 文件 | 键盘避让 | 备注 |
|---|---|---|---|
| `FormModal` | `src/components/form/FormModal.tsx` | 响应式 paddingBottom含 TextInput | 所有管理页 CRUD 复用 |
| `BottomSheet` | `src/components/ui/BottomSheet.tsx` | 响应式 paddingBottom + 手势下滑 Animated | 手势 `translateY` 仅用于下滑关闭,与键盘 padding 独立 |
| `NumpadSheet` | `src/components/form/NumpadSheet.tsx` | 内部 ScrollView | 记一笔主面板,主要用自定义 NumpadKeyboard系统键盘场景少 |
`BottomSheet` 的特殊点:它同时有**手势下滑关闭**(用 `Animated.Value` 做 translateY和**键盘避让**(用响应式 paddingBottom。两者必须独立——手势位移走 Animated键盘避让走 padding不要合并成一个位移值`Animated.add` 合并会导致手势和键盘互相干扰)。
---
## 6. 居中弹窗ConfirmDialog无需处理
`ConfirmDialog`(删除确认)是居中浮层(`justifyContent: 'center'`),无 TextInput、不贴底、无键盘**不受安全区/键盘影响,不需要上述任何处理**。
---
## 7. 验证清单(修弹窗后必测)
1. **底部留白**:打开弹窗(不点输入框),底部只有正常小留白,无"一大块空白"。
2. **键盘上移**:点输入框唤起键盘 → 确认/取消按钮在键盘上方可见,且弹窗与键盘之间无缝隙。
3. **关闭键盘无残留**:收起键盘 → 弹窗精确回到初始状态,底部留白和打开时一致。
4. **全面屏手势机**:在 MIUI 等全面屏手势设备上(`insets.bottom = 0`)也正常。
---
## 8. 经验教训
1. **RN `<Modal>` 是独立窗口**——这是 RN 的设计,不是 bug。任何依赖主窗口 context 的 APISafeArea、某些 Keyboard 行为)在 Modal 内都要重新建立。
2. **键盘避让用 padding不用位移**——这是 Flutter/RN 通用共识BeeCount 的 `AnimatedPadding + viewInsets`)。位移方案虽然直觉上像"弹窗上移",但会露出原位置,制造视觉缝隙。
3. **Hermes bundle 是二进制**——验证 release 包代码时必须 `grep -a`,否则永远误判"代码没进去"。详见 `android-build-guide.md §7.2`