☰
Android 地图开发避坑指南:生命周期、Marker 与 Polyline 实战
2026/10/18 14:58:05 网站建设 项目流程

简介:以Android谷歌地图API集成为核心的轻量级入门Demo工程,对应MapsDemo2.2,主要面向刚接触Android地图开发、希望快速跑通地图展示与定位完整流程的初级开发者。资源包共27个文件,整体仅59KB,内含MapView布局文件与MainActivity回调等Java源码,同时包含APK、DEX、resources.ap、proguard配置、工程属性与classpath等编译产物和工程配置,便于对照可运行成品逐项理解接入步骤。目前已有567人浏览学习,适合作为Android应用中接入谷歌地图的起步模板。通过阅读工程可系统掌握从API密钥申请、在AndroidManifest中声明网络与定位权限,到初始化MapView、借助getMapAsync与onMapReady移动镜头、开启我的位置并添加标记的完整链路;还能看到Google Play服务接入后Android项目打包生成的class、资源、dex等典型文件结构,以及压缩包内assets、gen、bin等目录的常规布局,为后续扩展离线地图、路线规划、自定义图层等复杂功能打下基础。

1. 一个看起来普通的 Android 地图 Demo,为什么值得逐行拆解

很多人拿到这份「Android 地图 API Demo」时,第一反应是“这不就是一个 Hello Map 模板吗”。真正跑起来才发现,地图模块的坑几乎全在配置层:Key 对不上、权限没申请、MapView 生命周期没同步,结果要么白屏、要么只显示网格、要么退出再进入直接闪退。这份资源好就好在,它把地图 SDK 从接入到使用的完整链路——申请密钥、配置依赖、初始化地图、添加 Marker、绘制 Polyline 路线、切换卫星图——全部串在一条主流程里,还顺带做了权限申请和状态容错。对刚接手地图开发的人来说,这是一份可以照着改的起手模板;对已经写过的熟手来说,也能从它的生命周期处理和兜底策略里看到一些值得保留的习惯。下面我按「工程结构 → 核心实现 → 避坑 → 二次开发」的顺序拆开讲。

2. 工程结构拆解:单 Activity 与地图 Fragment 的协作方式

2.1 为什么把地图页拆成独立的 Fragment

这个 Demo 没有把地图逻辑直接堆在 MainActivity 里,而是单独拆出了一个 MapFragment。这个设计不是炫技,而是地图 SDK 的特殊性决定的。一个 MapView 从创建到首帧瓦片加载完成,中间要经历几十个回调,还要处理权限弹窗、位置回调、相机动画、页面旋转。如果把这一切都塞进 Activity,只要后续新增一个底部弹窗或者二级入口,Activity 就会迅速膨胀到没法维护。

我拿到的这份源码里,MainActivity 的角色非常克制:申请定位权限、把 MapFragment 放进容器。所有地图相关逻辑都收在 MapFragment 内部。这样做还有一个实际好处:退出地图页时只需要销毁 Fragment,地图实例不会在后台继续占着 GL 纹理和网络连接。如果你将来要在一个 App 里多处展示地图,这个结构可以直接复用。

2.2 目录结构:每个文件负责哪件事

app/ ├── build.gradle.kts ├── proguard-rules.pro └── src/main/ ├── AndroidManifest.xml ├── java/com/example/mapdemo/ │ ├── MainActivity.kt │ ├── map/ │ │ ├── MapFragment.kt │ │ └── MapViewModel.kt │ ├── location/ │ │ └── LocationHelper.kt │ ├── route/ │ │ └── RouteDrawer.kt │ └── util/ │ └── MapKeyProvider.kt └── res/ ├── layout/ │ ├── activity_main.xml │ └── fragment_map.xml ├── values/ │ ├── strings.xml │ └── colors.xml └── drawable/ └── ic_marker_custom.png

