摘要:MapLibre 没有内置比例尺控件时,如何自己画一个贴合 UI、实时刷新的比例尺。本文给出地面分辨率公式(米/像素)、"1/2/5×10ⁿ"整档归一算法与自定义 View 完整实现。
文章导读
- 适用引擎/版本:MapLibre Android
- 完整源码:https://gitcode.com/qq_16064871/android_osmdroid_maplibre
- 阅读场景:需要自定义比例尺样式、在特定缩放级别展示特定分段、把比例尺叠加到自定义 HUD 上
问题引入
地图 SDK 自带的 MapLibre 比例尺 往往不能满足自定义 UI 需求,真实项目里常要自己画一个:贴合自有风格、随缩放级别换分段、底图切换保持一致。自己画的前提只有一个------知道当前缩放级别下"屏幕上的 1 像素 ≈ 地面多少米"。本文给出 Web 墨卡托投影下可直接落地的地面分辨率公式与 Kotlin 实现,并做"公式 ↔ 代码"逐项核对。与 osmdroid 篇(自带 ScaleBarOverlay)同源不同控件机制。
副标题:自己画一个能贴合 UI、实时刷新的比例尺。与 osmdroid 篇(自带 ScaleBarOverlay)同源不同控件机制。
一、为什么不用地图 SDK 自带比例尺
移动端地图 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)。
公式 ↔ 代码一致性核对 :代码
niceRound用log10取数量级、10^exp取基、按frac落在 (1,2,5,10] 区间选档,与文本完全一致;screenWidth = niceMeters / metersPerPixel与文本一致。无偏差。
四、自定义 View 的绘制要点
比例尺本质是一个自定义 View,在 onDraw 里:
- 从地图相机拿到当前
zoom与中心lat(DEMO 通过相机移动监听实时刷新)。 - 按上面的公式算
metersPerPixel与niceMeters、barWidthPx。 - 画一条横线 + 两端短竖线(经典比例尺样式),中间填
niceMeters文本。
伪实现骨架(Kotlin):
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")
}
五、两个易错点
- 错点 1:忘记纬度修正。直接拿赤道分辨率算高纬度地区的比例尺,会偏小(实际地面距离被低估)。墨卡托越靠极地 1 像素代表的地面越短。
- 错点 2:用瓦片"名义分辨率"而非"实际 DPI" 。公式给的是逻辑像素(dp)下的米/像素;若 View 用物理像素绘制,需按设备密度
density换算,否则真机上比例尺会偏density倍。
六、与 osmdroid 的异同:通用原理 vs MapLibre 实现
通用原理(完全一致) :地面分辨率 156543.0339... * cos(lat) / 2^zoom 是 Web 墨卡托的固有公式,与引擎无关;"1/2/5×10ⁿ 整档归一"也是通用做法。osmdroid 与 MapLibre 下,算出来的"每像素米数"在同等 zoom/lat 下数值相同。
MapLibre 专属实现差异:
| 维度 | osmdroid | 本文(MapLibre) |
|---|---|---|
| 控件机制 | 内置 ScaleBarOverlay,挂到 MapView 即可 |
需自定义 View,手动注册相机移动监听(cameraIdle/move)实时刷新 |
| 数据来源 | 从 MapView 的投影/缩放直接读 |
从相机(CameraPosition 的 zoom + 中心 lat)读取后自行算 metersPerPixel |
| 刷新时机 | Overlay 随地图重绘自动更新 | 必须主动监听相机变化并 invalidate() 自定义 View |
一句话:公式通用、控件机制不同。MapLibre 把"比例尺"交还给开发者自己画,好处是 UI 完全可控、能叠加到任意 HUD;代价是要自己接相机监听。若想省事,也可直接用 MapLibre 插件生态里的现成比例尺组件,原理仍是本节公式。
七、小结
- 比例尺 = 由
zoom、lat算metersPerPixel,再选整齐档。 - 必须乘
cos(lat),否则高纬度失真。 - "整段距离"用 1/2/5×10ⁿ 归一,体验远好于直接显示原始米数。
- 自定义 View 配合相机移动监听即可做到实时刷新。
完整工程源码见:https://gitcode.com/qq_16064871/android_osmdroid_maplibre