第三方 so 库引入 Android Studio表面上看就是把.so文件拷到某个目录就完事的活儿真动手过的人都知道十有八九第一次会卡在UnsatisfiedLinkError上。日志里就一行dlopen failed没有堆栈、没有原因、没有提示你到底是目录放错了、ABI 对不上还是文件根本没被打进 APK。我前后在好几个项目里接过第三方 so从人脸识别 SDK、音视频解码库到地图底层踩过的坑基本能凑齐一套病历。这篇就把 AndroidStudio 引入第三方 so 库这件事从头到尾拆开讲一遍包括文件该落在哪、Gradle 怎么配、ABI 怎么取舍、报错怎么查以及怎么在 Windows 11 上自己用 NDK 编一个 so 再接回工程做验证。内容基于 AGP 8.x 加 NDK r27/r28 的实测环境适合刚接触 JNI 的新手也适合接过一堆 SDK 但总被 so 问题绊住的老手。1. so 库从磁盘到 APK 的完整链路先弄清文件怎么被塞进去很多人接 so 失败的根因其实不在配置写错而在脑子里没有一条完整的文件流动路线。.so文件从你下载的 SDK 压缩包里出来到你手机上被System.loadLibrary加载成功中间至少经过四道关口源码目录、Gradle 资源合并、APK 打包、安装时的解压或映射。任何一道关口出问题最终表现都是同一个 UnsatisfiedLinkError但修法完全不同。所以第一步不是急着改 Gradle而是先把这条链路理清楚。1.1 ABI 目录名不是随便起的.so文件必须放在以 ABI 命名的子目录里这一点几乎没有任何商量余地。常见的目录名有这么几个armeabi-v7a、arm64-v8a、x86、x86_64早期的armeabi、mips、mips64现在基本可以无视了NDK r17 之后已经彻底移除。这里的 ABI 全称是 Application Binary Interface说白了就是 CPU 指令集加调用约定的组合arm64-v8a对应 64 位 ARM 架构armeabi-v7a对应 32 位 ARM 架构两者编译出来的机器码根本不通用把 32 位 so 塞进arm64-v8a目录安装时不会报错运行到加载那一刻才炸。系统在加载 so 时的查找顺序是从高到低匹配设备能力64 位 ARM 设备优先找arm64-v8a找不到才退回armeabi-v7a。这个退回机制在早些年帮了不少忙因为那时候 64 位设备都保留了 32 位运行环境。但从 Android 12 开始越来越多的机型尤其是主打性能的旗舰和部分折叠屏直接砍掉了 32 位支持只放armeabi-v7a的 APK 装上去会提示不兼容或者装上后加载 so 直接失败。所以现在接第三方库只要对方给了arm64-v8a就一定要带上别图省事只留一个 32 位目录。还有一类目录叫arm64-v8a下的libxxx.so和libxxx.so.dbg并存的情况.dbg是带调试符号的版本不会被打进 release APK看到它别慌也别手动删AGP 自己会处理。1.2 AGP 3.0 前后 jniLibs 的地位变化我最早接 so 那会儿还是在 Eclipse 时代so 放libs目录还得在项目属性里手动配 NDK 路径折腾半天。后来迁移到 Android StudioAGP 早期版本默认扫描的是src/main/libs再往后才统一成src/main/jniLibs。这个历史遗留导致网上大量老教程还在教你改sourceSets指向libs照着做也能跑通但会让人误以为libs是官方推荐路径其实不是。AGP 目前默认会去src/main/jniLibs下面按 ABI 目录收集 so这是零配置就能用的路径。至于libs目录它原本是给jar、aar用的dependencies里的implementation fileTree(dir: libs, include: [*.jar])扫的就是它把 so 也丢进去就必须额外告诉 Gradle jniLibs 也要看这个目录否则文件就在眼皮底下却打包不进去。另外要明确一个概念jniLibs只是预编译 so 的存放位置它和cpp目录下的 C/C 源码编译是两条独立的路。前者是把别人编好的二进制放进去后者是通过 CMake 或 ndk-build 现场编译。两者可以同时存在互不冲突但不要把源码文件和预编译 so 混在一个目录里那样只会让打包逻辑变乱。2. jniLibs 还是 sourceSets 指向 libs两种落位方式的取舍知道了链路接下来就是实际摆放。目前工程里接第三方 so主流就两种做法直接放进默认的jniLibs目录或者把 so 保留在libs目录下、用sourceSets手动指定。两种都能跑通但在和第三方 SDK 对接、多模块协作、版本管理这些场景下的体验差别很大选哪种其实取决于你的工程结构。2.1 默认 jniLibs 的完整用法最省事的做法是在模块目录下建这个结构app/ src/ main/ jniLibs/ arm64-v8a/ libxxx.so armeabi-v7a/ libxxx.so把第三方 SDK 里对应的 so 按 ABI 分好目录丢进去然后在 Java/Kotlin 侧调用public class NativeBridge { static { System.loadLibrary(xxx); } public static native String getVersion(); }注意System.loadLibrary(xxx)的参数是去掉lib前缀和.so后缀的库名也就是libxxx.so对应xxx。这一条看着幼稚但每年都有人写成System.loadLibrary(libxxx.so)然后来问为什么加载不上。jniLibs这种放法的好处是完全零配置Android Studio 建工程时选带 C 支持的模板就会自动建好这个目录树新手不会迷路。缺点也明显so 文件和源码混在src/main下面和第三方 SDK 的目录结构往往对不上。很多 SDK 发过来的压缩包是libs/arm64-v8a/libxxx.so这种布局你还得手动拆包、建目录、拷贝SDK 一升级就得重来一遍。2.2 用 sourceSets 把 so 目录搬到工程别处如果第三方 SDK 给的就是一个libs目录而且里面既有 jar 又有 so那更省心的做法是保持 SDK 原样不动直接让 Gradle 去这个目录捞 soandroid { sourceSets { main { jniLibs.srcDirs [libs] } } }Kotlin DSL 的写法略有不同android { sourceSets.getByName(main) { jniLibs.srcDirs(libs) } }这样app/libs/arm64-v8a/libxxx.so就能被正确打包。如果 SDK 的 so 分散在多个目录比如一个主库在libs一个插件库在plugins/xxx/libssrcDirs支持数组jniLibs.srcDirs [libs, plugins/video/libs]这里有个坑值得单独说srcDirs [...]是覆盖而不是追加。一旦你写了这行默认的src/main/jniLibs就不再生效了。我就遇到过有人既在jniLibs目录放了自研 so又写了一句jniLibs.srcDirs [libs]结果自研 so 默默消失了APK 里少一个库运行时报错还找不到原因。正确写法要么把两个目录都列上要么用追加Groovy 里jniLibs.srcDirs [libs]的行为在部分 AGP 版本上并不如预期建议还是全列出来最稳。2.3 两种放法的对比与选择建议把两种方式摊开对比一下会更清楚对比项默认 jniLibssourceSets 指向 libs配置成本零配置需改 build.gradle目录整洁度稍乱混入 src/main与 SDK 原始结构一致SDK 升级成本高需重新拆包摆放低覆盖目录即可多模块引用每个模块各自维护可抽出公共 libs 目录出问题时的排查难度低路径唯一中需确认 srcDirs 覆盖团队协作友好度一般好目录结构直观我的建议是这样如果只是接一两个 so、工程结构简单直接扔jniLibs最省事如果接的是成套 SDK、so 数量多、还要跟着 SDK 版本频繁更新就用sourceSets指到独立目录最好再把 so 放进一个单独的 Gradle 模块比如lib-native主工程依赖它这样版本升级时改一处就够了。提示无论用哪种方式ABI 子目录这一层绝对不能省。把libxxx.so直接放在libs/根目录下Gradle 不会报错但 so 不会被打进 APK因为打包器只认libs/ABI/这种结构。3. abiFilters 与 ABI 取舍APK 瘦身还是兼容优先文件放对了下一个决策是保留哪些 ABI。第三方 SDK 通常一次性给你四个甚至更多 ABI 的 so全打进 APK体积直接翻几倍。一个典型的人脸识别 SDK单个 ABI 的 so 加起来可能就有 15MB四个 ABI 全带上APK 光 so 部分就 60MB 起。abiFilters 就是用来解决这个问题的但它配错了同样会导致加载失败而且失败得很隐蔽。3.1 哪些 ABI 必须留、哪些可以砍先给结论面向普通用户的 Apparm64-v8a和armeabi-v7a这两个基本可以覆盖 99% 以上的真机x86和x86_64主要给模拟器和极少数平板如果不上架 Google Play 的模拟器渠道通常可以砍掉。但有两个前提必须确认清楚。第一你的第三方 SDK 是否在arm64-v8a里提供了完整的 so。有些老 SDK 只给armeabi-v7a这时候如果你强行配abiFilters arm64-v8a打包能过运行时在 64 位机型上就找不到库了。正确做法是先解压 SDK把每个 ABI 目录下的 so 文件名列出来做比对如果每个目录的文件名集合一致说明 SDK 支持多 ABI如果只有部分 ABI 有文件那能用的 ABI 就是这个子集。第二你的其他依赖里有没有强制要求某个 ABI。同一个 APK 里所有 so 的 ABI 集合最好保持一致如果 A 库只有arm64-v8aB 库只有armeabi-v7a而你又只保留arm64-v8a那 B 库在运行时必然加载失败。这种情况要么两个 ABI 都留要么联系 B 库提供方要 64 位版本。近两年上架应用商店时64 位支持已经是硬性要求所以能升级的库尽量升级。配置写法Groovyandroid { defaultConfig { ndk { abiFilters arm64-v8a, armeabi-v7a } } }Kotlin DSLandroid { defaultConfig { ndk { abiFilters listOf(arm64-v8a, armeabi-v7a) } } }注意 AGP 8.x 里ndk.abiFilters的位置在defaultConfig下网上有些老教程写在buildTypes或productFlavors里也能生效但统一放在defaultConfig更清晰。3.2 abiFilters 与 splits 的组合拳abiFilters是只打这些 ABI 的 sosplits.abi是按 ABI 拆成多个 APK两者目标不同但经常一起用。如果你的 App 主要靠应用商店分发商店支持按设备 ABI 下发对应的 APK那用 splits 拆包是最优解每个用户只下自己需要的那个版本体积最小android { splits { abi { enable true reset() include arm64-v8a, armeabi-v7a universalApk false } } }universalApk false表示不生成那个包含所有 ABI 的通用包如果设为 true会额外产出一个全量 APK体积很大一般只在内部测试时用。用 splits 时要注意版本号处理Google 官方推荐的做法是给不同 ABI 的 APK 分配不同的 versionCode通过android.applicationVariants.all钩子去改否则同一个版本号推多个 APK 到商店后台会冲突。如果不用商店分发或者只是内部使用那就老老实实保持单 APKabiFilters只留两个主流 ABI 就够了。我自己的经验是娱乐类、工具类 App 一个 APK 装到哪儿都能用比拆包省事得多用户也不太在意那几 MB 差异。3.3 一个容易忽略的细节空 ABI 目录导致安装失败这个坑我踩过一次记忆深刻。当时第三方 SDK 的压缩包里有个x86_64目录但里面是空的可能打包时漏了我把整个目录拷进libs。Gradle 打包时看到这个目录存在就认为 APK 支持x86_64在 AndroidManifest 里写上了对应的 native 平台标记。结果在x86_64模拟器上安装系统认为这个 APK 兼容装上后加载 so 时发现目录是空的直接崩溃。更隐蔽的是另一种情况abiFilters只配了arm64-v8a但libs下残留着armeabi-v7a目录如果某个插件库是通过sourceSets从别的路径引进来的两个 ABI 集合不一致打包时 AGP 可能会给出警告甚至合并出奇怪的产物。解决办法简单粗暴动手之前先把所有 ABI 目录里的文件列一遍任何空目录、只含.dbg的目录都删掉保证实际存在的目录里都有真实的.so文件。另外abiFilters不会自动删除你手动放进jniLibs的目录。它的作用是在打包阶段过滤源目录里的文件还在这本身没问题但如果你在两个地方分别配了 ABI 白名单比如defaultConfig一个、flavor一个最终生效的是它们的交集还是并集取决于具体配置方式容易出意外。所以 AB I 的白名单最好只在defaultConfig里维护一份。4. 不装设备也能验确认 so 真的进了 APK 的三步核对法配置写完Gradle 也跑通了别急着装手机。绝大多数 so 加载失败其实在 APK 产出的那一刻就已经注定了只是你没去看。养成一个习惯打完包先不装设备用下面三步确认 so 是否真的进了 APK能省下大量连真机、看 logcat 的时间。4.1 Build Analyze APK 看 lib 目录Android Studio 自带一个非常实用的 APK 分析器。菜单路径是Build→Analyze APK...选中刚打出来的 APK它会展示包内的完整结构。展开lib/目录你会看到所有被打进去的 so按 ABI 分组展示每个文件的原始大小和压缩后大小都会列出来。这一步要重点核对三件事ABI 目录是否符合预期多了少了都要警惕、so 文件名是否和System.loadLibrary里写的库名对得上、文件大小是否正常如果某个 so 显示几十字节那八成是个占位文件或者拷贝出错。Analyze APK 还有个隐藏用法它能直接对比两个 APK 的差异。比如你升级了 SDK 版本新旧 APK 各分析一次点一下对比so 有没有变化、体积涨了多少一目了然。这个功能在排查升级后突然加载失败的问题时特别好用。4.2 直接解压 APK 对文件名Analyze APK 有时在超大 APK 上会卡这时候用命令行更快。APK 本质就是个 zip直接列目录unzip -l app-release.apk | grep lib/输出会类似这样1234567 2025-01-01 12:00 lib/arm64-v8a/libxxx.so 987654 2025-01-01 12:00 lib/arm64-v8a/libyyy.so 876543 2025-01-01 12:00 lib/armeabi-v7a/libxxx.soWindows 上如果没有 unzip用 PowerShell 也行Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::OpenRead(app-release.apk).Entries | Where-Object { $_.FullName -like lib/* } | Select-Object FullName, Length这一招的好处是可以直接输出成文本方便和上一次的清单做 diff 比对。我有一次就是靠 diff 发现某个 so 在某次构建后悄悄消失了原因是一个新加的依赖配置里jniLibs.srcDirs覆盖了默认路径。4.3 adb 侧确认真实加载路径APK 里有 so不等于设备上就能加载。还有两个变量安装方式和设备 ABI。minSdk 大于等于 23 且开启了useLegacyPackaging为 false 的情况下so 不会在安装时被解压到lib目录而是直接从 APK 内存映射加载。所以你在设备上ls一下应用目录可能看不到 so 文件这并不代表有问题。想确认设备实际用的是什么 ABIadb shell getprop ro.product.cpu.abi想确认应用的实际 native 库目录adb shell pm path com.example.demo adb shell run-as com.example.demo ls -l /data/app/.../lib/arm64run-as只对 debuggable 的应用有效release 包用不了这时候可以退而求其次用adb shell dumpsys package com.example.demo | grep -i legacy看有没有extractNativeLibs相关信息或者直接看 APK 的 AndroidManifest 里android:extractNativeLibs的值。最直接的验证方式其实还是写一个最小调用在Application.onCreate里立刻调用一次System.loadLibrary并打日志能在启动阶段就把问题暴露出来不用等到用户点进某个功能页才崩。5. UnsatisfiedLinkError 的排查链路从日志原文反推根因前面都是预防措施真出了问题还是得靠日志。UnsatisfiedLinkError 的日志看着都很像但细看开头那几行指向的原因其实完全不同。这一节把常见的几种报错原文列出来配上对应的排查路径你可以直接对号入座。5.1 三种报错文本分别指向什么先看第一种java.lang.UnsatisfiedLinkError: dlopen failed: library libxxx.so not foundnot found 是最常见的形态翻译过来就是我在所有能找的地方都没找到这个库。根因通常是so 没打进 APK、ABI 对不上、库名拼写错误、或者System.loadLibrary和System.load混用导致路径不对。排查顺序建议是先Analyze APK确认 so 在不在包里再确认库名最后确认load系列函数的用法。第二种java.lang.UnsatisfiedLinkError: No implementation found for java.lang.String com.example.demo.NativeBridge.getVersion()No implementation found 说明库本身加载成功了但里面没有找到对应的 JNI 函数。这基本可以锁定在函数名或者签名上跟打包无关属于 JNI 名字修饰的问题下一小节细说。第三种java.lang.UnsatisfiedLinkError: dlopen failed: libxxx.so is 32-bit instead of 64-bit java.lang.UnsatisfiedLinkError: dlopen failed: libxxx.so has unexpected e_machine: AArch64这类报错直接点名了架构不匹配说明装进手机的 so 是 32 位但设备按 64 位加载或者反过来。最可能是 ABI 目录放错或者abiFilters配得和设备实际加载的 ABI 不一致。这时候去看一下adb shell getprop ro.product.cpu.abi的输出再和 APK 里的lib/目录对照问题就出来了。还有一类比较新的java.lang.UnsatisfiedLinkError: dlopen failed: empty/missing DT_HASH/DT_GNU_HASH in libxxx.so这是链接器找不到哈希段多半是第三方 so 编译时用的工具链太老或者被二次处理过这种只能联系库的提供方。5.2 名字修饰与 JNI 函数签名对不上JNI 的函数名不是随便起的它有一套严格的命名规则Java_加包名点换下划线加类名加方法名。假设你的类全路径是com.example.demo.NativeBridge方法叫getVersion那么 C 侧的函数名必须是JNIEXPORT jstring JNICALL Java_com_example_demo_NativeBridge_getVersion(JNIEnv *env, jclass clazz) { return (*env)-NewStringUTF(env, 1.0.0); }常见的错法有三种包名里有点没换成下划线、类名和方法名之间的分隔符写错、方法带参数时忘了在函数名后面加签名后缀。带重载或者带参数的方法JNI 函数名后面还要拼参数描述符比如getVersion(Ljava/lang/String;)Ljava/lang/String;会变成Java_com_example_demo_NativeBridge_getVersion__Ljava_lang_String_2这种就千万别手写了用javac -h生成头文件最稳妥。javac -h ./jni NativeBridge.java生成的.h文件里函数声明原封不动抄进 C 文件就对了。第三方 SDK 一般会提供封装好的 Java 层 API你只需要调它的接口不用管 JNI 名字但如果你自己在封一层这个坑就绕不过去。5.3 loadLibrary 与 load 混用踩的坑System.loadLibrary(xxx)和System.load(/absolute/path/libxxx.so)的区别前者只给库名让系统在 APK 的 native 库目录里找后者给绝对路径从文件系统加载。第三方 SDK 里用load的情况不少尤其是它把 so 从 assets 解压到私有目录再加载的场景。这两者混用最常见的错误是这样的SDK 文档让你把 so 放进jniLibs而 SDK 内部的代码却用System.load(context.getFilesDir() /libxxx.so)去加载它期望你从 assets 里把 so 拷出来。结果你按文档放好了 so 在 APK 包里SDK 却在私有目录找不到文件报的还是dlopen failed。判断方法很简单抓一下日志里完整的路径信息如果路径里有/data/user/0/包名/files/这类字符串说明走的是load而不是loadLibrary那就要去检查 SDK 文档里是不是有把 so 放到 assets的说明。这种情况其实跟本文讲的jniLibs引入方式没关系属于 SDK 自己的加载策略得按它的规则来。还有一个小细节Android 上System.loadLibrary加载的 so 不需要写lib前缀和.so后缀但System.load必须写完整文件名两个函数的参数规则完全不同别搞混。6. 多库共存时的同名覆盖、压缩与 16KB 页对齐单个 so 接进来顺利不代表多个 so 一起用也顺利。当工程里同时存在几个第三方 SDK每个都带 so而且有些库还依赖同一个底层库时名字冲突、压缩策略、页对齐这些问题就会冒出来。这几类问题往往不会在编译期报错只在特定机型或特定 Android 版本上出现排查成本很高。6.1 同名 so 的覆盖顺序最常见的冲突是两个 SDK 带了同名但版本不同的 so比如 A SDK 带libc_shared.so的旧版本B SDK 带新版本。打包时 AGP 只能保留一个保留哪个取决于合并顺序——而合并顺序跟依赖声明顺序、模块层级都有关非常不好预测。处理这类问题有两个方向。第一个方向是统一版本在packagingOptionsAGP 8.x 里是packaging里显式排除一个android { packaging { jniLibs { pickFirsts [lib/arm64-v8a/libc_shared.so, lib/armeabi-v7a/libc_shared.so] } } }pickFirsts表示遇到重复时取第一个excludes表示直接排除某个路径。选哪个要看哪个版本更新的可能性大。第二个方向是根本不带共享库如果你的工程里有两个以上带 C 的 so全都静态链接libc会更好也就是在 CMake 里配ANDROID_STLc_static这样每个 so 自带一份运行时不会互相抢。但第三方预编译的 so 你改不了它的链接方式只能靠pickFirsts或者干脆联系提供方要一份静态链接的版本。查看 APK 里到底保留了几份同名库还是用unzip -lunzip -l app-release.apk | grep -i libc如果arm64-v8a和armeabi-v7a各只有一份说明合并正常如果有重复路径同一个 ABI 下出现两次同名说明配置生效了但源目录还有残留得清理。6.2 extractNativeLibs 与 useLegacyPackagingandroid:extractNativeLibs这个清单属性控制着 so 在安装时的处理方式。设为true时安装器会把 APK 里的 so 解压到应用的lib目录设为false时不解压运行时直接从 APK 内存映射加载。从 AGP 4.2 开始如果minSdkVersion大于等于 23默认值就是false好处是安装快、占用空间小。但这个默认值在接入某些老 SDK 时会出问题如果 SDK 内部的加载逻辑依赖 so 在文件系统上真实存在比如先File.exists()判断一下再决定加载哪个那false就会让它判断失误。对应的 Gradle 配置在 AGP 8.x 里叫useLegacyPackagingandroid { packaging { jniLibs { useLegacyPackaging true } } }设成true就回到了安装时解压的老行为能兼容那些老逻辑代价是安装时间变长、应用占用空间变大。除非确实遇到兼容问题否则不建议全局打开按flavor或者按模块单独开更合理。还有一个相关的小坑如果你的 minSdk 低于 23现在很少见了useLegacyPackaging必须为true才能正常工作否则安装器根本不支持从 APK 直接映射 so。这种老配置一般不用管但如果你维护的是古董项目记得看一眼。6.3 16KB 页对齐这个新门槛这是近一两年新出现的硬性要求也是很多人还没意识到的问题。Android 15 开始支持 16KB 内存页大小的设备应用商店对面向新系统版本的应用提出了 16KB 对齐要求不满足的 so 在这些设备上可能直接加载失败。报错形态通常是dlopen failed: ... ELF alignment check failed之类。问题的根源在于传统的 so 链接时按 4KB 页对齐而 16KB 页设备要求.so的段偏移和虚拟地址按 16384 对齐。对于你自己编译的库解决办法很简单编译或链接时加上-Wl,-z,max-page-size16384用 CMake 的话可以这样写target_link_options(yourlib PRIVATE -Wl,-z,max-page-size16384)NDK 的工具链版本选较新的r27 以上AGP 用 8.5.1 以上很多情况下默认就是对齐好的。但对于第三方预编译的 so你只能联系提供方要 16KB 对齐的版本自己没办法重新对齐——重新对齐需要重编译二进制补丁的方式风险太大不要尝试。检测某个 so 是否满足 16KB 对齐可以用 NDK 里的llvm-readelfllvm-readelf -l libxxx.so | grep LOAD看每一行 LOAD 的 Align 列如果是0x4000就表示 16KB 对齐0x1000则是 4KB需要升级。这个检查建议在接入任何第三方 so 时都做一遍尤其是 SDK 版本比较老的。7. Windows 11 上用 NDK 编出 so 并接回 Android Studio 工程纸上得来终觉浅想真正搞明白 so 是怎么被加载的最快的办法是自己编一个最小 so接进工程跑通一次。Windows 11 上做这件事其实不难NDK 解压即用不用配环境变量也能跑。下面用一个简单的猜拳结果生成库做例子把整条链路走一遍。7.1 用 clang 命令行编一个最小 so先在本地解压一份 NDK假设放在D:\android-ndk-r27c。准备一个guess.c#include jni.h static unsigned int seed 20250101u; static int next_rand(void) { seed seed * 1103515245u 12345u; return (int)((seed / 65536u) % 32768u); } JNIEXPORT jint JNICALL Java_com_example_demo_NativeBridge_fingerGuess(JNIEnv *env, jclass clazz) { return next_rand() % 3; }这个库只做一件事返回 0、1、2 三个值之一对应猜拳的石头剪刀布。用 NDK 里的 clang 直接编成 arm64 的 so。注意 Windows 上 clang 是.cmd文件在 PowerShell 里调用要带完整后缀$NDK D:\android-ndk-r27c $NDK\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android21-clang.cmd -shared -fPIC -O2 -Wl,-z,max-page-size16384 -o libguess.so guess.c几个参数说一下-shared生成动态库-fPIC生成位置无关代码so 必须-O2开优化-Wl,-z,max-page-size16384是前面提到的页对齐参数一次到位。aarch64-linux-android21里的21是最低 API 级别要和你工程的 minSdk 匹配或更低否则编出来的库在低版本设备上跑不了。如果想要 32 位版本换成armv7a-linux-androideabi21-clang.cmd再编一次即可。两个产物分别放到arm64-v8a和armeabi-v7a目录。7.2 用 CMake 编并接进 Android Studio命令行方式适合快速验证工程里长期用还是得靠 CMake。在app/src/main/cpp/CMakeLists.txt里写cmake_minimum_required(VERSION 3.22.1) project(guesslib) add_library(guesslib SHARED guess.c) target_link_options(guesslib PRIVATE -Wl,-z,max-page-size16384) find_library(log-lib log) target_link_libraries(guesslib ${log-lib})build.gradle里指定构建脚本和 ABIandroid { defaultConfig { externalNativeBuild { cmake { cppFlags } } ndk { abiFilters arm64-v8a, armeabi-v7a } } externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt version 3.22.1 } } }这里version 3.22.1指定的是 CMake 版本必须是你 SDK Manager 里已经装过的版本写一个没装的版本会直接报错。C 侧新建工程时模板会自动创建这个目录结构手动加的工程要自己建。Java 侧对应的桥接类package com.example.demo; public class NativeBridge { static { System.loadLibrary(guesslib); } public static native int fingerGuess(); }注意System.loadLibrary(guesslib)对应libguesslib.soCMake 里add_library(guesslib SHARED ...)生成的就是这个文件名两边必须一致。第三方 SDK 的 so 名往往和它的库名不一样接的时候要去Analyze APK里看清真实文件名别凭 SDK 文档里的简称写。7.3 产物落位与验证CMake 编出来的 so 默认不会出现在源码目录而是落在app/build/intermediates/cxx/下面打包时自动进 APK不需要手动拷贝。这一点和预编译 so 的接入完全不同别搞混了走 CMake 的 so 不需要放进jniLibs。验证流程还是那三步先Build→Analyze APK看lib/目录里有没有libguesslib.so再解压确认两个 ABI 目录都在最后装到真机上跑一下fingerGuess()看返回值是否在 0 到 2 之间。如果 CMake 编译产物没进 APK重点检查三件事externalNativeBuild.cmake.path路径对不对相对的是模块根目录不是工程根目录、abiFilters有没有把 CMake 支持的 ABI 全过滤掉了、以及有没有在ndk块里手写了moduleName之类的老配置导致冲突。moduleName是 ndk-build 时代的写法和 CMake 混用只会互相打架该删就删。自己编一遍之后你对 so 从哪里来、到哪里去、怎么被加载这条链路的理解会完全不一样之后再接别人给的 so看目录结构和报错日志都会顺很多。最后分享一个我自己的习惯接任何新 so 之前先在Application.onCreate里主动调一次System.loadLibrary把库名一个个试加载一遍日志打上库名和结果。这样问题会在应用启动的第一秒暴露而不是等用户点到某个具体功能才崩。这个习惯帮我省掉的线上崩溃大概比我改过的所有 Gradle 配置加起来还多。