手把手教你用 COMPOSE 开发地图 APP~

翻译自:www.darrylbayliss.net/jetpack-com...

原作者:Darryl Bayliss

前言

很难想象 Jetpack Compose 1.0 早在 2021 年 7 月 就发布了。

如今两年过去了,Google Play 上排名前 1000 的 App 中已有 24% 采用了 Compose 这个新技术, 其影响力可见一斑。

Jetpack Compose 作为 MAD(现代 Android 开发)理念中的一员,确实取得了不少成绩。但我留意到有个技术角落被大家忽视了,那就是 Map 地图。

其实,我有阵子没接触 Compose SDK 了,但最近突然发现 Google Map 紧随 MAD 的步伐,发布了自己的 Compose lib,那叫一个兴奋🤩。

对于从事地图、测绘行业的企业和员工来说,这无疑是一个重要的好消息。因为如今的移动端地图市场价值 355 亿美元,而且据预测,到 2028 年这个价值将陡增到 877 亿美元,计算下来的复合年增长率 (CAGR) 高达 19.83%。

为什么说这个信号很重要?

因为更大的市场意味着企业将拥有更多的机会,从移动端地图的应用中获得收益。

应用的范围除了常见的场景以外,还包括食品、杂货配送和叫车服务。 而且你深入挖掘一下,就会发现还有很多不那么明显,但着实有关系的场景。

以下是我简单搜索后整理到的应用场景:

  • 对于智能城市来说,移动端地图是绝佳选择。 它有助于掌握城市的心跳变化,并进行可视化展示,促使更好地理解和应对城市所面临的各项挑战。这个对象不限于城市规划者,也包括应急组织乃至普通居民
  • 资源管理也可以从地图方案中受益。 从农业到渔业、从采矿到林业,地图总能为这一领域的相关人员提供一个视角,协助他们做出可持续汲取资源的正确决策
  • 交通运输在很大程度上也依赖地图技术。 不仅仅是 Google Map、Uber 这种面向消费者的 App,还包括面向企业的地图功能。此外,交通机构还可以使用地图来管理交通,比如如何引导流量以疏通拥堵

鉴于气候变化和天气变得越加难以预测的状况,地图技术还可以帮助气象机构、应急响应单位和野生动物保护主义者更好地知悉世界正在发生怎样的变化,以及可以采取哪些积极措施来减缓、减少这种变化。

我们身处的世界在不断生产出新的数据、越来越多的数据,现在是时候去学习如何将这些庞杂的数据放在地图上呈现出来。

言归正传,让我们回到本文的主题:如何使用 Google 的 Compose Map lib 一步步实现这个目标!

1. 导入 COMPOSE MAP 库

需要按照如下配置导入 Google Maps for Compose lib:

groovy 复制代码
 dependencies {
   implementation "com.google.maps.android:maps-compose:2.11.4"
   implementation "com.google.android.gms:play-services-maps:18.1.0"
 
   // Optional Util Library
   implementation "com.google.maps.android:maps-compose-utils:2.11.4"
   implementation 'com.google.maps.android:maps-compose-widgets:2.11.4'
 
   // Optional Accompanist permissions to request permissions in compose
   implementation "com.google.accompanist:accompanist-permissions:0.31.5-beta"
 }

Google Maps for Compose 是基于 Google Maps SDK 实现的,所以需要额外导入该 SDK。事实上,开发者不需要直接使用该 SDK 中提供的大多数对象,因为 Compose lib 已经将这些对象都包装到 Composable 函数里了。

另外的 utilswidgets lib 不是必须的,这取决于你的需求:

  • utils 库提供了在地图上展示聚类标记的功能
  • widgets 则提供了额外的 UI 组件(稍后会详细说明)

此外,我还导入了 Accompanist 的权限库,以展示如何便捷地请求地图所需的位置权限。 说明一下,Accompanist 是一个实验性库,供 Google 试用并收集开发者的反馈,用于开发尚未成为 Jetpack Compose 的部分功能。

最后,还需要前往如下的 Google Developer Console 注册 Google Maps SDK API 密钥并配置到项目里。

安全建议: 在 Google Developer Console 中一定要给你的 API 密钥上锁,确保它只适用于您的 App,以避免其他未经授权的盗用。

2. 展示地图界面

