简介:本资源为SeetaFace6人脸识别算法的Android DEMO工程,面向需要在移动端集成人脸检测、关键点定位、人脸识别等能力的Android开发者,尤其适合具备一定NDK与JNI基础的中高级工程师。工程已内置arm64-v8a与armeabi-v7a两套so库,可直接在主流真机与模拟器上运行调试,省去手动编译的繁琐流程。压缩包共565个文件,约146.83MB,包含276个hpp与87个h头文件、75个xml配置、60个so动态库,以及少量java源码、gradle构建脚本与bin、csta等模型数据文件,覆盖从接口声明、模型资源到工程配置的完整链路。目前已有751人学习下载。借助该工程,读者可快速验证SeetaFace6的人脸检测、活体检测、质量评估、年龄性别估计及口罩检测等能力,并参考作者提供的编译思路完成二次开发与模型替换,降低移动端人脸识别项目的接入门槛。
1. 从一次 NDK 版本对不上的翻车说起
Android 上跑人脸识别,SeetaFace6 是绕不开的一套。它不像 ML Kit 那样开箱即用,也不像某些云端 SDK 那样只给你一个 HTTP 接口——它把 C++ 推理引擎编译成.so动态库,通过 JNI 暴露给 Java/Kotlin 层。这意味着你拿到的 DEMO 工程能不能跑起来,很大程度上取决于一件事:so 库的 ABI 和你项目的 ABI 是否对得上。
我见过太多人在这上面翻车。工程 clone 下来,Android Studio 一开,Gradle sync 过了,兴冲冲点 Run,结果UnsatisfiedLinkError: dlopen failed: library "libseetaface6.so" not found。去jniLibs目录一看,只有arm64-v8a,而模拟器是 x86_64 的。或者反过来,真机是 armeabi-v7a 的老设备,工程里只放了 arm64 的库。这篇笔记就围绕这份包含arm64-v8a 和 armeabi-v7a 双架构 so 库的 SeetaFace6 Android DEMO 工程,把环境要求、导入步骤、ABI 配置、JNI 加载逻辑和验证方法一步步拆开讲。适合正在做 Android 端人脸检测/识别落地、被 so 库折腾过的开发者。
2. 环境准备:JDK、NDK、Gradle 与 ABI 的版本对齐
2.1 这套 DEMO 对工具链的最低要求
SeetaFace6 的 Android DEMO 本质上是一个标准的 Android 工程,核心依赖是预编译好的.so文件和对应的 JNI 封装。它不需要你在本地重新编译 C++ 源码(除非你要改推理逻辑),所以环境门槛比想象中低。但有几个版本红线不能踩:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 11 或 17 | AGP 7.x 要求 JDK 11+,AGP 8.x 要求 JDK 17 |
| Android Gradle Plugin | 7.4.x ~ 8.1.x | 太老的 AGP 不认abiFilters的新写法 |
| Gradle | 7.5 ~ 8.0 | 与 AGP 版本配套,别单独升级 |
| compileSdk | 33 或 34 | 低于 30 会有exported属性报错 |
| minSdk | 21 | SeetaFace6 的 so 最低支持 API 21 |
| NDK | 不需要 | 用预编译 so 时可以不装 NDK,但装了也不冲突 |
这里有个反直觉的点:很多人以为跑 JNI 工程必须装 NDK,其实用预编译 so 的 DEMO 根本不需要。NDK 只在你要从 C++ 源码重新编译 so 时才需要。如果你只是导入工程跑起来,Android Studio 提示你装 NDK 可以直接跳过。我一般会装一个 r25c 放着,但不会在build.gradle里配externalNativeBuild。
JDK 版本是最容易埋雷的地方。AGP 8.x 强制 JDK 17,如果你系统默认是 JDK 8,Gradle sync 会直接报Unsupported class file major version。常见做法是在gradle.properties里指定:
# gradle.properties org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=true然后在 Android Studio 的Settings > Build > Gradle > Gradle JDK里手动选 JDK 17 的路径。命令行构建的话,设JAVA_HOME指向 JDK 17。
2.2 ABI 过滤:为什么必须同时保留两个架构
这份 DEMO 的价值就在于它同时提供了arm64-v8a和armeabi-v7a两套 so。arm64-v8a是现在绝大多数真机的架构,64 位,性能好;armeabi-v7a是 32 位 ARM,覆盖老设备和一些低端机。如果你只保留一个,另一类设备直接崩。
在app/build.gradle里,ABI 过滤这样写:
// app/build.gradle android { defaultConfig { ndk { // 只保留这两个 ABI,避免打包进 x86 等无用架构 abiFilters 'arm64-v8a', 'armeabi-v7a' } } sourceSets { main { // so 库放在 libs 目录下,按 ABI 分子目录 jniLibs.srcDirs = ['libs'] } } }abiFilters的作用是告诉 Gradle:打包 APK 时只保留这两个架构的 so。如果你不加这行,而libs目录下恰好有 x86 的 so,它也会被打进去,APK 体积白白变大。jniLibs.srcDirs则是指定 so 的搜索路径——默认是src/main/jniLibs,但很多 DEMO 工程习惯放在libs下,所以需要显式声明。
目录结构必须严格按 ABI 分:
app/ libs/ arm64-v8a/ libseetaface6.so libseetaface_detector.so ... armeabi-v7a/ libseetaface6.so libseetaface_detector.so ...目录名一个字母都不能错。写成arm64_v8a或者armeabi_v7a,Gradle 不会报错,但运行时就是找不到库。这是血泪经验。
2.3 导入工程后的第一次 sync 检查清单
工程导入后,先别急着 Run。按这个顺序过一遍:
- 打开
app/build.gradle,确认compileSdk、minSdk、targetSdk没有报红。 - 确认
jniLibs.srcDirs指向的目录下确实有arm64-v8a和armeabi-v7a两个文件夹。 - 检查
gradle-wrapper.properties里的 Gradle 版本和 AGP 版本是否匹配。 - 点
Sync Now,看 Build 窗口有没有UnsatisfiedLinkError之外的错误。
如果 sync 阶段就报Could not find method ndk(),说明 AGP 版本太低,ndk块的位置不对——在 AGP 7.x 里ndk应该放在defaultConfig下,而不是android下。
3. 导入与配置:so 库加载、JNI 接口与权限声明
3.1 so 库的加载顺序与 System.loadLibrary
SeetaFace6 的 JNI 封装通常会在一个静态代码块里加载 so。你拿到的 DEMO 里一般有一个SeetaFaceEngine或类似的类,里面写着:
// SeetaFaceEngine.java static { // 加载顺序有讲究:先加载基础库,再加载依赖它的模块库 System.loadLibrary("seetaface6"); System.loadLibrary("seetaface_detector"); System.loadLibrary("seetaface_recognizer"); }System.loadLibrary("seetaface6")会自动补全前缀lib和后缀.so,去jniLibs对应的 ABI 目录下找libseetaface6.so。加载顺序不能乱:如果libseetaface_detector.so依赖libseetaface6.so里的符号,就必须先加载libseetaface6。顺序反了会报dlopen failed: cannot locate symbol。
如果你在 DEMO 里没找到静态代码块,可能是用ReLinker或者手动System.load绝对路径加载的。手动加载的写法:
// 手动指定绝对路径加载(不推荐,但调试时有用) String libPath = getApplicationInfo().nativeLibraryDir + "/libseetaface6.so"; System.load(libPath);nativeLibraryDir是 APK 安装后 so 被解压到的实际路径。用System.loadLibrary时系统会自动去这个目录找,所以大多数情况不需要手动拼路径。
3.2 AndroidManifest 权限与硬件加速配置
人脸识别要调摄像头,权限声明不能少:
<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-feature android:name="android.hardware.camera" android:required="true" /> <uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />maxSdkVersion这个属性容易被忽略。Android 13(API 33)之后存储权限拆成了READ_MEDIA_IMAGES等细分权限,老的READ_EXTERNAL_STORAGE在 API 33+ 上声明了也没用。DEMO 如果只是从摄像头实时取帧,其实不需要存储权限;但如果要从相册选图做人脸比对,就得按版本适配。
另外,如果 DEMO 里用了TextureView或SurfaceView做预览,建议在<application>里加:
<application android:hardwareAccelerated="true" ...>硬件加速对 SeetaFace6 的推理速度有影响,但某些老设备上开启后反而会导致预览花屏。如果遇到花屏,先试试关掉硬件加速对比一下。
3.3 把 so 库正确放进工程的两种方式
方式一:放在app/libs下,配合jniLibs.srcDirs = ['libs']。这是 DEMO 最常见的做法,好处是 so 和 Java 代码分离,升级 so 时不用动src/main。
方式二:放在app/src/main/jniLibs下,这是 Android Studio 的默认路径,不需要额外配jniLibs.srcDirs。缺点是 so 混在源码目录里,git 管理时容易误提交。
两种方式选一种就行,不要同时放。我遇到过有人两处都放了 so,结果 Gradle 打包时重复文件冲突,报Duplicate files copied in APK。解决办法是在packagingOptions里排除,但更好的做法是删掉多余的那份。
// 如果确实需要处理重复 so,用 pickFirst android { packagingOptions { pickFirst 'lib/arm64-v8a/libseetaface6.so' pickFirst 'lib/armeabi-v7a/libseetaface6.so' } }pickFirst的意思是遇到重复文件时取第一个,不报错。但这只是补救,根本解决还是保证 so 只放一处。
4. 避坑与排查:so 加载失败的 5 种典型场景
4.1 UnsatisfiedLinkError: dlopen failed: library not found
现象:APK 装到真机上,一打开就闪退,Logcat 里UnsatisfiedLinkError,提示找不到某个libxxx.so。
原因:三种可能。一是 so 根本没打进 APK;二是打进去了但 ABI 不匹配(比如设备是 arm64,APK 里只有 armeabi-v7a);三是 so 文件名和System.loadLibrary里的名字对不上。
解决:先把 APK 解压,看lib/目录下有没有对应的 ABI 文件夹和 so 文件。命令:
# 解压 APK 查看 so 是否打进去 unzip -l app-debug.apk | grep "\.so"如果lib/arm64-v8a/下是空的,说明abiFilters或jniLibs.srcDirs配错了。如果 so 在但设备加载失败,用adb shell getprop ro.product.cpu.abi确认设备实际 ABI。
4.2 UnsatisfiedLinkError: cannot locate symbol
现象:so 找到了,但加载时报cannot locate symbol "__aeabi_memcpy"或类似符号缺失。
原因:这是 32 位和 64 位混用导致的。armeabi-v7a的 so 依赖 32 位的系统库符号,如果设备是 64 位但 APK 只提供了 32 位 so,某些符号在 64 位运行时里找不到。反过来,64 位 so 在 32 位设备上根本跑不了。
解决:确保abiFilters里同时有arm64-v8a和armeabi-v7a,让系统根据设备自动选。如果只需要支持 64 位设备,那就只保留arm64-v8a,但要在build.gradle里明确声明,避免 Gradle 自动把 32 位也打进去。
4.3 模拟器上跑不起来
现象:真机正常,Android Studio 自带模拟器一跑就崩。
原因:主流模拟器是 x86_64 架构,而这份 DEMO 只提供了 ARM 的 so。x86_64 模拟器加载 ARM so 需要二进制翻译,性能极差且经常失败。
解决:换 ARM 架构的模拟器镜像(Apple Silicon Mac 上的模拟器是 arm64 的,可以直接跑),或者直接用真机调试。我一般调试 JNI 工程都插真机,模拟器只用来调 UI。
4.4 Gradle sync 报 Duplicate class 或 Duplicate files
现象:sync 阶段报Duplicate class或Duplicate files copied in APK。
原因:so 在libs和src/main/jniLibs两处都放了,或者依赖库里也带了同名 so。
解决:先find . -name "*.so"全局搜一遍,确认 so 只在一个地方。如果是依赖库冲突,用packagingOptions的pickFirst或exclude处理。
4.5 运行时闪退但没有明显报错
现象:Logcat 里没有UnsatisfiedLinkError,但 App 启动就闪退,或者调用识别接口时崩。
原因:可能是 so 加载成功了,但 JNI 方法签名对不上。比如 Java 层声明的是native void init(String modelPath),C++ 层实现的是Java_com_example_SeetaFace_init,包名或类名不一致就会找不到方法。
解决:用javah或javac -h生成头文件对比方法签名。或者直接看 DEMO 里 JNI 类的包名,确保和 so 编译时的包名一致。如果 DEMO 提供了源码,重新编译 so 时包名不能改。
5. 验证跑通:从安装到人脸检测出结果的完整检查链
装完不是终点,能跑出结果才算。我一般按这条链验证:
第一步,确认 so 加载成功。在Application或首个Activity的onCreate里加一行日志:
// 在加载 so 的静态代码块后加日志 Log.d("SeetaFace", "nativeLibraryDir: " + getApplicationInfo().nativeLibraryDir); Log.d("SeetaFace", "so loaded successfully");如果这行日志打出来了,说明 so 加载没问题。如果没打出来就崩了,回到第 4 章排查。
第二步,确认模型文件就位。SeetaFace6 的推理需要模型文件(.csta或.dat),这些文件通常放在assets目录下,首次启动时拷贝到内部存储。检查assets里有没有模型文件,以及拷贝逻辑是否执行:
// 检查模型文件是否拷贝成功 File modelDir = new File(getFilesDir(), "seetaface_models"); if (!modelDir.exists()) { // 从 assets 拷贝 copyAssets("seetaface_models", modelDir.getAbsolutePath()); } Log.d("SeetaFace", "model files: " + Arrays.toString(modelDir.list()));如果modelDir.list()返回 null 或空数组,说明拷贝失败。常见原因是assets里的模型文件名大小写和代码里写的不一致。
第三步,用一张测试图跑检测。DEMO 里一般有一个「选择图片」或「拍照检测」的按钮。选一张清晰的正脸照,看能不能画出人脸框。如果框的位置明显偏移,可能是模型文件版本和 so 版本不匹配——SeetaFace6 的不同小版本之间模型格式有差异。
第四步,看推理耗时。在检测接口前后打时间戳:
long start = System.currentTimeMillis(); // 调用 native 检测方法 List<FaceInfo> faces = engine.detect(bitmap); long cost = System.currentTimeMillis() - start; Log.d("SeetaFace", "detect cost: " + cost + "ms, faces: " + faces.size());arm64-v8a 真机上,单张 640x480 图片的检测耗时一般在 50~150ms 之间。如果超过 500ms,检查是不是误用了 armeabi-v7a 的 so(32 位性能差一截),或者模型文件放错了目录导致走了 fallback 逻辑。
第五步,连续跑 10 次看内存。JNI 层如果没做好资源释放,连续检测会导致 native 内存泄漏。用 Android Studio 的 Profiler 看 Native 内存曲线,如果每次检测后都涨一截不回落,说明detect返回的对象没被正确释放。DEMO 里一般有release()方法,确认在onDestroy里调了。
这套检查链走完,基本能确定工程是真正跑通了,而不是「编译过了但一用就崩」。从那以后我每次导入带 so 的 Android 工程,都强制先解压 APK 看lib/目录,再插真机跑一遍检测——模拟器省的那点时间,不够填排查 so 问题的坑。希望帮到你。
本文还有配套的精品资源,点击获取