
1. 卫星星空图到底是个啥能用来干什么做Android定位开发的朋友肯定都见过这样一张图一个圆形区域里面画着一圈一圈的同心圆中间还交叉着几条线圆里面散落着几个带颜色的小圆点旁边配着GPS、北斗这样的标签。这就是GNSS可视卫星星空图行业中一般叫satellite sky plot或者卫星天顶图。这张图说简单点就是把当前设备能看到的导航卫星按照它们在天空中的方位投影到一张平面图上。以你所在的位置为球心抬头往上看天顶方向是圆的中心四周地平线是圆的外沿。卫星离天顶越近画出来的点就越靠近圆心离地平线越近就越靠外圈。至于点在圆的哪个方向上就看卫星在你正北、东北、正东还是其他方位。我最早接触这玩意儿是做车载高精度定位项目现场工程师调试天线时总对着后台日志看原始十六进制帧看得头大。后来我去他们那边写了个简单页面把可视卫星实时画成天顶图天线稍微挪个位置、线缆哪怕松动一下图上立刻能看出来哪些卫星丢了、信噪比掉到多少、是不是整个天空半边直接空了。这种直观程度是任何表格日志都比不了的。你如果做的是这些方向大概率迟早要画这张图手持RTK/测量测绘终端外业人员需要快速判断当前卫星几何分布好不好有没有遮挡能不能固定解。车载导航/行车记录仪需要评估天线安装位置检测遮挡情况或者在用户界面里展示定位质量。手机定位测试工具做室内外定位、多系统融合定位的测试需要一个可视化页面来看搜星结果。AGNSS/DR组合定位方案调试需要同时观察原始GNSS数据和推算位置星空图是辅助排查的重要手段。无人机、无人车地面站飞手要在起飞前快速确认卫星数和DOP值这类图几乎是标配。这篇文章就把这张图的原理和Android端画法完整拆一遍。原理部分会讲到坐标怎么换算画法部分会给出可直接用的自定义View代码和踩过的坑。不管你是刚接触Android定位的新人还是被车载GNSS项目折磨过的老手看完都能照着画一张自己的星空图出来。2. 原理拆解从卫星位置到屏幕坐标2.1 GNSS定位的最小闭环先说清楚卫星是怎么被看见的要画一张正确的星空图得先理解一件事卫星的位置不是直接测量出来的而是算出来的然后你才能在脑子里把天上那颗卫星映射到纸上的一个点。每颗GNSS卫星——不管是美国的GPS、中国的北斗、俄罗斯的GLONASS还是欧洲的Galileo——不断地向地面广播自己的星历参数。接收机拿到至少4颗卫星的星历和测距信号之后就能解算出每颗卫星在地心地固坐标系ECEF中的三维坐标接收机自己在地球上的三维坐标经纬度和高度。有了这两个坐标下一步就是把卫星坐标从地心坐标系转换到以接收机为原点的站心坐标系这样卫星在你眼里才有明确的天空方向。这个方向用两个角度描述就是星空图的两个轴仰角Elevation从地平线往天顶抬头仰望的角度地平线是0°天顶是90°。方位角Azimuth从正北方向开始沿顺时针方向旋转到目标点的水平角度正北是0°正东是90°正南是180°正西是270°。具体计算过程大概是这样的接收机经纬度记为(lon, lat)卫星在地心坐标系的位置为(x_sat, y_sat, z_sat)先把用户位置也转换到ECEF坐标然后做一次坐标旋转得到卫星在站心坐标系ENU东、北、天下的分量(E, N, U)。然后仰角 atan2(U, sqrt(E² N²))方位角 atan2(E, N)这里Elevation的感知特别直观仰角太低的卫星信号穿过大气层的路径长多路径效应严重精度差仰角高的卫星信号质量好。所以一般接收机都有个截止仰角mask angle设置默认5°或者10°以下的卫星根本不会用于定位解算但星空图里通常会全部画出来用颜色区分哪些是有效卫星哪些只是可见但不用。2.2 在Android上你根本不需自己算位置这里要插一个很多人都不知道的好消息在Android上写星空图不需要自己去实现上面那套星历解算。Android系统从API 24Android 7.0开始提供了GnssStatus.Callback接口系统已经把每一颗可视卫星的方位角、仰角、载波频率、星座类型全部算好了你直接读取就行。再早一点的版本用Location.getSatellites()也能拿到相似数据只是效率和实时性差一些。所以应用层要做的事情其实只有三件从系统接口拿到每颗卫星的elevation和azimuth把这两个球面角度换算成屏幕上圆图里的x和y根据星座类型、信噪比、是否用于定位等条件给不同的卫星画不同的样式。这也是为什么我强烈建议你把数据层和绘制层分开。数据层负责对接GnssStatus.Callback把卫星快照保存到一个统一的数据模型里绘制层只管拿这个快照去画图。这样以后想加数据源比如接外部GNSS接收机通过蓝牙传NMEA或者换绘制方式比如从自定义View改成Compose都不用动核心逻辑。2.3 天顶图投影把球面拍平的关键公式现在到了最核心的地方。我们有一个半径R的圆圆心代表天顶仰角90°外圈代表地平线仰角0°。把卫星投影上去的公式其实极其简单半径方向卫星距离圆心的远近由仰角决定。r (90 - elevation) / 90 * R为什么是90减elevation因为天顶在图的中心。仰角是90°时r等于0点在圆心仰角是0°时r等于R点在最外圈。这个映射是线性的最简单也最常用。有少数专业软件会用等面积投影或者其他非线性映射但对Android端展示来说线性映射完全够用。圆周方向卫星落在圆上哪个角由方位角决定。rad Math.toRadians(azimuth)x centerX r * Math.sin(rad)y centerY - r * Math.cos(rad)x轴公式里的sin和y轴公式里的cos为什么是这样搭配因为方位角是从北开始顺时针计算的。在屏幕坐标系里x轴向右y轴向下正北方向对应的是屏幕上的上也就是y减小方向。把方位角转成弧度后可以用极坐标公式换算但要注意屏幕y轴朝下所以直接套标准公式会导致图形上下翻转需要在y分量前面取负号。上面给的写法是实践验证过的坐标画出来之后正北就指向屏幕正上方。这个映射理解之后剩下的全是工程问题参考圆怎么画、网格怎么画、卫星点怎么标、文字怎么排、图怎么刷新。下面一节一节说。3. Android端数据获取拿什么喂给这张图3.1 用GnssStatus.Callback还是getSatellites先说结论新项目一律用GnssStatus.Callback不要再碰Location.getSatellites()。getSatellites()有两个硬伤。第一它依赖Location对象更新而Location的更新频率和GNSS卫星状态刷新并不同步有时候定位已经变了卫星列表还是旧的第二它拿到的GpsSatellite对象里面方位角和仰角虽然也有但数据字段陈旧很多国产手机上拿到的值根本不准。GnssStatus.Callback从Android 7.0开始就是官方推荐的卫星状态监听方式它的回调时机和GNSS引擎的状态是绑定的卫星刚被捕获、刚参与定位、刚消失系统都会及时通知应用。用法也很简单GnssStatus.Callback callback new GnssStatus.Callback() { Override public void onSatelliteStatusChanged(GnssStatus status) { // 在这个回调里解析卫星数据 } }; gnssManager.registerGnssStatusCallback(callback, new Handler(Looper.getMainLooper()));需要注意的是API 30开始registerGnssStatusCallback多了一个指定Executor的重载方法。如果用的TargetSdk比较高建议直接传入一个后台线程的Executor不要把数据解析放在主线程。绘制倒是无所谓最终抛给UI线程即可。3.2 解析卫星状态数据结构拿到GnssStatus对象之后遍历每一颗卫星。需要重点关注这几个字段字段方法说明方位角getAzimuthDegrees()0到360度正北为0仰角getElevationDegrees()0到90度天顶为90载波频率getCarrierFrequencyHz()L1是1575.42MHzL5是1176.45MHz多频可以区分星座类型getConstellationType()逐个判断GPS、GLONASS、Galileo、北斗等信噪比getCn0DbHz()一般10-50之间越高越好是否用于定位usedInFix()参与定位解算的卫星通常要特别标出来星历是否有效hasEphemerisData()没有星历的卫星不能用来定位是否增强hasAlmanacData()/hasCarrierFrequencyHz()辅助判断卫星状态把这些字段包到一个数据类里比如叫SatelliteUiModel然后整个列表作为一帧卫星快照传给绘制层。这里有一个关键点回调频率可能非常高尤其是芯片性能强的手机一秒回调十几次都很正常。如果每次回调都触发invalidate()重绘会白白耗电甚至导致掉帧。务实做法是限流比如用一个Handler做节流500毫秒到1秒刷新一次UI就够了人眼根本分辨不出10Hz和1Hz在星空图上的差别。4. 画法落地自定义View绘制卫星星空图4.1 整体绘制架构绘制层我用一个自定义View来做继承View在onDraw里分三层画背景层天空背景色可以纯色也可以加渐变表示上半球天空。网格层最外圈的大圆地平线、43°仰角辅助圆有些工具会画几个固定仰角环、十字交叉的两个直径方向以及每30°一条的方位角射线。卫星层每个卫星画一个小圆点旁边标注星座名称和信噪比同时根据该卫星是否参与定位决定要不要加一圈高亮描边。图层分离的好处是网格只需要在View大小变化时重建一次不用每帧都算卫星层每帧变化但只更新需要更新的部分。如果图很复杂再考虑用SurfaceView但星空图这种量级普通自定义View完全够用。4.2 核心坐标转换代码直接贴出来下面这个skyPlotPoint()方法是整个自定义View里最核心的工具函数把仰角和方位角转成屏幕上的PointFprivate PointF skyPlotPoint(float elevationDegrees, float azimuthDegrees, float radius, float centerX, float centerY) { // 仰角映射到半径天顶90度 - 圆心地平线0度 - 外圈 float r (90f - elevationDegrees) / 90f * radius; // 方位角从正北开始顺时针计算转成弧度 double rad Math.toRadians(azimuthDegrees); // 注意y方向取负因为屏幕坐标系y轴向下 float x centerX r * (float) Math.sin(rad); float y centerY - r * (float) Math.cos(rad); return new PointF(x, y); }这段代码看着简单但它是整张图正确性的基石。我见过不少同事画出来星空图上下颠倒、或者卫星点全部聚到圆心的基本都是这里出问题。顺便说一句有些实现喜欢把方位角先减去90度再转坐标那是在标准数学极坐标公式下为了适配北向做的笨办法。直接用上面的公式简单且不容易错。4.3 参考圆与网格绘制的细节网格层决定了图看起来专不专业。我习惯画这些东西外圈大圆用Paint.Style.STROKE线宽两个像素颜色用白色半透明。这代表地平圈。内侧辅助圆通常在仰角45°和80°位置画两个虚线圆让用户快速判断卫星高度。45°圆对应半径R/280°圆对应半径R/9。十字线从圆心到正北、正东、正南、正西画四条线方便读方位角。正北方向我画一条颜色更亮的线并标注N字母这是看图时最重要的参考方向。方位角刻度每30°画一条短刻度线方向上对准对应的角度。字体大小和颜色要调好。星空图一般背景是深色的卫星点是亮色的家长里短那种浅色调在这里不适合。深蓝色或黑色的背景配合白色网格卫星点用星座专属颜色出来的图在强光下也能看清。以下是一个初始化画笔和绘制外圈、45°圈的代码片段private void initPaints() { gridPaint new Paint(Paint.ANTI_ALIAS_FLAG); gridPaint.setStyle(Paint.Style.STROKE); gridPaint.setStrokeWidth(2); gridPaint.setColor(Color.parseColor(#A0FFFFFF)); gridDashedPaint new Paint(Paint.ANTI_ALIAS_FLAG); gridDashedPaint.setStyle(Paint.Style.STROKE); gridDashedPaint.setStrokeWidth(1.5f); gridDashedPaint.setColor(Color.parseColor(#60FFFFFF)); gridDashedPaint.setPathEffect(new DashPathEffect(new float[]{8f, 8f}, 0f)); } Override protected void onDraw(Canvas canvas) { // 先画网格再画卫星 drawGrid(canvas); drawSatellites(canvas); }网格的具体绘制不再贴完整代码但有一个细节值得注意所有网格Paint和卫星Paint都要在init()或者View初始化的时候创建不要在onDraw里new对象。onDraw每秒可能要执行几十次每次创建对象会频繁触发GC导致掉帧这是自定义View新手最容易犯的毛病。4.4 卫星点怎么画才好看、信息量才够卫星点不是简单画个实心圆就完事。一颗卫星至少要表达四个信息星座、信噪比、是否参与定位、是哪颗卫星如PRN号。我的做法是颜色表示星座GPS用绿色、GLONASS用橙色、Galileo用蓝色、北斗用红色/黄色、QZSS用品红、SBAS用灰色。这套配色和很多专业GNSS软件的习惯一致用户迁移成本低。圆点大小表示信噪比信噪比低的画小圆高的画大圆。比如C/N0低于20画5px20到35画8px高于35画10px。这样扫一眼图就能看出哪几颗卫星信号好、哪几颗是飘着的。填充或描边表示是否用于定位参与定位解算的卫星用实心圆加白色描边不参与定位的只画空心圆或者半透明圆。旁边标注文字星座缩写加PRN号比如G29代表GPS 29号星C21代表北斗21号星。信噪比可以写在卫星点的上方或者作为第二行小字。信息太多的话为了避免互相遮挡建议只标注信噪比大于25的卫星。这里有个画图顺序的经验先把所有卫星按仰角从低到高排序越靠近外圈的先画再画靠近圆心的。否则高仰角的卫星点和低仰角的卫星点重叠时靠近外圈的先画会被覆盖用户就看不到低仰角卫星了。这个顺序问题很多开源示例都没处理实际画出来会乱糟糟的。卫星点的绘制大概长这样private void drawSatellites(Canvas canvas) { ListSatelliteUiModel sats dataSnapshot; if (sats null) return; // 先按仰角升序排序从外圈往内圈画 Collections.sort(sats, (a, b) - Float.compare(a.elevation, b.elevation)); for (SatelliteUiModel sat : sats) { PointF p skyPlotPoint(sat.elevation, sat.azimuth, radius, centerX, centerY); Paint paint getPaintForConstellation(sat.constellationType); float dotRadius getDotRadiusForCn0(sat.cn0); canvas.drawCircle(p.x, p.y, dotRadius, paint); if (sat.usedInFix) { // 高亮描边 canvas.drawCircle(p.x, p.y, dotRadius 3, glowPaint); } // 绘制文字标签 if (sat.cn0 25) { canvas.drawText(sat.label, p.x dp(6), p.y - dp(4), textPaint); canvas.drawText(String.format(%.0f, sat.cn0), p.x dp(6), p.y dp(12), smallTextPaint); } } }4.5 权限申请与动态刷新策略Android 6.0以上光在Manifest里声明权限不够运行时还要动态申请。GNSS相关需要的是uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /申请时机建议放在进入定位页面之前因为用户拒了权限后面的所有逻辑都跑不起来。还有个实际问题部分国产手机要求定位服务开关也要打开否则GnssStatus.Callback可能一次回调都不触发。所以页面里最好加一个定位是否可用的状态检测并提示用户打开定位开关。刷新策略我建议封装成一个SatelliteDataViewModel或者至少一个管理类private final Handler refreshHandler new Handler(Looper.getMainLooper()); private final Runnable refreshRunnable new Runnable() { Override public void run() { skyPlotView.updateSatellites(snapshot); refreshHandler.postDelayed(this, 1000); } };回调里只做数据拷贝不实时刷新UI。每秒钟重绘一帧既流畅又不费电。如果是要做高精度测量场景可以放到200ms刷新一次但说实话在手机屏幕上人眼已经分不出太大区别了。5. 常见问题与排查技巧实录5.1 模拟器上跑不出卫星数据怎么办很多人第一步就在模拟器上卡住了。Android Studio的模拟器默认的确拿不到GNSS卫星数据因为电脑本身没有GPS硬件模拟器也没有默认提供虚拟卫星源。有两个可行方案方案一用Android Studio自带模拟器的Extended Controls功能在GPS数据面板可以手动发送一个模拟位置。但这个方法对GnssStatus.Callback的触发不稳定很多版本压根不产生卫星状态回调。方案二用第三方GNSS模拟器软件配合虚拟串口转发。比如在电脑上跑一个NMEA位置源软件配合模拟器的虚拟串口功能把$GPGSV等语句转发进去。这个方案配置复杂但能模拟出完整的卫星场景。说句实在话做GNSS可视化开发强烈建议直接上真机调试。市面上几百块的支持北斗的Android手机就能满足需求。填坑的精力省下来比什么都强。5.2 数据回调正常但图上永远没点这种情况多半是漏了权限或者走到了异常分支。排查顺序确认ACCESS_FINE_LOCATION已授权不仅要看Manifest还要看运行时权限状态。确认定位服务开关已打开部分手机在服务未开启时不会回调。确认注册的是GnssStatus.Callback而不是GpsStatus.Listener后者在API 24之后基本废了。在onSatelliteStatusChanged回调里打日志确认status.getSatelliteCount()返回的卫星数大于0。如果卫星数一直是0那说明设备在室内或者天线出了问题看不到卫星。到窗边或者室外试一下很多Bug其实是没信号。5.3 卫星点永远堆在圆心或者乱跑这个问题的原因我已经在前面讲过了坐标转换公式用错了。最常见的是把仰角换算成半径时写成了r elevation / 90 * R这样仰角90度的卫星反而画在了最外圈仰角越低越靠圆心图像就完全反了。另一个常见错误是方位角的三角函数符号反了画出来的图左右镜像。写个小测试用例给一个仰角45度、方位角90度的卫星期望它出现在圆心的正右方。如果它跑到了正左方说明x轴公式里的sin要换个符号。5.4 卫星点挤成一团看不清这是因为你没有对标签做防重叠处理。简单处理办法是标记重要卫星高信噪比、用于定位的只给仰角大于15度且上方有空白区域的部分标文字。更进一步可以做简单的标签布置先按优先级排序逐个计算标签矩形如果与已放置标签相交就换个方向偏移。这个技术不复杂但实际体验提升非常明显特别是十几颗北斗卫星同时在天上的时候标出来的文字不会糊成一团。5.5 不同机型的兼容性处理思路Android系统版本跨度大GNSS支持能力差异也大。我自己用的兼容方案是运行Android 7.0以上用GnssStatus.Callback。遇到个别老设备只支持getSatellites()的做个适配器把两种数据源统一成同一种SatelliteUiModel列表。有些手机的GNSS芯片不支持部分星座比如某些低端机不支持Galileo这种情况没法通过软件解决只能图上少显示几颗星。在界面上加一个“支持的星座”说明能减少排查误会。还有一点要提醒getConstellationType()的返回值在不同Android版本里有一些常量差异建议写代码时用GnssStatus.CONSTELLATION_GPS这些官方常量比较不要用写死的数字。6. 一些提升体验的扩展思路星空图画出来能跑只是第一步。如果要把这张图真正放到工程里用起来有几个方向可以进一步做。一是把DOP值精度衰减因子和卫星数量直接叠加到星空图附近。GNSS领域其实有个共识卫星多不代表定位好分布好才是真的好。如果没有DOP值指标用户看到的是一堆散点很难判断当前定位质量。可以在星空图下方放一行小字GPS 12颗 / 北斗 14颗 / HDOP 0.8信息量立刻高一个档次。二是叠加历史轨迹。星空图是瞬时的但如果把卫星点按时间画成拖尾效果就能看到某颗卫星从地平线爬升到天顶又落下去的过程。这对观察遮挡问题特别有效比如固定位置明明在室外却总有一片扇区的卫星消失那多半是旁边有建筑物或者天线处在阴影区。三是支持手动设置截止仰角。专业用户希望看到不同仰角下的卫星分布和DOP变化。直接在星空图上画一个可拖动的仰角环默认10°、15°或20°比在设置里填数字直观得多。四是接入外部GNSS接收机数据。很多场景下手机自带的GNSS芯片满足不了需求需要连外接模块。外接模块输出的是NMEA 0183协议其中$GPGSV、$GAGSV、$BDGSV、$GLGSV分别对应不同星座的卫星视图语句。解析NMEA的GSV语句按照同样的SatelliteUiModel装配数据星空图不用改一行绘制代码就能复用。我在实际项目里遇到过一个特别典型的例子某款车载天线在高速行驶时经常掉星日志里只看到卫星数骤降查了半天查不出原因。后来把星空图和历史轨迹叠加起来发现卫星是在车辆经过一段两侧高楼的路段时从正两翼方向一颗颗消失的。问题根本不是天线质量是城市峡谷遮挡。这就是可视化工具的价值——它把看不见的信号问题变成了一眼瞄出来的空间问题。写代码画星空图技术含量其实没那么高坐标转换就一个公式绘制流程就是网格加散点。真正花功夫的地方在数据源的稳定性、异常情况的处理、以及用户怎么看这张图。如果你按这篇文章的思路把数据层和绘制层拆开先把坐标换算跑通再逐步加上信噪比、星座信息、分组排序这些细节半天时间就能做出一张可以拿出去展示的星空图。最后提醒一句画图前先把真机定位权限和室外环境准备好别像我当年一样在办公室里对着模拟器折腾了半天。