向应用添加“附近消息”功能

假设您在拥挤的购物中心,看到了朋友小杰。您可能想挥舞手臂并大声呼喊来引起小杰的注意。借助 Google 的 Nearby Messages API,您可以让应用代用户完成大声呼喊来引起对方注意的环节,让朋友能在相距较近时轻松发现彼此。此 Codelab 教您如何使用 Nearby Messages API 实现近距离的用户互动。为简单起见,在此 Codelab 中,每个用户都将发布其手机的 build 型号:android.os.Build.MODEL。但实际上,您可以让每位用户向附近的朋友发布他们的 userId 或适合您的用例的其他信息。Nearby Messages API 结合使用互联网连接、蓝牙和其他技术来提供此功能。

前提条件

  • 具备 Kotlin 和 Android 开发方面的基础知识
  • 了解如何在 Android Studio 上创建和运行应用
  • 两台或多台用于运行和测试代码的 Android 设备

学习内容

  • 如何将 Nearby 库添加到应用
  • 如何向相关方广播消息
  • 如何检测来自相关方的消息
  • 如何为消息获取 API 密钥
  • 电池续航时间最佳做法

所需条件

  • 一个用于获取 Google API 密钥的 Google 帐号(即 Gmail 地址)
  • 最新版本的 Android Studio
  • 两台安装有 Google Play 服务(即 Play 商店)的 Android 设备
  • 互联网连接(而不是不需要互联网连接的 Nearby Connections API)

构建内容

一个让用户可以发布设备信息并接收附近设备相关信息的 Activity 应用。此应用包含两个用户可以切换的开关:第一个开关用于发现或停止发现附近的消息;第二个开关用于发布或取消发布消息。对于此应用,我们希望发布和发现模式都在 120 秒后停止。为此,我们将稍微更深入地了解一下此 API,然后创建 PublishOptionsSubscribeOptions 对象,并利用这两者的 onExpired() 回调来关闭发布和订阅界面开关。

56bd91ffed49ec3d.png

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

f2936f15aa940a21.png

  1. 将项目命名为 Nearby Messages Example,并将语言设置为 Kotlin。

3220c65e598bf6af.png

  1. 向应用级 build.gradle 文件添加最新版本的 Nearby 依赖项。这样您就可以使用 Nearby Messages API 来发送消息和检测来自附近设备的消息。
implementation 'com.google.android.gms:play-services-nearby:18.0.0'
  1. 在 Android 代码块中将 viewBinding 构建选项设置为 true,以启用 ViewBinding
android {
   ...
   buildFeatures {
       viewBinding true
   }
}
  1. 点击 Sync Now 或绿色锤子按钮,让 Android Studio 能登记这些 Gradle 更改。

57995716c771d511.png

  1. 添加“Discover nearby devices”和“Share device information”这两个切换开关,以及会包含设备列表的 RecycleView。在 activity_main.xml 文件中,将代码替换为以下内容。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:id="@+id/activity_main_container"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   android:padding="16dp"
   tools:context=".MainActivity">

   <androidx.appcompat.widget.SwitchCompat
       android:id="@+id/subscribe_switch"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Discover nearby devices" />

   <androidx.appcompat.widget.SwitchCompat
       android:id="@+id/publish_switch"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="Share device information" />

   <androidx.recyclerview.widget.RecyclerView
       android:id="@+id/nearby_msg_recycler_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:transcriptMode="alwaysScroll" />
</LinearLayout>

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

定义变量

  1. 在主 activity (MainActivity.kt) 中,在 onCreate() 函数的上方粘贴此代码段来定义以下变量。
/**
* For accessing layout variables
*/
private lateinit var binding: ActivityMainBinding

/**
* Sets the time to live in seconds for the publish or subscribe.
*/
private val TTL_IN_SECONDS = 120 // Two minutes.

/**
* Choose of strategies for publishing or subscribing for nearby messages.
*/
private val PUB_SUB_STRATEGY = Strategy.Builder().setTtlSeconds(TTL_IN_SECONDS).build()

