Android自定义控件之雷达地图(飞机与人员位置显示)

1,322 阅读8分钟

前言

在做无人机项目时候,有一个功能是,手机绘制雷达图,将人与飞机的位置显示在雷达图中,在图中,飞机是点,人是箭头,当人转身时候,地图跟着旋转,当人朝向飞机的时候,箭头指向飞机位置。当时感觉开发比较复杂,就去掉该功能。现在想如果真要做该怎么实现,该功能效果图如下: 雷达地图.gif

参考博客

屏幕坐标系转化为数学坐标系
两点经纬度获取与正北方向夹角

注意

1.需要在设置>应用>应用管理,给应用开启位置权限才能获取到经纬度
2.原生方式获取经纬度不太准确
3.下面功能中,获取两点经纬度距离与正北方向夹角会有误差。

功能分析

1.绘制雷达图网格
2.人员箭头
3.飞机图标点绘制
4.手机经纬度,方向角获取

功能1:绘制雷达图网格比较简单,在画布绘制圆与线段即可。

功能2:人员箭头,箭头方向指向手机顶部,当人员转身超过一定角度时箭头轻微颤动,颤动效果可以使用ObjectAnimator动画对箭头图片进行操作。

功能3:飞机图标点绘制,比较复杂的一点是,如何确定飞机绘制点位置,当获取到飞机的经纬度怎么转换为屏幕上位置。目前思路是,以人员位置作为屏幕的中点,先获取两个点经纬度的距离,之后连接两点做线段,获取该线段与正北方向夹角,之后可以通过sin与cos函数,以及显示距离与屏幕像素点对应比例,计算出屏幕上xy值,从而获取飞机在屏幕的绘制点。由于无人机的飞行距离一般在5km左右,且不要求高精度,目前想到这种实现方式(个人感觉直接使用第三方地图服务会精准很多,但当时需求想实现这种方式,就很难受,尤其让自己来处理经纬度来进行换算很头疼)。关于人员转身飞机点旋转,实现想法是使用方向传感器获取手机的方向角,当人转身时,根据方向角度数,来旋转画布,从而实现地图上飞机点移动。

代码实现

完整代码在Github上,下面介绍的代码选自对于功能的主要部分。

绘制雷达图网格

雷达图网格绘制,是在画布中绘制调用画布drawCircle和drawLine方法绘制,圆圈与直线。(关于代码中centerX,centerY这种变量放在后面完整代码中展示。这里是功能实现主要代码),第一个drawCircle绘制灰色实心圆背景,for循环里面drawCircle绘制白灰色的圆环。drawLine第一个绘制竖线,第二个横线。至此雷达图网格绘制好了

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制雷达图线
        canvas.drawCircle(centerX, centerY, radiusUnit * CIRCLE_NUM, bgPaint);
        for (int i = 1; i <= CIRCLE_NUM; i++) {
            canvas.drawCircle(centerX, centerY, radiusUnit * i - LINE_WIDTH/2, linePaint);
        }
        canvas.drawLine(centerX, 0, centerX, viewHeight, linePaint);
        canvas.drawLine(0, centerY, viewWidth, centerY, linePaint);
       //飞机点绘制
       //...
    }

人员箭头

由于人员箭头想实现手机转动大于一定角度时候微微颤动的功能,直接在View的onDraw方法内不好时候,后面采用组合控件的方法通过约束布局使箭头在布局中居中,后面通过ObjectAnimator给箭头图片添加动画效果。 给箭头图片添加动画代码如下:

public void setNavDirectionDegree(float degree) {
    if (imgAnimator == null || !imgAnimator.isRunning()) {
        imgAnimator = ObjectAnimator.ofFloat(imgNavDirection, "rotation", degree, -degree*2,degree);
        imgAnimator.setDuration(100);
        imgAnimator.start();
    }
}

上面代码中imgNavDirection指的是箭头图片控件。关于手机转向超过一定角度,是通过获取方向角数据实现的,方向角获取代码在飞机图标点绘制功能介绍中。

飞机图标点绘制

