第三十章 综合实战:完整LBS应用开发(打卡签到+轨迹记录+地理围栏提醒)

终于到了最后一章。说实话,写到这里我自己也挺感慨的。前面二十九章我们拆解了 Location API 的每一个零件,从最基本的定位权限到高精度算法,从地理编码到围栏触发。但零件堆在一起不是产品,就像你有一堆乐高块,不等于拼出了千年隼号。

这一章,我们就来真正拼一次。我会带你从零搭建一个完整的 LBS 应用,包含三个核心功能:打卡签到轨迹记录地理围栏提醒。这三个功能几乎覆盖了市面上 80% 的 LBS 场景——考勤系统、运动 App、智能提醒,底层逻辑都差不多。

本章目标:完成一个可运行的 Demo App,包含完整的定位逻辑、数据持久化、后台服务和 UI 交互。

30.1 整体架构设计

动手写代码之前,我习惯先画一张架构图。你想想看,如果连模块之间的依赖关系都没理清,写到一半很容易卡住。下面这张图是我个人比较喜欢的分层结构:

LBS 应用整体架构 UI 层(Activity / Fragment / Compose) 打卡页面 | 轨迹地图 | 围栏设置 | 历史记录 业务逻辑层(ViewModel / UseCase) 签到校验 | 轨迹聚合 | 围栏匹配 | 通知决策 服务层(Service / WorkManager / BroadcastReceiver) 后台定位 | 围栏监控 | 省电策略 | 异常恢复 数据层(Room / DataStore / File) 签到记录表 | 轨迹点表 | 围栏配置表 | 本地缓存

嗯,这里要注意:千万不要把定位逻辑直接写在 Activity 里。我在项目中见过太多人图省事,结果 Activity 一重建,定位就断了。正确的做法是把定位服务放在 Service 层,通过 ViewModel 做数据桥接。

30.2 打卡签到模块

打卡签到,说白了就是判断用户当前位置是否在某个预设的「打卡范围」内。最常见的场景是公司考勤——你到了公司附近 100 米,App 自动允许打卡。

30.2.1 核心逻辑

判断逻辑其实很简单:计算用户当前位置与打卡点之间的距离,如果小于阈值,就认为打卡有效。但这里有个坑——距离计算要用 Haversine 公式,而不是简单的勾股定理。地球是球面,经纬度不能直接当平面坐标算。

// 打卡签到核心判断
fun isWithinCheckInRange(
    userLat: Double,
    userLng: Double,
    officeLat: Double,
    officeLng: Double,
    radiusMeters: Float = 100f
): Boolean {
    val results = FloatArray(1)
    Location.distanceBetween(userLat, userLng, officeLat, officeLng, results)
    return results[0] <= radiusMeters
}

我的经验:Location.distanceBetween 内部已经用了 Haversine 算法,精度足够。别自己手写数学公式,容易出 bug。我曾经见过一个同事自己写公式,结果在赤道附近误差达到 200 米……

30.2.2 防作弊策略

打卡签到最怕什么?怕用户用虚拟定位作弊。虽然 Android 原生提供了 isFromMockProvider() 方法,但说实话,这个很容易被绕过。

我建议的做法是:

  • 多重验证:同时检查 GPS 和网络定位,如果两者偏差过大,标记为可疑
  • 时间戳校验:记录定位获取的时间,如果与当前系统时间差距超过 5 秒,拒绝签到
  • 加速度传感器辅助:如果定位点变化但加速度计显示设备静止,大概率是模拟器
// 防作弊检查示例
fun isSuspiciousLocation(location: Location): Boolean {
    // 检查是否来自模拟提供者
    if (location.isFromMockProvider) return true
    
    // 检查时间戳合理性
    val age = System.currentTimeMillis() - location.time
    if (age > 5000) return true  // 超过5秒的旧数据
    
    return false
}

注意:不要完全依赖 isFromMockProvider。Android 10 之后,普通 App 无法直接判断模拟定位。更可靠的做法是结合服务端验证——把定位数据传到后端,用基站信息做交叉校验。

30.3 轨迹记录模块

轨迹记录是运动类 App 的核心功能。但说实话,无脑高频定位是新手最容易犯的错误。你想想看,如果每秒记录一个点,跑一小时就是 3600 个点,不仅耗电,而且数据冗余严重。