这个工程结构很典型,没有一个类是多余的工具类。MainActivity 负责权限入口;MapFragment 负责创建 MapView 和接收 onMapReady 回调;MapViewModel 用来存地图实例和当前相机状态,避免旋转屏幕时把地图引用丢掉;LocationHelper 把定位请求和结果回调封装掉,不污染页面层;RouteDrawer 只接收坐标列表和地图实例,专注画折线;MapKeyProvider 则从 meta-data 里读密钥。你在二次开发时,新增的搜索、周边推荐、轨迹回放等模块,都可以沿着这几个文件继续扩展,而不是往 MapFragment 里无脑加方法。

2.3 从安装到看到地图:启动链路和清单配置

先看 AndroidManifest.xml 里跟地图和定位相关的声明:

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <application> <meta-data android:name="MAP_API_KEY" android:value="${MAPS_API_KEY}" /> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>

这里MAP_API_KEY是我按常见写法起的名字,不同地图 SDK 对 meta-data 的 name 要求不一样,你拿到源码后去官方接入文档里确认一下,但${MAPS_API_KEY}这种占位符思路是通用的。meta-data 不直接用字符串写死,是因为 Debug 和 Release 通常要用两个不同的 Key。通过占位符,Gradle 在构建时能自动替换。

构建脚本里对应的配置是这样:

// app/build.gradle.kts android { defaultConfig { manifestPlaceholders["MAPS_API_KEY"] = getKeyByBuildType() } } fun getKeyByBuildType(): String { val prop = Properties().apply { val f = rootProject.file("local.properties") if (f.exists()) { f.inputStream().use { load(it) } } } return prop.getProperty("MAPS_API_KEY", "") }

逻辑说明:这段脚本先从项目根目录的local.properties读取密钥,读不到就返回空字符串。local.properties默认在 .gitignore 里,所以密钥不会被提交到版本库。manifestPlaceholders的注入发生在 Gradle 配置阶段,运行时 MapFragment 并不知道也不关心 Key 存在哪。这里的getKeyByBuildType()只是一个兜底实现,你完全可以在里面写死 Debug/Release 不同来源,只是不要真的把字符串写进源码。

再往下看布局。fragment_map.xml 里没有堆控件,核心就是一个地图容器加底部一个地图类型切换栏:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <com.example.mapdemo.widget.MapViewContainer android:id="@+id/map_view" android:layout_width="match_parent" android:layout_height="match_parent" /> <RadioGroup android:id="@+id/map_type_group" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_marginBottom="24dp" android:orientation="horizontal"> <RadioButton android:id="@+id/type_normal" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/map_type_normal" /> <RadioButton android:id="@+id/type_satellite" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/map_type_satellite" /> </RadioGroup> </RelativeLayout>

MapViewContainer是 Demo 自己封装的一层薄控件,它包住了 SDK 原生的地图 View。在这里我故意不写具体品牌类名,因为不同 SDK 的 MapView 类名不同,但生命周期职责是一样的。MapView 为什么要包一层?因为如果你直接把原生 MapView 写进 xml,Fragment 重建时很难控制它何时销毁,一旦控制不好就会出现第 5 章要讲的闪退问题。封装之后,容器可以在onDestroyView里明确释放底层资源。

启动链路到这里就清晰了:MainActivity 申请权限,成功后 replace MapFragment;MapFragment 在onViewCreated里拿到 MapViewContainer,调用它的onCreate和getMapAsync;SDK 异步创建地图实例,最终回调onMapReady。地图实例一旦就绪,后续的 Marker 和 Polyline 操作才有落点。

这里还有一个不少新手会选错的点:到底用 SupportMapFragment 还是 MapView。SupportMapFragment 写起来更省事,但它是 Fragment 嵌套 Fragment,生命周期由 FragmentManager 接管,自定义布局时要再套一个容器,麻烦。Demo 选 MapView 而不是 SupportMapFragment,就是要自己掌控生命周期,这也是后面第 5 章几个坑的源头。

3. 核心模块拆解:Key 配置、地图初始化与定位的完整链路

3.1 API Key 配置:别把密钥写在 strings.xml 里

地图 SDK 接入的第一步是配置 Key。很多新手为了省事,直接把 Key 写进 strings.xml,meta-data 里再引用@string/maps_api_key。这做法 Debug 阶段确实能跑,但发布后 APK 的 resources 很容易被反编译,等于把密钥公开了。这份 Demo 的配置方式更稳妥:Gradle 占位符加本地属性文件。

