本地多人連線遊戲如何優化裝置配對與資料交換流程

從這裡開始行動 - 提升本地多人連線遊戲配對效率與資料交換穩定度

  1. 設定裝置搜尋與廣播時間於 30 秒內自動關閉

    減少無用連線嘗試,降低干擾與耗電

  2. 每場遊戲配對成功後即時分配唯一 ID 給每台裝置

    方便後續資料交換與斷線重連管理

  3. 資料交換封包大小單次不超過 512KB

    減少丟包與延遲,提升即時互動感受

  4. 配對流程中遇權限彈窗,三秒內提示一次且最多三次

    避免用戶困惑或無感跳出,提高配對成功率

本地多人對戰遊戲的開場白與應用幻想

# 🌊 TicTacToe 遊戲-Android 上的 Nearby Connections 💥

## 👨🏻‍💻 這篇文章適合誰?

### 👉 如果你最近腦中冒出「怎麼讓 Android 裝置直接對戰」這種問題,或是偏偏就想搞:
- **本地多人遊戲**
- **離線資料傳輸**
- **手機或平板遙控什麼東西(像 Android TV 之類)**


唉,有時候就是不想用網路嘛。今天打算講一個很常見但又沒人好好說清楚的東西:Android 的 Nearby Connections。假設,你手癢要寫個井字棋(Tic Tac Toe),希望兩台 Android 手機能彼此對戰——重點來了,不用連 Wi-Fi、也不用網路流量,只靠裝置本身。欸,我是不是跑題?反正就是,Nearby Connections 很適合這場景啦。

### 👉 要試玩直接去這裡:https://play.google.com/store/apps/details?id=com.niranjan.khatri.tictactoe

## 🤷‍♂️什麼是 Nearby Connections API?

說到底,Nearby Connections 就是幫裝置找到彼此,然後透過藍牙、Wi-Fi Direct 或 Wi-Fi Aware來互通訊息。不論你要做多人遊戲、檔案快傳還是兩台設備在那邊悄悄話,它大概都能幫得上忙。嗯……不過有時遇到相容性還真會讓人頭痛,先提醒自己別太天真好了。

## 📚 目錄:

🫠 _**🔰 相依性與權限**_
_**🔰 選擇策略**_
_**🔰 廣播者與探索者**_
_**🔰 設備準備:廣播模式**_
_**🔰 設備準備:探索模式**_
_**🔰 處理探索事件**_
_**🔰 建立連線**_
_**🔰 傳送與接收 Payloads(資料負載)**_
_**🔰 停止廣播與探索行為**_
_**🔰 與端點斷線處理**_
_**🔰 權限及定位需求處理說明**_
_**🔰 實作建議做法說明(Best Practices)**

## 然後,接下來會把怎麼用 Nearby Connections 實現那個井字棋遊戲一層層拆給你看。🤔

## 🎯 相依性與權限

👉 想動手就得先在 app 的 build.gradle 檔塞進下面這些相依性才行。不過啊,好像每次設定 gradle 都會出現新 bug,有夠煩人的,但還是只能硬著頭皮弄完,不然根本動不了下一步……

權限、依賴庫與 Nearby 連線起手式瑣碎事

gradle 檔案什麼的,嗯——就直接來了:
implementation 'com.google.android.gms:play-services-nearby:19.3.0'
然後這邊不要忘記,AndroidManifest.xml 裡頭權限還得寫清楚,我常常自己漏掉欸。該加的都加進去吧,不過每次 Android 改版,需求好像又有點不同,有時搞不懂到底要幾層防護,唉。雖說最基本的像下面這些是常見組合:






這裡其實沒啥新花樣,但有件事真的麻煩——位置權限。不誇張,我以前卡住超多次。**❗重要**:位置相關權限在這整套流程裡超級關鍵,即使你以為根本沒要抓定位,它還是會因為 Nearby Connections 利用藍牙跟 Wi-Fi 去探測附近裝置而需要定位服務。嗯,有點莫名其妙但就是得接受啦。所以啊,務必在執行階段主動請求用戶授權,要不然肯定會被擋下來。

## 🎯 選擇連線策略

