25、位置共享:蓝牙定位共享、WebSocket实时位置同步、Firebase Realtime Database集成

位置共享这个功能,说白了就是把一个人的坐标实时推给另一个人。我最早接触这个需求是在一个户外徒步App里——几个人进山后手机信号时有时无,但蓝牙还能用。那时候我就意识到,位置共享不能只依赖网络,得有多套方案兜底。

这一章我们聊三个方向:蓝牙定位共享(近距离)、WebSocket实时同步(中远距离)、Firebase Realtime Database集成(云端持久化)。它们各有各的适用场景,也各有各的坑。

25.1 蓝牙定位共享:近距离的“悄悄话”

蓝牙定位共享,适合两个人距离在10米以内的情况。比如你在商场里找朋友,或者团队在野外作业。它的核心思路是:一台设备广播自己的位置,另一台设备扫描并接收。

我习惯用 Bluetooth LE(低功耗蓝牙) 来做这件事。Android 从 4.3 开始就支持 BLE,API 也比较成熟。

25.1.1 广播端(发送位置)

广播端需要把自己的经纬度编码到广播包里。注意,广播包最大只有31字节,所以得压缩一下数据。

// 广播端:发送位置
private fun startAdvertising(latitude: Double, longitude: Double) {
    val advertiseData = AdvertiseData.Builder()
        .setIncludeDeviceName(false)
        .addServiceUuid(ParcelUuid(LOCATION_SERVICE_UUID))
        .addServiceData(ParcelUuid(LOCATION_SERVICE_UUID), encodeLocation(latitude, longitude))
        .build()

    val advertiseSettings = AdvertiseSettings.Builder()
        .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
        .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
        .setConnectable(false)
        .build()

    bluetoothLeAdvertiser?.startAdvertising(advertiseSettings, advertiseData, advertiseCallback)
}

private fun encodeLocation(lat: Double, lng: Double): ByteArray {
    // 将经纬度转为int,节省空间
    val latInt = (lat * 1_000_000).toInt()
    val lngInt = (lng * 1_000_000).toInt()
    return ByteBuffer.allocate(8).putInt(latInt).putInt(lngInt).array()
}

这里有个细节:setConnectable(false) 表示只广播不连接,功耗更低。我在项目里实测过,开启广播后每小时耗电大约增加 3%-5%,还算能接受。

25.1.2 扫描端(接收位置)

扫描端就简单了,监听广播包,解析出经纬度。

// 扫描端:接收位置
private val scanCallback = object : ScanCallback() {
    override fun onScanResult(callbackType: Int, result: ScanResult?) {
        result?.let {
            val scanRecord = it.scanRecord
            val serviceData = scanRecord?.getServiceData(ParcelUuid(LOCATION_SERVICE_UUID))
            if (serviceData != null) {
                val (lat, lng) = decodeLocation(serviceData)
                updateUI(lat, lng)
            }
        }
    }
}

private fun decodeLocation(data: ByteArray): Pair<Double, Double> {
    val buffer = ByteBuffer.wrap(data)
    val lat = buffer.int / 1_000_000.0
    val lng = buffer.int / 1_000_000.0
    return Pair(lat, lng)
}
注意:Android 6.0 以上需要 ACCESS_FINE_LOCATION 权限才能扫描到 BLE 设备。Android 10 以上还需要 ACCESS_BACKGROUND_LOCATION 才能在后台扫描。我曾经因为没加后台权限,导致App切到后台后就收不到广播了,排查了半天。

25.2 WebSocket实时位置同步:中远距离的“对讲机”

蓝牙只能覆盖几十米。一旦距离拉远,就得靠网络了。WebSocket 是我个人比较推荐的方式——它比 HTTP 轮询省电,也比 MQTT 简单(不需要额外搭 Broker)。

你想想看,如果两个人都在移动,每隔几秒就要上报一次位置。用 HTTP 的话,每次都要建立连接、发送、断开,开销很大。WebSocket 建立一次连接后,双向推送,延迟通常在 100ms 以内。

25.2.1 客户端实现

Android 上我用 OkHttp 的 WebSocket 客户端,因为它和 Retrofit 配合得好,不用额外引入库。

// WebSocket 客户端
class LocationWebSocket(private val url: String) {
    private val client = OkHttpClient.Builder()
        .pingInterval(30, TimeUnit.SECONDS) // 心跳保活
        .build()
    private var webSocket: WebSocket? = null

    fun connect() {
        val request = Request.Builder().url(url).build()
        webSocket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: Response) {
                // 连接成功,可以开始发送位置
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                // 收到对方的位置
                val location = parseLocation(text)
                updateUI(location)
            }

            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                // 断线重连
                reconnect()
            }
        })
    }

    fun sendLocation(lat: Double, lng: Double) {
        val json = "{\"lat\": $lat, \"lng\": $lng}"
        webSocket?.send(json)
    }

    private fun reconnect() {
        // 延迟3秒后重连
        Handler(Looper.getMainLooper()).postDelayed({ connect() }, 3000)
    }
}
小技巧:WebSocket 的 pingInterval 一定要设。如果不设,运营商的路由器可能会把长时间空闲的连接断开。我遇到过在电梯里断连后重连失败的情况,加了心跳就好了。

25.2.2 服务端(Node.js 示例)

服务端我用 Node.js 的 ws 库,轻量又好用。每个客户端连接后,服务端负责转发位置数据。

// 服务端:位置转发
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

// 存储所有连接的客户端
const clients = new Map();