在运行时读取 Key 的部分,Demo 写了一个很薄的 MapKeyProvider:

object MapKeyProvider { fun obtain(context: Context): String { val appInfo = context.packageManager .getApplicationInfo( context.packageName, PackageManager.GET_META_DATA ) return appInfo.metaData?.getString("MAP_API_KEY") ?: "" } }

这段代码在 MapView 创建之前调用。为什么必须在创建之前读?因为 SDK 初始化时会读取 meta-data 去做校验,如果等到 onMapReady 才读到空 Key,地图实例已经带着错误状态启动了,表现就是瓦片加载全部失败。用GET_META_DATA标志是为了让getApplicationInfo把 meta-data 一并带出来,不加这个标志,返回的 ApplicationInfo 里 metaData 字段是 null。返回空字符串兜底,是为了避免在 release 包里因为 Key 缺失直接抛异常,宁可地图白屏也不要崩溃。如果你是接手项目,第一件事应该是打开 local.properties 确认MAPS_API_KEY是否存在,再决定要不要往 MapKeyProvider 里加日志。

3.2 MapView 初始化:所有生命周期方法都要同步调用

地图 View 不是普通 View,它内部有 GL 纹理、网络线程、手势监听器,Android 系统不会自动帮你管理它的生命周期。SDK 要求你在 Fragment 的每个生命周期回调里,显式调用 MapView 对应的同名方法。MapFragment 里最核心的一段代码长这样:

class MapFragment : Fragment(), OnMapReadyCallback { private var mapView: MapViewContainer? = null private var map: MapContext? = null private var savedBundle: Bundle? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { savedBundle = savedInstanceState return inflater.inflate(R.layout.fragment_map, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) mapView = view.findViewById(R.id.map_view) mapView?.onCreate(savedBundle) mapView?.getMapAsync(this) } override fun onResume() { super.onResume() mapView?.onResume() } override fun onPause() { mapView?.onPause() super.onPause() } override fun onDestroyView() { mapView?.onDestroy() super.onDestroyView() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) mapView?.onSaveInstanceState(outState) } override fun onMapReady(map: MapContext) { this.map = map map.uiSettings.isZoomGesturesEnabled = true map.uiSettings.isCompassEnabled = true } }

逻辑说明里有两个点要特别注意。第一,onCreate传入的 Bundle 是之前保存的地图状态,它和 Fragment 的savedInstanceState不是同一个,如果你把 Fragment 的 Bundle 混传,地图恢复时会因为数据不兼容而丢失缩放级别和中心点。第二,getMapAsync是异步回调,onMapReady里拿到的map对象不会在onViewCreated之后立刻可用,所以任何外部调用都要先判断空指针。onDestroyView和onDestroy的区别也值得强调:onDestroyView表示视图树要销毁但 Fragment 还可能回来,地图资源必须在这里释放;如果把释放写在onDestroy,第二次进入页面时 MapView 已经 detach 了。这里我用了MapViewContainer和MapContext作为占位名,真实替换成你接的 SDK 对应类型即可。

3.3 运行时定位:权限申请和位置回调的封装

定位权限在 Android 6.0 之后是危险权限,必须运行时申请。Demo 里 MainActivity 的写法很有代表性:

class MainActivity : AppCompatActivity() { private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result -> val fine = result[Manifest.permission.ACCESS_FINE_LOCATION] == true val coarse = result[Manifest.permission.ACCESS_COARSE_LOCATION] == true if (fine || coarse) { openMapFragment() } else { Toast.makeText(this, "定位权限被拒绝,地图仍然可以浏览", Toast.LENGTH_SHORT).show() openMapFragment() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) permissionLauncher.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } private fun openMapFragment() { supportFragmentManager.beginTransaction() .replace(R.id.container, MapFragment.newInstance()) .commit() } }

