多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Android 上跑通 PROJ 9 坐标转换:从交叉编译到 EPSG 解析的 8 个坑

Android 上跑通 PROJ 9 坐标转换:从交叉编译到 EPSG 解析的 8 个坑 PROJ 9.8.1 PROJ-JNI 交叉编译成 AAR 的全链路复盘构建、打包、运行时崩溃、EPSG 数据库每一环都藏着坑。前言在 Android 上做专业坐标转换可选的路子其实很窄。要在 EPSG:4326 / 4490 / 4547、Web Mercator 之间互转还得能吃下 CGCS2000 这类国内坐标系基本绕不开 PROJ 这套 C 库。麻烦的地方在于它看起来能跑交叉编译能过、so 也能正常打进 AAR、System.loadLibrary三连同样不报错。可createFromUserInput(EPSG:4326)一调就抛FactoryException: std::exception日志里干干净净连一行线索都没有 —— 接下来就是在黑盒里猜。这篇文章基于我把 PROJ 9.8.1 PROJ-JNI 用 NDK 25 交叉编译并打包成 AAR 的真实过程按「构建 → 打包 → 崩溃 → EPSG 解析」的顺序把 8 个坑逐个摊开。面向需要在 Androidarm64-v8a上做专业坐标转换的 Android / GIS 开发者。一、目标与产物最终产物是一个自包含的 AARproj-android-arm64-v8a.aar约 15MB仅 arm64-v8a内含三个 native solibsqlite3.so、libproj.so、libproj-binding.soassets/proj/PROJ 数据16 个文件含 10MB 的proj.dbclasses.jar完整org.osgeo.proj.*Java API已 Android 适配消费方只要implementation(files(libs/proj-android-arm64-v8a.aar)) 手动loadLibrary三连即可做 EPSG 坐标转换。先记住这一节目标是一个 AAR 拿走就能用。后面 8 个坑基本都是从这句话派生出来的 —— so、数据、Java API 必须自包含任何一处偷偷依赖宿主机环境都会在真机上炸。二、构建链路交叉编译的 5 个坑2.1 坑 1LTO 跨库链接失败NDK 下给libproj.so加-flto产出 bitcode再被 JNI 链接时lld报lto.tmp错误。修复关闭ENABLE_LTO改用-Os -ffunction-sections -fdata-sectionsstrip体积从 48MB 降到 4.8MB。2.2 坑 2C typeinfo/vtable undefinedproj-src/CMakeLists.txt里set(CMAKE_CXX_VISIBILITY_PRESET hidden)导致 JNI 链接时 typeinfo 符号未定义。修复改成default配合 strip 不影响最终体积。2.3 坑 3JNI 链接 PROJLIB 被当成目录交叉编译下find_library(PROJLIB NAMES proj PATHS ${PROJLIB})会把传入的目录路径当库。修复必须传完整路径-DPROJLIB.../lib/libproj.so。2.4 坑 4libproj-binding.so 输出位置诡异PROJ-JNI 把 so 输出到out/abi/classes/org/osgeo/proj/Java 包名子目录而不是lib/。打包脚本的find只查 build-jni 顶层会漏掉。修复需同时find ${abi_out}/classes。2.5 坑 5DT_NEEDED 把宿主机路径硬编码进 so最阴险build_proj.sh用-DSQLite3_LIBRARYWindows 绝对路径链接时lld把这个完整宿主机路径原样写进了libproj.so的DT_NEEDED。设备运行时dlopen会按字面量去找宿主机上的路径形如E:/xxx/proj/lib/libsqlite3.so→ 必然失败。修复写scripts/patch_dtneeded.py纯 Python 解析 ELF64 LE定位.dynstr节把绝对路径原地覆盖为libsqlite3.so这个 SONAME在打包前自动调用。三、打包 AARWindows 下的 4 个细节javac classpath 用分号;分隔不是冒号且加-encoding UTF-8源码含…/“”。用${JAVA_HOME}/bin/jar而非裸jarjar 不在 PATH。classes.jar 必须编proj-jni-src/src/main/java全部 .java--release 8依赖 gradle 缓存的geoapi-3.0.2.jarunit-api-2.1.3.jar跳过 java9 多版本类。改NativeResource.java必须同时改android-patches/版——build_aar.sh编译前会用android-patches版覆盖proj-jni-src同名文件只改一处会被覆盖。第 4 条尤其值得留意它属于改了没生效的典型调试时会让人反复怀疑编译器。四、运行时崩溃链NoSuchMethodError → VerifyError → FactoryException4.1 坑 6Android CheckJNI 不兼容java.util.loggingPROJ-JNI 的NativeResource.logger()返回Logger原生log()在 Android 上通过CallObjectMethod(Logger.log(...))输出日志。ART 的 CheckJNI 对Logger.log做签名校验时直接 abort。修复Java 侧logger()返回Object让原生GetStaticMethodID(()Ljava/util/logging/Logger;)解析失败 → 原生log()入口直接return并按Class.forName(android.os.Build)判 Android不能靠java.vm.nameART 上是 “Dalvik”/“ART”不含 “android”。4.2 坑 7C 侧 JNI 签名必须和 Java 一致只改 Java 侧不够。bindings.cpp的GetStaticMethodID(caller, logger, ()Ljava/util/logging/Logger;)在 ART 下签名不匹配仍抛NoSuchMethodError。修复必须同时把 C 签名改成()Ljava/lang/Object;并重编libproj-binding.so加if (env-ExceptionCheck()) env-ExceptionClear();防 FindClass 失败挂起异常。4.3 坑 8重编的 so 没进 AARbuild_all.shset -e陷阱build_proj.sh的cp -f在 Git Bash 下报are the same fileexit 非 0配合set -euo pipefail提前中断新版 so 停在了out/arm64-v8a/classes/org/osgeo/proj/而 AAR 打包用的是out/arm64-v8a/lib/里的旧 so。手动修正法cp-fout/arm64-v8a/classes/org/osgeo/proj/libproj-binding.so\out/arm64-v8a/lib/libproj-binding.so llvm-strip --strip-unneeded out/arm64-v8a/lib/libproj-binding.so ./build_aar.sh校验grep -ac Ljava/util/logging/Logger; libproj-binding.so应为 0Ljava/lang/Object;应为 1。五、EPSG 解析失败FactoryException: std::exception修完上面崩溃后createFromUserInput(EPSG:4326)抛FactoryException: std::exception。这是最磨人的一环根因有两层。5.1 根因 APROJ-JNI 的log()抛裸std::exceptionbindings.cpp的log()在 Android 上因logger()返回 null 抛 NPE原实现throw std::exception()被createFromUserInput误捕获成FactoryException: std::exception。关键经验FactoryException: std::exception裸std::exception不一定来自 PROJ 本身很可能是 PROJ-JNI 绑定层的 bug。遇到先grep bindings.cpp throw std::exception()。修复log()失败时ExceptionClear() 静默 return绝不 throw。5.2 根因 Baux 数据库路径被当数据库文件 ATTACH修复 log() 后真错误才透出DatabaseContext::create FAILED: SQLite error [ code 14 ] ... on ATTACH DATABASE。根因是把PROJ_DATA目录塞进了auxiliaryDatabasePathsPROJ 把它当数据库文件ATTACH 打开 → 失败目录不是文件SQLITE_CANTOPEN14。最终可靠方案三参DatabaseContext::create// get_database_context 内std::string db_filestd::string(proj_data)/proj.db;proj_context_set_search_paths(pctx,1,proj_data);// 仅设数据目录网格文件等proj_context_set_database_path(pctx,db_file.c_str(),nullptr,nullptr);// 双保险DatabaseContext::create(db_file,{},pctx);// proj.db 完整路径作第一参数要点auxiliaryDatabasePaths只放额外数据库文件路径绝不能放数据目录proj.db完整路径作为第一个参数databasePathproj_context_set_search_paths只用于网格/数据搜索目录与databasePath分离六、应用层坐标轴序与加载顺序编译和绑定层都通了最后还有两个纯应用层的问题同样能让人卡半天。PROJ 9 默认 EPSG:4326 / 4490 为lat, lon轴序。测试 APP 传[lon, lat]会报Invalid coordinate。改为doubleArrayOf(39.907, 116.391)先纬度后经度。三个 so 的加载顺序sqlite3 → proj → proj-binding跳过不存在的 tiff。org.proj.android.Proj.load()会崩它 load “tiff”需自带 loader 只 load 上述三连。本地org/osgeo/proj源码不要和 AARclasses.jar同名类共存否则checkDebugDuplicateClasses失败。七、一键诊断命令清单下面这段建议直接存成脚本改完 so 就过一遍 —— 前面几个坑都能在这里第一时间暴露。# so 内是否还残留桌面版 logging 签名grep-acLjava/util/logging/Logger;libproj-binding.so# 应0grep-acLjava/lang/Object;libproj-binding.so# 应1grep-acPROJ_DATAlibproj-binding.so# 应1含数据路径读取# DT_NEEDED 是否被正确改写为 SONAMEreadelf-dlibproj.so|grepNEEDED# 应含 libsqlite3.so非绝对路径# NativeResource 确认javap-classpathclasses.jar org.osgeo.proj.NativeResource|greplogger# 应显示static java.lang.Object logger()结论在 Android 上跑通 PROJ 的关键不在编译开关而在三个边界构建边界DT_NEEDED 路径硬编码、so 输出位置、LTO / visibility 跨库链接 —— 这三个问题都只在链接与打包阶段暴露编译能过不代表能跑。JNI 边界Android CheckJNI 不支持java.util.loggingC 侧签名必须与 Java 严格一致且log()等辅助函数绝不能抛裸异常。数据边界PROJ C 库只认环境变量或 C API不读 JavaSystem.setPropertyproj.db必须作为DatabaseContext::create的第一参数数据目录不能塞进 aux paths。把这三条边界处理对EPSG 解析与坐标转换就能稳定打通。再补三条下次直接照做的检查项加载顺序三个 so 固定为sqlite3 → proj → proj-binding跳过不存在的 tiff。改完 so 先验签名grep -ac Ljava/util/logging/Logger;应为 0、Ljava/lang/Object;应为 1。见到裸std::exception先怀疑绑定层grep bindings.cpp throw std::exception()别一头扎进 PROJ 源码。如果你也把 C 库往 Android 上搬过或者正在为 EPSG 解析、坐标轴序这类问题头疼欢迎在评论区聊聊你踩得最狠的那一个坑。关键词标签#Android#PROJ#坐标转换#交叉编译#NDK#JNI#GIS#EPSG
返回列表