Giao tiếp hai chiều khi không có Internet

1. Trước khi bắt đầu

Chẳng phải sẽ thật tốt nếu bạn có thể sử dụng thiết bị di động để cộng tác trong các dự án nhóm hoặc chia sẻ video, phát trực tuyến nội dung, chơi trò chơi nhiều người chơi – ngay cả khi không có kết nối Internet? Bạn thực sự có thể làm được. Trong lớp học lập trình này, bạn sẽ tìm hiểu cách làm việc đó.

Để đơn giản hoá mọi thứ, chúng ta sẽ xây dựng trò chơi Rock-Paper-Scissors (Oẳn tù tì) nhiều người chơi dùng được mà không cần Internet. Lớp học lập trình này hướng dẫn bạn cách sử dụng Nearby Connections API (API Kết nối lân cận) – một phần trong Dịch vụ Google Play cho phép người dùng giao tiếp với nhau dựa trên độ gần thực tế. Người dùng phải cách nhau trong vòng 100 mét. Không có giới hạn về kiểu dữ liệu hoặc lượng dữ liệu mà người dùng có thể chia sẻ – ngay cả khi không có kết nối Internet. Người dùng có thể phát trực tuyến video, gửi và nhận tin nhắn thoại, gửi tin nhắn văn bản, v.v.

Điều kiện tiên quyết

  • Có kiến thức cơ bản về Kotlin và phát triển Android
  • Biết cách tạo và chạy ứng dụng trên Android Studio
  • Hai hoặc nhiều thiết bị Android, để chạy và kiểm thử mã
  • chạy Android API cấp độ 16 trở lên
  • có cài đặt Dịch vụ Google Play
  • Phiên bản mới nhất của Android Studio.

Kiến thức bạn sẽ học được

  • Cách thêm thư viện Nearby Connections (Kết nối lân cận) trong Dịch vụ Google Play vào ứng dụng
  • Cách thể hiện ý muốn được giao tiếp với các thiết bị ở gần
  • Cách khám phá các thiết bị lân cận có quan tâm
  • Cách giao tiếp với các thiết bị đã kết nối
  • Các phương pháp hay nhất để bảo vệ dữ liệu và quyền riêng tư

Sản phẩm bạn sẽ tạo ra

Lớp học lập trình này hướng dẫn bạn cách tạo một ứng dụng có duy nhất một Hoạt động (Activity) cho phép người dùng tìm đối thủ và chơi trò chơi Rock-Paper-Scissors (Oẳn tù tì). Ứng dụng này có các thành phần giao diện người dùng sau đây:

  1. Một nút (button) để tìm đối thủ
  2. Tay điều khiển trò chơi có ba nút cho phép người dùng chọn (ROCK) ĐÁ, PAPER (GIẤY) hoặc SCISSORS (KÉO) để chơi
  3. Các TextView cho thấy điểm số
  4. Một TextView cho thấy trạng thái

625eeebfad3b195a.png

Hình 1

2. Tạo một dự án Android Studio

  1. Bắt đầu một dự án Android Studio mới.
  2. Chọn Empty Activity (Hoạt động trống).

f2936f15aa940a21.png

  1. Đặt tên Rock Paper Scissors cho dự án và đặt ngôn ngữ thành Kotlin.

1ea410364fbdfc31.png

3. Thiết lập mã

  1. Thêm phiên bản mới nhất của phần phụ thuộc Nearby (Chia sẻ lân cận) vào tệp build.gradle cấp độ ứng dụng. Việc này cho phép ứng dụng của bạn dùng Nearby Connections API để thể hiện rằng bạn có ý muốn được kết nối, khám phá thiết bị ở gần và giao tiếp.
implementation 'com.google.android.gms:play-services-nearby:LATEST_VERSION'
  1. Đặt tuỳ chọn xây dựng viewBinding thành true trong khối android để bật tính năng View Binding (Liên kết khung hiển thị), nhờ đó bạn không phải sử dụng findViewById tương tác với Views (Khung hiển thị).
android {
   ...
   buildFeatures {
       viewBinding true
   }
}
  1. Nhấp vào Sync Now (Đồng bộ hoá ngay) hoặc nút hình búa màu xanh lục để Android Studio xem xét những thay đổi Gradle này.

57995716c771d511.png

  1. Chúng ta sẽ sử dụng các vectơ có thể vẽ dành cho hình ảnh đá, giấy và kéo. Thêm ba tệp XML sau vào thư mục 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. Thêm tay điều khiển trò chơi (nói cách khác là các nút chơi), điểm và trạng thái của TextView cho màn hình trò chơi. Trong tệp activity_main.xml, hãy thay thế mã bằng đoạn sau:
<?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>

Bố cục của bạn bây giờ sẽ trông giống như trong Hình 1 ở trên.