30.3.1 采样策略

我个人习惯用「距离+时间双阈值」策略:

场景 最小时间间隔 最小距离间隔 定位优先级
步行 5 秒 10 米 高精度
跑步 3 秒 15 米 高精度
骑行 5 秒 30 米 平衡
驾车 10 秒 50 米 低功耗
// 轨迹点采样判断
class TrajectorySampler(
    private val minTimeMs: Long = 5000,
    private val minDistanceM: Float = 10f
) {
    private var lastRecordedLocation: Location? = null
    private var lastRecordedTime: Long = 0L
    
    fun shouldRecord(location: Location): Boolean {
        val now = System.currentTimeMillis()
        val timeDelta = now - lastRecordedTime
        val distanceDelta = lastRecordedLocation?.let {
            val results = FloatArray(1)
            Location.distanceBetween(
                it.latitude, it.longitude,
                location.latitude, location.longitude,
                results
            )
            results[0]
        } ?: Float.MAX_VALUE
        
        return timeDelta >= minTimeMs && distanceDelta >= minDistanceM
    }
}

30.3.2 轨迹压缩

记录完轨迹之后,还有一个重要步骤——压缩。直接用原始点绘制轨迹,不仅地图渲染卡顿,而且存储空间浪费严重。

我推荐使用 Douglas-Peucker 算法。这个算法的核心思想是:如果一条折线中某个点到两端连线的距离小于阈值,就把它去掉。说白了,就是保留拐弯处的关键点,去掉直线上的冗余点。

// Douglas-Peucker 轨迹压缩(简化版)
fun simplifyTrajectory(
    points: List<LatLng>,
    tolerance: Double = 1.0
): List<LatLng> {
    if (points.size <= 2) return points
    
    var maxDistance = 0.0
    var maxIndex = 0
    val end = points.size - 1
    
    for (i in 1 until end) {
        val distance = perpendicularDistance(points[i], points[0], points[end])
        if (distance > maxDistance) {
            maxDistance = distance
            maxIndex = i
        }
    }
    
    return if (maxDistance > tolerance) {
        val left = simplifyTrajectory(points.subList(0, maxIndex + 1), tolerance)
        val right = simplifyTrajectory(points.subList(maxIndex, points.size), tolerance)
        left.subList(0, left.size - 1) + right
    } else {
        listOf(points[0], points[end])
    }
}

实际效果:一条 1000 个点的轨迹,用 1 米容差压缩后,通常只剩 100-200 个点。地图显示效果几乎看不出区别,但性能提升了一个数量级。

30.4 地理围栏提醒模块

地理围栏,说白了就是画个圈,用户进圈或出圈时触发通知。Android 自带的 Geofencing API 其实挺好用,但有个限制——最多注册 100 个围栏。如果你需要更多,就得自己实现。

30.4.1 使用系统 Geofencing API

