第04章 - Android 集成与开发
4.1 这一章解决什么问题
Android 端集成 MapLibre Native 的难点不在 API 调用,而在三处:生命周期回调必须手动转发、MapLibre 单例必须先初始化、样式与图层操作全部发生在 native 的渲染线程上(你在 UI 线程调用的是代理)。任何一处没处理好,表现都是”地图不出图”或”偶发崩溃”,而不是明确的异常。
这一章按一次真实接入的顺序走:初始化、布局、生命周期、样式、图层、相机、手势、查询,最后是 Android 特有的坑。
4.2 初始化的顺序约束
入口类是 org.maplibre.android.MapLibre,它有三个重载:
public static synchronized MapLibre getInstance(Context context)
public static synchronized MapLibre getInstance(Context context, String apiKey, WellKnownTileServer tileServer)
public static synchronized MapLibre getInstance(Context context, String apiKey, WellKnownTileServer tileServer, /* ... */)
必须在 MapView 创建之前调用。它做三件事:加载 native 库、创建全局 FileSource(网络与缓存)、初始化 tile server 配置。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 1. 必须先初始化,否则 getMapAsync 不会回调
MapLibre.getInstance(this)
// 2. 再设置内容视图
setContentView(R.layout.activity_main)
// 3. 拿到 MapView
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
map.setStyle("https://demotiles.maplibre.org/style.json") { style ->
// 样式加载完成,可以安全地加图层
}
}
}
注意 mapView.onCreate(savedInstanceState):MapView 需要这个调用来恢复相机位置与内部状态,Activity 的 onCreate 里必须转发。
4.3 MapView 生命周期
MapView 的生命周期方法是成对的,全部要转发。漏掉任一对应的回调,常见后果如表:
| 方法 | 必须在哪里调用 | 漏掉的后果 |
|---|---|---|
onCreate(Bundle) |
Activity.onCreate |
相机位置丢失、内部状态未初始化 |
onStart() |
Activity.onStart |
渲染循环不启动,白屏 |
onResume() |
Activity.onResume |
地图暂停后就再也不更新 |
onPause() |
Activity.onPause |
后台仍占 GPU,耗电 |
onStop() |
Activity.onStop |
渲染线程不回收 |
onDestroy() |
Activity.onDestroy |
native 内存泄漏 |
onSaveInstanceState(Bundle) |
Activity.onSaveInstanceState |
旋转屏幕后地图回到初始位置 |
onLowMemory() |
Activity.onLowMemory |
内存紧张时不释放瓦片缓存 |
完整模板:
class MainActivity : AppCompatActivity() {
private lateinit var mapView: MapView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MapLibre.getInstance(this)
setContentView(R.layout.activity_main)
mapView = findViewById(R.id.mapView)
mapView.onCreate(savedInstanceState)
mapView.getMapAsync { map ->
map.setStyle("https://demotiles.maplibre.org/style.json")
}
}
override fun onStart() { super.onStart(); mapView.onStart() }
override fun onResume() { super.onResume(); mapView.onResume() }
override fun onPause() { super.onPause(); mapView.onPause() }
override fun onStop() { super.onStop(); mapView.onStop() }
override fun onDestroy() { super.onDestroy(); mapView.onDestroy() }
override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onSaveInstanceState(outState)
}
}
用 MapFragment 时反而简单:Fragment 内部会自动转发,不需要手动写这堆方法。新项目优先用 MapFragment。
4.4 样式:setStyle 与 Style.Builder
MapLibreMap 有三个 setStyle 重载:
public void setStyle(String style) // 不等待加载完成
public void setStyle(String style, Style.OnStyleLoaded callback) // 等加载完成后回调
public void setStyle(Style.Builder builder, Style.OnStyleLoaded callback)
只用第一个重载是典型错误:setStyle 是异步的,紧接着调 getStyle() 会得到 null 或空样式。所有图层与数据源操作都必须放进回调:
map.setStyle("https://demotiles.maplibre.org/style.json") { style ->
style.addSource(
GeoJsonSource("markers", FeatureCollection.fromFeatures(emptyList()))
)
style.addLayer(
SymbolLayer("marker-layer", "markers")
.withProperties(
PropertyFactory.iconImage("marker-icon"),
PropertyFactory.iconAllowOverlap(true)
)
)
}
从字符串直接构造样式(离线或内置 JSON):
val styleJson = """
{
"version": 8,
"sources": {},
"layers": [
{ "id": "background", "type": "background",
"paint": { "background-color": "#f2efe9" } }
]
}
""".trimIndent()
map.setStyle(Style.Builder().fromJson(styleJson)) { style ->
Log.d("Map", "custom style ready, layers = ${style.layers.size}")
}
Style(org.maplibre.android.maps.Style)提供的图层操作:
| 方法 | 作用 |
|---|---|
addSource(Source) |
添加数据源 |
getSourceAs<T>(String) |
按 ID 取数据源并转型 |
addLayer(Layer) |
追加图层到最上层 |
addLayerBelow(Layer, String) |
插到指定图层下方 |
addLayerAbove(Layer, String) |
插到指定图层上方 |
addLayerAt(Layer, int) |
按索引插入 |
getLayerAs<T>(String) |
取图层并转型 |
removeLayer(String) |
删除图层 |
addImage(String, Bitmap) |
注册 sprite 图片 |
addImage(String, Bitmap, boolean sdf) |
注册 SDF 图标,用于 icon-color 染色 |
addLayerBelow/Above 在接入已有样式的图层树时是必需的。直接用 addLayer 会把图层压在最上面,挡住标注——这是”我的线遮住了地名”这类问题的常见原因。
4.5 相机控制
MapLibreMap 的相机 API 分两组:瞬时跳转与动画。
// 瞬时跳转
map.cameraPosition = CameraPosition.Builder()
.target(LatLng(39.9042, 116.4074))
.zoom(10.0)
.bearing(0.0)
.tilt(0.0)
.build()
// 动画到目标(默认动画时长由 MapLibreConstants.ANIMATION_DURATION 决定)
map.animateCamera(
CameraUpdateFactory.newLatLngZoom(LatLng(31.2304, 121.4737), 12.0),
1500,
object : MapLibreMap.CancelableCallback {
override fun onFinish() { Log.d("Map", "camera arrived") }
override fun onCancel() { Log.d("Map", "camera cancelled") }
}
)
几个约束值直接写在 MapLibreConstants 里,可用 API 调整:
| 设置 | API | 说明 |
|---|---|---|
| 最小缩放 | setMinZoomPreference(float) |
小于该值不再缩 |
| 最大缩放 | setMaxZoomPreference(float) |
常用于限制在自己的瓦片层级范围 |
| 相机移动监听 | addOnCameraMoveListener(OnCameraMoveListener) |
每帧相机变化回调 |
| UI 控件 | getUiSettings() |
指南针、logo、attribution 开关 |
setMinZoomPreference/setMaxZoomPreference 是防护手段。用自己的瓦片集时,超出数据范围的缩放会请求不存在的瓦片,服务端 404 刷满日志。把 max zoom 限制在数据最大层级上更稳。
4.6 手势与交互
Android 的手势由 GesturesManager 体系处理,可以从 MapLibreMap 拿到并逐项开关。常用操作:
val ui = map.uiSettings
ui.isCompassEnabled = false
ui.isLogoEnabled = false
ui.isAttributionEnabled = true
// 关掉某类手势(例如让地图只读)
map.uiSettings.setAllGesturesEnabled(false)
setAllGesturesEnabled(false) 是”只读地图”最省事的做法,适合大屏看板。需要精细控制时,用 getGesturesManager() 拿到各手势识别器逐个设置阈值。
一个真实现象:上一行代码里的地图是只读的,但子 View(比如浮在上面的按钮)仍然响应——这是因为手势被地图消费之前已经在 View 层判定了。需要在父容器上做事件拦截,而不是只关掉地图手势。
4.7 查询与注解
运行时查询用于点击拾取:
map.addOnMapClickListener { latLng ->
Log.d("Map", "tapped at ${'$'}{latLng.latitude}, ${'$'}{latLng.longitude}")
true
}
onMapClick 返回 true 表示事件已消费。返回 false 时事件会继续向下传,可能触发别的监听器。
注解(Marker、Polyline、Polygon)是 Android 特有的一层封装,位于 org.maplibre.android.annotations。它比 Style 图层简单,适合少量交互元素;上百个以上就应该改用 GeoJSON source + layer,因为注解每个都会创建独立的 native 对象。
4.8 排错:Android 侧的高频问题
白屏,没有异常
按顺序排查:MapLibre.getInstance 是否调用、mapView.onStart() 是否转发、setStyle 是否在 getMapAsync 回调里调用。三者缺一都会白屏。
java.lang.IllegalStateException: MapLibreMap is not ready
在 getMapAsync 回调之前调用了地图方法。把逻辑移进回调,或用 addOnMapReadyCallback 排队。
setStyle 后立刻 getStyle() 得到 null
setStyle 异步。必须用带 Style.OnStyleLoaded 的重载。
图层挡住了标注
用了 addLayer 而不是 addLayerBelow。把业务图层插到 water、road-label 之类的锚点图层下方。
旋转屏幕后地图重置
onSaveInstanceState 没转发给 MapView。
native 崩溃日志里出现 libmaplibre.so
先看是不是跨线程调用地图 API。MapLibre 的 API 只能在主线程调用,在协程的 IO 调度器里直接调会崩。
MapTiler 底图 401 / 403
MapLibre.getInstance(context, apiKey, WellKnownTileServer.MapTiler) 需要有效 key。测试可以直接用 https://demotiles.maplibre.org/style.json,无需 key。
4.9 生产建议
- 优先用
MapFragment:自动处理生命周期,减少一整个类别的 bug。 - 图层操作统一收敛到样式回调:所有
addLayer/addSource放进OnStyleLoaded,避免异步竞态。 - 限制缩放范围:用
setMinZoomPreference/setMaxZoomPreference贴合自有瓦片层级,减少无效请求。 - 大数量要素用 GeoJSON source,不用注解:注解是逐对象封装,上百个后内存与帧率都明显劣化。
- R8/ProGuard 保留 native 回调类:JNI 通过反射调用
NativeMap等类,混淆会导致运行时找不到方法。官方 SDK 自带 consumer rules,自己写 JNI 类时要补-keep规则。
4.10 本章小结
| 要点 | 事实 |
|---|---|
| 入口初始化 | MapLibre.getInstance(context) 必须在 MapView 之前 |
| 包名 | org.maplibre.android,Style 在 org.maplibre.android.maps.Style |
| 样式异步 | 必须用 setStyle(String, Style.OnStyleLoaded) |
| 图层插入 | addLayerBelow/Above/At 控制绘制顺序 |
| 生命周期 | 8 个方法逐一转发;MapFragment 可自动处理 |
| 相机 | cameraPosition 瞬时、animateCamera 动画带 CancelableCallback |
| 只读地图 | uiSettings.setAllGesturesEnabled(false) |
| 注解 vs 图层 | 少量交互用注解,大量要素用 GeoJSON source |