/**
* The [Message] object used to broadcast information about the device to nearby devices.
*/
private lateinit var message: Message

/**
* A [MessageListener] for processing messages from nearby devices.
*/
private lateinit var messageListener: MessageListener

/**
* MessageAdapter is a custom class that we will define later. It's for adding
* [messages][Message] to the [RecyclerView]
*/
private lateinit var msgAdapter: MessageAdapter

由于我们希望自定义广播应持续的时间,所以定义了 Strategy。在此 Codelab 中,我们选择了 120 秒。如果您未指定 Strategy,此 API 会使用默认值。此外,即使我们在此 Codelab 中为发布和订阅使用同一 Strategy,这也不是必需操作。

  1. 更改您的 onCreate() 函数,以将 ViewBinding 对象传入 setContentView()。这会显示 activity_main.xml 布局文件的内容。
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)
}

连接界面按钮

此应用会执行三项操作:轻点一下开关即可发布消息、轻点一下开关即可发现消息,以及在 RecyclerView 中显示消息。

  1. 我们希望用户发布消息和取消发布消息,以及发现(即订阅)消息。现在,为此创建名为 publish()unpublish()subscribe()unsubscribe() 的桩方法。我们会在后续步骤中创建实现。
private fun publish() {
   TODO("Not yet implemented")
}

private fun unpublish() {
   TODO("Not yet implemented")
}

private fun subscribe() {
   TODO("Not yet implemented")
}

private fun unsubscribe() {
   TODO("Not yet implemented")
}
  1. 用户可以使用 activity 布局中添加的开关来发布消息或发现(即订阅)消息。连接两个 Switches 以调用我们在 onCreate() 函数末尾定义的方法。
   binding.subscribeSwitch.setOnCheckedChangeListener { buttonView, isChecked ->
       if (isChecked) {
           subscribe()
       } else {
           unsubscribe()
       }
   }

   binding.publishSwitch.setOnCheckedChangeListener { buttonView, isChecked ->
       if (isChecked) {
           publish()
       } else {
           unpublish()
       }
   }
  1. 现在,您已添加用于发布和发现消息的界面代码,接下来要设置用于显示和移除消息的 RecyclerViewRecyclerView 会显示正在主动发布的消息。订阅者会监听消息。发现消息后,订阅者会将其添加到 RecyclerView;当消息消失(即发布者停止发布)后,订阅者会将其从 RecyclerView 中移除。
private fun setupMessagesDisplay() {
   msgAdapter = MessageAdapter()
   with(binding.nearbyMsgRecyclerView) {
       layoutManager = LinearLayoutManager(context)
       this.adapter = msgAdapter
   }
}

class MessageAdapter : RecyclerView.Adapter<MessageAdapter.MessageVH>() {
   private var itemsList: MutableList<String> = arrayListOf()

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageVH {
       return MessageVH(TextView(parent.context))
   }

   override fun onBindViewHolder(holder: MessageVH, position: Int) {
       holder.bind(getItem(position))
   }

   override fun getItemCount(): Int = itemsList.size

   private fun getItem(pos: Int): String? = if (itemsList.isEmpty()) null else itemsList[pos]

   fun addItem(item: String) {
       itemsList.add(item)
       notifyItemInserted(itemsList.size)
   }

   fun removeItem(item: String) {
       val pos = itemsList.indexOf(item)
       itemsList.remove(item)
       notifyItemRemoved(pos)
   }

   inner class MessageVH(private val tv: TextView) : RecyclerView.ViewHolder(tv) {
       fun bind(item: String?) {
           item?.let { tv.text = it }
       }
   }
}
  1. onCreate() 函数的末尾,添加对 setupMessagesDisplay() 函数的调用。
override fun onCreate(savedInstanceState: Bundle?) {
   ...
   setupMessagesDisplay()
}

现在,界面全部设置完毕,您可以开始发布消息,让附近的其他设备发现。此时,您的应用应如下所示:

56bd91ffed49ec3d.png