👉 你知道嗎,其實 Nearby Connections 有蠻多種「Strategy」可以玩。每次看到那些策略選項就覺得 Google 是不是太愛命名哲學了?如果只是搞個井字遊戲那種兩人對戰(唉,小時候還挺愛玩的),通常就是 Strategy.P2P_STAR 比較適合啦。不過我又想岔題一下,每次查文件都怕自己又挑錯。

// 選擇策略
private companion object {
const val TAG = "TicTacToeVM"
// 因為僅需兩台裝置互聯,此處選擇該策略。
val STRATEGY = Strategy.P2P_POINT_TO_POINT
}

## 🎯 廣播者與探索者

👉 在井字遊戲中:可以透過 ConnectionsClient 管理廣播(advertising)與探索(discovery)流程。
kotlin
import com.google.android.gms.nearby.Nearby
import com.google.android.gms.nearby.connection.ConnectionsClient


// ...
private lateinit var connectionsClient: ConnectionsClient

// 可於 Activity 的 onCreate 或 ViewModel 的 init 方法初始化
connectionsClient = Nearby.getConnectionsClient(this) // 或 applicationContext
// ...
上面那段 sample code 一直讓我忍不住想起第一次弄 connectionsClient 初始化時的手忙腳亂,明明看起來沒什麼難度,但偏偏「this」和「applicationContext」總讓人猶豫,到底何時該用哪個,有興趣真的可以再深挖一下。

## 🎯 裝置準備:開始廣播

假設玩家 1 想要主辦一場比賽,那從設定開始吧。坦白說,每回寫到 device setup 的部分都莫名地緊張…可能是怕哪個步驟疏漏、或突然冒出奇怪 bug。不過,也只能硬著頭皮繼續下去了,大概大家都是如此吧。【注意事項】

權限、依賴庫與 Nearby 連線起手式瑣碎事

策略選擇?P2P_STAR 還是點對點,猶豫片刻

他們要開始廣播了。嗯,總覺得這一步好像很隆重,可是其實就點一下按鈕嘛,不知道為什麼每次都會猶豫個一兩秒。好吧。  
import com.google.android.gms.nearby.connection.AdvertisingOptions
import com.google.android.gms.nearby.connection.ConnectionInfo
import com.google.android.gms.nearby.connection.ConnectionLifecycleCallback
import com.google.android.gms.nearby.connection.ConnectionResolution
import com.google.android.gms.nearby.connection.ConnectionsStatusCodes
import com.google.android.gms.nearby.connection.DiscoveredEndpointInfo
import com.google.android.gms.nearby.connection.EndpointDiscoveryCallback
import com.google.android.gms.nearby.connection.Payload
import com.google.android.gms.nearby.connection.PayloadCallback
import com.google.android.gms.nearby.connection.PayloadTransferUpdate</code></pre>


// 這個 SERVICE_ID 說真的有點難記,但沒辦法,就是它——com.example.tictactoe.SERVICE_ID。
private const val SERVICE_ID = "com.example.tictactoe.SERVICE_ID"
private var localUsername: String = "Player1" // 有時候會懷疑要不要讓大家自己輸入名字,但先這樣吧。

private fun startAdvertising() {
val advertisingOptions = AdvertisingOptions.Builder().setStrategy(STRATEGY).build()
connectionsClient.startAdvertising(
localUsername, // 反正就你裝置的暱稱,別想太多。
SERVICE_ID, // 還是得靠這個 ID 才找得到彼此,蠻現實的。
connectionLifecycleCallback, // 之後再來處理細節,現在先放著不管也沒差。
advertisingOptions
).addOnSuccessListener {
// 廣播已經啟動…說起來其實也沒啥聲光效果,就 log 一下而已。
log("成功開始廣播。")
}.addOnFailureListener { e ->
// 唉,有時候還是會失敗,也不是什麼大不了的事啦(但還是蠻煩人的)。
log("廣播失敗:${e.localizedMessage}")
// 順便提醒一下使用者狀況,不然整個空白螢幕誰受得了?
}
}

// 雖然只是一行,但紀錄日誌就是寫程式時的小確幸之一,有種留痕跡的感覺。
private fun log(message: String) {
Log.d("TicTacToeNearby", message)
}

localUsername——這就是本機裝置顯示給別人看的名稱啦。嗯,有些人很在意取什麼暱稱,我反而總是用預設值,怕麻煩。SERVICE_ID 不用說,等同於標籤一樣存在,要搜尋服務非它不可;connectionLifecycleCallback 則專門拿來處理連線請求、建立或斷線等等事件,只不過現在還沒寫完罷了;advertisingOptions 裡面設定了一些流程參數,比如 Strategy,那到底 Strategy 是什麼?唉,以後再討論好了。

