室内定位为什么这么难搞?融合定位原理 + 完整接入实战

0 阅读5分钟

你有没有遇到过这种情况——

设备在室外运行好好的,GPS坐标稳、精度两三米。一进仓库,getLastKnownLocation() 返回null,或者给你一个五分钟前的老坐标。调度系统直接失去对设备的感知,运营那边开始催你。

这不是代码写错了,是物理限制。GPS是L波段信号,穿透力差,混凝土和金属屋顶是天然屏蔽。室内本来就不是GPS的主场。

这篇想聊三件事:融合定位在技术层面怎么工作的、工程上有哪些坑、怎么用API快速接进自己的系统


融合定位靠什么信号?

不是魔法,就三类射频信号。

基站

每个移动基站有唯一标识:MCC + MNC + LAC/TAC + Cell ID。终端在任意时刻都在某个基站覆盖范围内,查数据库匹配坐标,得到粗略位置。

精度100米到几公里,取决于基站密度。城市中心密,能做到100米级;农村基站稀,误差可能好几公里。

多基站时可以根据RSSI信号强度做三角加权,精度能拉到50~200米。

WiFi

这是室内精度最高的方案。WiFi AP的MAC地址全局唯一,商业数据库记录了海量AP的地理位置(通过众包积累)。扫描周边AP列表,用MAC地址查位置,再按RSSI加权,精度通常15~50米。

大型商场、写字楼、地铁站的AP密度高,精度可以做到15米以内。你在商场里没开GPS手机还知道你在几楼,靠的就是这个。

数据融合层

实际工程里不会只用一种信号——WiFi AP可能关机、基站受地形影响。成熟的融合定位服务同时接多种信号输入,在服务端做加权融合,输出一个置信度更高的坐标。


工程上真实会踩的坑

Android WiFi扫描越来越受限

Android 9(API 28)之后,WifiManager.startScan() 被节流:前台App最多每2分钟扫4次,后台更少。Android 10开始部分厂商ROM直接关掉了后台WiFi扫描。

如果你做的是消费端App,这一块要提前确认。老的WifiScan方案在新Android上基本不可靠了。IoT设备跑定制系统或直接读硬件的影响相对小,但也要确认。

基站参数采集别漏字段

Android上用TelephonyManager.getAllCellInfo()拿基站数据,不同网络制式(GSM、LTE、NR)字段名不一样,要做类型判断:

val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
// 需要 READ_PHONE_STATE 权限

tm.allCellInfo?.forEach { info ->
    when (info) {
        is CellInfoLte -> {
            val id = info.cellIdentity
            val sig = info.cellSignalStrength
            // id.ci        => Cell ID
            // id.tac       => TAC(LTE区域码)
            // id.mccString => MCC
            // id.mncString => MNC
            // sig.dbm      => 信号强度 dBm
            // sig.asuLevel => ASU
        }
        is CellInfoGsm -> {
            // id.cid => Cell ID
            // id.lac => LAC(GSM区域码)
        }
        is CellInfoNr -> { /* 5G,字段又不一样 */ }
    }
}

坐标系一定要确认清楚

这个坑很隐蔽:融合定位返回的坐标,不同服务商用的坐标系不同。如果API给你WGS84,你直接拿去高德地图展示,会有50~300米的系统性偏移——不是精度差,是坐标系没对齐。

接入前先确认返回的是什么坐标系,以及你的地图渲染层用的是什么。


接一个真实API:完整流程

说完原理,看看实际接入。用迈云位置服务 LTS 的融合定位接口演示,POST /api/service/location,Bearer Token认证,支持返回GCJ02。

请求结构

传WiFi列表(精度更高,优先用):

javascript

fetch('https://lts.maiyun.net/api/service/location', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: 3,                        // 3 = GCJ02
    time: Date.now(),               // 毫秒时间戳
    asset: 'device-warehouse-01',  // 设备唯一ID,用于轨迹
    wifis: [
      { mac: '9E:2B:A6:86:2A:0E', signal: -74 },
      { mac: '54:52:84:86:03:A8', signal: -79 },
      { mac: 'A8:6D:AA:12:34:56', signal: -85 },
    ],
  }),
})
.then(r => r.json())
.then(data => {
  if (data.result === 1) {
    console.log(data.point.lat, data.point.lng)
    console.log(data.address.name) // 完整地址,到街道级
  }
})

降级用基站(WiFi扫不到时兜底):

javascript