展示基础的地图界面很容易:

kotlin 复制代码
  setContent {
     val hydePark = LatLng(51.508610, -0.163611)
     val cameraPositionState = rememberCameraPositionState {
         position = CameraPosition.fromLatLngZoom(hydePark, 10f)
     }
 
     GoogleMap(
         modifier = Modifier.fillMaxSize(),
         cameraPositionState = cameraPositionState) {
             Marker(
                 state = MarkerState(position = hydePark),
                 title = "Hyde Park",
                 snippet = "Marker in Hyde Park"
             )
         }
  }

首先,创建一个指向具体区域的 LatLng 对象,并将其与 rememberCameraPositionState 结合使用,来设定 Camera 的初始位置。用手拖动地图或通过代码控制地图移动时,此方法会 remember 地图的当前 position。如果没有使用该方法,Compose 会在状态更改时,总是将地图展示回初始位置。

接下来,调用 GoogleMap 可组合函数,并传入尺寸相关的 Modifier 修饰符和 CameraPosition 状态。

GoogleMap 还提供了一个 slot API 来传入额外的 Composable 函数,利用他们可以地图上展示额外的数据。

比如,我们添加一个 Marker Composable,然后绑定到其数据相关的 MarkerState,里面指定好 Marker 标记所需的标题和描述内容。

运行一下,便可以看到带有海德公园标记的西伦敦美丽鸟瞰图。

3. 自定义标记视图

可以使用 MarkerInfoWindowContent 函数来改写 Marker 标记的窗口视图。,而且它还提供了基于 slot 的 API,这意味着还可以传入任意可组合函数来展示自定义视图内容。

kotlin 复制代码
    setContent {
     val hydePark = LatLng(51.508610, -0.163611)
     val cameraPositionState = rememberCameraPositionState {
         position = CameraPosition.fromLatLngZoom(hydePark, 10f)
     }
 
     GoogleMap(
         modifier = Modifier.fillMaxSize(),
         cameraPositionState = cameraPositionState) {
             MarkerInfoWindowContent(
                 state = MarkerState(position = hydePark),
                 title = "Hyde Park",
                 snippet = "Marker in Hyde Park"
             ) { marker ->
                 Column(horizontalAlignment = Alignment.CenterHorizontally) {
                     Text(
                         modifier = Modifier.padding(top = 6.dp),
                         text = marker.title ?: "",
                         fontWeight = FontWeight.Bold
                     )
                     Text("Hyde Park is a Grade I-listed parked in Westminster")
                     Image(
                         modifier = Modifier
                             .padding(top = 6.dp)
                             .border(
                                 BorderStroke(3.dp, color = Color.Gray),
                                 shape = RectangleShape
                             ),
                         painter = painterResource(id = R.drawable.hyde_park),
                         contentDescription = "A picture of hyde park"
                     )
                 }
             }
         }
 }

比如我们自定义了一个 Compose 布局:采用 Column 包裹展示 title 的 Text 控件、展示描述的 Text 控件以及展示该地址图片的 Image 控件。

运行一下你会看到,在点击标记时会显示咱们自定义的窗口视图。

4. 展示多个标记

展示多个标记非常简单,根据需要传递多个 Marker 即可。让我们为伦敦西部的几个不同公园都添加上 Marker。

kotlin 复制代码
      setContent {
         val hydePark = LatLng(51.508610, -0.163611)
         val regentsPark = LatLng(51.531143, -0.159893)
         val primroseHill = LatLng(51.539556, -0.16076088)
         val cameraPositionState = rememberCameraPositionState {
             position = CameraPosition.fromLatLngZoom(hydePark, 10f)
         }
     
         GoogleMap(
             modifier = Modifier.fillMaxSize(),
             cameraPositionState = cameraPositionState) {
                 // Marker 1
                 Marker(
                     state = MarkerState(position = hydePark),
                     title = "Hyde Park",
                     snippet = "Marker in Hyde Park"
                 )
                 // Marker 2
                 Marker(
                     state = MarkerState(position = regentsPark),
                     title = "Regents Park",
                     snippet = "Marker in Regents Park"
                 )
                 // Marker 3
                 Marker(
                     state = MarkerState(position = primroseHill),
                     title = "Primrose Hill",
                     snippet = "Marker in Primrose Hill"
                 )
             }
      }

