▎ 摘要: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,下一级链接它:
- :opencv —— 从 vendor 的 OpenCV 4.12.0 源码编出 libopencv_java4.so(target opencv_export),把 .so + 配置头导出到 opencv/src/main/cpp/export/。
- :LiteRT —— 编 libtensorflow-lite(target tensorflow-lite),它的第三方依赖(abseil-cpp、XNNPACK、ruy、eigen、flatbuffers、protobuf、cpuinfo、pthreadpool…)都以 git checkout 形式 vendor 在 LiteRT/src/main/cpp//。
- :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="2"
(cd "SRC" && bazel aquery "{CONFIG[@]}"
"mnemonic("TARGET))"
--output=jsonproto --include_artifacts=true) >"out.tmp"
mv "out.tmp" "out"
}
dump compile_actions.json 'CppCompile' dump genproto_actions.json 'GenProto' dump codegen_actions.json 'CppArchive|CppLink|GenProtoDescriptorSet|Genrule|TemplateExpand'
python3 "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 等全部静态链接进去,体积和编译时间都不小。
参考资料
- google-ai-edge/mediapipe (github.com/google-ai-e…)
- google-ai-edge/LiteRT (github.com/google-ai-e…)
- MediaPipe Tasks 官方文档 (ai.google.dev/edge/mediap…)