對了,在準備階段 Player 2 想要加入遊戲,所以得確認一切都設好才行。不過我老是在想,如果有一天不用 service ID 就能找到彼此,那該多輕鬆…啊,好像離題了,回來繼續寫程式吧。

廣播、搜尋:雙方裝置如何彼此靠近那一步

他們準備要開始探索了。嗯,這裡真的有點緊張,我總覺得每次寫到程式碼就特別容易分心,腦袋開始亂飄。但還是得拉回來。

kotlin
import com.google.android.gms.nearby.connection.DiscoveryOptions


private fun startDiscovery() {
val discoveryOptions = DiscoveryOptions.Builder().setStrategy(STRATEGY).build()
connectionsClient.startDiscovery(
SERVICE_ID, // 這個SERVICE_ID務必要和廣播端完全一樣
endpointDiscoveryCallback, // 等一下會提到這東西
discoveryOptions
).addOnSuccessListener {
// 探索開始啦。
log("成功啟動探索。")
}.addOnFailureListener { e ->
// 沒能順利開啟探索。
log("探索失敗:${e.localizedMessage}")
// 唉,有時候就是會出錯。
}
}
**SERVICE_ID:** 反正就是不能跟廣播端的不一樣,不然找不到對方,很煩。
**endpointDiscoveryCallback:** 用來處理遇見新廣播端或對方突然消失的那些奇怪事件,偶爾覺得太多細節真麻煩。

## 🎯 處理探索事件

當發現者(Player 2)碰巧遇上某個廣播端(Player 1),`endpointDiscoveryCallback` 就會跳出來通知你——不過有時候你根本沒注意畫面,只能等Log提醒。

private val endpointDiscoveryCallback = object : EndpointDiscoveryCallback(){
override fun onEndpointFound(endpointId: String, info: DiscoveredEndpointInfo) {
Log.d(TAG, "onEndpointFound: ")
Log.d(TAG, "正在請求連線....")
connectionsClient.requestConnection(
localUsername,
endpointId,
connectionLifecycleCallback
).addOnSuccessListener {
Log.d(TAG, "onEndpointFound: 成功請求連線")
}.addOnFailureListener {
Log.d(TAG, "onEndpointFound: 請求連線失敗")
}
}

override fun onEndpointLost(endpointId: String) {
Log.d(TAG, "onEndpointLost:")
}
}
**onEndpointFound:** 每當偵測到一個SERVICE_ID吻合的新廣播端,就會被觸發一次,有種「欸?真的找到人了嗎?」的感覺,而`discoveredEndpointInfo.endpointName`其實就是對方送過來的名字,比如:"Player1"。說真的,有時候看到陌生ID還挺慌張。

**onEndpointLost:** 如果剛才那個廣播端突然沒了,這裡就會收到通知。我常在想,他們到底跑哪去了?啊,不管了,再回主題吧。

廣播、搜尋:雙方裝置如何彼此靠近那一步

發現彼端時會發生什麼,及那些小細節

## 🎯 建立連線

嗯,連線這件事啊,總覺得有點像是在交換名片,只是數位化。過程其實就是個雙向握手啦,兩邊都要點頭才算數——咦,我是不是又想太多?先不管。

- **發起方請求連線:** 玩家2的裝置會去呼叫connectionsClient.requestConnection(...),這名字好長,其實就只是「欸我要連一下」那種感覺而已。
- **廣播方接收請求:** 這時候,玩家1那端的裝置會透過connectionLifecycleCallback收到通知。老實說,有時候等這通知還挺焦躁的,不知道你有沒有同感?
- **廣播方接受或拒絕:** 玩家1可以根據當下心情……呃不是,是根據狀況決定要不要接受連線,有點像是選擇加好友還是不理他。
- **雙方獲得狀態通知:** 最後,不論成敗,兩邊的裝置都會各自從自己的connectionLifecycleCallback裡得到更新。不曉得為什麼,每次我看到「成功」還是「失敗」時,都會有一點小情緒波動耶,好啦扯遠了。


