简介:面向Android平台的人脸识别开发DEMO工程,围绕SeetaFace6算法封装了人脸检测、关键点定位、人脸识别、活体检测、质量评估及年龄性别估计等能力,并适配arm64-v8a与armeabi-v7a两种ABI的so库,适合需要在移动端快速验证人脸识别流程的初中级Android开发者与算法集成人员参考。压缩包共565个文件、约146.83MB,以hpp/h头文件、xml配置、so动态库为主,辅以java源码、gradle构建脚本与少量bin、png等资源,目录涵盖工程源码、原生库与构建产物,便于按模块查阅和二次集成。已有756人学习下载,对想了解SeetaFace6在Android端如何组织依赖、加载原生库以及跑通口罩检测与口罩场景人脸识别模型的读者,具有直接的参考价值。
1. 从一台 Android 15 手机说起:android seetaface 的 so 为什么突然装不进去
一个开发者把 seetaface6 的 Android DEMO 装到新买的 Android 15 手机上,APK 装得上,图标能点开,一进人脸检测页面就闪退,logcat 里翻来覆去只有一行dlopen failed: library "libseetaface6.so" not found。文件明明躺在jniLibs/arm64-v8a里,APK 里也解得出这个 so,可它就是不加载。这种闪退非常典型:android seetaface 这类带预编译 so 的老工程,问题往往不在 Java 层,而在 ABI 目录、C++ 运行时依赖和 so 的段对齐上。装不好最轻是白屏闪退,最重是编译都过不去,卡在INSTALL_FAILED_NO_MATCHING_ABIS上反复重装。这篇记录的是我完整装一遍 seetaface6 Android DEMO 的过程,包含 arm64-v8a 与 armeabi-v7a 两套 so 的放置、Gradle 配置、模型文件搬迁和验证手段,适合手里已经拿到这份 DEMO、要把它在自己机器上跑起来的人。
2. 装之前把版本钉死:JDK、NDK、Gradle 三件套怎么选
预编译 so 的工程最怕的一件事,是拿最新版 Android Studio 直接Open老工程,然后一路点「升级」。seetaface6 的 DEMO 大多停留在 AGP 4.x 到 7.x 之间,Gradle 用的还是 6.x 甚至 5.x,你一点升级,AGP 跳到 8.x,JDK 要求从 8 变成 17,构建脚本里的compileSdkVersion、buildConfigField、packagingOptions一堆语法全变,还没开始玩人脸,先花两天修构建脚本。我一般的做法是:先把工程跑起来,再考虑升级,绝不倒过来。
2.1 JDK 版本跟 AGP 走,不跟 Android Studio 走
Android Studio 自带 JetBrains Runtime,但命令行./gradlew用的是JAVA_HOME。这两个经常不是同一个版本,于是出现「IDE 里能编译,终端里报Unsupported class file major version」的经典错位。对应关系记牢:
| AGP 版本 | 最低 JDK | Gradle 版本 | 说明 |
|---|---|---|---|
| 4.1 / 4.2 | JDK 8 | 6.5 ~ 6.7.1 | 老 DEMO 最常见,别升级 |
| 7.0 ~ 7.4 | JDK 11 | 7.0.2 ~ 7.5 | 开始要求 JDK 11 |
| 8.0 及以上 | JDK 17 | 8.0+ | 需要 namespace,老 so 工程改动大 |
先看清工程根目录gradle/wrapper/gradle-wrapper.properties里写的是哪个版本,再反推 AGP 和 JDK。不要看build.gradle里的 AGP 写得新就以为 Gradle 也新,两者对不上是Could not find method implementation()这类报错的高发区。
# 先确认命令行用的是哪个 JDK,别只看 IDE 的 java -version echo $JAVA_HOME # 再确认工程声明的 Gradle 版本 cat gradle/wrapper/gradle-wrapper.properties | grep distributionUrl # 需要切 JDK 时临时指定,不影响系统全局 export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-11.jdk/Contents/Home ./gradlew --version./gradlew --version输出的前几行里既有 Gradle 版本也有 JVM 版本,一条命令同时把两个变量亮出来,比在 IDE 设置面板里翻半天可靠。我习惯在跑任何构建命令前先敲这一下,三秒钟,省掉一堆「明明改了却不生效」的玄学。
2.2 NDK 只在你要改 JNI 封装层时才需要
这份 DEMO 的价值在于so 已经编译好了,arm64-v8a 和 armeabi-v7a 两份都在。也就是说,绝大多数人不需要装 NDK 就能跑通——你只是把 so 打进 APK,再通过 Java 层写好的 native 方法调用它。把 NDK 当成必需品,是本末倒置。
只有当你要动 JNI 接口(比如自己加一个nativeInit的重载),或者要和一份新版本的 so 对接时,才需要 NDK。这种情况下 NDK 版本要和 so 编译时用的 STL 对齐。seetaface6 的官方 so 一般走c++_shared,所以必须把libc++_shared.so一起打进去。常见做法是用 NDK r21e(21.4.7075529)或 r25c,前者兼容老工程,后者对 16KB 页对齐友好,二选一即可,别混。
// app/build.gradle 里锁死 NDK 与 CMake,避免多人协作时版本漂移 android { ndkVersion "21.4.7075529" // 有 externalNativeBuild 时才需要这一行 // cmake { version "3.10.2" } }ndkVersion写在模块级build.gradle里,比放在local.properties里更靠谱——后者不进版本库,换台机器就丢。真要完全不碰 C++,这一行也可以不写,省得 Android Studio 见到它就要求你下载 NDK。
2.3 SDK 版本与 minSdk 的取舍
compileSdkVersion决定你用什么 API 编译,minSdkVersion决定装机的下限。seetaface6 的 so 用的是 C++11 和标准 STL,minSdkVersion 21是常见起点——低于 21 会遇到部分 C++ 运行时符号缺失。targetSdkVersion建议不要一味追新,30 或 33 都比较稳;追到 34、35 会强制触发 16KB 页对齐、前台服务类型等一堆新约束,而你的 so 是几年前编的,很容易在真机上撞墙。
提示:minSdk 从 21 提到 23、24,能少掉一批老设备上的加载问题,但如果你的目标用户里有大量低端机,先别动,等真机验证过再决定。
把这三个值和 ABI 一起写进表格钉在 README 里,是我这几年最省事的习惯:
| 项 | 取值 | 备注 |
|---|---|---|
| JDK | 11 | AGP 7.x 对应 |
| Gradle | 7.5 | 与 AGP 7.4 搭配 |
| AGP | 7.4.x | 老语法基本能过 |
| compileSdk | 33 | 兼顾新库 |
| minSdk | 21 | so 的 C++ 运行时下限 |
| targetSdk | 30 | 避免新约束干扰 |
| ABI | arm64-v8a、armeabi-v7a | 两份 so 都要留 |
3. 导入 DEMO 工程:so 放哪里、模型放哪里
拿到 DEMO 后第一件事不是双击打开,而是先在文件管理器里看清它的目录形态。seetaface6 这类工程通常有两种组织方式:一种把 so 放在app/libs/<abi>/,另一种放在app/src/main/jniLibs/<abi>/。两者都能用,但对应的 Gradle 配置不一样,搞混了就是「编译通过、运行时找不到库」。
3.1 目录结构先摆正
无论选哪种放法,ABI 目录名必须一字不差:arm64-v8a、armeabi-v7a,写arm64、v8a、armeabi_v7a(下划线)都不认,Gradle 会安静地跳过,最后打出来的 APK 里一个 so 都没有。
app/ ├── libs/ # 方案 A:自定义 jniLibs 根目录 │ ├── arm64-v8a/ │ │ ├── libseetaface6.so # 主库 │ │ └── libc++_shared.so # C++ 运行时,别漏 │ └── armeabi-v7a/ │ ├── libseetaface6.so │ └── libc++_shared.so ├── src/main/ │ ├── assets/ │ │ └── seetaface6/ # 模型文件,.csta 结尾 │ │ ├── face_detector.csta │ │ ├── face_recognizer.csta │ │ └── face_landmarker_pts5.csta │ └── java/... └── build.gradleso 用方案 A 还是方案 B,取决于你拿到的那份 DEMO 原本怎么放。用方案 A 要在sourceSets里把jniLibs.srcDirs指过去,用方案 B 则什么都不用写,Gradle 默认就认src/main/jniLibs。
模型文件为什么必须放assets而不是res/raw?因为 seetaface6 的 native 接口收的是文件系统路径,不是InputStream。assets里的东西在 APK 里是压缩包内路径,native 层打不开,所以还得在首次启动时把它们复制到getFilesDir()之类的可写目录,这件事在第 3.3 节展开。res/raw更麻烦,编译期会做资源 ID 映射,路径形态和你预期的完全不一样,别绕这个弯。
3.2 build.gradle 的三个关键片段
android { compileSdkVersion 33 defaultConfig { applicationId "com.example.seetaface6demo" minSdkVersion 21 targetSdkVersion 30 versionCode 1 versionName "1.0" // 关键点一:只保留这两个 ABI,其他一律不出包 ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' } } // 关键点二:告诉 Gradle 自定义的 so 目录在哪 sourceSets { main { jniLibs.srcDirs = ['libs'] } } // 关键点三:so 不压缩、不裁剪,否则 dlopen 会失败 packagingOptions { jniLibs { useLegacyPackaging = true // AGP 4.2+ 才有;老的用 packagingOptions } pickFirst 'lib/arm64-v8a/libc++_shared.so' pickFirst 'lib/armeabi-v7a/libc++_shared.so' } buildTypes { release { minifyEnabled false // 先关混淆,跑通再开 proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } }abiFilters是出包阶段的过滤器,它决定最终 APK 里带哪些 ABI 目录,和 so 放在哪儿是两回事。最常犯的错是:文件放在arm64-v8a里,abiFilters却只写了armeabi-v7a,编译毫无怨言,一装到 64 位机器就闪退。
useLegacyPackaging = true控制的是 so 是否被压缩进 APK。设成false时 so 直接从 APK 映射加载,体积小、启动快,但对 so 的对齐要求更高;设成true时会解压到应用私有目录,兼容性更好。预编译的老 so 我一般先设true保平安,等真机都验证过了再考虑切false省那点体积。
pickFirst解决的是重复文件冲突:libc++_shared.so可能同时来自你的libs/和某个第三方 AAR,两份内容不一致时 Gradle 会报More than one file was found with OS independent path。加pickFirst表示「随便取一份」,但要确认取到的是能和主库配套的那份,否则会在运行期变成符号找不到。
3.3 模型文件从 assets 搬到可写目录
这是整个安装过程中最容易漏的一步。模型.csta文件打包进assets后,路径是file:///android_asset/seetaface6/xxx.csta,native 层用fopen打不开,必须复制到getFilesDir()或getExternalFilesDir()。复制只在首次启动做一次,之后复用。
// ModelInstaller.java —— 首次启动把 assets 里的模型拷到私有目录 public final class ModelInstaller { private static final String ASSET_DIR = "seetaface6"; // assets 下的子目录 private static final String TAG = "ModelInstaller"; public static File ensureModels(Context ctx) throws IOException { File dstDir = new File(ctx.getFilesDir(), ASSET_DIR); // /data/data/<pkg>/files/seetaface6 if (!dstDir.exists() && !dstDir.mkdirs()) { throw new IOException("cannot create " + dstDir); } String[] names = ctx.getAssets().list(ASSET_DIR); // 列出 assets 子目录 if (names == null || names.length == 0) { throw new IOException("assets/" + ASSET_DIR + " is empty"); } byte[] buf = new byte[8192]; for (String name : names) { File out = new File(dstDir, name); if (out.exists() && out.length() > 0) continue; // 已拷过就跳过 try (InputStream in = ctx.getAssets().open(ASSET_DIR + "/" + name); OutputStream os = new FileOutputStream(out)) { int n; while ((n = in.read(buf)) > 0) os.write(buf, 0, n); } Log.i(TAG, "copied " + name + " -> " + out.getAbsolutePath()); } return dstDir; } }这段代码有三个参数值得留意。ASSET_DIR必须和你在src/main/assets/下建的目录名完全一致,大小写敏感,Android 的 assets 在某些机型上对大小写处理的宽松程度不一样,统一用小写最保险。缓冲区给 8KB 是经验值,模型文件通常几 MB 到十几 MB,太小会拖慢首次启动。out.exists() && out.length() > 0这个双重判断是为了防止上次拷贝中途被杀进程留下一个空文件——只用exists()判断的话,那个空壳会让你后面查模型加载失败查到怀疑人生。
注意:不要用
getCacheDir()存模型。系统在存储紧张时会清理缓存目录,用户某天打开 App 就突然报模型加载失败,而且这种故障极难复现。
调用点放在Application.onCreate()或者检测页面的onCreate()里,但别放主线程。十几个 MB 的 IO 在主线程跑,低端机上直接触发 ANR。常见做法是丢到一个单线程ExecutorService里,或者在SplashActivity里做,做完再跳转。
4. 编译出包:armeabi-v7a 和 arm64-v8a 怎么分开出
两个 ABI 塞在一个 APK 里,体积直接翻倍。如果你的分发渠道允许,用 ABI split 拆成两个包更合适;如果只发一个包,那就保留abiFilters双 ABI,让系统自己挑。这两种策略的差别不只是体积,还影响你在真机上排查问题的思路。
4.1 splits abi 的配置与产物命名
android { splits { abi { enable true // 开启 ABI 拆包 reset() // 清掉默认值 include 'arm64-v8a', 'armeabi-v7a' universalApk false // 不再额外出一个全量包 } } } // 给每个 ABI 产物加版本号后缀,方便区分 ext.abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2] android.applicationVariants.all { variant -> variant.outputs.each { output -> def abi = output.getFilter(com.android.build.OutputFile.ABI) if (abi != null) { output.versionCodeOverride = ext.abiCodes.get(abi) * 1000 + variant.versionCode } } }reset()不能省——AGP 默认已经包含了一堆 ABI,不清空的话你include的两个之外还会混进 x86,出四个包,白折腾。versionCodeOverride那段是给应用市场用的:同一个应用的不同 ABI 包必须有不同 versionCode,否则后装的会覆盖先装的。乘 1000 是留出余量,主版本号加一的时候不至于撞车。
拆包之后产物在app/build/outputs/apk/debug/下,文件名形如app-arm64-v8a-debug.apk、app-armeabi-v7a-debug.apk。装的时候要挑对:adb install不会帮你选,装错了就是INSTALL_FAILED_NO_MATCHING_ABIS。
# 先看设备到底支持哪些 ABI,再决定装哪个包 adb shell getprop ro.product.cpu.abilist # 典型输出:arm64-v8a,armeabi-v7a,armeabi # 按设备首选 ABI 安装对应产物 adb install -r app/build/outputs/apk/debug/app-arm64-v8a-debug.apk # 装完确认实际落地的 native 库目录 adb shell pm path com.example.seetaface6demoro.product.cpu.abilist的顺序有意义,第一个是首选 ABI。有些 64 位设备为了兼容还保留armeabi-v7a,这时你装 v7a 包也能跑,但性能会打折——人脸检测这种计算密集的活,跑在 32 位兼容层上帧率能差一倍。
4.2 externalNativeBuild:什么时候必须动 CMake
DEMO 里如果带了src/main/cpp/CMakeLists.txt,说明 JNI 封装层是源码形式给你的,需要 CMake 参与编译。这时候externalNativeBuild必须配,而且abiFilters要在两处保持一致,否则 CMake 编出来的架构和 so 目录对不上。
android { defaultConfig { externalNativeBuild { cmake { cppFlags "-std=c++11 -fexceptions" // 只编这两个 ABI,和 jniLibs 目录保持一致 abiFilters 'arm64-v8a', 'armeabi-v7a' } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" version "3.10.2" } } }cppFlags里的-fexceptions不能删。seetaface6 的 C++ 接口内部用异常做错误传递,关掉异常支持后编译能过,一调用就崩,而且崩溃栈指向的位置和真正的原因差着十万八千里。-std=c++11是 so 的编译标准,你这边用 c++17 编封装层,头文件里的 ABI 可能对不上,符号修饰规则变了就链接不上。
如果 CMake 报Could not find CMake 3.10.2,去 SDK Manager 的 SDK Tools 页勾上对应版本,别图省事改成你本机装的 3.28——CMake 大版本之间的行为差异足够让你多花一晚上。
4.3 真机第一次运行该盯哪几个日志
装上去之后别急着点按钮,先开 logcat 过滤。加载阶段的关键日志有三类:dlopen相关、UnsatisfiedLinkError、以及你自己打的模型拷贝日志。过滤表达式用tag:ModelInstaller|tag:seeta|level:error之类的组合,能一次看全。
# 清空旧日志,重新拉起 App,只看和 native 加载有关的部分 adb logcat -c adb shell am start -n com.example.seetaface6demo/.MainActivity adb logcat -v time | grep -iE "dlopen|UnsatisfiedLink|seeta|ModelInstaller"adb logcat -c先清空是必须的,否则你会在一堆上一次运行的残留日志里找线索,越看越乱。-v time加上时间戳,能对上「哪一步之后开始报错」。如果 grep 出来一行都没有,说明 App 压根没走到加载逻辑,回头查System.loadLibrary的调用位置是不是在某个提前 return 的分支后面。
5. 排查:五个翻车现场和它们的后悔药
下面五条都是这类预编译 so 工程的高频故障,按「现象 → 原因 → 解决」记,遇到时直接对号入座,比从头读日志快得多。
5.1 现象:dlopen failed: library "libc++_shared.so" not found
原因很直接:主库libseetaface6.so是用c++_shared编的,运行时依赖这个 C++ 运行时库,而你只把主库放进了jniLibs,没带运行时。Gradle 不会提示,因为对它来说 so 就是个二进制文件,它不解析依赖。
解决办法是把libc++_shared.so按同样的 ABI 目录各放一份。这个文件可以从 NDK 里取,路径是<NDK>/toolchains/llvm/prebuilt/<host>/sysroot/usr/lib/aarch64-linux-android/(对应 arm64-v8a)和arm-linux-androideabi/(对应 armeabi-v7a)。注意两个 ABI 的文件不能互换,取错了会变成符号版本不匹配,报错换成cannot locate symbol,更难查。
# 从 NDK 里抠出对应 ABI 的 C++ 运行时 NDK=$ANDROID_HOME/ndk/21.4.7075529 cp $NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so \ app/libs/arm64-v8a/ cp $NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/arm-linux-androideabi/libc++_shared.so \ app/libs/armeabi-v7a/5.2 现象:java.lang.UnsatisfiedLinkError: No implementation found for native ...
so 加载成功了,但 Java 层声明的 native 方法在 so 里找不到对应实现。原因通常有两种:一是方法签名不匹配,包名或类名被改过;二是开了minifyEnabled true,R8 把类名混淆了,而 so 里的符号是按原始类名注册的。
先查签名。native方法的全限定名必须和 so 里注册的完全一致,包括包路径。DEMO 里如果你改了applicationId又顺手改了包名,但没重新编 JNI,就会这样。解决办法是先把minifyEnabled关掉,跑一遍确认是混淆的问题;确认之后,在proguard-rules.pro里加保护规则:
# JNI 入口类不能被混淆,否则 native 方法找不到实现 -keep class com.example.seetaface6demo.jni.** { *; } -keepclasseswithmembernames class * { native <methods>; }第二条规则是通用写法,保留所有含 native 方法的类的成员名,成本很低,建议所有带 JNI 的工程都加上。
5.3 现象:检测页面能进,但一调用初始化就返回失败码,日志里没有任何异常
最容易被误判成「so 有问题」的一类。实际是模型文件没到位——路径传的是 assets 内路径,或者拷贝过程中断留下了 0 字节文件,native 层fopen失败后返回一个错误码,Java 层没抛异常,于是表现成「静默失败」。
排查顺序:先看拷贝日志里有没有打出每个文件的绝对路径,再adb shell进去ls -l确认文件大小。
# 进到应用私有目录看模型到底有没有落地、大小对不对 adb shell run-as com.example.seetaface6demo ls -l files/seetaface6如果文件大小是 0,说明拷贝被中断了(比如用户在拷贝过程中按了返回键杀掉 Activity)。把拷贝逻辑从 Activity 生命周期里挪出来,放到Application或者一个独立的前台任务里,并且用「临时文件 + 重命名」的写法保证原子性,就不会再出现半截文件。
5.4 现象:Android 15 真机上 so 加载失败,Android 12 上同一个包正常
这是近两年新增的坑。Android 15 起,64 位设备对 so 的 LOAD 段对齐要求提高到 16KB,用老版本 NDK 编出来的 so 如果只按 4KB 对齐,加载时会直接失败。seetaface6 的官方预编译 so 大多是几年前出的,正好踩在这条线上。
先确认是不是对齐问题,用 NDK 里的llvm-readelf看 LOAD 段的 Align 值:
# Align 需要是 0x4000(16KB)或更大;显示 0x1000 就是 4KB 对齐 $ANDROID_HOME/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf \ -l app/libs/arm64-v8a/libseetaface6.so | grep LOAD如果确实是 4KB,只有三条路:用 NDK r28 及以上重新编译 so(需要有源码)、把targetSdk压在 34 及以下避开强制要求、或者只在 Android 14 及以下的设备上跑。临时验证阶段我一般先压targetSdk,等所有功能跑通了再回头解决对齐——不然功能还没验证,先卡在编译工具链上,很容易半途而废。
5.5 现象:INSTALL_FAILED_NO_MATCHING_ABIS,或者装上了但立刻闪退
前者是包和设备 ABI 不匹配,后者多半是abiFilters和实际 so 目录对不上。注意一个反直觉的点:abiFilters只影响打包,不影响编译期的 so 收集。你可能abiFilters写了两个,但libs/armeabi-v7a/目录压根是空的,打出来的 APK 里 v7a 目录下什么都没有,装到只支持 v7a 的设备上就闪退。
解决办法是打完包先验一遍 APK 里的 so 清单,这一步花十秒,能省掉后面半小时的真机重装。
# 列出 APK 内所有 so,确认两个 ABI 目录都有内容 unzip -l app/build/outputs/apk/debug/app-debug.apk | grep "lib/"输出里应该能看到lib/arm64-v8a/libseetaface6.so、lib/arm64-v8a/libc++_shared.so、lib/armeabi-v7a/下同样的两个文件,四个都在才算齐。
6. 装完之后:三行命令确认 so 是真的被加载进进程了
APK 装成功、页面能打开,不代表 so 真的加载了。很多情况下 App 只是走到了一个降级分支,把功能默默关掉。要确认 native 层真的在工作,得看进程的内存映射——so 被dlopen之后,会以.so文件的形式出现在/proc/<pid>/maps里,这是最硬的证据,没有之一。
第一步,确认 APK 里的 so 齐全。这一步在装机前做,前面 5.5 节已经写过,核心就是unzip -l加 grep,四个文件一个不能少。
第二步,确认运行时 so 被映射进进程。这需要 App 处于运行状态,并且已经走过加载逻辑。
PKG=com.example.seetaface6demo # 拿到进程号(多进程应用会有多个 pid,取主进程那个) PID=$(adb shell pidof $PKG | tr -d '\r') echo "pid=$PID" # 在进程的内存映射里找 seeta,找到就说明 so 已经被 dlopen 成功 adb shell "cat /proc/$PID/maps" | grep -i seetatr -d '\r'不能省。Windows 下 adb 输出的行尾带\r,拼进后面命令里会变成一个诡异的路径,报错信息完全看不出是换行符的问题。这个坑我踩过不止一次,现在所有adb shell取值的脚本里都强制加这一步。
正常输出形如/data/app/.../lib/arm64-v8a/libseetaface6.so,后面跟着一段内存区间和权限位r-xp。如果 grep 什么都没有,说明加载逻辑根本没执行到,或者执行了但失败被 catch 住了——回去看 5.1 到 5.4。
第三步,确认模型文件落地并且大小对得上。这一步在 5.3 节写过命令,但验证的时候要看的是「每个文件都非空」,而不是「目录存在」。
# 只看大小,0 字节的一眼就能看出来 adb shell run-as $PKG ls -l files/seetaface6run-as在 debug 包上可用,release 包会报package not debuggable。验证阶段一律用 debug 包,别拿 release 包来查这类问题,权限这道墙会让排查多绕一圈。
三步都过了,再去做功能验证:放一张测试进assets,跑一次检测,日志里打出检测到的人脸框数量。这一步的判据是「数量大于 0 且框的坐标在图片范围内」——只看到「检测成功」这四个字不算数,很多 DEMO 的日志在失败路径上也打「成功」,得看具体数值。我一般会故意用一张纯色图再跑一次,确认它返回 0,一正一反两次都符合预期,才算这条链路真的通了。
| 验证项 | 命令/位置 | 通过判据 |
|---|---|---|
| APK 内 so 齐全 | unzip -l ... | grep lib/ | 两个 ABI 各 2 个 so |
| so 被加载 | /proc/<pid>/maps | 出现 libseetaface6.so |
| 模型落地 | run-as ... ls -l files/ | 每个 .csta 非 0 字节 |
| 功能可用 | 应用日志 | 正样本框数 > 0,负样本 = 0 |
从那以后我每次装这类带预编译 so 的工程,都强制走一遍这个四步验证表,哪怕只是换了个 ABI 目录名这种「一看就不会错」的改动。因为 so 加载失败的表现太安静了,安静到你会误以为是算法不准。希望帮到你。
本文还有配套的精品资源,点击获取