可以看到各标记成功出现在了地图上。

5. 展示聚类标记

地图 App 随时可能会在短时间内变得很忙碌,以快速展示用户所需的内容。如果我们在地图上展示多达 300 个标记,用户则很难看抓住地图上的重点。而且 Google Map 和硬件设备也会苦不堪言,因为它们必须渲染每个 Marker 标记,这会影响设备性能和电池寿命。

解决方案是 Clustering 聚类,这是一种将彼此靠近的 Markers 分组为单个 Marker 的技术。这种聚类操作建立在缩放级别的基础之上:

  • 缩小地图时,标记们将聚合在一起形成一个集群 Cluster
  • 放大地图时,该集群将分散成多个单独的标记 Markers

Google Maps for Compose 提供了 Clustering 可组合函数以满足该需求,无需开发者编写复杂的排序或过滤逻辑,轻松完成聚类。

kotlin 复制代码
      setContent {
         val hydePark = LatLng(51.508610, -0.163611)
         val regentsPark = LatLng(51.531143, -0.159893)
         val primroseHill = LatLng(51.539556, -0.16076088)
     
         val crystalPalacePark = LatLng(51.42153, -0.05749)
         val greenwichPark = LatLng(51.476688, 0.000130)
         val lloydPark = LatLng(51.364188, -0.080703)
         val cameraPositionState = rememberCameraPositionState {
             position = CameraPosition.fromLatLngZoom(hydePark, 10f)
         }
     
         GoogleMap(
             modifier = Modifier.fillMaxSize(),
             cameraPositionState = cameraPositionState) {
     
                 val parkMarkers = remember {
                     mutableStateListOf(
                         ParkItem(hydepark, "Hyde Park", "Marker in hyde Park"),
                         ParkItem(regentspark, "Regents Park", "Marker in Regents Park"),
                         ParkItem(primroseHill, "Primrose Hill", "Marker in Primrose Hill"),
                         ParkItem(crystalPalacePark, "Crystal Palace", "Marker in Crystal Palace"),
                         ParkItem(greenwichPark, "Greenwich Park", "Marker in Greenwich Park"),
                         ParkItem(lloydPark, "Lloyd park", "Marker in Lloyd Park"),
                     )
                 }
     
                 Clustering(items = parkMarkers,
                 onClusterClick = {
                     // Handle when the cluster is tapped
                 }, onClusterItemClick = { marker ->
                     // Handle when a marker in the cluster is tapped
                 })
             }
     }
     
     data class ParkItem(
         val itemPosition: LatLng,
         val itemTitle: String,
         val itemSnippet: String) : ClusterItem {
             override fun getPosition(): LatLng =
                 itemPosition
     
             override fun getTitle(): String =
                 itemTitle
     
             override fun getSnippet(): String =
                 itemSnippet
     }

请留意上述新增的 ParkItem data class, 因为传递到 Clustering 函数必须实现 ClusterItem 接口,所以我们得利用该类包装下需要聚类的各 Marker 信息,包括:位置、标题和描述。

通过放大和缩小操作,可以看到聚类的代码生效了。

6. 获取位置权限 授权

地图的显示通常要和用户的实时位置保持同步,因此地图 App 有理由去请求获取用户位置的许可。

尊重用户的权限。 可以说,位置权限是用户最敏感的权限之一。 明确地告知用户 App 需要此权限的理由,并积极说明获得该权限将带来的好处。如果 App 的某些功能完全不需要权限,则可获得用户的好感。

Google 官方提供了如何处理用户位置权限 的指导,以及如何在后台访问位置数据的说明。

进行充分的调查后,仍确定 App 需要用户权限来访问位置的话,可以使用 Accompanist 库中的权限库进行便捷地处理:

