在 Android 上以 AR 模式显示附近的地点 (Kotlin)

1. 准备工作

摘要

此 Codelab 会教您如何使用 Google Maps Platform 中的数据在 Android 上以增强现实 (AR) 模式显示附近的地点。

2344909dd9a52c60.png

前提条件

  • 基本了解如何使用 Android Studio 进行 Android 开发
  • 熟悉 Kotlin

学习内容

  • 向用户请求使用设备相机和获取设备位置信息的权限。
  • Places API 集成,以提取设备位置附近的地点。
  • ARCore 集成,以查找可作为水平面的表面,从而可使用 Sceneform 在 3D 空间中锚定和放置虚拟对象。
  • 使用 SensorManager 收集关于设备在空间中的位置的信息,并使用 Maps SDK for Android 实用程序库将虚拟对象定位到正确的航向上。

所需条件

2. 进行设置

Android Studio

此 Codelab 使用 Android 10.0(API 级别 29),并且需要您在 Android Studio 中安装 Google Play 服务。要安装这两个依赖项,请完成以下步骤:

  1. 点击 Tools > SDK Manager,以进入 SDK 管理器。

6c44a9cb9cf6c236.png

  1. 检查是否已安装 Android 10.0。如未安装,请进行安装,方法是:选中 Android 10.0 (Q) 旁边的复选框,再点击 OK,然后在出现的对话框中再次点击 OK

368f17a974c75c73.png

  1. 最后,安装 Google Play 服务,操作步骤为:进入 SDK Tools 标签页,选中 Google Play services 旁边的复选框,点击 OK,然后在出现的对话框中再次选择 OK**。**

497a954b82242f4b.png

必需的 API

在下一节中的第 3 步,为此 Codelab 启用 Maps SDK for AndroidPlaces API

开始使用 Google Maps Platform

如果您之前从未使用过 Google Maps Platform,请参阅 Google Maps Platform 使用入门指南或观看 Google Maps Platform 使用入门播放列表中的视频,完成以下步骤:

  1. 创建一个结算帐号。
  2. 创建一个项目。
  3. 启用所需的 Google Maps Platform API 和 SDK(已在上一节中列出)。
  4. 生成一个 API 密钥。

可选:Android 模拟器

如果您没有支持 ARCore 的设备,也可以使用 Android 模拟器来模拟 AR 场景和虚构设备的位置。鉴于在本练习中,您还将用到 Sceneform,因此您还必须按照“配置模拟器以支持 Sceneform”下的步骤进行操作。

3. 快速上手

为了让您能尽快上手,我们在下面提供了一些起始代码,以帮助您顺利完成此 Codelab。您可以跳到解决方案部分,但如果您想查看所有步骤,请继续阅读。

如果您已安装 git,则可以克隆相应代码库。

git clone https://github.com/googlecodelabs/display-nearby-places-ar-android.git

或者,您也可以点击下方按钮,下载源代码。

获取代码后,请直接打开 starter 目录下的项目。

4. 项目概览

接下来,浏览您在上一步中下载的代码。在此代码库中,您应该能找到一个名为 app 的模块,其中包含了 com.google.codelabs.findnearbyplacesar 软件包

AndroidManifest.xml

AndroidManifest.xml 文件中声明了以下属性,以使您在此 Codelab 中能够使用所需的功能:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<!-- Sceneform requires OpenGL ES 3.0 or later. -->
<uses-feature
   android:glEsVersion="0x00030000"
   android:required="true" />

<!-- Indicates that app requires ARCore ("AR Required"). Ensures the app is visible only in the Google Play Store on devices that support ARCore. For "AR Optional" apps remove this line. -->
<uses-feature android:name="android.hardware.camera.ar" />

uses-permission 用于指定需要用户授予哪些权限,然后才能使用这些功能,声明的权限如下:

  • android.permission.INTERNET - 您的应用需要获得此权限,才能执行网络操作以及通过互联网提取数据,例如通过 Places API 提取地点信息。
  • android.permission.CAMERA - 您需要获得相机使用权限,才能使用设备的相机以增强现实模式显示对象。
  • android.permission.ACCESS_FINE_LOCATION - 您需要获得位置信息,才能根据设备位置提取附近的地点。

uses-feature 用于指定此应用需要的硬件功能,声明的功能如下:

  • 需要 OpenGL ES 3.0 版。
  • 需要支持 ARCore 的设备。

此外,application 对象下还添加了以下元数据标记:

<application
  android:allowBackup="true"
  android:icon="@mipmap/ic_launcher"
  android:label="@string/app_name"
  android:roundIcon="@mipmap/ic_launcher_round"
  android:supportsRtl="true"
  android:theme="@style/AppTheme">

  <!--
     Indicates that this app requires Google Play Services for AR ("AR Required") and causes
     the Google Play Store to download and install Google Play Services for AR along with
     the app. For an "AR Optional" app, specify "optional" instead of "required".
  -->

  <meta-data
     android:name="com.google.ar.core"
     android:value="required" />

  <meta-data
     android:name="com.google.android.geo.API_KEY"
     android:value="@string/google_maps_key" />

  <!-- Additional elements here -->

</application>

第一个元数据条目用于指明 ARCore 是运行此应用的必要条件,第二个条目用于向 Maps SDK for Android 提供 Google Maps Platform API 密钥。

build.gradle

build.gradle 中,指定了下面这些额外的依赖项:

dependencies {
    // Maps & Location
    implementation 'com.google.android.gms:play-services-location:17.0.0'
    implementation 'com.google.android.gms:play-services-maps:17.0.0'
    implementation 'com.google.maps.android:maps-utils-ktx:1.7.0'

    // ARCore
    implementation "com.google.ar.sceneform.ux:sceneform-ux:1.15.0"

    // Retrofit
    implementation "com.squareup.retrofit2:retrofit:2.7.1"
    implementation "com.squareup.retrofit2:converter-gson:2.7.1"
}

以下是对每个依赖项的简要说明:

  • 组 ID 为 com.google.android.gms 的库(即 play-services-locationplay-services-maps)用于获取设备的位置信息以及使用与 Google 地图相关的功能。
  • com.google.maps.android:maps-utils-ktx 是 Maps SDK for Android 实用程序库的 Kotlin 扩展程序 (KTX) 库。后面将需要使用此库中的功能在真实空间中定位虚拟对象。
  • com.google.ar.sceneform.ux:sceneform-uxSceneform 库,利用该库,您无需学习 OpenGL 即可渲染逼真的 3D 场景。
  • 组 ID com.squareup.retrofit2 内的依赖项是 Retrofit 依赖项,利用它们,您可以快速编写 HTTP 客户端以与 Places API 进行交互。

项目结构

项目中将包含以下软件包和文件:

  • **api** - 此软件包包含用于通过 Retrofit 与 Places API 交互的类。
  • **ar** - 此软件包包含与 ARCore 相关的所有文件。
  • **model** - 此软件包包含一个数据类 Place,用于封装 Places API 返回的单个地点。
  • MainActivity.kt - 这是您应用中包含的单个 Activity,用于显示地图和相机视图。

5. 设置场景

接下来,我们深入介绍应用的核心组件,首先从增强现实组件开始。

MainActivity 包含一个 SupportMapFragment(负责显示地图对象)和一个 ArFragment 的子类 PlacesArFragment(负责显示增强现实场景)。

增强现实设置

除了显示增强现实场景之外,PlacesArFragment 还会向用户请求相机权限(如果尚未获得权限)。您还可以通过替换 getAdditionalPermissions 方法请求其他权限。假设您还需要获取位置权限,可以指定该权限并替换 getAdditionalPermissions 方法:

class PlacesArFragment : ArFragment() {

   override fun getAdditionalPermissions(): Array<String> =
       listOf(Manifest.permission.ACCESS_FINE_LOCATION)
           .toTypedArray()
}

运行应用

接下来,在 Android Studio 中打开 starter 目录下的框架代码。如果您点击工具栏中的 Run > Run ‘app',并将应用部署到设备或模拟器,则系统应该会先提示您启用位置和相机权限。请点击 Allow,然后,您应该会看到并排显示的相机视图和地图视图,如下所示:

e3e3073d5c86f427.png

检测平面

通过相机环顾您所处的环境时,您可能会发现水平表面上叠加了几个白色圆点,就像以下图片中地毯上的白色圆点一样。

2a9b6ea7dcb2e249.png

这些白色圆点是 ARCore 提供的指引,表示已检测到水平面。利用检测到的这些平面,您可以创建所谓的“锚点”,从而在现实空间中定位虚拟对象。

如需详细了解 ARCore 及其理解您周围环境的方式,请参阅其基础概念

6. 获取附近的地点

接下来,您需要访问和显示设备的当前位置,然后使用 Places API 提取附近的地点。

地图设置

Google Maps Platform API 密钥

前面,您创建了一个 Google Maps Platform API 密钥,利用该密钥可以查询 Places API 和使用 Maps SDK for Android。接下来,打开 gradle.properties 文件,然后将字符串 "YOUR API KEY HERE" 替换为您创建的 API 密钥。

