Mohikanz Advent Calendar 2018 #1 12日目の記事です。
前日は @MakTak さんの Node.jsでiBeaconの距離推定する でした。
##TL;DR##
Wi-Fi peer-to-peerもあるけど、Nearbyも良いよ!
端末同士でデータを散発的に送り合うようなものを作りたくて、調べたあれこれになります。
##Wi-Fi peer-to-peer##
Androidでpeer-to-peer(=P2P)、端末間通信をする場合、まず候補に上がるのが標準機能に含まれるWi-Fi peer-to-peerではないかと思います。
公式のリファレンスどおり実装していけば問題なく通信確立でき、簡単なデータの送受信もできたので何も問題なさそうだったのですが…
##ラグがひどい##
一度に送るデータはせいぜい十数バイトだったのですがラグがすごかったのです…
早い時は数十〜数百msecで届くのが、数秒以上かかるケースが散発してしまい(数回に1度くらいの頻度)わりとリアルタイム性が必要だった今回のアプリではちょっと採用は厳しい感じでした。。
両方或いは片方の端末をネットワークから外したり、同じネットワークにつないだ状態にしたりと試してみましたが結果は変わらずでした。
##Nearby##
途方に暮れていたのですが、困った時の日本Androidの会。
Nearbyというのがありまして、結論から言うとほとんどラグもなく双方向通信を行うことができました。
Nearby自体は近距離にあるデバイスの検知、通信を行うことで色々なサービスを作れるプラットフォームのようで、今回はその中のNearby Connections APIを利用していきます。
##実装編##
ここからはほとんど公式のパクリとおりですが実装していきます。
事前にGoogle Play Services SDKを開発環境に組み込んでおき、動かす端末にはGoogle Play開発者サービスの7.8以降がインストールされている必要があります。
まずはおなじみのパーミッション。
<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_COARSE_LOCATION" />
ご存知ACCESS_COARSE_LOCATION
は厄介なやつで、Android 6.0(APIレベル23)以降の端末では アプリの設定画面で許可しないとNearby自体が動きません。
その他にも配布するようなアプリの場合は細かいお作法があります。詳しくはこちら。
次にgradleの設定。今の最新版は11.8.0でした。
dependencies {
implementation 'com.google.android.gms:play-services-nearby:{latest_version}'
}
####Strategy####
ここからはソースコードを書いていきます。
まずはStrategyとやらを選びます。
P2P_STAR(1対N型)とP2P_CLUSTER(M対N型)が選べますが、今回は接続してる端末それぞれからデータを投げ合いたかったのでP2P_CLUSTERを選択しました。
####Advertise and discover####
私ここよーとお知らせするadvertiseと、探しにいくdiscoverを開始します。両方同時に行うこともできます(後述のちょっとしたコツは要りました)。
advertiseはこんな感じ。
Nearby.getConnectionsClient(context).startAdvertising(
"ユーザ名とか"
context.packageName,
connectionLifecycleCallback,
AdvertisingOptions.Builder().setStrategy(Strategy.P2P_CLUSTER).build()
)
.addOnSuccessListener { /* advertise開始成功 */ }
.addOnFailureListener { /* advertise開始失敗 */ }
Strategy.P2P_CLUSTERをセットしています。connectionLifecycleCallbackについては後ほど。
discoverはこちら。
Nearby.getConnectionsClient(context).startDiscovery(
context.packageName,
endpointDiscoveryCallback,
DiscoveryOptions.Builder().setStrategy(Strategy.P2P_CLUSTER).build()
)
.addOnSuccessListener { /* discover開始成功 */ }
.addOnFailureListener { /* discover開始失敗 */ }
endpointDiscoveryCallbackについても後ほど。
####Manage connections####
まずはendpointDiscoveryCallbackの実装をば。
段々恥ずかしい感じになってきておりますが…
private val endpointDiscoveryCallback = object : EndpointDiscoveryCallback() {
override fun onEndpointFound(endpointId: String, discoveredEndpointInfo: DiscoveredEndpointInfo) {
val client = Nearby.getConnectionsClient(context)
// ここでstopしないと接続できず
client.stopAdvertising()
client.stopDiscovery()
GlobalScope.launch {
// delay噛ませないとrequestConnectionに失敗する
delay(1000)
client
.requestConnection(
"ユーザ名とか",
endpointId,
connectionLifecycleCallback
)
.addOnFailureListener {
// リトライさせたり
}
}
}
override fun onEndpointLost(endpointId: String) {
// A previously discovered endpoint has gone away.
}
}
コメントにあるとおり、自分の持ってる端末だけかもですが(Nexus 5と5Xで動かしてます)細かい調整をしてます。もっと良い方法があれば教えてください…!
前述のコツになるのですが、requestConnection()する前にstopAdvertising()とstopDiscovery()してます。
advertiseとdiscoverを同時にかけてるとここで止めないとうまくつながらず…どちらかの端末をadvertise、反対側をdiscoverで固定してればstopしなくてOKでした。
####Accept or reject a connection####
connectionLifecycleCallbackの実装に進みます。こやつはadvertiseした時と、endpointDiscoveryCallbackの中で接続要求にいく時にも渡しています。
private val connectionLifecycleCallback = object : ConnectionLifecycleCallback() {
override fun onConnectionInitiated(endpointId: String, connectionInfo: ConnectionInfo) {
// requestConnection()した方かされた方かはここで取れる
val isDiscoverer = !connectionInfo.isIncomingConnection
Nearby.getConnectionsClient(context).acceptConnection(endpointId, payloadCallback)
}
override fun onConnectionResult(endpointId: String, result: ConnectionResolution) {
when (result.status.statusCode) {
ConnectionsStatusCodes.STATUS_OK -> {
// データのやり取りにendpointIdが必要なので保持る
heldEndpointId = endpointId
}
ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED -> {
// The connection was rejected by one or both sides.
}
ConnectionsStatusCodes.STATUS_ERROR -> {
// The connection broke before it was able to be accepted.
}
else -> {
// Unknown status code
}
}
}
override fun onDisconnected(endpointId: String) {
// We've been disconnected from this endpoint. No more data can be
// sent or received.
}
}
セキュアなデータのやり取りをしたい場合はonConnectionInitiated()でゴニョゴニョしますが今回は割愛します。
payloadCallbackが出てきますが、ここまで来てようやくデータの送受信の準備ができました。
####Exchange data####
onConnectionResult()でSTATUS_OKが受け取れたらデータの送受信ができるようになります。
例えば文字列を送る場合はこんな感じで送ることができます。
Nearby
.getConnectionsClient(context)
.sendPayload(heldEndpointId, Payload.fromBytes(str.toByteArray()))
送れるペイロードの種類は現状ではBYTES/FILE/STREAMの3種類です。
今回は送るデータが少ないので手軽なBYTESを使いました。ちなみにBYTESでは32kbまでのデータを送れるようです。
受信側のコードはこんな感じになります。
private val payloadCallback = object : PayloadCallback() {
override fun onPayloadReceived(endpointId: String, payload: Payload) {
val b = payload.asBytes() ?: return
val data = String(b)
}
override fun onPayloadTransferUpdate(endpointId: String, update: PayloadTransferUpdate) {
when (update.status) {
IN_PROGRESS -> {}
SUCCESS -> {}
FAILURE -> {}
CANCELED -> {}
}
}
}
onPayloadTransferUpdate()でデータの受信状況をモニターできますが、
Unlike FILE and STREAM payloads, BYTES payloads are sent as a single chunk, so there is no need to wait for the SUCCESS update (although it will still be delivered, immediately after the call to onPayloadReceived())
と公式にあるとおりBYTESペイロードの場合は一塊で送られてくるので、特に気にせずonPayloadReceived()でデータを取り出して完了です。
##まとめ##
Nearbyはclosed sourceなようで実装までは追えなかったのですが、BLEやWi-Fi、音声を組み合わせて使っているようです。
なのでシチュエーションによってはハマらないケースもあると思いますが、P2Pで何か作る時にはぜひ使ってみてほしいプラットフォームです。
明日(というか今日)13日は@netakaさんの電子ペーパー名札をつくってみたです。
##参考##
androidとiosでMultipeer接続
Nearby Connections APIを使ってみる
Release Notes
google sample