SkyWay ✖️ Firestore でオレ流 iOS チュートリアル

なんだか [面白そうなイベント](https://qiita.com/official-events/93564ad363199fa7999c) やってるので触ってみようかなぁSkyWay

## SkyWayとは
[公式HP](https://skyway.ntt.com/ja/about/?) にこう書いてますね。
> **SkyWayは、リアルタイムコミュニケーションを実現するマルチプラットフォームSDKです。**

まぁ、そういうことですね。似たようなサービスとして
- Agora
- Tencent Cloud
- MediaSoup

などなどいくつかありますね。
ちなみにiOS目線で言うと全部触りはしましたが、今のところ上記３つのうち一番使いやすいのはAgoraで一番使いにくいのはダントツでMediaSoupです。
どれもWebRTCの技術を内部的に使用しているのかな。




## 早速公式のチュートリアル見てみた
チュートリアルというか [クイックスタート](https://qiita.com/official-events/93564ad363199fa7999c) ですね。
クイックスタートでやった内容はここに書きません。
興味がある人は↑のリンク踏んで書かれている通りに進めてください。完璧な手順書になっているので、Macがあれば猿でもできます。
そして手順を踏んで出来上がったものは**自分の顔が２つ映るだけの謎のアプリ**でした。
誰ともコミュニケーションを実現できていません。

ということで...

## 最低限の、通話アプリっぽいものを作りました
今回実装したいものはこれ
- 部屋を作る
- 自分が作った部屋に入る
- 他人が作った部屋を見つける
- 他人が作った部屋に入る
- 他人と話す

です。
クイックスタートで行ったのは
- 部屋を作る
- 自分が作った部屋に入る
- 自分と話す

でした。
あ、今回は前提としてエラーハンドリングとかはマジで適当です


## まずFirestoreの方から
誰かが作った部屋の一覧を見る機能は [Firestore](https://firebase.google.com/docs/firestore/quickstart?hl=ja) で実現することにします。

Firestoreで管理するのは部屋のモデル。
Model名にSSがついているのは気にしないでください。
idはSkyWaySDKで作成したRoomのidと紐付けます。
あと名前とパスワードがあればいいよね

```SSRoom.swift
import Foundation
import SwiftyJSON

struct SSRoom {
    let id: String
    let name: String
    let password: String?
}

extension SSRoom {
    init?(json: JSON) {
        guard let id = json["id"].string,
            let name = json["name"].string else {
            return nil
        }
        self.init(id: id,
                  name: name,
                  password: json["password"].string)
    }
}

```
部屋の保存、取得はこんな感じ。
保存はSkyWayで部屋作った時に呼ばれます。
取得が部屋一覧画面見たいのを作ってそこから呼びます。

```FirestoreManager.swift
import Foundation
import FirebaseFirestore
import SwiftyJSON

struct FirestoreManager {
    private let db = Firestore.firestore()
    
    func rooms() async throws -> [SSRoom] {
        return try await withCheckedThrowingContinuation { continuation in
            db.collection("rooms").getDocuments() { (snapshot, error) in
                if let snapshot {
                    let rooms = snapshot.documents.compactMap { document in
                        SSRoom(json: JSON(document.data()))
                    }
                    continuation.resume(with: .success(rooms))
                } else {
                    continuation.resume(with: .success([]))
                }
            }
        }
    }
    
    func createRoom(room: SSRoom) async throws {
        return try await withCheckedThrowingContinuation { continuation in
            let parameters: [String: Any] = {
                if let password = room.password {
                    return [
                        "id": room.id,
                        "name": room.name,
                        "password": password
                    ]
                } else {
                    return [
                        "id": room.id,
                        "name": room.name
                    ]
                }
            }()
            db.collection("rooms").addDocument(data: parameters) { _ in
                continuation.resume()
            }
        }
    }
}
```
あとは画面側の部屋一覧表示したり部屋作ったり、あとはユーザ名入れられたりとかってのを作りました。コードは割愛

## 本題のSkyWayロジック部分
入室パターンは
・部屋を作ってそこに入室
・部屋の一覧から選んでそこに入室

登場人物は
・SFURoom (トークルーム
・LocalSFURoomMember（自分
・RoomMember (入室済みの人たち

と言うことで超ざっくり言うと
・ルームはcreateかfindしてあげる
・ルームはメンバーを持っていて、メンバーは音とか映像のソースを持っているので、それをsubscribeして画面に出す。
・ルームはメンバーが変更されたり、メンバーが持っているソースが変わったりしたら通知をしてくれるdelegateを持っているので、それを使ってリアルタイムで入退室を検知

こんな感じです。
```SkywayManager.swift
protocol SkywayManagerDelegate: AnyObject {
    func didPartnerJoined(videoStream: RemoteVideoStream?)
}
class SkywayManager {
    enum SMError: Error {
        case createRoom
        case joinRoom
        case publishAudio
        case publishVideo
        
        var message: String {
            switch self {
            case .createRoom:
                return "トークルームの作成に失敗しました"
            case .joinRoom:
                return "トークルームへの参加に失敗しました"
            case .publishAudio:
                return "音声の配信に失敗しました"
            case .publishVideo:
                return "映像の配信に失敗しました"
            }
        }
    }
    
    private static let token: String = "Your Token."
    
    private let firestoreManager = FirestoreManager()
    private var me: SkyWayRoom.LocalSFURoomMember?
    private var room: SFURoom? {
        didSet {
            room?.delegate = self
        }
    }
    
    weak var delegate: SkywayManagerDelegate?

    func createRoom(name: String,
                    password: String?) async -> Result<SSRoom, SMError> {
        do {
            try await Context.setup(withToken: Self.token,
                                    options: nil)
            let option = Room.InitOptions()
            guard let room: SFURoom = try? await .create(with: option) else {
                return .failure(.createRoom)
            }
            let ssRoom = SSRoom(id: room.id,
                                name: name,
                                password: password)
            try await firestoreManager.createRoom(room: ssRoom)
            
            try await join(room: room)
            
            return .success(ssRoom)
        } catch {
            return .failure(.joinRoom)
        }
    }
    
    func joinRoom(room ssRoom: SSRoom) async -> Result<SSRoom, SMError> {
        do {
            try await Context.setup(withToken: Self.token,
                                     options: nil)
            let query = SkyWayRoom.Room.Query()
            query.id = ssRoom.id
            let room: SFURoom = try await .find(by: query)
            
            try await join(room: room)
            
            return .success(ssRoom)
        } catch {
            print(error.localizedDescription)
            return .failure(.joinRoom)
        }
    }
    
    private func join(room: SFURoom) async throws {
        if let member = room.members.first(where: { member in
            member.metadata == UserDefaults.uuid
        }) {
            try? await member.leave()
        }
        let memberInit: Room.MemberInitOptions = .init()
        memberInit.name = UserDefaults.userName
        memberInit.metadata = UserDefaults.uuid
        let member = try await room.join(with: memberInit)
        me = member
        // AudioStreamの作成
        let auidoSource: MicrophoneAudioSource = .init()
        let audioStream = auidoSource.createStream()
        let _ = try await member.publish(audioStream, options: nil)
        
        // Cameraの設定
        guard let camera = CameraVideoSource.supportedCameras().first(where: { $0.position == .front }) else {
            return .failure(.publishVideo)
        }
            
        // キャプチャーの開始
        try await CameraVideoSource.shared().startCapturing(with: camera, options: nil)
            
        // VideoStreamの作成
        let localVideoStream = CameraVideoSource.shared().createStream()
        let _ = try await member.publish(localVideoStream, options: nil)
        
        self.room = room
    }

    
    func leave() async {
        try? await me?.leave()
        // こちら追記。落ちるんだけど！って言っていたのは私がポンコツだったからであって、ちゃんとドキュメントに書いてありました。
        try? await room?.dispose()
        me = nil
        room = nil
        try? await Context.dispose()
    }
}

extension SkywayManager: RoomDelegate {
    func roomPublicationListDidChange(_ room: SkyWayRoom.Room) {
        guard let me,
              let partner = room.members.first(where: { $0.id != me.id }),
              let audioPublication = partner.publications.first(where: { $0.contentType == .audio }),
              let videoPublication = partner.publications.first(where: { $0.contentType == .video }) else {
            DispatchQueue.main.async { [weak self] in
                self?.delegate?.didPartnerJoined(videoStream: nil)
            }
            return
        }
        
        Task {
            guard let _ = try? await me.subscribe(publicationId: audioPublication.id, options: nil) else {
                print("[Tutorial] Subscribing failed.")
                return
            }
            print("🎉Subscribing audio stream successfully.")
            
            guard let videoSubscription = try? await me.subscribe(publicationId: videoPublication.id, options: nil) else {
                print("[Tutorial] Subscribing failed.")
                return
            }
            print("🎉Subscribing video stream successfully.")
            
            if let remoteVideoStream = videoSubscription.stream as? RemoteVideoStream {
                DispatchQueue.main.async { [weak self] in
                    self?.delegate?.didPartnerJoined(videoStream: remoteVideoStream)
                }
            }
        }
    }
}
```

## 完成イメージ

![SkyWay.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/115916/108ea5f6-0026-8ad6-da06-7c7f1c6a3538.gif)

制作時間は4~5時間くらいでした。

## SkyWayの良かったところ・良くなかったところ
※あくまで個人の主観です。iOS SDKの個人的評価です。

良かったところ
・余計なステップが無いので実装が楽。
・ドキュメント、コード内のコメントが日本語でわかりやすい。これはかなりでかい
・Swift Concurrency が使われていて嬉しい。
・SwiftUIのExampleがある。よく読んでないけど。嬉しい。

良くなかったところ
・一度入室後、アプリキルなどして不正に退室した後再入場しようとした場合失敗する。毎回Roomのmember見て自分がいたら退室する〜みたいなめんどいことしないといけなかった
~~・Roomへのjoinでクラッシュすることがある。せっかくtryしているのにエラーをcatchせず落ちる。~~ 
~~・catchしたエラーのコードがわかりにくい~~ 
↑追記: 私がちゃんとドキュメントを最後まで読んでいなかっただけで、ちゃんとフローを実行すれば発生しません。


## 最後に
基本的にドキュメントもSDKもしっかりしているので、扱いやすく簡単に実現できました。
次気が向けば、ミュート機能とか背景設定したりとか、あとはトークンの払い出しを動的にもやりたいですね。
最後まで見ていただきありがとうございました。

## 追記
ソースは[githubに公開しています](https://github.com/pooonjuice/SkywaySample "github")
あと、[続き書きました](https://qiita.com/tabu_ch/items/d8b4ae2471469d6a2474 "続き")