SkyWay SDKを使用して、SwiftUIでビデオチャットアプリを作成しました。SkyWayの使用方法やM1 Macでビルドする方法は前回の記事を参考にしてください。
使用環境
- MacBook Air (M1, 2020)
- macOS Monterey 12.3.1
- Xcode 13.3.1
- iOS 15.4.1
- SkyWay Community Edition
画面の構成
RoomVideoViewを親Viewとして、機能ごとに子Viewに分割して画面を構成しています。SkyWay SDKは現時点ではSwiftUIに対応していないため、UIViewRepresentableでSDKのSKWVideoクラスをラップしたVideViewを用意しました。また、ルーム機能にはメッセージの送受信機能もあるので、MessageViewに受信メッセージの表示と、メッセージ送信を実装しました。
実装方法
今回の実装内容を順番にみていきます。
SkyWay SDKのラップクラス
まず、SkyWay SDKをクラスにまとめておきます。ルームメンバーの入室・退出イベントやテキストチャットの受信イベントをViewで検知するためにObservableObjectを継承し、監視対象のプロパティを@Publishedにつけておきます。あとはSwiftUIのランタイムが関連のViewを自動的に更新してくれます。
また、View特有のForEachで配列を扱えるようにIdentifiableを継承して一意のidをつけておきます。
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
@StateObjectを使ってSkyWayRoomをインスタンス化します。ここでは、@Publishedしたプロパティにはアクセスしませんが、子Viewにインスタンスを渡し、子View側で@ObservedObjectとしてインスタンスを保持することで、Publishedを検知します。このように、Viewをまたがってプロパティの変更を検知することができます。
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オブジェクトが変更が検知されます。(便利すぎ)
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クラスで管理されているため、ここではメッセージの表示と、キーボード入力の処理だけ実装しています。
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をラップしています。
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")
}
}
完成
実機での実行イメージです。円のクリップが自分の映像で、四角が相手の映像です。
メンバーが参加するたびに動的に参加人数が更新され、ビデオ映像が追加されていきます。
通信系のアプリはイベント管理と表示の処理が煩雑になりがちですが、SwiftUIでこのあたりを意識することなく、かなりシンプルにコーディングできました。お試しあれ!