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