📱

AndroidでWebSocket通信

2022/03/09に公開

WebSocketとは?

初めにピア間で通信を確立し、その後はそのコネクション上で低コストな双方向通信が出来るプロトコルです。

HTTPの拡張のような形のプロトコルで、利用するポート番号も同じなので経路上のネットワーク機器によるトラブルに見舞われることも少ないという特徴があります。

参考

AndroidでWebSocketがしたい

仕事でAndroidアプリでWebSocketを扱うことになって色々調べていたのですが、どうやらHTTPクライアントとして既に利用していたOkHttp3がWebSocketもサポートしていることが分かったので、今回はこれを利用します。

OkHttpの基本的な使い方は以下の記事などが分かり易いと思います。
OkHttp(基本的なGET・POST) - Qiita

  • OkHttpClientのインスタンスを作成
  • Requestオブジェクトを作成

まではHTTPクライアントと同じです。

Androidアプリ-サーバー間で最低限のWebSocket通信ができるところまでを実装します。

Android側の実装

build.gradleに依存関係を追加

OkHttp3を追加します。
記事執筆時点で最新のバージョンを追加してます。

app/build.gradle
dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.4.1'
    implementation 'com.google.android.material:material:1.5.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    
    // 追加
    implementation 'com.squareup.okhttp3:okhttp:4.9.1'
}

WebSocket作成

デモ用なので、MainActivityに全てべた書きします。

OkHttpClientnewWebSocket()でWebSocketを作成します。

fun newWebSocket(request: Request, listener: WebSocketListener): WebSocket

Requestで接続先のURLを指定します。
第2引数のlistenerには、WebSocketListenerという抽象クラスを実装したクラスのインスタンスを渡します。

WebSocketListenerにはonOpen(), onMessage()など特定のイベント発生時に呼び出されるメソッドが用意されているので、それらをオーバーライドして実装します。

今回はその実装を含めたWebSocketクライアント用のクラスを作成し、listenerとしてthisを渡しています。
なお、newWebSocket()は非同期でWebSocket接続を開始し、即WebSocketをreturnします。
接続が成功/失敗したときは、onOpen()onFailureで通知を受け取ることが出来ます。

最終的な実装は以下です。

MainActivity.kt
package com.example.websocket_android

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val webSocketClient = WebSocketClient()
        webSocketClient.send("Hello from Android")
    }
}

class WebSocketClient() : WebSocketListener() {
    private val ws: WebSocket

    init {
        val client = OkHttpClient()

        // 接続先のエンドポイント
        // localhostとか127.0.0.1ではないことに注意
        val request = Request.Builder()
            .url("ws://10.0.2.2:8080")
            .build()

        ws = client.newWebSocket(request, this)
    }

    fun send(message: String) {
        ws.send(message)
    }

    override fun onOpen(webSocket: WebSocket, response: Response) {
        println("WebSocket opened successfully")
    }

    override fun onMessage(webSocket: WebSocket, text: String) {
        println("Received text message: $text")
    }

    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
        println("Received binary message: ${bytes.hex()}")
    }

    override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
        webSocket.close(1000, null)
        println("Connection closed: $code $reason")
    }

    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
        println("Connection failed: ${t.localizedMessage}")
    }
}

接続先のURLはlocalhostとか127.0.0.1にしたくなりますが、エミュレータで実行した場合それはローカルのマシンではなくエミュレータ自身を指すので接続できません。
Androidのエミュレータでは10.0.2.2がローカルマシンにブリッジされるようになっているので、それを指定します。
エミュレータのネットワークについては、公式ドキュメントが詳しいです。

ネットワークの設定

ネットワーク通信を許可する設定と、暗号化なしの通信(wssではなくws)を許可する設定をマニフェストファイルに書く必要があります。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.websocket_android">
<!--    インターネット接続を許可-->
    <uses-permission android:name="android.permission.INTERNET" />
    <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.Websocketandroid"
        android:usesCleartextTraffic="true">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

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

</manifest>

サーバー側

Node.jsのWebSocket実装ライブラリであるwsを使います。
最低限の実装です。