下面呢,就是要定義所謂的connectionLifecycleCallback了。唉,有時真的很懷念不用煩惱 callback 的日子,但技術嘛,就是一直堆上來的東西。

連接協商過程混亂記——誰先接受誰先等?

這個 callback(啊其實我一直覺得這名字有點生硬,不過…沒差啦)會被廣告者跟探索者同時拿來用。只是,尷尬的地方在於,雖然都是連線建立流程,可兩邊觸發的細節又不太一樣,有時候你會突然搞混誰該幹嘛。

欸,像下面這段 Kotlin code,其實就是那個連線生命週期的 callback 實作。寫到一半想到,Log.d 真的很吵——但不記錄更慌,所以還是貼著:

// 廣告者、探索者全都得接連線通知
private val connectionLifecycleCallback = object: ConnectionLifecycleCallback(){
override fun onConnectionInitiated(endpointId: String, info: ConnectionInfo) {
Log.d(TAG, "onConnectionInitiated ")
Log.d(TAG, "Accepting Connection...")
connectionsClient.acceptConnection(
endpointId,
payloadCallback // 處理收到資料而已
).addOnSuccessListener { Log.d(TAG, "Connection accepted from $opponentPlayer.") }
.addOnFailureListener { e -> Log.e(TAG, "Failed to accept connection: ${e.localizedMessage}") }
}

override fun onConnectionResult(endpointId: String, resolution: ConnectionResolution) {
Log.d(TAG, "onConnectionResult: ")
when (resolution.status.statusCode){
ConnectionsStatusCodes.STATUS_OK -> {
Log.d(TAG, "onConnectionResult: ConnectionStatusCodes.STATUS_OK")
// 連線已經弄好!此刻可傳 payload。
// 假如你是在玩井字棋什麼鬼的,大概就開始了。
// 玩家1(廣告者)也許先行一步吧。
connectionsClient.stopAdvertising()
connectionsClient.stopDiscovery()
opponentEndpointId = endpointId
Log.d(TAG, "onConnectionResult: $opponentEndpointId")
newGame()
TicTacToeRouter.navigateTo(Screen.Game)
}
ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED -> {
Log.d(TAG, "onConnectionResult: ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED")
// 換 UI:「被拒絕」
}
ConnectionsStatusCodes.STATUS_ERROR -> {
Log.d(TAG, "onConnectionResult: ConnectionsStatusCodes.STATUS_ERROR")
// 換 UI:「出錯了」
}
else -> {
Log.d(TAG, "onConnectionResult: Unknown status code ${resolution.status.statusCode}")
}
}
}

override fun onDisconnected(p0: String) {
Log.d(TAG, "onDisconnected: ")
}
}

然後再說明一下:**onConnectionInitiated** 這玩意兒,每當 requestConnection 被叫了以後,不論是哪方,其實都會跳進來一次。嗯,廣告者通常這裡要不是 accept 就是 reject;至於探索者則多半只能乾等。不知道為什麼想到自己高中的社團面試,好像也是在等人家決定要不要收你,哈哈。

至於 **onConnectionResult**,基本上雙方都能收到結果回報。如果看到 **STATUS_OK** 啊,就表示真的連上了,不用再緊張。

最後還有一個 **onDisconnected**,顧名思義,如果哪天裝置之間掉線或掛掉,它就會被呼叫一次。有時候莫名其妙就斷了,也只有無奈。

## 🎯 傳送與接收 Payload

對了——等到配對完成後,那才是真正可以用 Payloads 傳資料給彼此,不管是遊戲資訊、檔案或其他鬼東西都能丟。Payload 支援幾種型態:

- **Payload.fromBytes():** 拿原始 byte array 去傳送(適合自訂格式、遊戲步驟啥的)。
- **Payload.fromFile():** 可以處理比較大的檔案傳輸啦。

唉,每次講到資料交換,都想起第一次做 socket programming 時把自己搞得焦頭爛額……咳,好像扯遠了。總之,用 Payload 就能讓兩端裝置直接互通消息,很直白啦。

連接協商過程混亂記——誰先接受誰先等?

交換資料那點小心機——Payload 的各種花樣說明一下

fromStream():嗯,這個方法主要就是拿來做串流資料傳輸用的啦。針對井字遊戲(Tic-Tac-Toe)來說,遊戲每一步都會被打包成位元組陣列傳送,比如那種像是 "MOVE:2,1" 這樣的字串,其實單看還挺直白——就代表你在第2列、第1行下了一步嘛。但有時候我其實搞不太懂為什麼大家都喜歡用英文指令,好吧先不管。