前面说到飞机图标点绘制难点在于怎么将经纬度转换为屏幕坐标,关于实现的思路,在前面写的功能分析的功能3部分。分析里面个人感觉关键点在于,如何获取两点经纬度的距离,如何根据两点经纬度得出与正北方向的夹角。受制于自身能力,目前不懂地图经纬度专业知识(搜索了下相关的专业是地理信息系统专业GIS),根据上述功能分析里面思路实现会与现实世界中有误差。 关于距离与夹角的获取是从网上搜索到的方法。经纬度转换的方法如下:

/**
 * 经纬度操作相关工具类
 */
public class GeoUtils {
    private static final double EARTH_RADIUS = 6371; // 地球半径,单位:千米

    /**
     * 计算两点间距离,返回单位km,有误差
     */
    public static double calculateDistance(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));
        double distance = EARTH_RADIUS * c;
        return distance;
    }

    /**
     * 获取AB连线与正北方向的角度
     *
     * @param A A点的经纬度
     * @param B B点的经纬度
     * @return AB连线与正北方向的角度(0~360)
     */
    public static double getAngle(MyLatLng A, MyLatLng B) {
        double dx = (B.m_RadLo - A.m_RadLo) * A.Ed;
        double dy = (B.m_RadLa - A.m_RadLa) * A.Ec;
        double angle = 0.0;
        angle = Math.atan(Math.abs(dx / dy)) * 180. / Math.PI;
        double dLo = B.m_Longitude - A.m_Longitude;
        double dLa = B.m_Latitude - A.m_Latitude;
        if (dLo > 0 && dLa <= 0) {
            angle = (90. - angle) + 90;
        } else if (dLo <= 0 && dLa < 0) {
            angle = angle + 180.;
        } else if (dLo < 0 && dLa >= 0) {
            angle = (90. - angle) + 270;
        }
        return angle;
    }

    public static class MyLatLng {
        final static double Rc = 6378137;
        final static double Rj = 6356725;
        double m_LoDeg, m_LoMin, m_LoSec;
        double m_LaDeg, m_LaMin, m_LaSec;
        double m_Longitude, m_Latitude;
        double m_RadLo, m_RadLa;
        double Ec;
        double Ed;

        public MyLatLng(double longitude, double latitude) {
            m_LoDeg = (int) longitude;
            m_LoMin = (int) ((longitude - m_LoDeg) * 60);
            m_LoSec = (longitude - m_LoDeg - m_LoMin / 60.) * 3600;

            m_LaDeg = (int) latitude;
            m_LaMin = (int) ((latitude - m_LaDeg) * 60);
            m_LaSec = (latitude - m_LaDeg - m_LaMin / 60.) * 3600;

            m_Longitude = longitude;
            m_Latitude = latitude;
            m_RadLo = longitude * Math.PI / 180.;
            m_RadLa = latitude * Math.PI / 180.;
            Ec = Rj + (Rc - Rj) * (90. - m_Latitude) / 90.;
            Ed = Ec * Math.cos(m_RadLa);
        }
    }
}

关于两点经纬度间距离与正北方向夹角计算原理不懂,还请大佬可以指点下。
得到两点经纬度间距离,与正北方向夹角后,可以在View上进行绘制了。在绘制时候需要注意下,为了方便飞机点绘制,我们在绘制完雷达图的网格后,需要对坐标系进行操作,将View坐标系变为以View中心为原点的数学坐标系(数学坐标系就是y轴正向向上,x轴正方向向右)。这里我是在onDraw()方法中进行操作,代码如下:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //绘制雷达图线
    canvas.drawCircle(centerX, centerY, radiusUnit * CIRCLE_NUM, bgPaint);
    for (int i = 1; i <= CIRCLE_NUM; i++) {
        canvas.drawCircle(centerX, centerY, radiusUnit * i - LINE_WIDTH/2, linePaint);
    }
    canvas.drawLine(centerX, 0, centerX, viewHeight, linePaint);
    canvas.drawLine(0, centerY, viewWidth, centerY, linePaint);

    canvas.save();//画布保存
    //矩阵转换,变为数学坐标系
    canvas.translate(centerX,centerY);
    canvas.rotate(180);
    canvas.scale(-1,1);
    canvas.rotate(degree,0,0);//根据方向角,旋转画布,改变飞机对应位置
    //飞机点绘制
    canvas.drawCircle(pointX,pointY,15,pointPaint);
    canvas.restore();//画布恢复
}

