放弃 Bazel,用 Android Studio + CMake 从源码编译 MediaPipe Tasks

1 阅读6分钟

▎ 摘要:Google 官方的 MediaPipe 示例靠预编译 AAR 开箱即用,但想改 C++ 源码、加自定义算子、解决 16KB 页对齐这类问题时,AAR 就不够用了。本文分享一套「把 Bazel 构建图翻译成 CMake,再用 AGP 的 externalNativeBuild 在 Android Studio 里编译」的完整方案:三级原生模块依赖链、bazel aquery 代码生成、第三方源码 vendor 化,以及踩过的 8 个坑。


前言

用 MediaPipe 做端侧 AI,最常见的姿势是:

implementation 'com.google.mediapipe:tasks-vision:0.10.x'

这条路线胜在简单——AAR 里已经预编译好了 libmediapipe_tasks_jni.so 和整套 Java 封装。但一旦你的需求越过「调用现成 API」这条线,问题就来了:

  • 要改 C++ 源码(自定义算子、改预处理、调模型图);
  • 要启用 GPU 自定义算子或裁剪不必要依赖;
  • 撞上 16KB 页对齐、特定 NDK ABI 等设备兼容问题,预编译 .so 对不上;
  • 想要可调试、能单步进 C++ 的符号,而不是一份黑盒 .so。

而 MediaPipe 官方源码用的是 Bazel。Bazel 的依赖图精确、可复现,但对 Android 开发者来说门槛高、和 Android Studio 集成差、链路长。

于是就有了这条路:保留 Bazel 依赖图作为唯一事实来源,把它翻译成 CMake,再用 Android Studio 直接编。 工程地址 github.com/lyqaiym/fac…

▎ 一句话概括本文方案:Bazel 负责"真相",CMake 负责"落地",Android Studio 负责"日常"。

一、整体架构:三级依赖链

最终产物是一个纯 Android Studio 工程。:app 是上游的 Face Landmarker 示例 UI,真正的重头戏是下面三级原生模块,全部从 vendor 进来的源码用 CMake 编译:

:opencv → libopencv_java4.so ↓ (导出 .so + 头文件) :LiteRT → libtensorflow-lite (前身 TFLite) ↓ :mediapipe_tasks → libmediapipe_tasks_jni.so (app 真正 load 的 JNI 层) ↓ :app (Google Face Landmarker 示例)

每一级都编出自己的 .so,下一级链接它:

  1. :opencv —— 从 vendor 的 OpenCV 4.12.0 源码编出 libopencv_java4.so(target opencv_export),把 .so + 配置头导出到 opencv/src/main/cpp/export/。
  2. :LiteRT —— 编 libtensorflow-lite(target tensorflow-lite),它的第三方依赖(abseil-cpp、XNNPACK、ruy、eigen、flatbuffers、protobuf、cpuinfo、pthreadpool…)都以 git checkout 形式 vendor 在 LiteRT/src/main/cpp//。
  3. :mediapipe_tasks —— 编 libmediapipe_tasks_jni.so(target mediapipe_tasks_jni),app 实际加载的 JNI 层。

工具链版本在根 build.gradle 的 ext 里锁死:

ext { minSdkVersion = 24 compileSdkVersion = 34 targetSdkVersion = 34 ndkVersion = "28.2.13676358" cmakeVersion = "4.1.2" }

配套 Gradle 8.7 / AGP 8.5.1 / Kotlin 1.9.24,原生模块只编 arm64-v8a。


二、核心思路:把 Bazel 依赖图"翻译"成 CMake

这是整套方案里最关键的一步。

2.1 极薄的 CMakeLists

