摘要:UI 设计稿里的比例尺是白底黑字、左对齐、带圆角描边,SDK 自带的控件颜色字体全定死------那就自己画一个。前提只有一个:知道当前缩放下"屏幕 1 像素 ≈ 地面多少米"。这篇给出 Web 墨卡托地面分辨率公式、"1/2/5 × 10ⁿ"整档归一的完整实现,以及两个真机必踩的易错点。
目录(TOC)
- [一、为什么不用 SDK 自带比例尺](#一、为什么不用 SDK 自带比例尺 "#%E4%B8%80%E4%B8%BA%E4%BB%80%E4%B9%88%E4%B8%8D%E7%94%A8-sdk-%E8%87%AA%E5%B8%A6%E6%AF%94%E4%BE%8B%E5%B0%BA")
- 二、核心公式:地面分辨率
- 三、由分辨率反推"好看的整段距离"
- [四、自定义 View 的绘制要点](#四、自定义 View 的绘制要点 "#%E5%9B%9B%E8%87%AA%E5%AE%9A%E4%B9%89-view-%E7%9A%84%E7%BB%98%E5%88%B6%E8%A6%81%E7%82%B9")
- 五、两个易错点
- [六、与 osmdroid 的异同](#六、与 osmdroid 的异同 "#%E5%85%AD%E4%B8%8E-osmdroid-%E7%9A%84%E5%BC%82%E5%90%8C")
- 七、坑对照表与小结
一、为什么不用 SDK 自带比例尺
想象一个验收场景:UI 设计稿里的比例尺是白底黑字、左对齐、带圆角描边,还要跟着卫星 / 矢量底图切换保持一致,放大到某一档要显示特定分段。
而 SDK 自带的比例尺控件------长得跟你无关,颜色字体全定死,还盖不住自定义 HUD。
移动端地图 SDK 通常自带比例尺控件,但真实项目里常常需要自己画一个:
- 要贴合自有 UI 风格;
- 要在特定缩放级别展示特定分段;
- 要在卫星影像 / 矢量底图切换时保持一致;
- 甚至要把比例尺叠加到自定义 HUD 上。
自己画的前提只有一个:
知道当前缩放级别下,"屏幕上的 1 像素 ≈ 地面多少米"。
二、核心公式:地面分辨率
Web 墨卡托(EPSG:3857)投影下,地面分辨率(每像素代表的地面距离,单位:米 / 像素)为:
text
resolution(zoom) = (地球周长_赤道) / (256 * 2^zoom)
= (2 * PI * R) / (256 * 2^zoom)
其中:
R = 6378137.0(WGS-84 椭球长半轴,米);256是标准瓦片像素边长;2^zoom是该级别下的瓦片总数(单边)。
代入 R 后常用形式:
text
resolution(zoom) = 156543.03392804062 / 2^zoom // 米/像素(赤道处)
纬度修正
上面是赤道 分辨率。分辨率随纬度变化(墨卡托在两极被拉伸),需乘 cos(lat):
text
resolution(zoom, lat) = 156543.03392804062 * cos(lat * PI / 180) / 2^zoom
公式 ↔ 代码一致性核对 :DEMO 中比例尺核心计算写成
val metersPerPixel = 40075016.686 * cos(latRad) / (256.0 * 2.0.pow(zoom)), 其中40075016.686 / 256 = 156543.03392804062,与文本公式156543.0339... / 2^zoom完全等价;latRad = cameraLat * PI / 180正确;niceNumber阈值用1.5 / 3.5 / 7.5选档(文本第三节同步)。无偏差。
三、由分辨率反推"好看的整段距离"
屏幕宽度固定(比如打算让比例尺条占 100 dp),但"100 dp × metersPerPixel"往往是个奇怪的数(如 347.2 米)。
用户更希望看到 100m / 200m / 500m / 1km 这类整齐刻度。
做法是:先算当前 100dp 对应多少米,再向上取到"1/2/5 × 10ⁿ"的整齐档:
kotlin
fun niceRound(rawMeters: Double): Double {
val exp = floor(log10(rawMeters)) // 数量级
val base = 10.0.pow(exp)
val frac = rawMeters / base
val nice = when {
frac < 1.5 -> 1.0
frac < 3.5 -> 2.0
frac < 7.5 -> 5.0
else -> 10.0
}
return nice * base
}
然后反算这段距离在屏幕上该画多宽:
kotlin
screenWidthDp = niceMeters / metersPerPixel
把这个 screenWidthDp 作为比例尺条的长度,旁边标注 niceMeters 的友好文本(>=1000 时显示 km)。
为什么是 1/2/5 而不是 1/3/5?
这是数据可视化里的经典约定("nice numbers"):
- 1、2、5 是 10 的约数,档位之间的倍数关系均匀(1→2 是 ×2,2→5 是 ×2.5,5→10 是 ×2),视觉上跳跃感一致;
- 如果用 1/3/5,档位是 ×3、×1.67、×2,间隔忽大忽小,缩放时比例尺条会"跳";
- 而且 1/2/5 的刻度便于心算------用户看一眼 200m 的条,本能就能估出半条是 100m。
公式 ↔ 代码一致性核对 :代码
niceRound用log10取数量级、10^exp取基、按frac落在 (1,2,5,10] 区间选档,与文本完全一致;screenWidth = niceMeters / metersPerPixel与文本一致。无偏差。
四、自定义 View 的绘制要点
比例尺本质是一个自定义 View,在 onDraw 里:
- 从地图相机拿到当前
zoom与中心lat(DEMO 通过相机移动监听实时刷新); - 按上面的公式算
metersPerPixel与niceMeters、barWidthPx; - 画一条横线 + 两端短竖线(经典比例尺样式),中间填
niceMeters文本。
kotlin
override fun onDraw(canvas: Canvas) {
val zoom = currentZoom
val lat = currentLat
val mpp = 156543.03392804062 * cos(lat * PI / 180) / 2.0.pow(zoom)
val raw = TARGET_DP * mpp // 打算画 TARGET_DP 像素对应的米
val nice = niceRound(raw)
val barPx = (nice / mpp).toFloat() // 实际像素宽
canvas.drawLine(0f, h/2f, barPx, h/2f, paint)
canvas.drawLine(0f, 0f, 0f, h, paint)
canvas.drawLine(barPx, 0f, barPx, h, paint)
drawLabel(if (nice >= 1000) "${nice/1000} km" else "${nice.toInt()} m")
}
注意刷新机制 :MapLibre 不会替你刷新自定义 View,必须自己监听相机变化并 invalidate()。建议同时监听 cameraIdle(稳定后刷新)和 cameraMove(移动中跟随),或者用 cameraMove + 去抖。
五、两个易错点
易错点 1:忘记纬度修正
直接拿赤道分辨率算高纬度地区的比例尺,会偏小(实际地面距离被低估)。
因为墨卡托越靠极地,1 像素代表的地面越短:
| 纬度 | cos(lat) | 相对赤道的分辨率 |
|---|---|---|
| 0°(赤道) | 1.000 | 100% |
| 30° | 0.866 | 86.6% |
| 45° | 0.707 | 70.7% |
| 60° | 0.500 | 50.0% |
也就是说,在北纬 60° 地区,如果忘了 cos(lat),比例尺会偏大一倍------用户按比例尺量出的距离全是错的。这在测绘场景里是硬伤。
易错点 2:用瓦片"名义分辨率"而非"实际 DPI"
公式给的是逻辑像素(dp)下的米 / 像素;若 View 用物理像素 绘制,需按设备密度 density 换算,否则真机上比例尺会偏 density 倍。
kotlin
// 正确:把 dp 换算成物理像素后再画
val barPx = (nice / mpp) * resources.displayMetrics.density
// 或者反过来:用物理像素反推 raw 时先除以 density
val rawPx = TARGET_PX / density * mpp
这个 bug 的隐蔽之处在于:它在某一台开发机上是"对"的(因为开发机 density 恰好是某个值,或者你调试时用的是 dp),换成另一台不同密度的机器就偏了。
六、与 osmdroid 的异同
通用原理(完全一致)
地面分辨率 156543.0339... * cos(lat) / 2^zoom 是 Web 墨卡托的固有公式,与引擎无关 ;"1/2/5 × 10ⁿ 整档归一"也是通用做法。osmdroid 与 MapLibre 下,算出来的"每像素米数"在同等 zoom / lat 下数值相同。
MapLibre 专属实现差异
| 维度 | osmdroid | 本文(MapLibre) |
|---|---|---|
| 控件机制 | 内置 ScaleBarOverlay,挂到 MapView 即可 |
需自定义 View,手动注册相机监听实时刷新 |
| 数据来源 | 从 MapView 的投影 / 缩放直接读 |
从相机(CameraPosition 的 zoom + 中心 lat)读取后自行算 |
| 刷新时机 | Overlay 随地图重绘自动更新 | 必须主动监听相机变化并 invalidate() |
一句话:
公式通用、控件机制不同。 MapLibre 把"比例尺"交还给开发者自己画,好处是 UI 完全可控、能叠加到任意 HUD;代价是要自己接相机监听。
若想省事,也可直接用 MapLibre 插件生态里的现成比例尺组件------原理仍是本节公式。
七、坑对照表与小结
| # | 症状 | 根因 | 解法 |
|---|---|---|---|
| 1 | 比例尺显示 347.2m 这种怪数 | 直接显示原始米数,未归整 | niceRound() 取 1/2/5×10ⁿ 档 |
| 2 | 高纬度地区比例尺偏大 | 忘了乘 cos(lat) |
必须做纬度修正 |
| 3 | 换台机器比例尺偏 density 倍 | 混用了 dp 与物理像素 | 按 displayMetrics.density 换算 |
| 4 | 缩放后比例尺不更新 | 未监听相机变化 | 监听 cameraIdle / cameraMove 并 invalidate() |
| 5 | 移动中比例尺疯狂闪烁 | cameraMove 每帧都算并重绘 |
去抖,或只在 cameraIdle 更新 |
| 6 | 档位跳跃忽大忽小 | 用了非 1/2/5 的档位 | 用 1/2/5×10ⁿ,倍数关系均匀 |
小结:
- 比例尺 = 由
zoom、lat算metersPerPixel,再选整齐档; - 必须乘
cos(lat),否则高纬度失真(北纬 60° 会偏大一倍); - "整段距离"用 1/2/5 × 10ⁿ 归一,体验远好于直接显示原始米数;
- 自定义 View 配合相机移动监听即可做到实时刷新,注意区分 dp 与物理像素。
完整可运行源码 :GitCode 仓库 · android_osmdroid_maplibre
对照阅读 :本系列第 07 篇(osmdroid 极值缩放:边界护栏与比例尺换算)------两篇都用到地面分辨率,osmdroid 有内置
ScaleBarOverlay,MapLibre 要自己画,对照看能理解"可控性 vs 便利性"的取舍。延伸阅读:本系列第 14 篇(MapLibre 范围缩放:由分辨率反推缩放级别)------本文是"由 zoom 算分辨率",那篇是"由分辨率反推 zoom",互为逆运算。
评论区聊聊 :你在真机上遇到过比例尺偏
density倍的情况吗?还是被"347.2m"这种刻度逼疯过?说说你的踩坑现场。
本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。