不连接到互联网时的双向通信

1. 准备工作

如果可以使用自己的移动设备协作完成团队项目或者分享视频、在线播放内容、玩多人游戏(即使未接入互联网也没问题),那岂不是很好吗?您真的可以做到。在此 Codelab 中,您将学习如何做到这一点。

为了简单起见,我们将构建一个无需连接到互联网就能玩的多人石头剪刀布游戏。此 Codelab 将教您如何使用 Nearby Connections API(Google Play 服务的一部分)让用户能够在离得很近时相互通信。用户之间的距离必须在大约 100 米以内。对用户可以分享的数据类型或数据量没有限制,即使未连接到互联网也是如此。用户可以播放流式视频、发送和接收语音消息、发送短信,等等。

前提条件

  • 具备 Kotlin 和 Android 开发方面的基础知识
  • 了解如何在 Android Studio 中创建和运行应用
  • 两台或多台用于运行和测试代码的 Android 设备
  • 运行的是 Android API 级别 16 或更高级别
  • 已安装 Google Play 服务
  • 最新版本的 Android Studio

学习内容

  • 如何将 Google Play 服务 Nearby Connections 库添加到您的应用
  • 如何通告您有兴趣与附近的设备通信
  • 如何发现附近感兴趣的设备
  • 如何与已连接的设备通信
  • 隐私设置和数据保护的最佳实践

构建内容

此 Codelab 为您展示了如何构建单个 Activity 应用,让用户能够寻找对手来玩石头剪刀布游戏。该应用具有以下界面元素:

  1. 一个用于寻找对手的按钮
  2. 带有三个按钮的游戏控制器,可让用户选择“石头”“布”或“剪刀”来玩游戏
  3. 用于显示得分的 TextView
  4. 用于显示状态的 TextView

625eeebfad3b195a.png

图 1

2. 创建 Android Studio 项目

  1. 启动一个新的 Android Studio 项目。
  2. 选择 Empty Activity

f2936f15aa940a21.png

  1. 将项目命名为 Rock Paper Scissors,并将语言设置为 Kotlin。

1ea410364fbdfc31.png

3. 设置代码

  1. 最新版本的 Nearby 依赖项添加到您的应用级 build.gradle 文件中。这样可让您的应用使用 Nearby Connections API 通告您有兴趣连接、发现附近的设备以及通信。
implementation 'com.google.android.gms:play-services-nearby:LATEST_VERSION'
  1. android 代码块中将 viewBinding build 选项设置为 true 以启用视图绑定,这样您就不必使用 findViewById 与视图交互了。
android {
   ...
   buildFeatures {
       viewBinding true
   }
}
  1. 点击 Sync Now 或绿色锤子按钮,以便 Android Studio 将这些 Gradle 更改考虑在内。

57995716c771d511.png

  1. 我们对石头、布和剪刀图片使用矢量可绘制对象。将以下三个 XML 文件添加到您的 res/drawable 目录。

res/drawables/rock.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
   android:height="24dp"
   android:tintMode="multiply"
   android:viewportHeight="48.0"
   android:viewportWidth="48.0"
   android:width="24dp">
 <path
     android:fillColor="#ffffff"
     android:pathData="M28,12l-7.5,10 5.7,7.6L23,32c-3.38,-4.5 -9,-12 -9,-12L2,36h44L28,12z"/>
</vector>

res/drawables/paper.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
   android:height="24dp"
   android:tintMode="multiply"
   android:viewportHeight="48.0"
   android:viewportWidth="48.0"
   android:width="24dp">
 <path
     android:fillColor="#ffffff"
     android:pathData="M28,4L12,4C9.79,4 8.02,5.79 8.02,8L8,40c0,2.21 1.77,4 3.98,4L36,44c2.21,0 4,-1.79 4,-4L40,16L28,4zM26,18L26,7l11,11L26,18z"/>
</vector>

