首页 / 资讯中心 / 文章详情

Android 地图开发避坑指南:生命周期、Marker 与 Polyline 实战

Android 地图开发避坑指南:生命周期、Marker 与 Polyline 实战 ★ FEATURED ARTICLE
简介以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:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / application meta-data android:nameMAP_API_KEY android:value${MAPS_API_KEY} / activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.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:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent com.example.mapdemo.widget.MapViewContainer android:idid/map_view android:layout_widthmatch_parent android:layout_heightmatch_parent / RadioGroup android:idid/map_type_group android:layout_widthwrap_content android:layout_heightwrap_content android:layout_alignParentBottomtrue android:layout_marginBottom24dp android:orientationhorizontal RadioButton android:idid/type_normal android:layout_widthwrap_content android:layout_heightwrap_content android:textstring/map_type_normal / RadioButton android:idid/type_satellite android:layout_widthwrap_content android:layout_heightwrap_content android:textstring/map_type_satellite / /RadioGroup /RelativeLayoutMapViewContainer是 Demo 自己封装的一层薄控件它包住了 SDK 原生的地图 View。在这里我故意不写具体品牌类名因为不同 SDK 的 MapView 类名不同但生命周期职责是一样的。MapView 为什么要包一层因为如果你直接把原生 MapView 写进 xmlFragment 重建时很难控制它何时销毁一旦控制不好就会出现第 5 章要讲的闪退问题。封装之后容器可以在onDestroyView里明确释放底层资源。启动链路到这里就清晰了MainActivity 申请权限成功后 replace MapFragmentMapFragment 在onViewCreated里拿到 MapViewContainer调用它的onCreate和getMapAsyncSDK 异步创建地图实例最终回调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.xmlmeta-data 里再引用string/maps_api_key。这做法 Debug 阶段确实能跑但发布后 APK 的 resources 很容易被反编译等于把密钥公开了。这份 Demo 的配置方式更稳妥Gradle 占位符加本地属性文件。在运行时读取 Key 的部分Demo 写了一个很薄的 MapKeyProviderobject 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 不会再执行默认行为。如果返回 falseSDK 会额外做一次移动相机让 Marker 居中这通常不是你想要的。showInfoWindow()是手动弹窗在列表和地图联动的场景里很有用用户点击列表项时找到对应 Marker 并调用这个方法让地图自动弹出详情。还有一个容易被忽略的点多个 Marker 靠得很近时点按区域会互相覆盖判断点击目标时要用marker.id而不是marker.title因为 title 用户看得到却不一定唯一id 才是内部标识。4.2 Polyline路线就是一组经纬度按顺序连起来Polyline 画起来很简单但坑在坐标系和像素宽度。先看基础用法class RouteDrawer(private val map: MapContext?) { fun draw(points: ListLatLng) { 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 ): ListLatLng { 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): ListLatLng { 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里调onDestroyMapView 持有的 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(): ListMarkerItem suspend fun getRoute(start: LatLng, end: LatLng): ListLatLng } 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 下看一眼就交付。地图模块的坑基本都在配置层真正静下心排查一次后面就会顺手很多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站