### 1. 傳送資料:

kotlin
// 傳送玩家的位置
private fun sendPosition(position: Pair<Int, Int>) {
Log.d(
TAG,
"sendPosition: Sending [${position.first}, ${position.second}] to $opponentEndpointId"
)
connectionsClient.sendPayload(
opponentEndpointId,
position.toPayLoad()
).addOnSuccessListener {
Log.d(TAG, "Payload sent successfully: $position")
}.addOnFailureListener { e ->
Log.e(TAG, "Payload sending failed: ${e.localizedMessage} ")
}
}


fun Pair
.toPayLoad() = Payload.fromBytes("$first, $second".toByteArray(UTF_8))

唉,你知道嗎,每次寫到這裡都覺得自己像個機器人在搬磚,但沒辦法,程式本來就很瑣碎。拉回正題,就是上面那段會把你要傳出去的位置資訊包成 payload 丟給對手端。

### 2. 接收資料:

然後啊,你總不能光丟不接吧,所以需要一個叫 `payloadCallback` 的東西來處理收到的資料。嗯,有點像郵差送信,但又不是那麼溫馨。這 callback 通常是在廣播方(advertiser)調用 `acceptConnection()` 的時候一起塞進去,至於探索方(discoverer),他們也得設置同一份 callback,不過一般情況下當連線成功、`onConnectionResult` 那邊設定就差不多了。欸,我有點想問,真的有人第一次就看得懂官方文件嗎?好啦,我再拉回來。

kotlin
// 此 callback 給廣播者和探索者共用比較省事
private val payloadCallback: PayloadCallback = object : PayloadCallback() {
// 收到新資料觸發這裡
override fun onPayloadReceived(endpointId: String, payload: Payload) {
Log.d(TAG, "onPayloadReceived: ")
if (payload.type == Payload.Type.BYTES) {
val position = payload.toPosition()
Log.d(TAG, "Received [${position.first}, ${position.second}] from $endpointId")
play(opponentPlayer, position)
} else if (payload.type == Payload.Type.FILE) {
// 如果哪天真有人傳檔案類型,就在這邊處理吧,但範例沒特別寫。
Log.d(TAG, "File payload received (not handled in this example).")
}
}

override fun onPayloadTransferUpdate(endPoint: String, update: PayloadTransferUpdate) {
Log.d(TAG, "onPayloadTransferUpdate: ")
// 主要就是即時告訴你現在檔案或資料到底丟到哪了。
// 尤其大型檔案才會需要顯示進度,不然平常小東西一下就結束。
when (update.status) {
PayloadTransferUpdate.Status.SUCCESS -> Log.d(TAG, "Payload transfer SUCCESS for $endPoint")
PayloadTransferUpdate.Status.IN_PROGRESS -> {
Log.d(TAG,"Payload transfer IN_PROGRESS for $endPoint:${update.bytesTransferred}/${update.totalBytes}")
}
PayloadTransferUpdate.Status.FAILURE ->Log.e(TAG,"Payload transfer FAILURE for $endPoint")
PayloadTransferUpdate.Status.CANCELED->Log.w(TAG,"Payload transfer CANCELED for $endPoint")
}
}
}

話說,每次看到「雙方共用同一個 callback」我腦袋都會自動浮現「不要重複造輪子」四個字。不過確實省事啦,只要 advertiser 在 acceptConnection 時已經把 payloadCallback 塞進去了,那 discoverer 那頭通常也不用再特別設定一次。同樣一支 callback 就能雙向處理所有收發流程,很方便但也容易讓人搞混。

另外補充一句,其實 onPayloadTranfserUpdate 那塊雖然平常可能沒啥感覺,可如果是遇到大容量檔案類型,如 `Payload.Type.File` 或甚至 `STREAM` 的狀況,就超級重要了,可以即時看到進度條一直跑——但老實說我很少碰到真的會卡住等它慢慢傳完,大部分都是秒過,好啦抱怨完畢。不知為何寫著寫著好像離題了,又扯遠了…反正最後記得別遺漏掉任何步驟,一整套走下來才安心。(話說,如果能自動幫忙 debug 就好了。)

