用 Kotlin 从零造一个纯离线徒步导航 APP:定位精度与功耗的工程实践

2026/10/15 Android

起因

去年走武功山穿越,在铁蹄峰的岔路口,两步路弹了个会员广告,而山脊上手机早就没有信号了。回来后我决定自己写一个徒步导航 APP,目标很明确:

  • 完全离线:离线地图、离线海拔,山里流量用不上
  • 无广告、无会员、不强制登录
  • 息屏放口袋导航不断(很多商业 APP 后台定位会被杀)

技术选型上,目标机是华为 Mate 40 Pro(无 GMS),所以 FusedLocationProvider 直接排除,老老实实用 LocationManager。地图渲染选了 Mapsforge 0.25.0 + Elevate 主题,DEM 海拔用 Copernicus GLO-30(1°×1° 瓦片转成自定义 bin 内置 assets)。

一、分档 GPS 采样:不是所有场景都配 3 秒档

定位功耗的大头在 GPS 芯片,而”看地图”和”记轨迹”对点位节奏的需求完全不同。我用两个档位解决:

enum class Mode { IDLE, TRACKING }

fun start(requested: Mode = Mode.IDLE) {
    val (intervalMs, minDistM) =
        if (requested == Mode.TRACKING) 3000L to 3f else 10_000L to 10f
    lm.requestLocationUpdates(
        LocationManager.GPS_PROVIDER, intervalMs, minDistM,
        locationListener, Looper.getMainLooper()
    )
}

记录轨迹时切 TRACKING(3s/3m),只看地图用 IDLE(10s/10m)。再叠加一层”静止休眠”:连续 3 分钟位移 < 15 米就 pause() 掉 GPS,注册 TYPE_SIGNIFICANT_MOTION 触发传感器(芯片级检测,零功耗),一走动自动唤醒。

二、飞点剔除:location.time 的墙钟坑

多径反射会产生”瞬间横跳”的飞点,经典做法是算隐含速度:距离除以时间差。我最初用 location.time 当时间戳,埋了个大坑:

// 坑:location.time 是墙钟,NTP 校时会拨表
val dtSec = (location.time - lastTimeMs) / 1000.0
if (dtSec <= 15.0 && distM / dtSec > 15.0) accept = false

手机联网校时把时间拨几秒,dt 就错了——拨小了,正常移动被误判飞点;拨大了,真飞点被放行。修法是换成系统单调钟:

val now = SystemClock.elapsedRealtimeNanos()   // 永不开倒车
val dtSec = (now - lastAcceptedElapsedNanos) / 1_000_000_000.0

另一个结构性问题:隐含速度的分母是”距上一接受点的时间差”。长时间断流(息屏、进隧道)后重连的第一个点,dt 很大但距离也大,容易被误杀。实测坐车全程丢点、恢复后连出大直线。修复:隐含速度门限只对 dt ≤ 15s 的正常采样节奏生效。

三、轨迹导航:窗口匹配,但窗口要按米算

导航核心是点投影路径匹配:每个 GPS 点只在上次匹配位置附近的窗口里找最近线段,而不是全轨迹暴力搜索。最初窗口是固定 ±80 个点,很快发现不对:

  • 点稀的轨迹(100 米/点)→ 80 点横跨 8 公里,会错误吸附到轨迹另一头的同名段
  • 点密的轨迹(3 米/点)→ 80 点才 240 米,走着走着就跟丢了

改成按米划窗,用平均段长换算:

val avgSegM = cumDist[n - 1] / (n - 1).coerceAtLeast(1)
searchWindow = (SEARCH_WINDOW_METERS / avgSegM).toInt().coerceIn(20, 300)

报警阈值同理做了自适应:峡谷、密林里 GPS 精度掉到 40 米时,固定 30 米阈值会把”人在轨迹上”误报成偏离。阈值 = max(30m, GPS精度 × 2),解除阈值取报警值的 2/3 形成迟滞区间,防止在阈值边上反复横跳。

四、用 dumpsys 验证,而不是靠感觉

优化不能凭感觉。分档 + 休眠上线后真机取证:

adb shell dumpsys location | grep outdoornav

系统侧记录请求为 min/max interval = 3s/10s,证明两档都被系统真实接收;更关键的是硬件激活时长——请求挂了 18 小时,GPS 芯片实际只工作了 46 分钟,占空比约 4.2%,613 个定位点平均 4.5 秒/个。静止休眠用的显著运动传感器全程只触发 3 次、累计 16 秒。退后台后 gps provider: ProviderRequest[OFF],没有泄漏的定位请求。

五、结果

最终 APK 87MB(内置江西省离线地图 + 当前目的地 DEM)。实战武功山穿越:蓝线导航、偏离震动报警、地形级海拔剖面全部离线可用,下山电量还剩六成。

几个踩坑总结:Mapsforge 瓦片层要 layers.add(0) 插底层,add 会盖住轨迹 overlay;Compose 里 Modifier.weight 只能在 Row/ColumnScope 内调;华为机要在启动管理里关掉自动管理,否则前台服务也救不了后台定位。

参考

Search

    Post Directory