DriftLedger/docs/android-build-guide.md
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

21 KiB
Raw Blame History

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工作目录为项目根

# 环境变量(每次新开 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.propertiessdk.dir=C\:\\Users\\fmq\\AppData\\Local\\Android\\Sdkgradle 会优先读它,前台/后台 shell 都不再依赖环境变量。该文件在 .gitignore 内、不进仓库。后台/自动化编译尤其需要它——后台新 shell 不继承交互会话里的 ANDROID_HOME,缺 local.properties 会直接 SDK location not found 失败(详见 §7.6)。

# ① 删除旧的原生工程,强制重新生成(确保原生改动被注入)
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.jsonappId、权限、插件配置
  • 在新机器上首次拉取代码

何时只需直接编译(无需 prebuild

  • 只改了 src/ 下的 TS/TSXJS Bundle 由 Metro/assemble 自动打入)
  • 只改了 android/app/build.gradlegradle.properties 等已生成的 Gradle 配置

[!WARNING] 改了 TS 后release 包偶尔不会更新(见 §7gradle 的 createBundleReleaseJsAndAssets 可能因为缓存跳过重新打包,或 Metro 用了 transformer cache。若发现"改了代码但设备上行为没变"先按 §7.2 验证 bundle 是否真的含新代码,而不是反复改代码。


1. 体积优化核心原理

1.1 ABI 分包 (ABI Splits)

在 Android 系统的 build.gradle 中,我们启用了分包编译:

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 中指定了默认编译目标为:

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 Pluginsize-optimization。 当在新设备上重新 git clone 项目后,运行以下指令即可自动拉起所有 Config Plugin 并在重新生成的 android/ 目录中完美注入上述所有的体积优化配置ABI 分包、默认单架构编译、NDK 版本统一):

npx expo prebuild --platform android

2. 编译命令与打包指令

请在项目的根目录(若已在 android/ 目录中则不需要前缀 cd android)执行以下指令:

2.1 本地测试/实体分发(仅编译 arm64-v8a最快最推荐

直接运行默认编译,会使用 gradle.properties 中配置的 arm64-v8a

Git Bash推荐与本文档 §0 的 TL;DR 一致):

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

$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 以及一个通用包,可以在命令行中通过参数覆盖默认架构

$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 原生安卓模拟器上测试:

.\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=x86_64 --offline --no-daemon

即可秒级编译出专门针对模拟器的 app-x86_64-release.apk


3. Proguard/R8 混淆(已默认开启)

本项目的 gradle.properties默认启用代码混淆与资源压缩:

android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true

这使得 arm64-v8a 独立包从 ~37MB 压缩到约 24MB。无需手动开启。

Warning

若新增了依赖反射/动态加载的库(如 ONNX Runtime 的 JNI 调用),混淆后可能出现运行时 ClassNotFoundException。此时需在 android/app/proguard-rules.pro(由 ppocr 等 Config Plugin 注入)中补充保留规则,例如:

-keep class com.microsoft.onnxruntime.** { *; }
-dontwarn com.microsoft.onnxruntime.**

调试混淆问题时,可临时把上述两个属性改为 false 排查是否为混淆所致。


4. 通过 adb 安装到真机

编译产物就绪后,用 adb 覆盖安装到已连接的设备(保留应用数据):

# 确认设备已连接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 Pluginplugins/*/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 有一条「通用包名替换」规则:

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 子包引用单独改写到伪装包:

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 编译时暴露。快速自查注入结果:

# 检查目标包名/类是否被注入到预期路径
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 但安装后行为没变 忘记重新 prebuildandroid/ 是旧的) 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.jsonandroid.package 为准,本项目为 com.example.driftledger

# 例:只改了 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改的是 .ktMetro bundle 不受影响)
cd android && ./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon

Warning

这条捷径只对「改 .kt 内容」成立。以下情况必须走 §0 全量 prebuild否则 android/ 副本与注入结果不一致:改了 app.plugin.js 注入/复制逻辑;新增或删除 .kt/资源文件(手动同步不会更新 MainApplicationadd(...) 注入或 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-cachemetro-file-map-* bundle 时间戳更新了,但内容不含新代码
Windows 文件锁 apk/打包中间产物被 adb、杀毒软件、Explorer 预览占用gradle 无法写入/删除 packageRelease FAILED / externalNativeBuildCleanRelease FAILED / "另一个程序正在使用此文件"
设备跑旧 APK adb install -r 时 USB 断开/授权失效,实际没装上 adb: no devicesSuccess 但应用没更新

7.2 验证 bundle 是否含新代码(关键!)

遇到"改了没生效",第一步永远是验证 bundle而不是反复改代码。 项目用 Hermes 字节码bundle 是二进制。

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 步即可:

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 FAILEDclean FAILED"另一个程序正在使用此文件")时:

# 杀掉占用进程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.propertiessdk.dir(见 §0 TIP一劳永逸不依赖 env
命令接 | tail/| head 管道掩盖退出码 gradle 实际 BUILD FAILED,但管道让 shell 退出码 = tail 的 0误判成功APK 时间戳其实是旧的 后台编译不要接管道,让退出码真实反映 gradle判断成败靠读日志的 BUILD SUCCESSFUL/FAILED + 核对 APK 时间戳,而非 $?
Git Bash 下 grep -Econflicting matchers specified -E 组合多模式直接报错退出 该环境 grep 别名冲突;改用 grep -e A -e Bsed -n 或多次 grep 串联
adb: command not found 后台/新 shell 的 PATH 没有 platform-tools 用全路径 $ANDROID_HOME/platform-tools/adb.exe,或 export PATH=...

验证后台编译是否真成功的三重核对:① stdout 含 BUILD SUCCESSFUL;② stderr 无 ^e: 开头的 Kotlin 编译错误(编译错误走 stderrstdout 往往只有 > Task :app:compileReleaseKotlin FAILED);③ APK 文件时间戳晚于本次编译开始时间。三者缺一即视为失败——尤其别被 | tail 的假成功骗到。