关于左边转换可以分下面几步:
第一步:canvas.translate(centerX,centerY)将坐标系原点由View左上角移动到View中心。
第二步:canvas.rotate(180)顺时针旋转画布,将坐标系变换向上y轴为正,向左x轴为正。
第三步:canvas.scale(-1,1)改变View的缩放比例,这个方法对应为canvas.scale(x,y)。因此代码对应意思就是,对于x轴,方向取反,原来对应坐标值乘以1.0(相当于大小不变),对应y轴,方向不变,原来对应坐标值乘以1.0。总体来说,如果负数方向取反,如果正数,尺寸 * 数值 = 变化后尺寸。经过第三步,坐标系变为向上为y正半轴,向左为x正半轴(及数学坐标系)。

image.png 在View内有下面的代码,MAP_MAX指的是雷达图最大圆半径对应现实距离(单位km),viewWidth是雷达图宽度,viewWidth / 2f就是雷达图最大圆半径。GeoUtils.getAngle()方法获取两经纬度角度。

    private final int MAP_MAX = 5;//雷达图最大显示范围,单位km,指的是雷达图半径对应多少km
    private float length = 0f;
    /** (lon1,lat1)手机经纬度 (lon2,lat2)飞机经纬度*/
    public void setLatLng(double lon1,double lat1, double lon2, double lat2) {
        double distance = GeoUtils.calculateDistance(lon1,lat1,lon2,lat2);
        length = (float) (distance / MAP_MAX * viewWidth / 2f);//按照km为单位换算  一格1km
        GeoUtils.MyLatLng point1 = new GeoUtils.MyLatLng(lon1,lat1);
        GeoUtils.MyLatLng point2 = new GeoUtils.MyLatLng(lon2,lat2);
        float angle = (float) GeoUtils.getAngle(point1,point2);
        pointX = length * (float) Math.sin(angle);
        pointY = length * (float) Math.cos(angle);
        postInvalidate();
    }

获取飞机绘制点的位置原理是,在一个数学坐标系上,将手机的位置放在坐标系原点上,y正半轴为北,x正半轴为东,根据GeoUtils.getAngle()方法获取的角度,以y顺时针开始转动对应角度,之后延伸两经纬度间绘制长度(这个长度指,现实距离对应在地图上绘制的长度length),获取在坐标系中的对应的x,y值,可通过三角函数sin和cos获取。

经过上面的步骤获取到是静态的雷达地图,想要实现手机转动时候,地图跟着转动的话,还需要实现方向角监听器来获取方向角,方向角取值范围[0,359),随着度数增大,方向顺时针旋转。地图转动逻辑如下图: image.png 图中前两个指的是静态的雷达图,最后一个是想要的可转动雷达图。静态的雷达图当手机旋转时候,可以看到地图没有变化,而动态雷达图,当手机旋转时候,地图会随着手机方向角跟着旋转。例如上图中开始手机顶部对准北方,接着手机旋转到东方,此时方向角为90度,图中第二个手机界面向第三个手机界面变换的话,需要地图逆时针旋转90度。对应地图旋转实现,可以使用canvas.rotate(degree,x,y)方法实现,第一个参数是顺时针旋转度数,第二个参数是旋转点x值,第三个参数是旋转点y值。使用canvas.rotate(degree)的话,旋转点是坐标系的原点。

但实际使用会发现,当方向角90度时候,地图要逆时针旋转90度,如果将坐标系转化为数学坐标系后调用canvas.rotate(-90,0,0)。会发现地图不是逆时针旋转而是顺时针。
造成这现象的原因是,在实现将View坐标转换为数学坐标的步骤三中,使用了canvas.scale(-1,1)改变View的缩放比例。使得x方向与原来相反。这个方法改变x的方向。关于这个方法可以写一个View测试一下,用于测试的TestView代码如下:

public class TestView extends View {
    private Paint redPaint, greenPaint, blackPaint;
    public TestView(Context context) {
        super(context);
        init();
    }

    public TestView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public TestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        redPaint = new Paint();
        redPaint.setColor(Color.RED);
        redPaint.setStrokeWidth(5);


        greenPaint = new Paint();
        greenPaint.setColor(Color.GREEN);
        greenPaint.setStrokeWidth(5);

