24、距离计算:Haversine公式、SphericalUtil工具类、多点距离与面积计算

说到地理位置的距离计算,很多人第一反应就是「直接算直线距离不就行了?」。嗯,如果你是在一张平面地图上,那确实可以。但问题是——地球是圆的。

我在刚做LBS项目那会儿,就踩过这个坑。直接用欧几里得距离算两个经纬度点,结果在短距离上误差还能忍,一旦跨城市、跨国家,那偏差简直离谱。后来我才意识到,必须用球面几何来处理。

这一章,我们就来聊聊Android里最常用的距离计算方案:Haversine公式、Google Maps的SphericalUtil工具类,以及如何计算多点路径长度和多边形面积。

24.1 为什么不能直接用平面距离?

先看一个简单例子:

  • 北京:39.9042°N, 116.4074°E
  • 纽约:40.7128°N, 74.0060°W

如果你用平面直角坐标去算,结果大概在几千公里。但实际飞行距离大约是1.1万公里。为什么会差这么多?因为地球表面是曲面,经线在极点汇聚,纬线长度随纬度变化。平面公式完全没考虑这些。

说白了,经纬度是球面坐标,不是平面坐标。你要在球面上算距离,就得用球面三角学。

24.2 Haversine公式

Haversine公式是计算球面上两点间大圆距离的经典方法。它基于球面余弦定理的变体,数值稳定性更好,尤其适合短距离计算。

公式长这样:

a = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)
c = 2 · atan2(√a, √(1−a))
d = R · c

其中R是地球半径,通常取6371公里。

我直接给你一个可用的Java实现:

public class DistanceCalculator {
    private static final double EARTH_RADIUS_KM = 6371.0;

    public static double haversine(double lat1, double lon1,
                                   double lat2, double lon2) {
        double dLat = Math.toRadians(lat2 - lat1);
        double dLon = Math.toRadians(lon2 - lon1);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
                 + Math.cos(Math.toRadians(lat1))
                 * Math.cos(Math.toRadians(lat2))
                 * Math.sin(dLon / 2) * Math.sin(dLon / 2);
        double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        return EARTH_RADIUS_KM * c;
    }
}

调用方式:

double distance = DistanceCalculator.haversine(
    39.9042, 116.4074,  // 北京
    40.7128, -74.0060   // 纽约
);
// 结果约 10989 km
我的经验:Haversine公式在距离小于1公里时,由于浮点精度问题,偶尔会出现微小误差。如果你做的是室内定位或近距离蓝牙测距,建议用Vincenty公式替代。不过对于绝大多数LBS场景,Haversine完全够用。

24.3 SphericalUtil工具类

如果你用的是Google Maps SDK,那其实不用自己手写Haversine。Google在com.google.maps.android:android-maps-utils库中提供了SphericalUtil类,封装了各种球面计算。

添加依赖:

implementation 'com.google.maps.android:android-maps-utils:2.4.0'

计算两点距离:

import com.google.maps.android.SphericalUtil;
import com.google.android.gms.maps.model.LatLng;

LatLng beijing = new LatLng(39.9042, 116.4074);
LatLng newYork = new LatLng(40.7128, -74.0060);

double distanceMeters = SphericalUtil.computeDistanceBetween(beijing, newYork);
// 结果单位是米,约 10989000 m

我个人习惯用SphericalUtil,原因有三:

  • 它内部用的是Haversine的优化版本,精度和性能都很好
  • 返回单位是米,省去单位换算
  • 它还提供了方位角、终点推算等实用方法

比如计算从北京出发,沿正东方向走1000公里后的位置:

LatLng destination = SphericalUtil.computeOffset(
    beijing, 1000000, 90  // 1000公里,正东90度
);
注意:SphericalUtil依赖Google Play Services。如果你的应用在国内无法使用Google服务,那就只能自己实现Haversine了。我曾经在一个海外项目中同时保留两套方案,通过BuildConfig动态切换。

