TencentCloudのビデオ関連サービスの一つである Tencent Real-Time Communication(TRTC)のビデオ音声通話SDKを使ってみました。
つくったもの
勢いでTRTC SDKを使っていくつかのバリエーションのアプリをつくってみました。最初は練習としてAndroidとiOSで最小限のビデオ通話ができるアプリを作成し、感覚をつかんだら応用としてグループでビデオ通話ができるアプリに挑戦しました。
グループ通話アプリでは一般的な命令的プログラミングと、宣言的UI(Jetpack Complse/SwiftUI)のそれぞれで実装してみました。
アプリの動作イメージ
最小限のビデオ通話アプリ
動作OS | Android | iOS |
---|---|---|
動作イメージ | ||
開発ツール | Android Studio | Xcode |
開発言語 | Kotlin | Swift |
UIフレームワーク | Activity XML | UIKit |
アプリ名 | VideoSample | VideoSample |
グループビデオ通話アプリ
開発環境
- MacBook Air (M1, 2020)
- macOS Ventura 13.0.1
- Android Studio Dolphin | 2021.3.1 Patch 1
- Xcode 14.1
今回作成したソースコード一式はこちらです。
以降はそれぞれのアプリの作成メモです。これからアプリを作成される方の参考になればうれしいです。
事前準備(Android/iOS共通)
-
アカウントの登録
SDKを使用するにはTencent Cloudのアカウントが必要です。クレジットカードの登録が必要ですが無料枠が用意されているため、開発で利用する分には無料枠に収まると思います。
Tencent Cloud アカウント登録ガイダンス -
SDKシークレットの取得
Tencent Cloudサイトのコンソールでアプリケーションを登録してSDKAppIDとSecretKeyを取得します。
https://www.tencentcloud.com
ナビゲーションヘッダーのコンソールから入ります。
コンソールを開くと製品の一覧が表示されます。この中から「Tencent Real-Time Communication」をクリックします。
「Create an application now」をクリックして入力を進めると、SDKAppIDとSecretKeyが払い出されるのでメモっておきます。
-
SDKのダウンロード
登録の途中でSDKのダウンロードサイトに誘導されます。もし迷子になったらここからダウンロードできます。
これで準備OKです。
最小限のビデオ通話アプリ
練習のために必要最低限の実装でビデオ通話アプリを作ってみます。AndroidとiOSの両方で試してみました。
Android版
日本語のチュートリアルが用意されていますので、この手順どおりにやっていけば特にハマることなく進められると思います。
プロジェクト設定
-
SDKのデモサンプルはJavaベースだったので、今回はせっかくなのでKotlinでやってみます。
-
CPUアーキテクチャと依存関係を追加します。
build.gradle(Module)のdefaultConfigとdependenciesにそれぞれ追加します。build.gradle(Module)android { namespace 'com.example.videosample' compileSdk 32 defaultConfig { applicationId "com.example.videosample" minSdk 26 targetSdk 32 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + ndk { + abiFilters "armeabi-v7a", "arm64-v8a" + } } ... } dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.5.1' implementation 'com.google.android.material:material:1.7.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'com.tencent.liteav:LiteAVSDK_TRTC:latest.release' // TRTC SDK testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.4' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' }
追加したら
Sync Now
をクリックしてプロジェクトを同期します。いったんビルドしてシミュレーターでアプリが起動するか確認します。 -
混合規則の設定
proguard-rules.proでTRTC SDKのクラスを追加します。proguard-rules.pro-keep class com.tencent.** { *; }
-
アプリの権限を追加
AndroidManifest.xml にTRTC SDKに必要な権限を追加します。AndroidManifest.xml<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> + <uses-permission android:name="android.permission.BLUETOOTH" /> + <uses-permission android:name="android.permission.CAMERA" /> + <uses-feature android:name="android.hardware.camera.autofocus" /> <application ... </application> </manifest> }
-
権限要求コードを追加
Androidの場合はマニフェストに権限を追加しただけでは利用者向けにアクセスの許可を要求するUIは表示されないため、許可を要求するコードを追加します。activity_main.xml
MainActivity.ktclass MainActivity : AppCompatActivity() { private var granted = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + requestPermissions() } + private fun requestPermissions() { + val permissions = arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + ) + var requestPermission = false + for (permission in permissions) { + if (ActivityCompat.checkSelfPermission(this, permission) + == PackageManager.PERMISSION_GRANTED + ) { + continue + } else { + val requestPermissionsLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ActivityResultCallback<Map<String, Boolean>> { grantResults: Map<String, Boolean> -> + if (grantResults.containsValue(false)) { + // denied + Toast.makeText(applicationContext, "Please allow access permissions.", Toast.LENGTH_LONG).show() + } else { + // permitted + granted = true + } + } + ) + requestPermissionsLauncher.launch(permissions) + requestPermission = true + break + } + } + if (!requestPermission) { + // already permitted + granted = true + } + } }
これでTRTC SDKを組み込むための準備が完了です。
画面レイアウトの作成
自分と相手の映像を表示するTXCloudVideoViewをそれぞれ定義し、ルームに出入りするためのボタンを追加します。
-
自分の映像
メイン画面にTXCloudVideoViewを配置します。<com.tencent.rtmp.ui.TXCloudVideoView android:id="@+id/trtcLoalVideoView" android:layout_width="match_parent" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" tools:layout_editor_absoluteX="135dp" > </com.tencent.rtmp.ui.TXCloudVideoView>
-
相手の映像と名前
画面の右上に適当なサイズでTXCloudVideoViewを配置します。相手の名前用にTextViewを子要素として配置します。<com.tencent.rtmp.ui.TXCloudVideoView android:id="@+id/trtcRemoteVideoView" android:layout_width="100dp" android:layout_height="150dp" android:layout_marginTop="16dp" android:layout_marginEnd="16dp" android:background="@android:color/darker_gray" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent"> <TextView android:id="@+id/remoteUserIdLabel" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|center" android:layout_weight="1" android:text="user1" android:textAlignment="center" android:textColor="@color/black" android:translationZ="2dp" tools:ignore="TextContrastCheck" /> </com.tencent.rtmp.ui.TXCloudVideoView>
相手の名前が常に映像の上に表示されるようにtranslationZを指定しておきます。この指定が無いとビデオ映像の下に隠れる場合がありました。
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.tencent.rtmp.ui.TXCloudVideoView
android:id="@+id/trtcLoalVideoView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
tools:layout_editor_absoluteX="135dp" >
</com.tencent.rtmp.ui.TXCloudVideoView>
<Button
android:id="@+id/joinButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:backgroundTint="#00FF00"
android:text="Join"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:ignore="TextContrastCheck" />
<com.tencent.rtmp.ui.TXCloudVideoView
android:id="@+id/trtcRemoteVideoView"
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:background="@android:color/darker_gray"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/remoteUserIdLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center"
android:layout_weight="1"
android:text="user1"
android:textAlignment="center"
android:textColor="@color/black"
android:translationZ="2dp"
tools:ignore="TextContrastCheck" />
</com.tencent.rtmp.ui.TXCloudVideoView>
</androidx.constraintlayout.widget.ConstraintLayout>
シグネチャー生成クラス
TRTCのルームに参加するためにはSDKシークレットを元にシグネチャーを生成する必要があります。生成方法は公式サンプルにあるJavaコードを参考にkotlinで実装しました。
SDKAPPIDとSECRETKEYはTencent Cloudのサイトで取得したものを使用します。
TrtcUserSig.kt
package com.example.videosample
import android.util.Base64
import org.json.JSONException
import org.json.JSONObject
import java.io.UnsupportedEncodingException
import java.nio.charset.Charset
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.util.*
import java.util.zip.Deflater
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class TrtcUserSig {
val SDKAPPID: Int = MY_SDKAPPID
val SECRETKEY:String = MY_SECRETKEY
val EXPIRETIME = 10 * 60 // seconds
fun genTestUserSig(userId: String): String {
return genTLSSignature(
SDKAPPID.toLong(),
userId,
EXPIRETIME.toLong(),
null,
SECRETKEY
)
}
private fun genTLSSignature(sdkAppId: Long, userId: String, expire: Long, userBuf: ByteArray?, priKeyContent: String): String {
val currTime = System.currentTimeMillis() / 1000
val sigDoc = JSONObject()
try {
sigDoc.put("TLS.ver", "2.0")
sigDoc.put("TLS.identifier", userId)
sigDoc.put("TLS.sdkappid", sdkAppId)
sigDoc.put("TLS.expire", expire)
sigDoc.put("TLS.time", currTime)
} catch (e: JSONException) {
e.printStackTrace()
}
var base64UserBuf: String? = null
if (null != userBuf) {
base64UserBuf = Base64.encodeToString(userBuf, Base64.NO_WRAP)
try {
sigDoc.put("TLS.userbuf", base64UserBuf)
} catch (e: JSONException) {
e.printStackTrace()
}
}
val sig: String = hmacsha256(sdkAppId, userId, currTime, expire, priKeyContent, base64UserBuf)
if (sig.isEmpty()) {
return ""
}
try {
sigDoc.put("TLS.sig", sig)
} catch (e: JSONException) {
e.printStackTrace()
}
val compressor = Deflater()
compressor.setInput(sigDoc.toString().toByteArray(Charset.forName("UTF-8")))
compressor.finish()
val compressedBytes = ByteArray(2048)
val compressedBytesLength = compressor.deflate(compressedBytes)
compressor.end()
return String(base64EncodeUrl(Arrays.copyOfRange(compressedBytes, 0, compressedBytesLength)))
}
private fun hmacsha256(sdkAppId: Long, userId: String, currTime: Long, expire: Long, priKeyContent: String, base64UserBuf: String?): String {
var contentToBeSigned = """
TLS.identifier:$userId
TLS.sdkappid:$sdkAppId
TLS.time:$currTime
TLS.expire:$expire
""".trimIndent()
if (null != base64UserBuf) {
contentToBeSigned += "TLS.userbuf:$base64UserBuf\n"
}
return try {
val byteKey = priKeyContent.toByteArray(charset("UTF-8"))
val hmac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(byteKey, "HmacSHA256")
hmac.init(keySpec)
val byteSig = hmac.doFinal(contentToBeSigned.toByteArray(charset("UTF-8")))
String(Base64.encode(byteSig, Base64.NO_WRAP))
} catch (e: UnsupportedEncodingException) {
return ""
} catch (e: NoSuchAlgorithmException) {
return ""
} catch (e: InvalidKeyException) {
return ""
}
}
private fun base64EncodeUrl(input: ByteArray): ByteArray {
val base64 = String(Base64.encode(input, Base64.NO_WRAP)).toByteArray()
for (i in base64.indices) {
when (base64[i].toInt().toChar()) {
'+' -> base64[i] = '*'.code.toByte()
'/' -> base64[i] = '-'.code.toByte()
'=' -> base64[i] = '_'.code.toByte()
else -> {}
}
}
return base64
}
}
ビデオ通話処理の実装
最後にビデオ通話処理を実装します。enterRoomメソッドでルームへ参加し、処理結果はリスナーで通知されます。リスナーはインナークラスで実装しています。TRTC SDKはシンプルな構成のため、少ないコードで実装できます。
class MainActivity : AppCompatActivity() {
// (省略)
override fun onCreate(savedInstanceState: Bundle?) {
// (省略)
joinButton.setOnClickListener {
if (joined) {
exitRoom()
} else {
enterRoom()
}
}
}
private fun enterRoom() {
val roomId = 1
val userId = "Android demo1"
val isFrontCamera = true
trtcCloud = TRTCCloud.sharedInstance(applicationContext)
trtcCloud.setListener(TRTCCloudImplListener())
val sdkSecret = TrtcUserSig()
val trtcParams = TRTCCloudDef.TRTCParams()
trtcParams.sdkAppId = sdkSecret.SDKAPPID
trtcParams.userId = userId
trtcParams.roomId = roomId
trtcParams.userSig = sdkSecret.genTestUserSig(trtcParams.userId)
trtcCloud.enterRoom(trtcParams, TRTCCloudDef.TRTC_APP_SCENE_VIDEOCALL)
trtcCloud.startLocalAudio(TRTCCloudDef.TRTC_AUDIO_QUALITY_SPEECH)
trtcCloud.startLocalPreview(isFrontCamera, trtcLocalVideoView)
}
private fun exitRoom() {
trtcCloud.stopLocalAudio()
trtcCloud.stopLocalPreview()
trtcCloud.exitRoom()
}
private inner class TRTCCloudImplListener : TRTCCloudListener() {
override fun onEnterRoom(result: Long) {
joined = true
joinButton.text = "Leave"
joinButton.setBackgroundColor(Color.RED)
}
override fun onExitRoom(reason: Int) {
joined = false
joinButton.text = "Join"
joinButton.setBackgroundColor(Color.GREEN)
}
override fun onUserVideoAvailable(userId: String, available: Boolean) {
if (available) {
trtcCloud.startRemoteView(userId, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, trtcRemoteVideoView)
remoteUserIdLabel.text = userId
} else {
trtcCloud.stopRemoteView(userId, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG)
remoteUserIdLabel.text = ""
}
}
}
}
MainActivity.kt - 全文
package com.example.videosample
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.widget.*
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import com.tencent.rtmp.ui.TXCloudVideoView
import com.tencent.trtc.TRTCCloud
import com.tencent.trtc.TRTCCloudDef
import com.tencent.trtc.TRTCCloudListener
class MainActivity : AppCompatActivity() {
private var granted = false
private lateinit var trtcCloud: TRTCCloud
private var joined = false
private lateinit var trtcLocalVideoView: TXCloudVideoView
private lateinit var trtcRemoteVideoView: TXCloudVideoView
private lateinit var remoteUserIdLabel: TextView
private lateinit var joinButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
requestPermissions()
trtcLocalVideoView = findViewById(R.id.trtcLoalVideoView)
trtcRemoteVideoView = findViewById(R.id.trtcRemoteVideoView)
remoteUserIdLabel = findViewById(R.id.remoteUserIdLabel)
joinButton = findViewById(R.id.joinButton)
joinButton.setOnClickListener {
if (!granted) {
Toast.makeText(applicationContext, "Please allow access permissions.", Toast.LENGTH_LONG).show()
} else {
if (joined) {
exitRoom()
} else {
enterRoom()
}
}
}
}
private fun enterRoom() {
val roomId = 1
val userId = "Android demo1"
val isFrontCamera = true
trtcCloud = TRTCCloud.sharedInstance(applicationContext)
trtcCloud.setListener(TRTCCloudImplListener())
val sdkSecret = TrtcUserSig()
val trtcParams = TRTCCloudDef.TRTCParams()
trtcParams.sdkAppId = sdkSecret.SDKAPPID
trtcParams.userId = userId
trtcParams.roomId = roomId
trtcParams.userSig = sdkSecret.genTestUserSig(trtcParams.userId)
trtcCloud.enterRoom(trtcParams, TRTCCloudDef.TRTC_APP_SCENE_VIDEOCALL)
trtcCloud.startLocalAudio(TRTCCloudDef.TRTC_AUDIO_QUALITY_SPEECH)
trtcCloud.startLocalPreview(isFrontCamera, trtcLocalVideoView)
}
private fun exitRoom() {
trtcCloud.stopLocalAudio()
trtcCloud.stopLocalPreview()
trtcCloud.exitRoom()
}
private inner class TRTCCloudImplListener : TRTCCloudListener() {
private val logTag = "MyDebug#TRTCCloudImplListener"
override fun onEnterRoom(result: Long) {
Log.d(logTag, "onEnterRoom result: $result")
joined = true
joinButton.text = "Leave"
joinButton.setBackgroundColor(Color.RED)
}
override fun onExitRoom(reason: Int) {
Log.d(logTag, "onExitRoom reason: $reason")
joined = false
joinButton.text = "Join"
joinButton.setBackgroundColor(Color.GREEN)
}
override fun onUserVideoAvailable(userId: String, available: Boolean) {
Log.d(logTag, "onUserVideoAvailable userId: $userId, available: $available")
if (available) {
trtcCloud.startRemoteView(userId, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, trtcRemoteVideoView)
remoteUserIdLabel.text = userId
} else {
trtcCloud.stopRemoteView(userId, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG)
remoteUserIdLabel.text = ""
}
}
override fun onError(errCode: Int, errMsg: String, extraInfo: Bundle) {
Log.d(logTag, "errCode errMsg: $errMsg")
}
}
private fun requestPermissions() {
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
var requestPermission = false
for (permission in permissions) {
if (ActivityCompat.checkSelfPermission(this, permission)
== PackageManager.PERMISSION_GRANTED
) {
// already permitted
continue
} else {
val requestPermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
ActivityResultCallback<Map<String, Boolean>> { grantResults: Map<String, Boolean> ->
if (grantResults.containsValue(false)) {
// denied
Toast.makeText(applicationContext, "Please allow access permissions.", Toast.LENGTH_LONG).show()
} else {
// permitted
granted = true
}
}
)
requestPermissionsLauncher.launch(permissions)
requestPermission = true
break
}
}
if (!requestPermission) {
granted = true
}
}
}
完成
これでAndroid版ビデオ通話アプリの完成です。
iOS版
iOS版のを参考に進めていきます。
チュートリアル通りだとうまくいかないところがあったので注意事項です。
- ライブラリに「ReplayKit.framework」も追加が必要です
- デモサンプルのEXPIRETIMEのデフォルトが0になっているのでそのままだとルームに接続できません
プロジェクトの作成
-
Xcodeでプロジェクトを作成します。
-
SDKをプロジェクトフォルダーにコピー
SDKはどこに置いてもビルドは成功するのですが、自分の環境ではSDKがプロジェクトフォルダーの外にあると実行時にランタイムエラーが発生したので、プロジェクトフォルダーの中にSDKをコピーしました。
-
ライブラリーの追加
Build Phases
>Link Binary with Libraries
からSDKと依存ライブラリーを追加します。たくさんあるのでひたすらポチポチ。
- 必要なライブラリ
- TXLiteAVSDK_TRTC.Framework
- TXFFmpeg.xcframework
- TXSoundTouch.xcframework
- 必要な依存ライブラリ
- GLKit.framework
- AssetsLibrary.framework
- SystemConfiguration.framework
- libsqlite3.0.tbd
- CoreTelephony.framework
- AVFoundation.framework
- OpenGLES.framework
- Accelerate.framework
- MetalKit.framework
- libresolv.tbd
- MobileCoreServices.framework
- libc++.tbd
- CoreMedia.framework
- ReplayKit.framework ← お忘れなく
- 必要なライブラリ
-
Embedを設定
ライブラリーの追加に続けて、General
>Frameworks,Libraries,and Embedded Content
でTXFFmpeg.xcframework、TXSoundTouch.xcframeworkをEmbed & Sign
に設定します。
-
プライバシー設定
info
>CustomiOS Target Properies
でマイクとカメラのアクセスを追加します。
これで準備OKです。ビルドして実機で動作するか確認しておきます。
ビルド環境がApple Mチップの場合はデフォルト設定だとシミュレータでは動作しません。iOSのシミュレータではカメラがサポートされていないのでシミュレータで動作させる必要はないと思いますが、もしMチップ上のシミュレータでデバッグする場合は、後半部のSwiftUIの章を参考にしてください。
画面レイアウトの作成
storybordで相手の映像を表示するViewをとルームに出入りするためのボタンを追加します。自分の映像は親Viewに直接表示するのでここでは定義不要です。
シグネチャー生成クラス
公式サンプルからコピペします。SDKAPPIDとSECRETKEYはTencent Cloudのサイトで取得したものを使用します。
TrtcUserSig.swift
import Foundation
import CommonCrypto
import zlib
let SDKAPPID: Int = MY_SDKAPPID
let SECRETKEY:String = MY_SECRETKEY
let EXPIRETIME: Int = 10 * 60 // seconds
class TrtcUserSig {
class func genTestUserSig(identifier: String) -> String {
let current = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970
let TLSTime: CLong = CLong(floor(current))
var obj: [String: Any] = [
"TLS.ver": "2.0",
"TLS.identifier": identifier,
"TLS.sdkappid": SDKAPPID,
"TLS.expire": EXPIRETIME,
"TLS.time": TLSTime
]
let keyOrder = [
"TLS.identifier",
"TLS.sdkappid",
"TLS.time",
"TLS.expire"
]
var stringToSign = ""
keyOrder.forEach { (key) in
if let value = obj[key] {
stringToSign += "\(key):\(value)\n"
}
}
print("string to sign: \(stringToSign)")
let sig = hmac(stringToSign)
obj["TLS.sig"] = sig!
print("sig: \(String(describing: sig))")
guard let jsonData = try? JSONSerialization.data(withJSONObject: obj, options: .sortedKeys) else { return "" }
let bytes = jsonData.withUnsafeBytes { (result) -> UnsafePointer<Bytef> in
return result.bindMemory(to: Bytef.self).baseAddress!
}
let srcLen: uLongf = uLongf(jsonData.count)
let upperBound: uLong = compressBound(srcLen)
let capacity: Int = Int(upperBound)
let dest: UnsafeMutablePointer<Bytef> = UnsafeMutablePointer<Bytef>.allocate(capacity: capacity)
var destLen = upperBound
let ret = compress2(dest, &destLen, bytes, srcLen, Z_BEST_SPEED)
if ret != Z_OK {
print("[Error] Compress Error \(ret), upper bound: \(upperBound)")
dest.deallocate()
return ""
}
let count = Int(destLen)
let result = self.base64URL(data: Data.init(bytesNoCopy: dest, count: count, deallocator: .free))
return result
}
class func hmac(_ plainText: String) -> String? {
let cKey = SECRETKEY.cString(using: String.Encoding.ascii)
let cData = plainText.cString(using: String.Encoding.ascii)
let cKeyLen = SECRETKEY.lengthOfBytes(using: .ascii)
let cDataLen = plainText.lengthOfBytes(using: .ascii)
var cHMAC = [CUnsignedChar].init(repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
let pointer = cHMAC.withUnsafeMutableBufferPointer { (unsafeBufferPointer) in
return unsafeBufferPointer
}
CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256), cKey!, cKeyLen, cData, cDataLen, pointer.baseAddress)
let data = Data.init(bytes: pointer.baseAddress!, count: cHMAC.count)
return data.base64EncodedString(options: [])
}
class func base64URL(data: Data) -> String {
let result = data.base64EncodedString(options: Data.Base64EncodingOptions.init(rawValue: 0))
var final = ""
result.forEach { (char) in
switch char {
case "+":
final += "*"
case "/":
final += "-"
case "=":
final += "_"
default:
final += "\(char)"
}
}
return final
}
}
ビデオ通話処理の実装
Androidと同様に通話処理を実装します。TRTC SDKのクラスやメソッド、イベントはAndorid版と同じため同じ感覚で実装できます。デリゲートはextensionで実装すると処理を局所化することができます。
class ViewController: UIViewController {
private var trtcCloud: TRTCCloud = TRTCCloud.sharedInstance()
override func viewDidLoad() {
super.viewDidLoad()
trtcCloud.delegate = self
}
}
extension ViewController: TRTCCloudDelegate {
func onEnterRoom(_ result: Int) {
print("*** onEnterRoom: result: \(result)")
}
func onExitRoom(_ reason: Int) {
print("*** onExitRoom: reason: \(reason)")
}
}
ViewController.swift - 全文
import Foundation
import UIKit
import TXLiteAVSDK_TRTC
class ViewController: UIViewController {
@IBOutlet weak var remoteVideoView: UIView!
@IBOutlet weak var remoteUserLabel: UILabel!
@IBOutlet weak var joinButton: UIButton!
private var trtcCloud: TRTCCloud = TRTCCloud.sharedInstance()
private var joined = false
override func viewDidLoad() {
super.viewDidLoad()
remoteUserLabel.text = ""
remoteVideoView.layer.opacity = 0.3
joinButton.tintColor = UIColor.systemGreen
}
@IBAction func joinButtonTouched(_ sender: Any) {
if joined {
exitRoom()
} else {
enterRoom()
}
}
private func enterRoom() {
let roomId: Int = 1
let userId: String = "iOS demo1"
let isFrontCamera = true
trtcCloud.delegate = self
trtcCloud.startLocalPreview(isFrontCamera, view: view)
let params = TRTCParams()
params.sdkAppId = UInt32(SDKAPPID)
params.roomId = UInt32(roomId)
params.userId = userId
params.role = .anchor
params.userSig = TrtcUserSig.genTestUserSig(identifier: userId) as String
trtcCloud.enterRoom(params, appScene: .videoCall)
let encParams = TRTCVideoEncParam()
encParams.videoResolution = ._640_360
encParams.videoBitrate = 550
encParams.videoFps = 15
trtcCloud.setVideoEncoderParam(encParams)
trtcCloud.startLocalPreview(isFrontCamera, view: view)
trtcCloud.startLocalAudio(.music)
}
private func exitRoom() {
trtcCloud.exitRoom()
trtcCloud.stopLocalPreview()
trtcCloud.stopLocalAudio()
trtcCloud.delegate = self
}
}
extension ViewController: TRTCCloudDelegate {
func onEnterRoom(_ result: Int) {
print("*** onEnterRoom: result: \(result)")
joined = true
joinButton.setTitle("Leave", for: .normal)
joinButton.tintColor = UIColor.red
}
func onExitRoom(_ reason: Int) {
print("*** onExitRoom: reason: \(reason)")
joined = false
joinButton.setTitle("Join", for: .normal)
joinButton.tintColor = UIColor.systemGreen
}
func onUserVideoAvailable(_ userId: String, available: Bool) {
print("*** onUserAudioAvailable: userId: \(userId), available: \(available)")
if available {
trtcCloud.startRemoteView(userId, streamType:.small, view: remoteVideoView)
remoteVideoView.layer.opacity = 1
remoteUserLabel.text = userId
} else {
trtcCloud.stopRemoteView(userId, streamType: .small)
remoteVideoView.layer.opacity = 0.3
remoteUserLabel.text = ""
}
}
func onError(_ errCode: TXLiteAVError, errMsg: String?, extInfo: [AnyHashable : Any]?) {
if let errMsg = errMsg {
print("*** onError: \(errCode): \(errMsg)")
} else {
print("*** onError: \(errCode): ?")
}
}
}
完成
グループビデオ通話アプリ
グループビデオ通話アプリは二種類のプログラミング方法で作成しました。
・命令的プログラミング
・宣言的UI
命令的プログラミング
命令的プログラミングでグループ通話を実装する方法はこれまでのやり方と同じのため実装手順は省略します。GitHubにソースコードを上げていますので参考にしてください。
- Android版のグループ通話アプリ
- iOS版のグループ通話アプリ
リモートユーザーの入室/退出、カメラのON/OFF、マイクのON/OFFなど様々なイベントが複数のユーザー毎に発生するため処理がかなり複雑になってしまいました。命令的プログラミングでロジックとUIを綺麗に分離して書くのは難しいですね。
宣言的UI
以降では宣言的UIフレームワークを使ったグループ通話を実装方法を説明します。下の図のように、TRTC SDKをViewModelでラップし適宜Viewを更新します。Jetpack ComposeでもSwiftUIでもどちらも同じ構造で実装できます。View側では状態ごとの処理分岐が不要なためスッキリした実装になったと思います。
Jetpack Composeでグループ通話を実装(Android)
プロジェクト設定
-
プロジェクトの作成
Empty Compose Activity
を選択します。
後は先ほどと同じ手順で進めていきます。ここからは差分だけ記載します。 -
CPUアーキテクチャと依存関係を追加(最初と同じ)
-
混合規則の設定(最初と同じ)
-
アプリの権限を追加(最初と同じ)
-
権限要求コードを追加(最初と同じ)
-
ライブラリーの追加
デフォルトではViewModelが追加されないため自分で追加します。また、ボタンに使用するアイコン用にcompose iconも追加しておきます。build.gradle(Module)dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.activity:activity-compose:1.3.1' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" implementation 'androidx.compose.material:material:1.1.1' + // ADD: ViewModel + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" + implementation "androidx.compose.runtime:runtime-livedata:1.3.1" + // ADD: compose icon + implementation "androidx.compose.material:material-icons-extended:1.3.1" // ADD: TRTC SDK implementation 'com.tencent.liteav:LiteAVSDK_TRTC:latest.release' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version" }
ビデオViewをAndroidViewでラップ
Jetpack ComposeではViewクラスを直接扱うことができないため、AndroidViewでラップしViewのインスタンスを入手します。TRTC SDKではローカルビデオとリモートビデオでメソッドが別れているので、それぞれのComposableメソッドを用意します。
package com.example.videosamplecompose
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.tencent.liteav.base.ContextUtils.getApplicationContext
import com.tencent.rtmp.ui.TXCloudVideoView
import com.tencent.trtc.TRTCCloud
import com.tencent.trtc.TRTCCloudDef
@Composable
fun TrtcLocalVideoView(isFrontCamera: Boolean, modifier: Modifier = Modifier) {
val trtcCloud = TRTCCloud.sharedInstance(getApplicationContext())
AndroidView(
factory = ::TXCloudVideoView,
update = { view ->
trtcCloud.startLocalPreview(isFrontCamera, view)
},
modifier = modifier
)
}
@Composable
fun TrtcRemoteVideoView(remoteUid: String, modifier: Modifier = Modifier) {
val trtcCloud = TRTCCloud.sharedInstance(getApplicationContext())
AndroidView(
factory = ::TXCloudVideoView,
update = { view ->
trtcCloud.startRemoteView(remoteUid, TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, view)
},
modifier = modifier
)
}
ViewModelの実装
androidx.lifecycle.ViewModel
から継承したクラスを定義します。Viewへ更新を伝播させたいプロパティはMutableLiveData
にしておきます。List型もmutableStateListOf()
でインスタンスを生成しておくことで同様にViewへ更新通知が飛びます。
class TrtcViewModel: ViewModel() {
val joined = MutableLiveData(false)
val remoteUsers = MutableLiveData<MutableList<RemoteUser>>(mutableStateListOf())
val audioAvailable = MutableLiveData(false)
val videoAvailable = MutableLiveData(false)
val isFrontCamera = MutableLiveData(true)
private lateinit var trtcCloud: TRTCCloud
// (省略)
TrtcViewModel.kt
package com.example.videosamplecompose
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.tencent.trtc.TRTCCloud
import com.tencent.trtc.TRTCCloudDef
import com.tencent.trtc.TRTCCloudListener
class RemoteUser(var userId: String) : ViewModel() {
var videoAvailable = MutableLiveData(false)
var audioAvailable = MutableLiveData(false)
}
class TrtcViewModel(var debugPreview: Boolean = false): ViewModel() {
val joined = MutableLiveData(false)
val errorCode = MutableLiveData(0)
val errorMessage = MutableLiveData("")
val userId = MutableLiveData("")
val roomId = MutableLiveData(0)
val remoteUsers = MutableLiveData<MutableList<RemoteUser>>(mutableStateListOf())
val audioAvailable = MutableLiveData(false)
val videoAvailable = MutableLiveData(false)
val isFrontCamera = MutableLiveData(true)
private lateinit var trtcCloud: TRTCCloud
init {
if (debugPreview) {
userId.value = "User1"
for (i in 1..3) {
val id = "user$i"
val user = RemoteUser(id)
remoteUsers.value?.add(user)
}
}
}
fun setup(context: Context) {
trtcCloud = TRTCCloud.sharedInstance(context)
trtcCloud.setListener(TRTCCloudImplListener())
userId.value = "User-" + genRandomString(4)
videoAvailable.value = true
audioAvailable.value = true
}
fun join(roomId: Int) {
this.roomId.value = roomId
// workaround: stop the video once
videoAvailable.value = false
trtcCloud.stopLocalPreview()
val sdkSecret = TrtcUserSig()
val trtcParams = TRTCCloudDef.TRTCParams()
trtcParams.sdkAppId = sdkSecret.SDKAPPID
trtcParams.userId = userId.value
trtcParams.roomId = roomId
trtcParams.userSig = sdkSecret.genTestUserSig(trtcParams.userId)
//trtcParams.startLocalPreview(isFrontCamera, view)
trtcCloud.startLocalAudio(TRTCCloudDef.TRTC_AUDIO_QUALITY_SPEECH)
trtcCloud.enterRoom(trtcParams, TRTCCloudDef.TRTC_APP_SCENE_VIDEOCALL)
}
fun leave() {
trtcCloud.stopLocalAudio()
//trtcCloud.stopLocalPreview()
trtcCloud.exitRoom()
}
fun muteLocalVideo(mute: Boolean) {
trtcCloud.muteLocalVideo(TRTCCloudDef.TRTC_VIDEO_STREAM_TYPE_BIG, mute)
videoAvailable.value = !mute
}
fun muteLocalAudio(mute: Boolean) {
trtcCloud.muteLocalAudio(mute)
audioAvailable.value = !mute
}
fun switchCamera(isFrontCamera: Boolean? = null) {
if (isFrontCamera == null) {
this.isFrontCamera.value = !this.isFrontCamera.value!!
} else {
this.isFrontCamera.value = isFrontCamera
}
trtcCloud.deviceManager.switchCamera(this.isFrontCamera.value!!)
}
private inner class TRTCCloudImplListener : TRTCCloudListener() {
private val logTag = "[MyDebug:TRTCCloudListener]"
override fun onEnterRoom(result: Long) {
Log.d(logTag, "onEnterRoom result: $result")
joined.value = true
videoAvailable.value = true
}
override fun onExitRoom(reason: Int) {
Log.d(logTag, "onExitRoom reason: $reason")
joined.value = false
}
override fun onRemoteUserEnterRoom(userId: String?) {
Log.d(logTag, "onRemoteUserEnterRoom userId: $userId")
if (userId != null) {
val user = RemoteUser(userId)
remoteUsers.value?.add(user)
}
}
override fun onRemoteUserLeaveRoom(userId: String?, reason: Int) {
Log.d(logTag, "onRemoteUserLeaveRoom userId: $userId, reason: $reason")
val user = findRemoteUser(userId)
if (user != null) {
remoteUsers.value?.remove(user)
}
}
override fun onUserAudioAvailable(userId: String?, available: Boolean) {
Log.d(logTag, "onUserAudioAvailable userId: $userId, available: $available")
val user = findRemoteUser(userId)
if (user != null) {
user.audioAvailable.value = available
}
}
override fun onUserVideoAvailable(userId: String, available: Boolean) {
Log.d(logTag, "onUserVideoAvailable userId: $userId, available: $available")
val user = findRemoteUser(userId)
if (user != null) {
user.videoAvailable.value = available
}
}
override fun onError(errCode: Int, errMsg: String, extraInfo: Bundle) {
Log.d(logTag, "onError errCode: $errCode, errMsg: $errMsg")
}
}
private fun findRemoteUser(userId: String?): RemoteUser? {
if (userId != null) {
val users = remoteUsers.value?.filter { user ->
user.userId == userId
}
if (users != null && users.isNotEmpty()) {
return users[0]
}
}
return null
}
private fun genRandomString(length: Int) : String {
val charset = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz0123456789"
return (1..length)
.map { charset.random() }
.joinToString("")
}
}
Viewの実装
Boxレイアウトの中に、上記のTrtcLocalVideoViewと、リモートユーザを表示するためのRemoteUserView、操作パネルのControlPanelViewを配置します。この定義だけであとはViewModelによって自動的に画面が更新されます。ロジックを書かなくて良いので楽ですね。
MainView.kt
package com.example.videosamplecompose
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import com.example.videosamplecompose.ui.theme.VideoSampleComposeTheme
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<TrtcViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestPermissions()
setContent {
VideoSampleComposeTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
MainView(viewModel)
}
}
}
}
private fun requestPermissions() {
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
var requestPermission = false
for (permission in permissions) {
if (ActivityCompat.checkSelfPermission(this, permission)
== PackageManager.PERMISSION_GRANTED
) {
// already permitted
continue
} else {
val requestPermissionsLauncher = registerForActivityResult(
RequestMultiplePermissions(),
ActivityResultCallback<Map<String?, Boolean?>> { grantResults: Map<String?, Boolean?> ->
if (grantResults.containsValue(false)) {
// denied
Toast.makeText(
applicationContext, "Please allow access permissions.",
Toast.LENGTH_LONG
).show()
} else {
// permitted
viewModel.setup(applicationContext)
}
}
)
requestPermissionsLauncher.launch(permissions)
requestPermission = true
break
}
}
if (!requestPermission) {
// permitted
viewModel.setup(applicationContext)
}
}
}
@Composable
fun MainView(viewModel: TrtcViewModel, modifier: Modifier = Modifier) {
val videoAvailable = viewModel.videoAvailable.observeAsState()
val isFrontCamera = viewModel.isFrontCamera.observeAsState()
Box(modifier = modifier) {
if (videoAvailable.value == true && !viewModel.debugPreview) {
TrtcLocalVideoView(isFrontCamera.value!!)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.DarkGray)
)
}
RemoteUserView(
viewModel,
Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
)
ControlPanelView(
viewModel,
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp)
)
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
VideoSampleComposeTheme {
val viewModel = TrtcViewModel(true)
MainView(viewModel)
}
}
RemoteUserView.kt
package com.example.videosamplecompose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.MicOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.videosamplecompose.ui.theme.VideoSampleComposeTheme
@Composable
fun RemoteUserView(viewModel: TrtcViewModel, modifier: Modifier = Modifier) {
val remoteUsers = viewModel.remoteUsers.observeAsState()
Column(modifier) {
remoteUsers.value?.forEach { user ->
RemoteVideoView(user)
}
}
}
@Composable
fun RemoteVideoView(user: RemoteUser, modifier: Modifier = Modifier) {
val videoAvailable = user.videoAvailable.observeAsState()
val audioAvailable = user.audioAvailable.observeAsState()
Box(
modifier
.padding(bottom = 16.dp)
.size(100.dp, 150.dp)
.background(Color.White.copy(alpha = 0.3f))
) {
if (videoAvailable.value == true) {
TrtcRemoteVideoView(user.userId)
} else {
Icon(
imageVector = Icons.Filled.VideocamOff,
contentDescription = "VideocamOff",
modifier = Modifier.align(Alignment.Center)
)
}
Row(modifier = Modifier.align(Alignment.BottomStart)) {
Icon(
imageVector = if (audioAvailable.value == true) Icons.Outlined.Mic else Icons.Outlined.MicOff,
contentDescription = "Mic Status"
)
Text(user.userId)
}
}
}
@Preview(showBackground = true)
@Composable
fun RemoteUserViewPreview() {
VideoSampleComposeTheme {
val viewModel = TrtcViewModel(true)
Box(
Modifier
.size(600.dp, 800.dp)
.background(Color.Gray)
) {
RemoteUserView(
viewModel,
Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
)
}
}
}
ControlPanelView.kt
package com.example.videosamplecompose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Person
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.videosamplecompose.ui.theme.VideoSampleComposeTheme
@Composable
fun ControlPanelView(viewModel: TrtcViewModel, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.3f))
.padding(12.dp)
) {
Column {
StatusView(viewModel)
ControlsView(viewModel)
ErrorView(viewModel)
}
}
}
@Composable
fun StatusView(viewModel: TrtcViewModel, modifier: Modifier = Modifier) {
val userId = viewModel.userId.observeAsState()
val roomId = viewModel.roomId.observeAsState()
val remoteUsers = viewModel.remoteUsers.observeAsState()
Row(modifier) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = "Person"
)
//Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(userId.value!!)
Box(Modifier.weight(1f))
Text("Room: #${roomId.value} (+${remoteUsers.value?.count()} Joined)")
}
}
@Composable
fun ControlsView(viewModel: TrtcViewModel, modifier: Modifier = Modifier) {
val joined = viewModel.joined.observeAsState()
val audioAvailable = viewModel.audioAvailable.observeAsState()
val videoAvailable = viewModel.videoAvailable.observeAsState()
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f))
IconButton(modifier = Modifier.padding(0.dp), onClick = {
if (videoAvailable.value == true) {
viewModel.muteLocalVideo(true)
} else {
viewModel.muteLocalVideo(false)
}
}) {
if (videoAvailable.value == true) {
Icon(Icons.Filled.Videocam, contentDescription = "Videocam")
} else {
Icon(Icons.Filled.VideocamOff, contentDescription = "VideocamOff")
}
}
IconButton(modifier = Modifier.padding(0.dp), onClick = {
viewModel.switchCamera()
}) {
Icon(Icons.Default.Cameraswitch, contentDescription = "switchCamera")
}
IconButton(onClick = {
if (audioAvailable.value == true) {
viewModel.muteLocalAudio(true)
} else {
viewModel.muteLocalAudio(false)
}
}) {
if (audioAvailable.value == true) {
Icon(Icons.Filled.Mic, contentDescription = "Mic")
} else {
Icon(Icons.Filled.MicOff, contentDescription = "MicOff")
}
}
Button(
onClick = {
if (joined.value == true) {
viewModel.leave()
} else {
viewModel.join(1)
}
},
colors = ButtonDefaults.buttonColors(
backgroundColor = if (joined.value == true) Color.Red else Color.Green,
Color.White
)
) {
if (joined.value == true) {
Icon(imageVector = Icons.Filled.CallEnd, contentDescription = "Call")
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Leave")
} else {
Icon(imageVector = Icons.Filled.Call, contentDescription = "Call")
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Join")
}
}
}
}
@Composable
fun ErrorView(viewModel: TrtcViewModel, modifier: Modifier = Modifier) {
Row(modifier) {
if (viewModel.errorCode.value != 0) {
val text = "${viewModel.errorMessage.value}(${viewModel.errorCode.value})"
Text(text)
}
}
}
@Preview(showBackground = true)
@Composable
fun ControlePaneViewPreview() {
VideoSampleComposeTheme {
val viewModel = TrtcViewModel(true)
Box(Modifier
.size(600.dp, 800.dp)
.background(Color.Gray)
) {
ControlPanelView(
viewModel,
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp)
)
}
}
}
完成
これでAndroid版のグループ会話アプリの完成です。
TRTC SDKをViewModel化しComposableでロジックとViewを分離することができました。複数の会話相手の様々なイベントとViewを同期させるのは命令的プログラミングだとかなり煩雑になりがちですが、宣言的UIでスッキリした実装になりました。
SwiftUIでグループビデオ通話を実装(iOS)
最後にSwiftUIでiOSアプリを実装します。ここまで来るとTRTC SDKを使ったアプリの実装にもだいぶ慣れてきました。
プロジェクトの作成
-
SDKをプロジェクトフォルダーにコピー(最初と同じ)
-
ライブラリーを追加(最初と同じ)
-
Embed設定(最初と同じ)
-
プライバシー設定(最初と同じ)
-
Apple Mチップの場合のプレビュー対策
TRTC SDKは開発環境がApple Mチップの場合はXcodeのデフォルト設定ではSwiftUIのプレビューやシミュレータが使用できません。
Build Settings
>Architectures
>Excluded Architectures
>Debug
>Any iOS Simulator SDK
に「arm64」を設定します。
SwiftUIのプレビューが使えないとかなり不便なのでこれは必須設定と思います。
TRTC VideoViewのラッパービュー
Jetpack Composeと同様にSWiftUIもUIViewに直接アクセスできないため、UIViewControllerRepresentableプロトコル経由でUIViewControllerのインスタンスを入手します。ローカル映像とリモート映像用のそれぞれのラッパービューを実装します。
import Foundation
import SwiftUI
import TXLiteAVSDK_TRTC
struct TrtcLocalVideoView: UIViewControllerRepresentable {
var available: Bool
var isFrontCamera: Bool
private let trtcCloud: TRTCCloud = TRTCCloud.sharedInstance()
func makeUIViewController(context: Context) -> UIViewController {
let viewController = UIViewController()
viewController.view.backgroundColor = .lightGray
return viewController
}
func updateUIViewController(_ viewController: UIViewController, context: Context) {
if available {
trtcCloud.startLocalPreview(isFrontCamera, view: viewController.view)
let encParams = TRTCVideoEncParam()
encParams.videoResolution = ._640_360
encParams.videoBitrate = 550
encParams.videoFps = 15
trtcCloud.setVideoEncoderParam(encParams)
viewController.view.layer.opacity = 1
} else {
trtcCloud.stopLocalPreview()
viewController.view.layer.opacity = 0.2
}
}
final class Coordinator: NSObject {
let parent: TrtcLocalVideoView
init(_ parent: TrtcLocalVideoView) {
self.parent = parent
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
static func dismantleUIView(_ viewController: UIViewController, coordinator: Coordinator) {
coordinator.parent.trtcCloud.stopLocalPreview()
}
}
struct TRTCRemoteVideoView: UIViewControllerRepresentable {
let remoteUserId: String
private let trtcCloud: TRTCCloud = TRTCCloud.sharedInstance()
func makeUIViewController(context: Context) -> UIViewController {
let viewController = UIViewController()
viewController.view.backgroundColor = .lightGray
return viewController
}
func updateUIViewController(_ viewController: UIViewController, context: Context) {
trtcCloud.startRemoteView(remoteUserId, streamType:.small, view: viewController.view)
}
final class Coordinator: NSObject {
let parent: TRTCRemoteVideoView
init(_ parent: TRTCRemoteVideoView) {
self.parent = parent
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
static func dismantleUIView(_ viewController: UIViewController, coordinator: Coordinator) {
coordinator.parent.trtcCloud.stopRemoteView(coordinator.parent.remoteUserId, streamType: .small)
}
}
ViewModelの実装
ObservableObjectから継承したクラスを定義します。Viewへ更新を伝播させたいプロパティは@Published
にしておきます。このあたりはJetpack Composeとノリは同じです。
class TrtcViewModel: NSObject, ObservableObject {
@Published var joined = false
@Published var remoteUsers: [RemoteUser] = []
@Published var audioAvailable = true
@Published var videoAvailable = true
@Published var isFrontCamera = true
private var trtcCloud: TRTCCloud = TRTCCloud.sharedInstance()
// (省略)
Viewの実装
ZStackの中に、TrtcLocalVideoView、リモートユーザを表示するためのRemoteUserView、操作パネルのControlPanelViewを配置します。構成はJetpack Compose版とまったく同じ感覚でいけます。
ViewMoldeのインスタンスはenvironmentObjectを使って環境変数に設定します。環境変数を使うと配下のViewすべてでViewModelをアクセスできるため引数でバケツリレーする必要がなくシンプルです。
ContentView.kt
import SwiftUI
struct ContentView: View {
let viewModel = TrtcViewModel()
var body: some View {
MainView()
.environmentObject(viewModel)
}
}
struct MainView: View {
@EnvironmentObject var viewModel: TrtcViewModel
var body: some View {
ZStack(alignment: .topTrailing) {
TrtcLocalVideoView(available: viewModel.videoAvailable, isFrontCamera: viewModel.isFrontCamera)
.edgesIgnoringSafeArea(.all) // Expand the display area to the safe area
VStack {
HStack {
Spacer()
.frame(maxHeight: .infinity)
RemoteUserView()
.frame(maxHeight: .infinity, alignment: .topTrailing)
}
ControlPanelView()
.background(Color.black.opacity(0.3))
.cornerRadius(8)
}
.padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = TrtcViewModel(debugPreview: true)
MainView()
.environmentObject(viewModel)
}
}
RemoteUserView.kt
import SwiftUI
struct RemoteUserView: View {
@EnvironmentObject var viewModel: TrtcViewModel
var body: some View {
VStack {
ForEach(viewModel.remoteUsers) { remoteUser in
ZStack(alignment: .bottomLeading) {
RemoteVideoView(remoteUser: remoteUser)
.frame(width: 100, height: 150)
.background(Color.black.opacity(0.3))
CaptionView(remoteUser: remoteUser)
}
}
}
}
struct RemoteVideoView: View {
@ObservedObject var remoteUser: RemoteUser
var body: some View {
if remoteUser.videoAvailable {
TRTCRemoteVideoView(remoteUserId: remoteUser.userId)
} else {
Image(systemName: "video.slash.fill")
}
}
}
struct CaptionView: View {
@ObservedObject var remoteUser: RemoteUser
var body: some View {
HStack(spacing: 2) {
Image(systemName: remoteUser.audioAvailable ? "mic.fill" : "mic.slash.fill")
.font(.caption)
Text(remoteUser.userId)
.font(.caption)
}
.padding(4)
}
}
}
struct RemoteUserView_Previews: PreviewProvider {
static var previews: some View {
let model = TrtcViewModel(debugPreview: true)
ZStack(alignment: .topTrailing) {
VStack {
HStack {
Spacer()
.frame(maxHeight: .infinity)
RemoteUserView()
.frame(maxHeight: .infinity, alignment: .topTrailing)
.environmentObject(model)
}
}
.padding()
}
}
}
ControlPanelView.kt
import SwiftUI
struct ControlPanelView: View {
@EnvironmentObject var viewModel: TrtcViewModel
var body: some View {
VStack {
StatusView()
ControlView()
ErrorView()
}
.padding()
}
struct StatusView: View {
@EnvironmentObject var viewModel: TrtcViewModel
var body: some View {
HStack {
Label(viewModel.userId, systemImage: "person")
Spacer()
Text("Room: #\(viewModel.roomId) (+\(viewModel.remoteUsers.count) Joined)")
}
}
}
struct ControlView: View {
@EnvironmentObject var viewModel: TrtcViewModel
var body: some View {
HStack {
Spacer()
Button(action: {
if viewModel.videoAvailable {
viewModel.muteLocalVideo(mute: true)
} else {
viewModel.muteLocalVideo(mute: false)
}
}, label: {
Image(systemName: viewModel.videoAvailable ? "video.fill" : "video.slash.fill")
})
.tint(.primary)
.padding(.trailing)
Button(action: {
viewModel.switchCamera()
}, label: {
Image(systemName: "arrow.triangle.2.circlepath")
})
.disabled(!viewModel.videoAvailable)
.tint(.primary)
.padding(.trailing)
Button(action: {
if viewModel.audioAvailable {
viewModel.muteLocalAudio(mute: true)
} else {
viewModel.muteLocalAudio(mute: false)
}
}, label: {
Image(systemName: viewModel.audioAvailable ? "mic.fill" : "mic.slash.fill")
})
.tint(.primary)
.padding(.trailing)
Button(action: {
if viewModel.joined {
viewModel.exitRoom()
} else {
viewModel.enterRoom()
}
}) {
HStack {
Image(systemName: "phone.fill")
Text(viewModel.joined ? "Leave" : "Join")
}
}
.buttonStyle(.borderedProminent)
.tint(viewModel.joined ? .red : .green)
}
}
}
struct ErrorView: View {
@EnvironmentObject var viewModel: TrtcViewModel
var body: some View {
HStack {
if viewModel.errCode != 0 {
Text(viewModel.errMsg)
}
}
}
}
}
struct ControlPanelView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = TrtcViewModel(debugPreview: true)
ZStack(alignment: .topTrailing) {
VStack {
Spacer()
.frame(maxHeight: .infinity)
ControlPanelView()
.background(Color.black.opacity(0.3))
.cornerRadius(8)
.environmentObject(viewModel)
}
.padding()
}
}
}
完成
これでSwiftUIで実装したiOS版のグループ会話アプリの完成です。
Jetpac Composeと同様に、TRTC SDKをViewModel化しロジックとViewを分離することができました。SwiftUIとJetpac Composeはほとんど同じ流儀で実装できるのでiOSとAndroid版などマルチプラットフォーム対応がしやすいと思います。
さいごに
TRTC SDKを使ってみた感想
結論としてTRTC SDKはどのプラットフォームでも、どのプログラミング言語でも、どのUIフレームワークでもすべて期待通り動作しました。
TRTC SDKはAndroid版とiOS版でクラスの構造やメソッドがほぼ互換のため、実装上の互換性が高く簡単にアプリに組み込むことができました。日本語のチュートリアルが充実しているのも良かったです。サンプルアプリのUIやサンプルコードのコメントが中国語なのは少々戸惑いましたが、コード自体は英語なのでそれほど支障は無いと思います。
まとめるとこんな感じです。
良いところ
・日本語のチュートリアルが充実している
・Android, iOSでクラスやメソッドが互換でそれぞれの言語仕様の流儀に沿っている
・各プラットフォーム用のミニマムなサンプルアプリが公開されている
・Web版のサンプルが端末アプリの動作確認用に便利ではかどる
戸惑ったところ
・アカウント登録に写真付き身分証明書のアップロードを求められる(最初のハードルが高い?)
・サンプルアプリのUIやコメントがバリバリの中国語(APIドキュメントやコード自体は英語だったので支障は無かったです)
作成物
今回作成したアプリのプロジェクト一式をGitHubで公開していますので参考になれば幸いです。
おしまい!