第二章:原始数据采集——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());
💡 小技巧:实际项目中,我建议同时注册GPS和Network两个Provider。GPS信号弱的时候(比如室内),Network提供者还能给出WiFi或基站定位的结果。虽然精度差一些,但至少不会断档。

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")
    }
}
🔑 关键点:iOS的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;
    }
}
⚠️ 注意:NMEA里的经纬度是「度分」格式,不是十进制。我见过有同事直接当十进制解析,结果定位到太平洋去了。转换时一定要先取前两位(或三位)作为度,剩下的作为分。

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());
}
💡 经验之谈:精度阈值别设太死。我在高架桥下测试时,精度经常跳到30-40米,但轨迹整体还是可用的。建议设成动态阈值——信号好的时候用10米,信号差的时候放宽到50米。

2.5 本章知识体系

说了这么多,咱们用一张图把核心逻辑串起来:

原始数据采集核心流程 Android LocationManager iOS CLLocationManager NMEA原始数据流 原始数据字段 纬度 | 经度 | 时间戳(UTC) | 精度(米) | 卫星数 | 海拔 时间戳统一(转UTC) 精度过滤(动态阈值) ✅ 干净的原始定位数据 → 进入算法模块

这张图把整个流程串起来了。从Android/iOS的API拿到原始位置,或者直接从NMEA流里解析,然后统一时间戳、过滤掉精度差的数据,最后得到干净的原始数据,交给后面的算法去处理。

📌 核心要点回顾:
  • Android用LocationManager或FusedLocationProvider,注意时间戳问题
  • iOS用CLLocationManager,权限申请要谨慎
  • NMEA解析时注意度分格式转换,别搞错了
  • 所有时间戳统一成UTC毫秒,避免时区混乱
  • 精度过滤用动态阈值,别一刀切

嗯,这一章就到这儿。原始数据采集看着简单,但细节不少。我当年第一个定位项目,就是栽在时间戳和精度过滤上——数据采了一大堆,回头一看全是废的。所以,把基础打牢,后面才能走远。

可可精品资料库