上个月帮一个做音视频集成的团队看崩溃,他们的 App 一点进直播间就挂,日志里只留了一行java.lang.UnsatisfiedLinkError: dlopen failed: library "libmediaprocess.so" not found。Java 层代码翻来覆去看了两小时没毛病,SDK 文档也照着写了,最后发现问题极其朴素:第三方 so 库压根没被打进 APK,app/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 NEEDED(Linux 或 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 选择本质上是包体积和兼容性之间的取舍。全量包含四个 ABI(armeabi-v7a、arm64-v8a、x86、x86_64),兼容性最好但包体积最大,一个稍大的 so 库每个架构都要占几 MB,四个加起来能到几十 MB。我的建议是:
| 方案 | 包含 ABI | 适用场景 | 包体积影响 |
|---|---|---|---|
| 双架构(推荐) | armeabi-v7a+arm64-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 必须是分别编译出来的对应版本,不能把同一个文件复制两遍——除非这个库确实是通用的(极少见)。放好之后同步 Gradle,Java 层直接调用:
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') }如果需要批量引入,用fileTree:
dependencies { implementation fileTree(dir: 'libs', include: ['*.aar']) }老版本 AGP(4.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 拆分的 APK":
android { 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块里另一个关键配置是useLegacyPackaging:
android { packaging { jniLibs { useLegacyPackaging = true } } }这个开关控制的是打包方式。false(AGP 默认值)表示 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里的package | build.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.nonFinalResIds=false之类的兼容开关——但更好的做法是催厂商重新出包。
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 最占地方。
还有一个进阶工具apkanalyzer,SDK 自带:
apkanalyzer files list app/build/outputs/apk/debug/app-debug.apk | grep 'lib/'它比unzip的好处是能直接跟 AndroidStudio 的构建产物路径联动,在 CI 里做校验很方便。
5.2 adb 侧验证设备实际加载的 ABI
APK 里有 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,真机是 ARM,ABI 不匹配 | 确认真机 ABI,补齐对应架构的 so |
| 用 AAR 引了却没生效 | 用了flatDir,或 AAR 里的目录名写成了jniLibs | 改用files/fileTree;AAR 内必须是jni/<abi>/ |
报libc++_shared.so not found | STL 依赖缺失 | 找厂商要完整包,或补上匹配 NDK 版本的 STL |
报is 32-bit instead of 64-bit | ABI 目录和文件架构不匹配 | 用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:extractNativeLibs="true",会跟主工程的useLegacyPackaging配置打架。用tools:replace="android: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 时,先用一个空壳页面单独验证。不要一上来就往业务代码里塞,先写个最小 Activity,System.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的习惯,能挡掉大部分意外。