wss.on('connection', (ws, req) => {
    const userId = req.url.split('?userId=')[1];
    clients.set(userId, ws);

    ws.on('message', (message) => {
        // 解析消息,找到目标用户
        const data = JSON.parse(message);
        const targetUserId = data.targetUserId;
        const targetWs = clients.get(targetUserId);
        
        if (targetWs && targetWs.readyState === WebSocket.OPEN) {
            // 转发位置
            targetWs.send(JSON.stringify({
                fromUserId: userId,
                lat: data.lat,
                lng: data.lng,
                timestamp: Date.now()
            }));
        }
    });

    ws.on('close', () => {
        clients.delete(userId);
    });
});

这个方案有个问题:如果服务端重启,所有连接都会断。所以实际项目中我还会加一个 Redis 做 session 持久化,不过那是后话了。

25.3 Firebase Realtime Database 集成:云端的“黑板”

WebSocket 虽然实时性好,但有个硬伤——如果接收方离线,消息就丢了。Firebase Realtime Database 就不一样了,它会把数据持久化在云端,用户上线后自动同步。

说白了,Firebase 就是一个实时同步的 JSON 数据库。你写入数据,所有监听这个路径的客户端都会立刻收到通知。非常适合做位置共享的“历史轨迹”和“离线补发”。

25.3.1 初始化 Firebase

首先在 build.gradle 里加依赖:

implementation 'com.google.firebase:firebase-database:20.3.0'

然后在 Application 里初始化:

FirebaseApp.initializeApp(this)

25.3.2 写入位置数据

我习惯用 push() 方法生成唯一 key,这样不会覆盖之前的数据。

// 写入位置到 Firebase
fun uploadLocation(userId: String, lat: Double, lng: Double) {
    val database = FirebaseDatabase.getInstance()
    val ref = database.getReference("locations/$userId")

    val locationData = hashMapOf(
        "lat" to lat,
        "lng" to lng,
        "timestamp" to ServerValue.TIMESTAMP
    )

    ref.push().setValue(locationData)
        .addOnSuccessListener {
            // 写入成功
        }
        .addOnFailureListener { e ->
            Log.e("LocationSync", "写入失败", e)
        }
}
关键点:使用 ServerValue.TIMESTAMP 而不是 System.currentTimeMillis()。因为不同手机的系统时间可能不一致,用服务器时间戳才能保证时序正确。我在测试时发现两台手机时间差了5秒,导致轨迹回放顺序错乱,改用服务器时间戳后问题解决。

25.3.3 监听位置变化

监听端用 addChildEventListener,每次有新数据都会回调。

// 监听对方的位置
fun observeLocation(targetUserId: String, onLocationUpdate: (Double, Double) -> Unit) {
    val database = FirebaseDatabase.getInstance()
    val ref = database.getReference("locations/$targetUserId")
    
    // 只监听最新的位置
    val query = ref.orderByChild("timestamp").limitToLast(1)
    
    query.addChildEventListener(object : ChildEventListener {
        override fun onChildAdded(snapshot: DataSnapshot, previousChildName: String?) {
            val lat = snapshot.child("lat").value as Double
            val lng = snapshot.child("lng").value as Double
            onLocationUpdate(lat, lng)
        }

        override fun onChildChanged(snapshot: DataSnapshot, previousChildName: String?) {
            // 位置更新时也会触发
        }

        override fun onChildRemoved(snapshot: DataSnapshot) {}
        override fun onChildMoved(snapshot: DataSnapshot, previousChildName: String?) {}
        override fun onCancelled(error: DatabaseError) {
            Log.e("LocationSync", "监听失败: ${error.message}")
        }
    })
}

这里有个性能问题:如果位置更新很频繁(比如每秒一次),Firebase 的实时同步会带来不小的流量开销。我建议根据场景调整上报频率——步行时每5秒一次,开车时每2秒一次,静止时每30秒一次。

25.4 三种方案的对比与选择

我把这三种方案整理成了表格,方便你根据场景选择:

特性 蓝牙 BLE WebSocket Firebase RTDB
有效距离 10-30米 不限(依赖网络) 不限(依赖网络)
延迟 < 1秒 100-500ms 200-1000ms
是否需要网络 不需要 需要 需要
离线消息 不支持 不支持 支持(持久化)
功耗
实现复杂度
典型场景 室内找朋友、野外无信号 实时追踪、车队管理 轨迹记录、离线补发

我个人建议:如果只是两个人近距离共享,蓝牙就够了。如果需要实时追踪且双方都在线,WebSocket 是性价比最高的方案。如果要做历史轨迹回放或者担心对方掉线,那就上 Firebase。

25.5 知识体系总览

下面这张图把三种方案的核心流程串起来了,你可以对照着看:

位置共享三种方案架构图 位置共享引擎 蓝牙 BLE 广播 广播端:编码经纬度到广播包 扫描端:解析广播包获取位置 WebSocket 长连接 客户端:OkHttp WebSocket 服务端:Node.js ws 转发 Firebase RTDB 写入:push() + ServerValue.TIMESTAMP 监听:addChildEventListener 蓝牙:近距离无网络 | WebSocket:实时双向 | Firebase:持久化离线补发

嗯,到这里三种方案都讲完了。蓝牙适合“面对面”,WebSocket 适合“在线实时”,Firebase 适合“有历史记录”。实际项目中我经常把它们组合起来用——蓝牙兜底近距离,WebSocket 做主通道,Firebase 做数据备份。这样不管网络好不好,位置都不会丢。

避坑指南:我曾经在一个项目里只用了 WebSocket,结果用户进电梯后断网,位置就卡住了。后来加了 Firebase 做离线缓存,用户出电梯后自动补发这段时间的位置,体验好了很多。所以我的建议是:永远不要只依赖一种方案

公众号:蓝海资料掘金营,微信deep3321