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

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

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

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

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

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

# 構成
| 名前        | バージョン          |
| ---------- | ---------------- |
| macOS      | Mojave 10.14.3   |
| Xcode      | 10.1             |
| Swift      | 4.2.1            |
| iOS        | 12.1             |
| シミュレーター | iPhone 6 / 7 / 8 |

* hosts(macOS)

    ```:hosts
    127.0.0.1       devnokiyo.example.com
    ```

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

# ActionCableClientを導入する
ライブラリを利用して開発します。少し変則的な導入をしているので説明します。
## まず通常どおりCocoaPodsで導入する
公式の説明どおり一般的な導入をまず行います。

```ruby:Podfile
pod "ActionCableClient"
```
```bash
$ pod install
```
## ビルドエラーを解消する
残念ながら現行バージョン(0.2.3)ではSwift4.2に対応しきれていないようで、筆者の環境ではビルドエラーが発生してしまいます。また、開発中と思われる最新のコードはビルドは通りますが動作が不安定です。(バージョンのタグ付けがないので不安定なのは当たり前ですね。)
[同件と思われるissue](https://github.com/danielrhodes/Swift-ActionCableClient/issues/44)に便乗しつつ、今回はビルドエラーの箇所を修正することにしました。

* Pods/ActionCableClient/Source/Classes/RetryHandler.swift
<img width="519" alt="01.png" src="https://qiita-image-store.s3.amazonaws.com/0/240974/fdc11ec2-7faf-ea40-e114-39760a98adb5.png">
* noshilan/Pods/Starscream/Source/WebSocket.swift
<img width="514" alt="02.png" src="https://qiita-image-store.s3.amazonaws.com/0/240974/b922724c-8e2c-deaa-7afc-b8df93ac46a4.png">

今回はこの修正がありますのでCocoaPodsでインストールしたライブラリもGitの管理対象に含めました。

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

```swift:ActionCableApp/ViewControllers/BarkVc.swift
class BarkVc: UIViewController {
    private let CableUrl = "ws://devnokiyo.example.com/cable" // ActionCableのコネクションのエンドポイント
    private let ChannelIdentifier = "RoomChannel"             // チャンネル名
    
    private var client: ActionCableClient!                    // ActionCableのコネクションに関連するインスタンス
    private var channel: Channel!                             // ActionCableのチャンネルを関連するインスタンス
    
    // 【補足】「ユーザーをRDBから取得して、その分だけ動的に表示して・・・」と
    // 要件が大きくなるとサンプルの目的が反れるので、3ユーザーのみに限定して作成しました。
    @IBOutlet weak var chiyoUsv: UserStatusView!  // 上段ユーザーのステータス
    @IBOutlet weak var eruUsv: UserStatusView!    // 中段ユーザーのステータス
    @IBOutlet weak var otomeUsv: UserStatusView!  // 下段ユーザーのステータス
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // ActionCable関連のインスタンスを初期化・接続する。
        initClient()
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // 画面を閉じるときはコネクションを切断する。
        // サンプルではアプリの中断は考慮しない。
        client.disconnect()
    }

    @IBAction func tapBawBawButton(_ sender: Any) {
        // 「ワンワン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"bawbaw"
        bark(bark: "bawbaw")
    }
    
    @IBAction func tapWaooonButton(_ sender: Any) {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":"waooon"
        bark(bark: "waooon")
    }
    
    @IBAction func tapMumblingButton(_ sender: Any) {
        // 「独り言」ボタン押下時はRoomChannelのmumblingアクションを呼出す。
        // 送信情報) 無し。アクションを呼出すのみ。
        channel?.action("mumbling")
    }

    private func initClient() {
        // コネクションのエンドポイントを指定する。
        // 送信情報) 呼出し元ViewControllerから取得したaccount
        // 【補足】ユーザーの割出しと認証が必要ならOpenID Connectのアクセストークンなどになると思います。
        client = ActionCableClient(url: URL(string: "\(CableUrl)/?account=\(account!)")!)
        client.onConnected = {
            // 【補足】このサンプルではチャンネルをサブスクライブした直後にRoomChannelのgreetingアクションを呼出します。
            // コネクションの接続完了を待たずにチャンネルを作成してしまうと非同期の問題でRoomChannelのgreetingアクションが
            // 呼ばれないことがありました。そのため、コネクションの接続が完了してからチャンネルをサブスクライブします。
            self.initChannel()
        }

        // 【補足】onConnectedの他にも以下のコールバックが用意されています。
        // client.willConnect = {}
        // client.onDisconnected = { (error: ConnectionError?) in }
        // client.willReconnect {}
        
        // 【補足】以下のプロパティで再接続のポリシーを設定出来るようです。(厳密に確認していません。)
        // client.reconnectionStrategy

        // コネクションに接続する。
        client.connect()
    }
    
    private func initChannel() {
        // チャンネルを作成する。サブスクライブは手動で行う。
        // 送信情報) "room":ルーム名(ID)
        self.channel = self.client.create(ChannelIdentifier, identifier: ["room": room], autoSubscribe: false)

        self.channel.onSubscribed = {
            // サブスクライブしたら、ルームに入ったことになる。
            // 同じルームのアクティブユーザーに通知するのでRoomChannelのgreetingアクションを呼出す。
            self.channel?.action("greeting")
        }
        
        self.channel.onReceive = {(data: Any?, error: Error?) in
            // 自他問わずアクティブユーザーより送信された情報をこのコールバックで受信する。
            if let response = RoomChannelResponse(data: data) {
                // 誰に関する情報か判定する。自身も含まれる。
                // findUserStatusViewメソッドは以下のいずれかのクラス変数を返却する。
                // chiyoUsv
                // eruUsv
                // otomeUsv
                // 受信情報) "account":ユーザーのアカウント
                let userStatusView = self.findUserStatusView(account: response.account)
                switch response.type {
                case .roomIn:
                    // 受信情報) "type":"in"
                    // 受信情報) "roommate":"[アクティブユーザーのアカウント...]"
                    // updateUserStatusメソッドはアクティブユーザーを以下のようにする。
                    // 表示内容:(^○^)
                    // オンラインの色(緑)
                    self.updateUserStatus(accounts: response.roommate, type: response.type)

                    // ルームに入ったユーザーは挨拶する。
                    // 受信情報) "type":"in"            表示内容: (^○^) (言語:日本語)
                    userStatusView.bark.text = NSLocalizedString(response.type.rawValue, comment: self.defaultComment)
                    break
                case .roomOut:
                    // ルームから出たユーザーは挨拶する。
                    // 受信情報) "type":"out"           表示内容: ( ˘ω˘ ) (言語:日本語)
                    userStatusView.bark.text = NSLocalizedString(response.type.rawValue, comment: self.defaultComment)
                    // オフラインの色(赤)に変更する。 
                    userStatusView.online.backgroundColor = UIColor.red
                    break
                case .mumbling:
                    // 受信情報)
                    //  "type":"mumbling"
                    //  "content":"(ﾟДﾟ;)"             表示内容: (ﾟДﾟ;) (言語:不問) バックエンドの固定値なので言語設定に依存しない
                    // 【補足】「独り言」は自身が送信した情報をActionCableを経由して自身のみが受信します。
                    if let content = response.content {
                        userStatusView.bark.text = content
                    }
                    break
                case .bark:
                    // 受信情報)
                    //  "type":"bark"
                    //  "content":"bawbaw" / "wooon"  表示内容: ワンワン / ワオーーン (言語:日本語)
                    if let content = response.content {
                        userStatusView.bark.text = NSLocalizedString(content, comment: self.defaultComment)
                    }
                    break
                }
            }
        }
        
        // チャンネルをサブスクライブする。
        self.channel.subscribe()
    }

    private func bark(bark: String) {
        // 「ワオーーン」ボタン押下時はRoomChannelのbarkアクションを呼出す。
        // 送信情報) "content":bark
        channel?.action("bark", with: ["content": bark])            
    }
```
# 筆者がハマったところ
* ActionCableClientのビルドエラー
前述のとおり修正しました。

* チャンネルの初期化
コネクションに接続した直後にチャンネルのアクションへ送信するとき、送信できないときがありました。チャンネルの初期化はコネクションの接続が完了してから行います。非同期処理のタイミングによる問題だと思います。

    ```swift:失敗例
    client.connect()
    channel = self.client.create(ChannelIdentifier, identifier: ["room": room], autoSubscribe: false)
    channel.subscribe()
    ```
    ```swift:成功例
    client.onConnected = {
            channel = self.client.create(ChannelIdentifier, identifier: ["room": room], autoSubscribe: false)
            channel.subscribe()
    }
    client.connect()
    ```

# 参考
[Rails 5 Action CableチャットアプリのiOSクライアント側を作る](https://qiita.com/ariiyu/items/55cc0cff65728c71369f)

# 終わりに
チャットアプリのサンプルが定番なので、少し違うアプローチでサンプルを作っていたはずなのですが、結局仕組みは似たり寄ったりになってきました。筆者はiOS、Androidの順で実装しているのでバックエンドとの主な仕様調整はiOS版で行なっています。その意味ではiOS版のほうが壁に当たることが多いです。細かい作込みはしていませんが、バックエンドとアプリの双方を実装して、やりたいことの表現と仕組みを概ね理解することができました。