res/drawables/scissors.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
   android:width="24dp"
   android:height="24dp"
   android:tintMode="multiply"
   android:viewportWidth="48.0"
   android:viewportHeight="48.0">
   <path
       android:fillColor="#ffffff"
       android:pathData="M19.28,15.28c0.45,-1 0.72,-2.11 0.72,-3.28 0,-4.42 -3.58,-8 -8,-8s-8,3.58 -8,8 3.58,8 8,8c1.17,0 2.28,-0.27 3.28,-0.72L20,24l-4.72,4.72c-1,-0.45 -2.11,-0.72 -3.28,-0.72 -4.42,0 -8,3.58 -8,8s3.58,8 8,8 8,-3.58 8,-8c0,-1.17 -0.27,-2.28 -0.72,-3.28L24,28l14,14h6v-2L19.28,15.28zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4zM12,40c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4zM24,25c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM38,6L26,18l4,4L44,8L44,6z" />
</vector>
  1. 为游戏屏幕添加游戏控制器(换句话说,玩游戏的按钮)、得分和状态 TextView。在 activity_main.xml 文件中,将代码替换为以下内容:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/status"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:padding="16dp"
       android:text="searching..."
       app:layout_constraintBottom_toTopOf="@+id/myName"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       />

   <TextView
       android:id="@+id/myName"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_gravity="center"
       android:text="You (codeName)"
       android:textAlignment="center"
       android:textAppearance="?android:textAppearanceMedium"
       app:layout_constraintEnd_toStartOf="@+id/opponentName"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/status"
       />

   <TextView
       android:id="@+id/opponentName"
       android:layout_width="0dp"
       android:layout_height="wrap_content"
       android:layout_gravity="center"
       android:text="Opponent (codeName)"
       android:textAlignment="center"
       android:textAppearance="?android:textAppearanceMedium"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toEndOf="@+id/myName"
       app:layout_constraintTop_toBottomOf="@+id/status"
       />

   <TextView
       android:id="@+id/score"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_gravity="center"
       android:layout_margin="16dp"
       android:text=":"
       android:textAlignment="center"
       android:textSize="80sp"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/myName"
       />

   <androidx.appcompat.widget.AppCompatButton
       android:id="@+id/rock"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:drawableTop="@drawable/rock"
       android:text="Rock"
       app:layout_constraintEnd_toStartOf="@+id/paper"
       app:layout_constraintHorizontal_chainStyle="spread"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/score"
       />

   <androidx.appcompat.widget.AppCompatButton
       android:id="@+id/paper"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:drawableTop="@drawable/paper"
       android:text="Paper"
       app:layout_constraintEnd_toStartOf="@+id/scissors"
       app:layout_constraintStart_toEndOf="@+id/rock"
       app:layout_constraintTop_toBottomOf="@+id/score"
       />

   <androidx.appcompat.widget.AppCompatButton
       android:id="@+id/scissors"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:drawableTop="@drawable/scissors"
       android:text="Scissors"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toEndOf="@+id/paper"
       app:layout_constraintTop_toBottomOf="@+id/score"
       />

   <androidx.appcompat.widget.AppCompatButton
       android:id="@+id/disconnect"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="32dp"
       android:text="disconnect"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/paper"
       />

   <androidx.appcompat.widget.AppCompatButton
       android:id="@+id/findOpponent"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="32dp"
       android:text="find opponent"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/paper"
       />

</androidx.constraintlayout.widget.ConstraintLayout>

您的布局现在应如上面的图 1 所示。

注意:在您自己的项目中,应将值(如 16dp)更改为资源(如 @dimen/activity_vertical_margin)。

4. 将 Nearby Connections 添加到您的应用

准备 manifest.xml 文件

将以下权限添加到清单文件中。由于 ACCESS_FINE_LOCATION 是一项危险权限,因此您的应用将包含相关的代码,这些代码将触发系统提示用户代表您的应用准许或拒绝访问。Wi-Fi 权限适用于点对点连接,而不适用于互联网连接。

<!-- Required for Nearby Connections →

<!--    Because ACCESS_FINE_LOCATION is a dangerous permission, the app will have to-->
<!--    request it at runtime, and the user will be prompted to grant or deny access.-->

<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>

选择 Strategy

Nearby Connections API 要求您选择一种 Strategy,用来确定您的应用如何与其他附近的设备连接。选择 P2P_CLUSTERP2P_STARP2P_POINT_TO_POINT

出于我们的目的,我们将选择 P2P_STAR,因为我们希望能够看到来自玩家的许多传入请求,这些玩家想要挑战我们,但一次只能与另外一个人对战。