kotlin 复制代码
     // Don't forget to add the permissions to AndroidManifest.xml
     val allLocationPermissionState = rememberMultiplePermissionsState(
         listOf(android.Manifest.permission.ACCESS_COARSE_LOCATION,
                android.Manifest.permission.ACCESS_FINE_LOCATION)
     )
     
     // Check if we have location permissions
     if (!allLocationPermissionsState.allPermissionsGranted) {
         // Show a component to request permission from the user
         Column(
             horizontalAlignment = Alignment.CenterHorizontally,
             verticalArrangement = Arrangement.Center,
             modifier = Modifier
             .padding(horizontal = 36.dp)
             .clip(RoundedCornerShape(16.dp))
             .background(Color.white)
         ) {
             Text(
                 modifier = Modifier.padding(top = 6.dp),
                 textAlign = TextAlign.Center,
                 text = "This app functions 150% times better with percise location enabled"
             )
             Button(modifier = Modifier.padding(top = 12.dp), onClick = {
                 allLocationPermissionsState.launchMultiplePermissionsRequest()
             }) {
                 Text(text = "Grant Permission")
             }
         }
     }

如上代码所示,首先检查 App 是否已有访问 ACCESS_FINE_LOCATION 或者高精度 GPS 的权限。

如果未被授予,则展示一个对话框组合:向用户解释需要该权限的理由,并提供迁移到系统授予权限的按钮入口。

7. 展示地图移动动画

地图 App 通常需要用户通过触摸来移动视图,Google Maps for Compose 为此提供了移动视图的相应 API,让开发者们得以依据触摸事件将视图导航到指定的区域。

这里我们通过几个 Marker 标记的切换来展示地图的移动效果。

kotlin 复制代码
     Box(contentAlignment = Alignment.Center) {
         GoogleMap(
             modifier = Modifier.fillMaxSize(),
             cameraPositionState = cameraPositionState
         ) {
             Clustering(items = parkMarkers,
                 onClusterClick = {
                     // Handle when the click is tapped
                     false
                 }, onClusterItemClick = { marker ->
                     // Handle when the marker is tapped
                 })
     
             LaunchedEffect(key1 = "Animation") {
                 for (marker in parkMarkers) {
                     cameraPositionState.animate(
                         CameraUpdateFactory.newLatLngZoom(
                             marker.itemPosition, // LatLng
                             16.0f), // Zoom level
                           2000 // Animation duration in millis
                         ),
                         delay(4000L) // Delay in millis
                 }
             }
         }
     }

上述的代码关键在于 LaunchedEffect,其会遍历所有 Marker,逐个调用 cameraPositionState.animate() 来完成导航到它的操作。其中 Camera 会通过使用 newLatLngZoom() 来更新接收到的数据变化。

该方法的参数是 LatLng 类型,它包含:表示地图缩放级别的 float 数据和设置动画持续时长的 long 数据。

最后,为了区分开 Marker 间的动画,使用 delay() 在每个动画之间添加 4s 的停顿。

8. 展示额外街景

Google Maps for Compose 可以提供的不仅仅是一张航拍地图,在 App 被授予访问街景权限后还可以展示某位置的 360 度视图。

代码上,可以使用 StreetView 可组合函数项来实现:

kotlin 复制代码
     var selectedMarker: ParkItem? by remember { mutableStateOf(null) }
     
     if (selectedMarker != null) {
         StreetView(Modifier.fillMaxSize(), streetViewPanoramaOptionsFactory = {
             StreetViewPanoramaOptions().position(selectedMarker!!.position)
         })
     } else {
         Box(contentAlignment = Alignment.Center) {
             GoogleMap(
                 modifier = Modifier.fillMaxSize(),
                 cameraPositionState = cameraPositionState
             ) {
                 Clustering(items = parkMarkers,
                 onClusterClick = {
                     // Handle when the cluster is clicked
                     false
                 }, onClusterItemClick = { marker ->
                     // Handle when a marker in the cluster is clicked
                     selectedMarker = marker
                     false
                 })
             }
         }
     }

每当点击 Marker 时,代码里都会赋值 selectedMarker 变量,这意味着有 Marker 被选中了。这时候代码里将利用 Marker 里的位置信息去展示对应的 StreetView 视图。

9. 展示绘制形状/注释

开发者可能有在地图上绘制形状和注释的需求,Google Maps for Compose 相应地提供了诸多可组合函数来实现这类操作。

这里以 Circle 可组合函数为例进行说明。

如果 App 想要展示用户的当前位置,圆形则是一个不错的表现形式,可以考虑采用圆圈来表示用户活动的区域。