Lưu ý: Trong dự án riêng của bạn, hãy thay đổi các giá trị như 16dp thành các tài nguyên như @dimen/activity_vertical_margin.

4. Thêm tính năng Nearby Connections (Kết nối lân cận) vào ứng dụng

Chuẩn bị tệp manifest.xml

Thêm các quyền sau đây vào tệp kê khai. Do ACCESS_FINE_LOCATION là một quyền nguy hiểm nên ứng dụng của bạn sẽ chứa mã kích hoạt hệ thống để thay ứng dụng nhắc người dùng cấp hoặc từ chối quyền truy cập. Quyền truy cập Wi-Fi chỉ áp dụng cho các kết nối ngang hàng, không phải kết nối Internet.

<!-- 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"/>

Chọn một chiến lược

Nearby Connections API yêu cầu bạn chọn Strategy nhằm xác định cách ứng dụng kết nối với các thiết bị ở gần khác. Chọn P2P_CLUSTER, P2P_STAR hoặc P2P_POINT_TO_POINT.

Đối với mục đích của chúng ta, P2P_STAR sẽ được chọn vì chúng ta muốn thấy nhiều yêu cầu đến từ những người chơi muốn thử thách chúng ta, nhưng chỉ chơi với một người tại một thời điểm.

Bạn phải sử dụng Strategy mà bạn chọn cho cả việc quảng cáo (advertising) lẫn khám phá (discovery) trong ứng dụng. Hình bên dưới cho thấy cách hoạt động của từng Strategy.

Thiết bị có thể yêu cầu N kết nối đi

Thiết bị có thể nhận M kết nối đến

P2P_CLUSTER

N = nhiều

M = nhiều

dẫn đến băng thông của các kết nối thấp hơn

P2P_STAR

N = 1

M = nhiều

dẫn đến băng thông của các kết nối cao hơn

P2P_POINT_TO_POINT

N = 1

M = 1

thông lượng cao nhất có thể

Khai báo biến trong MainActivity

  1. Bên trong hoạt động chính (MainActivity.kt), phía trên hàm onCreate(), hãy khai báo các biến sau bằng cách dán đoạn mã này. Các biến này xác định logic cụ thể của trò chơi cũng như các quyền trong thời gian chạy.