您选择的 Strategy 必须同时用于应用中的通告和发现。下图显示了每种 Strategy 的运作方式。

设备可以请求 N 个传出连接

设备可以接收 M 个传入连接

P2P_CLUSTER

N = 多个

M = 多个

产生较低带宽的连接

P2P_STAR

N = 1 个

M = 多个

产生较高带宽的连接

P2P_POINT_TO_POINT

N = 1 个

M = 1 个

尽可能高的吞吐量

在 MainActivity 中定义变量

  1. 在主 activity (MainActivity.kt) 内的 onCreate() 函数上方,通过粘贴此代码段来定义以下变量。这些变量定义了游戏专用逻辑和运行时权限。
/**
* Enum class for defining the winning rules for Rock-Paper-Scissors. Each player will make a
* choice, then the beats function in this class will be used to determine whom to reward the
* point to.
*/
private enum class GameChoice {
   ROCK, PAPER, SCISSORS;

   fun beats(other: GameChoice): Boolean =
       (this == ROCK && other == SCISSORS)
               || (this == SCISSORS && other == PAPER)
               || (this == PAPER && other == ROCK)
}

/**
* Instead of having each player enter a name, in this sample we will conveniently generate
* random human readable names for players.
*/
internal object CodenameGenerator {
   private val COLORS = arrayOf(
       "Red", "Orange", "Yellow", "Green", "Blue", "Indigo", "Violet", "Purple", "Lavender"
   )
   private val TREATS = arrayOf(
       "Cupcake", "Donut", "Eclair", "Froyo", "Gingerbread", "Honeycomb",
       "Ice Cream Sandwich", "Jellybean", "Kit Kat", "Lollipop", "Marshmallow", "Nougat",
       "Oreo", "Pie"
   )
   private val generator = Random()

   /** Generate a random Android agent codename  */
   fun generate(): String {
       val color = COLORS[generator.nextInt(COLORS.size)]
       val treat = TREATS[generator.nextInt(TREATS.size)]
       return "$color $treat"
   }
}

/**
* Strategy for telling the Nearby Connections API how we want to discover and connect to
* other nearby devices. A star shaped strategy means we want to discover multiple devices but
* only connect to and communicate with one at a time.
*/
private val STRATEGY = Strategy.P2P_STAR

/**
* Our handle to the [Nearby Connections API][ConnectionsClient].
*/
private lateinit var connectionsClient: ConnectionsClient

/**
* The request code for verifying our call to [requestPermissions]. Recall that calling
* [requestPermissions] leads to a callback to [onRequestPermissionsResult]
*/
private val REQUEST_CODE_REQUIRED_PERMISSIONS = 1

/*
The following variables are convenient ways of tracking the data of the opponent that we
choose to play against.
*/
private var opponentName: String? = null
private var opponentEndpointId: String? = null
private var opponentScore = 0
private var opponentChoice: GameChoice? = null

/*
The following variables are for tracking our own data
*/
private var myCodeName: String = CodenameGenerator.generate()
private var myScore = 0
private var myChoice: GameChoice? = null

/**
* This is for wiring and interacting with the UI views.
*/
private lateinit var binding: ActivityMainBinding
  1. 更改您的 onCreate() 函数,以将 ViewBinding 对象传入 setContentView()。这会显示 activity_main.xml 布局文件的内容。此外,初始化 connectionsClient,以便您的应用可以与 API 通信。
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)
   connectionsClient = Nearby.getConnectionsClient(this)
}

验证必需的权限

一般来讲,在 AndroidManifest.xml 文件中声明危险权限,但必须在运行时请求这些权限。对于其他必要的权限,您仍应在运行时对其进行验证,以确保结果符合您的预期。如果用户拒绝其中任一权限,应显示一个消息框,通知他们不授予这些权限就无法继续操作,因为我们的示例应用没有这些权限就无法使用。

  • onCreate() 函数下方,添加以下代码段,以验证我们是否具有这些权限:
@CallSuper
override fun onStart() {
   super.onStart()
   if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
       requestPermissions(
           arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
           REQUEST_CODE_REQUIRED_PERMISSIONS
       )
   }
}

