第二章:原始数据采集——Android/iOS原生定位API使用、NMEA协议解析、原始经纬度与时间戳获取
做定位算法这些年,我有个很深的体会:算法再牛,也架不住原始数据烂。你想想看,如果采集到的经纬度本身就是歪的,后面再怎么滤波、融合,效果都有限。所以这一章,咱们先把地基打牢——聊聊怎么从手机系统里拿到最原始的定位数据。
2.1 Android原生定位API:LocationManager与FusedLocationProvider
Android上获取定位,主要有两条路:一条是传统的LocationManager,另一条是Google Play服务提供的FusedLocationProviderClient。我个人习惯,如果是做原型验证,用LocationManager就够了;但如果是做产品级应用,建议用后者——功耗和精度平衡得更好。
2.1.1 LocationManager方式
先看个最简单的例子。我早期做轨迹记录App时,就是这么写的:
// 获取系统定位服务
LocationManager locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);
// 检查权限(Android 6.0+ 需要运行时权限)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
// 申请权限...
return;
}
// 注册位置监听
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER, // 使用GPS提供者
1000L, // 最小时间间隔:1秒
0.0f, // 最小距离变化:0米
locationListener
);
// 定义监听器
LocationListener locationListener = new LocationListener() {
@Override
public void onLocationChanged(Location location) {
double latitude = location.getLatitude();
double longitude = location.getLongitude();
long timestamp = location.getTime(); // 注意:这是系统时间戳
float accuracy = location.getAccuracy(); // 精度(米)
// 这里拿到原始经纬度,后面章节会讲怎么处理
Log.d("RawLocation", "lat: " + latitude + ", lng: " + longitude);
}
};
location.getTime()作为时间戳,结果发现部分国产手机上这个值跟实际时间差了8小时。后来查文档才知道,这个时间戳是GPS卫星时间,不是本地时间。建议用System.currentTimeMillis()配合记录。
2.1.2 FusedLocationProvider方式
如果你用的是Google Play服务,代码会更简洁:
FusedLocationProviderClient fusedClient =
LocationServices.getFusedLocationProviderClient(this);
// 创建定位请求
LocationRequest locationRequest = LocationRequest.create()
.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY)
.setInterval(1000) // 请求间隔1秒
.setFastestInterval(500); // 最快500ms更新一次
// 请求位置更新
fusedClient.requestLocationUpdates(locationRequest,
new LocationCallback() {
@Override
public void onLocationResult(LocationResult locationResult) {
for (Location location : locationResult.getLocations()) {
// 处理每个位置点
processRawLocation(location);
}
}
}, Looper.getMainLooper());
2.2 iOS原生定位API:CLLocationManager
iOS这边相对封闭一些,但API设计得挺优雅。我刚开始做iOS定位时,被权限申请流程折腾过——iOS的定位权限分「使用期间」和「始终」,选错了就得重新提审。
import CoreLocation
class LocationManager: NSObject, CLLocationManagerDelegate {
let manager = CLLocationManager()
func startTracking() {
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = kCLDistanceFilterNone // 不设距离过滤
manager.allowsBackgroundLocationUpdates = true // 后台定位
// 请求权限(iOS 14+ 新增精确开关)
manager.requestWhenInUseAuthorization()
// 如果需要后台定位,还要调用 requestAlwaysAuthorization()
manager.startUpdatingLocation()
}
// 代理方法:位置更新
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
let latitude = location.coordinate.latitude
let longitude = location.coordinate.longitude
let timestamp = location.timestamp // iOS自带时间戳,靠谱
let horizontalAccuracy = location.horizontalAccuracy
// 原始数据输出
print("Raw: \(latitude), \(longitude), acc: \(horizontalAccuracy)m")
}
}
location.timestamp是设备时间,跟GPS卫星时间无关。这一点比Android省心。但要注意,如果用户关闭了「设置-隐私-定位服务-系统服务-时间校准」,这个时间戳也可能不准。
2.3 NMEA协议解析:从卫星信号到经纬度
说到NMEA,很多做应用层的同学可能觉得陌生。但如果你要深入做定位算法,这玩意儿绕不开。说白了,NMEA就是GPS模块吐出来的一串文本,里面包含了卫星编号、信噪比、经纬度、海拔等等信息。
我当年第一次看到NMEA数据时,一脸懵——这都什么鬼?后来慢慢摸清了门道。
2.3.1 常见的NMEA语句
| 语句类型 | 说明 | 示例 |
|---|---|---|
| $GPGGA | 全球定位系统固定数据 | $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 |
| $GPRMC | 推荐最小定位信息 | $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A |
| $GPGSV | 卫星可见状态 | $GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74 |
2.3.2 解析$GPGGA语句
以$GPGGA为例,我写了个简单的解析函数:
public class NmeaParser {
/**
* 解析$GPGGA语句
* 格式:$GPGGA,时间,纬度,N,经度,E,定位质量,卫星数,水平精度因子,海拔,M,大地水准面高度,M,差分时间,差分站ID*校验和
*/
public static GgaData parseGGA(String nmeaSentence) {
if (!nmeaSentence.startsWith("$GPGGA")) {
return null;
}
String[] parts = nmeaSentence.split(",");
if (parts.length < 10) {
return null; // 数据不完整
}
GgaData data = new GgaData();
// 时间:hhmmss.sss 格式
data.utcTime = parts[1];
// 纬度:ddmm.mmmm 格式,需要转换
String latStr = parts[2];
String latDir = parts[3]; // N或S
double latitude = convertToDecimalDegrees(latStr, latDir);
// 经度:dddmm.mmmm 格式
String lngStr = parts[4];
String lngDir = parts[5]; // E或W
double longitude = convertToDecimalDegrees(lngStr, lngDir);
// 定位质量:0=无效,1=单点定位,2=差分定位
data.quality = Integer.parseInt(parts[6]);
// 卫星数
data.satelliteCount = Integer.parseInt(parts[7]);
// 海拔(米)
data.altitude = Double.parseDouble(parts[9]);
return data;
}
/**
* 将NMEA格式的度分转换为十进制
* 例如:4807.038,N -> 48.1173
*/
private static double convertToDecimalDegrees(String dms, String direction) {
// 找到小数点位置,前两位是度,后面是分
int dotIndex = dms.indexOf('.');
double degrees = Double.parseDouble(dms.substring(0, dotIndex - 2));
double minutes = Double.parseDouble(dms.substring(dotIndex - 2));
double result = degrees + minutes / 60.0;
// 南纬或西经取负值
if (direction.equals("S") || direction.equals("W")) {
result = -result;
}
return result;
}
static class GgaData {
String utcTime;
double latitude;
double longitude;
int quality; // 0=无效, 1=单点, 2=差分
int satelliteCount;
double altitude;
}
}
2.4 原始经纬度与时间戳的获取策略
好了,API会调了,NMEA也能解析了。但实际采集时,还有几个坑要避开。
2.4.1 时间戳的统一
不同来源的时间戳,格式可能不一样:
- Android Location.getTime():GPS卫星时间,可能跟本地时间有时差
- iOS location.timestamp:设备本地时间,相对可靠
- NMEA $GPGGA中的时间:UTC时间,需要加上时区偏移
- System.currentTimeMillis():设备本地时间,但用户可能改了系统时间
我的做法是:统一使用UTC时间戳(毫秒级),在采集时就把所有时间都转成UTC。这样后面做时间同步、轨迹匹配时,不会因为时区问题出幺蛾子。
2.4.2 精度过滤
不是所有返回的位置都能用。我一般会加个过滤:
// 只保留精度在50米以内的数据
if (location.hasAccuracy() && location.getAccuracy() < 50.0f) {
// 这个点可以入库
saveToDatabase(location);
} else {
// 精度太差,丢弃或标记
Log.w("LocationFilter", "精度不足: " + location.getAccuracy());
}
2.5 本章知识体系
说了这么多,咱们用一张图把核心逻辑串起来:
这张图把整个流程串起来了。从Android/iOS的API拿到原始位置,或者直接从NMEA流里解析,然后统一时间戳、过滤掉精度差的数据,最后得到干净的原始数据,交给后面的算法去处理。
- Android用LocationManager或FusedLocationProvider,注意时间戳问题
- iOS用CLLocationManager,权限申请要谨慎
- NMEA解析时注意度分格式转换,别搞错了
- 所有时间戳统一成UTC毫秒,避免时区混乱
- 精度过滤用动态阈值,别一刀切
嗯,这一章就到这儿。原始数据采集看着简单,但细节不少。我当年第一个定位项目,就是栽在时间戳和精度过滤上——数据采了一大堆,回头一看全是废的。所以,把基础打牢,后面才能走远。