        blackPaint = new Paint();
        blackPaint.setColor(Color.BLACK);
        blackPaint.setStrokeWidth(1);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawColor(Color.WHITE);
        float centerX = getWidth()/2;
        float centerY = getHeight()/2;
        //View坐标系转换为数学坐标系
        canvas.drawLine(0,centerY,getWidth(),centerY, blackPaint);
        canvas.drawLine(centerX,0,centerX,getHeight(), blackPaint);
        canvas.translate(centerX,centerY);
        canvas.rotate(180);
        
        //canvas.scale(-1,1);

        canvas.drawLine(0,0,0,50, redPaint);
        canvas.rotate(30,0,0);
        canvas.drawLine(0,0,0,50, greenPaint);
    }
}

下图中,第一个是注释掉canvas.scale(-1,1)效果,第二个是启用canvas.scale(-1,1)代码后的效果。 image.png 可以看到,没有canvas.scale()时,canvas.rotate(30,0,0)效果是顺时针,当执行canvas.scale(-1,1)后,canvas.rotate(30,0,0)看上去的效果是逆时针旋转30度。
对于这种现象,个人认为是,执行canvas.scale(-1,1)后,canvas.rotate(30,0,0)是顺时针30度(上图左一),但由于canvas.scale(-1,1)让x轴方向取反,让实际线段位置是顺时针旋转30度后按y轴对称绘制出来的(上图左二),这样解释感觉不太好理解,暂时没有更好描述,期待可以和大佬讨论下。

关于雷达地图完整View代码如下,代码中setDegree()方法是根据方向角设置地图旋转角度:

public class RadarView extends View {
    private final int CIRCLE_NUM = 5;//绘制圆个数
    private final int LINE_WIDTH = 4;
    private final int MAP_MAX = 5;//雷达图最大显示范围,单位km,指的是雷达图半径对应多少km
    private Paint linePaint, bgPaint, pointPaint;
    private int defaultWidth = -1, defaultHeight = -1;
    private int viewWidth = -1, viewHeight = -1;
    private int radiusUnit = -1;//圆等分时候,单位半径,及最小圆半径
    private int centerX, centerY;
    private float length = 0f;
    private float pointX = 0f,pointY = 0f;
    private float degree;

    public RadarView(Context context) {
        super(context);
        init(context);
    }

    public RadarView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public RadarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
        linePaint = new Paint();
        linePaint.setColor(Color.parseColor("#919191"));
        linePaint.setStrokeWidth(LINE_WIDTH);
        linePaint.setStyle(Paint.Style.STROKE);

        bgPaint = new Paint();
        bgPaint.setColor(Color.parseColor("#FF666666"));
        bgPaint.setStyle(Paint.Style.FILL);

        pointPaint = new Paint();
        pointPaint.setColor(Color.parseColor("#919191"));
        pointPaint.setStyle(Paint.Style.FILL);

        defaultWidth = DensityUtil.dp2px(context, 200);
        defaultHeight = DensityUtil.dp2px(context, 200);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int width, height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            width = defaultWidth;
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            height = defaultHeight;
        }
        width = Math.min(width, height);
        height = width;
        setMeasuredDimension(width, height);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        viewWidth = w;
        viewHeight = h;
        radiusUnit = viewWidth / 2 / CIRCLE_NUM;
        centerX = viewWidth / 2;
        centerY = viewHeight / 2;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制雷达图线
        canvas.drawCircle(centerX, centerY, radiusUnit * CIRCLE_NUM, bgPaint);
        for (int i = 1; i <= CIRCLE_NUM; i++) {
            canvas.drawCircle(centerX, centerY, radiusUnit * i - LINE_WIDTH/2, linePaint);
        }
        canvas.drawLine(centerX, 0, centerX, viewHeight, linePaint);
        canvas.drawLine(0, centerY, viewWidth, centerY, linePaint);

        canvas.save();
        //变为数学坐标系
        canvas.translate(centerX,centerY);
        canvas.rotate(180);
        canvas.scale(-1,1);
        canvas.rotate(degree,0,0);//手机转动时候,点的位置
        //飞机点绘制
        canvas.drawCircle(pointX,pointY,15,pointPaint);
        canvas.restore();
    }

    /** 点旋转角度 */
    public void setDegree(float degree){
        this.degree = degree;
        postInvalidate();
    }

    /** (lon1,lat1)手机经纬度 (lon2,lat2)飞机经纬度*/
    public void setLatLng(double lon1,double lat1, double lon2, double lat2) {
        double distance = GeoUtils.calculateDistance(lon1,lat1,lon2,lat2);
        length = (float) (distance / MAP_MAX * viewWidth / 2f);//按照km为单位换算  一格1km
        CustomLog.INSTANCE.d("distance:"+distance*1000);
        CustomLog.INSTANCE.d("r:"+ length);
        GeoUtils.MyLatLng point1 = new GeoUtils.MyLatLng(lon1,lat1);
        GeoUtils.MyLatLng point2 = new GeoUtils.MyLatLng(lon2,lat2);
        float angle = (float) GeoUtils.getAngle(point1,point2);
        pointX = length * (float) Math.sin(angle);
        pointY = length * (float) Math.cos(angle);
        postInvalidate();
    }
}