npm install ws
server.js
const WebSocketServer = require('ws').Server

const wss = new WebSocketServer({
  port: 8080,
})

// connectionイベントは、ハンドシェイクが完了し、コネクションが開いたときに発火される
// イベントリスナーは、そのそれぞれのコネクションを表すwsを引数にして呼ばれる
wss.on('connection', ws => {
  
  // 適当にメッセージを送信
  ws.send('Hello from server')
  
  // メッセージを受信したら、それをログ出力
  ws.on('message', data => {
    console.log(`Received message: ${data}`)
  })
})

結果

サーバーを起動した状態でAndroidアプリを動かすと、以下のログが確認できます。

アプリ側

I/System.out: WebSocket opened successfully
I/System.out: Received text message: Hello from server

サーバー側

Received message: Hello from Android

接続状態を管理する

さて、無事接続が確認できましたが、WebSocketはHTTPとは違いステートフルなプロトコルです。
つまり、通信上のそれぞれのやりとりは独立しておらず、コネクションの状態はこちらで管理する必要があります。

PingとPong

WebSocketには、標準でPingとPongという疎通確認用のコントロールフレームが定義されています。
Ping Pongは慣習として使われるメッセージとかではなく、RFCで定義された仕様です。
なので、どのWebSocketライブラリにも実装されているはずです。

RFCにある通り、Pingを受け取った側は、必ずPongを返さなければいけません。

Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in response.

なのでどのライブラリでもPongは勝手に送り返すようになっているはずですが、Pingの送り方に関してはライブラリごとに実装が異なります。

OkHttp3にはPingを自分で送るためのAPIは用意されておらず、初めにOkHttpClientを作成するときにPingを送るインターバルを設定し、後はライブラリ側で勝手に送ってくれるようです。
(ちなみにwsには自分でPingを送るための関数が用意されています。)

そしてサーバーからPingが返ってこなかった場合、onFailure()が呼ばれるようになっています。

AndroidからPingを送ってみる

OkHttpClient作成時にpingIntervalを設定するよう、先ほどのコードを修正します。

MainActivity.kt
 init {
        // Pingを送るインターバルを設定したでclientを作成
        val client = OkHttpClient.Builder().pingInterval(5, TimeUnit.SECONDS).build()

        // 接続先のエンドポイント
        // localhostとか127.0.0.1ではないことに注意
        val request = Request.Builder()
            .url("ws://10.0.2.2:8080")
            .build()

        ws = client.newWebSocket(request, this)
    }

サーバー側で確認

サーバーは、Pingが来たときにその旨を表示するようにします。

wss.on('connection', ws => {
  // 適当にメッセージを送信
  ws.send('Hello from server')

  // メッセージを受信したら、それをログ出力
  ws.on('message', data => {
    console.log(`Received message: ${data}`)
  })

  // Pingを受け取ったときに発火するイベント
  ws.on('ping', () => {
    console.log('Received Ping. Sending Pong back...')

    // pongは、こちらで何もしなくても勝手に送り返される
  })
})

サーバーを再起動してAndroidアプリを実行すると、サーバー側で以下のように5秒ごとにログが表示され、Pingが送られてきていることが分かります。

Received Ping. Sending Pong back...
Received Ping. Sending Pong back...
Received Ping. Sending Pong back...

余談: pingの挙動のカスタマイズについて

OkHttp3でPingの挙動をいじれないことはGitHubのIssueでも取り上げられており、カスタマイズしたいという要望もあるみたいですが、開発チームはそのようなAPIを提供する予定はない」と解答しています。
理由としては、同じくpingのAPIを提供しないブラウザのAPIに合わせているからだそうです。

さいごに

WebSocketは通信してみるだけなら簡単ですが、接続状態の監視や再接続の処理などを考える始めると難しく感じます。このあたりの知見が欲しい…。

AndroidとKotlinについては経験が浅いので、間違い等ありましたらご指摘ください。
この記事が何かの参考になれば幸いです。

参考

GitHubで編集を提案

Discussion