这里的细节是,权限被拒绝时也照常进入地图页,只是没有定位能力。很多 Demo 把权限申请和地图初始化串成强依赖,用户一旦拒绝就白屏,这体验是非常差的。定位只是地图功能的增强项,不应该阻塞地图展示。registerForActivityResult是 AndroidX 提供的结果回调方式,不需要重写onRequestPermissionsResult,代码也更好读。

定位请求的封装在 LocationHelper 里,配合融合定位客户端使用:

class LocationHelper(private val context: Context) { fun requestSingleUpdate(callback: (LatLng) -> Unit) { val client = LocationServices.getFusedLocationProviderClient(context) val request = LocationRequest.Builder(1000L) .setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY) .setWaitForAccurateLocation(false) .build() client.getCurrentLocation(LocationRequest.PRIORITY_HIGH_ACCURACY, null) .addOnSuccessListener { location -> callback.invoke(LatLng(location.latitude, location.longitude)) } .addOnFailureListener { e -> Log.e("LocationHelper", "getCurrentLocation failed", e) } } }

参数说明:LocationRequest.Builder(1000L)里的 1000 是更新间隔,单位毫秒,Demo 只需要单次定位,所以这个值不影响结果;PRIORITY_HIGH_ACCURACY会让系统优先使用 GPS 和网络定位,代价是耗电高,但 Demo 场景更看重位置准度;setWaitForAccurateLocation(false)表示不强制等精度校准,有缓存位置就先返回,这样回调速度更快,适合冷启动地图时快速落位。getCurrentLocation的第一个参数是优先级,第二个参数是取消令牌,这里传 null 即可。最终拿到的Location对象,latitude 和 longitude 转成LatLng丢给地图就够了。在模拟器上跑的时候,记得先在扩展工具里手动设置一个经纬度,否则这个回调大概率一直超时。

4. 业务功能落地:Marker 点击、Polyline 路线与图层切换

4.1 Marker:不只是把点丢到地图上

Demo 的核心功能是标记点和展示路线。Marker 的添加代码非常直白:

val markerOptions = MarkerOptions() .position(LatLng(12.345678, 45.678901)) .title("起点") .snippet("点击查看详情") .anchor(0.5f, 1.0f) .icon(BitmapDescriptorFactory.fromResource(R.drawable.ic_marker_start)) map?.addMarker(markerOptions)

逻辑说明:position必填,没有经纬度 SDK 会直接抛异常;title和snippet组合起来是点击 Marker 后弹出的信息窗内容;anchor表示图标上哪个点对齐到经纬度坐标,(0.5f, 1.0f)的意思是图标底部中心对准坐标点,导航类 App 的起点终点 Marker 基本都用这种锚点;icon不传就用默认红点,传了自定义图要注意尺寸,建议 48dp 以内,否则会挡住周边地图内容且影响点按命中。

Marker 的点击回调是区分「能看」和「能用」的关键:

map?.setOnMarkerClickListener { marker -> marker.showInfoWindow() Toast.makeText(requireContext(), "点击了 ${marker.title}", Toast.LENGTH_SHORT).show() true }

这里true表示事件已经被消费,SDK 不会再执行默认行为。如果返回 false,SDK 会额外做一次移动相机让 Marker 居中,这通常不是你想要的。showInfoWindow()是手动弹窗,在列表和地图联动的场景里很有用:用户点击列表项时,找到对应 Marker 并调用这个方法,让地图自动弹出详情。还有一个容易被忽略的点:多个 Marker 靠得很近时,点按区域会互相覆盖,判断点击目标时要用marker.id而不是marker.title,因为 title 用户看得到却不一定唯一,id 才是内部标识。

4.2 Polyline:路线就是一组经纬度按顺序连起来

Polyline 画起来很简单,但坑在坐标系和像素宽度。先看基础用法:

class RouteDrawer(private val map: MapContext?) { fun draw(points: List<LatLng>) { if (points.size < 2) return val polylineOptions = PolylineOptions() .addAll(points) .width(14f) .color(0xFF2B7FFF.toInt()) .geodesic(true) map?.addPolyline(polylineOptions) } }