kotlin 复制代码
         Box(contentAlignment = Alignment.Center) {
             GoogleMap(
                 modifier = Modifier.fillMaxSize(),
                 cameraPositionState = cameraPositionState
             ) {
                 Clustering(items = parkMarkers,
                 onClusterClick = {
                     // Handle when the cluster is clicked
                     false
                 }, onClusterItemClick = { marker ->
                     // Handle when a marker in the cluster is clicked
                     selectedMarker = marker
                     false
                 })
             }
         }
     
         parkMarkers.forEach {
             Circle(
                 center = it.position,
                 radius = 120.0,
                 fillColor = Color.Green,
                 strokeColor = Color.Green
             )
         }

如上所示,为每个 Marker 标记设置一个圆圈:指定 Marker 的 position 为圆心以及半径,还有可选的边框的颜色和填充颜色。(Box 是堆叠布局,这样的话各圆圈就可以展示在 Map 的上方)

10. 展示比例尺

一幅好的地图通常会附有图例和图表,用于展示地图上的某一空间尺度相当于物理上的多大距离。这可以帮助用户准确您了解地图中展示的空间大小,因为并非每幅地图都采用相同的测量方式。

可对于那些支持缩放的数字地图而言,这个需求会增加实现上的复杂性,因为展示的距离在动态变化着。好在 Google Maps for Compose 同样考虑到了这点。

导入 Widgets 库之后,开发者可以使用 DisappearingScaleBarScaleBar 两个可组合函数。它们是位于地图界面顶部的 UI 组件,为用户展示依据缩放级别实时变化的距离参照值。

kotlin 复制代码
         Box(contentAlignment = Alignment.Center) {
             GoogleMap(
                 modifier = Modifier.fillMaxSize(),
                 cameraPositionState = cameraPositionState
             ) {
                 // You can also use ScaleBar
                 DisappearingScaleBar(
                     modifier = Modifier
                     .padding(top = 5.dp, end = 15.dp)
                     .align(Alignment.TopStart),
                     cameraPositionState = cameraPositionState
                 )
     
                 Clustering(items = parkMarkers,
                 onClusterClick = {
                     // Handle when the cluster is clicked
                     false
                 }, onClusterItemClick = { marker ->
                     // Handle when a marker in the cluster is clicked
                     selectedMarker = marker
                     false
                 })
             }
         }
     
         parkMarkers.forEach {
             Circle(
                 center = it.position,
                 radius = 120.0,
                 fillColor = Color.Green,
                 strokeColor = Color.Green
             )
         }

如下所示,Map 顶部将出现一个比例尺,它会随着缩放级别保持刷新。

MAP 学习资料

Google Maps for Compose 是 Compose 中集成地图功能的绝佳方式,而且还有很多其他的知识需要持续的学习。 如果有需要,可以参考如下几方面资料:

  • Google Maps for Compose Repo:官方的 Compose Map lib 源码库。还包括代码示例,以及反馈 bug、贡献代码的说明等
  • Android 版 Google 地图网站:Google Map 背后的概念、逻辑。虽然跟 Compose 库没关系,但因为实际上是它们在背后提供的数据支持,有必要了解一下
  • Google Maps Platform Discord Google Map 官方的 Discord 服务。让大家聚在一起讨论多个平台上的 Google Map 表现、寻求和提供帮助以及展示个人集成的 Map 成品等
相关推荐
众拾达人4 分钟前
Android自动化测试实战 Java篇 主流工具 框架 脚本
android·java·开发语言
吃着火锅x唱着歌1 小时前
PHP7内核剖析 学习笔记 第四章 内存管理(1)
android·笔记·学习
_Shirley2 小时前
鸿蒙设置app更新跳转华为市场
android·华为·kotlin·harmonyos·鸿蒙
hedalei4 小时前
RK3576 Android14编译OTA包提示java.lang.UnsupportedClassVersionError问题
android·android14·rk3576
锋风Fengfeng4 小时前
安卓多渠道apk配置不同签名
android
枫_feng5 小时前
AOSP开发环境配置
android·安卓
叶羽西5 小时前
Android Studio打开一个外部的Android app程序
android·ide·android studio
qq_171538856 小时前
利用Spring Cloud Gateway Predicate优化微服务路由策略
android·javascript·微服务
Vincent(朱志强)8 小时前
设计模式详解(十二):单例模式——Singleton
android·单例模式·设计模式
mmsx8 小时前
android 登录界面编写
android·登录界面