DriftLedger/docs/android-build-guide.md
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

530 lines
16 KiB
Markdown

# Android 编译与真机运行完整指南
> 本文档记录了从零开始搭建 Android 编译环境、修复 Config Plugin / Kotlin / 依赖冲突、到最终在真机上成功运行 debug APK 的完整流程。
>
> 适用环境:Windows 11 + Expo SDK 54 + React Native 0.81 + PP-OCRv5 (ONNX Runtime)。
---
## 目录
1. [环境准备](#1-环境准备)
2. [安装 Android SDK + NDK](#2-安装-android-sdk--ndk)
3. [ONNX 模型获取](#3-onnx-模型获取)
4. [expo prebuild](#4-expo-prebuild)
5. [Gradle 编译](#5-gradle-编译)
6. [安装到真机](#6-安装到真机)
7. [Metro + 运行](#7-metro--运行)
8. [常见问题与解决方案](#8-常见问题与解决方案)
9. [已修复的 Bug 清单](#9-已修复的-bug-清单)
---
## 1. 环境准备
### JDK
React Native 0.81 + Gradle 8.x 需要 **JDK 17+**。本项目使用 **JDK 21**
```bash
# 验证
java -version
# 应输出: java version "21.0.x"
# 确认 JAVA_HOME 指向正确的 JDK
echo $JAVA_HOME
# 应输出: C:\Program Files\Java\jdk-21
```
> **注意**:如果 `JAVA_HOME` 指向了别的用户的残留目录(如 `D:\Users\yxp\JDK`),需要通过「系统属性 → 环境变量 → 系统变量」修正为实际 JDK 路径。
### Node.js
```bash
node -v # 需要 v20+
npm -v
```
---
## 2. 安装 Android SDK + NDK
### 方式 A:Android Studio(推荐)
1. 下载 Android Studio:https://developer.android.com/studio
2. 安装时勾选 Android SDK / SDK Platform / Android Virtual Device
3. 首次启动自动下载 SDK 到默认位置 `C:\Users\<用户>\AppData\Local\Android\Sdk`
### 方式 B:命令行(轻量)
如果已装 Android Studio 但缺少 cmdline-tools:
```bash
SDK="$HOME/AppData/Local/Android/Sdk"
TMP="/tmp"
# 下载 cmdline-tools(官方,约 130MB)
curl -L -o "$TMP/cmdline-tools.zip" \
"https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip"
# 解压到标准位置
mkdir -p "$SDK/cmdline-tools"
unzip -q "$TMP/cmdline-tools.zip" -d "$TMP/"
cp -r "$TMP/cmdline-tools" "$SDK/cmdline-tools/latest"
```
### 必装组件
```bash
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
SDKMGR="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager.bat"
# 接受所有 license
yes | "$SDKMGR" --licenses
# 安装必需组件
yes | "$SDKMGR" \
"platform-tools" \
"platforms;android-35" \
"build-tools;35.0.0"
```
| 组件 | 用途 |
|------|------|
| `platform-tools` | adb(设备连接) |
| `platforms;android-35` | 编译目标 SDK |
| `build-tools;35.0.0` | aapt/dex/打包工具 |
| `cmdline-tools;latest` | sdkmanager(expo prebuild 需要) |
### NDK(React Native 编译 C++ 需要)
React Native 0.81 要求 **NDK 27.1.12297006**(~745MB)。
#### 国内推荐:腾讯云镜像下载
Google 官方源在国内经常断连导致 zip 损坏。**强烈推荐用腾讯云镜像**:
```bash
SDK="$HOME/AppData/Local/Android/Sdk"
TMP="/tmp"
# 从腾讯云镜像下载(55MB/s 稳定,约 13 秒)
curl -L -o "$TMP/ndk.zip" \
"https://mirrors.cloud.tencent.com/AndroidSDK/android-ndk-r27b-windows.zip"
# 解压
mkdir -p "$TMP/ndk-extract"
unzip -q "$TMP/ndk.zip" -d "$TMP/ndk-extract"
# 安装到标准位置
mv "$TMP/ndk-extract/android-ndk-r27b" "$SDK/ndk/27.1.12297006"
```
#### expo-sqlite 的 NDK 版本冲突
`expo-sqlite` 可能要求 NDK `27.0.12077973`(不同于 RN 要求的 `27.1.12297006`)。
解决方式:用 Windows 目录联接(junction)让两个版本指向同一份 NDK:
```powershell
# 在 PowerShell 中执行
cmd /c mklink /J `
"C:\Users\<用户>\AppData\Local\Android\Sdk\ndk\27.0.12077973" `
"C:\Users\<用户>\AppData\Local\Android\Sdk\ndk\27.1.12297006"
```
> 两个版本号 ABI/API 兼容,联接不会导致编译问题。
#### NDK 完整性验证
NDK 解压后必须验证 arm64 目标库完整,否则 CMake 会报 "clang broken":
```bash
SDK="$HOME/AppData/Local/Android/Sdk"
NDK="$SDK/ndk/27.1.12297006"
# 关键文件必须存在
ls "$NDK/sysroot/usr/lib/aarch64-linux-android/24/crtbegin_dynamic.o" \
"$NDK/sysroot/usr/lib/aarch64-linux-android/libc.a" \
"$NDK/toolchains/llvm/prebuilt/windows-x86_64/lib/clang/18/lib/linux/libclang_rt.builtins-aarch64-android.a"
```
> ⚠️ 如果 C 盘空间不足(NDK 解压后约 2.3GB),解压会中途截断,导致 `libc.a` 等文件缺失。确保至少有 **5GB 可用空间**。
---
## 3. ONNX 模型获取
本项目使用 PP-OCRv5 + ONNX Runtime(替代 NCNN,见 plan.md 决策 7)。
### 下载社区 ONNX 模型
```bash
cd plugins/ppocr/assets
mkdir -p . && cd .
# det 模型(4.8 MB)
curl -L -o ppocrv5_det.onnx \
"https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx"
# rec 模型(16.6 MB)
curl -L -o ppocrv5_rec.onnx \
"https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx"
# CJK 字典(26 KB,PaddleOCR 标准字典)
curl -L -o ppocr_keys_v1.txt \
"https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/release/2.6/ppocr/utils/ppocr_keys_v1.txt"
```
### 验证模型 I/O 规格
```python
import onnx
for name in ['det', 'rec']:
m = onnx.load(f'plugins/ppocr/assets/ppocrv5_{name}.onnx')
print(f'{name}:')
for i in m.graph.input:
shape = [d.dim_value or d.dim_param for d in i.type.tensor_type.shape.dim]
print(f' input {i.name}: {shape}')
for o in m.graph.output:
shape = [d.dim_value or d.dim_param for d in o.type.tensor_type.shape.dim]
print(f' output {o.name}: {shape}')
```
预期输出:
- det: 输入 `x: [N, 3, H, W]`(动态),输出 `[N, 1, H, W]`
- rec: 输入 `x: [N, 3, 48, W]`(高固定 48),输出 `[N, T, 18385]`
---
## 4. expo prebuild
`expo prebuild` 把 Config Plugin 注入到原生工程(生成 `android/` 目录)。
```bash
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
npx expo prebuild --platform android --no-install
```
### 验证注入结果
```bash
# Kotlin 源码(应有 9 个 .kt 文件)
find android/app/src/main/java/com/beancount -name "*.kt" | sort
# ONNX 模型 + 字典
ls android/app/src/main/assets/
# MainApplication 里 OcrPackage 注册
grep "OcrPackage" android/app/src/main/java/com/example/beanmobile/MainApplication.kt
# onnxruntime gradle 依赖
grep "onnxruntime" android/app/build.gradle
# AndroidManifest 服务注册
grep "Billing" android/app/src/main/AndroidManifest.xml
```
### android/ 目录锁定问题(Windows + ZCode)
如果 `android/` 目录被进程锁定导致 prebuild 无法 `rmdir`,在临时副本项目中 prebuild 再 robocopy 回来:
```bash
# 1. 创建临时副本
TMP="/tmp/bm-prebuild"
mkdir -p "$TMP"
cp app.json package.json package-lock.json tsconfig.json "$TMP/"
cp -r app/ src/ plugins/ "$TMP/"
# node_modules 用 junction 共享
powershell -NoProfile -Command "cmd /c mklink /J '$TMP/node_modules' '$(pwd)/node_modules'"
# 2. 在临时项目 prebuild
cd "$TMP"
npx expo prebuild --platform android --no-install --clean
# 3. robocopy 回原项目(能处理锁定)
powershell -NoProfile -Command "robocopy '$TMP/android' '$(pwd -W || pwd)/android' /E /PURGE"
```
---
## 5. Gradle 编译
### 前提:onnxruntime 版本修正
Config Plugin 注入的 `app/build.gradle` 中 onnxruntime 版本可能不匹配,每次 prebuild 后需检查:
```bash
# 确认是 1.20.0(不是 1.20.1,后者不存在)
grep "onnxruntime" android/app/build.gradle
# 如果是 1.20.1,修正:
sed -i 's/onnxruntime-android:1.20.1/onnxruntime-android:1.20.0/' android/app/build.gradle
```
### 编译 Kotlin(快速验证,不打包)
```bash
cd android
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
./gradlew :app:compileDebugKotlin --no-daemon
```
### 完整打包 APK
```bash
./gradlew :app:assembleDebug --no-daemon
```
APK 输出位置:`android/app/build/outputs/apk/debug/app-debug.apk`(~250MB)
### Gradle 缓存清理(遇到诡异问题时)
```bash
# 删 build 缓存
rm -rf app/build build app/.cxx
# 彻底重来(连 Gradle daemon 也杀)
powershell -NoProfile -Command "Get-Process -Name 'java' -ErrorAction SilentlyContinue | Stop-Process -Force"
```
---
## 6. 安装到真机
### 开启 USB 调试
1. 手机「设置 → 关于手机」→ 连续点击「MIUI 版本」7 次 → 开启开发者选项
2. 「设置 → 更多设置 → 开发者选项」→ 打开:
- ✅ USB 调试
-**USB 安装**(红米/小米必须开,否则 `adb install` 被拒为 `INSTALL_FAILED_USER_RESTRICTED`)
3. USB 连接电脑(文件传输模式)
### adb install
```bash
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
ADB="$ANDROID_HOME/platform-tools/adb"
# 确认设备连接
"$ADB" devices
# 应显示 device 状态
# 安装
"$ADB" install -r android/app/build/outputs/apk/debug/app-debug.apk
```
---
## 7. Metro + 运行
Debug APK 需要从 Metro server 加载 JS bundle(不内嵌 JS)。
### 启动 Metro
```bash
npx expo start --dev-client --port 8081
```
### adb reverse(USB 连接,手机和电脑不在同一 WiFi 时必需)
```bash
"$ADB" reverse tcp:8081 tcp:8081
```
### 启动 app
```bash
# 命令行启动
"$ADB" shell am start -n com.example.beanmobile/.MainActivity
# 或直接在手机桌面点击「Bean Mobile」图标
```
### 验证
```bash
# 进程存活?
"$ADB" shell pidof com.example.beanmobile
# JS 日志
"$ADB" logcat -d | grep "ReactNativeJS"
# 截图
"$ADB" shell screencap -p /sdcard/screen.png
"$ADB" pull /sdcard/screen.png /tmp/app-screen.png
```
---
## 8. 常见问题与解决方案
### `withAndroidMainApplication is not a function`
**原因**:SDK 54 的 `@expo/config-plugins` 没有 `withAndroidMainApplication` / `withAndroidGradle`
**解决**:改用 `withMainApplication` + `withAppBuildGradle` + `withDangerousMod`
### `Cannot read properties of undefined (reading '0')` in AndroidManifest
**原因**:`manifest.application` 在 prebuild 初始模板里可能不存在。
**解决**:操作前加防御性检查:
```javascript
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
```
### `modResults` 结构搞错导致 manifest 重复 `<root>` 标签
**原因**:`withAndroidManifest` 的 `modResults` 结构是 `{ manifest: { ... } }`,不是直接就是 manifest。
**解决**:所有权限和 application 操作通过 `modConfig.modResults.manifest`,不是 `modConfig.modResults` 本身。
### MainApplication 里 import OcrPackage 没注入
**原因**:Kotlin 的 package 声明**没有分号**(`package com.example.beanmobile`,不是 `package com.example.beanmobile;`),正则 `^(package [\w.]+;)` 匹配不到。
**解决**:正则去掉分号要求:`^(package\s+[\w.]+;?\s*)$`。
### Config Plugin 返回 undefined 导致整个 config 丢失
**原因**:插件函数最后漏了 `return config;`,导致 `withAndroidManifest` 的结果没返回,config 变成 undefined,后续所有插件崩溃。
**解决**:每个插件函数末尾必须有 `return config;`
### `NDK at ...27.0.12077973 did not have a source.properties file`
**原因**:expo-sqlite 要求 NDK 27.0,但只装了 27.1;Gradle 自动下载 27.0 失败留下空目录。
**解决**:用 `mklink /J` 创建目录联接(见第 2 节)。
### NDK `clang broken` / `cannot open crtbegin_dynamic.o`
**原因**:NDK 解压时磁盘空间不足,arm64 目标库被截断。
**解决**:确保至少 5GB 可用空间,重新从腾讯云镜像下载并解压。
### `onnxruntime-android:1.20.1` not found
**原因**:Maven Central 上最新稳定版是 `1.20.0`,`1.20.1` 不存在。
**解决**:`sed -i 's/1.20.1/1.20.0/' android/app/build.gradle`
### `NoSuchMethodError: getDirectConverter` (expo-font 崩溃)
**原因**:`@expo/vector-icons@15.x` 拉进了 `expo-font@57.0.0`(SDK 55 版本),与 `expo-modules-core@3.0.x`(SDK 54)不兼容。
**解决**:在 `package.json` 加 npm override 强制降级:
```json
"overrides": {
"expo-font": "~14.0.12"
}
```
### `Unable to resolve "expo-linking"`
**原因**:expo-router 的 peer dependencies 没有全部安装。
**解决**:补全所有 peer deps:
```bash
npm install --legacy-peer-deps \
@expo/metro-runtime expo-constants expo-linking \
react-dom react-native-gesture-handler react-native-reanimated \
@react-navigation/drawer
```
### `INSTALL_FAILED_USER_RESTRICTED` (红米/小米)
**原因**:MIUI 的「USB 安装」开关默认关闭。
**解决**:开发者选项里打开「USB 安装」。
### `EBUSY: resource busy or locked, rmdir android/`
**原因**:ZCode 的子进程(NodeBabyLinkService)持有 `android/` 目录句柄,`rmdir` 失败。
**解决**:在临时副本项目里 prebuild,再用 `robocopy /E /PURGE` 复制回原项目(见第 4 节)。
### `attribute android:canTakeScreenshots not found`
**原因**:`canTakeScreenshots` 是 API 30+ 属性,部分 compileSdk 配置下 AAPT 不识别。
**解决**:从 `accessibility_service_config.xml` 删除该属性(非必需,截图功能在 Kotlin 代码里实现)。
---
## 9. 已修复的 Bug 清单
以下是真机编译过程中暴露并修复的全部 bug(共 26 个):
### Config Plugin Bug(7 个)
| 文件 | Bug | 修复 |
|------|-----|------|
| `plugins/ppocr/app.plugin.js` | `withAndroidMainApplication` 不存在 | 改用 `withMainApplication` + `withAppBuildGradle` + `withDangerousMod` |
| `plugins/ppocr/app.plugin.js` | `withDangerousMod` 回调里 `modConfig.platformProjectRoot` undefined | 改为 `modConfig.modRequest.platformProjectRoot` |
| `plugins/*/app.plugin.js`(3 个) | `manifest.application[0]` 崩溃(prebuild 初始 manifest 无 application 节点) | 加 `Array.isArray` 防御 |
| `plugins/ppocr/app.plugin.js` | MainApplication import 没注入(Kotlin package 无分号) | 正则去掉分号要求 |
| `plugins/accessibility/app.plugin.js` | `return config` 丢失导致整个 config 变 undefined | 补回 `return config` |
### Kotlin 编译 Bug(11 个)
| 文件 | Bug | 修复 |
|------|-----|------|
| `FloatingTip.kt:80` | `val layoutParams` 不可重赋值 + 类型不匹配 | 拆出 apply 块 + 用 LinearLayout.LayoutParams |
| `OcrTileService.kt:50` | `startActivityAndCollapse(intentSender)` 参数不匹配 | 改传 PendingIntent |
| `OcrModule.kt:179` | `putDouble(confidence)` Float 不匹配 | `.toDouble()` |
| `OcrModule.kt:215,235` | `longArrayOf(1,3,...)` Int vs Long | 显式 `.toLong()` + `L` 后缀 |
| `OcrModule.kt:221,242` | `detOutputs.forEach{it.close()}` 未解析 | 改用 `detOutputs.close()`(OrtSession.Result.close) |
| `OcrModule.kt:333` | det 输出维度多了 channel 维,`prob[y][x]` 是 FloatArray 不是 Float | `[0][0]` 剥掉 N+C 两维,函数签名改 `Array<FloatArray>` |
### 资源/环境 Bug(8 个)
| 问题 | 修复 |
|------|------|
| cmdline-tools 缺失 | 从 Google 官方下载安装 |
| platform 35 缺失 | sdkmanager 安装 |
| NDK 27.0 vs 27.1 版本冲突 | `mklink /J` 目录联接 |
| NDK 下载损坏(Google 源断连) | 腾讯云镜像替代 |
| `accessibility_service_config.xml` canTakeScreenshots 属性 | 删除(非必需) |
| `onnxruntime:1.20.1` 不存在 | 改用 1.20.0 |
| expo-font 57.0 vs 14.0 版本冲突 | npm overrides 强制降级 |
| expo-router peer deps 缺失 | 补全 8 个依赖包 |
---
## 附录:完整一键流程
```bash
# === 环境变量(每次新终端都要设) ===
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export ANDROID_HOME="C:/Users/$USER/AppData/Local/Android/Sdk"
# === 1. 依赖 ===
npm install --legacy-peer-deps
# === 2. prebuild ===
npx expo prebuild --platform android --no-install
sed -i 's/onnxruntime-android:1.20.1/onnxruntime-android:1.20.0/' android/app/build.gradle
# === 3. 编译 ===
cd android
./gradlew :app:assembleDebug --no-daemon
# === 4. 安装 ===
cd ..
"$ANDROID_HOME/platform-tools/adb" install -r android/app/build/outputs/apk/debug/app-debug.apk
# === 5. Metro + 运行 ===
npx expo start --dev-client --port 8081 &
sleep 10
"$ANDROID_HOME/platform-tools/adb" reverse tcp:8081 tcp:8081
"$ANDROID_HOME/platform-tools/adb" shell am start -n com.example.beanmobile/.MainActivity
```