
如果你用过 GPS Test 或者无人机地面站肯定见过那张自带深色底色的圆盘图一圈一圈的同心圆外圈标着 N/E/S/W中间散落着许多带编号的小圆点。搞 GNSS 的同行管它叫卫星星空图也叫卫星天顶图、卫星天空图Sky Plot。别看这图长得不算复杂它其实把头顶上几十颗卫星的空间分布、信号强度、定位可用性全都压缩到了一个二维平面上。这篇内容我按自己实际做过的方案把它的原理、Android 端的数据获取、以及 Canvas 绘制的完整套路拆开讲一遍想做定位类诊断工具、GNSS 调试页或者测绘 APP 的朋友可以直接参考。1. 卫星星空图画的到底是什么1.1 从天空到纸面只有两个核心参数要描述一颗卫星此刻在天空中的哪个位置其实不需要三维坐标只需要两个角度就够了方位角Azimuth和仰角Elevation。方位角是以正北为零点、顺时针旋转到卫星在地平面投影方向的角度取值 0° 到 360°。仰角是卫星相对地平线的夹角取值 0° 到 90°0° 代表卫星正好在地平线上90° 代表正在头顶正上方。你可以想象自己站在操场中央头顶上方是整个半球形的天空先转身找到卫星的朝向再抬头判断它离地平线有多高这两个动作对应下来的两个数值就能唯一确定一颗卫星的位置。星空图本质上就是把这个半球天空“拍扁”到一个圆盘上你站在圆盘中心正上方的天顶被压到圆心四周的地平线被摊到外圈。这种投影方式和雷达屏幕很像所以不少做惯雷达显控的老工程师第一次看到天空图会直接叫它“极坐标图”。名字不重要重要的是图上每一个点都对应一组方位角和仰角读图的时候心里要时刻带着半球概念才不会把方向搞反。1.2 一张图能读出什么信息很多人第一次看到星空图觉得它就是个“卫星位置展示”实际上它在实际工程里的信息量远不止这些。第一它能直观反映当前可见卫星数量和空间分布均匀度。定位精度和卫星几何分布强相关卫星如果都挤在天空的某一侧即便数量再多定位精度也可能很差。这个“分布好不好”的量化指标就是 DOP精度衰减因子而星空图恰好能一眼看出卫星分布是否均匀点均匀散落在圆盘各处DOP 一般不会差点全部压在一侧DOP 基本好不了。第二它能暴露环境遮挡。在高楼林立的城市街道、山谷、隧道口附近测试时星空图上一定会出现某个方向没有卫星或者卫星明显偏少的情况那个方向就是被楼体或山体挡住的方向。室内靠近窗户的位置图上会呈现出“半边有星、半边空白”的典型特征这在信号遮挡排查里非常有用。第三它能帮助判断多路径效应。低仰角的卫星信号容易被地面、墙面反射造成测距误差。如果星空图上大量卫星集中在低仰角区域且载噪比跳变得很厉害基本可以推断当前环境多路径比较严重这时候定位精度受影响是必然的。第四配合 usedInFix 字段它能显示当前真正参与定位解算的卫星。参与解算的卫星和不参与解算的卫星如果能在图上用不同样式区分调试定位算法时会方便很多。1.3 为什么导航软件都爱用这张图原因很简单它是判断“当前能不能定准”的最快入口。定位算法再复杂最终落到工程现场工程师第一眼要看的还是卫星状态。星空图把几十颗卫星的分布、强度、参与度全部集中在一个小圆盘里比任何一张数据表格都直观。我自己最早实现这个图是在做车载定位终端调试的时候。当时车辆进入地下停车场出口定位结果一直在几十米到一两百米之间漂移靠日志里的文本数据很难快速定位问题后来接了一张实时星空图一眼就看到出口方向大量低仰角卫星被坡道遮挡剩余可见卫星集中在一个窄扇形区域里问题瞬间就解释清楚了。从那之后我就认定凡是做 GNSS 状态展示星空图应该是标配。2. Android 端 GNSS 原始数据从哪拿2.1 首选 GnssStatus.CallbackAndroid 系统从 7.0API 24开始提供了 GnssStatus 和 GnssStatus.Callback这是目前获取卫星状态最推荐的方案。用法不复杂通过 LocationManager 注册一个回调系统会周期性把当前可见卫星的状态推给你。核心代码如下val locationManager getSystemService(Context.LOCATION_SERVICE) as LocationManager val gnssCallback object : GnssStatus.Callback() { override fun onSatelliteStatusChanged(status: GnssStatus) { // status 里包含当前所有可见卫星的状态 val count status.satelliteCount for (i in 0 until count) { val svid status.getSvid(i) val cn0 status.getCn0DbHz(i) val elevation status.getElevationDegrees(i) val azimuth status.getAzimuthDegrees(i) val constellation status.getConstellationType(i) val usedInFix status.usedInFix(i) } } } locationManager.registerGnssStatusCallback(gnssCallback, Handler(Looper.getMainLooper()))注册之后系统会以一定频率回调 onSatelliteStatusChanged频率由硬件和系统共同决定通常是 1 秒左右一次。这里要注意回调默认走的是 Binder 线程如果要更新 UI记得 post 到主线程或者像我上面这样注册时直接传入主线程的 Handler。在 Android 7.0 之前的系统上这套 API 不可用早年用的是 GpsStatus.Listener现在已经基本被淘汰新项目直接按 GNSS 时代写就行。2.2 权限与系统版本要求这个环节最容易踩坑。获取卫星状态需要定位权限而且必须是精确位置权限。在 AndroidManifest.xml 里声明uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION /Android 6.0 以上还需要运行时动态申请。尤其要注意的是如果应用只申请了 ACCESS_COARSE_LOCATION在大多数系统上拿不到 GnssStatus 数据Android 12API 31之后系统对定位权限管理更严格模拟器、部分定制的 ROM 上即使给了权限也不一定有数据输出部分厂商 ROM尤其是国产定制系统默认关闭“位置信息服务”里的 GNSS 开关或者要求在设置里单独打开“提高定位精度/扫码定位”之类的选项这点在调试时非常容易让人误以为代码写错了。所以我的经验是真机调试第一件事不是看代码而是先装一个 GPS Test 之类的现成工具确认当前系统里确实能收到卫星数据再来查自己的代码逻辑。如果现成工具都收不到卫星那就是权限、系统设置或者测试环境的问题跟代码无关。2.3 每个卫星字段都要怎么用GnssStatus 里可用的字段不少做星空图真正需要的有这几个字段含义典型取值用途getSvid(i)卫星编号GPS 1~32北斗 1~37 等图上标注编号getConstellationType(i)星座类型1 GPS、3 格洛纳斯、5 北斗、6 伽利略等区分颜色getAzimuthDegrees(i)方位角0°~360°计算绘制位置getElevationDegrees(i)仰角0°~90°计算绘制位置getCn0DbHz(i)载噪比0~50 dBHz决定点的大小/颜色usedInFix(i)是否参与定位解算true/false图上标记参与状态hasEphemerisData(i)是否有星历true/false判断冷启动状态这里特别提醒 svid 的含义。很多人习惯把 svid 直接当作 GPS PRN 来理解但不同星座的 svid 含义不一样。GPS 的 svid 通常就是 PRN 1~32北斗的 svid 在 Android 上报里经常是 1~37 或 201~233 之类的区间不同厂商实现也不完全一致。所以在图上标注编号时建议带上星座缩写前缀比如 “GPS 05”“BDS 12”“GAL 03”避免用户混淆。载噪比C/N0是另一个值得细说的字段单位是 dBHz数值越高代表信号越强。实际工程里超过 40 dBHz 算是信号良好30~40 dBHz 算一般低于 25 dBHz 的卫星基本不具备定位价值。星空图里可以用载噪比控制卫星点的表现一眼就能看出哪些卫星信号好、哪些是在“凑数”。3. 核心画法把极坐标换算成 Canvas 坐标3.1 先约定一张图的“地图方向”画图之前必须先把坐标系约定死不然画着画着方向就乱了。我的约定是正北在圆盘正上方正东在圆盘正右方圆心代表天顶仰角 90°外圈代表地平线仰角 0°。也就是说这是一张“俯视天空”的图观察者站在圆心抬头望天头顶正上方的卫星画在圆心地平线附近的卫星画在圆周附近。这个约定和绝大多数 GNSS 工具软件一致不要自己发明新方向否则拿给别人看别人会很不适应。3.2 仰角和方位角的换算公式上面这个约定下换算公式其实很简洁。设圆心坐标为 (cx, cy)最大绘制半径为 R某颗卫星的仰角为 elev、方位角为 azim那么它在 Canvas 上的画布坐标按下面三步算。第一步把仰角转换成“距离圆心的比例”。这里推荐用余弦映射而不是简单的线性映射unitR cos(elev * PI / 180)这样当真仰角 0° 时 unitR1卫星落在圆周上仰角 90° 时 unitR0落在圆心。为什么用余弦而不是直接用 (90 - elev) / 90 做线性映射因为从观察者视角看同样的角度间隔在低仰角区域覆盖的天空面积更大余弦映射画出来的点分布更接近人眼对天空的透视感受。实际使用中线性映射也能用只是低仰角区域的卫星会被压缩得比较密。第二步把方位角换算成 Canvas 角度。Android Canvas 的默认坐标系是 x 轴向右、y 轴向下0° 角在正右顺时针增大。我们约定正北在上换算关系就是canvasAngle azim - 90 angleRad canvasAngle * PI / 180第三步根据半径和角度求坐标r R * unitR x cx r * cos(angleRad) y cy r * sin(angleRad)验证一下关键方向azim0正北canvasAngle-90°cos0sin-1所以点画在圆心正上方符合约定azim90正东canvasAngle0°点画在正右正确azim180正南点画在正下azim270正西点画在正左。这套公式可以直接抄进项目里用。3.3 网格和方向标注的绘制网格是星空图的骨架一般包括同心圆和方位角线。同心圆代表仰角圈我通常画 30° 和 60° 两个圈再加上最外面的 0° 地平线圈。半径分别对应30° 圈半径 R * cos(30°) ≈ R * 0.866 60° 圈半径 R * cos(60°) R * 0.5方位角线一般每 45° 画一条也就是 0°、45°、90°、135°、180°、225°、270°、315°从圆心发散到圆周方便读图时快速判断某颗卫星的朝向。方向标注 N/E/S/W 放在圆周外面用 Canvas 的 drawText 绘制。一段简洁的网格绘制代码private fun drawGrid(canvas: Canvas, cx: Float, cy: Float, r: Float) { // 画仰角圈 for (elev in intArrayOf(0, 30, 60)) { val radius r * cos(Math.toRadians(elev.toDouble())).toFloat() canvas.drawCircle(cx, cy, radius, gridPaint) } // 画方位角线 for (azim in 0 until 360 step 45) { val angle Math.toRadians(azim - 90.0) val dx cos(angle).toFloat() * r val dy sin(angle).toFloat() * r canvas.drawLine(cx, cy, cx dx, cy dy, gridPaint) } // 画方向标注 canvas.drawText(N, cx, cy - r - dp(10), textPaint) canvas.drawText(E, cx r dp(14), cy dp(4), textPaint) canvas.drawText(S, cx, cy r dp(24), textPaint) canvas.drawText(W, cx - r - dp(14), cy dp(4), textPaint) }绘制时的画笔注意开抗锯齿Paint(Paint.ANTI_ALIAS_FLAG)网格线颜色用半透明白比如0x80FFFFFF这样既不会抢卫星点的视觉重点又能在深色背景下保持清晰。4. 星点、编号、信噪比的绘制细节4.1 按星座区分颜色星空图上的卫星点默认应该按星座类型区分颜色否则多星座混在一起根本没法看。我自己常用的配色方案是星座类型常量推荐颜色GPSCONSTELLATION_GPS绿色GLONASSCONSTELLATION_GLO蓝色GALILEOCONSTELLATION_GALILEO橙黄色BEIDOUCONSTELLATION_BEIDOU红色QZSSCONSTELLATION_QZSS紫色SBAS/其他其他常量灰色颜色编码的意义在于快速区分系统现在多星座接收机普遍能同时看到二十颗以上卫星如果都用同一个颜色读图效率会大幅下降。颜色选择上我建议避开红绿色盲难以区分的搭配不要单纯依赖颜色传达关键信息图形上也要有其它辅助标识。4.2 点大小、编号位置与参与解算标记确定了坐标和颜色接下来就是星点的视觉编码。我个人习惯是把“载噪比”映射成点的大小把“是否参与定位解算”映射成外圈描边。载噪比映射到尺寸的参考逻辑是pointRadius dp(4) cn0 / 30f * dp(3)也就是说低载噪比的卫星画小点高载噪比的卫星画大点。实测下来30 dBHz 以下的点很小40 dBHz 以上的点明显突出一张图上信号分布一目了然。如果你做过视觉设计会发现人的视觉对“大小”的敏感度远高于“颜色深浅”所以用尺寸表达载噪比是很自然的选择。编号文字的放置也有讲究。我一般把编号放在卫星点的右上角偏移位置避免文字压住图形本身。如果卫星数量太多文字会出现重叠这时候有两个处理思路要么在重叠时只保留载噪比更高的卫星文字要么让文字跟随点缩小到阈值之后就不显示只留一个点。后者在实际工具软件里更常见视觉效果也更干净。参与定位解算的标记我习惯用外圈描边画一个比点稍大的空心圆这样一眼就能看出哪些卫星正在被定位算法使用。未参与解算的卫星不画外圈只在图上标出它“存在”。4.3 看着不乱的几个布局技巧这些细节都是踩过坑才总结出来的。背景一定要深色。星空图本质是一个“信号仪表盘”深色背景下亮色卫星点对比度高读图体验明显更好。白色背景反射进眼睛再传到大脑别说现场调试就是坐在办公室看久了也难受。网格线要比星点弱。网格是参考系不是主角颜色透明度控制在 30%~50% 比较合适。星点则用不透明的纯色保证视觉优先级第一。如果 View 支持手势旋转或缩放建议给卫星点坐标和网格坐标做同一套变换避免出现“网格转了点没转”的割裂感。不做交互的话保持最简画法就行不用引入多余复杂度。文字大小要适配。Android 设备屏幕密度差异很大dp 单位能解决大部分适配问题但网格文字和卫星编号文字最好用 sp 单位跟随系统字体设置。5. 组装一个可直接用的 SkyPlotView5.1 数据模型和整体结构代码结构不必复杂一个自定义 View 加一个数据类就够了。数据类放在独立文件里方便回调线程和 UI 线程之间传数据data class SatelliteInfo( val svid: Int, val constellationType: Int, val azimuth: Float, val elevation: Float, val cn0: Float, val usedInFix: Boolean )SkyPlotView 继承 View对外暴露一个 setSatellites 方法传入 List 内部保存引用后调用 invalidate() 触发重绘。所有绘制逻辑集中在 onDraw 里不在 onDraw 以外的地方做计算和 IO。View 主体结构如下class SkyPlotView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private var satellites: ListSatelliteInfo emptyList() private val gridPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.STROKE strokeWidth dp(1f) color 0x60FFFFFF.toInt() } private val textPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize sp(11f) color 0xA0FFFFFF.toInt() textAlign Paint.Align.CENTER } private val satellitePaint Paint(Paint.ANTI_ALIAS_FLAG).apply { style Paint.Style.FILL } fun setSatellites(list: ListSatelliteInfo) { satellites list invalidate() } }5.2 监听 GNSS 回调并刷新拿到 GnssStatus.Callback 的数据后要做的事很明确把 status 转成 SatelliteInfo 列表再交给 View 绘制。private val gnssCallback object : GnssStatus.Callback() { override fun onSatelliteStatusChanged(status: GnssStatus) { val list mutableListOfSatelliteInfo() for (i in 0 until status.satelliteCount) { list.add( SatelliteInfo( svid status.getSvid(i), constellationType status.getConstellationType(i), azimuth status.getAzimuthDegrees(i), elevation status.getElevationDegrees(i), cn0 status.getCn0DbHz(i), usedInFix status.usedInFix(i) ) ) } skyPlotView.post { skyPlotView.setSatellites(list) } } }注册和注销放在 Activity 或 Fragment 的 onResume/onPause 里避免在后台持续占用 GNSS 芯片导致的耗电override fun onResume() { super.onResume() locationManager.registerGnssStatusCallback(gnssCallback, Handler(Looper.getMainLooper())) } override fun onPause() { super.onPause() locationManager.unregisterGnssStatusCallback(gnssCallback) }别忘了在 onRequestPermissionsResult 里确认用户真的授予了ACCESS_FINE_LOCATION权限没拿到就去注册回调系统会直接抛出 SecurityException这点在发布前一定测一遍。5.3 onDraw 里的核心绘制逻辑onDraw 里把前面讲的换算逻辑完整落下来override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val cx width / 2f val cy height / 2f val radius min(width, height) / 2f - dp(24) // 1. 画背景网格 drawGrid(canvas, cx, cy, radius) // 2. 画卫星 for (sat in satellites) { // 仰角 - 半径比例 val unitR cos(Math.toRadians(sat.elevation.toDouble())).toFloat() val r radius * unitR // 方位角 - Canvas 角度 val angleRad Math.toRadians(sat.azimuth - 90.0) val x cx r * cos(angleRad).toFloat() val y cy r * sin(angleRad).toFloat() // 载噪比 - 点大小 val pointRadius dp(3f) sat.cn0 / 40f * dp(5f) satellitePaint.color colorForConstellation(sat.constellationType) satellitePaint.style Paint.Style.FILL canvas.drawCircle(x, y, pointRadius, satellitePaint) // 参与定位解算 - 外圈描边 if (sat.usedInFix) { satellitePaint.style Paint.Style.STROKE satellitePaint.strokeWidth dp(1.5f) canvas.drawCircle(x, y, pointRadius dp(4f), satellitePaint) } // 编号标注 canvas.drawText(sat.svid.toString(), x dp(8f), y - dp(8f), textPaint) } }colorForConstellation 建议封装成一个独立函数用一个 when 表达式按常量映射颜色不要写在循环里。这样代码清晰以后想加星座也很方便。整段画下来没有摩登的操作核心就是把前面两节讲到的坐标换算和视觉编码落实成代码。实测下来这个 View 在普通手机上 60fps 跑满没有任何压力毕竟每帧只有几十个点和几条线计算量非常小。6. 实测中的常见问题与排查经验6.1 模拟器上永远没有卫星这不是代码问题是模拟器默认没有 GNSS 硬件抽象层。Android Studio 自带的模拟器在多数系统镜像上不会输出真实的卫星状态少数镜像提供了模拟位置功能但和真实卫星数据完全是两回事。排查办法很简单换真机。而且真机还要注意测试环境。室内靠窗和室外开阔地的可见卫星数量和分布会有明显差异做功能验证务必到窗户边或者室外去。我在办公室工位测试时经常只能看到三五颗 GPS 卫星走到楼下开阔地立刻变成二十多颗。6.2 卫星分布“缺半边”不是 bug第一次看到星空图的人往往会问为什么我的图只有半边有卫星是不是绘制逻辑写反了绝大多数情况不是。卫星分布不均匀是真实环境造成的尤其是靠窗测试时窗户朝向的那一侧卫星多背向窗户的那一侧几乎没有卫星。另外部分系统在弱信号环境下会优先上报信号较强的卫星导致图上看起来稀疏这都属于正常现象。判断绘制逻辑有没有问题最直接的方法是在开阔地竖直举起手机如果此时卫星仍然集中在一侧再怀疑代码。正常情况下开阔环境下卫星分布应该比较均匀虽然不会严格对称但不会集中在一个 90° 扇形区域内。6.3 载噪比跳变得很厉害载噪比本身就是动态变化的几 dBHz 的波动完全正常。但如果发现同一颗卫星的载噪比在 10 dBHz 以上来回跳甚至从图上消失又出现大概率是周边环境有反射、遮挡或者快速移动导致信号不稳定。这种情况下不要急着怀疑代码先看现象跳变的同时卫星位置在图上是否也在大幅变动如果位置稳定、只有强度在跳基本可以判断是环境多路径如果位置也在跳那就是接收机对信号的跟踪不稳定在算法层面做平滑滤波会有效果。6.4 北斗 svid 的“不连续性”测试多星座时会发现北斗卫星的 svid 在 Android 系统上和你在某些协议文档里看到的编号不一定对得上。不同手机厂商上报的北斗 svid 范围可能有差异有的从 1 开始有的从 38 开始有的直接上报 200 多打头看起来“断断续续”的。处理建议是图上显示原始 svid 即可不要自行映射成“标准 PRN”。显示原始编号、带上星座前缀能保证和原始日志对应上调试时追踪问题方便得多。6.5 刷新频率与 UI 线程GnssStatus 回调频率一般在 1Hz 左右绘制压力不大。但要注意回调所在线程如果在 Binder 线程里直接构建 List 并更新 UI可能会导致偶发的界面卡顿甚至崩溃。统一用 View.post 或者注册时指定主线程 Handler 就好。另外一个优化手段是“脏检查”只有卫星数量、位置、载噪比变化超过阈值时才触发 invalidate()否则跳过本帧。实测下来这个优化在低端机上能省一点电虽然省得不多但对长期挂着定位调试页的场景还是有意义的。常见现象可能原因排查方向模拟器无卫星模拟器无 GNSS 硬件换真机、室外测试卫星总在一侧遮挡/环境所致非 bug开阔地验证载噪比剧烈跳变多路径、信号反射检查周边环境北斗编号不连续厂商实现差异显示原始 svidUI 卡顿/崩溃回调线程操作 UIpost 主线程我把这个 SkyPlotView 写进公司内部定位调试工具之后最大感受是这套东西难点不在代码而在坐标系约定和数据语义。坐标系一旦想清楚所有绘制顺理成章数据语义一旦搞清楚星点、颜色、大小、外圈这些视觉编码就都有据可依。最后再分享一个小技巧调试线上问题时可以在星空图外面加一个“底图数据快照”导出功能把当前所有卫星的方位角、仰角、载噪比、参与解算状态一次性导出成文本配合截图现场分析效率特别高。后续如果想把图做得更专业还可以叠加定位误差椭圆、各星座 DOP 贡献、历史轨迹回放原理完全一样往数据层加字段就行。