@CallSuper
override fun onRequestPermissionsResult(
   requestCode: Int,
   permissions: Array<out String>,
   grantResults: IntArray
) {
   super.onRequestPermissionsResult(requestCode, permissions, grantResults)
   val errMsg = "Cannot start without required permissions"
   if (requestCode == REQUEST_CODE_REQUIRED_PERMISSIONS) {
       grantResults.forEach {
           if (it == PackageManager.PERMISSION_DENIED) {
               Toast.makeText(this, errMsg, Toast.LENGTH_LONG).show()
               finish()
               return
           }
       }
       recreate()
   }
}

此时,我们已经编写了用来完成以下任务的代码:

  • 创建了布局文件
  • 在清单中声明了必要的权限
  • 在运行时验证了必需的危险权限

反向演示

现在我们已经完成了准备工作,接下来就可以开始编写 Nearby Connections 代码来与附近的用户连接和通信了。通常,您的应用必须先允许其他设备找到它并且它必须先扫描其他设备,然后您才能真正开始与附近的设备通信。

换句话说,在我们的石头剪刀布游戏的上下文中,您和您的对手必须先找到对方,然后才能开始玩游戏。

您可以通过一个称为通告的过程使您的设备可被检测到。同样,您可以通过一个称为发现的过程发现附近的对手。

为了理解该过程,最好按相反的顺序处理代码。为此,我们将按以下步骤操作:

  1. 我们将假装已经连接,首先编写用于发送和接收消息的代码。出于我们目前的目的,这意味着,编写用于真正玩石头剪刀布游戏的代码。
  2. 我们将编写用于通告我们有兴趣与附近的设备连接的代码。
  3. 我们将编写用于发现附近的设备的代码。

发送和接收数据

您使用 connectionsClient.sendPayload() 方法以 Payload 的形式发送数据,并使用 PayloadCallback 对象接收载荷。Payload 可以是任意内容:视频、照片、信息流,或其他任何类型的数据。而且,没有数据量限制。

  1. 在我们的游戏中,载荷是指用户选择的石头、布或剪刀。当用户点击某个控制器按钮时,应用会将他们的选择以载荷的形式发送到对手的应用。为了记录用户的选择,请在 onRequestPermissionsResult() 函数下方添加以下代码段。
/** Sends the user's selection of rock, paper, or scissors to the opponent. */
private fun sendGameChoice(choice: GameChoice) {
   myChoice = choice
   connectionsClient.sendPayload(
       opponentEndpointId!!,
       Payload.fromBytes(choice.name.toByteArray(UTF_8))
   )
   binding.status.text = "You chose ${choice.name}"
   // For fair play, we will disable the game controller so that users don't change their
   // choice in the middle of a game.
   setGameControllerEnabled(false)
}

/**
* Enables/Disables the rock, paper and scissors buttons. Disabling the game controller
* prevents users from changing their minds after making a choice.
*/
private fun setGameControllerEnabled(state: Boolean) {
   binding.apply {
       rock.isEnabled = state
       paper.isEnabled = state
       scissors.isEnabled = state
   }
}
  1. 设备通过 PayloadCallback 对象接收载荷,该对象有两个方法。onPayloadReceived() 方法可告知您的应用何时在接收消息,onPayloadTransferUpdate() 方法可跟踪收到的消息和外发的消息的状态。

出于我们的目的,我们将读取从 onPayloadReceived() 收到的消息作为对手的选择,并使用 onPayloadTransferUpdate() 方法跟踪和确认两个玩家何时做出了选择。在 onCreate() 方法上方添加以下代码段。

/** callback for receiving payloads */
private val payloadCallback: PayloadCallback = object : PayloadCallback() {
   override fun onPayloadReceived(endpointId: String, payload: Payload) {
       payload.asBytes()?.let {
           opponentChoice = GameChoice.valueOf(String(it, UTF_8))
       }
   }

   override fun onPayloadTransferUpdate(endpointId: String, update: PayloadTransferUpdate) {
       // Determines the winner and updates game state/UI after both players have chosen.
       // Feel free to refactor and extract this code into a different method
       if (update.status == PayloadTransferUpdate.Status.SUCCESS
           && myChoice != null && opponentChoice != null) {
           val mc = myChoice!!
           val oc = opponentChoice!!
           when {
               mc.beats(oc) -> { // Win!
                   binding.status.text = "${mc.name} beats ${oc.name}"
                   myScore++
               }
               mc == oc -> { // Tie
                   binding.status.text = "You both chose ${mc.name}"
               }
               else -> { // Loss
                   binding.status.text = "${mc.name} loses to ${oc.name}"
                   opponentScore++
               }
           }
           binding.score.text = "$myScore : $opponentScore"
           myChoice = null
           opponentChoice = null
           setGameControllerEnabled(true)
       }
   }
}