關掉搜尋跟廣播,有些動作總該結束但又留尾巴

## 🎯 停止廣播與發現
這件事我之前真的一直忘掉。嗯,當你發現或廣播已經用不到了,最好還是趕快把它們停掉比較妥當——畢竟省電嘛,不然手機電池很快就見底了。然後,系統資源也會悄悄流失。唉,每次都覺得沒差,其實差很多欸。

connectionsClient.stopAdvertising()
connectionsClient.stopDiscovery()
通常啦,只要建立好連線而且確定不會再有新玩家接入,你就可以考慮收掉廣播;至於「發現」功能,只要一找到並順利接上某個廣播端,也該讓那段探索旅程劃下句點。嗯,有時候我也會猶豫太早關掉會不會錯過人,但理智告訴我…還是省點心力吧。

## 🎯 與端點斷線
說真的,每次遊戲結束或者有人按離開,有沒有乖乖斷線?大多數人其實…懶得管啦。不過明確執行斷線操作才對,不然背後一堆殘留連線怪礙眼的。

private fun disconnectFromEndpoint() {
connectionsClient.disconnectFromEndpoint(opponentEndpointId)
Log.d(TAG, "Disconnected from $opponentEndpointId.")
// ConnectionLifecycleCallback 中的 onDisconnected 回呼將被觸發。
// 例如:可在「離開遊戲」按鈕被點擊或遊戲結束時呼叫此方法。
}

有時想著「應該自動就好了啊」,但系統哪有那麼貼心?結果常常忘記寫這段 code,久了自己都搞不清楚到底誰還在房間裡鬼混,好煩喔。

## 🎯 權限與定位處理
Nearby 的世界離不開權限這兩字(講到權限頭好痛…),尤其 Android 一版比一版囉唆。有位置授權、藍牙什麼什麼 scan、advertise 的,一堆條件卡住你,就只是想連一下裝置而已啊…

val REQUIRED_PERMISSIONS =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.BLUETOOTH_ADVERTISE,
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.ACCESS_FINE_LOCATION
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION)
} else {
arrayOf(android.Manifest.permission.ACCESS_COARSE_LOCATION)
}
if (!hasPermissions(this, REQUIRED_PERMISSIONS)) {
requestMultiplePermissions.launch(
REQUIRED_PERMISSIONS
)
}
private fun hasPermissions(context: Context, permissions: Array
): Boolean {
return permissions.isEmpty() || permissions.all {
ContextCompat.checkSelfPermission(
context,
it
) == PackageManager.PERMISSION_GRANTED
}
}
private val requestMultiplePermissions = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions.entries.any { !it.value }) {
Toast.makeText(this, "Required permissions needed", Toast.LENGTH_LONG).show()
finish()
} else {
recreate()
}
}

每次彈出授權視窗都怕用戶直接拒絕。「又不是非給不可!」他們總這樣抱怨。其實,我自己遇到會先查為什麼要給,放心啦,不是真的想跟蹤你……突然扯遠了,再回來說重點。

<pre><code class="language-yaml">## 💻  重點整理:
- **優先清楚說明授權需求**:最好在跳出請求前,大方解釋原因。不然人家一臉問號亂拒,到最後只能哭著 Debug。
- **使用獨特 Service ID**:Service ID 啦,別偷懶複製別人的,要屬於自己的 app,例如 com.yourapp. 免得衝突搞半天找不到問題根源。有一次我就是因為撞名 debug 超久,好狼狽。


嗯,其它雜碎的小細節偶爾還是需要反覆檢查,每次寫完總忍不住問:「是不是又漏東漏西?」人生苦短,但 bug 永存,大概就是這種調調吧。

關掉搜尋跟廣播,有些動作總該結束但又留尾巴

權限彈窗、版本差異和一堆容易卡關的小坑洞