24.4 多点距离计算

实际场景中,我们经常需要计算一条路径的总长度。比如骑行轨迹、外卖配送路线。这时候就需要把路径拆成若干线段,逐段累加。

用SphericalUtil实现很简单:

public double computePathLength(List<LatLng> points) {
    double total = 0;
    for (int i = 0; i < points.size() - 1; i++) {
        total += SphericalUtil.computeDistanceBetween(
            points.get(i), points.get(i + 1));
    }
    return total;
}

如果你自己实现Haversine版本,逻辑完全一样,只是把computeDistanceBetween换成你的函数。

这里有个坑——我曾经遇到过:用户上传的GPS轨迹点非常密集,每秒一个点。如果直接逐段累加,计算量很大,而且相邻点之间的抖动会导致路径长度被高估。我的做法是先做轨迹简化(比如Douglas-Peucker算法),再计算长度。

24.5 多边形面积计算

除了距离,面积计算也很常见。比如圈地、电子围栏、农田测量等。

球面上多边形面积的计算比平面复杂得多。好在SphericalUtil也提供了现成方法:

List<LatLng> polygon = Arrays.asList(
    new LatLng(39.90, 116.40),
    new LatLng(39.91, 116.41),
    new LatLng(39.89, 116.42),
    new LatLng(39.90, 116.40)  // 首尾闭合
);

double areaMeters = SphericalUtil.computeArea(polygon);

返回单位是平方米。如果你需要平方公里,除以1e6即可。

如果你不能使用Google服务,自己实现球面面积计算可以用以下公式(基于球面三角剖分):

public static double computeAreaOnSphere(List<LatLng> points, double radius) {
    int n = points.size();
    if (n < 3) return 0;
    double total = 0;
    for (int i = 0; i < n; i++) {
        LatLng p1 = points.get(i);
        LatLng p2 = points.get((i + 1) % n);
        LatLng p3 = points.get((i + 2) % n);
        // 计算球面三角形的面积,累加
        // 这里省略具体实现,核心是球面角盈公式
    }
    return Math.abs(total) * radius * radius;
}

嗯,这个实现细节比较多。如果你只是做普通App,我建议直接用SphericalUtil。只有当你需要离线计算或自定义地球半径时,才考虑手写。

24.6 知识体系总览

下面这张图帮你理清本章的核心逻辑:

距离计算知识体系 球面距离计算 Haversine公式 SphericalUtil工具类 多点距离与面积 球面三角学 · 大圆距离 数值稳定 · 适合短距离 Google Maps Utils库 computeDistanceBetween computeOffset · computeHeading 路径长度逐段累加 多边形面积 · 球面角盈 轨迹简化 · 防抖动

24.7 避坑指南

最后,我总结几个实际项目中容易踩的坑:

  • 经纬度顺序搞反:很多API是(lat, lng),但有些是(lng, lat)。我曾在集成第三方地图SDK时,因为顺序问题排查了一整天。建议统一用LatLng对象封装。
  • 地球半径取值不一致:Haversine公式中R取6371km还是6378km?差别不大,但如果你做高精度测量,建议用WGS84椭球体的平均半径6371.009km。
  • 面积计算要求闭合多边形:首尾点必须相同,否则SphericalUtil会默认帮你闭合,但结果可能不是你想要的。
  • 性能问题:如果你需要实时计算大量点(比如每秒更新轨迹),建议把经纬度转成弧度缓存起来,避免重复调用Math.toRadians。

核心要点:

  • 球面距离用Haversine公式,短距离精度足够
  • 能用SphericalUtil就用,省心省力
  • 多点距离就是逐段累加,注意轨迹简化
  • 面积计算依赖球面角盈,推荐直接用工具类

好了,这一章的内容就到这里。距离计算是LBS开发的基础功,但也是容易出细节问题的地方。你想想看,如果外卖App的配送距离算错了,用户投诉可不是闹着玩的。所以,多测试、多验证,准没错。


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