第三十章 综合实战:完整LBS应用开发(打卡签到+轨迹记录+地理围栏提醒)
终于到了最后一章。说实话,写到这里我自己也挺感慨的。前面二十九章我们拆解了 Location API 的每一个零件,从最基本的定位权限到高精度算法,从地理编码到围栏触发。但零件堆在一起不是产品,就像你有一堆乐高块,不等于拼出了千年隼号。
这一章,我们就来真正拼一次。我会带你从零搭建一个完整的 LBS 应用,包含三个核心功能:打卡签到、轨迹记录、地理围栏提醒。这三个功能几乎覆盖了市面上 80% 的 LBS 场景——考勤系统、运动 App、智能提醒,底层逻辑都差不多。
本章目标:完成一个可运行的 Demo App,包含完整的定位逻辑、数据持久化、后台服务和 UI 交互。
30.1 整体架构设计
动手写代码之前,我习惯先画一张架构图。你想想看,如果连模块之间的依赖关系都没理清,写到一半很容易卡住。下面这张图是我个人比较喜欢的分层结构:
嗯,这里要注意:千万不要把定位逻辑直接写在 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 完整流程串联
最后,我们把三个模块串起来,看看一次完整的用户操作流程:
- 用户打开 App,进入打卡页面。App 获取当前位置,与公司坐标比对。如果在范围内,显示「可打卡」按钮
- 用户点击打卡,记录签到数据到 Room 数据库,同时上传到服务器
- 用户切换到运动模式,点击「开始记录」。前台 Service 启动,开始采集轨迹点
- 运动过程中,每隔 5 秒/10 米采样一个点,用 Douglas-Peucker 压缩后存入数据库
- 同时,后台围栏监控线程持续运行。当用户靠近预设的「超市」围栏时,触发通知提醒
- 用户结束运动,停止 Service,生成轨迹回放
你看,三个功能虽然看起来独立,但底层共享了同一套定位基础设施。这就是架构设计的意义——不要重复造轮子,但要把轮子造好。
好了,三十章的内容到这里就全部结束了。从第一行的 requestLocationUpdates 到现在的完整 LBS 应用,我们走了很长一段路。说实话,写这个系列的过程中,我自己也重新梳理了很多知识点——有些细节以前自己都没想透。
如果你能把这一章的 Demo 完整跑起来,恭喜你,你已经具备了独立开发 LBS 应用的能力。以后遇到定位相关的需求,不管是考勤、导航还是社交签到,你都能从容应对。
最后,送你一句话:技术是练出来的,不是看出来的。打开 Android Studio,动手吧。
公众号:蓝海资料掘金营,微信deep3321