首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
Android 构建与依赖的两个硬坑:NDK 工具链误判与 System.setProperty 前置注入
📅 2026/10/27 1:59:31
✍️ 爱科研究院
👁 阅读 3,247
两个坑同源你以为用的是 A实际跑的是 B。前言构建问题最折磨人的地方是错误信息离根因很远。你改了三十行 CMake 参数报错却是找不到某个头文件你确认依赖已经打进包里运行起来还是ClassNotFoundException。等你终于找到原因往往发现只是某个默认值替你做了决定。这篇文章记录两个这样的坑。一个在编译期cmake 版本差一个小数点把 Android 交叉编译当成了 Nsight Tegra 工程。一个在运行期Apache POI 在 Android 上崩因为 JVM 按标准顺序去找 XML 工厂而这套顺序在 Android 上全部落空。它们的共性是**“看不见的默认值”**CMake 会自己挑一个工具链版本JVM 会自己挑一个实现类。默认值不写出来就永远不知道自己用的是哪一个。这两个坑还共享一个排查特征真正的原因出现在你最后才会怀疑的地方。构建脚本看过、依赖列表看过、源码也看过才想起来问一句我用的是哪个 cmake、哪个工厂。所以后文的重点不是记住这个坑而是把那几个默认值显式化。一、坑一cmake 3.18.1 把 Android 当成了 Nsight Tegra工程需要在 Android 上编译PROJ坐标投影库和SQLite它的依赖构建脚本里指定cmake-DCMAKE_SYSTEM_NAMEAndroid\-DCMAKE_ANDROID_NDK$NDK_PATH\-DANDROID_ABIarm64-v8a\...用 NDK 自带的 cmake3.18.1跑报错信息完全不知所云——交叉编译分支根本没走cmake 把这次配置当成了Nsight TegraNVIDIA 早年那个 Android IDE工程。Nsight Tegra 是 NVIDIA 给 Android 做的一套 Visual Studio 插件cmake 早年为了支持它在Android这个 system name 下塞了一段专门的生成器逻辑。后来这条路被废弃3.19 把它整体移除。于是同一个-DCMAKE_SYSTEM_NAMEAndroid在 3.18 里命中旧分支在 3.19 里才落到通用的交叉编译流程——版本差一位走的是两套代码。1.1 根因cmake 对CMAKE_SYSTEM_NAMEAndroid的支持分水岭在3.19cmake 版本行为 3.19Android命中遗留的 Nsight Tegra 支持代码交叉编译工具链设置被忽略或错乱 3.19Nsight Tegra 支持被移除Android走标准交叉编译分支NDK r19 自带的恰好是 3.18.1。它不报不支持而是静默走错分支错误在很后面的编译阶段才爆出来找不到头文件、链了 x86 的库排查极其痛苦。确认的方式是看 cmake 的检测输出里有没有Nsight Tegra字样或者把-DCMAKE_SYSTEM_NAMEAndroid配上--system-information看它最终把目标系统解析成了什么。这类走错分支的问题证据只有一行日志找到它就省掉半天。1.2 修复与版本守卫# config.sh显式指向本机 SDK 里的新版 cmakeCMAKE_BINSDK/cmake/版本/bin/cmake.exe# 3.22.1满足 3.19# 构建脚本开头加版本守卫CMAKE_VERSION$($CMAKE_BIN--version|head-1|grep-o[0-9]*\.[0-9]*)ifversion_lt$CMAKE_VERSION3.19;thenechoERROR: cmake 3.19 required (found$CMAKE_VERSION)echoNDK 自带的 3.18.1 会把 Android 误判为 Nsight Tegraexit1fi交叉编译的工具链版本是硬依赖构建脚本第一件事就该校验版本。这类校验写十行能省掉一整天。二、坑一的第二现场构建期的 sqlite3PROJ 的构建需要在构建机上运行 sqlite3 生成proj.db投影数据库所以构建机上得有可用的sqlite3.exeSQLITE3_BIN你的 SDK 路径/platform-tools/sqlite3.exe# 3.50.4两个注意点一是 PROJ 要求 sqlite3 3.46生成的 db 格式与用到的 SQL 特性依赖版本二是位数无关——构建机上的 sqlite3 是 32 位也没关系它只负责生成proj.db这个数据文件db 文件跨平台可用设备上跑的是你自己交叉编译的 arm64libsqlite3.so。版本为什么要卡这么死proj.db的 schema 由 PROJ 的 SQL 脚本创建脚本里用到的语法和函数要跟 sqlite3 的能力匹配。版本低了会在建表阶段报语法错而这个错通常被包在脚本执行失败里看不到具体是哪一句。分清构建期工具跑在构建机与目标期库跑在设备这条能解释掉很多为什么我编的库在手机上跑不了。它还能解释另一个困惑sqlite3命令在构建机能跑编出来的libsqlite3.so在设备上却加载失败——前者是 x86 的宿主程序后者是 arm64 的共享库除了名字相似没有任何关系proj.db是两者之间唯一的数据契约。三、交叉编译的完整工具链构建机Windows ├── cmake 3.19 ← 坑 1NDK 自带 3.18.1 不行 ├── ninja / make ← 生成器 ├── NDKclang、sysroot ← 真正的交叉编译器 ├── sqlite3.exe 3.46 ← 坑 2构建期生成 proj.db ├── JDKGradle 需要 └── 源码包 ├── proj-9.8.1.tar.gz └── sqlite-amalgamation.zip ← 单文件版 SQLite 源码 产物arm64-v8a ├── libproj.so ├── libsqlite3.so └── proj.db ← 数据文件打进 assets 或 jniLibs任何一环版本不对错误都出现在离根因很远的地方。这张图值得贴在构建脚本旁边ninja 没装cmake 会在生成阶段报一个含糊的 generator 错误sqlite3 版本低了错误会出现在 PROJ 初始化数据库的那一刻看起来像运行期问题。四、Android 上编译 PROJ 的要点先编 SQLite单文件 C 源码最容易成功cmake-DCMAKE_SYSTEM_NAMEAndroid\-DCMAKE_ANDROID_NDK$NDK\-DANDROID_ABIarm64-v8a\-DANDROID_PLATFORMandroid-24\-DCMAKE_BUILD_TYPERelease\-DBUILD_SHARED_LIBSON\../sqlite-amalgamation-3460100先编 SQLite 不只是顺序问题也是隔离问题单文件源码、无外部依赖能最快验证 NDK 与 cmake 这一层有没有配好。它编不过PROJ 一定编不过先在它身上排查更省时间。再编 PROJ 主体cmake-DCMAKE_SYSTEM_NAMEAndroid\-DCMAKE_ANDROID_NDK$NDK\-DANDROID_ABIarm64-v8a\-DANDROID_PLATFORMandroid-24\-DCMAKE_BUILD_TYPERelease\-DBUILD_SHARED_LIBSON\-DENABLE_TIFFOFF\# 按需裁剪可选依赖-DENABLE_CURLOFF\-DENABLE_TOOLSOFF\# 不编命令行工具只要库-DSQLITE3_INCLUDE_DIR$DEPS/include\-DSQLITE3_LIBRARY$DEPS/${ABI}/lib/libsqlite3.so\../proj-9.8.1BUILD_SHARED_LIBSON是为了拿到.so而不是静态库方便直接丢进jniLibs。ENABLE_TOOLSOFF则是不编命令行工具——设备上没人敲proj编了只是浪费一次链接。如果还要和 JNI 混用注意 STL 选择必须和 Gradle 侧一致声明了-DANDROID_STLc_shared就得把libc_shared.so一并打进 APK否则运行期会以dlopen failed的形式失败。产物这样部署app/src/main/jniLibs/arm64-v8a/ ├── libproj.so └── libsqlite3.so app/src/main/assets/ └── proj.db # 首次运行时解压到私有目录proj.db不能直接从 assets 读PROJ 需要随机访问得先拷到文件系统funensureProjDb(context:Context):String{valoutFileFile(context.filesDir,proj/proj.db)if(!outFile.exists()){outFile.parentFile.mkdirs()context.assets.open(proj.db).use{input-outFile.outputStream().use{input.copyTo(it)}}}returnoutFile.parent// 设置 PROJ_DATA 环境变量指向此目录}五、Gradle 侧的集成预编译好的 so 直接放jniLibs要源码集成则在 Gradle 里声明android{defaultConfig{externalNativeBuild{cmake{abiFiltersarm64-v8a// 只编需要的 ABI省时间arguments-DANDROID_STLc_shared}}}externalNativeBuild{cmake{pathfile(src/main/cpp/CMakeLists.txt)version3.22.1// ← 显式指定别让它用 NDK 自带的}}}version 3.22.1这行就是坑 1 在 Gradle 侧的防御——不指定时AGP 可能选到 NDK 自带的旧 cmake。AGP 定位 cmake 时看三处local.properties的cmake.dir、DSL 里的version、SDK 里已安装的版本三者都对不上才报错。换句话说它找的是这台机器上有什么不是你需要什么——同一份工程在两个人机器上走出不同结果往往就是这一步的差异。ABI要不要说明arm64-v8a必须现代设备armeabi-v7a按设备清单老工业手持机可能还是 32 位x86_64模拟器调试用发布可不带x86基本淘汰不带每个 ABI 都要把 PROJ 重编一遍abiFilters精确裁剪能省大量 CI 时间。六、构建工具的断供与资产分级这次踩坑还有个背景工程里原本自带 cmake 与sqlite3.exe作为构建工具入库后来被当作临时文件误删连源包也一起没了。恢复时发现一个事实本机 Android SDK 里就有现成的替代品——SDK/cmake/版本/bin/cmake.exeSDK Manager 装的和你的 SDK 路径/platform-tools/sqlite3.exe。sdk/cmake/与sdk/platform-tools/是两个常被忽略的工具仓库。由此得出资产分级资产策略源码包tar.gz/zip必须入库或私服删了就没了构建工具cmake/sqlite3可不入库记录获取路径SDK 目录/官网构建产物out/、解压目录不入库脚本可重建构建脚本config.sh入库注释写清每个工具的版本要求与获取方式out/和解压的源码目录都能从入库的 tar.gz 重建真正不可再生的只有源码包本身。删工具前先看 SDK 目录能不能复用。这个分级背后有个取舍把 cmake 与 sqlite3 一起入库好处是任何机器 clone 下来就能编坏处是仓库体积涨几十 MB而且二进制绑平台——Windows 的 exe 到 macOS 上没用。折中方案是入库一份工具清单写明版本、下载地址、校验和脚本按需拉取仓库里只留怎么拿到而不是东西本身。七、排查 NDK 构建问题的通用方法这类问题的错误信息离根因很远方法论比记住具体坑更重要。先确认走没走对分支cmake...21|grep-itegra\|android\|system# 出现 Nsight Tegra 字样 → 工具链识别错了再验证工具链到底用了谁ninja-v|grepclang# 编译命令里的 clang 路径应是 $NDK/toolchains/llvm/...# 若是 MSVC 或 mingw 的 gcc → 交叉编译没生效在编本机代码最后检查产物架构llvm-readelf-hlibproj.so|grepMachine# 应该是 AArch64若是 x86-64 → 交叉编译失败还有一条最小化复现。先只编 sqlite-amalgamation单文件成功后再编 PROJ依赖链从最底层逐个验证。另外别忘了留日志。CMakeFiles/CMakeOutput.log与CMakeError.log记录了每一次编译探测的结果交叉编译有没有生效、编译器是哪条路径都在里面。报错行只是结论日志才是证据。八、坑二导出 Excel 时崩得莫名其妙需求是把测量成果导出成.xlsx选了 Apache POI。PC 上一切正常打包到 Android 就崩javax.xml.stream.FactoryConfigurationError: Provider for javax.xml.stream.XMLInputFactory cannot be found或者另一种形态java.lang.ClassNotFoundException: Didnt find class com.bea.xml.stream.XMLOutputFactory8.1 根因Android 砍掉了 JDK 的 XML APIPOI 处理 OOXML 依赖StAXjavax.xml.stream.*标准 JDK 内置实现Android Runtime 没有。根因值得说透一点Android 的 Java 类库不是完整 JDKjava.xml里留下了 DOM、SAX、XmlPull却去掉了javax.xml.stream。POI 读写 OOXML 时用 StAX 做流式解析大文件不可能整棵 DOM 装进内存所以必然踩空——这不是配置问题是类根本不存在。XMLInputFactory.newInstance()按标准顺序找实现1. 系统属性 javax.xml.stream.XMLInputFactory 2. SPIMETA-INF/services/javax.xml.stream.XMLInputFactory 3. 默认实现类名在 Android 上三步全落空。8.2 解法注入一个 StAX 实现引入 Aalto体积小、适合 Androiddependencies{implementationcom.fasterxml:aalto-xml:1.3.2}然后在一切 XML 解析发生之前设置系统属性privatefuninitXmlParserProperty(){System.setProperty(org.apache.poi.javax.xml.stream.XMLInputFactory,com.fasterxml.aalto.stax.InputFactoryImpl)System.setProperty(org.apache.poi.javax.xml.stream.XMLOutputFactory,com.fasterxml.aalto.stax.OutputFactoryImpl)System.setProperty(org.apache.poi.javax.xml.stream.XMLEventFactory,com.fasterxml.aalto.stax.EventFactoryImpl)}属性名是org.apache.poi.javax.xml.stream.*不是标准的javax.xml.stream.*——POI 在 Android 上优先读这组带前缀的属性这是它对 Android 的官方兼容方案。三个属性对应 StAX 的三个工厂XMLInputFactory管读、XMLOutputFactory管写、XMLEventFactory管事件对象。导出 xlsx 时三个都会用到只设前两个仍会在写共享字符串表那一步崩报的还是同一个看不懂的工厂错误。九、这段代码放哪注入必须发生在任何 XML 解析之前而 Android 启动流程里有很多比你想象的早的代码进程启动 → Application 构造 → attachBaseContext() ← ① → ContentProvider.onCreate() ← ② 比 Application.onCreate 还早 → Application.onCreate() ← ③ → Activity 创建② 是最大的坑很多三方 SDK启动优化框架、推送通过 ContentProvider 初始化声明在 Manifest 里由系统按序创建。如果某个 Provider 间接触发了 XML 解析注入就晚了。DefaultLifeCycle(application.HostApplication,...)classHotFixApplicationLike(...):DefaultApplicationLike(...){overridefunonBaseContextAttached(base:Context){super.onBaseContextAttached(base)MultiDex.install(base)// ① 先 MultiDexHotFixManager.initHotFix(this)// ② 再热修复initXmlParserProperty()// ③ 最后全局属性注入}}时序规律凡是影响类加载/解析行为的注入放attachBaseContext凡是业务初始化放onCreate。System.setProperty属于前者它改变的是工厂类的查找行为必须抢在所有人前面。还有一点容易忽略System.setProperty是进程级的写在attachBaseContext里只对当前进程的后续代码生效。应用一旦开了多进程定位、推送常各占一个每个进程都要各自走一遍初始化——属性和静态变量一样不跨进程。十、JDK 有、Android 没有的通用模式POI 不是孤例库缺的东西解法Apache POIjavax.xml.stream (StAX)注入 Aalto某些 SOAP 库javax.xml.soap引入 saaj 实现老版本 bouncycastle 相关javax.security 部分加密 API换 Android 适配版java.awt 相关图片处理java.awt.*换 Android Bitmap 方案javax.naming某些连接池JNDI换配置方式java.lang.instrument字节码增强不可能换方案判断方法看到以javax.开头的ClassNotFoundException或NoClassDefFoundError先查这个包在 Android 上是否存在而不是怀疑依赖没打进包。这类问题的报错还会变形先是FactoryConfigurationError接着在某处变成NullPointerException。看到这两种错误成对出现基本可以往某个 JDK 专有实现缺失的方向想。unzip-lapp.apk|grepaalto d8 --list-classes app.apk2/dev/null|grep-iaalto如果类在依赖里却不在 APK 里多半是R8 把只被反射引用的类删了-keep class com.fasterxml.aalto.stax.** { *; }这是本案例的第二个坑注入了属性、引了依赖Release 包还是崩——Aalto 的工厂类只被字符串引用R8 看不见调用关系当死代码删了。keep 的粒度也要注意只 keep 工厂类不够它们依赖的stax实现类、以及META-INF/services下的 SPI 声明同样会被裁。稳妥些按包 keep并把规则放进库的consumer-rules.pro导出去避免宿主工程开混淆后再崩一次。十一、怎么确认注入生效funassertXmlFactoryConfigured(){valimplSystem.getProperty(org.apache.poi.javax.xml.stream.XMLInputFactory)check(implcom.fasterxml.aalto.stax.InputFactoryImpl){StAX 注入失败当前值$impl}// 再实际创建一次确保类存在且没被 R8 删runCatching{Class.forName(impl).getDeclaredConstructor().newInstance()}.onFailure{throwIllegalStateException(StAX 实现类不可用被混淆/裁剪,it)}}TestfunexportMinimalXlsx(){valwbXSSFWorkbook()wb.createSheet(t).createRow(0).createCell(0).setCellValue(ok)valbytesByteArrayOutputStream().also{wb.write(it)}.toByteArray()assertTrue(bytes.size100)// 再读回来valreadXSSFWorkbook(ByteArrayInputStream(bytes))assertEquals(ok,read.getSheet(t).getRow(0).getCell(0).stringCellValue)}端到端用例要在 Debug 和 Release混淆开启包上各跑一次——只有 Release 才暴露 R8 的问题。两个验证的分工也清楚Debug 的启动自检要反射创建类成本不低适合早暴露配置错误Release 靠这条端到端用例兜底验证裁剪之后还跑不跑得通。十二、POI 在 Android 上的其他注意点POI 组件体积要不要poi核心.xls 必需poi-ooxml大依赖一堆 xmlbeans.xlsx 必需poi-ooxml-schemas / ooxml-schemas巨大用精简版poi-ooxml-liteimplementationorg.apache.poi:poi:5.2.5implementationorg.apache.poi:poi-ooxml:5.2.5// xmlbeans 传递依赖注意方法数别在主线程写 Excel——POI 写大文件是 CPU 内存双密集suspendfunexport(report:Report):FilewithContext(Dispatchers.IO){XSSFWorkbook().use{wb-fillSheets(wb,report)valfFile(exportDir,report_${timestamp()}.xlsx)f.outputStream().use{wb.write(it)}f}}withContext(Dispatchers.IO)只是把阻塞挪走并不解决内存峰值XSSFWorkbook是全量在内存里建模的十万行级别该换SXSSF而不是靠加大堆去硬扛。行数上万时换SXSSFWorkbook滑动窗口写完的行刷盘valwbSXSSFWorkbook(100)// 内存里只保留 100 行注意 SXSSF 只支持写、不支持读追加修改已有文件要 XSSF 读再 SXSSF 写。窗口大小是内存与磁盘 IO 的权衡窗口越小越省内存、临时文件刷得越勤窗口越大越接近 XSSF 的行为。几万行的报表用 100~500 就够别为了更快把窗口开到几万那等于没流式。用完记得dispose()否则临时文件会留在磁盘上。结论NDK 自带 cmake 3.18.1 在 Windows 下会把CMAKE_SYSTEM_NAMEAndroid误判为 Nsight Tegra必须 3.19构建脚本开头就要做版本守卫。Gradle 侧externalNativeBuild.cmake.version要显式指定否则 AGP 可能选到 NDK 自带的旧版本。构建期工具sqlite3.exe与目标期库libsqlite3.so是两回事前者位数无关、版本有要求后者必须按 ABI 交叉编译abiFilters精确裁剪能省大量 CI 时间。proj.db这类需要随机访问的数据文件不能从 assets 直读首次运行要拷到私有目录并把PROJ_DATA指过去。构建资产分级源码包必须入库工具记录获取路径产物可重建删工具前先看 SDK 目录能不能复用。NDK 问题的排查顺序确认分支 → 验证工具链 → 检查产物架构 → 最小化复现。Android 没有 StAXPOI 因此崩解法是引入 Aalto 并设置org.apache.poi.javax.xml.stream.*属性注意前缀。这类属性注入必须放attachBaseContext因为 ContentProvider 的初始化比Application.onCreate还早行为口径是影响类加载/解析的放前面业务初始化的放后面。javax.*开头的ClassNotFoundException先查 Android 是否支持该包别急着怀疑打包。只被字符串引用的实现类会被 R8 删掉必须 keep验证要在Release 包上做Debug 包看不出来。两个坑一个在编译期、一个在运行期但本质相同默认值不写出来你就不知道自己用的是哪一个。如果你也被 cmake 的静默分支或 R8 的聪明坑过欢迎在评论区说说。关键词标签#Android#NDK#CMake#交叉编译#ApachePOI#R8#构建工具链
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/27 1:54:30
MediaPipe手部关键点检测移植到车载芯片(RTOS/SoC)实录:四个移植坑与解法
2026/10/27 1:54:30
aiAgent学习笔记
2026/10/27 1:54:30
如何在 Windows 11 或 10 上制作 ChromeOS Flex 启动 U 盘
2026/10/27 4:34:46
营销技能实战指南:从零构建可落地的私域运营能力
2026/10/27 4:34:46
marketingskills:重建你和钱的关系
2026/10/27 4:34:46
Ponytail技术术语解析与行业应用场景探析
2026/10/27 4:34:46
PyTorch实战:MNIST手写数字识别全流程解析
2026/10/27 4:34:46
AI-native制造业ERP/MES架构设计与落地实践
2026/10/27 4:29:45
VOC-IRLeak数据集:工业气体泄漏红外检测技术解析
2026/10/27 0:04:20
WorkBuddy_新建任务与工作模式19_“模式选错”的典型代价
2026/10/27 0:04:20
WorkBuddy_新建任务与工作模式20_新手推荐路径
2026/10/27 0:04:20
ZerolanLiveRobot开发者指南:如何扩展一个新服务并注册自定义事件(附完整实战示例)
2026/10/26 11:29:50
全国计算机二级C语言真题PDF高效刷题指南
2026/10/26 0:03:29
requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南
2026/10/26 11:29:49
递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现
2026/10/25 22:15:16
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/26 11:29:48
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/25 12:42:56
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)