### 架构重构:三层分层目录化 - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
371 lines
21 KiB
Markdown
371 lines
21 KiB
Markdown
# 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/TSX(JS Bundle 由 Metro/assemble 自动打入)
|
||
> - 只改了 `android/app/build.gradle`、`gradle.properties` 等已生成的 Gradle 配置
|
||
>
|
||
> [!WARNING]
|
||
> **改了 TS 后,release 包偶尔不会更新**(见 §7):gradle 的 `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 但安装后行为没变 | 忘记重新 prebuild(android/ 是旧的) | `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;改的是 .kt,Metro 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 编译错误(编译错误走 stderr,stdout 往往只有 `> Task :app:compileReleaseKotlin FAILED`);③ APK 文件时间戳晚于本次编译开始时间。三者缺一即视为失败——尤其别被 `| tail` 的假成功骗到。
|