简介:面向 Android 平台的人脸识别开发 DEMO 工程,适合需要在移动端快速验证 SeetaFace6 能力的技术人员与中高级开发者,可用于人脸检测、关键点定位、人脸识别、活体检测、质量评估、年龄性别估计,以及口罩检测与戴口罩场景识别等功能的集成参考。压缩包共 565 个文件,约 146.83MB,以 C++ 头文件(hpp、h)、Android 工程配置与界面资源(xml、java、gradle)、预编译动态库(so)及模型/数据文件(bin、csta)为主,并同时提供 arm64-v8a 与 armeabi-v7a 两套 so,便于覆盖主流真机架构并减少自行编译成本。已有 756 人学习,工程目录完整,可对照官方算法模块理解 JNI 调用、模型加载与前后处理流程,也适合作为二次开发基线或排查编译、架构适配问题的参照。
1. 一个能跑起来的 seetaface6 Android DEMO,卡住人的往往不是算法
上周有人在群里贴了一段日志:把 seetaface6 的 Android DEMO 拖进 Android Studio,Gradle Sync 过了,Run 也过了,Logcat 里只留一句dlopen failed: library "libSeetaFaceDetector.so" not found。工程目录翻了三遍,so 明明就在里面——问题是他那份压缩包里 arm64-v8a 和 armeabi-v7a 两个目录放的是同一份 32 位文件,目录名对了,内容不对,加载器照样不认。
这类 so 库工程的翻车点几乎全在环境侧:ABI 目录名写错一个字符、Gradle 版本和 JDK 对不上、模型文件没拷到可读路径。装好了是一次性的事,装不好能耗掉一个下午。下面这套流程适合手上已经拿到 seetaface6 Android DEMO(带 arm64-v8a 与 armeabi-v7a 两套 so)的开发者,从版本对齐一路走到真机跑通。
2. 装前对齐四件套:JDK、Gradle、NDK、CMake 的版本怎么钉死
老 DEMO 最大的问题是它出生得太早。很多 seetaface6 的 Android 工程还停留在 AGP 3.x + Gradle 5.x 的年代,用新版本 Android Studio 打开,第一屏就是红字:The project is using an incompatible version of the Android Gradle plugin。这个时候有两条路,降 Studio 版本或者把 AGP 升上去。我一般选后者,因为 Studio 的安装包和索引缓存占的磁盘比工程本身大得多,降版本是给自己找麻烦。
2.1 一张表把版本钉死,别让工具替你选
先把目标版本写进工程根目录的备忘里,后面所有报错都对着这张表排查:
| 组件 | 建议版本 | 为什么是它 |
|---|---|---|
| JDK | 17 | AGP 8.x 强制要求,低版本直接拒绝编译 |
| Gradle | 8.7 | 与 AGP 8.4 稳定搭配,太新会触发 API 移除告警 |
| AGP | 8.4.2 | 支持 namespace、支持 jniLibs 新打包策略 |
| NDK | 26.1.10909125(r26b) | 与 r28 相比,对老 so 更友好 |
| CMake | 3.22.1 | SDK Manager 内置版本,不额外下载 |
| compileSdk | 34 | 低于 33 会在依赖解析时报一堆警告 |
| minSdk | 21 | seetaface6 的 so 常见下限,写低了 dlopen 会失败 |
| targetSdk | 34 | 与 compileSdk 保持一致最省事 |
minSdk 这一项值得多说一句。它不是「随便写个 21 就行」的字段,so 在编译时绑定了一个最低 API 级别,如果 App 的 minSdk 低于 so 的编译目标,安装能过,运行时加载会直接失败,而且报错信息通常只说「找不到库」,不会告诉你真正原因。拿不准的时候,用llvm-readelf -A看 so 的.note.android.ident段,里面写着它要求的最低 API。
2.2 JDK 和 Gradle 是绑死的,别单独升级其中一个
AGP 8.x 只认 JDK 17,而 Gradle 8.7 支持 JDK 8 到 21。所以顺序是:先定 AGP,再定 Gradle,最后配 JDK。工程里改两处文件就够。
gradle/wrapper/gradle-wrapper.properties:
# 只改这一行,让 wrapper 去下对应的 Gradle 发行版 distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zipgradle.properties:
# 多 ABI 打包时 dex + 资源合并很吃内存,2g 经常 OOM org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=trueorg.gradle.jvmargs这一行是血泪经验。带两套 so 的工程在assembleDebug阶段要把 so 解压、strip、再压回 APK,同时还要跑 dex 合并,默认堆内存下很容易出现java.lang.OutOfMemoryError: Java heap space,报错栈还指不到真正的位置,像个黑匣子。改成 4g 之后这类报错基本消失。
JDK 的切换在命令行里做,别指望 IDE 的配置面板每次都生效:
# macOS:列出本机所有 JDK,把 17 那个的路径写进 JAVA_HOME /usr/libexec/java_home -V export JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew -version./gradlew -version的输出里会打印JVM:那一行,确认是 17.x 再往下走。Windows 下把JAVA_HOME指到C:\Program Files\Java\jdk-17即可,改完要重开终端,环境变量不会在已打开的窗口里生效。
2.3 NDK 与 CMake:不动 native 层也必须装对
seetaface6 的 Android DEMO 绝大多数是纯 Java/Kotlin 调 JNI,so 是预编译产物,工程本身不参与 native 编译。很多人因此跳过 NDK 安装,结果在配置abiFilters或者 later 想换一份 so 的时候被卡住。装上不亏,占几百 MB。
# 先看本地已经有什么,避免重复下载 sdkmanager --list | grep -E "ndk;|cmake;" # 指定版本安装,别用 --install 不写版本号,会拉到最新版 sdkmanager "ndk;26.1.10909125" "cmake;3.22.1"装完之后在app/build.gradle里把版本写死:
android { // 显式指定,避免机器上装了多个 NDK 时被随机挑中 ndkVersion "26.1.10909125" }local.properties里只留sdk.dir就够,ndk.dir在 AGP 8 已经废弃,写了反而会触发一条「请改用 ndkVersion」的警告。我在两台机器上对比过,同一份工程在写了ndk.dir的机器上构建产物会多出一层目录,打包脚本跟着改,属于自己给自己挖坑。
3. so 库落位:arm64-v8a 与 armeabi-v7a 两个目录不能糊弄
拿到压缩包解压之后,第一件事不是往工程里拖,而是先确认两套 so 是不是真的两套。用文件头判断最稳,不依赖文件名,也不依赖压缩包里的目录提示。
# 分别看两个目录下同名 so 的机器类型 llvm-readelf -h arm64-v8a/libSeetaFaceDetector.so | grep Machine llvm-readelf -h armeabi-v7a/libSeetaFaceDetector.so | grep Machine正确输出应该是AArch64和ARM两种。如果两个目录下都是ARM,说明这份包里的 arm64-v8a 是复制粘贴凑数的,装到 64 位真机上必定UnsatisfiedLinkError。llvm-readelf在 NDK 的toolchains/llvm/prebuilt/<平台>/bin/下面,把它加进 PATH 后面会一直用到。
3.1 jniLibs 的目录结构只有四种合法写法
Gradle 认的 ABI 目录名只有armeabi-v7a、arm64-v8a、x86、x86_64四个,多一个下划线、少一个字母都不会报错,只会被静默忽略。这就是玄学的地方:目录在那儿,APK 里就是没有。
app/src/main/ ├── java/ # 业务代码与 JNI 封装类 ├── assets/ │ └── models/ # 模型文件,随 APK 打包 └── jniLibs/ ├── arm64-v8a/ │ ├── libSeetaFaceDetector.so │ ├── libSeetaFaceLandmarker.so │ ├── libSeetaFaceRecognizer.so │ ├── libSeetaFaceTracker.so │ ├── libSeetaFaceAgePredictor.so │ ├── libSeetaFaceGenderPredictor.so │ ├── libSeetaFaceEyeStateDetector.so │ ├── libSeetaFaceMaskDetector.so │ └── libSeetaFaceQualityAssessor.so └── armeabi-v7a/ └── 同名 so,一套完整的 32 位版本具体的 so 文件名以你手上这份包为准,大小写敏感,libSeetaFaceDetector.so和libseetafaceDetector.so在 Linux 上是两个东西。两个 ABI 目录下的文件清单必须完全一致,少一个模块的 so,用到那个模块的时候才会崩,排查成本极高。
如果这份包里带libSeetaAuthorize.so之类的授权校验库,也一并放进去。授权文件(常见是一个.dat或.license后缀的小文件)一般放 assets 根目录或者随模型一起放,路径写错的表现不是崩溃,而是初始化返回空指针——这种静默失败比崩溃更难查。
3.2 abiFilters 决定「打包哪些」,不是「能加载哪些」
app/build.gradle里这一段是核心:
android { defaultConfig { ndk { // 只把这两套打进 APK,去掉会白占体积的 x86 系列 abiFilters 'arm64-v8a', 'armeabi-v7a' } } sourceSets { main { // 默认就是这个路径,工程是从别处搬来的话要显式声明 jniLibs.srcDirs = ['src/main/jniLibs'] } } }abiFilters的作用是过滤,不是声明能力。写进去的 ABI 才会被塞进 APK;不写的即使目录里躺着 so 也会被丢掉。反过来,写进去但目录里没有对应文件,Gradle 不会报错,只会打一条 warning,APK 里那个 ABI 就是空的,设备一旦命中就直接崩溃。所以配完这段一定要回去unzip -l验证一次。
jniLibs.srcDirs在标准工程里可以不写,但如果这份 DEMO 的 so 放在libs/或者根目录的jniLibs/下,就必须显式指过去。我见过把 so 放在app/libs/arm64-v8a/的工程,作者本地能跑是因为他 IDE 里配了自定义路径,换台机器立刻失效。
3.3 模型文件只能落在真实路径上,assets 里的不算
native 层初始化模型时拿到的是一个文件路径,而 assets 里的文件在 APK 里是压缩存储的,没有真实路径可给。所以标准做法是首次启动把模型从 assets 拷到应用私有目录。
// 首次启动拷贝模型,已存在则跳过,避免每次冷启动都写盘 private File prepareModel(String name) throws IOException { File dir = new File(getExternalFilesDir(null), "models"); if (!dir.exists() && !dir.mkdirs()) { throw new IOException("create model dir failed: " + dir); } File out = new File(dir, name); if (out.exists() && out.length() > 0) { return out; // 长度校验,防止上次拷贝被中途杀掉留下空文件 } try (InputStream in = getAssets().open("models/" + name); OutputStream os = new FileOutputStream(out)) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) > 0) { os.write(buf, 0, len); } } return out; }getExternalFilesDir(null)在 Android 10 之后不需要任何存储权限,路径是/sdcard/Android/data/<包名>/files/,卸载即清空,适合放这类可再生的模型文件。用getFilesDir()也可以,区别是内部存储空间更紧张,模型多了容易顶到配额。out.length() > 0这个判断是后悔药:拷贝过程被系统杀掉会留下 0 字节文件,下次启动直接读会拿到一个「模型损坏」的返回值。
4. 编译配置:把老 DEMO 拉到 AGP 8 上的三处必改项
AGP 从 3.x 跨到 8.x,有三处是硬性变更,不补齐连编译都过不去。这三处报错信息写得都不够直白,新人容易在第一条上卡很久。
4.1 namespace、exported、compileSdk
第一处是namespace。AGP 8 移除了在AndroidManifest.xml里写package的做法,改到 build 文件里:
android { // 必须与 Java/Kotlin 源码的包路径一致,否则 R 类找不到 namespace 'com.example.seetaface6demo' compileSdk 34 }同时把 manifest 里的package="..."删掉,留着会报package attribute is deprecated。第二处是 Android 12 起强制要求的android:exported,带 intent-filter 的 Activity 必须显式声明:
<activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>这一项漏了的表现是安装阶段直接失败,报INSTALL_FAILED_...加一句 manifest 合并错误,不是运行时崩溃,所以装不上去的时候先看 manifest 而不是看代码。第三处是摄像头权限,DEMO 里如果是直接调用Camera.open()的老写法,Android 6 以上必须运行时申请,否则黑屏:
<uses-permission android:name="android.permission.CAMERA" /> <uses-feature android:name="android.hardware.camera" android:required="true" />uses-feature里的required="true"会影响应用商店的可见性,只做内部测试无所谓,要上架的话按实际需求改。
4.2 打包完先验一次,两个 ABI 都得在
# 只编 debug,别一上来就 release,签名会多一层干扰 ./gradlew :app:assembleDebug # 列出 APK 里的 native 库,确认两个目录都在且文件数对得上 unzip -l app/build/outputs/apk/debug/app-debug.apk | grep 'lib/'正常输出里lib/arm64-v8a/和lib/armeabi-v7a/各有一组同名文件。如果只有一组,回到 3.2 检查abiFilters;如果目录在但文件数是 0,检查jniLibs.srcDirs指的位置对不对。这一步花三十秒,能省掉后面在真机上反复装包的十分钟。
4.3 减包体的取舍:split abi 还是 universal apk
两套 so 加上模型文件,APK 很容易到几十兆。如果只是自己调试,直接出 universal apk 最省事;要分发给不同架构的设备,用 split:
android { splits { abi { enable true reset() include 'arm64-v8a', 'armeabi-v7a' // 同时产出一个包含全部 ABI 的包,方便随手装 isUniversalApk true } } }代价是产物变成三个 APK,版本号需要自己按 ABI 分配 offset,否则同一台设备升不上去。调试阶段我一般不开 split,等确定要发版再打开。还有一个更省的选择是只保留 arm64-v8a,现在 32 位设备基本只剩老旧机型,但如果你的使用者里有这类设备,armeabi-v7a 就不能砍。
5. 避坑与排查:五条血泪记录,每条都能对上一条日志
5.1 dlopen failed: library "libSeetaFaceDetector.so" not found
现象:APK 装上了,一进人脸检测页面就崩,日志里就这一句,路径里甚至带着/data/app/.../lib/arm64/,看起来像系统找不到。
原因:三种可能——目录名写错导致 so 没进包、abiFilters只留了一个 ABI 但设备是另一个、或者放进去的 so 本身就是错架构的文件。
解决:先unzip -l确认 APK 里有这个文件,再用llvm-readelf -h确认文件头是AArch64。两边都对还报错的话,看System.loadLibrary的调用顺序,有依赖关系的库必须先加载。
5.2 dlopen failed: cannot locate symbol "__aeabi_memcpy"
现象:库找到了,但加载时报符号找不到,通常发生在 armeabi-v7a 上。
原因:so 编译时链接了libc++_shared.so,而工程只把业务 so 拷进了 jniLibs,C++ 运行时库没跟着打进去。arm64-v8a 上有时能过是因为系统自带了一份,32 位不一定。
解决:把 NDK 里的libc++_shared.so一并放进两个 ABI 目录,路径在$ANDROID_NDK/toolchains/llvm/prebuilt/<平台>/sysroot/usr/lib/下面,按 ABI 各取一份。如果工程自己有 native 编译,改arguments "-DANDROID_STL=c++_shared"更彻底。
5.3 Could not find method namespace() / 同步直接失败
现象:Gradle Sync 一秒失败,报方法不存在。
原因:Gradle 版本升上去了,AGP 还停在 3.x,或者反过来。两者是配套关系,不能各升各的。
解决:回到第 2 章的表,把gradle-wrapper.properties和根目录build.gradle里的 AGP 版本对齐。改完必须点一次File > Invalidate Caches,否则 IDE 还在用旧的模型跑同步。
5.4 模型初始化返回空,日志里什么都没有
现象:不崩溃,检测框一个都不出,加日志才发现初始化函数返回了空指针。
原因:模型文件路径不对,或者文件拷过去了但大小是 0,或者模型版本和 so 版本不匹配——seetaface6 的模型文件和库是有版本对应的,混用会失败但不会报错。
解决:在初始化前把路径、文件大小、文件头几个字节打进日志;确认模型是从这份包里原样拷出来的,不要拿另一份工程的模型凑。
5.5 arm64 设备正常,armeabi-v7a 设备闪退
现象:新手机跑得好好的,一台老机器装上去就退。
原因:32 位 so 用了 NEON 指令,而老设备的 CPU 不支持;或者 32 位 so 根本没打进去,设备回退加载失败。
解决:先用adb shell getprop ro.product.cpu.abilist看设备支持的 ABI 列表,再决定要不要保 32 位。真要保,就得找一份不带 NEON 依赖的构建;seetaface6 这类计算密集的库在 32 位上性能本来就吃紧,抽帧降分辨率是必要的。
6. 装完怎么验证:三条命令确认 so 真的被加载进来了
编译通过不等于跑通。我在工程里固定加一个自检入口,启动时打一行日志,把实际加载的 ABI 和库名都吐出来:
object SoLoader { // 顺序有讲究:被依赖的库排在前面 private val LIBS = listOf( "SeetaFaceDetector", "SeetaFaceLandmarker", "SeetaFaceRecognizer" ) fun load(): Boolean = runCatching { LIBS.forEach { System.loadLibrary(it) } Log.i("SoLoader", "loaded=${LIBS.size} abi=${Build.SUPPORTED_ABIS.first()}") true }.onFailure { Log.e("SoLoader", "load failed: ${it.message}") }.getOrDefault(false) }配合三条命令,基本能把「so 到底有没有被加载」这件事钉死:
# 1. 确认 APK 里的 native 库清单 unzip -l app-debug.apk | grep 'lib/' # 2. 确认设备实际加载的 ABI 是 64 位还是 32 位 adb shell dumpsys package com.example.seetaface6demo | grep -i primaryCpuAbi # 3. 过滤加载日志,看有没有 load failed adb logcat -s SoLoader:V AndroidRuntime:E顺手再查一项 16KB 页对齐。新版本 Android 在部分设备上把内存页从 4KB 改成 16KB,so 的 LOAD 段如果没按 16KB 对齐,装得上但加载失败:
# LOAD 段的 Align 应该是 0x4000(16KB),0x1000 是老的 4KB llvm-readelf -l libSeetaFaceDetector.so | grep LOAD三条命令都过了,再跑一次实际检测,看耗时和识别结果。我习惯在自检里顺手打一个初始化耗时,正常情况下 det + landmark + recognizer 三个模块加载完在一秒以内,超过三秒说明模型是从网络或者慢速存储上读的,路径该换了。
从那以后我每次拿到一份带 so 的 Android 工程,都强制先走一遍readelf看架构、unzip看产物、dumpsys看运行时 ABI 这三步,再动手改代码。这套流程花不到五分钟,能挡掉九成的加载失败。希望帮到你。
本文还有配套的精品资源,点击获取