# 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` 的假成功骗到。
