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

16 KiB

Android 编译与真机运行完整指南

本文档记录了从零开始搭建 Android 编译环境、修复 Config Plugin / Kotlin / 依赖冲突、到最终在真机上成功运行 debug APK 的完整流程。

适用环境:Windows 11 + Expo SDK 54 + React Native 0.81 + PP-OCRv5 (ONNX Runtime)。


目录

  1. 环境准备
  2. 安装 Android SDK + NDK
  3. ONNX 模型获取
  4. expo prebuild
  5. Gradle 编译
  6. 安装到真机
  7. Metro + 运行
  8. 常见问题与解决方案
  9. 已修复的 Bug 清单

1. 环境准备

JDK

React Native 0.81 + Gradle 8.x 需要 JDK 17+。本项目使用 JDK 21

# 验证
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

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:

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"

必装组件

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 损坏。强烈推荐用腾讯云镜像:

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 中执行
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":

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 模型

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 规格

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/ 目录)。

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

验证注入结果

# 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 回来:

# 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 后需检查:

# 确认是 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(快速验证,不打包)

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

./gradlew :app:assembleDebug --no-daemon

APK 输出位置:android/app/build/outputs/apk/debug/app-debug.apk(~250MB)

Gradle 缓存清理(遇到诡异问题时)

# 删 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

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

npx expo start --dev-client --port 8081

adb reverse(USB 连接,手机和电脑不在同一 WiFi 时必需)

"$ADB" reverse tcp:8081 tcp:8081

启动 app

# 命令行启动
"$ADB" shell am start -n com.example.beanmobile/.MainActivity

# 或直接在手机桌面点击「Bean Mobile」图标

验证

# 进程存活?
"$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 初始模板里可能不存在。

解决:操作前加防御性检查:

if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
  manifest.application = [{ $: {} }];
}

modResults 结构搞错导致 manifest 重复 <root> 标签

原因:withAndroidManifestmodResults 结构是 { 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 强制降级:

"overrides": {
  "expo-font": "~14.0.12"
}

Unable to resolve "expo-linking"

原因:expo-router 的 peer dependencies 没有全部安装。

解决:补全所有 peer deps:

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 个依赖包

附录:完整一键流程

# === 环境变量(每次新终端都要设) ===
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