经纬度,方向角获取

下面看下如何获取经纬度与方向角角,期间使用的一些方法需要在,AndroidManifest.xml文件内添加位置权限,添加权限如下:

<!--    精确位置信息权限,通过GPS获取位置信息-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!--    大致位置信息权限,通过WiFi或基站获取位置信息-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

之后在Activity或Fragment内获取经纬度和方向角,先看如何获取经纬度(经纬度,方向角获取完整代码在该小节末尾)。

经纬度

先实现位置管理器,之后在位置监听器里面使用random来随机生成飞机位置(现实中是根据其他方式获取飞机位置的,这里为了模拟效果,使用随机数方式生成无人机位置),在后面对开启和关闭位置服务时候的代码处理(没有特殊处理的话,只需要在位置监听器里面重写onProviderDisabled和onProviderEnabled方法即可),最后在判断GPS与网络定位是否可用,若可以在判断是否授予了精确位置和粗略位置的权限,若满足上述条件,就可以调用locationManager.requestLocationUpdates()方法请求位置更新。相关代码如下:

        locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
        locationListener = object :LocationListener{
            override fun onLocationChanged(location: Location) {
                lat1 = location.latitude
                lon1 = location.longitude
                if (!hasInitialized){
                    val random = Random()
                    var randomValue = 0.02 + random.nextDouble() * (0.02 - 0.01)
//                    var randomValue = 0.0002 + random.nextDouble() * (0.0002 - 0.0001)//测试用  较近范围
                    if(random.nextBoolean()){
                        randomValue = -randomValue
                    }
                    lat2 = lat1 + randomValue
                    if(random.nextBoolean()){
                        randomValue = -randomValue
                    }
                    lon2 = lon1 + randomValue
                    hasInitialized = true
                }
                binding.layoutRadarMap.setLatLng(lon1,lat1,lon2,lat2)
                CustomLog.d("起点经纬度:($lon1,$lat1)")
                CustomLog.d("终点经纬度:($lon2,$lat2)")
//                binding.layoutRadarMap.setLatLng(0.0,0.0,0.0,0.015)//测试用,正北方向点
                locationInfo.lon1 = lon1
                locationInfo.lat1 = lat1
                locationInfo.lon2 = lon2
                locationInfo.lat2 = lat2
                locationInfoLiveData.value = locationInfo
            }

            /** 位置提供者被禁用时调用,例如禁用了 GPS 定位功能,那么该方法就会被触发 如果禁用时没有重写该方法会导致闪退*/
            override fun onProviderDisabled(provider: String) {
                CustomLog.d(TAG,provider)
            }

            /** 位置提供者被启用时调用,之前被禁用的位置提供者(如打开了 GPS 定位功能),该方法就会被触发。如果重新启用时没有重写该方法会导致闪退*/
            override fun onProviderEnabled(provider: String) {
                CustomLog.d(TAG,provider)
            }
        }

        //判断GPS或网络定位是否启用
        if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
            //检查是否授予了访问精确位置和粗略位置的权限
            if (ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
                //请求位置更新,这里使用GPS,最小时间间隔0,最小距离0,及对应的位置监听器
                locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0f, locationListener)
            }
        }

当应用关闭的时候,需要取消监听器,一般是在onStop()或onDestroy()内实现。这里是在onDestroy()实现监听器取消。