// 注册地理围栏
fun addGeofence(context: Context, geofence: Geofence) {
    val geofencingClient = LocationServices.getGeofencingClient(context)
    
    val geofenceRequest = GeofencingRequest.Builder()
        .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
        .addGeofence(geofence)
        .build()
    
    val pendingIntent = PendingIntent.getBroadcast(
        context,
        0,
        Intent(context, GeofenceReceiver::class.java),
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
    
    geofencingClient.addGeofences(geofenceRequest, pendingIntent)
}

30.4.2 自定义围栏(超过 100 个的场景)

如果你需要管理上千个围栏,系统 API 就不够用了。我的做法是:用空间索引 + 定期扫描

具体来说:

  • 把所有围栏按经纬度建立 四叉树索引(QuadTree)
  • 每次定位更新时,只查询当前位置附近的围栏(比如 500 米范围内)
  • 用 Haversine 公式逐一判断是否进入/离开
// 自定义围栏匹配
fun checkCustomGeofences(
    currentLocation: Location,
    allFences: List<CustomGeofence>
): List<CustomGeofence> {
    // 先用粗略范围过滤(减少计算量)
    val nearbyFences = allFences.filter { fence ->
        val distance = calculateApproxDistance(
            currentLocation.latitude, currentLocation.longitude,
            fence.latitude, fence.longitude
        )
        distance < fence.radius + 500  // 多取 500 米缓冲
    }
    
    // 精确匹配
    return nearbyFences.filter { fence ->
        val results = FloatArray(1)
        Location.distanceBetween(
            currentLocation.latitude, currentLocation.longitude,
            fence.latitude, fence.longitude,
            results
        )
        results[0] <= fence.radius
    }
}

性能提醒:如果你有 10000 个围栏,每次定位都遍历一遍,手机 CPU 会哭的。一定要用空间索引做预过滤。我曾在项目中用 QuadTree 把每次查询从 O(n) 降到了 O(log n),效果立竿见影。

30.5 数据持久化设计

三个模块的数据都需要存到本地。我用 Room 数据库,设计了三张表:

// 签到记录表
@Entity(tableName = "check_in_records")
data class CheckInRecord(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val timestamp: Long,
    val latitude: Double,
    val longitude: Double,
    val locationName: String,
    val isValid: Boolean
)

// 轨迹点表
@Entity(tableName = "trajectory_points")
data class TrajectoryPoint(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val sessionId: String,  // 一次运动会话的 ID
    val timestamp: Long,
    val latitude: Double,
    val longitude: Double,
    val accuracy: Float
)

// 围栏配置表
@Entity(tableName = "geofence_configs")
data class GeofenceConfig(
    @PrimaryKey val fenceId: String,
    val latitude: Double,
    val longitude: Double,
    val radiusMeters: Float,
    val label: String,
    val isEnabled: Boolean
)

30.6 后台定位与省电策略

轨迹记录和围栏监控都需要后台运行。但 Android 对后台定位的限制越来越严——从 Android 8 的后台服务限制,到 Android 10 的后台定位权限,再到 Android 12 的精确位置权限。

我的建议是:

  • 前台 Service:轨迹记录必须用前台 Service,带通知栏提醒。否则系统随时可能杀掉你的进程
  • WorkManager:围栏检查可以用 WorkManager 做周期性任务,省电且兼容性好
  • 低功耗定位:如果不需要高精度,用 PRIORITY_BALANCED_POWER_ACCURACY 代替高精度模式
// 前台 Service 示例
class TrackingService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = createNotification()
        startForeground(NOTIFICATION_ID, notification)
        
        // 启动定位
        startLocationUpdates()
        return START_STICKY
    }
    
    private fun createNotification(): Notification {
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("轨迹记录中")
            .setContentText("正在记录您的运动轨迹")
            .setSmallIcon(R.drawable.ic_tracking)
            .setOngoing(true)
            .build()
    }
}

避坑指南:我曾经在 Android 11 上遇到一个问题——前台 Service 启动后,如果用户手动划掉通知,Service 会被系统杀死。解决方案是在通知上添加一个「重新连接」的 Action,或者用 startForegroundService() 配合 Service.START_REDELIVER_INTENT

30.7 完整流程串联

最后,我们把三个模块串起来,看看一次完整的用户操作流程:

  1. 用户打开 App,进入打卡页面。App 获取当前位置,与公司坐标比对。如果在范围内,显示「可打卡」按钮
  2. 用户点击打卡,记录签到数据到 Room 数据库,同时上传到服务器
  3. 用户切换到运动模式,点击「开始记录」。前台 Service 启动,开始采集轨迹点
  4. 运动过程中,每隔 5 秒/10 米采样一个点,用 Douglas-Peucker 压缩后存入数据库
  5. 同时,后台围栏监控线程持续运行。当用户靠近预设的「超市」围栏时,触发通知提醒
  6. 用户结束运动,停止 Service,生成轨迹回放

你看,三个功能虽然看起来独立,但底层共享了同一套定位基础设施。这就是架构设计的意义——不要重复造轮子,但要把轮子造好


好了,三十章的内容到这里就全部结束了。从第一行的 requestLocationUpdates 到现在的完整 LBS 应用,我们走了很长一段路。说实话,写这个系列的过程中,我自己也重新梳理了很多知识点——有些细节以前自己都没想透。

如果你能把这一章的 Demo 完整跑起来,恭喜你,你已经具备了独立开发 LBS 应用的能力。以后遇到定位相关的需求,不管是考勤、导航还是社交签到,你都能从容应对。

最后,送你一句话:技术是练出来的,不是看出来的。打开 Android Studio,动手吧。

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