ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android GNSS卫星星空图(Sky Plot)绘制实战:从数据到自定义View

Android GNSS卫星星空图(Sky Plot)绘制实战:从数据到自定义View 1. 为什么要画一张“天顶图”先想清楚再动手在Android上做GNSS定位相关开发最怕的就是“拿着手机不知道天上是什么情况”。你辛辛苦苦调了一上午算法定位精度就是上不去这时候如果能直接看到当前天空上有多少颗可见卫星、它们分布在什么方位、信号强度如何、哪些正在参与解算很多问题一眼就能定位是卫星几何分布太差、是某个方向的信号被遮挡、还是某个频点根本没收到星。这其实就是卫星星空图Satellite Sky Plot的核心价值。业内也叫“卫星天顶图”或者“天空图”它把三维天空中的卫星位置用极坐标投影到一张二维平面上X轴是方位角Azimuth半径是仰角Elevation圆心就是接收机头顶正上方。你拿着这张图等于“上帝视角”俯视整个天空卫星在哪个方向、多高、信号弱不弱一目了然。在Android端这件事的开销其实非常低。Android系统从API 24Android 7.0开始提供GnssStatus类通过LocationManager.registerGnssStatusCallback()可以拿到实时卫星状态包含卫星的方位角Azimuth、仰角Elevation、载波频率CarrierFrequencyHz、信号强度Cn0DbHz和参与解算的标志位usedInFix。拿到这三个核心数值方位角、仰角、信噪比你就能在自定义View里完整复现专业GNSS接收机上的那种星空图。这篇文章我打算把整套东西掰开揉碎来讲从Android侧卫星数据的获取链路到极坐标投影的数学原理再到自定义View里具体怎么画、怎么优化性能、怎么处理小米/华为等厂商的兼容性差异。如果你正准备给自己的定位App加一个“卫星状态可视化”页面或者纯粹想搞懂Sky Plot背后的原理这篇文章应该能帮你少踩不少坑。2. Android侧卫星数据的获取与坑2.1 GnssStatus回调链路从系统服务到你手上Android的GNSS卫星数据获取链路是固定的一套流程核心入口是LocationManager。你需要先在AndroidManifest.xml里声明位置权限——注意Android 10API 29以后不能只声明ACCESS_FINE_LOCATION还要在代码里动态申请并且在设置中开启定位服务否则registerGnssStatusCallback可能直接不回调或者抛出SecurityException。基本的数据获取代码结构如下LocationManager locationManager (LocationManager) getSystemService(Context.LOCATION_SERVICE); if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { // 动态申请权限 return; } GnssStatus.Callback gnssStatusCallback new GnssStatus.Callback() { Override public void onSatelliteStatusChanged(GnssStatus status) { // 拿到实时的卫星状态 for (int i 0; i status.getSatelliteCount(); i) { float azimuth status.getAzimuthDegrees(i); // 方位角 0~360 float elevation status.getElevationDegrees(i); // 仰角 0~90 float cn0 status.getCn0DbHz(i); // 载噪比 dBHz boolean usedInFix status.usedInFix(i); // 是否参与定位解算 int constellation status.getConstellationType(i); // 星座类型 } } }; locationManager.registerGnssStatusCallback(gnssStatusCallback, new Handler(Looper.getMainLooper()));这里一定要留意整个回调在Android 7.0之前是GpsStatus.Listener回调方法是onGpsStatusChanged(int event)你需要在event为GpsStatus.GPS_EVENT_SATELLITE_STATUS时调用locationManager.getGpsStatus(null)再去遍历gpsStatus.getSatellites()。这段老代码虽然已经过时但如果你要兼容Android 6.0以下的设备还是得留着。我在实际项目里是用一个抽象接口把新旧两套API统一封装掉了业务层只管拿数据底层自己适配。2.2 卫星数据里的“隐藏字段”载波频率与星座类型GnssStatus里除了方位角和仰角还有两个很容易被忽略但非常有用的字段getCarrierFrequencyHz()和getConstellationType()。getCarrierFrequencyHz()返回的是当前卫星信号的载波频率单位Hz。比如GPS L1是1575.42MHz北斗B1I是1561.098MHzGalileo E1是1575.42MHz。这个字段存在的意义在于同一颗卫星可能同时发射多个频点的信号同一个星座下也有不同频点的卫星在使用你要过滤出“当前我这部手机实际收到了哪些频点”的信号就得靠它。有的机型L5频段接收能力弱你在图上就会看到1575.42MHz的卫星很多但1176.45MHzL5的寥寥无几这种信息对分析定位精度非常有价值。getConstellationType()返回的是星座类型常量常量值星座CONSTELLATION_GPS1GPSCONSTELLATION_SBAS2SBAS星基增强CONSTELLATION_GLONASS3GLONASSCONSTELLATION_QZSS4QZSS日本准天顶CONSTELLATION_BEIDOU5北斗CONSTELLATION_GALILEO6GalileoCONSTELLATION_IRNSS7IRNSS印度区域导航在星空图里我强烈建议根据不同星座用不同颜色的圆点否则混在一起就是一团乱麻。默认的配色方案可以参考市面上专业测试工具的做法GPS用绿色、GLONASS用蓝色、北斗用红色、Galileo用黄色、QZSS用橙色、SBAS用紫色。这样用户一眼就能分辨当前主要收到的是哪个系统的卫星在多系统融合定位的时代这几乎是可视化页面的标配能力。2.3 厂商兼容性小米、华为、三星的“非标准”行为这块我踩过不少坑。Android原生GnssStatus接口的语义是统一的但厂商在底层实现上并不完全一致具体表现有几种部分机型不会回调onSatelliteStatusChanged常见于一些ROM做了省电策略如果你没有先拿到一次定位onLocationChangedGNSS芯片可能根本没启动回调自然不来。解决方法是注册回调后主动调一次locationManager.requestLocationUpdates()拿到一个粗略定位后就移除让芯片跑起来。getCarrierFrequencyHz()返回0在老一些的机型上这个字段可能没有填充返回0。画图时你需要做判断频率为0的卫星可以不按L1/L5区分直接归到“未知频点”一类。usedInFix标志不稳定在部分华为和荣耀机型上usedInFix在卫星刚捕获还没锁定的时候经常跳变导致图上“正在参与解算”的圆点闪来闪去。我最后的处理方案是加一个“软状态”连续3次回调约3秒都标记为true才认为真正参与解算连续3次false才认为脱出用滞后判断滤掉抖动。还有一个通用建议回调频率不要设太高。registerGnssStatusCallback在部分机型上最快也只能达到1秒一次你就算疯狂注册也拿不到更高频的数据反而因为回调在Binder线程频繁触发会导致UI卡顿。我推荐在回调里只做数据拷贝然后用runOnUiThread或者postInvalidateOnAnimation通知View重绘不要在回调线程里直接操作任何View对象。3. 极坐标投影原理方位角和仰角是怎么变成平面坐标的3.1 从经纬球面到平面的数学映射卫星在天空中的位置本质上是一个球面坐标方位角azimuth0~360度北为0顺时针增加和仰角elevation0~90度水平为0天顶为90度。星空图要做的就是把这个球面坐标投影到二维平面。最常用的投影方式是“等距方位投影”Azimuthal Equidistant Projection但在卫星星空图场景里大家实际上用的是更简单的一版直接以仰角作线性半径映射。公式非常简单r (90 - elevation) / 90 * R x cx r * sin(azimuth * PI / 180) y cy - r * cos(azimuth * PI / 180)其中R是画布上天顶图的外圈半径(cx, cy)是画布中心点。y方向要取负号因为屏幕坐标系y轴向下而方位角定义是北为0、顺时针增加相当于“从北向东转”在屏幕上是向右转所以x sin(azimuth)y -cos(azimuth)。这个投影的逻辑很直观仰角90度的卫星在圆心仰角0度的卫星在外圈仰角45度的卫星在半径一半的位置。为什么不用真正的等距方位投影因为卫星星空图关心的是“方位和高度分布”的可读性线性仰角映射已经足够直观而且软件实现简单、抗锯齿效果好。专业GNSS接收机比如u-blox的u-center、Septentrio的RxTools里默认也是这种画法。3.2 仰角圆环的标定与刻度天顶图通常要画三个同心圆分别代表仰角90度、45度、0度。以R为外圈半径则45度圆的半径是R/290度是圆心点。除了圆环还要画方位角的十字线北、东、南、西四条径向线和方位角刻度每30度一个标记。这里有一个细节值得说北向不是随便定的它必须是“真北”而不是“磁北”。手机里的Azimuth角度在GNSS协议层面是基于WGS-84坐标系下的真北方向不是地磁北极。所以你在图上标“N”的时候要明确告诉用户这是True North否则有人拿指南针对比会以为你的图画错了。如果设备本身携带了电子罗盘你还可以加一个简单的旋转逻辑根据设备当前朝向旋转整个天空图让图上北方向始终对准物理世界的北方。这个功能对“拿着手机对比天空找卫星”的场景非常实用。实现起来就是给整个画布加一个rotate()变换旋转角度等于设备朝向的反向。3.3 信噪比CN0和颜色映射如何一眼看出信号好坏光画位置还不够星空图的核心价值之一是“信号质量可视化”。在圆点旁边标一个数字比如“CN0 35dBHz”是最朴素的做法但一屏十几颗星全标数字图面会非常拥挤。更好的方案是“位置半径/颜色双编码”。以信噪比CN0为例我常用的映射方案是CN0范围颜色含义≥ 40 dBHz亮绿色信号很好30 ~ 40 dBHz黄色信号一般20 ~ 30 dBHz橙色信号较弱 20 dBHz红色信号极差/不可用 0灰色卫星被跟踪但信号无效在实现上颜色映射可以用一个简单的分段函数也可以用HSV连续色相变化从绿色120度到红色0度。我建议用分段连续结合把CN0在20~40之间做线性插值色相从0红到120绿渐变同时再把圆点半径也随CN0缩放比如半径从2dp到8dp线性映射。这样即使色弱用户也能通过点的大小感知信号强弱。圆点的大小还要区分“参与解算”和“未参与解算”。我习惯用实心圆表示usedInFixtrue空心圆表示usedInFixfalse。这样图上一眼就能看出当前解算使用了哪几颗星以及这些星的分布是否包围了用户位置上方。4. 自定义View的实现从0到1画出一张可用星空图4.1 View整体架构与测量自定义View我建议继承View而不是SurfaceView因为卫星状态回调频率不高1Hz普通View的就地重绘完全够用不需要引入SurfaceView的线程和同步复杂度。架构上分成三个层次背景层画外圈圆、内圈圆、方位十字线、30度刻度标记、星座图例。卫星层遍历卫星数组每个卫星画一个圆点或方点根据星座定颜色、CN0定颜色深浅和大小、usedInFix定实心/空心。信息层画卫星PRN编号文本、当前可见卫星总数、参与解算数、平均CN0等信息。在onMeasure阶段建议把View宽高等比约束取min(width, height)作为画布边长中心点设为边长一半。外圈半径R设为边长/2 - paddingpadding至少留16dp给文本和刻度留空间。4.2 画网格和方位刻度网格部分的代码比较简单Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cx getWidth() / 2f; float cy getHeight() / 2f; float R Math.min(cx, cy) - padding; // 画三个同心圆仰角0度、45度、90度 Paint ringPaint new Paint(Paint.ANTI_ALIAS_FLAG); ringPaint.setStyle(Paint.Style.STROKE); ringPaint.setStrokeWidth(dp2px(1)); ringPaint.setColor(0xFFCCCCCC); canvas.drawCircle(cx, cy, R, ringPaint); canvas.drawCircle(cx, cy, R / 2, ringPaint); // 90度是圆心可以用一个很小的点表示 // 画方位十字线和45度斜线 canvas.drawLine(cx - R, cy, cx R, cy, ringPaint); // 东西线 canvas.drawLine(cx, cy - R, cx, cy R, ringPaint); // 南北线 canvas.drawLine(cx - R * 0.707f, cy - R * 0.707f, cx R * 0.707f, cy R * 0.707f, ringPaint); canvas.drawLine(cx - R * 0.707f, cy R * 0.707f, cx R * 0.707f, cy - R * 0.707f, ringPaint); // 方位文字标注N、E、S、W 以及 30度刻度短横线 Paint textPaint new Paint(Paint.ANTI_ALIAS_FLAG); textPaint.setTextSize(sp2px(12)); textPaint.setTextAlign(Paint.Align.CENTER); textPaint.setColor(0xFF333333); canvas.drawText(N, cx, cy - R - dp2px(6), textPaint); canvas.drawText(E, cx R dp2px(12), cy dp2px(4), textPaint); canvas.drawText(S, cx, cy R dp2px(16), textPaint); canvas.drawText(W, cx - R - dp2px(12), cy dp2px(4), textPaint); // 30度方位刻度线 for (int angle 0; angle 360; angle 30) { double rad Math.toRadians(angle); float x1 cx (R - dp2px(8)) * (float) Math.sin(rad); float y1 cy - (R - dp2px(8)) * (float) Math.cos(rad); float x2 cx R * (float) Math.sin(rad); float y2 cy - R * (float) Math.cos(rad); canvas.drawLine(x1, y1, x2, y2, ringPaint); } }方向逻辑再强调一遍屏幕坐标系y轴向下所以“北”对应的屏幕y坐标是cy - R上方cos(azimuth)要取负号才能得到正确的y值很多新手在这里容易懵。我最早实现的时候忘了加负号结果所有卫星都画到了南半球。4.3 卫星点绘制与PRN标注卫星点的绘制是整个View的核心private void drawSatellites(Canvas canvas, float cx, float cy, float R) { if (satelliteList null) return; for (SatelliteData sat : satelliteList) { // 投影 double rad Math.toRadians(sat.azimuth); float r (90f - sat.elevation) / 90f * R; float x cx r * (float) Math.sin(rad); float y cy - r * (float) Math.cos(rad); // 颜色 Paint dotPaint new Paint(Paint.ANTI_ALIAS_FLAG); dotPaint.setStyle(sat.usedInFix ? Paint.Style.FILL : Paint.Style.STROKE); dotPaint.setColor(getColorByCn0(sat.cn0)); dotPaint.setStrokeWidth(dp2px(2)); float radius dp2px(4) sat.cn0 / 40f * dp2px(4); // 半径随CN0放大 canvas.drawCircle(x, y, radius, dotPaint); } }PRN编号卫星号我建议标注在圆点旁边偏移一点的位置canvas.drawText(String.valueOf(sat.prn), x dp2px(8), y - dp2px(8), textPaint);注意PRN编号不同星座会有重复比如GPS的PRN 1~32和北斗的PRN 1~37都可能存在单标数字可能混淆最好是组合显示例如G1、B1、R1这样加前缀。星座前缀映射GPSGGLONASSR北斗BGalileoEQZSSQSBASSIRNSSI标注字体不要太大8~10sp即可并且要在绘制文字前通过Paint.setTypeface设置一个中等的字重太粗的字会盖住星星点。4.4 性能优化经验Paint复用与避免内存抖动这里有一个“看起来微不足道但实际影响很大”的点不要把Paint对象放在onDraw里循环创建。我在调试中发现如果每画一颗卫星就new Paint()虽然1Hz频率下勉强不卡但在内存抖动上非常难看GC频繁触发会导致掉帧。正确做法是把Paint设置成View的成员变量或类静态变量onDraw里只用setColor()、setStyle()改变属性。再有就是satelliteList的更新要避免在主线程里直接改引用。GNSS回调线程通过postInvalidateOnAnimation()通知重绘时View可能正在绘制直接改List会引发ConcurrentModificationException。我习惯的做法是在回调线程里把数据拷贝到一个volatile引用指向的数组onDraw开始先获取当前引用再遍历这样既不用加锁也不会并发崩溃。4.5 状态角计的弧线绘制要不要画“可用卫星数不够”的提示如果当前可见卫星数很少比如在室内、隧道、高楼峡谷星空图就是空荡荡的一片。这时候直接画空图会让人误以为是程序bug。我建议在图上叠加一个半透明的提示图层文字类似“可见卫星数过少定位精度可能下降”同时在信息面板里显示当前可见数和参与解算数。如果再做深一点可以画“DOP值”的变化曲线。但这就超出星空图本身的范围了属于另一个View的职责。在星空图里我只会在底部加一行小字显示“当前位置上方可见卫星N颗参与解算M颗”把判断交给用户。5. 实测效果与避坑指南5.1 一个实际的演示场景我在一台Pixel 6上实测这颗SoC的GNSS能力比较典型在空旷楼顶走到窗边再回到室内整个过程的星空图变化会非常有意思室外开阔处可见卫星约20~32颗GPS、Galileo、北斗、GLONASS都有单颗CN0普遍在35~45dBHz三星座以上同时参与解算。窗边可见卫星数掉到10~15颗东南方向如果窗户朝南有一颗比较亮的GPS L1信号可以到42dBHz但西北方向的卫星几乎全部消失。室内靠窗1米可见6~8颗CN0普遍降到25dBHz以下usedInFix标志频繁跳变星空图上会出现大面积的空心圆点。这个变化过程用星空图可视化后对测试人员理解“环境遮挡对定位的影响”非常直观。我们内部做定位算法评估时也常拿星空图和RTKLIB的Sky Plot在同一个时段做对比验证Android端拿到的星历和方位仰角没有异常。5.2 常见问题排查为什么我的图上卫星总在同一个位置不动如果你发现卫星点几乎不动或者所有卫星都集中在某一小片区域大概率是数据源问题而不是绘图问题。排查思路打印原始azimuth和elevation值看是否有变化。如果数值一直不变说明GnssStatus回调返回的是缓存数据尝试杀掉定位服务、重启App。检查是否开了“模拟位置”或者开发者选项里的“位置模拟”模拟定位下卫星数据会被伪造。部分设备在省电模式下GNSS定位频率会大幅降低卫星状态更新可能十几秒才一次路径上看起来像“跳变”。去设置里把App的电池优化改为“不限制”。5.3 数据过滤与平滑不要直接渲染原始值原始GnssStatus数据有一个特点卫星方位角和仰角在刚捕获阶段并不稳定尤其是低仰角卫星仰角10度多径效应和信号反射会让方位跳变10~20度。如果直接渲染圆点会“抖”得厉害。我在实际项目里加了一个很简单的低通滤波smoothAzimuth smoothAzimuth (rawAzimuth - smoothAzimuth) * 0.3f;但方位角涉及0/360跨越不能直接做这种线性平滑——比如一颗卫星方位从355度平滑到5度线性插值会从355经360到5其实是绕了360度一大圈。正确做法是先把差值归一化到[-180, 180]之间再算float diff rawAzimuth - smoothAzimuth; if (diff 180) diff - 360; if (diff -180) diff 360; smoothAzimuth diff * 0.3f; // 归一化到0~360 if (smoothAzimuth 0) smoothAzimuth 360; if (smoothAzimuth 360) smoothAzimuth - 360;这个细节不处理图上的卫星在0度附近会画出诡异的长线。仰角平滑则不需要这种处理直接线性低通即可。5.4 关于“星空图”和“卫星列表”两种视图的取舍星空图不是万能的。当卫星数量超过25颗时圆点和PRN文字在图面上会互相重叠可读性直线下降。这时候我强烈建议在同一页面提供两种视图切换一种是本文讲的星空图另一种是传统的表格列表——按“星座/PRN/仰角/方位角/CN0/是否参与解算”展示。列表视图实现成本很低就是一个RecyclerView但在测试调试场景里价值极高。星空图适合“看分布”列表适合“看明细”两者互为补充。6. 进阶思路在星空图上叠加更多维度6.1 北斗GEO/IGSO/MEO区分显示如果你经常在国内测试建议在星空图上把北斗卫星的三种轨道类型区分开——GEO卫星赤道上空固定位置仰角基本不变在星空图上会表现为“不动点”IGSO是“字轨迹”MEO则是“划过天空”。如何区分它们简单粗暴的方法是查星历里的轨道参数更实用的方法是看卫星PRN号北斗GEO一般是PRN 1~5部分新的也是GEOIGSO是PRN 6~10、13~16MEO是PRN 11~12、17~37。这个编码规则虽然历史上调整过但当前主流的组网卫星大致遵循。我在图上加了一个小的“轨道类型”图例用户能直观看到当前有几颗GEO在头顶对高精度静态测量场景特别有用。6.2 天空遮挡图Sky Mask叠加接收机测试里还有一张图叫“天空遮挡图”它是在某个固定的场地比如楼顶天线安装点预先测量出四周建筑遮挡的方位/仰角范围然后画在星空图上作为灰色扇形区域。这样你现在看到每一颗卫星就知道它是否处于遮挡区域。Android端可以把这个遮罩数据做成配置文件第一次进场地时手动标注点几个点连成一个扇形存到本地之后每次打开星空图自动叠加。这个功能在固定监测站、车载天线安装验证中非常实用。6.3 和“载噪比直方图”联动星空图画出卫星位置和CN0但如果你想精确比较多颗卫星的CN0差异可以直接在点击某颗卫星时弹出一个mini直方图横轴是时间最近1~5分钟纵轴是CN0看这个卫星的信号稳定性。Android的GnssStatus.Callback能持续1Hz回调累积几分钟的数据完全够用。我自己的做法是维护一个LinkedHashMap卫星标识, ListCN0采样每颗星最多保存300个点5分钟超出就丢最旧的。6.4 不用等回调也能画的离线图很多人不知道Android在API 24之后还可以通过LocationManager.getGnssHardwareModel()拿硬件型号通过GnssMeasurementsEvent拿原始观测值。如果你熟悉RINEX格式甚至可以把原始观测值伪距、载波相位、多普勒导出成RINEX文件再用RTKLIB在PC端画出更专业的Sky Plot。Android端实时画图只是一个“快速预览”工具真要用于科学分析数据记录功能比画图本身更有价值。7. 写在最后的调试建议如果你是从零开始做这个功能我的建议是别急着美化UI先把核心链路跑通用adb shell dumpsys location确认定位服务有没有启动。在手机上装一个GPS Status Toolbox之类的参考App先确认这颗手机在当前位置大概能收到多少颗星再对比自己App的数据是否一致。调试时把azimuth/elevation/cn0/usedInFix全量打印到Logcat和绘图结果对照千万不要只看图判断逻辑对错。开一个低版本的Android 7.0模拟器测一下老API路径真的很多人会忽略GpsStatus的兼容性问题上线后炸锅。这个星空图功能本身不复杂但它牵扯到的数据链路、坐标转换、UI绘制和厂商兼容性每一项都值得单独验证。把基础打扎实了后面叠加任何高级功能都会顺畅很多。我最后想多说一句测试时尽量在真实开阔环境验证别指望在办公室窗边就能看到一颗完美的星座图——卫星分布会诚实地告诉你你当前所处的位置到底适不适合做高精度定位。
返回列表