上个月帮一个做音视频集成的团队看崩溃他们的 App 一点进直播间就挂日志里只留了一行java.lang.UnsatisfiedLinkError: dlopen failed: library libmediaprocess.so not found。Java 层代码翻来覆去看了两小时没毛病SDK 文档也照着写了最后发现问题极其朴素第三方 so 库压根没被打进 APKapp/build/intermediates/里干干净净。这种坑在 AndroidStudio 里引入第三方 so 库时出现的频率高得离谱因为整个链路从目录约定、Gradle 配置到 ABI 匹配、打包策略任何一环写错都不会在编译期报错全部推迟到运行时炸给你看。这篇就把 AndroidStudio 引入第三方 so 库这件事从头到尾拆一遍so 库在工程里到底放在哪、ABI 目录怎么规划、jniLibs和sourceSets怎么配、AAR 和本地仓库两条路怎么走、改完怎么验证真的进包了以及我这些年踩过的、文档里基本不会写的坑。不管你是刚接手一个带 Native 依赖的项目还是正在集成某个只给你.so文件不给源码的 SDK都能直接照着抄。1. 先把 so 库这件事的底层逻辑捋清楚1.1 so 库在 Android 工程里到底扮演什么角色.so文件是 Linux 体系下的动态链接库Android 沿用了这套机制。Java 层用System.loadLibrary(mediaprocess)这行代码去加载libmediaprocess.so注意这里有个约定传进去的名字不带lib前缀也不带.so后缀系统会自动补全。所以你手里拿到的文件如果叫libmediaprocess.so加载时写mediaprocess如果第三方给的文件名是mediaprocess.so没有 lib 前缀那就要用System.load()配合绝对路径或者干脆改名——这一点后面还会专门讲。从编译期到运行期so 库的旅程是这样的Gradle 打包阶段把指定目录下的.so按 ABI 分类塞进 APK 的lib/abi/目录安装时如果extractNativeLibs为 true系统会把它们解压到/data/app/包名/lib/abi/App 启动后System.loadLibrary触发dlopen系统按当前设备的 CPU 架构去对应目录里找这个文件。整条链路上任何一环对不上结果都是运行时的UnsatisfiedLinkError或者dlopen failed编译期一句话都不会提示你。理解这个流程的意义在于当崩溃发生时你能沿着文件有没有进 APK → 进了哪个 ABI 目录 → 设备要的是哪个 ABI → 文件名对不对 → 它自己依赖的其他 so 在不在这条链一路排查下去而不是对着 Java 代码干瞪眼。绝大多数 so 相关的崩溃根因都在前四步里。1.2 为什么很多团队宁愿引 so 也不引源码第三方给你 so 而不是源码通常出于几个现实原因。一是商业保护算法、编解码这类核心资产不愿意开源二是构建成本C/C 代码的交叉编译链条又长又脆NDK 版本、CMake 版本、STL 选型稍有不一致就编不过厂商直接给你预编译产物把复杂度挡在门外三是体积和编译速度考量有些库编译一次要十几分钟放进 CI 里谁都受不了。代价就是你失去了对它的完全掌控。你无法调试它的内部逻辑无法针对特定 ABI 重新编译还得被动接受它选定的 STL 方案是c_static还是c_shared。其中c_shared最容易出问题如果第三方 so 依赖libc_shared.so却没有把它一起给你你的 App 在加载时就会看到dlopen failed: library libc_shared.so not found这样看似莫名其妙的报错。遇到这种情况要么找厂商要完整的依赖包要么在自己的工程里补上对应 NDK 版本编译出来的libc_shared.so要么让厂商改用静态链接 STL 重新出包。我一般优先选第一条第二条只作为临时过渡因为 NDK 版本不匹配时补进去的 STL 也可能引发更隐晦的崩溃。1.3 动手前必须确认的三件事在往工程里丢文件之前先花十分钟把这三件事问清楚能省下后面好几个小时。第一目标 ABI 有哪些。具体就是拿到手的 so 覆盖了armeabi-v7a、arm64-v8a、x86、x86_64中的哪几个。如果只给了armeabi-v7a那你的 App 在只有 64 位支持的设备上就跑不起来因为 Android 从某个版本开始要求 64 位设备必须提供对应的 64 位库。这个信息决定了你后面abiFilters怎么写、要不要拆 APK。第二文件命名规范不规范。必须是lib开头、.so结尾。我见过厂商直接给media.so的也见过给libmedia.so.1.2的这些都得在本地重命名或者改用绝对路径加载。第三它有没有隐性依赖。用readelf -d libxxx.so | grep NEEDEDLinux 或 WSL 下执行能看到它依赖了哪些其他动态库。如果输出里出现libc_shared.so、liblog.so、libz.so之外的第三方库名就说明你还得把那个库一起打包进来。这一步很多人跳过然后在真机上被教做人。提示这三件事的信息来源优先级是厂商提供的集成文档 直接问厂商技术对接人 自己用readelf和file命令分析。只靠猜很危险。2. 目录结构怎么规划才不出岔子2.1 jniLibs 是 AGP 默认认的路径Android Gradle Plugin 有一个默认约定src/main/jniLibs/目录下的内容会被自动识别为 Native 库并打包。这个目录下的结构必须严格按 ABI 名来组织一级子目录名就是 ABI 名称二级才是.so文件app/src/main/jniLibs/ ├── arm64-v8a/ │ └── libmediaprocess.so └── armeabi-v7a/ └── libmediaprocess.so只要目录结构是这样app/build.gradle里一行配置都不用写直接assembleDebug就能进包——这是最省事也最不容易出错的方式。ABI 目录名必须精确匹配写arm64不行写armeabi-v8a也不行写armeabi-v7同样不行AGP 不会给你任何警告它只是安静地忽略这个目录然后你在真机上收崩溃日志。jniLibs这个名字本身也是约定它跟java、res、assets是同一层级的东西。很多人第一次接触会习惯性地建一个libs目录那是 Eclipse 时代遗留的做法AGP 默认不认——除非你手动用sourceSets指过去这在下一节会讲。2.2 第三方 SDK 压缩包里常见的三种目录形态拿到 SDK 压缩包解压之后你会看到几种截然不同的目录形态处理方法各不相同。形态一已经按 ABI 分好目录。类似libs/arm64-v8a/libxxx.so、libs/armeabi-v7a/libxxx.so。这是最友好的情况直接把arm64-v8a和armeabi-v7a这两个目录整体拷到src/main/jniLibs/下面就行目录名不用改。形态二所有 so 平铺在一个目录里。比如libs/libxxx.so。这时你得自己判断这个 so 是什么架构的用file libxxx.so命令可以看到输出类似ELF 64-bit LSB shared object, ARM aarch64说明它是arm64-v8a输出ELF 32-bit LSB shared object, ARM, EABI5就是armeabi-v7a。判断完自己建对应目录放进去。形态三嵌套在示例工程里。有些厂商给的压缩包是一个完整 Demo 工程so 藏在demo/app/src/main/jniLibs/里。这种就照着 Demo 的目录结构搬。还有一种恶心人的情况目录名是armeabi不带 v7a。armeabi这个 ABI 早就被 NDK 移除了现在应该把它当作armeabi-v7a处理也就是把目录改名。放在原来那个目录名下AGP 会忽略它。2.3 ABI 怎么裁全量、双架构还是单架构ABI 选择本质上是包体积和兼容性之间的取舍。全量包含四个 ABIarmeabi-v7a、arm64-v8a、x86、x86_64兼容性最好但包体积最大一个稍大的 so 库每个架构都要占几 MB四个加起来能到几十 MB。我的建议是方案包含 ABI适用场景包体积影响双架构推荐armeabi-v7aarm64-v8a面向真机市场发布的绝大多数 App中等单架构仅arm64-v8a内测包、新设备定向发版、包体积极端敏感最小全量四个都含需要覆盖模拟器调试、企业内部老设备最大拆包每 ABI 一个 APK包体积敏感但要求全兼容能接受多包发布每个包都小x86、x86_64主要是给模拟器用的。如果你团队里有人用 x86 模拟器调试而工程里只放了 ARM 架构的 so模拟器上会直接崩。这时候有两个办法让厂商提供 x86 版本很多厂商不给或者改用 ARM 架构的模拟器镜像——后者是我更推荐的现在 Apple Silicon 和 ARM 服务器上跑 ARM 镜像性能没问题还避免了架构不一致带来的假象。需要注意的是只保留arm64-v8a有风险。因为 Android 系统在加载 so 时会优先匹配设备的主 ABI如果 APK 里没有对应架构的库会退化到 32 位版本但如果连 32 位版本都没有就直接崩。所以裁到只剩arm64-v8a之后务必确认你的minSdk覆盖的设备都支持 64 位。3. 四种引入方式按场景挑一个3.1 方式一丢进 jniLibs最省事优先选适用场景so 文件不多、不需要在多个模块间复用、就是想把库跑起来。步骤很直接创建一个目录结构然后把文件拷进去mkdir -p app/src/main/jniLibs/arm64-v8a mkdir -p app/src/main/jniLibs/armeabi-v7a cp libmediaprocess.so app/src/main/jniLibs/arm64-v8a/ cp libmediaprocess.so app/src/main/jniLibs/armeabi-v7a/注意两个架构的 so 必须是分别编译出来的对应版本不能把同一个文件复制两遍——除非这个库确实是通用的极少见。放好之后同步 GradleJava 层直接调用public class MediaProcessor { static { System.loadLibrary(mediaprocess); } public native int init(String config); }这种方式的好处是零配置、零学习成本。坏处是 so 文件进了 Git 仓库几十 MB 的二进制文件会让 clone 变慢而且多个模块要用同一个 so 时得复制多份。所以规模大了之后建议升级到 AAR 方式。3.2 方式二保留在 libs 目录用 sourceSets 指过去很多团队习惯把第三方产物统一放在app/libs/下面so 也不例外。这时候 AGP 不认这个目录得手动告诉它android { sourceSets { main { jniLibs.srcDirs [libs] } } }这行的意思是把app/libs追加为 jniLibs 的搜索路径。注意是追加不是替换写了这行之后src/main/jniLibs依然是有效路径两边的 so 都会被扫到。这里面有个容易踩的坑如果你写的是jniLibs.srcDirs [libs/armeabi-v7a, libs/arm64-v8a]那就错了。srcDirs接受的应该是包含 ABI 子目录的父目录而不是 ABI 目录本身。写成前者AGP 会把libs/armeabi-v7a当成父目录然后在它下面找 ABI 子目录自然什么都找不到。另外如果你同时用sourceSets指向了libs又在libs根目录下放了.jar或.aar这些文件不会被当成 so 处理不会有冲突。但如果libs下有个armeabi这样的废弃目录名它是会被扫描但被忽略的不会报错只是静默失效。3.3 方式三打包成 AAR适合多人协作与复用当 so 需要在多个模块或多个项目之间复用时AAR 是最干净的方案。AAR 里 so 的存放路径和 APK 不一样需要放在jni/abi/目录下而不是jniLibs/mylib.aar ├── AndroidManifest.xml ├── classes.jar ├── jni/ │ ├── arm64-v8a/ │ │ └── libmediaprocess.so │ └── armeabi-v7a/ │ └── libmediaprocess.so └── R.txt自己做一个 AAR 有两种办法。简单的是直接在 AndroidStudio 里新建一个 Library Module把 so 放进这个 Module 的src/main/jniLibs/然后./gradlew :mylib:assembleRelease产物就在mylib/build/outputs/aar/下面AGP 会自动把它转换成jni/结构。拿到 AAR 之后怎么引最直接的是丢进app/libs/dependencies { implementation files(libs/mylib.aar) }如果需要批量引入用fileTreedependencies { implementation fileTree(dir: libs, include: [*.aar]) }老版本 AGP4.x 及更早还有一个flatDir的写法repositories { flatDir { dirs libs } } dependencies { implementation(name: mylib, ext: aar) }这种写法我不推荐用了flatDir不支持传递依赖AAR 自己依赖的其他库不会被自动拉进来很容易出现明明引入了却没生效的问题。新项目直接用files或fileTree。3.4 方式四走本地 Maven 仓库或远程仓库团队规模再大一点AAR 丢libs目录也不合适了——版本管理混乱、更新靠手动拷贝。这时候把 AAR 发布到 Maven 仓库是正解。临时方案可以用本地目录仓库// settings.gradle 里AGP 7 推荐写在 dependencyResolutionManagement dependencyResolutionManagement { repositories { maven { url uri(${rootDir}/local-repo) } } }然后在app/build.gradle里正常声明依赖dependencies { implementation com.example.native:mylib:1.0.0 }本地仓库的目录结构必须是标准的 Maven 布局com/example/native/mylib/1.0.0/mylib-1.0.0.aar加上同目录下的mylib-1.0.0.pom。手工摆这个结构容易写错建议用maven-publish插件自动生成或者在 Library Module 里加一个发布任务。这一步做对了后面所有模块引这个 so 都只需要一行implementation版本升级也变成改版本号的事。4. Gradle 里那些必须写对的配置4.1 abiFilters 与 splits 的区别和选择abiFilters和splits都能控制 APK 里包含哪些 ABI但用途完全不同很多人会混。abiFilters是过滤器写在defaultConfig里作用是只保留这几个 ABI 的 so其他全部丢弃android { defaultConfig { ndk { abiFilters armeabi-v7a, arm64-v8a } } }它的典型用途是裁剪掉不需要的架构。比如某个第三方 SDK 里混着x86的 so你不想要就用abiFilters把它过滤掉。注意这个过滤是作用在整个 App 的 Native 层面包括你自己编译的、以及所有依赖带入的 so。splits是拆分器写在android顶层作用是生成多个按 ABI 拆分的 APKandroid { splits { abi { enable true reset() include armeabi-v7a, arm64-v8a universalApk false } } }产物会变成app-arm64-v8a-debug.apk、app-armeabi-v7a-debug.apk这样的多个文件每个只含一个 ABI。universalApk true会额外再打一个包含全部 ABI 的包方便你本地调试时不用纠结装哪个。一个非常重要的注意点abiFilters和splits.abi同时启用时行为会变得反直觉。官方建议是二选一用了 splits 就不要再写 abiFilters。我自己实测下来两个都写的时候容易出现某些 ABI 的包内容为空的情况排查起来很浪费时间。4.2 packaging 块里的 pickFirsts 与 useLegacyPackaging当两个依赖里带了同名同路径的 so 时打包会直接失败报More than one file was found with OS independent path lib/arm64-v8a/libxxx.so。解决办法是在 packaging 块里声明优先级android { packaging { jniLibs { pickFirsts [lib/arm64-v8a/libc_shared.so, lib/armeabi-v7a/libc_shared.so] } } }pickFirsts表示遇到重名就取第一个忽略其余的。这个配置最常用来处理libc_shared.so冲突——因为每个依赖 STL 的 so 都可能带一份自己的 STL导致 APK 里同一个路径出现多个副本。jniLibs块里另一个关键配置是useLegacyPackagingandroid { packaging { jniLibs { useLegacyPackaging true } } }这个开关控制的是打包方式。falseAGP 默认值表示 so 以未压缩形式直接存在 APK 里安装时不解压运行时直接从 APK 里 mmap 加载好处是安装快、占用空间小true表示走传统方式安装时把 so 解压到/data/app/.../lib/目录下好处是兼容性更好某些老设备或者某些需要在文件系统路径下读取 so 的场景必须用它。我遇到需要开useLegacyPackaging true的典型场景有两个一是第三方 SDK 内部用绝对路径去/data/app/.../lib/里找 so这种做法本身不推荐但确实存在二是某些加固、热修复方案要求 so 在文件系统里可见。除此之外建议保持默认的false。4.3 AGP 版本差异速查表AndroidStudio 和 AGP 版本迭代很快同样的配置在不同版本里名字不一样这是新手最容易迷糊的地方。下面这张表是我实际用过的对照配置项AGP 7.x 及以前AGP 8.0 及以后打包配置块packagingOptions { }packaging { }so 相关配置packagingOptions { pickFirst ... }packaging { jniLibs { pickFirsts [...] } }排除文件packagingOptions { exclude ... }packaging { resources { excludes [...] } }命名空间AndroidManifest.xml里的packagebuild.gradle里的namespace依赖声明implementation一样一样AGP 8.0 之后packagingOptions这个名字还能用一段时间但会给出弃用警告建议直接改成packaging。另外注意pickFirst单数字符串参数和pickFirsts复数集合参数的区别在packagingOptions时代是前者在packaging.jniLibs里是后者。写混了会直接编译报错好在是编译期就能发现不算太坑。还有一个容易忽略的点AGP 8 默认开启nonTransitiveRClass并且对资源、assets 的处理更严格如果你的 so 是通过一个老旧的 AAR 引入的AAR 里的AndroidManifest.xml带package属性而不是namespace可能会报错。这种情况要么升级 AAR要么临时用android.nonFinalResIdsfalse之类的兼容开关——但更好的做法是催厂商重新出包。5. 怎么确认 so 真的进包了5.1 命令行与 Android Studio 两种验证手段配置改完之后第一件事永远是验证 so 真的进 APK 了。不要跳过这一步直接装到手机上试因为一旦崩溃你会分不清是配置没生效还是代码写错了。命令行方式最直接./gradlew :app:assembleDebug unzip -l app/build/outputs/apk/debug/app-debug.apk | grep \.so正常的输出长这样1234567 2024-01-01 00:00 lib/arm64-v8a/libmediaprocess.so 987654 2024-01-01 00:00 lib/armeabi-v7a/libmediaprocess.so如果这一行都没有说明打包这一步就失败了后面不用查了回头检查目录结构和sourceSets配置。如果只有armeabi-v7a没有arm64-v8a那就是文件没拷全或者abiFilters写窄了。AndroidStudio 里也有图形化的方式菜单Build→Analyze APK...选中刚才生成的 APK展开lib/目录就能看到所有 so 及其大小。这个方式的好处是能顺便看到体积占比判断哪个 so 最占地方。还有一个进阶工具apkanalyzerSDK 自带apkanalyzer files list app/build/outputs/apk/debug/app-debug.apk | grep lib/它比unzip的好处是能直接跟 AndroidStudio 的构建产物路径联动在 CI 里做校验很方便。5.2 adb 侧验证设备实际加载的 ABIAPK 里有 so 不代表设备就会加载它。设备的 ABI 列表决定了系统去哪找adb shell getprop ro.product.cpu.abi adb shell getprop ro.product.cpu.abilist前者返回主 ABI比如arm64-v8a后者返回完整的支持列表比如arm64-v8a,armeabi-v7a,armeabi。系统的查找顺序就是按abilist从左到右第一个在 APK 里存在的目录就会被选中。这里有个隐蔽的坑假设设备abilist是arm64-v8a,armeabi-v7a而你的 APK 里只有armeabi-v7a的 so那么系统会正常降级加载 32 位版本App 能跑。但如果设备是纯 64 位abilist只有arm64-v8a近两年的新设备越来越多而你的 APK 里没有arm64-v8a那就直接崩。所以用 32 位 so 兜底的老办法正在逐渐失效这也是我一直建议尽快拿到 64 位 so 的原因。想确认运行期加载的是哪个路径下的 so可以在崩溃前打印一下// 调试用正式包里记得去掉 android.util.Log.d(NativeLib, dataDir getApplicationInfo().nativeLibraryDir);日志里会打出类似/data/app/~~xxx/com.example.app-xxx/lib/arm64的路径。对不上就是打包方式或者 ABI 的问题。5.3 so 相关的崩溃日志怎么读UnsatisfiedLinkError的日志看着都差不多但关键信息全在冒号后面的那半句里逐字读能省很多时间。dlopen failed: library libxxx.so not found—— 字面意思这个 so 在 APK 的任何 ABI 目录里都找不到。重点查文件有没有拷进去、目录名对不对、文件名有没有lib前缀。dlopen failed: /data/app/.../lib/arm64/libxxx.so is 32-bit instead of 64-bit—— ABI 装错了。你往arm64-v8a目录里放了个 32 位的 so。用file命令确认架构然后重新放。dlopen failed: library libc_shared.so not found—— 这是被依赖的库缺失不是主库的问题。前面 1.3 节讲过处理办法。java.lang.UnsatisfiedLinkError: No implementation found for int com.example.Foo.bar()—— 这个跟打包没关系so 加载成功了但里面没有这个方法签名。通常是 Java 侧的native方法声明和 C 侧的 JNI 注册对不上或者方法所在的 so 被加载了但另一个 so 没被加载。查一下System.loadLibrary是不是漏调了某个库。java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol xxx referenced by libyyy.so—— 这个最麻烦说明 so 加载了但它引用了一个不存在的符号。原因往往是系统版本差异某个 API 在低版本 ROM 上不存在。只能靠提高minSdk或者让厂商重新编译解决。6. UnsatisfiedLinkError 排查速查与踩坑记录6.1 高频问题速查表把这些年遇到的 so 相关故障整理成一张表出问题时对着症状找比盲查快得多。现象大概率原因处理办法APK 里完全搜不到 so目录结构不对AGP 没扫到确认路径是src/main/jniLibs/abi/或检查sourceSets只有一个 ABI 的 so少拷了文件或abiFilters写窄补齐文件核对abiFilters列表打包报重名文件错误多个依赖带同名 so用packaging.jniLibs.pickFirsts指定优先级装到真机就崩模拟器正常模拟器是 x86真机是 ARMABI 不匹配确认真机 ABI补齐对应架构的 so用 AAR 引了却没生效用了flatDir或 AAR 里的目录名写成了jniLibs改用files/fileTreeAAR 内必须是jni/abi/报libc_shared.so not foundSTL 依赖缺失找厂商要完整包或补上匹配 NDK 版本的 STL报is 32-bit instead of 64-bitABI 目录和文件架构不匹配用file命令核对后重新放置升级 AGP 后配置报错packagingOptions改名 /pickFirst变pickFirsts参照 4.3 的对照表改6.2 几个不常见但很坑的案例案例一so 存在但被 R8 剥离。严格来说 so 本身不会被 R8 处理但如果你在 Java 层用了反射去调用某个类R8 可能把那个类裁剪掉导致native方法声明消失最终报No implementation found。解决办法是在proguard-rules.pro里保留相关类-keep class com.example.nativelib.** { *; } -keepclasseswithmembernames class * { native methods; }第二段规则是保留所有含 native 方法的类的原生方法名这是 JNI 场景的标配建议直接加到规则文件里。案例二android:extractNativeLibs被其他库的 Manifest 覆盖。多个 AAR 合并 Manifest 时如果某个库声明了android:extractNativeLibstrue会跟主工程的useLegacyPackaging配置打架。用tools:replaceandroid:extractNativeLibs在AndroidManifest.xml里显式声明优先级。案例三Gradle 缓存里的旧 so 没被替换。你更新了 so 文件但打包出来的还是老版本这种情况先执行./gradlew clean再删掉app/build/intermediates/目录。AGP 对二进制文件的增量判断在少数版本里确实有 bug我碰到过一次清了缓存就好了。案例四NDK 编译出来的 so 和预编译 so 命名冲突。如果工程里既用 CMake 编译生成libnative.so又引了一个第三方的libnative.so两个会互相覆盖。这时候要给自己的 CMake 目标改名或者在packaging块里把第三方那份排除掉。6.3 16 KB 页对齐这个新变化这几年 Android 在内存页大小上有个变化值得单独提一句。传统 Android 设备的物理内存页是 4 KB新版本系统开始支持 16 KB 页大小的设备。这对 Native 库的影响是so 在 APK 里需要按 16 KB 边界对齐否则在新设备上可能加载失败或者性能受损。判断自己的包有没有问题可以用zipalign检查zipalign -c -P 16 -v 4 app-debug.apk如果输出的 so 条目对齐校验不通过说明需要重新对齐zipalign -P 16 -f -v 4 app-debug.apk app-debug-aligned.apk不过更根本的解决办法是升级工具链——较新版本的 AGP 和 NDK 在打包时已经自动处理了这个对齐。麻烦的是第三方预编译 so它们是厂商用老工具链出的对齐信息可能不满足要求。这种情况只能找厂商要新版本或者用llvm-objcopy之类的手段调整节区对齐后重新打包但那属于曲线救国能推动厂商升级就别自己动手。注意这个变化的影响范围跟targetSdk有关不是所有 App 都必须立刻处理。但如果你在做长生命周期的产品提前把工具链版本和第三方库版本对齐比事后救火省事得多。6.4 我个人的几条硬经验最后说几条我这些年攒下来的、文档里基本看不到的经验。so 文件不要进 Git 主干仓库。一个几十 MB 的 so 提交进去每个同事 clone 都要多等好几分钟而且二进制文件没法 diff冲突了只能整个覆盖。团队的做法应该是把 so 和 AAR 放到内部的文件服务或者 Maven 仓库build.gradle里走坐标引用实在要放仓库里的用 Git LFS 管理。给 so 加上大小和架构的校验脚本。我现在的项目在 CI 里加了一步构建完自动跑unzip -l检查 so 列表比对一份预期清单数量或架构对不上就直接让流水线失败。这一步拦下过好几次本地改好了忘了提交某个 ABI的低级失误成本极低收益极高。集成新 so 时先用一个空壳页面单独验证。不要一上来就往业务代码里塞先写个最小 ActivitySystem.loadLibrary加一个最简单的 JNI 调用确认能跑通再往业务里集成。这样排查范围小得多出问题时你能确定就是 so 本身的问题。保留一份 so 的来源记录。哪个 so 来自哪个 SDK 的哪个版本写在一个NATIVE_LIBS.md里。半年后有人问这个libfoo.so是谁引进的翻文档比翻 Git 历史快一百倍。我上一个项目就是因为没做这件事升级某个 SDK 时误删了一个看着没人用的 so导致线上崩溃率飙升回滚加排查花了整个通宵。遇到问题先看file和readelf的输出别先怀疑代码。我处理过的 so 相关故障里九成以上是打包和 ABI 层面的问题Java 代码本身写错的概率反而很低。养成拿到 so 第一件事就是file libxxx.so和readelf -d libxxx.so | grep NEEDED的习惯能挡掉大部分意外。