参数说明:width的单位是屏幕像素,不是 dp。同样 14f 在不同屏幕密度下粗细不一致,想统一就要用TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14f, resources.displayMetrics)转一次。color是 ARGB 值,0xFF2B7FFF表示完全不透明的蓝色,注意高位是透明度,漏写0xFF会出现一条几乎看不见的线条。geodesic(true)让线段沿着地球大圆路径绘制,短距离路线差异很小,跨城市路线必须开,否则直线会穿过地球内部。

Demo 里给 RouteDrawer 准备了两组数据:一组是写死的示例坐标,一组是通过网络接口拉回来的真实路线。网络部分用协程封装比较干净:

suspend fun fetchRouteWithFallback( start: LatLng, end: LatLng ): List<LatLng> { return runCatching { val url = "https://your-server/route?" + "start=${start.latitude},${start.longitude}" + "&end=${end.latitude},${end.longitude}" val json = HttpUtil.get(url) parseRouteJson(json) }.getOrElse { listOf(start, end) } } fun parseRouteJson(json: String): List<LatLng> { val array = JSONObject(json) .getJSONObject("route") .getJSONArray("points") return List(array.length()) { i -> val point = array.getJSONObject(i) LatLng(point.getDouble("lat"), point.getDouble("lng")) } }

这段代码的容错思路值得学习:网络请求失败时不弹错误页,而是退化成一条从起点到终点的直线,用户至少能看到两点之间的相对关系。不过真实项目里直线只能当兜底,你还需要加一个重试按钮,否则用户会以为路线就是这么直。还要留意,URL 参数里的经纬度由数字和负号组成,不需要做 URL encode,但如果你的后端返回了额外的空格或特殊字符,JSON 解析就会翻车,所以最好在后端统一格式化。坐标点数量很多时,Polyline 的性能也要注意:几千个点一次性 addAll 会让地图掉帧,常见做法是先用道格拉斯-普克算法抽稀,保留关键拐点,再交给地图绘制。

4.3 地图类型切换:卫星图、地形图与普通图

Demo 底部有一个 RadioGroup 用来切换地图类型,核心逻辑只有几行:

mapTypeGroup.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.type_normal -> map?.mapType = MAP_TYPE_NORMAL R.id.type_satellite -> map?.mapType = MAP_TYPE_SATELLITE R.id.type_terrain -> map?.mapType = MAP_TYPE_TERRAIN R.id.type_hybrid -> map?.mapType = MAP_TYPE_HYBRID } }

逻辑说明:MAP_TYPE_NORMAL是默认矢量图,道路和 POI 都是矢量渲染,缩放时文字不模糊;MAP_TYPE_SATELLITE是纯卫星影像,没有道路标注,适合看地表形态;MAP_TYPE_HYBRID是卫星图叠加道路和地名,户外路线和骑行类 App 最常用;MAP_TYPE_TERRAIN带等高线,适合登山徒步。切换类型会触发瓦片重新加载,如果在新类型还没加载完时又切回去,会看到短暂空白,属于正常现象,不用管。

切换地图类型时,已经画好的 Marker 和 Polyline 不会被清除,因为它们属于覆盖物层,和底图类型无关。如果你后续加了自定义地图样式,比如夜间模式,那设置的是 map 的样式对象而不是 mapType,两者不要混。地图的手势设置也建议在onMapReady里统一配好:

map?.uiSettings?.apply { isZoomGesturesEnabled = true isScrollGesturesEnabled = true isCompassEnabled = true isMyLocationButtonEnabled = false }

这里把缩放和平移手势打开,指南针显示出来,但定位按钮关掉。定位按钮关掉的原因是我在 MainActivity 里已经自己申请了权限,如果 SDK 的定位按钮也打开,会出现双重权限申请,用户会被连续弹窗轰炸。你把 Demo 改成正式项目时,这个是高频雷区。

5. 避坑指南:地图空白、定位失效与闪退的常见排查

5.1 地图只出网格,看不见道路和文字

现象:地图控件已经显示,底图是浅灰色网格,右下角有版权信息,但道路、城市名、POI 全都没有。