override fun onDestroy() {
    super.onDestroy()
    locationManager?.removeUpdates(locationListener)
}

方向角

方向角获取,我是让fragment实现SensorEventListener接口,并重写onSensorChanged和onAccuracyChanged这两个方法,分别获取响应传感器数据变化和精度变化,代码如下:

private var lastDegree:Float = 999f
override fun onSensorChanged(event: SensorEvent?) {
    if (event?.sensor?.type == Sensor.TYPE_ORIENTATION) {
        val degree = event?.values?.get(0) ?:0F
        binding.layoutRadarMap.setDegree(degree)
        locationInfo.degree = degree
        locationInfoLiveData.value = locationInfo
        if (abs(lastDegree) <999f && abs(lastDegree - degree) > 5f){
            val startDegree = degree / 360 * 8f
            binding.layoutRadarMap.setNavDirectionDegree(startDegree)
            lastDegree = degree
        }
        if(lastDegree == 999f){
            lastDegree = degree
        }
    }
}

override fun onAccuracyChanged(p0: Sensor?, p1: Int) {
    // 当传感器精度发生变化时的回调
}

上述代码只对传感器数据进行操作,先判断事件类型是不是方向角传感器传来的,之后通过event?.values?.get(0)获取方向角,locationInfo和locationInfoLiveData仅用于屏幕显示位置数据,可以忽略。lastDegree是用于象征人员箭头轻微颤动的参数,当lastDegree赋值过(abs(lastDegree) <999f),且lastDegree - degree大于一定角度时候会调用binding.layoutRadarMap.setNavDirectionDegree(startDegree)方法让箭头图标轻微颤动。当lastDegree未赋值过(lastDegree == 999f),给lastDegree赋值。

之后在onResume()内声明传感器管理器,及设置传感器类型,并给传感器管理器添加监听(由于fragment实现了传感器监听器SensorEventListener),因此在添加监听器时 sensorManager.registerListener(this, rotationVectorSensor, SensorManager.SENSOR_DELAY_NORMAL),使用this即可,完整代码如下:

//方向角监听器实现
sensorManager = requireContext().getSystemService(Context.SENSOR_SERVICE) as SensorManager
val rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION)
sensorManager.registerListener(this, rotationVectorSensor, SensorManager.SENSOR_DELAY_NORMAL)

同样在页面不可见或销毁的时候,取消监听器,代码如下:

override fun onDestroy() {
    super.onDestroy()
    // 取消注册传感器监听器
    sensorManager.unregisterListener(this)
}

经纬度,方向角获取完整代码

