SkyWayをSwiftUIで実装する〜複数人ビデオチャット編

SkyWay SDKを使用して、SwiftUIでビデオチャットアプリを作成しました。SkyWayの使用方法やM1 Macでビルドする方法は[前回](https://qiita.com/yuppejp/items/48e4bac395e0cb740fe1)の記事を参考にしてください。


## 使用環境
- MacBook Air (M1, 2020)
- macOS Monterey 12.3.1
- Xcode 13.3.1
- iOS 15.4.1
- [SkyWay Community Edition](https://webrtc.ecl.ntt.com/)

# 画面の構成

RoomVideoViewを親Viewとして、機能ごとに子Viewに分割して画面を構成しています。SkyWay SDKは現時点ではSwiftUIに対応していないため、UIViewRepresentableでSDKのSKWVideoクラスをラップしたVideViewを用意しました。また、ルーム機能にはメッセージの送受信機能もあるので、MessageViewに受信メッセージの表示と、メッセージ送信を実装しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/c0a79202-cb0e-961b-98d4-70acf9efeb7e.png)

# 実装方法

今回の実装内容を順番にみていきます。

## SkyWay SDKのラップクラス
まず、SkyWay SDKをクラスにまとめておきます。ルームメンバーの入室・退出イベントやテキストチャットの受信イベントをViewで検知するためにObservableObjectを継承し、監視対象のプロパティを@Publishedにつけておきます。あとはSwiftUIのランタイムが関連のViewを自動的に更新してくれます。
また、View特有のForEachで配列を扱えるようにIdentifiableを継承して一意のidをつけておきます。

```swift:SkyWayRoom.swif
import Foundation
import SkyWay

struct MediaStream: Identifiable {
    let id = UUID()
    let peerId: String
    let stream: SKWMediaStream
}

struct Message: Identifiable {
    var id = UUID()
    var peerId: String
    var date = Date()
    var text: String
}

class SkyWayRoom: ObservableObject {
    private let skywayAPIKey = "YOUR API KEY"
    private let skywayDomain = "localhost"
    private var peer: SKWPeer?
    private var room: SKWRoom?
    @Published var peerId = ""
    @Published var localStream: SKWMediaStream?
    
    var streams: [MediaStream] = []
    @Published var streamCount = 0
    
    var messages: [Message] = []
    @Published var lastMessageId = UUID()
    
    func join(_ roomName: String) {
        let option = SKWPeerOption.init()
        option.key = skywayAPIKey
        option.domain = skywayDomain
        if let peer = SKWPeer(options: option) {
            self.peer = peer
            
            registerPeerCallbacks(peer, roomName: roomName)
        }
    }
    
    func leave() {
        if room != nil {
            let meshRoom = room as! SKWMeshRoom
            meshRoom.close()
            streams.removeAll()
            streamCount = 0
            room = nil
        }
        
        if peer != nil {
            peer?.destroy()
            peer = nil
        }
    }
    
    func isJoined() -> Bool {
        room != nil && localStream != nil
    }
    
    func addMessage(peerId: String, text: String) {
        let msg = Message(peerId: peerId, text: text)
        messages.append(msg)
        lastMessageId = msg.id
    }

    func sendMessage(text: String) {
        if let room = room {
            room.send(text as NSObject)
        }
        
        // 自分からの送信メッセージはコールバックされないので、自前でエコー
        addMessage(peerId: peerId, text: text)
    }

    func registerPeerCallbacks(_ peer: SKWPeer, roomName: String) {
        peer.on(.PEER_EVENT_OPEN, callback: { (obj) -> Void in
            if let peerId = obj as? String {
                self.peerId = peerId

                SKWNavigator.initialize(peer)
                let constraints = SKWMediaConstraints()
                constraints.minFrameRate = 30
                self.localStream = SKWNavigator.getUserMedia(constraints)
                
                let roomOption = SKWRoomOption()
                roomOption.mode = .ROOM_MODE_MESH
                roomOption.stream = self.localStream
                if let room = peer.joinRoom(withName: roomName, options: roomOption) {
                    self.room = room
                    self.registerRoomCallbacks(room)
                }
            }
        })
    }
    
    func registerRoomCallbacks(_ room: SKWRoom) {
        room.on(.ROOM_EVENT_STREAM, callback: { (obj) -> Void in
            if let mediaStream = obj as? SKWMediaStream {
                if let peerID = mediaStream.peerId {
                    let item = MediaStream(peerId: peerID, stream: mediaStream)
                    self.streams.append(item)
                    self.streamCount = self.streams.count
                }
            }
        })
        
        room.on(.ROOM_EVENT_PEER_LEAVE, callback: { (obj) -> Void in
            if let peerId = obj as? String {
                for (index, item) in self.streams.enumerated() {
                    if item.peerId == peerId {
                        self.streams.remove(at: index)
                        self.streamCount = self.streams.count
                    }
                }
            }
        })

        room.on(.ROOM_EVENT_DATA, callback: { (obj) -> Void in
            if let message = obj as? SKWRoomDataMessage {
                let peerId: String = message.src
                if let data = message.data as? String {
                    self.addMessage(peerId: peerId, text: data)
                }
            }
        })
    }
}
```

## ContentView

<span>@</span>StateObjectを使ってSkyWayRoomをインスタンス化します。ここでは、<span>@</span>Publishedしたプロパティにはアクセスしませんが、子Viewにインスタンスを渡し、子View側で<span>@</span>ObservedObjectとしてインスタンスを保持することで、Publishedを検知します。このように、Viewをまたがってプロパティの変更を検知することができます。

```swift:ContentView.swif
struct ContentView: View {
    @State var isJoined = false
    @State var roomName = "room1"
    @StateObject var skyWayRoom = SkyWayRoom()
    
    var body: some View {
        NavigationView {
            VStack {
                RoomVideoView(skyWayRoom: skyWayRoom)
            }
            .navigationTitle(roomName)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing){
                    if isJoined {
                        Button("Leave") {
                            skyWayRoom.leave()
                            isJoined = false
                        }
                    } else {
                        Button("Join") {
                            skyWayRoom.join(roomName)
                            isJoined = true
                        }
                    }
                }
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(isJoined: false, roomName: "room1")
    }
}
```

## RoomVideoView

ルームの参加メンバーの人数が可変のため、LazyVGridで動的にカラムを分割して相手の映像を表示します。メンバーの参加、退出のタイミングでObservableObjectプロトコルを通じて、SkyWayRoomオブジェクトが変更が検知されます。（便利すぎ）

```swift:RoomVideoView.swift
import SwiftUI

struct RoomVideoView: View {
    var columns: [GridItem] = [GridItem(.adaptive(minimum: 80))]
    @ObservedObject var skyWayRoom: SkyWayRoom

    var body: some View {
        VStack {
            HStack {
                VStack {
                    Text(String(skyWayRoom.streamCount) + "人")
                        .font(.largeTitle)
                    
                    Text("参加人数")
                        .font(.caption)
                }
                .frame(maxWidth: .infinity)
                
                HStack {
                    VStack {
                        if let localStream = skyWayRoom.localStream {
                            VideoView(stream: localStream)
                                .frame(minWidth: 80, minHeight: 110)
                                .clipShape(Circle())
                                .overlay(Circle().stroke(Color.gray, lineWidth: 1))
                        } else {
                            // デバッグ（レイアウト確認）
                            Text("My Camera")
                                .frame(minWidth: 80, minHeight: 110)
                                .clipShape(Circle())
                                .overlay(Circle().stroke(Color.gray, lineWidth: 1))
                        }
                        
                        Text(skyWayRoom.peerId)
                            .frame(alignment: .top)
                            .font(.caption)
                    }
                    .frame(minWidth: 200)
                }
            }
            .frame(minHeight: 140, maxHeight: 140)
            
            LazyVGrid(columns: columns) {
                if skyWayRoom.isJoined() {
                    ForEach(skyWayRoom.streams) { stream in
                        VStack {
                            VideoView(stream: stream.stream)
                                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 130)
                            Text(stream.peerId)
                                .font(.caption)
                        }
                    }
                } else {
                    // デバッグ（レイアウト確認）
                    ForEach((1...6), id: \.self) {
                        Text("\($0)")
                            .font(.title)
                            .frame(minWidth: 0, maxWidth: .infinity, minHeight: 130)
                            .background(Color.white)
                    }
                }
            }
            .padding()
            .background(Color(red: 210/255, green: 217/255, blue: 230/255, opacity: 1.0))

            Spacer()

            MessageView(room: skyWayRoom)
        }
    }
}

struct GridVideoView_Previews: PreviewProvider {
    @StateObject static private var skyWayRoom = SkyWayRoom()

    static var previews: some View {
        RoomVideoView(skyWayRoom: skyWayRoom)
    }
}

```

## MessageView

ルームメンバーのグループチャットを担当するViewです。チャットデータはSkyWayRoomクラスで管理されているため、ここではメッセージの表示と、キーボード入力の処理だけ実装しています。

```MessageView.swift
import SwiftUI

struct MessageView: View {
    @ObservedObject var room: SkyWayRoom
    @State var inputText = ""
    
    var body: some View {
            ScrollViewReader { reader in
                List {
                    ForEach(room.messages) { message in
                        MessageItemView(message: message)
                            .id(message.id)
                    }
                }
                .listStyle(PlainListStyle())
                .onChange(of: room.lastMessageId) { id in
                    // 末尾にスクロール
                    withAnimation(.linear(duration: 2)) {
                        reader.scrollTo(id)
                    }
                }
            }
            
            HStack {
                TextField("メッセージを入力...", text: $inputText)
                    .padding()
                
                Button("送信") {
                    UIApplication.shared.closeKeyboard()
                    
                    if !inputText.isEmpty {
                        room.sendMessage(text: inputText)
                        inputText = "" // クリア
                    }
                }
                
                Button("Cancel") {
                    UIApplication.shared.closeKeyboard()
                    inputText = "" // クリア
                }
                .padding()
            }
            .frame(maxWidth: .infinity, maxHeight: 50)
        .onAppear {
            if isPreview() {
                for i in 1..<4 {
                    room.addMessage(peerId: "peer" + String(i), text: "メッセージ" + String(i))
                }
            }
        }
    }
    
    func isPreview() -> Bool {
        return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
    }
    
}

struct MessageItemView: View {
    @State var message: Message
    
    var body: some View {
        VStack {
            HStack {
                Text(message.peerId)
                    .font(.caption)
                
                Text(formatTime(date: message.date))
                    .font(.caption)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            
            Text(message.text)
                .font(.footnote)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
    
    func formatTime(date: Date) -> String {
        let f = DateFormatter()
        f.timeStyle = .short
        f.dateStyle = .short
        f.locale = Locale(identifier: "ja_JP")
        let time = f.string(from: date)
        return time
    }
}

extension UIApplication {
    func closeKeyboard() {
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

struct MessageView_Previews: PreviewProvider {
    @StateObject static var room = SkyWayRoom()
    
    static var previews: some View {
        MessageView(room: room)
    }
}
```

## VideoView

UIViewRepresentableでSkyWay SDKをラップしています。

```swift:VideoView.swift
import SwiftUI
import SkyWay

struct VideoView: UIViewRepresentable {
    typealias UIViewType = SKWVideo
    let stream: SKWMediaStream
    let videoView = UIViewType()

    func makeUIView(context: Context) -> UIViewType {
        return videoView
    }

    func updateUIView(_ uiView: UIViewType, context: Context) {
        stream.addVideoRenderer(uiView, track: 0)
    }

    final class Coordinator: NSObject {
        let parent: VideoView
        init(_ parent: VideoView) {
            self.parent = parent
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    static func dismantleUIView(_ uiView: UIViewType, coordinator: Coordinator) {
        // perform additional clean-up work related custom view
        coordinator.parent.stream.removeVideoRenderer(uiView, track: 0)
    }
}

struct VideoView_Previews: PreviewProvider {
    static var previews: some View {
        //VideoView()
        Text("test")
    }
}
```

# 完成

実機での実行イメージです。円のクリップが自分の映像で、四角が相手の映像です。
メンバーが参加するたびに動的に参加人数が更新され、ビデオ映像が追加されていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/d218e4fd-e16d-851f-0820-8633ef939bbc.png)

通信系のアプリはイベント管理と表示の処理が煩雑になりがちですが、SwiftUIでこのあたりを意識することなく、かなりシンプルにコーディングできました。お試しあれ！

# 参考
- [SkyWayをSwiftUIで実装する(M1 Macを使用)](https://qiita.com/yuppejp/items/48e4bac395e0cb740fe1)
- [SkyWay iOS SDK ドキュメント](https://webrtc.ecl.ntt.com/documents/ios-sdk.html)
- [SkyWay iOS SDK リファレンス](https://webrtc.ecl.ntt.com/ios-reference/)