添加发布和发现代码

  1. 如要发送消息,我们首先需要一个 Message 对象。考虑到这是演示,我们仅发送设备的型号。将下面这段代码添加到 onCreate() 函数中,用于创建要发送的消息。
override fun onCreate(savedInstanceState: Bundle?) {
   ...

   // The message being published is simply the Build.MODEL of the device. But since the
   // Messages API is expecting a byte array, you must convert the data to a byte array.
   message = Message(Build.MODEL.toByteArray(Charset.forName("UTF-8")))

}
  1. 如要发布其他附近的设备能发现的 Message,只需调用 Nearby.getMessagesClient(activity).publish(message)。不过,我们建议您更进一步,构建您自己的 PublishOptions 对象;这样一来就可以指定自己的自定义 Strategy,并利用 PublishCallback(在已发布的消息失效时发出通知)。在以下代码中,我们创建了一个选项,以便我们可以在已发布的 TTL 失效后为用户关闭开关。然后,我们会在调用 publish() 时传入此选项。将您的 publish() 函数更新为以下内容。
private fun publish() {
   val options = PublishOptions.Builder()
       .setStrategy(PUB_SUB_STRATEGY)
       .setCallback(object : PublishCallback() {
           override fun onExpired() {
               super.onExpired()
               // flick the switch off since the publishing has expired.
               // recall that we had set expiration time to 120 seconds
               // Use runOnUiThread to force the callback
               // to run on the UI thread
               runOnUiThread{
                   binding.publishSwitch.isChecked = false
               }
           }
       }).build()

   Nearby.getMessagesClient(this).publish(message, options)
}

每当用户开启 publish 开关时,此代码就会运行。

  1. 发布操作需要 Message,而订阅操作需要 MessageListener。但是在这里,我们同样建议您构建一个 SubscribeOptions 对象,即使 API 正常运行并不需要此对象也要构建。例如,通过构建自己的 SubscriptionOption,您可以指定发现模式的持续时间。

将以下 MessageListener 代码添加到 onCreate() 函数中。检测到消息时,监听器会将其添加至 RecyclerView 中。当消息消失时,监听器会将其从 RecyclerView 中移除。

messageListener = object : MessageListener() {
   override fun onFound(message: Message) {
       // Called when a new message is found.
       val msgBody = String(message.content)
       msgAdapter.addItem(msgBody)
   }

   override fun onLost(message: Message) {
       // Called when a message is no longer detectable nearby.
       val msgBody = String(message.content)
       msgAdapter.removeItem(msgBody)
   }
}
  1. 从技术上讲,订阅者不需要 TTL(或者可以将其 TTL 设置为无穷大)。但在此 Codelab 中,我们希望发现模式在 120 秒后停止。因此,我们要构建自己的 SubscribeOptions,并使用其 onExpired() 回调来关闭订阅界面 Switch。使用以下这段代码更新您的订阅函数。
private fun subscribe() {
   val options = SubscribeOptions.Builder()
       .setStrategy(PUB_SUB_STRATEGY)
       .setCallback(object : SubscribeCallback() {
           override fun onExpired() {
               super.onExpired()
               // flick the switch off since the subscribing has expired.
               // recall that we had set expiration time to 120 seconds
               // Use runOnUiThread to force the callback
               // to run on the UI thread
               runOnUiThread {
                   binding.subscribeSwitch.isChecked = false
               }
           }
       }).build()

   Nearby.getMessagesClient(this).subscribe(messageListener, options)
}
  1. 请务必让您的用户能够关闭信息共享功能。这意味着让发布者能停止发布,并让订阅者能停止订阅。如要停止发布,发布者必须指定要停止发布的消息。因此,如果正在广播 10 条消息,可以停止发布 1 条,留下 9 条。
private fun unpublish() {
   Nearby.getMessagesClient(this).unpublish(message)
}
  1. 一个应用可以同时发布多条消息,但一次只能存在一个 MessageListener;因此,退订更为通用。如要停止订阅,订阅者必须指定监听器。