在地图上显示设备位置

添加 API 密钥后,在地图上添加一个帮助程序,以帮助定位用户相对于地图的位置。为此,请转到 setUpMaps 方法,并在 mapFragment.getMapAsync 调用内将 googleMap.isMyLocationEnabled 设置为 true.这样做会在地图上显示蓝点。

private fun setUpMaps() {
   mapFragment.getMapAsync { googleMap ->
       googleMap.isMyLocationEnabled = true
       // ...
   }
}

获取当前位置

要获取设备的位置,您需要使用 FusedLocationProviderClient 类。获取此类的实例的操作已在 MainActivityonCreate 方法中完成。要使用此对象,请填写 getCurrentLocation 方法,该方法接受 lambda 参数,以便将位置传递给此方法的调用方。

要完成此方法,您可以访问 FusedLocationProviderClient 对象的 lastLocation 属性,然后添加一个 addOnSuccessListener,如下所示:

fusedLocationClient.lastLocation.addOnSuccessListener { location ->
    currentLocation = location
    onSuccess(location)
}.addOnFailureListener {
    Log.e(TAG, "Could not get location")
}

getCurrentLocation 方法是在从其中提取附近地点的 setUpMaps 方法中的 getMapAsync 所提供的 lambda 内部调用的。

开始对地点的网络调用

getNearbyPlaces 方法调用中,请注意以下参数传入了 placesServices.nearbyPlaces 方法:API 密钥、设备位置、以米为单位的半径(设置为 2 千米)和地点类型(当前设置为 park)。

val apiKey = "YOUR API KEY"
placesService.nearbyPlaces(
   apiKey = apiKey,
   location = "${location.latitude},${location.longitude}",
   radiusInMeters = 2000,
   placeType = "park"
)

要完成网络调用,请传入您在 gradle.properties 文件中定义的 API 密钥。以下代码段是在 build.gradle 文件中 android > defaultConfig 配置下定义的:

android {
   defaultConfig {
       resValue "string", "google_maps_key", (project.findProperty("GOOGLE_MAPS_API_KEY") ?: "")
   }
}

这将使字符串资源值 google_maps_key 在构建时可用。

要完成网络调用,只需通过 Context 对象上的 getString 读取此字符串资源即可。

val apiKey = this.getString(R.string.google_maps_key)

7. 在 AR 模式下显示地点

到目前为止,您已完成以下操作:

  1. 在首次运行应用时向用户请求相机和位置权限
  2. 设置 ARCore 以开始跟踪水平平面
  3. 使用 API 密钥设置 Maps SDK
  4. 获取设备的当前位置
  5. 使用 Places API 提取附近的地点(具体为公园)

完成此练习的剩余步骤是,在增强现实模式下定位您所提取的地点。

场景理解

ARCore 可以通过设备相机检测每帧图像中有趣且独特的点(即特征点),从而理解现实世界中的场景。当这些特征点成簇出现,并且看起来位于常见水平表面(例如桌子和地面)上时,ARCore 就可以将该地图项作为水平平面提供给应用。

如前所见,在检查到平台时,ARCore 会通过显示白色圆点来为用户提供指引。

2a9b6ea7dcb2e249.png

添加锚点

检测到平面后,您就可以附加名为锚点的对象。通过锚点,您可以放置虚拟对象,并保证这些对象看起来保持在空间中的同一位置。接下来修改代码,以在检测到平面后附加一个锚点。

setUpAr 中,PlacesArFragment 上附加了一个 OnTapArPlaneListener。每当点按 AR 场景中的某个平面时,系统就会调用此监听器。在此调用中,您可以通过监听器中提供的 HitResult 创建 AnchorAnchorNode,如下所示:

arFragment.setOnTapArPlaneListener { hitResult, _, _ ->
   val anchor = hitResult.createAnchor()
   anchorNode = AnchorNode(anchor)
   anchorNode?.setParent(arFragment.arSceneView.scene)
   addPlaces(anchorNode!!)
}

您将在 AnchorNode 中附加场景中的子节点对象(PlaceNode 实例),前者在 addPlaces 方法调用中进行处理。

运行应用

如果您在完成上述修改后运行应用,请环顾四周,直到检测到平面为止。然后点按表示平面的白色圆点。点按之后,您现在应该能在地图上看到标记,代表所有离您最近的公园。但是,如果您注意看就会发现,这些虚拟对象都卡在创建的锚点上,而不是相对于它们在空间中的位置进行放置。