body: JSON.stringify({
  from: 3,
  time: Date.now(),
  asset: 'device-warehouse-01',
  cellulars: [
    {
      cell: 12345678,   // Cell ID
      primary: true,   // 是否主基站
      dbm: -85,        // 信号强度
      country: 460,    // MCC:中国
      network: 0,      // MNC:中国移动
      area: 4321       // TAC(LTE)
    }
  ]
})

返回结构

json

{
  "result": 1,
  "name": "某仓储广场B区",
  "point": { "lng": 117.334369, "lat": 39.116094 },
  "address": {
    "name": "天津市东丽区万新街道平盈路8号",
    "context": {
      "province": { "name": "天津市", "code": "120000" },
      "city":     { "name": "天津市", "code": "120100" },
      "district": { "name": "东丽区", "code": "120110" },
      "township": { "name": "万新街道", "code": "120110006" }
    }
  }
}

address.context 直接到街道级,物流末端、区域分析场景不需要再额外发一次逆地址解析请求,省一次调用。


降级策略:-3 这个错误码别忽略

接口有个 -3,含义是"信号数据不足,无法完成定位"。极端弱信号环境(全屏蔽机房、电梯深处)会出现。

推荐的降级顺序:

GPS 可用
  └→ 直接用 GPS

GPS 弱/不可用
  └→ 融合定位(WiFi 优先,基站兜底)

融合定位返回 -3
  └→ 使用上一次成功坐标 + 标记 stale: true

全部失败
  └→ 上报 location_status: "unavailable",不上传坐标

有一个常见的低级错误:在 -3 时把 point 设成 {lat: 0, lng: 0} 存进数据库。等轨迹回放的时候,设备轨迹线会突然飞到几内亚湾再飞回来。


一个完整的 Android 采集上报封装

把上面的串起来:

class FusedLocationReporter(
    private val context: Context,
    private val apiKey: String,
    private val deviceId: String
) {
    private val httpClient = OkHttpClient()

    private fun collectWifis(): List<Map<String, Any>> {
        val wm = context.applicationContext
            .getSystemService(Context.WIFI_SERVICE) as WifiManager
        return wm.scanResults.map {
            mapOf("mac" to it.BSSID, "signal" to it.level)
        }
    }

    private fun collectCellulars(): List<Map<String, Any>> {
        val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
        return tm.allCellInfo
            ?.filterIsInstance<CellInfoLte>()
            ?.map { info ->
                mapOf(
                    "cell"    to info.cellIdentity.ci,
                    "primary" to info.isRegistered,
                    "dbm"     to info.cellSignalStrength.dbm,
                    "country" to (info.cellIdentity.mccString?.toIntOrNull() ?: 0),
                    "network" to (info.cellIdentity.mncString?.toIntOrNull() ?: 0),
                    "area"    to info.cellIdentity.tac
                )
            } ?: emptyList()
    }

    suspend fun report(): Result<LocationData> = withContext(Dispatchers.IO) {
        runCatching {
            val wifis = collectWifis()
            val cellulars = collectCellulars()

            // 无信号数据直接跳出
            if (wifis.isEmpty() && cellulars.isEmpty()) {
                throw IllegalStateException("no signal data")
            }

            val payload = buildMap {
                put("from", 3)
                put("time", System.currentTimeMillis())
                put("asset", deviceId)
                // WiFi 优先
                if (wifis.isNotEmpty()) put("wifis", wifis)
                else put("cellulars", cellulars)
            }

            val request = Request.Builder()
                .url("https://lts.maiyun.net/api/service/location")
                .addHeader("Authorization", "Bearer $apiKey")
                .post(
                    Gson().toJson(payload)
                        .toRequestBody("application/json".toMediaType())
                )
                .build()

            val raw = httpClient.newCall(request).execute()
                .body?.string() ?: throw IOException("empty response")

            Gson().fromJson(raw, LocationData::class.java)
                .also { if (it.result != 1) throw RuntimeException("locate failed: ${it.result}") }
        }
    }
}

融合定位不是银弹,精度上限在那里,WiFi密集区15米、基站兜底100米+。但在GPS完全不可用的场景下,有一个粗略的位置感知,远比完全失联要好得多。

自建WiFi/基站数据库基本不现实(迈云LTS那边有20亿+ WiFi AP积累量),用成品服务接入是唯一合理的路。lts.maiyun.net 文档和控制台都可以直接体验,注册后能看到调用量和错误率统计。

有问题或者有不同实现方式的,评论区聊。