private fun unsubscribe() {
   Nearby.getMessagesClient(this).unsubscribe(messageListener)
}
  1. 顺便提一下,虽然此 API 应在客户端进程终止时关闭其进程,但您可能想在 onDestroy() 生命周期方法中停止订阅(并在方便的情况下停止发布)。
override fun onDestroy() {
   super.onDestroy()
   // although the API should shutdown its processes when the client process dies,
   // you may want to stop subscribing (and publishing if convenient)
   Nearby.getMessagesClient(this).unpublish(message)
   Nearby.getMessagesClient(this).unsubscribe(messageListener)
}

此时,我们的代码应该可以编译了。不过,由于缺少 API 密钥,应用还无法按预期运行。您的 MainActivity 看上去应如下所示。

package com.example.nearbymessagesexample

import android.os.Build
import android.os.Bundle
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.nearbymessagesexample.databinding.ActivityMainBinding
import com.google.android.gms.nearby.Nearby
import com.google.android.gms.nearby.messages.Message
import com.google.android.gms.nearby.messages.MessageListener
import com.google.android.gms.nearby.messages.PublishCallback
import com.google.android.gms.nearby.messages.PublishOptions
import com.google.android.gms.nearby.messages.Strategy
import com.google.android.gms.nearby.messages.SubscribeCallback
import com.google.android.gms.nearby.messages.SubscribeOptions
import java.nio.charset.Charset

class MainActivity : AppCompatActivity() {

   /**
    * For accessing layout variables
    */
   private lateinit var binding: ActivityMainBinding

   /**
    * Sets the time to live in seconds for the publish or subscribe.
    */
   private val TTL_IN_SECONDS = 120 // Two minutes.

   /**
    * Choose of strategies for publishing or subscribing for nearby messages.
    */
   private val PUB_SUB_STRATEGY = Strategy.Builder().setTtlSeconds(TTL_IN_SECONDS).build()

   /**
    * The [Message] object used to broadcast information about the device to nearby devices.
    */
   private lateinit var message: Message

   /**
    * A [MessageListener] for processing messages from nearby devices.
    */
   private lateinit var messageListener: MessageListener

   /**
    * For adding [messages][Message] to the [RecyclerView]
    */
   private lateinit var msgAdapter: MessageAdapter

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

       binding.subscribeSwitch.setOnCheckedChangeListener { buttonView, isChecked ->
           if (isChecked) {
               subscribe()
           } else {
               unsubscribe()
           }
       }

       binding.publishSwitch.setOnCheckedChangeListener { buttonView, isChecked ->
           if (isChecked) {
               publish()
           } else {
               unpublish()
           }
       }
       setupMessagesDisplay()

       // The message being published is simply the Build.MODEL of the device. But since the
       // Messages API is expecting a byte array, you must convert the data to a byte array.
       message = Message(Build.MODEL.toByteArray(Charset.forName("UTF-8")))