mediapipe_tasks 的 CMakeLists.txt 被刻意写得极薄——只声明依赖层,真正的对象库和链接命令全在 generated/*.cmake 里:

include(generated/10_common.cmake) # 每个 target 的编译 flag 口味 include(generated/20_protos.cmake) # protoc include(generated/30_flatbuffers.cmake) # flatc include(generated/40_configure.cmake) # 模板展开、descriptor set include(generated/45_codegen.cmake) # 所有对象库等待的屏障 include(generated/50_mediapipe.cmake) # //mediapipe/... 对象库 include(generated/60_external.cmake) # icu、re2、sentencepiece 等对象库 include(generated/90_link.cmake) # 最终链接出 .so

▎ 这些 .cmake 不是手写的,也不能手改。它们由 tools/bazel2cmake/ 从 Bazel 的 aquery 输出自动生成。 2.2 用 aquery 导出编译图

原理很直接:Bazel 的一个 .so target,本质上就是「一堆编译单元 + 编译参数 + 链接参数」。aquery 能把整套编译图 dump 成 JSON,生成器再把它还原成等价的 CMake。

tools/bazel2cmake/regen.sh 的核心:

SRC="$ROOT/mediapipesource" TARGET='//mediapipe/tasks/java/com/google/mediapipe/tasks/core:libmediapipe_tasks_jni.so' CONFIG=(--config=android_arm64 -c opt)

export ANDROID_NDK_HOME="{ANDROID_NDK_HOME:-ANDROID_HOME/ndk/28.2.13676358}"

dump() { local out="1"filter="1" filter="2" (cd "SRC" && bazel aquery "{CONFIG[@]}"
"mnemonic("filter,¨deps(filter\", deps(TARGET))"
--output=jsonproto --include_artifacts=true) >"SRC/SRC/out.tmp" mv "SRC/SRC/out.tmp" "SRC/SRC/out" }

dump compile_actions.json 'CppCompile' dump genproto_actions.json 'GenProto' dump codegen_actions.json 'CppArchive|CppLink|GenProtoDescriptorSet|Genrule|TemplateExpand'

python3 "ROOT/tools/bazel2cmake/main.py"dumps"ROOT/tools/bazel2cmake/main.py" --dumps "SRC"
--out "$ROOT/mediapipe_tasks/src/main/cpp/generated"

它按 mnemonic 过滤出三类动作(C++ 编译 / proto 生成 / 打包链接与代码生成),分别 dump,再交给 main.py 生成 .cmake。 2.3 分工明确

  • Bazel 负责"真相":只有 mediapipesource/ 里的 BUILD 文件变了,才需要重跑一次 regen.sh。
  • Android Studio 负责"日常":平时构建直接消费已提交的 .cmake,既不需要 Bazel,也不需要 Python。

▎ 这种「代码生成式」迁移,避免了手写几千行 CMake 去对齐一个不断演进的 Bazel 图,也保证了 CMake 版本和 Bazel 版本行为一致。


三、第三方源码的 vendor 化

flatbuffers.sh 是一个幂等的一次性脚本,负责复现整棵第三方依赖树:

  • 把每个第三方仓库 clone 到钉死的 commit/tag(版本集中在脚本顶部变量里,不散落在几十条 clone 命令中);
  • 应用 tools/patches/ 下的补丁;
  • 编出宿主 flatc 到 build/host-flatc(:LiteRT 通过 TFLITE_HOST_TOOLS_DIR 引用)。

MODULES=" opencv/src/main/cpp/opencv|github.com/opencv/open… glog/src/main/cpp/glog|github.com/google/glog…... # ... mediapipesource|git@github.com:google-ai-edge/mediapipe.git|master "

LITERT_DEPS=" LiteRT|git@github.com:google-ai-edge/LiteRT.git|v2.1.6 abseil-cpp|github.com/abseil/abse… # ... "

▎ 一个很务实的决策:补丁只打影响 CMake 编译的。Bazel 里的 BUILD/.bzl 补丁一律不打,因为 CMake 直接编 .cc/.h 源码,根本不经过 BUILD 文件。

补丁主要解决 CMake 专用问题:audio_tools 的 C++ 修复、icu 的 udata、sentencepiece、stb_image 补实现文件,以及最关键的 litert_custom_ops.diff——把 MediaPipe 的 GPU 自定义算子补进 LiteRT(v2.1.6 原生一个都没有)。

四、构建步骤

4.1 前置条件

  • local.properties 配好 sdk.dir(每台机器各自路径,已 gitignore)。根 build.gradle 读它并暴露成 ext.ANDROID_SDK 传给 CMake,兜底读 ANDROID_HOME / ANDROID_SDK_ROOT;
  • NDK 28.2.13676358(AGP 按 ndkVersion 自动装);
  • face_landmarker.task 模型由 app/download_tasks.gradle 在 preBuild 时从 Google storage 拉到 app/src/main/assets/。

4.2 常用命令

编整个 app

./gradlew :app:assembleDebug

只编某个原生 .so

./gradlew :mediapipe_tasks:externalNativeBuildDebug ./gradlew :LiteRT:externalNativeBuildDebug ./gradlew :opencv:externalNativeBuildDebug

单测(骨架)

./gradlew :app:testDebugUnitTest

全新 checkout 上先跑一次 flatbuffers.sh 拉齐依赖树;之后每次构建都是纯 Android Studio 流程。

五、踩过的坑(都是真金白银)

坑: C++ 标准不一致 现象: absl::Status 的 MakeErrorImpl 链接失败
解法: :mediapipe_tasks 和 :LiteRT 强制 -std=c++20(:opencv 用 c++17),否则 absl::SourceLocation 被解析成两个不同类型 ──────────────────────────────────────── 坑: AGP 编了 1600 个多余 target 现象: 报找不到 Python.h 解法: 每个模块 externalNativeBuild.cmake 配 targets(mediapipe_tasks_jni / tensorflow-lite / opencv_export),AGP 默认会编所有发现的共享库 target 且无视 EXCLUDE_FROM_ALL ──────────────────────────────────────── 坑: glog 污染缓存 现象: 重配置后 abseil/tflite 被编成 90 多个 .so 解法: set(BUILD_SHARED_LIBS OFF CACHE BOOL ... FORCE),glog 的 option(BUILD_SHARED_LIBS ON) 会写缓存,必须赶在第一次 add_subdirectory 前钉死 ──────────────────────────────────────── 坑: XNNPACK 缺 liblog 现象: __android_log_vprint 未定义 解法: target_link_libraries(xnnpack-logging PUBLIC log),Bazel 里这来自 Android crosstool 默认 linkopts ──────────────────────────────────────── 坑: 16KB 页对齐 现象: libimage_processing_util_jni.so 加载失败 解法: CameraX 钉到 1.4.2,1.3.4 的 .so 是 4KB 对齐 ──────────────────────────────────────── 坑: OpenCV ABI 不一致 现象: 混用 SDK 预编译和自编 .so 崩溃 解法: 只用 vendor 的 4.12.0 源码编出的 .so + 头文件,两者对 HAVE_IPP/HAVE_TBB 的定义必须一致 ──────────────────────────────────────── 坑: NDK 头文件引用 现象: mediapipe/util/cpu_util.cc 找不到 cpu-features.h 解法: CMake 里做一个 ndkroot/ndk 符号链接指向 ANDROID_NDK,复刻 Bazel 里 @androidndk 的目录层级

▎ 这些坑大多数都藏在「Bazel 和 CMake 的默认行为不一致」里:Bazel 靠 crosstool 和 WORKSPACE 隐式补齐的东西,CMake 需要你显式写出来。

六、总结

这套方案的本质,是把「Bazel 构建图」当成唯一构建真源,用代码生成器映射到 CMake,让你能在 Android Studio 里像普通原生工程一样编译、断点、调试 MediaPipe。

它换来的:

  • 可改源码:改 C++、加算子、换模型图都不再受制于预编译 AAR;
  • 可复现:依赖全部钉死 commit/tag,flatbuffers.sh 幂等;
  • 可维护:上游 BUILD 文件变了,重跑 regen.sh 即可同步,不用手改几千行 CMake。

代价也很明显:

  • 首次搭建成本高(vendor 源码 + 跑 Bazel 生成 + 编三块 .so);
  • mediapipe_tasks 目标 .so 把 LiteRT 全家桶、OpenCV、glog 等全部静态链接进去,体积和编译时间都不小。

参考资料