GeoLibre 深度解析:基于 tauri-plugin-geolocation 2.3.2 的 Android GNSS 卫星计数 vendor 补丁
【免费下载链接】GeoLibreA lightweight, cloud-native GIS platform for visualizing, exploring, and analyzing geospatial data. It runs in the web browser, on the desktop, on mobile, and inside Jupyter notebooks.项目地址: https://gitcode.com/GitHub_Trending/ge/GeoLibre
导读
本文围绕 GEOLIBRE_PATCH.md 展开,剖析 GeoLibre 桌面 GIS 的移动端(Android)定位能力如何通过 vendoring(供应商源码内嵌)方式修补上游tauri-plugin-geolocation2.3.2,从而在定位结果中暴露 GNSS 卫星使用数量。读完本文,你将掌握:该补丁的动机与三个改动文件的职责、GnssStatusCompat卫星计数的采集与防陈旧配对机制、一次性定位与持续追踪两条调用链的差异,以及升级上游插件时必须执行的对比、重打补丁与重建 APK 流程。
适用前提:本文讨论的是 GeoLibre 桌面应用中随 Tauri 打包的 Android/iOS 原生定位路径。桌面端与浏览器端定位走 WebView 的
navigator.geolocation,不受本补丁影响(详见下文「前端如何消费补丁结果」)。
一、为什么要 vendoring:上游 Android bridge 缺失卫星计数
GeoLibre 的 Field Collection(外业采集)与 GPS Tracking(GPS 追踪)工具需要向用户展示当前定位所利用的 GNSS 卫星数量。但 crates.io 上的tauri-plugin-geolocation2.3.2 存在一个明确的短板:
upstream Android bridge does not expose the number of GNSS satellites used in a location fix
即上游 Android 桥接层在返回定位结果时,不携带"参与定位解算的卫星数量"这一元数据。因此 GeoLibre 选择把整个插件源码 vendoring 进仓库,在其基础上施加一个刻意保持最小化的补丁 delta(见 Cargo.toml 中的注释:This is a patched 2.3.2 release; see vendor/tauri-plugin-geolocation/GEOLIBRE_PATCH.md before upgrading.)。
1.1 为什么不能直接依赖 crates.io 版本
从依赖声明可以看出,GeoLibre 在 apps/geolibre-desktop/src-tauri/Cargo.toml 中使用了path 依赖:
tauri-plugin-geolocation = { path = "vendor/tauri-plugin-geolocation" }这一选择的直接后果是:Cargo 将本目录视为本地 crate,Dependabot 不会自动提出上游版本升级的 PR(文档原话:Because Cargo sees a path dependency, Dependabot will not propose upstream version updates automatically)。这意味着升级必须由维护者手动执行,且每次升级都要重新对比、重打补丁、验证并重建 Android APK——这正是 GEOLIBRE_PATCH.md 存在的核心价值:它是一份面向未来升级的维护契约。
1.2 为什么移动端必须走原生插件而非 WebView
GeoLibre 前端封装层 geolocation.ts 对此有详细说明:在打包后的 Android/iOS 应用中,WebView 的navigator.geolocation路径是坏的——Android 侧 WebView 会在运行时请求ACCESS_FINE/COARSE_LOCATION,但这些权限并未声明在(自动生成且被 gitignore 的)AndroidManifest 中,于是 Android 系统直接自动拒绝、从不弹出系统对话框,用户"永远不会被询问 GPS 权限",且失败请求路径会直接崩溃应用(源码注释记载:reported on a Galaxy S25 Ultra)。
因此 GeoLibre 移动端改用官方@tauri-apps/plugin-geolocation:其 Android/iOS 原生库自带位置<uses-permission>清单条目、能驱动系统权限对话框,并以原生方式读取位置而非脆弱的 WebView 桥。同时该插件被懒加载(import()动态引入),不会进入 web bundle,桌面端完全不受影响。
二、补丁总览:刻意保持最小的三个文件 delta
GEOLIBRE_PATCH.md 明确声明补丁 delta 被有意限制在以下三个文件:
| 文件 | 补丁职责 |
|---|---|
android/src/main/java/Geolocation.kt | 观察GnssStatusCompat,为观测打时间戳,防止陈旧元数据与融合定位配对;在卫星数可用时通知一次性调用者 |
android/src/main/java/GeolocationPlugin.kt | 在原生坐标中纳入satellites;让 GNSS 元数据与超时短暂竞态;无连续 watch 时在完成后注销一次性 GNSS 监听;抑制等于/低于 Android 上报速度不确定度的速度值,缺失的速度/方位不再序列化为 Android 的零值占位符 |
src/models.rs | 通过 Rust 反序列化保留可选的卫星数字段 |
下文分别深入这三个文件的实现细节。
三、Geolocation.kt:卫星计数的采集、时间戳与防陈旧配对
文件路径:apps/geolibre-desktop/src-tauri/vendor/tauri-plugin-geolocation/android/src/main/java/Geolocation.kt
3.1 核心状态与陈旧阈值
补丁引入了一个私有伴生对象常量,定义 GNSS 元数据的"新鲜度窗口":
private companion object { const val SATELLITE_STALENESS_NANOS = 3_000_000_000L // 3 秒 }同时维护两个@Volatile字段:satellitesUsed: Int?(参与解算的卫星数)与satellitesUpdatedAtNanos: Long?(观测时间戳),并用satelliteListeners集合保存等待一次性通知的回调。
3.2startGnssStatusUpdates():注册 GNSS 状态回调
该方法通过LocationManagerCompat.registerGnssStatusCallback注册一个GnssStatusCompat.Callback:
- 在
onSatelliteStatusChanged中遍历status.satelliteCount,统计status.usedInFix(index)为 true 的卫星数,写入satellitesUsed并记录SystemClock.elapsedRealtimeNanos()时间戳; - 随后清空并逐个调用
satelliteListeners,实现一次性监听者的投递; onStopped时清空两个字段;- 注册可能抛
SecurityException(缺权限),此时同样清空状态。
3.3satellitesUsedFor():核心防陈旧配对逻辑
这是整个补丁的关键技术决策。融合定位(FusedLocationProvider)的结果可能来自 Wi-Fi/蜂窝定位,而 GNSS 状态回调是全局的——如果不去校验时间,室内就会把"上一次室外遗留的卫星计数"错误地附加到室内定位上。因此:
fun satellitesUsedFor(location: Location): Int? { val updatedAt = satellitesUpdatedAtNanos ?: return null val ageNanos = kotlin.math.abs(location.elapsedRealtimeNanos - updatedAt) return satellitesUsed?.takeIf { ageNanos <= SATELLITE_STALENESS_NANOS } }只有"卫星观测时刻"与"定位时刻"的差值落在 3 秒窗口内,才认为该卫星计数与本次定位相关;否则返回 null(表示不可用)。文档中的"timestamp the observation so stale metadata is not paired with a fused fix"正是这一实现。
3.4canWaitForSatellites()与onSatellitesUsedAvailable()
canWaitForSatellites(location):判断某个定位结果在"未来"是否仍可能被匹配到的 GNSS 回调命中(即定位本身还不算太旧),供一次性调用流程决定是否值得等待;onSatellitesUsedAvailable(callback):若当前已有新鲜计数则立即回调并返回空注销函数;否则把回调加入satelliteListeners,返回一个可移除该回调的注销闭包——为 Plugin 层的超时竞态提供基础。
此外,stopGnssStatusUpdates()负责注销回调并清空全部状态,clearLocationUpdates()会连带停止 GNSS 状态更新,避免后台泄漏。
四、GeolocationPlugin.kt:一次性定位竞态与坐标净化
文件路径:apps/geolibre-desktop/src-tauri/vendor/tauri-plugin-geolocation/android/src/main/java/GeolocationPlugin.kt
4.1convertLocation():卫星数入坐标 + 速度/方位净化
原生Location转 JSObject 时新增三处修改:
- 卫星数:
implementation.satellitesUsedFor(location)?.let { coords.put("satellites", it) }——仅在防陈旧校验通过时写入coords.satellites; - 速度抑制:Android 8+(API 26+)且
location.hasSpeedAccuracy()时,若location.speed <= location.speedAccuracyMetersPerSecond(速度低于系统上报的不确定度),则强制上报为0f,避免把噪声当作真实运动; - 缺失值不再用零占位:
location.hasSpeed()/location.hasBearing()为 false 时,不再序列化 Android 的零值占位符,speed、heading字段直接缺失——这与 models.rs 中二者为Option<f64>的类型设计相呼应。
4.2resolveCurrentPosition():GNSS 元数据与 1.5 秒超时的竞态
注释解释了设计意图:一次性的融合定位可能在 Android 首个 GNSS 状态回调之前就到达。为了让 Field Collection 拿到与持续追踪一致的卫星数,插件给出一个短暂的等待窗口:
if (implementation.satellitesUsedFor(location) != null || !implementation.canWaitForSatellites(location) ) { resolveOneShot(invoke, location) // 已有计数,或定位已太旧无法等待 → 立即返回 return } // 否则注册一次性监听 + 1.5 秒超时兜底 if (!resolved) mainHandler.postDelayed(timeout, 1500)- 卫星回调先到:
mainHandler.removeCallbacks(timeout),立刻返回(携带卫星数); - 超时先到:注销监听器,按原样返回定位(没有卫星数)——正如注释所说:
The timeout preserves the normal result on coarse-only or non-GNSS devices.(在仅有粗略定位或无 GNSS 的设备上保持正常结果)。
4.3 一次性调用的资源回收:pendingOneShots计数
getCurrentPosition会先尝试getLastLocation(maximumAge)缓存命中,失败才走sendLocation。每个一次性请求进入时pendingOneShots += 1,每次finishOneShot()递减,并且:
if (pendingOneShots == 0 && watchers.isEmpty()) { implementation.stopGnssStatusUpdates() }只有当没有进行中的一次性请求且没有活跃 watcher时,才停止 GNSS 监听——对应文档中的"unregister one-shot GNSS monitoring after completion when no continuous watch is active"。持续watchPosition场景则始终由requestLocationUpdates+startGnssStatusUpdates()维持监听,clearWatch清空 watchers 后同样会停止。
4.4 权限声明与生命周期
插件通过@TauriPlugin(permissions = ...)声明ACCESS_FINE_LOCATION与ACCESS_COARSE_LOCATION(以及 coarse 单独别名),并自动写入 AndroidManifest;onPause时清除全部定位更新以避免后台定位调用,onResume时重建 watchers。checkPermissions/requestPermissions在系统定位服务关闭时直接 reject("Location services are disabled.")。
五、models.rs:Rust 侧保留可选卫星数
文件路径:apps/geolibre-desktop/src-tauri/vendor/tauri-plugin-geolocation/src/models.rs
Rust 侧的Coordinates结构体在altitude、speed、heading之后新增了第四个可选字段:
/// Satellites used for the fix, when the platform reports it (Android only). pub satellites: Option<u32>,- 类型为
Option<u32>(而非u64),保持specta类型推导与 JS 侧number兼容; - 配合
#[serde(rename_all = "camelCase")],反序列化为coords.satellites; - 注释明确标注"Android only"——iOS 实现(GeolocationPlugin.swift)不提供该字段,
Option语义保证了缺失时前端拿到的是null而非假数据。
同时PositionOptions中enable_high_accuracy、timeout、maximum_age的文档注释也保留了 Android/iOS 的差异说明(如 Android 的getCurrentPosition会忽略 timeout,iOS 忽略 timeout 与 maximumAge),这对理解前端选项映射至关重要。
六、前端如何消费补丁结果:Field Collection 与 GPS Tracking
补丁的产物——coords.satellites——最终通过 GeoLibre 前端封装层进入用户界面。
6.1 双路径选择与原生选项映射
apps/geolibre-desktop/src/lib/geolocation.ts 中:
nativeGeolocationAvailable()判定isTauri() && isMobile(),仅打包后的移动应用走原生插件;手机浏览器虽然满足isMobile()但不是 Tauri,仍正确回退到navigator.geolocation;toPluginOptions()补齐浏览器可选参数与插件必填参数的差异(浏览器Infinity超时不可用,默认取 30000ms);- 关键的
nativeWatchOptions():由于 Android 桥把timeout直接喂给LocationRequest.Builder作为更新间隔(min interval / max batching delay),若沿用一次性调用的默认值,融合提供器会每 30 秒才给一个 fix,导致"地图保持居中"看起来失灵。因此持续追踪使用独立参数nativeIntervalMs(默认NATIVE_WATCH_INTERVAL_MS = 1000,即 GNSS 接收机产生 fix 的频率),使实时追踪平滑移动;该参数在 iOS 被忽略(由CLLocationManager流式输出)。
6.2 卫星数展示链路
- FieldCollectionDialog.tsx 调用
getCurrentPosition({ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }),并在结果面板渲染gps.satellitesValue(卫星数或gps.notAvailable); - GpsTrackingDialog.tsx 的 watch 回调把
fix.satellites存入追踪状态(satellites: fix.satellites); - StatusBar.tsx 以
gps.satellitesShortValue("卫星: N")形式持续显示; - 国际化文案(
satellites、satellitesShort、satellitesValue、satellitesShortValue)覆盖 ar/de/en/es/fa/zh 等全部语言包,说明该指标是产品化的正式 UI 元素,而非调试字段。
整个链路可以概括为:原生GnssStatusCompat计数 → 防陈旧配对 →coords.satellites→ 前端懒加载插件 → Field Collection / GPS Tracking / 状态栏展示。
七、升级维护指南:对比、重打补丁与重建 APK
GEOLIBRE_PATCH.md 给出了明确的升级流程,这是文档的实操核心,务必完整遵守:
When upgrading, compare the new upstream release against this directory, reapply and test the files above, update the version noted here, and rebuild an Android APK.
翻译为可执行步骤:
- 对比:把新的上游发布版本与当前 vendor 目录逐文件 diff,重点看本文提到的三个文件在上游是否已有变化(尤其是
GnssStatusCompatAPI 或Location相关 API 的变更); - 重打补丁:将上文描述的 delta 重新应用到新版本对应文件上;
- 测试:在 Android 真机上验证一次性定位与持续追踪两条路径的卫星数、超时回退(无 GNSS 设备)、速度抑制行为;
- 更新版本记录:更新本文件首行的版本号(当前为 2.3.2),并同步 Cargo.toml.orig 中的版本信息;
- 重建 APK:重新构建 Android 安装包后发布。
同时要注意两点运维约束:
- 由于
Cargo.toml使用 path 依赖指向 vendor 目录,Dependabot 不会自动提出上游升级,升级必须人工触发(这也是维护者依赖本文件提醒自己的原因); - 若上游修复了卫星计数问题并合入官方版本,本补丁的 delta 可以收缩甚至完全移除——届时可将依赖改回 crates.io 版本以恢复自动更新。
八、补丁设计的可借鉴要点
从源码层面总结,这个 vendor 补丁之所以可靠,在于几个工程原则:
- 最小化 delta:只改三个文件、每处改动职责单一,方便升级时对比重打;
- 时间戳驱动的数据关联:用
elapsedRealtimeNanos差值(3 秒窗口)解决"全局 GNSS 回调 vs 局部定位结果"的配对问题,而不是盲目相信"最近一次的卫星数"; - 竞态兜底:一次性定位等待 GNSS 回调最多 1.5 秒,超时即返回无卫星数的正常结果,保证在无 GNSS/粗略定位设备上不劣化体验;
- 资源生命周期管理:
pendingOneShots计数 + watchers 判断,确保无消费者时立即注销原生监听,避免电量泄漏; - 数据净化:低于速度不确定度的速度置 0、缺失的速度/方位不序列化零占位符,保证下游拿到的是语义正确的数据;
- 文档即维护契约:GEOLIBRE_PATCH.md 不仅记录"改了什么",更规定了"升级时必须做什么",把知识沉淀在代码旁,降低后续维护者(或 AI Agent)接手成本。
这些模式同样适用于其他"上游不满足需求、又必须保持可升级性"的依赖修补场景。
结语
GeoLibre 通过 vendoringtauri-plugin-geolocation2.3.2 并施加三个文件的最小补丁,为 Field Collection 与 GPS Tracking 提供了 Android 原生 GNSS 卫星计数能力,同时用时间戳防陈旧配对、1.5 秒竞态超时和严格的生命周期管理保证了数据的正确性与资源的节省。对于任何需要在 Tauri 移动端获得超出上游插件能力的定位元数据的项目,本仓库的 vendor 目录与 GEOLIBRE_PATCH.md 都是一份可直接参照的完整实践样例。
【免费下载链接】GeoLibreA lightweight, cloud-native GIS platform for visualizing, exploring, and analyzing geospatial data. It runs in the web browser, on the desktop, on mobile, and inside Jupyter notebooks.项目地址: https://gitcode.com/GitHub_Trending/ge/GeoLibre
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考