ABI 这个问题,我在带新人的时候发现一个规律:几乎所有人都知道 APK 里有个lib目录,也都知道里面按 CPU 架构分了几个子目录,但真正问下去——为什么同一个 App 要放三份 .so、删掉 armeabi 会不会出事、64 位到底快在哪里、为什么有些机型装上去一点就闪退——能讲清楚的不多。我自己就栽过跟头:早期做一个音视频处理的项目,为了压包体把armeabi-v7a删了只留arm64-v8a,测试机全是新旗舰,跑得飞起,结果上线第二天收到一批中低端机的崩溃反馈,全是UnsatisfiedLinkError。那次之后我把 Android ABI 这套东西从指令集到打包配置到线上排查完整梳理了一遍。这篇内容就是那段时间的积累,从arm64-v8a、armeabi-v7a 与 armeabi三种架构的底层差异讲起,落到兼容性规则、打包裁剪策略、性能优化的真实收益,再到线上排查的具体手法。适合正在接 NDK、正在被 so 体积困扰、或者正准备做 64 位迁移的 Android 开发者看,做过 JNI 的能直接拿去对照,没碰过 Native 的也能把兼容性这块搞明白。
1. 先把 ABI 这个概念拆开看
1.1 指令集、调用约定与应用二进制接口的三层关系
很多人把 ABI 和 CPU 架构混为一谈,其实这两者不是一回事。CPU 架构决定的是指令集,也就是这颗芯片认得哪些机器码;ABI(Application Binary Interface)在指令集之上还规定了一堆更细的东西:寄存器怎么用、函数参数怎么传(放寄存器还是压栈)、栈帧怎么组织、数据类型对齐规则、系统调用号怎么编、动态库的符号命名和重定位格式。打个比方,指令集像是“这门语言有哪些词”,ABI 则是“这些词怎么组成句子、标点怎么打、敬语怎么用”——词对了但语法不对,对方照样听不懂。
这个区分在实际开发里非常关键。同样是 ARM 指令集,32 位和 64 位就是两套不同的 ABI,编译出来的机器码根本无法互相识别。你在 arm64 设备上跑一个 arm64-v8a 的 .so,系统内核能加载;你放一个 armeabi-v7a 的 .so,在支持 32 位的设备上系统会用兼容层把它跑起来,但在纯 64 位设备上就直接拒绝加载。这就是为什么同一个功能要编出好几份二进制。
具体到 Android 生态,官方支持过的 ABI 有这么几种:armeabi、armeabi-v7a、arm64-v8a、x86、x86_64、mips、mips64。后两个早在 NDK r17 就被移除了,现在基本没人提;x86 系列主要活在模拟器和少量早期平板、Chromebook 上。日常真正需要操心的是 ARM 这三兄弟。
1.2 三种 ARM ABI 的来龙去脉与当前定位
armeabi是最老的一代,对应 ARMv5TE 指令集,最大的特征是没有硬件浮点单元,浮点运算靠软件模拟,性能极差。它还缺少很多现代指令,比如没有 NEON 向量指令。这个东西在 NDK r17(2018 年)就被正式移除了,意味着你就算想编也编不出来。现在如果还有项目里躺着lib/armeabi/目录,那大概率是几年前遗留的第三方 SDK 没更新。
armeabi-v7a对应 ARMv7-A,2009 年前后随 Android 2.x 普及。它带来了硬件浮点(VFPv3)、可选的 NEON 向量指令、Thumb-2 指令集。这里有个细节值得说清楚:Android 的 armeabi-v7a ABI 强制使用-mfloat-abi=softfp,也就是说浮点参数仍然通过通用寄存器传递(走软件约定),但浮点运算本身用硬件执行。这么做是为了和老的 armeabi 保持调用约定兼容,代价是损失了一点传参效率。NEON 在 v7a 上是可选的,虽然绝大多数实际设备都有,但严格来说不能无条件假设。
arm64-v8a对应 ARMv8-A 的 64 位执行状态 AArch64。它的变化不只是“位宽翻倍”,而是整套架构重做:31 个通用寄存器(32 位只有 16 个)、固定的 32 位指令编码(告别 Thumb 模式切换)、更大的虚拟地址空间、内置 AES/SHA 硬件加速指令、更严格的栈对齐要求。从 2019 年 8 月起,应用商店对新提交和更新的应用强制要求提供 64 位版本,这条规则基本宣告了“只发 32 位包”的时代结束。
顺带提一句 NDK 版本对应关系,这个在实际编库时经常对不上:NDK r17 移除 armeabi 和 mips;r18 起-mfloat-abi相关行为有变化;r23 之后默认工具链全面转向 LLVM 的 clang,很多老Android.mk里的 gcc 参数会直接报错。我建议统一用 r21 以上的 LTS 版本,跨项目维护成本最低。
1.3 为什么架构选错不会立刻报错,而是跑到一半才崩
这是最坑的一点,也是我那次线上事故的根本原因。假设你的 APK 里只有arm64-v8a目录,用户装在一台只支持 32 位的老设备上,会发生什么?安装阶段可能完全正常,启动也正常——因为 Java/Kotlin 那层代码是 dex 字节码,跟 ABI 无关,ART 会把它编译成当前设备能跑的机器码。一路跑到调用System.loadLibrary("xxx")的那一行,才抛出UnsatisfiedLinkError。
更隐蔽的一种情况是:主 .so 加载成功了,但它在运行过程中按需dlopen另一个依赖库,那个库路径或者架构不对,崩溃发生在用户点了某个功能按钮之后。这种崩溃的堆栈信息往往指向业务代码深处,跟“架构”两个字看起来毫无关系,排查成本极高。
还有一种更少见的:ABI 对了,但 .so 里用到了当前 CPU 不支持的指令。比如你在编译时开了-march=armv8-a+crypto,然后在某颗阉割了加密扩展的芯片上跑,触发SIGILL(非法指令)。这种崩溃的堆栈看起来乱七八糟,PC 指针指向的地址甚至反汇编不出来,因为那条指令在当前 CPU 上根本不成立。
注意:
UnsatisfiedLinkError和SIGILL在崩溃平台上经常被归到不同的错误类型里,看趋势图的时候要一起看,不然容易漏掉架构问题。
2. 系统的 so 加载规则到底是怎么定的
2.1 一次 loadLibrary 背后的完整查找链路
搞清楚这条链路,很多问题不用猜。调用System.loadLibrary("foo")之后,大致会走这么几步:Runtime.loadLibrary0拿到当前 ClassLoader,从 ClassLoader 里取出nativeLibraryDir(这个路径在安装时由 PackageManager 根据设备的首选 ABI 确定,通常长这样/data/app/~~随机串/包名-随机串/lib/arm64),然后拼出完整文件名libfoo.so,交给 native 层的dlopen。如果这个目录里没有,再按java.library.path里的系统路径查找。
关键点在于:nativeLibraryDir在安装时就已经定死了,指向某一个 ABI 子目录。系统不会在你loadLibrary失败后自动去试另一个 ABI 目录。这一点直接引出了后面要讲的“混合架构陷阱”。
设备选哪个 ABI 目录,取决于两个东西的匹配结果:设备支持的 ABI 列表(Build.SUPPORTED_ABIS,从高到低排),和 APK 里实际包含的 ABI 目录。系统会按设备 ABI 优先级依次比对,取第一个在 APK 里存在的。
可以用几条命令直接看现场:
# 查看设备支持的 ABI 列表,顺序即优先级 adb shell getprop ro.product.cpu.abilist # 输出示例:arm64-v8a,armeabi-v7a,armeabi # 查看某个应用实际被分配的 primaryCpuAbi adb shell dumpsys package com.example.app | grep -i abidumpsys里那个primaryCpuAbi=arm64-v8a就是最终答案。如果这里显示的是armeabi-v7a,而你的 .so 只有 arm64 版本,那启动必崩,一查一个准。
2.2 64 位设备跑 32 位包的兼容逻辑与现实代价
大部分 64 位 ARM 设备是“双栈”的,能同时跑 32 位和 64 位进程,但同一个进程不能混用——要么整个进程是 32 位,要么整个进程是 64 位。系统在安装时根据 APK 里的 ABI 目录和设备的 ABI 列表做决策,一旦选定,这个 App 的所有 native 代码都必须在同一个位宽下运行。
这里有个容易被忽略的现实约束:进程位宽决定了能映射的地址空间。32 位进程的用户态地址空间上限大概 3GB 出头(还要扣掉各种保留区),做大图处理、视频解码、3D 渲染这类内存吃紧的场景,32 位进程很容易 OOM。而 64 位进程不存在这个瓶颈,理论上可以映射到 TB 级别。这也是为什么某些重内存型 App 强推 64 位——不是为了跑得快,而是为了不崩。
另外,近几年的新设备趋势很明显:一部分旗舰机型和部分定制系统在系统层就把 32 位兼容能力砍掉了,只保留 64 位运行时。这类设备上装只有 32 位 so 的 App,安装阶段就可能被拦下来,或者装上后无法启动。判断当前进程位宽有个简单方法:
// API 23+ 可用 boolean is64Bit = Process.is64Bit();这个方法比去解析Build.SUPPORTED_ABIS靠谱得多,因为它看的是当前进程的实际状态。做日志埋点的时候把这个值带上,线上排查架构问题能省一半时间。
2.3 最隐蔽的坑:混合架构下的 so 依赖断裂
这是我要重点讲的一个场景,因为在社区里被反复问到,但很少有人讲透。
假设你的 APK 同时包含arm64-v8a和armeabi-v7a两个目录。arm64-v8a里有libmain.so,但它依赖的libcodec.so只放在了armeabi-v7a里(第三方 SDK 只给了 32 位版本,或者打包脚本漏拷贝了)。用户是新设备,系统优先选arm64-v8a目录,libmain.so加载成功,然后它去dlopen("libcodec.so"),在arm64-v8a目录里找不到,转头去系统库路径找,也找不到,直接崩。
崩溃日志长这样:
java.lang.UnsatisfiedLinkError: dlopen failed: "/data/app/.../lib/arm64/libcodec.so" is 32-bit instead of 64-bit注意后半句“is 32-bit instead of 64-bit”——这说明系统其实找到了这个文件,但发现位宽不对。为什么会找到?因为某些情况下加载器会去别的路径兜底,或者构建时把 32 位 so 错误地塞进了 64 位目录。不管哪种,看到这句报错,第一反应就应该是去解包看目录结构。
反过来也有一种:arm64-v8a目录里所有 so 都在,但少了一个。系统在选目录时只看目录存在与否,不看内容完整度,照样把nativeLibraryDir定到arm64-v8a,然后在运行到那个缺失的库时崩。这种情况最容易发生在“部分模块改用 arm64,部分模块还是老三样”的过渡期项目里。
我自己的做法是:把 ABI 目录完整性做成构建期的硬校验,任何一个目录里的 so 集合与其他目录不一致,直接让构建失败。这个校验脚本不到三十行,但拦住过好几次事故。
3. 打包策略:全量、裁剪还是分包
3.1 三种主流方案的取舍逻辑
先摆一个最基本的判断标准:你控制不控制分发渠道。如果走应用市场,市场本身支持按 ABI 拆包分发,那你完全可以让每个用户只下载到跟自己设备匹配的那一份;如果是自己分发安装包、或者需要提供离线包,那就得在一个包里塞多份,体积压力全在自己身上。
方案上大致三种:
第一种是全量打包,arm64-v8a、armeabi-v7a都放进去。优点是绝对兼容,缺点是包体大,通常 so 部分会占到 10MB 到 50MB 不等,视依赖的库而定。早期没得选,现在基本只在特殊分发场景用。
第二种是只发arm64-v8a。包体最省,性能也最好,但直接放弃了 32 位设备市场。如果产品定位明确是面向新设备,这是可行的;但如果用户盘里有相当比例的老机型,这一刀切下去会很疼。
第三种是按 ABI 拆分成多个 APK,或者用 App Bundle 交给商店按需分发。这是现在的标准答案。App Bundle 的机制是上传一个包含全部资源的 bundle,商店在分发时根据设备配置生成只含对应 ABI 的 APK 或 split APK。开发者侧只需要正常打包,配置层面几乎无感。
3.2 用 abiFilters 做精准裁剪的实操配置
不管走哪种方案,abiFilters都是必须掌握的开关。它决定了最终产物里保留哪些 ABI 目录。Gradle 里的写法:
android { defaultConfig { ndk { // 只保留这两个,x86 系列会被剔除 abiFilters 'arm64-v8a', 'armeabi-v7a' } } }这里有个坑要提醒:abiFilters只对本模块自己编译的 native 代码生效,对以 aar/jar 形式引入的第三方依赖里的 so 不一定生效。有些库在 aar 里预置了多个 ABI 目录,abiFilters能不能剔掉,取决于 AGP 版本和依赖的打包方式。我遇到过abiFilters写得很干净,但解包一看lib/x86/还在的情况,最后是在packagingOptions里补了一刀:
android { packagingOptions { // 显式排除不需要的 ABI 目录 jniLibs { excludes += ['lib/x86/**', 'lib/x86_64/**', 'lib/armeabi/**'] } // 多个依赖里出现同名 so 时的处理策略 pickFirsts += ['lib/**/libc++_shared.so'] } }pickFirsts那条是经验之谈:libc++_shared.so几乎每个带 C++ 的第三方 SDK 都会带一份,版本不同的时候不处理就会报重复文件冲突,或者更糟——被随机选了一个旧版本,运行时符号找不到。
3.3 体积收益的量化:一份真实的测算
空谈“能省多少”没意义,我拿一个中等规模的音视频类项目做过测算,依赖包括 FFmpeg 裁剪版、一个图像处理库、一个网络库的 native 部分。结果大致如下:
| 打包方案 | so 总体积 | 相对全量 | 说明 |
|---|---|---|---|
| 全量(v8a + v7a + armeabi) | 约 42MB | 100% | armeabi 是老库残留 |
| 全量(v8a + v7a) | 约 36MB | 86% | 剔除 armeabi 后 |
| 仅 v8a | 约 21MB | 50% | 放弃 32 位设备 |
| 仅 v7a | 约 15MB | 36% | 放弃 64 位性能 |
| Bundle 分包后用户实收 | 约 18~21MB | 43%~50% | 单设备只收一份 |
从这张表能看出两个信息:一是armeabi那份纯属浪费,任何还有它的项目都应该第一时间清掉;二是 arm64 的 so 普遍比 v7a 大 30%~45%,因为 64 位指令编码固定为 4 字节,而 32 位可以用 Thumb 压缩到 2 字节,代码段天然更小。
3.4 还有一个容易漏的体积项:so 是否解压
AndroidManifest.xml里有个android:extractNativeLibs属性,默认值在不同 AGP 版本下变过。它的含义是:安装时是否把 APK 里的 .so 解压到/data/app/.../lib/目录。
设成false的话,.so 直接从 APK 内 mmap 加载,好处是安装后占用空间更小(不用再存一份)、安装更快、启动时加载也可能更快(省了磁盘 IO)。代价是 .so 必须在 APK 里保持页对齐,否则加载失败。
现在 AGP 默认会做对齐处理,但要确认两点:一是 .so 在 APK 里是未压缩存储的,二是 4KB 还是 16KB 对齐。后者在近两年变成了一个新话题——部分新设备开始支持 16KB 页大小,如果 so 只按 4KB 对齐,在这些设备上加载会出问题。构建时可以用zipalign -c -P 16 -v 4 app.apk检查。
4. 性能差异到底有多大,怎么吃满
4.1 arm64 的真实优势不只是“位宽翻倍”
很多人以为 64 位就是“能寻址更大内存”,实际上在计算密集场景里,arm64 的收益主要来自三处。
第一是寄存器数量。AArch64 有 31 个通用寄存器可用,而 AArch32 只有 16 个(其中还有几个被 SP、PC 占用)。寄存器多了,编译器可以把更多局部变量留在寄存器里,减少访存次数。这个提升在函数调用频繁、循环体复杂的代码里非常明显,纯计算型负载通常能拿到 10%~20% 的提升。
第二是指令编码。AArch64 走的是定长 32 位指令,不再需要 ARM/Thumb 状态切换,流水线更友好,分支预测也更简单直接。这一块带来的收益比较隐性,但在大量小函数调用的场景里能体现出来。
第三是 SIMD 和加密指令。AArch64 的 NEON 是架构强制项,不用再判断“这颗 CPU 到底支不支持”。同时它内置了 AES、SHA1、SHA256 的硬件指令,做加解密的时候差距能到几倍。如果你的 App 有 TLS 通信、有本地数据加密,这一块的收益很实在。
4.2 编译参数怎么调才有意义
默认的 Release 编译配置其实已经不错了,但有几个开关值得手动加上:
# CMakeLists.txt 里的常用优化组合 set(CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -O2 -fvisibility=hidden") set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O2 -fvisibility=hidden") # 函数级/数据级分段,配合链接器的垃圾回收,能有效减小体积 set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -ffunction-sections -fdata-sections") set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--gc-sections")-fvisibility=hidden这条要特别说。C++ 编译出来的符号默认是全局可见的,-fvisibility=hidden把所有符号藏起来,只显式导出的才暴露。这么做的收益有两层:一是动态符号表变小,so 体积下降明显(有些项目能降 10% 以上);二是减少符号冲突风险——两个 so 里有同名符号,全局可见时可能互相干扰。
-O3要不要开?我的经验是谨慎。-O3会做更激进的向量化和循环展开,代码体积涨得厉害,某些情况下因为指令缓存命中率下降反而变慢。除非你做过明确的 A/B 测试,否则-O2是更稳妥的选择。带-flto(链接时优化)的话效果通常不错,但会显著拖慢构建速度,建议只在正式包开启。
还有一个常被忽略的成本项:调不调 strip。so 里保留的调试符号表动辄几百 KB 到几 MB,strip掉能立竿见影瘦一圈。但注意,strip 之后线上崩溃堆栈就只剩地址了,得有配套的符号表管理机制(保留未 strip 的版本,配合ndk-stack或者崩溃平台上传符号文件)。我见过为了省体积直接 strip 又不留符号,结果线上崩溃完全没法定位的情况,这就属于捡了芝麻。
4.3 加载耗时与内存占用的权衡
一个反直觉的现象:arm64 的 so 更大,理论上加载更慢,但实测下来差异往往被其他因素盖过。真正影响加载耗时的,主要是 so 的依赖数量和重定位项多少。
减少 DT_NEEDED 数量是个有效手段。如果你的代码用了libc++_shared.so,那每个依赖它的 so 都会在加载时触发一次依赖解析。更麻烦的是如果多个第三方 SDK 各自静态链接了一份 libc++,so 体积会成倍膨胀,还可能因为 ODR(单一定义规则)冲突产生难以复现的诡异 bug。统一用一份共享的libc++_shared.so是更干净的方案,代价是这个 so 必须打进包里。
内存占用这块,64 位进程因为指针翻倍,同样的数据结构会略大一些,通常增加 15%~30%。但要注意 ART 在 64 位下有更好的 GC 表现,加上没有 3GB 地址空间天花板,综合下来对于内存吃紧的应用,跑 64 位反而更不容易 OOM。
关于加载时机,我的建议是把非首屏必需的 so 从static { System.loadLibrary() }里挪出来,改成懒加载。静态初始化块里的loadLibrary会阻塞类加载,直接影响冷启动时间。实测把两个非核心 so 挪到懒加载后,冷启动能快 100ms 以上,具体数值取决于 so 大小和存储介质。
5. 线上问题排查的实战手法
5.1 UnsatisfiedLinkError 的几种典型形态与对应病根
同一个错误类,背后可能是完全不同的原因,光看错误类型没有意义,必须看 message。我把遇到过的整理成一张对照表:
| 错误信息特征 | 根本原因 | 处置方向 |
|---|---|---|
couldn't find "libxxx.so" | 目标 ABI 目录里没有这个文件 | 检查打包产物目录结构 |
is 32-bit instead of 64-bit | 位宽不匹配,32 位 so 混入 64 位路径 | 检查依赖库的 ABI 覆盖 |
is 64-bit instead of 32-bit | 反向不匹配,进程是 32 位但加载了 64 位 so | 检查是否有动态下载 so 的逻辑 |
has text relocations | 老式 so 未按位置无关代码编译 | 重新用-fPIC编译 |
cannot locate symbol "xxx" | 符号未定义,通常是依赖库版本不一致 | 用nm -D比对符号表 |
library "libxxx.so" not found | 完全没找到,路径或名字写错 | 核对文件名大小写与前缀 |
“cannot locate symbol”这一类特别值得展开。典型场景是:主 so 依赖libc++_shared.so的某个新版本符号,但包里被pickFirsts选中了一份旧版本,链接期没问题(因为编译用的是新版本头文件),运行期找不到符号就崩了。这类问题的排查思路是先确认包里实际是哪一份,再看那份导出符号里有没有缺失的那个。
5.2 一套可复用的排查工具箱
排查 ABI 问题,把下面这几条命令练熟,基本能覆盖九成场景。它们都不依赖什么特殊工具,普通的构建机或者开发机上就能跑。
# 1. 看 APK 里到底有哪些 ABI 目录和 so 文件 unzip -l app-release.apk | grep "lib/" # 2. 确认某个 so 的 ELF 头信息,包括位宽和架构 readelf -h libxxx.so | grep -E "Class|Machine" # 3. 列出动态符号表,排查符号缺失 nm -D --defined-only libxxx.so | head -50 # 4. 查看 so 依赖了哪些其他库 readelf -d libxxx.so | grep NEEDED # 5. 查看 so 里是否包含不支持的指令集特性 readelf -A libxxx.so第 2 条对定位位宽问题极其高效,Class: ELF64还是ELF32一眼就清楚,比解包翻目录更直接。第 5 条能看出 so 里标记的架构特性(比如有没有Tag_CPU_arch: v8、有没有 NEON 标记),排查SIGILL的时候是必备手段。
还有一个容易被忽视的动作:核对线上崩溃设备的具体型号与系统版本分布。如果某一类崩溃集中出现在某个价位段或者某个系统版本区间,那基本可以断定是架构或者指令集问题,而不是代码逻辑问题。这个判断能直接砍掉一半排查路径。
5.3 崩溃堆栈里 native 部分的还原
SIGSEGV、SIGILL、SIGABRT这类 native 崩溃,堆栈里全是地址和寄存器快照,看不懂是正常的,得靠符号化还原。基本流程是拿到崩溃日志(含#00 pc 00000000000abc12 /data/app/.../lib/arm64/libxxx.so (函数名+偏移)这样的行),把它喂给ndk-stack:
adb logcat | ndk-stack -sym ./obj/local/arm64-v8a/符号目录必须和崩溃设备的 ABI 严格对应。用 v7a 的符号去还原 arm64 的崩溃,出来的结果是错的,而且错得很隐蔽——你可能对着一个根本不存在的函数名查半天。
如果项目接入的是第三方的崩溃收集服务,注意确认它有没有正确上传 native 符号表,以及上传的是哪个 ABI 的。我遇到过只传了 v7a 符号、arm64 崩溃全部还原不出来的情况,白白浪费了两周排查时间。
一个实操建议:把未 strip 的 so 按版本号 + ABI 归档,构建流水线里自动上传。这件事前期做起来麻烦,但真正出问题的时候能救命。
6. 工程化落地与长期维护
6.1 依赖收口:把 so 的来源管起来
项目做大了之后,so 的来源会变得非常分散:自己编的、第三方 SDK 带的、某个功能库间接依赖的、甚至历史遗留没人说得清出处的。这种状态下任何架构调整都是灾难。
我的做法是建一份 so 清单,记录每个 so 的来源、版本、支持的 ABI、体积、是否提供源码。维护方式可以很简单,一个 Markdown 表格贴在仓库里,每次接入新依赖时更新。看着原始,但极其有效——想剔掉某个 ABI 的时候,翻表就知道会影响谁。
更进一步,可以在构建期自动生成这份清单并和基线对比。思路是解包最终 APK,列出所有 so 路径和大小,和上一次的基线做 diff,新增或体积变化超过阈值的打出来。这个脚本我用 bash 写过一版,大概五十行,接在 CI 的最后一步,日报里推送。它抓到过一次某依赖库悄悄从 v7a 换成了全 ABI,包体一夜涨了 8MB。
6.2 CI 里必须有的几道 ABI 校验
基于前面的经验,我把几项检查固化成流水线卡点。这些东西不复杂,但能挡住绝大部分低级错误。
第一道,目录完整性校验:如果产物里同时有多个 ABI 目录,检查每个目录下的 so 文件名集合是否一致。不一致就报警,人工确认是有意为之还是一时疏忽。
第二道,位宽一致性校验:遍历每个 ABI 目录下的 so,用readelf -h检查Class字段是否与目录名匹配。arm64-v8a目录里必须是ELF64,armeabi-v7a里必须是ELF32。这条能直接拦住混合架构问题。
第三道,废弃 ABI 检查:扫描产物里有没有lib/armeabi/或lib/mips/,有就报错。老库残留是包体膨胀的常见来源,必须主动清理。
第四道,页对齐检查:如果开启了extractNativeLibs=false,用zipalign -c验证对齐状态,包括 4KB 和 16KB 两种情况。
这几道检查加起来,单次执行时间也就几秒钟,但能避免的线上事故价值不可估量。
6.3 迁移节奏上的几个判断
如果在做一个还没完全 64 位化的项目,我的建议是分阶段推进,别想着一把梭。
第一阶段,清掉armeabi。这一步几乎没有风险,因为对应设备占比已经低到可以忽略,NDK 也不再支持。先做这个能立刻看到包体收益。
第二阶段,补齐arm64-v8a。把所有依赖库过一遍,缺 64 位版本的想办法替换或者找替代方案。这一步是整个迁移里最费时的,尤其是那些年久失修的第三方 SDK。有些库会提供更新版本,有些只能自己重新编译,还有些彻底没戏——那就得评估业务上能不能砍掉。
第三阶段,验证 64 位下的稳定性。切到只发 64 位或者双架构并存,重点观察内存占用、崩溃率、启动耗时。特别注意那些依赖指针大小的代码(比如把指针强转成int的老代码),这类问题在 32 位下永远不会暴露,切到 64 位立刻崩。
第四阶段,根据实际用户分布决定要不要继续保留armeabi-v7a。这个决策要有数据支撑,看的是真实活跃用户的机型分布,而不是想当然。
我在实际操作中的一个体会是,迁移过程中最大的阻力从来不是技术,而是依赖的不可控。很多第三方库的维护状态不透明,你没法确定它什么时候会更新,甚至没法确定它内部用了什么架构假设。所以越早把依赖清单建起来、越早开始推动替换,后面越从容。等到应用商店的合规期限压过来的时候再动手,就只能被迫接受一些不理想的方案了。
最后再分享一个小技巧:做 64 位迁移验证的时候,别只在旗舰机上测。找一个两三年前的、支持 32 位兼容的中端机,和一个明确只支持 64 位的新设备,这两台机器一起测,能覆盖绝大多数边界情况。中端机上要看的是双架构切换逻辑对不对,新设备上看的是纯 64 位环境下有没有隐藏的依赖缺失。这两类问题在模拟器上基本测不出来,因为模拟器的 ABI 配置和真机差异太大。