/**
* 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. Thay đổi hàm onCreate() của bạn để chuyển đối tượng ViewBinding thành setContentView(). Thao tác này sẽ hiện nội dung của tệp bố cục activity_main.xml. Ngoài ra, hãy khởi động connectionsClient để ứng dụng có thể giao tiếp với API.
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)
   connectionsClient = Nearby.getConnectionsClient(this)
}

Xác minh quyền cần thiết

Theo quy tắc, các quyền nguy hiểm được khai báo trong tệp AndroidManifest.xml nhưng phải được yêu cầu trong thời gian chạy. Đối với các quyền cần thiết khác, bạn vẫn nên xác minh các quyền đó trong thời gian chạy để đảm bảo kết quả như mong đợi. Và nếu người dùng từ chối quyền nào trong số đó, hãy hiện một thông báo ngắn để người dùng biết rằng họ không thể tiếp tục nếu không cấp các quyền này vì ứng dụng mẫu sẽ không chạy được nếu thiếu các quyền đó.

  • Bên dưới hàm onCreate(), hãy thêm đoạn mã sau đây để xác minh rằng chúng ta có các quyền cần thiết:
@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()
   }
}

Tới đây, chúng ta đã viết mã để hoàn thành các nhiệm vụ sau:

  • Đã tạo tệp bố cục
  • Đã khai báo quyền cần thiết trong tệp kê khai
  • Đã xác minh quyền nguy hiểm cần có trong thời gian chạy

Đảo ngược hướng dẫn từng bước

Hiện tại, chúng ta đã xử lý xong các bước sơ bộ. Chúng ta đã sẵn sàng bắt đầu viết mã Nearby Connections (Kết nối lân cận) để kết nối với người dùng ở gần và giao tiếp. Thông thường, để có thể thực sự bắt đầu giao tiếp với thiết bị ở gần, ứng dụng của bạn phải cho phép thiết bị khác tìm thấy, đồng thời cũng phải quét tìm các thiết bị khác.

Nói cách khác, trong ngữ cảnh của trò chơi Rock-Paper-Scissors, bạn và đối thủ phải tìm thấy nhau để có thể bắt đầu trò chơi.

Bạn có thể giúp các thiết bị lân cận khám phá ra thiết bị của mình thông qua một quá trình có tên là quảng cáo (advertising). Tương tự như vậy, bạn có thể tìm ra các đối thủ lân cận thông qua một quá trình gọi là khám phá (discovery).

Để hiểu được quy trình, tốt nhất bạn nên xử lý mã theo thứ tự đảo ngược. Để đạt được mục tiêu đó, chúng ta sẽ tiếp tục như sau:

  1. Chúng ta sẽ giả sử rằng mình đã kết nối và viết mã để gửi và nhận tin nhắn. Theo mục đích hiện tại của chúng ta, việc viết mã này là để thực sự chơi trò chơi Rock-Paper-Scissors.
  2. Chúng ta sẽ viết mã để quảng cáo việc chúng ta muốn kết nối với các thiết bị lân cận.
  3. Chúng ta sẽ viết mã để khám phá các thiết bị lân cận.

Gửi và nhận dữ liệu

Bạn sử dụng phương thức connectionsClient.sendPayload() để gửi dữ liệu dưới dạng Payload và sử dụng đối tượng PayloadCallback để nhận các gói dữ liệu (payload). Payload có thể là nội dung bất kỳ: video, ảnh, luồng phát hoặc loại dữ liệu khác. Không có giới hạn về dữ liệu.

  1. Trong trò chơi của chúng ta, gói dữ liệu này là một lựa chọn (đá, giấy hoặc kéo). Khi người dùng nhấp vào một trong các nút điều khiển, ứng dụng của họ sẽ gửi lựa chọn đó đến ứng dụng của đối thủ dưới dạng một gói dữ liệu. Để ghi lại lượt chơi của người dùng, hãy thêm đoạn mã sau đây vào bên dưới hàm 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. Một thiết bị nhận các gói dữ liệu qua đối tượng PayloadCallback có hai phương thức. Phương thức onPayloadReceived() sẽ thông báo cho ứng dụng của bạn khi nhận được tin nhắn, còn phương thức onPayloadTransferUpdate() sẽ theo dõi trạng thái của cả tin nhắn đến và đi.

Để phục vụ mục đích của mình, chúng ta sẽ đọc tin nhắn đến từ onPayloadReceived() khi đối thủ thực hiện lượt chơi, đồng thời sử dụng phương thức onPayloadTransferUpdate() để theo dõi và xác nhận thời điểm cả hai người đã thực hiện xong lượt chơi của họ. Thêm đoạn mã này vào phía trên phương thức 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)
       }
   }
}

Bạn quảng cáo sự hiện diện hoặc mối quan tâm của mình với hy vọng ai đó ở gần sẽ chú ý đến và đề nghị kết nối với bạn. Do đó, phương thức startAdvertising() của Nearby Connections API yêu cầu một đối tượng gọi lại (callback object). Lệnh gọi lại ConnectionLifecycleCallback sẽ thông báo cho bạn khi có người nào đó chú ý đến quảng cáo của bạn và muốn kết nối. Đối tượng gọi lại có 3 phương thức:

  • Phương thức onConnectionInitiated() cho bạn biết rằng có ai đó chú ý đến quảng cáo của bạn và muốn kết nối. Kết quả là bạn có thể chọn chấp nhận kết nối với connectionsClient.acceptConnection().
  • Khi có người chú ý đến quảng cáo của bạn, họ sẽ gửi cho bạn một yêu cầu kết nối. Cả bạn và người gửi đều phải chấp nhận yêu cầu kết nối để thực sự kết nối. Phương thức onConnectionResult() cho bạn biết liệu kết nối đã được thiết lập hay chưa.
  • Hàm onDisconnected() cho bạn biết rằng kết nối không còn hoạt động. Điều này có thể xảy ra, chẳng hạn như khi bạn hoặc đối thủ quyết định chấm dứt kết nối.

Cách quảng cáo:

  1. Đối với ứng dụng của chúng ta, chúng ta sẽ chấp nhận kết nối khi nhận lại lệnh gọi onConnectionInitiated(). Sau đó, bên trong onConnectionResult(), nếu kết nối đã được thiết lập, chúng ta sẽ ngừng quảng cáo và khám phá vì chúng ta chỉ cần kết nối với một đối thủ để chơi trò chơi. Và cuối cùng, trong onConnectionResult(), chúng ta sẽ đặt lại trò chơi này.

Hãy dán đoạn mã sau trước phương thức 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. Vì rất thuận tiện khi gọi resetGame() tại nhiều điểm nối (juncture) nên chúng ta sẽ biến phương thức này thành chương trình con (subroutine) của chính nó. Hãy thêm mã này ở phần cuối của lớp 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. Đoạn mã sau đây là lệnh gọi quảng cáo thực tế, trong đó bạn cho Nearby Connections API biết rằng bạn muốn chuyển sang chế độ quảng cáo (advertising mode). Hãy thêm mã này bên dưới phương thức 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
   )
}

Khám phá

Phần bổ sung cho quảng cáo là khám phá. Hai lệnh gọi này là rất giống nhau, ngoại trừ việc sử dụng các lệnh gọi lại riêng biệt. Lệnh gọi lại cho lệnh gọi startDiscovery() là một đối tượng EndpointDiscoveryCallback. Đối tượng này có hai phương thức gọi lại: onEndpointFound() được gọi khi phát hiện thấy một quảng cáo, còn onEndpointLost() được gọi khi một quảng cáo không còn nữa.

  1. Ứng dụng của chúng ta sẽ kết nối với người quảng cáo đầu tiên chúng ta phát hiện thấy. Tức là chúng ta sẽ tạo một yêu cầu kết nối bên trong phương thức onEndpointFound() và không xử lý gì trong phương thức onEndpointLost(). Hãy thêm lệnh gọi lại vào trước phương thức 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. Ngoài ra, hãy thêm đoạn mã này để thực sự thông báo cho Nearby Connections API biết rằng bạn muốn chuyển sang chế độ khám phá (discovery mode). Hãy thêm mã này ở phần cuối của lớp MainActivity.
private fun startDiscovery(){
   val options = DiscoveryOptions.Builder().setStrategy(STRATEGY).build()
   connectionsClient.startDiscovery(packageName,endpointDiscoveryCallback,options)
}
  1. Đến thời điểm này, phần Nearby Connections (Kết nối lân cận) trong bài tập của chúng ta đã xong! Chúng ta có thể quảng cáo, khám phá và giao tiếp với các thiết bị lân cận. Nhưng chúng ta chưa thể chơi trò chơi này. Chúng ta phải hoàn tất việc kết nối các thành phần hiển thị giao diện người dùng:
  • Khi người dùng nhấp vào nút FIND OPPONENT (TÌM ĐỐI THỦ), ứng dụng sẽ gọi cả startAdvertising()startDiscovery(). Bằng cách này, bạn vừa khám phá vừa được khám phá.
  • Khi người dùng nhấp vào một trong các nút điều khiển của ROCK (ĐÁ), PAPER (GIẤY), hoặc SCISSORS (KÉO), ứng dụng cần gọi sendGameChoice() để truyền dữ liệu đó cho đối thủ.
  • Khi một trong hai người dùng nhấp vào nút DISCONNECT (NGỪNG KẾT NỐI), ứng dụng sẽ đặt lại trò chơi.

Cập nhật phương thức onCreate() để phản ánh các lượt tương tác này.

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
}

Dọn dẹp

Bạn nên ngừng sử dụng Nearby API khi không còn cần thiết nữa. Đối với trò chơi mẫu của mình, chúng ta huỷ bỏ tất cả tài sản bên trong hàm vòng đời hoạt động onStop().

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

5. Chạy ứng dụng

Chạy ứng dụng trên hai thiết bị và tận hưởng trò chơi!

e545703b29e0158a.gif

6. Phương pháp hay nhất về quyền riêng tư

Trò chơi Rock-Paper-Scissors của chúng ta không chia sẻ dữ liệu nhạy cảm nào. Ngay cả tên mã cũng được tạo ngẫu nhiên. Đó là lý do chúng ta tự động chấp nhận kết nối bên trong onConnectionInitiated(String, ConnectionInfo).

Đối tượng ConnectionInfo chứa một mã thông báo duy nhất cho mỗi kết nối mà ứng dụng của bạn có thể truy cập được thông qua getAuthenticationDigits(). Bạn có thể hiện mã thông báo cho cả hai người dùng để xác minh bằng hình ảnh. Ngoài ra, bạn có thể mã hoá mã thông báo thô (raw token) trên một thiết bị rồi gửi dưới dạng gói dữ liệu để giải mã trên thiết bị khác trước khi bạn bắt đầu chia sẻ dữ liệu nhạy cảm. Để biết thêm về quá trình mã hoá Android, hãy xem bài đăng trên blog có tên " Improve your app's cryptography, from message authentication to user presence" (Cải thiện khía cạnh mã hoá của ứng dụng, từ xác thực thông báo đến sự hiện diện của người dùng).

7. Xin chúc mừng

Xin chúc mừng! Giờ đây, bạn đã biết cách dùng Nearby Connections API để kết nối người dùng với nhau mà không cần kết nối Internet.

Tóm lại, để sử dụng Nearby Connections API, bạn cần thêm phần phụ thuộc cho play-services-nearby. Bạn cũng cần yêu cầu các quyền trong tệp AndroidManifest.xml và kiểm tra các quyền đó trong thời gian chạy. Bạn cũng đã biết cách thực hiện các việc sau:

  • Quảng cáo mối quan tâm kết nối với người dùng lân cận
  • Khám phá những người dùng lân cận muốn kết nối
  • Chấp nhận kết nối
  • Gửi tin nhắn
  • Nhận tin nhắn
  • Bảo vệ quyền riêng tư của người dùng

Tiếp theo là gì?

Hãy xem loạt blog và mẫu của chúng tôi