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

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

เกี่ยวกับ Codelab นี้

subjectอัปเดตล่าสุดเมื่อ ก.ย. 21, 2023
account_circleเขียนโดย Isai Damier

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 文件中请求权限,并在运行时检查这些权限。您还学习了如何执行以下操作:

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

后续操作

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