       messageListener = object : MessageListener() {
           override fun onFound(message: Message) {
               // Called when a new message is found.
               val msgBody = String(message.content)
               msgAdapter.addItem(msgBody)
           }

           override fun onLost(message: Message) {
               // Called when a message is no longer detectable nearby.
               val msgBody = String(message.content)
               msgAdapter.removeItem(msgBody)
           }
       }

   }

   override fun onDestroy() {
       super.onDestroy()
       // although the API should shutdown its processes when the client process dies,
       // you may want to stop subscribing (and publishing if convenient)
       Nearby.getMessagesClient(this).unpublish(message)
       Nearby.getMessagesClient(this).unsubscribe(messageListener)
   }

   private fun publish() {
       val options = PublishOptions.Builder()
           .setStrategy(PUB_SUB_STRATEGY)
           .setCallback(object : PublishCallback() {
               override fun onExpired() {
                   super.onExpired()
                   // flick the switch off since the publishing has expired.
                   // recall that we had set expiration time to 120 seconds
                   runOnUiThread {
                       binding.publishSwitch.isChecked = false
                   }
                   runOnUiThread() {
                       binding.publishSwitch.isChecked = false
                   }
               }
           }).build()

       Nearby.getMessagesClient(this).publish(message, options)
   }

   private fun unpublish() {
       Nearby.getMessagesClient(this).unpublish(message)
   }

   private fun subscribe() {
       val options = SubscribeOptions.Builder()
           .setStrategy(PUB_SUB_STRATEGY)
           .setCallback(object : SubscribeCallback() {
               override fun onExpired() {
                   super.onExpired()
                   runOnUiThread {
                       binding.subscribeSwitch.isChecked = false
                   }
               }
           }).build()

       Nearby.getMessagesClient(this).subscribe(messageListener, options)
   }

   private fun unsubscribe() {
       Nearby.getMessagesClient(this).unsubscribe(messageListener)
   }

   private fun setupMessagesDisplay() {
       msgAdapter = MessageAdapter()
       with(binding.nearbyMsgRecyclerView) {
           layoutManager = LinearLayoutManager(context)
           this.adapter = msgAdapter
       }
   }

   class MessageAdapter : RecyclerView.Adapter<MessageAdapter.MessageVH>() {
       private var itemsList: MutableList<String> = arrayListOf()

       override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageVH {
           return MessageVH(TextView(parent.context))
       }

       override fun onBindViewHolder(holder: MessageVH, position: Int) {
           holder.bind(getItem(position))
       }

       override fun getItemCount(): Int = itemsList.size

       private fun getItem(pos: Int): String? = if (itemsList.isEmpty()) null else itemsList[pos]

       fun addItem(item: String) {
           itemsList.add(item)
           notifyItemInserted(itemsList.size)
       }

       fun removeItem(item: String) {
           val pos = itemsList.indexOf(item)
           itemsList.remove(item)
           notifyItemRemoved(pos)
       }

       inner class MessageVH(private val tv: TextView) : RecyclerView.ViewHolder(tv) {
           fun bind(item: String?) {
               item?.let { tv.text = it }
           }
       }
   }
}

将 Google 提供的 API 密钥添加到清单文件中

Nearby Messages API 包含 Google 为您提供的服务器组件。当您发布消息后,Nearby Messages API 实际上会将消息发送到 Google 服务器,然后订阅者可以从此服务器查询消息。为了让 Google 能识别您的应用,您需要将 Google 提供的 API_KEY 添加到 Manifest.xml 文件中。之后我们就可以运行和操作应用了。

获取 API 密钥的过程分为三个步骤:

  1. 转到 Google Developers Console
  2. 点击 + 创建凭据,然后选择 API 密钥*。*
  3. 复制已创建的 API 密钥并将其粘贴到您的 Android 项目的清单文件中。

在您的清单文件中,在 application 内添加 com.google.android.nearby.messages.API_KEY 元数据项。此文件内容应如下所示。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.example.nearbymessagesexample">

   <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/Theme.NearbyMessagesExample">

       <meta-data
           android:name="com.google.android.nearby.messages.API_KEY"
           android:value="ADD_KEY_HERE" />

       <activity android:name=".MainActivity">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>

添加 API 密钥后,在两台或更多设备上运行此应用,以查看设备之间的通信情况。

ba105a7c853704ac.gif

  • 为了保护用户隐私并延长电池续航时间,您应该在用户离开包含必要功用的功能后立即停止发布和订阅。
  • 您应使用“附近消息”功能在设备之间建立近程关系,而不是用于持续通信。持续通信时设备的电池电量消耗速度是正常情况的 2.5 到 3.5 倍。

恭喜!您现在已经了解如何使用 Nearby Messages API 在附近的设备之间发送和发现消息。

简言之,如要使用 Nearby Messages API,您需要为 play-services-nearby 添加依赖项,还需要从 Google Developers Console 获取 API 密钥并将其添加到 Manifest.xml 文件中。此 API 需要连接互联网,以便发布者可以将他们的消息发送到 Google 服务器,供订阅者抓取。

  • 您已了解如何发送消息
  • 您已了解如何通过订阅发现消息
  • 您已了解如何使用消息(在本例中,只是在 RecyclerView 中显示了消息)

后续操作

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