原因:这个现象九成是 API Key 没有通过校验。SDK 初始化时,会用当前 APK 的包名和签名 SHA1 去服务端校验 Key,两者只要有一个不匹配,瓦片请求就会返回 403,客户端拿不到数据就只能画网格。另一个常见原因是 meta-data 的 name 写错,SDK 读不到完整 Key 时表现一模一样。

解决:先在终端过滤日志,看有没有权限校验失败的线索:

adb logcat | grep -iE "apikey|key|denied"

如果日志里出现Request denied或403,按下面顺序排查:确认 AndroidManifest 里的 applicationId 是不是你在服务后台填的包名;确认当前安装包用的是不是 debug 签名;确认 meta-data 名字和官方要求一致;最后确认 local.properties 里的MAPS_API_KEY是否包含空格或换行。改完 Key 后,建议先卸载 App 再重装,因为部分 SDK 会把校验失败结果缓存住,直接覆盖安装可能还是白屏。这个坑最大的困扰是它不报崩溃,只默默出网格,所以你一定要先看日志再动手。

5.2 定位成功,但相机没有移动到当前城市

现象:点击定位按钮,Toast 显示拿到了经纬度,地图却停在初始视野;或者先跳到正确位置,又弹回原点。

原因:这是典型的时序冲突。定位回调是异步的,onMapReady 也不一定先执行。MainActivity 里权限通过后立刻 replace Fragment,此时地图还没 ready,定位回调却先回来了,拿着没有 map 实例的对象去移动相机,自然不会生效。另一种情况是先后调用了多个移动相机的代码,后面的把前面覆盖了。

解决:把「定位成功」和「地图就绪」当作两个独立事件,用一个小状态机统一判断:

class CameraController { private var mapReady = false private var pendingTarget: LatLng? = null fun onMapReady() { mapReady = true pendingTarget?.let { moveTo(it) } } fun onLocationReceived(target: LatLng) { if (mapReady) { moveTo(target) } else { pendingTarget = target } } private fun moveTo(target: LatLng) { map?.animateCamera(CameraUpdateFactory.newLatLngZoom(target, 15f)) } }

pendingTarget会在地图就绪后自动消费,这个模式不只适合地图,任何「异步数据先到、初始化后到」的场景都能用。需要注意animateCamera是带动画的移动,如果在它之后又调了moveCamera,动画会被打断,所以每次改动相机前,先确认没有别的协程在写同一份 camera 状态。

5.3 权限同意后还是拿不到位置

现象:第一次点允许,没有任何定位结果;或者 getCurrentLocation 一直回调失败。

原因:有几种情况。第一,室内高精度定位基本拿不到 GPS 信号,融合定位会切到网络定位,而网络定位依赖基站和 Wi-Fi 数据库,模拟器完全没有这些数据;第二,同时申请了精确定位和粗略定位,但用户只同意了一个;第三,Android 12 之后,用户可以在系统设置里单独关闭「精确定位」开关,只给大致位置,老代码不一定能感知这个变化。

解决:先检查定位服务有没有打开:

val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) return }

然后在addOnFailureListener里把错误码打出来,区分location unavailable、timeout、permission denied三种情况,不要统一弹一个「定位失败」。真机测试比模拟器可靠,测试时尽量靠近窗边或室外。用户拒绝精确定位时,可以降级用粗略位置,但地图页要提示当前精度不足,否则用户看到偏移的定位点会以为是 bug。

5.4 退出地图页再进来,第二次闪退

现象:第一次进地图页一切正常,返回后再次进入,应用崩溃,日志指向 MapView 相关类。

原因:MapView 的资源没有跟随 Fragment 的销毁步骤走。如果只调了onResume和onPause,没有在onDestroyView里调onDestroy,MapView 持有的 GL 纹理不会释放。Fragment 重建时,新的 MapView 想复用同一份底层资源,冲突就产生了。另一个隐藏原因是手动缓存了 MapView 实例,第二次进入直接使用旧对象,SDK 不允许同一个 MapView 被 attach 两次。

解决:严格按生命周期顺序同步调用,并且在onDestroyView里把引用置空:

override fun onDestroyView() { super.onDestroyView() mapView?.onDestroy() mapView = null }

Fragment 重建时不要自己保存 MapView 引用,让重建后的 Fragment 重新从布局里 findViewById。App 里有多个入口都要展示地图时,优先复用同一个 Fragment 实例,而不是反复 replace 创建新的。如果你用了 ViewPager 或者底部导航,切页时 Fragment 可能会被移出视图树,这时候也要重新走一遍onCreateView流程,不能直接拿旧的 mapView 继续操作。

5.5 release 包地图空白,debug 一切正常

现象:debug 包能正常看到地图,release 包地图区域完全空白,连网格都没有,logcat 里也看不到地图 SDK 的日志。

原因:第一个嫌疑是混淆。地图 SDK 内部有很多通过反射调用的类,release 构建开了 minifyEnabled 后,类名和方法名被重命名,SDK 反射不到就初始化失败。第二个嫌疑是签名。release 的 SHA1 和 debug 不一样,但 Key 只绑了 debug 签名。第三个嫌疑是 release 的 manifestPlaceholders 没有注入 Key。

解决:在 proguard-rules.pro 里 keep 住地图 SDK 的包名,不要整库 keep,否则会把 SDK 体积全部带进 APK。一个可参考的写法:

-keep class com.your.mapsdk.** { *; } -keep class com.your.mapsdk.model.** { *; }

这里com.your.mapsdk是占位包名,替换成你实际接入的 SDK 包名即可。混淆规则写完以后,用 release 包跑一次日志,看到具体哪个类报 ClassNotFoundException,再收紧 keep 范围。签名问题则是回到开发者后台,把 release 签名的 SHA1 也加进 Key 绑定。最后检查 release 构建时MAPS_API_KEY占位符有没有被赋值,很多项目的 release 只配置了minifyEnabled,忘了配 placeholder。

6. 二次开发:把这份 Demo 改成自己的地图模块

6.1 抽数据源:让 Marker 不写死

Demo 里写死两个坐标点还忍得住,一旦要显示 100 个门店,把数据硬编码在 MapFragment 里就是灾难。我一般会先加一层接口:

interface MapDataSource { suspend fun getMarkers(): List<MarkerItem> suspend fun getRoute(start: LatLng, end: LatLng): List<LatLng> } data class MarkerItem( val id: String, val position: LatLng, val title: String, val snippet: String )

MapFragment 只依赖接口,不依赖具体实现。本地 JSON 数据、后端接口、数据库都能作为实现注入。配合协程,地图页就能从「启动后写死两个点」变成「异步拉取、失败重试、下拉刷新」的完整流程。

6.2 给地图页加参数:从固定位置变成可选落地页

在 MapFragment 里加一个newInstance(lat, lng, title)静态入口,把 Marker 的 position 和 title 改成从构造参数读取。这样地图页可以从首页入口跳进来定位到某个点,也可以从列表页跳进来高亮某个门店。参数要放进 Fragment 的 arguments,不要写成成员变量直接传,否则 Activity 被系统回收后 Fragment 重建时参数会丢。

6.3 验证清单:改造完先跑这几项

验证点操作通过标准
初始视野冷启动进入地图页能看到默认城市或上次 View 状态,无网格空白
权限交互首次安装后点击定位弹窗允许后地图移到当前位置,拒绝后不崩溃
覆盖物点击 Marker、切换底图类型信息窗弹出、卫星图切换后 Marker 仍在
生命周期进入地图页 3 次,期间锁屏无闪退,位置不会漂回原点
Release 验证gradlew assembleRelease 后安装地图正常显示,无空白

最后分享一个个人习惯:我每次接地图类需求,都会先把第 5 章这五条按照「现象 → 原因 → 解决」的格式抄到需求文档最前面,让同事在开发前就知道哪里容易翻车。项目验收前,也强制把上面这张表从头跑一遍,尤其是 release 包,别只在 debug 下看一眼就交付。地图模块的坑基本都在配置层,真正静下心排查一次,后面就会顺手很多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询