您通告您在附近或有兴趣,希望附近有人会注意到您并请求与您连接。因此,Nearby Connections API 的 startAdvertising() 方法需要一个回调对象。当注意到了您的通告的某个人想要与您连接时,该回调(即 ConnectionLifecycleCallback)会通知您。该回调对象有三个方法:

  • onConnectionInitiated() 方法可告知您有人注意到了您的通告并且想要与您连接。因此,您可以选择使用 connectionsClient.acceptConnection() 接受连接。
  • 当有人注意到您的通告时,他们会向您发送连接请求。您和发送者必须都接受连接请求才能真正连接。onConnectionResult() 方法可告知您是否建立了连接。
  • onDisconnected() 函数可告知您连接不再处于活跃状态。例如,如果您或对手决定终止连接,就会发生这种情况。

如需通告,请执行以下操作:

  1. 对于我们的应用,获得 onConnectionInitiated() 回调时,我们将接受连接。然后,在 onConnectionResult() 内,如果建立了连接,我们将停止通告和发现,因为我们只需要与一个对手连接来玩游戏。最后,在 onConnectionResult() 中,我们将重置游戏。

onCreate() 方法前面粘贴以下代码段。

// Callbacks for connections to other devices
private val connectionLifecycleCallback = object : ConnectionLifecycleCallback() {
   override fun onConnectionInitiated(endpointId: String, info: ConnectionInfo) {
       // Accepting a connection means you want to receive messages. Hence, the API expects
       // that you attach a PayloadCall to the acceptance
       connectionsClient.acceptConnection(endpointId, payloadCallback)
       opponentName = "Opponent\n(${info.endpointName})"
   }

   override fun onConnectionResult(endpointId: String, result: ConnectionResolution) {
       if (result.status.isSuccess) {
           connectionsClient.stopAdvertising()
           connectionsClient.stopDiscovery()
           opponentEndpointId = endpointId
           binding.opponentName.text = opponentName
           binding.status.text = "Connected"
           setGameControllerEnabled(true) // we can start playing
       }
   }

   override fun onDisconnected(endpointId: String) {
       resetGame()
   }
}
  1. 由于 resetGame() 在不同的时刻调用起来非常方便,因此我们使其成为它自己的子例程。在 MainActivity 类的底部添加以下代码。
/** Wipes all game state and updates the UI accordingly. */
private fun resetGame() {
   // reset data
   opponentEndpointId = null
   opponentName = null
   opponentChoice = null
   opponentScore = 0
   myChoice = null
   myScore = 0
   // reset state of views
   binding.disconnect.visibility = View.GONE
   binding.findOpponent.visibility = View.VISIBLE
   setGameControllerEnabled(false)
   binding.opponentName.text="opponent\n(none yet)"
   binding.status.text ="..."
   binding.score.text = ":"
}
  1. 以下代码段是实际的通告调用,您在该调用中告知 Nearby Connections API 您想要进入通告模式。在 onCreate() 方法下方添加该代码段。
private fun startAdvertising() {
   val options = AdvertisingOptions.Builder().setStrategy(STRATEGY).build()
   // Note: Advertising may fail. To keep this demo simple, we don't handle failures.
   connectionsClient.startAdvertising(
       myCodeName,
       packageName,
       connectionLifecycleCallback,
       options
   )
}

发现

通告的补充是发现。这两个调用非常相似,只不过它们使用不同的回调。startDiscovery() 调用的回调是一个 EndpointDiscoveryCallback 对象。此对象有两个回调方法:每次检测到通告时都会调用 onEndpointFound();每次通告不再可用时都会调用 onEndpointLost()

  1. 我们的应用将与我们检测到的第一个通告者连接。这意味着,我们将在 onEndpointFound() 方法内发出连接请求,而不使用 onEndpointLost() 方法执行任何操作。在 onCreate() 方法前面添加以下回调。
