Nginx,Redis,MySQLを使ってほんの少し実践的なRails ActionCableと、iOS/Androidのサンプルアプリを作って全体像を学ぶ〜Android編〜

# 前置き
「Rails ActionCableで双方向通信してみたい」「モバイルアプリでリアルタイム通信アプリ作りたい」と思いサンプルアプリを作ってみました。個々の詳細については既に解説してくださっている記事はありますので、大まかに環境構築やソースコードを記事にします。以下の3部構成になっています。

* [バックエンド](https://qiita.com/items/4945ed7cd17ba43c3e05)
* [iOS](https://qiita.com/items/4abea6237c00c384cdfc)
* Android(本記事)

# お遊びサンプルの紹介
以下のアニメーションGIF（ちょっと荒いですね:no_mouth:）をご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
![android_demo.gif](https://qiita-image-store.s3.amazonaws.com/0/240974/088a7073-616f-cb1f-53fb-b0a6dd30aab8.gif)

このアプリでは大きく2つのActionCableの使い方があります。

* 同じルーム内の全ユーザーにブロードキャスト
    * ルームに入る  
現在ルーム内にいるユーザー(以下、アクティブユーザー)にルームに入ったことを通知し、アクティブユーザーを取得します。
そして、各ユーザーのアクティブ状況を表示します。
    * 「ワンワン」ボタンと「ワオーーン」ボタン  
文字入力で任意の文字が送れないだけで、チャットでいうところの「メッセージ」とほぼ同義です。ボタンに対応したメッセージを送信します。  
※アニメーションGIFでは「わんわん」「ワオォーン」になっています。途中から文字を修正しました:bow:
    * ルームから出る  
「←」をタップしたり、アプリを閉じたりするとルームから出たことにします。ルームに入るときと同様にアクティブユーザーを取得して、各ユーザーのアクティブ状況を表示します。
* 自分にブロードキャスト
    * 「独り言」  
「独り言」ということで自分のみメッセージを受信します。

# 構成
| 名前           | バージョン          |
| ------------- | ---------------- |
| macOS         | Mojave 10.14.3   |
| AndroidStudio | 3.3.2            |
| Kotlin        | 1.3.21           |
| AVD(API)      | Pixel(28), Pixel２(27 / 28) |

* hosts(AVD)
よろしければ筆者の[記事](https://qiita.com/items/5388169f798bd2678d64)をご覧ください。筆者はこのサンプルアプリを作る過程でAVDのhostsについて知り記事にしました。

    ```:hosts
    10.0.2.2       devnokiyo.example.com
    ```

ソースコードは[GitHub](https://github.com/devnokiyo/ActionCable-iOS-Android)に公開しています。よろしければご覧ください。

# actioncable-client-javaを導入する
ライブラリを利用して開発しますのでgradleに追記してAndroidStudioで同期します。

* build.gradle 
![01.png](https://qiita-image-store.s3.amazonaws.com/0/240974/a6deedda-d5d5-b466-f1e3-c006eb70603b.png)

* app/build.gradle
![02.png](https://qiita-image-store.s3.amazonaws.com/0/240974/40c2315b-2796-28af-c32c-7498e47259f1.png)

# ソースコードの説明
サンプルアプリはActionCableと本質的に関係ない部分も多いので、[GitHub](https://github.com/devnokiyo/ActionCable-iOS-Android)に公開しているソースコードの原形を保ちながら主要な部分を抽出しました。ソースコードの中にコメントで説明します。

```kotlin:app/src/main/java/com/devnokiyo/actioncableapp/activities/BarkActivity.kt

class BarkActivity : AppCompatActivity() {
    private val cableUrl = "ws://devnokiyo.example.com/cable"   // ActionCableのコネクションのエンドポイント
    private val channelIdentifier = "RoomChannel"               // チャンネル名

    private lateinit var client: Consumer                       // ActionCableのコネクションに関連するインスタンス
    private lateinit var channel: Subscription                  // ActionCableのチャンネルを関連するインスタンス
    private var handler = Handler()                             // メインスレッドで実行するハンドラー

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_bark)
        // 「ワンワン」ボタンなどのリスナーを設定する。
        initListener()
    }

    override fun onResume() {
        super.onResume()
        // ActionCable関連のインスタンスを初期化・接続する。
        initClientAndChannel()
    }

    override fun onPause() {
        super.onPause()
        // 画面を閉じるときはコネクションを切断する。
        // サンプルでは便宜上onPause()で実装しておく。
        client.disconnect()
    }

    private fun initListener() {
        bawBawButton.setOnClickListener {
        // 「ワンワン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"bawbaw"
            bark(bark = "bawbaw")
        }
        WaooonButton.setOnClickListener {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"waooon"
            bark(bark = "waooon")
        }
        mumblingButton.setOnClickListener {
        // 「独り言」ボタン押下時はRoomChannelのmumblingアクションを呼出す。
        // 送信情報) 無し。アクションを呼出すのみ。
            channel.perform("mumbling")
        }
    }

    private fun initClientAndChannel() {
        // コネクションのエンドポイントを指定する。
        // 送信情報) 呼出し元アクティビティから取得したaccount
        // 【補足】ユーザーの割出しと認証が必要ならOpenID Connectのアクセストークンなどになると思います。
        client = ActionCable.createConsumer(URI("$cableUrl/?account=$account"))

        // チャンネルを作成する。サブスクライブは手動で行う。
        // 送信情報) "room":ルーム名(ID)
        channel = client.subscriptions.create(Channel(channelIdentifier).apply { addParam("room", room) })

        channel.onConnected {
            // サブスクライブしたら、ルームに入ったことになる。
            // 同じルームのアクティブユーザーに通知するのでRoomChannelのgreetingアクションを呼出す。
            channel.perform("greeting")
        }

        channel.onReceived { data ->
            // UIスレッド(メインスレッド)で実行する。
            handler.post {
                // 自他問わずアクティブユーザーより送信された情報をこのコールバックで受信する。
                RoomChannelResponse.create(data)?.let { response ->

                    // 誰に関する情報か判定する。自身も含まれる。
                    // 受信情報) "account":ユーザーのアカウント
                    // findUserStatusViewメソッドは以下のいずれかのクラス変数を返却する。
                    // chiyoUsv
                    // eruUsv
                    // otomeUsv
                    val userStatusView = findUserStatusView(response.account)

                    when (response.type) {
                        SocketType.RoomIn -> {
                            // ルームに入ったらユーザーのステータスを更新する。
                            // 受信情報) "type":"in"
                            // 受信情報) "roommate":"[アクティブユーザーのアカウント...]"
                            // updateUserStatusメソッドはアクティブユーザーを以下のようにする。
                            // 表示内容:(^○^)
                            // オンラインの色(緑)
                            updateUserStatus(accounts = response.roommate, type = response.type)

                            // ルームに入ったユーザーは挨拶する。
                            // 受信情報) "type":"in"            表示内容: (^○^) (言語:日本語)
                            // getResourceStringメソッドはstrings.xml内の指定したname属性の内容を取得する。
                            userStatusView.bark.text = getResourceString(response.type.rawValue)
                        }
                        SocketType.RoomOut -> {
                            // ルームから出たユーザーは挨拶する。
                            // 受信情報) "type":"out"           表示内容: ( ˘ω˘ ) (言語:日本語)
                            userStatusView.bark.text = getResourceString(response.type.rawValue)

                            // オフラインの色(赤)に変更する。
                            userStatusView.online.setBackgroundColor(Color.RED)
                        }
                        SocketType.Mumbling -> {
                            // 受信情報)
                            //  "type":"mumbling"
                            //  "content":"(ﾟДﾟ;)"             表示内容: (ﾟДﾟ;) (言語:不問) バックエンドの固定値なので言語設定に依存しない
                            // 【補足】「独り言」は自身が送信した情報をActionCableを経由して自身のみが受信します。
                            response.content?.let { content ->
                                userStatusView.bark.text = content
                                userStatusView.rockBark()
                            }
                        }
                        SocketType.Bark -> {
                            // 受信情報)
                            //  "type":"bark"
                            //  "content":"bawbaw" / "wooon"  表示内容: ワンワン / ワオーーン (言語:日本語)
                            response.content?.let { content ->
                                userStatusView.bark.text = getResourceString(content)
                                userStatusView.rockBark()
                            }
                        }
                    }
                }
            }
        }

        // 【補足】他にも以下のコールバックが用意されています。
        // channel.onFailed { e: ActionCableException -> }
        // channel.onDisconnected {}
        // channel.onRejected {}
        
        // 【補足】以下のプロパティで再接続のポリシーを設定出来るようです。(厳密に確認していません。)
        // val options = Consumer.Options().apply {
        // reconnection = true
        // reconnectionMaxAttempts = 30
        // reconnectionDelay = 3
        // reconnectionDelayMax = 30
        // }
        // client = ActionCable.createConsumer(URI("$cableUrl/?account=$account"), options)

        // コネクションに接続してチャンネルをサブスクライブする。
        client.connect()
    }

    private fun bark(bark: String) {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":bark
        channel.perform("bark", JsonObject().apply { addProperty("content", bark) })
    }

    // res/values/strings.xml内の指定したname属性の内容を取得する。
    private fun getResourceString(name: String): String =
        getString(resources.getIdentifier(name, "string", packageName))
}
```

# 終わりに
チャットアプリのサンプルが定番なので、少し違うアプローチでサンプルを作っていたはずなのですが、結局仕組みは似たり寄ったりになってきました。iOS版ではハマったことがありましたが、iOS版がある程度仕上がってからAndroid版を実装したので仕様でハマるところはありませんでした。Xcode/Swiftを見ながらAndroidStudio/Kotlinへ書写した感じですね。iOS版同様に細かい作込みはしていませんが、バックエンドとアプリの双方を実装して、やりたいことの表現と仕組みを概ね理解することができました。