class RadarFragment : BaseBindingViewFragment<FragmentRadarBinding>(R.layout.fragment_radar) ,
    SensorEventListener {
    companion object{
        const val TAG = "RadarFragment"
    }
    private lateinit var sensorManager: SensorManager
    private lateinit var locationManager: LocationManager
    private lateinit var locationListener: LocationListener
    private lateinit var locationInfo: LocationInfo
    private var locationInfoLiveData = MutableLiveData<LocationInfo>()
    private var hasInitialized = false//飞机经纬度是否初始化
    private var lastDegree:Float = 999f
    var lat1:Double = 0.0
    var lon1:Double = 0.0
    var lon2:Double = 0.015
    var lat2:Double = 0.0

    override fun initBinding(layoutInflater: LayoutInflater): FragmentRadarBinding? {
        return FragmentRadarBinding.inflate(layoutInflater)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initView()
    }

    private fun initView() {

        locationInfo = LocationInfo()
        locationInfoLiveData.observe(viewLifecycleOwner){ it ->
            binding.locationInfo = ""+
                "手机经纬度:(${it.lon1}${it.lat1})\n" +
                    "飞机经纬度:(${it.lon2},${it.lat2})\n" +
                    "两点距离(米):${(GeoUtils.calculateDistance(it.lon1, it.lat1, it.lon2, it.lat2)*1000).toInt()}\n"+
                    "与正北方向夹角:${GeoUtils.getAngle(GeoUtils.MyLatLng(it.lon1, it.lat1),GeoUtils.MyLatLng(it.lon2, it.lat2)).toInt()}\n"+
                    "方向角:${it.degree}"
        }
    }

    override fun onResume() {
        super.onResume()
        //地址监听
        locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager
        locationListener = object :LocationListener{
            override fun onLocationChanged(location: Location) {
                lat1 = location.latitude
                lon1 = location.longitude
                if (!hasInitialized){
                    val random = Random()
                    var randomValue = 0.02 + random.nextDouble() * (0.02 - 0.01)
//                    var randomValue = 0.0002 + random.nextDouble() * (0.0002 - 0.0001)//测试用  较近范围
                    if(random.nextBoolean()){
                        randomValue = -randomValue
                    }
                    lat2 = lat1 + randomValue
                    if(random.nextBoolean()){
                        randomValue = -randomValue
                    }
                    lon2 = lon1 + randomValue
                    hasInitialized = true
                }
                binding.layoutRadarMap.setLatLng(lon1,lat1,lon2,lat2)
                CustomLog.d("起点经纬度:($lon1,$lat1)")
                CustomLog.d("终点经纬度:($lon2,$lat2)")
//                binding.layoutRadarMap.setLatLng(0.0,0.0,0.0,0.015)//测试用,正北方向点
                locationInfo.lon1 = lon1
                locationInfo.lat1 = lat1
                locationInfo.lon2 = lon2
                locationInfo.lat2 = lat2
                locationInfoLiveData.value = locationInfo
            }

            /** 位置提供者被禁用时调用,例如禁用了 GPS 定位功能,那么该方法就会被触发 如果禁用时没有重写该方法会导致闪退*/
            override fun onProviderDisabled(provider: String) {
                CustomLog.d(TAG,provider)
            }

            /** 位置提供者被启用时调用,之前被禁用的位置提供者(如打开了 GPS 定位功能),该方法就会被触发。如果重新启用时没有重写该方法会导致闪退*/
            override fun onProviderEnabled(provider: String) {
                CustomLog.d(TAG,provider)
            }
        }

        //判断GPS或网络定位是否启用
        if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
            //检查是否授予了访问精确位置和粗略位置的权限
            if (ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
                //请求位置更新,这里使用GPS,最小时间间隔0,最小距离0,及对应的位置监听器
                locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0f, locationListener)
            }
        }

        //方向角监听器实现
        sensorManager = requireContext().getSystemService(Context.SENSOR_SERVICE) as SensorManager
        val rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION)
        sensorManager.registerListener(this, rotationVectorSensor, SensorManager.SENSOR_DELAY_NORMAL)
    }

    override fun onDestroy() {
        super.onDestroy()
        // 取消注册传感器监听器
        sensorManager.unregisterListener(this)
        locationManager?.removeUpdates(locationListener)
    }

    override fun onSensorChanged(event: SensorEvent?) {
        if (event?.sensor?.type == Sensor.TYPE_ORIENTATION) {
            val degree = event?.values?.get(0) ?:0F
            binding.layoutRadarMap.setDegree(degree)
            locationInfo.degree = degree
            locationInfoLiveData.value = locationInfo
            if (abs(lastDegree) <999f && abs(lastDegree - degree) > 5f){
                val startDegree = degree / 360 * 8f
                binding.layoutRadarMap.setNavDirectionDegree(startDegree)
                lastDegree = degree
            }
            if(lastDegree == 999f){
                lastDegree = degree
            }
        }
    }

    override fun onAccuracyChanged(p0: Sensor?, p1: Int) {
        // 当传感器精度发生变化时的回调
    }
}

总结

1.原生获取经纬度的方式,获取经纬度刷新频率不是很高,在我的手机上测试可能2s甚至更长才更新一次。并且获取的经纬度有时候误差比较大,最大时候有几十米的误差。用于实际项目时应该使用第三方SDK获取,比如百度地图。
2.方向角获取当前使用的是方向角监听器的方式实现,目前该方式已过时,官方推荐使用加速度传感器和磁场传感器的组合来实现方向角监听,这里为了简单实现使用方向传感器获取,后面学习下加速度传感器和磁场传感器使用来替换掉。
3.canvas.scale(-1,1),导致画布旋转时候,给人感觉是逆时针旋转,关于这部分的解释,感觉解释的不到位,期待能在评论区中能和大佬交流一下。

代码地址

GitHub