// Callbacks for finding other devices
private val endpointDiscoveryCallback = object : EndpointDiscoveryCallback() {
   override fun onEndpointFound(endpointId: String, info: DiscoveredEndpointInfo) {
       connectionsClient.requestConnection(myCodeName, endpointId, connectionLifecycleCallback)
   }

   override fun onEndpointLost(endpointId: String) {
   }
}
  1. 此外,还应添加用于实际告知 Nearby Connections API 您想要进入发现模式的代码段。在 MainActivity 类的底部添加该代码段。
private fun startDiscovery(){
   val options = DiscoveryOptions.Builder().setStrategy(STRATEGY).build()
   connectionsClient.startDiscovery(packageName,endpointDiscoveryCallback,options)
}
  1. 此时,我们工作中的 Nearby Connections 部分已经完成了!我们可以通告、发现附近的设备以及与之通信。但是,我们还不太能玩游戏。我们必须完成界面视图的连接:
  • 当用户点击寻找对手按钮时,应用应同时调用 startAdvertising()startDiscovery()。这样,您既会发现,又会被发现。
  • 当用户点击某个控制器按钮(即石头剪刀)时,应用需要调用 sendGameChoice() 以将该数据传输给对手。
  • 当任一用户点击断开连接按钮时,应用应重置游戏。

更新 onCreate() 方法以反映这些互动。

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)
   connectionsClient = Nearby.getConnectionsClient(this)

   binding.myName.text = "You\n($myCodeName)"
   binding.findOpponent.setOnClickListener {
       startAdvertising()
       startDiscovery()
       binding.status.text = "Searching for opponents..."
       // "find opponents" is the opposite of "disconnect" so they don't both need to be
       // visible at the same time
       binding.findOpponent.visibility = View.GONE
       binding.disconnect.visibility = View.VISIBLE
   }
   // wire the controller buttons
   binding.apply {
       rock.setOnClickListener { sendGameChoice(GameChoice.ROCK) }
       paper.setOnClickListener { sendGameChoice(GameChoice.PAPER) }
       scissors.setOnClickListener { sendGameChoice(GameChoice.SCISSORS) }
   }
   binding.disconnect.setOnClickListener {
       opponentEndpointId?.let { connectionsClient.disconnectFromEndpoint(it) }
       resetGame()
   }

   resetGame() // we are about to start a new game
}

清理

当不再需要 Nearby API 时,您应停止使用它。对于我们的示例游戏,我们在 onStop() activity 生命周期函数内释放所有资源。

@CallSuper
override fun onStop(){
   connectionsClient.apply {
       stopAdvertising()
       stopDiscovery()
       stopAllEndpoints()
   }
   resetGame()
   super.onStop()
}

5. 运行应用

在两台设备上运行应用,尽情畅玩吧!

e545703b29e0158a.gif

6. 隐私设置最佳实践

我们的石头剪刀布游戏不会分享任何敏感数据。甚至连代号都是随机生成的。这就是我们在 onConnectionInitiated(String, ConnectionInfo) 内自动接受连接的原因。

ConnectionInfo 对象包含每个连接的唯一令牌,您的应用可通过 getAuthenticationDigits() 访问该令牌。您可以向两个用户显示令牌以进行视觉验证。作为替代方案,您也可以先在一台设备上加密原始令牌,并将其以载荷的形式发送到另一台设备上进行解密,然后再开始分享敏感数据。如需详细了解 Android 加密,请查看这篇名为“改进应用的加密 - 从消息身份验证到用户存在”的博文。

7. 恭喜

恭喜!现在,您已经知道了如何在未连接到互联网的情况下通过 Nearby Connections API 连接用户。

总之,如需使用 Nearby Connections API,您需要添加对 play-services-nearby 的依赖关系。此外,您还需要在 AndroidManifest.xml 文件中请求权限,并在运行时检查这些权限。您还学习了如何执行以下操作:

  • 通告您有兴趣与附近的用户连接
  • 发现希望连接的附近用户
  • 接受连接
  • 发送消息
  • 接收消息
  • 保护用户隐私

后续操作

查看我们的博客系列和示例