f93eb87c98a0098d.png

在最后一步中,您将通过使用设备上的 Maps SDK for Android 实用程序库SensorManager 来纠正此问题。

8. 定位地点

为了能够在增强现实模式下将虚拟地点图标定位到准确的航向上,您需要以下两项信息:

  • 正北方在哪
  • 北方与每个地点之间的角度是多少

确定北方

使用设备上提供的位置传感器(地磁和加速度计)即可确定北方。借助这两个传感器,您可以收集有关设备在空间中的位置的实时信息。如需详细了解位置传感器,请参阅计算设备的屏幕方向

要访问这些传感器,您需要先获取一个 SensorManager,然后在这些传感器上注册 SensorEventListenerMainActivity 的生命周期方法中已经为您完成了这些步骤:

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   // ...
   sensorManager = getSystemService()!!
   // ...
}

override fun onResume() {
   super.onResume()
   sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)?.also {
       sensorManager.registerListener(
           this,
           it,
           SensorManager.SENSOR_DELAY_NORMAL
       )
   }
   sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.also {
       sensorManager.registerListener(
           this,
           it,
           SensorManager.SENSOR_DELAY_NORMAL
       )
   }
}

override fun onPause() {
   super.onPause()
   sensorManager.unregisterListener(this)
}

onSensorChanged 方法中提供了一个 SensorEvent 对象,该对象中包含关于给定传感器数据随时间变化的详细信息。接下来,将以下代码添加到该方法中:

override fun onSensorChanged(event: SensorEvent?) {
   if (event == null) {
       return
   }
   if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
       System.arraycopy(event.values, 0, accelerometerReading, 0, accelerometerReading.size)
   } else if (event.sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
       System.arraycopy(event.values, 0, magnetometerReading, 0, magnetometerReading.size)
   }

   // Update rotation matrix, which is needed to update orientation angles.
   SensorManager.getRotationMatrix(
       rotationMatrix,
       null,
       accelerometerReading,
       magnetometerReading
   )
   SensorManager.getOrientation(rotationMatrix, orientationAngles)
}

上述代码会检查传感器类型,并根据具体的类型更新相应的传感器读数(加速度计或磁力计读数)。利用这些传感器读数,就可以确定设备与北方之间的角度值(即 orientationAngles[0] 的值)。

球形航向

现在已经确定了北方,下一步是确定北方与每个地点之间的角度,然后利用该信息在增强现实模式下将各地点定位到正确的航向上。

要计算航向,您需要使用 Maps SDK for Android 实用程序库,其中包含一些辅助函数,用于通过球面几何计算距离和航向。如需了解详情,请参阅库概览

接下来,您需要使用实用程序库中的 sphericalHeading 方法,该方法会计算两个 LatLng 对象之间的航方/方位。Place.kt 中定义的 getPositionVector 方法中需要用到此信息。该方法最终将返回一个 Vector3 对象,然后每个 PlaceNode 都会将该对象用作其在 AR 空间中的局部坐标。

接下来,将该方法中的航向定义替换为以下代码:

val heading = latLng.sphericalHeading(placeLatLng)

此操作应该会产生以下方法定义:

fun Place.getPositionVector(azimuth: Float, latLng: LatLng): Vector3 {
   val placeLatLng = this.geometry.location.latLng
   val heading = latLng.sphericalHeading(placeLatLng)
   val r = -2f
   val x = r * sin(azimuth + heading).toFloat()
   val y = 1f
   val z = r * cos(azimuth + heading).toFloat()
   return Vector3(x, y, z)
}

局部坐标

在 AR 模式下正确定位地点的最后一步是,在向场景添加 PlaceNode 对象时使用 getPositionVector 的结果。请转到 MainActivity 中的 addPlaces,在为每个 placeNode 设置父级的代码行下方(即 placeNode.setParent(anchorNode) 下方),将 placeNodelocalPosition 设置为调用 getPositionVector 的结果,如下所示:

val placeNode = PlaceNode(this, place)
placeNode.setParent(anchorNode)
placeNode.localPosition = place.getPositionVector(orientationAngles[0], currentLocation.latLng)

默认情况下,getPositionVector 方法会将节点的 y 轴距离设置为 1 米,这是 getPositionVector 方法中的 y 值所指定的距离。如果您想要调整此距离(例如调整为 2 米),请直接根据需要修改该值。

完成以上更改后,添加的 PlaceNode 对象现在所定位的航向应该准确无误了。现在,运行应用并查看结果!

9. 恭喜

恭喜您成功完成此 Codelab!

了解更多内容