嗯,說到這個 serviceid 的東西,欸,我一開始還真的搞不懂。  
- **使用者友善的端點名稱:** 在你廣播的時候啊,最好是給出一個簡單明瞭又容易認得的名稱,不然用戶會看得一頭霧水。怎麼說呢,有時候我自己找裝置都會疑惑半天,「這到底是不是我要連的那個?」唉,總之清楚點比較好。
- **明確的連線流程:** 你讓使用者知道現在到底在幹嘛其實蠻重要,不然他們常常就以為手機壞掉了。比如說,目前是「搜尋中」、還是「連線中」或「已經斷線」?最好全部標示清楚。而且一定要有個能隨時取消連接的方法,不然很煩。這種細節常被忽略吧,但沒有它真的容易爆炸(心情上)。
- **完成後停止操作:** 絕對不要忘記,如果不用了,就得馬上呼叫 stopAdvertising()、stopDiscovery() 跟 disconnectFromEndpoint()。我老是忘,所以手機電量咻咻地掉,也不知道發生啥事(嘆)。可以考慮把這些清理動作寫在 onDestroy() 或某些合適的生命週期方法裡面,要不然等著哭吧。
- **處理失敗情境:** 網路狀態一直都不是很靠譜,大概也沒救了。所以如果遇到失敗,建議直接加重試邏輯,不然至少提醒一下用戶:「啊,失敗了喔。」否則大家只會傻等下去而已。有點像等公車結果司機根本繞路那種感覺,好啦離題了,拉回來——重試或提示都要有就是了。
- **資料負載大小考量:** 有些場景需要頻繁傳送小訊息,比如遊戲步驟啥的,那 Payload.fromBytes() 其實效率高很多。但如果檔案大到嚇死人,可以考慮 Payload.fromFile() 或 Payload.fromStream() 吧。我之前試過硬塞圖片進 fromBytes 結果 app 卡死…所以嗯,小心用法囉。

經驗談與意外收穫:測試細節、錯誤處理還有省電心法

唉,這些有效負載的大小限制真的要小心一點——老實說,我之前還真的沒特別注意過,直到某次突然出現奇怪錯誤才發現原來位元組數超標。欸,不過好像也有官方文件可以查,就是…你知道嘛,有時候懶得翻。總之,反正建議就是真的保持你的資料量精簡一點啦,不然到時候 debug 起來很煩人。

如果你想讓連線一直活著、尤其是應用被放到背景的時候(雖然講真的,大部分遊戲可能不會這樣搞,但萬一有特別需求咧),記得得啟動前景服務。不然系統那個神祕的機制隨時就把你連線斷了,你根本不知道為什麼。嗯,我自己上次忘了開前景服務結果測試玩到一半直接掉線,氣死。

啊對,其實不要太相信模擬器。我以前也偷懶都用模擬器在 Nearby Connections 測試,結果踩了一堆雷。還是乖乖去找幾台不同型號的 Android 實體裝置,全都跑一下,比較保險啦。不過,有時候找不到那麼多手機,只能借朋友的…唉。

說到命名這件事喔,就是 Endpoint 的名字啊,它有限制字節數,所以你如果硬要塞長長一串名字,那它直接給你砍掉剩下前面幾個字。不如乾脆精簡點,「user1」什麼的也行吧。嗯,但每次取名我還是糾結半天,到底叫啥比較帥?

策略選擇也是門學問喔。有 P2P_STAR 跟 P2P_CLUSTER 這種,如果你的應用只有三五台裝置一起玩,那其實 P2P_STAR 就夠用了;但是場景再複雜一點,好像又得考慮換策略……算了,也許大部分人根本不會遇到啦。我是不是想太多?先挑適合你的拓撲再說吧。

另外錯誤處理不能省略啊,每次嘗試建立連線失敗,都該看一下 ConnectionsStatusCodes 裡面的細節資訊。有些錯誤碼寫得很玄,可是沒有它還真不知道哪裡出問題。唉,我曾經光靠猜亂改,一整天都修不好。

後續就是那些步驟囉。如果你打算在 Android 上做井字棋或者任何那種需要多人或多裝置同步的小東西,上面講那些細節其實就是基本功吧。記住最好常常回頭查官方 Android Developers 的文件,他們更新超快,一轉眼新的 API 又冒出來了,你以為全懂其實早就落伍。

至於更詳細資料,就——Google for Developers 上面直接搜「Nearby Connections」專頁就好啦,裡面範例和進階功能都有,反正也不用全部背起來,有空再看嘛。(話說我每次都是臨時抱佛腳。)

最後補一句,如果覺得內容有幫助⋯呃,也不是強迫,但可以順手追蹤一下給個讚吧?有鼓勵我才想繼續寫東西,畢竟偶爾自言自語久了會累,好啦!

Related to this topic:

Comments