DriftLedger/docs/android-build-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

371 lines
21 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.

# Android 安装包打包与体积优化指南 (Android Build Guide)
在引入机器学习引擎ONNX Runtime及 React Native 新架构后,本项目的原生 C/C++ SO 库体积大幅增加。为避免将无意义的冗余架构包打包给最终手机用户,本项目对 Android 构建配置进行了专项体积优化。
本篇指南将为您介绍项目中的打包机制、优化细节,以及如何通过命令行进行差异化编译。
---
## 0. TL;DR — 改了原生代码后的完整重建流程
> 当你修改了 `plugins/*/android/` 下的任何 Kotlin/Java 原生源码、或 `app.plugin.js` 注入逻辑后,`android/` 目录里那份由 prebuild 生成的原生工程**已经过时**,必须重新生成才能让改动生效。这是最常见的「我改了代码但安装后没变化」的根因。
完整的三步重建命令Git Bash工作目录为项目根
```bash
# 环境变量(每次新开 shell 都要设;可写入 ~/.bashrc 持久化)
export JAVA_HOME="/c/Program Files/Java/jdk-21"
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
export PATH="$ANDROID_HOME/platform-tools:$PATH" # 让 adb 可用
```
> [!TIP]
> **不想每次 export `ANDROID_HOME`** 把 SDK 路径写进 `android/local.properties``sdk.dir=C\:\\Users\\fmq\\AppData\\Local\\Android\\Sdk`gradle 会优先读它,前台/后台 shell 都不再依赖环境变量。该文件在 `.gitignore` 内、不进仓库。**后台/自动化编译尤其需要它**——后台新 shell 不继承交互会话里的 `ANDROID_HOME`,缺 `local.properties` 会直接 `SDK location not found` 失败(详见 §7.6)。
```bash
# ① 删除旧的原生工程,强制重新生成(确保原生改动被注入)
rm -rf android
npx expo prebuild --platform android
# ② 清理 Gradle 缓存后编译 Release默认 arm64-v8a真机最快
cd android
./gradlew.bat clean --offline
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline
# ③ 通过 adb 覆盖安装到已连接的真机
adb install -r -d app/build/outputs/apk/release/app-arm64-v8a-release.apk
```
> **何时需要重新 prebuild**
> - 改了 `plugins/*/android/*.kt`(任何 Config Plugin 的原生源码)
> - 改了 `plugins/*/app.plugin.js`(注入逻辑)
> - 改了 `app.json`appId、权限、插件配置
> - 在新机器上首次拉取代码
>
> **何时只需直接编译(无需 prebuild**
> - 只改了 `src/` 下的 TS/TSXJS Bundle 由 Metro/assemble 自动打入)
> - 只改了 `android/app/build.gradle`、`gradle.properties` 等已生成的 Gradle 配置
>
> [!WARNING]
> **改了 TS 后release 包偶尔不会更新**(见 §7gradle 的 `createBundleReleaseJsAndAssets` 可能因为缓存跳过重新打包,或 Metro 用了 transformer cache。若发现"改了代码但设备上行为没变"**先按 §7.2 验证 bundle 是否真的含新代码**,而不是反复改代码。
---
## 1. 体积优化核心原理
### 1.1 ABI 分包 (ABI Splits)
在 Android 系统的 `build.gradle` 中,我们启用了分包编译:
```groovy
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
universalApk true
}
}
```
* **效果**:系统会为每种 CPU 架构单独输出一个体积小巧的 APK并额外保留一个兼容所有架构的通用包Universal APK
* **独立包大小**~35MB - ~40MB
* **通用包大小**~140MB - ~170MB
### 1.2 默认编译架构限制
我们在 `android/gradle.properties` 中指定了默认编译目标为:
```properties
reactNativeArchitectures=arm64-v8a
```
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。
> [!NOTE]
> **`-PreactNativeArchitectures` 与 ABI Splits 的关系**:当启用了 §1.1 的 `splits.abi` 后,`assembleRelease` 会**无条件生成所有 `include` 列出的架构分包 + universal 包**`-PreactNativeArchitectures` 参数只能限制"编译哪几个架构的原生库",并不能减少最终输出的 APK 数量。若想只产出一个 arm64 包、跳过其它架构的编译耗时,最干净的做法是**注释掉 `app/build.gradle` 里的 `splits { abi { ... } }` 块**,让 `reactNativeArchitectures=arm64-v8a` 单独生效。
### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
> [!IMPORTANT]
> 由于 `android/` 目录被 Git 忽略(见 `.gitignore`**不要直接手动修改 `android/` 下的文件并提交**——它们是 prebuild 生成的产物,会在下次重建时丢失。
> 本项目已编写了专门的本地 Config Plugin[size-optimization](file:///c:/Users/fmq/Documents/work/DriftLedger/plugins/size-optimization/app.plugin.js)。
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起所有 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置ABI 分包、默认单架构编译、NDK 版本统一):
> ```bash
> npx expo prebuild --platform android
> ```
---
## 2. 编译命令与打包指令
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
### 2.1 本地测试/实体分发(仅编译 arm64-v8a最快最推荐
直接运行默认编译,会使用 `gradle.properties` 中配置的 `arm64-v8a`
**Git Bash推荐与本文档 §0 的 TL;DR 一致):**
```bash
export JAVA_HOME="/c/Program Files/Java/jdk-21"
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
cd android
./gradlew.bat :app:assembleRelease --offline
```
**PowerShell**
```powershell
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
cd android
.\gradlew.bat :app:assembleRelease --offline --no-daemon
```
编译完成后,可在以下路径找到适合真机安装的轻量版 APK开启混淆后约 **24MB**
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
---
### 2.2 全量分包发布(适合多设备兼容测试/全网发布)
若需要同时生成适配所有手机架构的独立 APK 以及一个通用包,可以在命令行中通过参数**覆盖默认架构**
```powershell
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
cd android
.\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 --offline --no-daemon
```
编译完成后,在输出目录会产生 5 个文件:
1. `app-arm64-v8a-release.apk` (推荐绝大多数真机,约 37MB)
2. `app-armeabi-v7a-release.apk` (适合极少数老旧真机)
3. `app-x86-release.apk` (适合 32 位模拟器)
4. `app-x86_64-release.apk` (适合 64 位模拟器)
5. `app-universal-release.apk` (包含上述全部架构的通用胖包,约 140MB)
---
### 2.3 模拟器专用打包 (x86_64)
若需要直接打包在 Windows/macOS 的 x86_64 原生安卓模拟器上测试:
```powershell
.\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=x86_64 --offline --no-daemon
```
即可秒级编译出专门针对模拟器的 `app-x86_64-release.apk`
---
## 3. Proguard/R8 混淆(已默认开启)
本项目的 `gradle.properties` 已**默认启用**代码混淆与资源压缩:
```properties
android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true
```
这使得 arm64-v8a 独立包从 ~37MB 压缩到约 **24MB**。无需手动开启。
> [!WARNING]
> 若新增了依赖反射/动态加载的库(如 `ONNX Runtime` 的 JNI 调用),混淆后可能出现运行时 `ClassNotFoundException`。此时需在 `android/app/proguard-rules.pro`(由 ppocr 等 Config Plugin 注入)中补充保留规则,例如:
> ```proguard
> -keep class com.microsoft.onnxruntime.** { *; }
> -dontwarn com.microsoft.onnxruntime.**
> ```
> 调试混淆问题时,可临时把上述两个属性改为 `false` 排查是否为混淆所致。
---
## 4. 通过 adb 安装到真机
编译产物就绪后,用 adb 覆盖安装到已连接的设备(保留应用数据):
```bash
# 确认设备已连接USB 调试已开启)
adb devices
# 覆盖安装:-r 保留数据,-d 允许版本号不升(覆盖安装相同/更低 versionCode 时需要)
adb install -r -d android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
```
常见问题:
| 现象 | 原因与解决 |
|---|---|
| `adb: command not found` | adb 不在 PATH。Git Bash 下执行 `export PATH="/c/Users/fmq/AppData/Local/Android/Sdk/platform-tools:$PATH"` |
| `device offline` / 列表为空 | 手机未授权 USB 调试,或驱动未装;重新插拔并在手机弹窗点「允许」 |
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | 签名不一致(如之前装的是 debug 版)。先 `adb uninstall com.example.driftledger` 再装 |
| 装完打开白屏/闪退 | 多为混淆误删(见 §3或原生库架构不匹配模拟器需 x86_64 包) |
---
## 5. 原生插件Config Plugin开发避坑指南
本项目通过 7 个 Expo Config Plugin`plugins/*/app.plugin.js`)在 prebuild 时注入原生代码与配置。以下是踩过的坑:
### 5.1 跨插件包名一致性陷阱(无障碍伪装包)
**背景**:为绕过微信 8.0.52+ 的节点混淆,`accessibility` 插件把 7 个 Kotlin 文件整体迁移到伪装包 `com.google.android.accessibility.selecttospeak`(伪装成系统「随说随读」服务),并在注入时用正则把源码里的 `com.beancount.mobile.accessibility` 改写成这个伪装包名。
**陷阱**`notification-listener` / `screenshot-monitor` / `sms-receiver` 这三个插件的 Kotlin 源码**也 import 了** `com.beancount.mobile.accessibility.{SelectToSpeakService, ReactContextHolder}`。它们各自的 `app.plugin.js` 有一条「通用包名替换」规则:
```js
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
```
这条规则会**无差别**地把 `com.beancount.mobile.accessibility` 也替换成 `appId.accessibility`,导致引用指向一个不存在的包,编译时报:
```
e: ... BillingNotificationListenerService.kt: Unresolved reference 'accessibility'
e: ... BillingNotificationListenerService.kt: Unresolved reference 'SelectToSpeakService'
```
**修复**(已在三个插件中落地):在通用替换**之前**,先把 accessibility 子包引用单独改写到伪装包:
```js
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
// 必须先改写 accessibility 子包引用,再做通用 com.beancount.mobile → appId 替换
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
```
> **教训**:任何插件如果要用正则做包名改写,必须**先处理跨插件共享的子包引用**(尤其是被「伪装/重命名」过的包),再做通配替换,否则通用规则会破坏跨插件依赖。
### 5.2 验证 prebuild 注入是否成功
prebuild 不会因「源码与注入结果不一致」而报错(它只是文件复制 + 字符串替换),所以注入错误只能在 gradle 编译时暴露。快速自查注入结果:
```bash
# 检查目标包名/类是否被注入到预期路径
find android/app/src/main/java -iname "*SelectToSpeak*" -o -iname "*ReactContext*"
# 对比源文件与注入文件的差异(应仅 package 声明行不同)
diff plugins/accessibility/android/SelectToSpeakService.kt \
android/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt
```
若编译报 `Unresolved reference`先用上述命令确认类是否被注入、package 是否正确改写。
### 5.3 编译失败排查清单
| 错误特征 | 可能原因 | 排查 |
|---|---|---|
| `Unresolved reference 'XXX'` | 跨插件 import 包名改写不一致(见 §5.1 | 检查注入后文件的 `import` 行 |
| `Cannot resolve symbol` / 找不到 R 资源 | res/xml 未随 kt 一起注入 | 检查 `app/src/main/res/xml/` 是否有配置文件 |
| 改了 kt 但安装后行为没变 | 忘记重新 prebuildandroid/ 是旧的) | `rm -rf android && npx expo prebuild` |
| `Execution failed ... mergeReleaseResources` | 资源 ID 冲突 / strings.xml 重复注入 | 检查插件是否做了幂等判断(`if (!content.includes(...))` |
| `Argument type mismatch: 'Float', but 'Double' was expected`(多在 `Math.ceil`/`Math.floor` 处) | `java.lang.Math.ceil`/`floor` **只有 double 重载**Kotlin 传 Float 不会自动提升 | 改 `x.toDouble()`。注意 `Math.round` 同时有 float/double 两个重载,所以 `Math.round(Float)` 不报错——别误以为 `ceil` 也能直接传 Float |
### 5.4 只改单个原生源文件时的快速同步(免全量 prebuild
§0 的全量重建要 `rm -rf android && prebuild`,慢且扰动整个原生工程。当**只改了某个 `plugins/<x>/android/*.kt` 的内容**(没改 `app.plugin.js` 注入逻辑、没新增/删除文件、没改 assets、没改 app.json可手动把改后的源同步到 android 副本,省去全量 prebuild。Config Plugin 在 prebuild 时对 .kt 做的事 = 复制 + 把 `package`/`import` 里的 `com.beancount.mobile` 替换成 appId手动等价如下appId 以 `app.json``android.package` 为准,本项目为 `com.example.driftledger`
```bash
# 例:只改了 plugins/ppocr/android/OcrModule.kt
python -c "
s=open('plugins/ppocr/android/OcrModule.kt',encoding='utf-8').read()
s=s.replace('com.beancount.mobile','com.example.driftledger')
open('android/app/src/main/java/com/example/driftledger/ppocr/OcrModule.kt','w',encoding='utf-8',newline='\n').write(s)
"
# 然后直接编译(无需 prebuild改的是 .ktMetro bundle 不受影响)
cd android && ./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
```
> [!WARNING]
> 这条捷径**只对「改 .kt 内容」成立**。以下情况**必须**走 §0 全量 prebuild否则 android/ 副本与注入结果不一致:改了 `app.plugin.js` 注入/复制逻辑;新增或删除 `.kt`/资源文件(手动同步不会更新 `MainApplication` 的 `add(...)` 注入或 res 复制);改了 `assets/`(模型/字典)或 `app.json`。同步后务必 `grep` 副本确认 `package` 行已是 appId、且无残留 `com.beancount.mobile`。
---
## 6. 常用速查
| 目标 | 命令 |
|---|---|
| 改了 TS 后热更新(无需重编) | `npm run start` → 手机摇一摇 Reload |
| 改了原生后重建并安装 | 见 §0 TL;DR 三步 |
| 只看编译是否通过(不出 APK | `./gradlew.bat :app:compileReleaseKotlin --offline` |
| 查看连接的设备 | `adb devices -l` |
| 查看应用日志 | `adb logcat *:S ReactNativeJS:V ReactNative:V` |
| 卸载应用 | `adb uninstall com.example.driftledger` |
---
## 7. Release 构建陷阱(改了 TS 却没生效?看这里)
> 这是项目中最坑、最耗时的问题之一。症状:**改了 `src/` 下的 TS/TSX编译成功安装到手机但行为毫无变化**——仿佛代码没改。这几乎总是 JS Bundle 缓存或 Windows 文件锁导致的。
### 7.1 根因分类
| 根因 | 机制 | 表现 |
|---|---|---|
| **gradle bundle task 缓存** | `:app:createBundleReleaseJsAndAssets` 基于输入快照判定 UP-TO-DATE即使删了 bundle 输出文件gradle 的 task snapshot 仍认为"最新",跳过打包 | `./gradlew :app:createBundleReleaseJsAndAssets` 显示 `UP-TO-DATE` |
| **Metro transformer cache** | Metro 对每个源文件缓存编译结果命中就用旧版。Windows 下缓存在 `%LOCALAPPDATA%/Temp/metro-cache``metro-file-map-*` | bundle 时间戳更新了,但内容不含新代码 |
| **Windows 文件锁** | apk/打包中间产物被 adb、杀毒软件、Explorer 预览占用gradle 无法写入/删除 | `packageRelease FAILED` / `externalNativeBuildCleanRelease FAILED` / "另一个程序正在使用此文件" |
| **设备跑旧 APK** | `adb install -r` 时 USB 断开/授权失效,实际没装上 | `adb: no devices``Success` 但应用没更新 |
### 7.2 验证 bundle 是否含新代码(关键!)
**遇到"改了没生效",第一步永远是验证 bundle而不是反复改代码。** 项目用 Hermes 字节码bundle 是二进制。
```bash
BUNDLE="android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle"
# ⚠️ 必须用 grep -a强制文本模式因为 Hermes bundle 是二进制,
# 默认 grep 会跳过二进制文件,导致永远匹配 0 → 误判"代码没进去"
grep -a -c "你新加的字符串常量" "$BUNDLE"
# 例grep -a -c "keyboardDidShow" "$BUNDLE" → 应 ≥1
```
> [!IMPORTANT]
> **绝对不要用 `grep -c "..."`(不带 -a验证 Hermes bundle。** 字符串常量(如事件名 `'keyboardDidShow'`、组件名在字节码里是明文存的minify 不会改变,用 `grep -a` 能可靠检测。本项目曾因误用 `grep`(不加 -a反复重编 5 次,浪费大量时间,根因竟是验证方法错了。
如果 `grep -a` 确认 bundle 含新代码但设备行为没变 → 是「设备跑旧 APK」问题重装/清数据)。
如果 bundle **不含**新代码 → 是「gradle/Metro 缓存」问题,按 §7.3 处理。
### 7.3 强制 bundle 重新生成的可靠步骤
按顺序尝试,通常第 1 步即可:
```bash
cd "/c/Users/fmq/Documents/work/DriftLedger"
# ① 删除 bundle 输出 + sourcemap让 gradle 的 bundle task 不再 UP-TO-DATE
rm -f android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle
rm -f android/app/build/generated/sourcemaps/react/release/index.android.bundle.map
# ② 若 ① 无效task 仍 UP-TO-DATE手动删除整个 app/build绕过 gradle clean
# 因为 clean 常因 CMake/文件锁失败而中断,反而没清掉 bundle
rm -rf android/app/build
# ③ 若仍无效bundle 生成但内容旧):清 Metro 缓存Windows 多处)
rm -rf node_modules/.cache .expo
rm -rf "$LOCALAPPDATA/Temp/metro-cache" "$LOCALAPPDATA/Temp/metro-file-map-"*
rm -rf "$LOCALAPPDATA/Temp/1/metro-cache" "$LOCALAPPDATA/Temp/1/metro-file-map-"*
# ④ 重新编译(带 --no-daemon 可规避 daemon 缓存与锁)
cd android
export JAVA_HOME="/c/Program Files/Java/jdk-21"
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
```
验证打包成功后 bundle 是否更新§7.2),再安装。
### 7.4 Windows 文件锁处理
`packageRelease FAILED``clean FAILED`"另一个程序正在使用此文件")时:
```bash
# 杀掉占用进程adb/Explorer 预览/杀毒扫描最常见)
taskkill //F //IM adb.exe
taskkill //F //IM java.exe
sleep 2
# 重试对应的失败 task不必全量重编
./gradlew.bat :app:packageRelease -PreactNativeArchitectures=arm64-v8a --offline
```
> [!TIP]
> - **不要用 `gradlew clean`**:它依赖 `externalNativeBuildCleanRelease`,该 task 在本项目(含原生 CMake 库)极易因文件锁失败,导致 clean 中断、bundle 也没清掉。改用 `rm -rf app/build` 更可靠。
> - **不要并发跑多个 gradle 进程**:会互相锁文件。编译前 `taskkill //F //IM java.exe` 清理。
> - **`adb install` 前确认设备在线**`adb devices -l`,列表为空说明 USB 断开或授权失效,`install` 会失败或装到错误的设备。
### 7.5 排查决策树
```
改了 TS编译安装后行为没变
├─ grep -a 验证 bundle 含新代码§7.2
│ ├─ 不含 → gradle/Metro 缓存 → §7.3 强制重生成
│ └─ 含 → 设备跑的是旧 APK
│ ├─ adb devices 确认在线 → adb install -r -d 重装
│ └─ 仍不行 → 卸载重装adb uninstall com.example.driftledger && adb install <apk>
```
### 7.6 后台 / 自动化编译陷阱
在 CI、IDE 后台任务、或 agent 的后台 shell 里跑 gradle 时,有几个交互会话遇不到的坑:
| 陷阱 | 现象 | 解决 |
|---|---|---|
| **后台 shell 不继承 `ANDROID_HOME`** | `Failed to apply plugin 'com.facebook.react.rootproject'``SDK location not found ... local.properties` | 写 `android/local.properties``sdk.dir`(见 §0 TIP一劳永逸不依赖 env |
| **命令接 `\| tail`/`\| head` 管道掩盖退出码** | gradle 实际 `BUILD FAILED`,但管道让 shell 退出码 = `tail` 的 0误判成功APK 时间戳其实是旧的 | 后台编译**不要接管道**,让退出码真实反映 gradle判断成败靠读日志的 `BUILD SUCCESSFUL/FAILED` + 核对 APK 时间戳,而非 `$?` |
| **Git Bash 下 `grep -E` 报 `conflicting matchers specified`** | 用 `-E` 组合多模式直接报错退出 | 该环境 grep 别名冲突;改用 `grep -e A -e B`、`sed -n` 或多次 `grep` 串联 |
| **`adb: command not found`** | 后台/新 shell 的 PATH 没有 platform-tools | 用全路径 `$ANDROID_HOME/platform-tools/adb.exe`,或 `export PATH=...` |
> **验证后台编译是否真成功的三重核对**:① stdout 含 `BUILD SUCCESSFUL`;② stderr 无 `^e: ` 开头的 Kotlin 编译错误(编译错误走 stderrstdout 往往只有 `> Task :app:compileReleaseKotlin FAILED`);③ APK 文件时间戳晚于本次编译开始时间。三者缺一即视为失败——尤其别被 `| tail` 的假成功骗到。