LoginSignup
4
1

More than 1 year has passed since last update.

image.png
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

グループビデオ通話アプリ

動作OS Android Android iOS iOS
動作イメージ
開発ツール Android Studio Xcode
開発言語 Kotlin Swift
UIフレームワーク Activity XML JetpackCompose UIKit SwiftUI
アプリ名 VideoSample2 VideoComposeSample VideoSample2 VideoSwiftUISample

開発環境

  • MacBook Air (M1, 2020)
  • macOS Ventura 13.0.1
  • Android Studio Dolphin | 2021.3.1 Patch 1
  • Xcode 14.1

今回作成したソースコード一式はこちらです。

以降はそれぞれのアプリの作成メモです。これからアプリを作成される方の参考になればうれしいです。

事前準備(Android/iOS共通)

  1. アカウントの登録
    SDKを使用するにはTencent Cloudのアカウントが必要です。クレジットカードの登録が必要ですが無料枠が用意されているため、開発で利用する分には無料枠に収まると思います。
    Tencent Cloud アカウント登録ガイダンス

  2. SDKシークレットの取得
    Tencent Cloudサイトのコンソールでアプリケーションを登録してSDKAppIDとSecretKeyを取得します。
    https://www.tencentcloud.com

    ナビゲーションヘッダーのコンソールから入ります。
    image.png
    コンソールを開くと製品の一覧が表示されます。この中から「Tencent Real-Time Communication」をクリックします。
    image.png
    「Create an application now」をクリックして入力を進めると、SDKAppIDとSecretKeyが払い出されるのでメモっておきます。
    image.png

  3. SDKのダウンロード
    登録の途中でSDKのダウンロードサイトに誘導されます。もし迷子になったらここからダウンロードできます。

これで準備OKです。

最小限のビデオ通話アプリ

練習のために必要最低限の実装でビデオ通話アプリを作ってみます。AndroidとiOSの両方で試してみました。

Android版

日本語のチュートリアルが用意されていますので、この手順どおりにやっていけば特にハマることなく進められると思います。

プロジェクト設定

  1. Empty Activityのプロジェクトを作成します。
    image.png

  2. SDKのデモサンプルはJavaベースだったので、今回はせっかくなのでKotlinでやってみます。
    image.png

  3. 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をクリックしてプロジェクトを同期します。いったんビルドしてシミュレーターでアプリが起動するか確認します。

  4. 混合規則の設定
    proguard-rules.proでTRTC SDKのクラスを追加します。

    proguard-rules.pro
    -keep class com.tencent.** { *; }
    
  5. アプリの権限を追加
    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>
    }
    
  6. 権限要求コードを追加
    Androidの場合はマニフェストに権限を追加しただけでは利用者向けにアクセスの許可を要求するUIは表示されないため、許可を要求するコードを追加します。

    activity_main.xml
    MainActivity.kt
    class 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をそれぞれ定義し、ルームに出入りするためのボタンを追加します。

  1. 自分の映像
    メイン画面に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>
    
    
  2. 相手の映像と名前
    画面の右上に適当なサイズで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
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
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はシンプルな構成のため、少ないコードで実装できます。

MainActivity.kt(抜粋)
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 - 全文
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になっているのでそのままだとルームに接続できません

プロジェクトの作成

  1. Xcodeでプロジェクトを作成します。

    • interface: Storybord
    • Language: Swift
      image.png
  2. SDKをプロジェクトフォルダーにコピー
    SDKはどこに置いてもビルドは成功するのですが、自分の環境ではSDKがプロジェクトフォルダーの外にあると実行時にランタイムエラーが発生したので、プロジェクトフォルダーの中にSDKをコピーしました。
    image.png

  3. ライブラリーの追加
    Build Phases > Link Binary with LibrariesからSDKと依存ライブラリーを追加します。たくさんあるのでひたすらポチポチ。
    image.png

    • 必要なライブラリ
      • 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 ← お忘れなく
  4. Embedを設定
    ライブラリーの追加に続けて、General > Frameworks,Libraries,and Embedded ContentでTXFFmpeg.xcframework、TXSoundTouch.xcframeworkをEmbed & Signに設定します。
    image.png

  5. プライバシー設定
    info > CustomiOS Target Properiesでマイクとカメラのアクセスを追加します。

    • Privacy - Microphone Usage Description
    • Privacy - Camera Usage Description
      image.png

これで準備OKです。ビルドして実機で動作するか確認しておきます。

ビルド環境がApple Mチップの場合はデフォルト設定だとシミュレータでは動作しません。iOSのシミュレータではカメラがサポートされていないのでシミュレータで動作させる必要はないと思いますが、もしMチップ上のシミュレータでデバッグする場合は、後半部のSwiftUIの章を参考にしてください。

画面レイアウトの作成

storybordで相手の映像を表示するViewをとルームに出入りするためのボタンを追加します。自分の映像は親Viewに直接表示するのでここでは定義不要です。
image.png

シグネチャー生成クラス

公式サンプルからコピペします。SDKAPPIDとSECRETKEYはTencent Cloudのサイトで取得したものを使用します。

TrtcUserSig.swift
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 - 全文
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): ?")
        }
    }
}

完成

これでiOS版ビデオ通話アプリの完成です。

グループビデオ通話アプリ

グループビデオ通話アプリは二種類のプログラミング方法で作成しました。
・命令的プログラミング
・宣言的UI

命令的プログラミング

命令的プログラミングでグループ通話を実装する方法はこれまでのやり方と同じのため実装手順は省略します。GitHubにソースコードを上げていますので参考にしてください。

  • Android版のグループ通話アプリ

  • iOS版のグループ通話アプリ

リモートユーザーの入室/退出、カメラのON/OFF、マイクのON/OFFなど様々なイベントが複数のユーザー毎に発生するため処理がかなり複雑になってしまいました。命令的プログラミングでロジックとUIを綺麗に分離して書くのは難しいですね。

宣言的UI

以降では宣言的UIフレームワークを使ったグループ通話を実装方法を説明します。下の図のように、TRTC SDKをViewModelでラップし適宜Viewを更新します。Jetpack ComposeでもSwiftUIでもどちらも同じ構造で実装できます。View側では状態ごとの処理分岐が不要なためスッキリした実装になったと思います。

Jetpack Composeでグループ通話を実装(Android)

プロジェクト設定

  1. プロジェクトの作成
    Empty Compose Activityを選択します。
    image.png
    後は先ほどと同じ手順で進めていきます。ここからは差分だけ記載します。

  2. CPUアーキテクチャと依存関係を追加(最初と同じ)

  3. 混合規則の設定(最初と同じ)

  4. アプリの権限を追加(最初と同じ)

  5. 権限要求コードを追加(最初と同じ)

  6. ライブラリーの追加
    デフォルトでは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メソッドを用意します。

TrtcVideoView.swift
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へ更新通知が飛びます。

TrtcViewModel.kt(抜粋)
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
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によって自動的に画面が更新されます。ロジックを書かなくて良いので楽ですね。
image.png

MainView.kt
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
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
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を使ったアプリの実装にもだいぶ慣れてきました。

プロジェクトの作成

  1. Xcodeでプロジェクトを作成します。
    - interface: SwiftUI
    - Language: Swift
    image.png

  2. SDKをプロジェクトフォルダーにコピー(最初と同じ)

  3. ライブラリーを追加(最初と同じ)

  4. Embed設定(最初と同じ)

  5. プライバシー設定(最初と同じ)

  6. Apple Mチップの場合のプレビュー対策
    TRTC SDKは開発環境がApple Mチップの場合はXcodeのデフォルト設定ではSwiftUIのプレビューやシミュレータが使用できません。
    Build Settings > Architectures > Excluded Architectures > Debug > Any iOS Simulator SDKに「arm64」を設定します。
    SwiftUIのプレビューが使えないとかなり不便なのでこれは必須設定と思います。
    image.png

TRTC VideoViewのラッパービュー

Jetpack Composeと同様にSWiftUIもUIViewに直接アクセスできないため、UIViewControllerRepresentableプロトコル経由でUIViewControllerのインスタンスを入手します。ローカル映像とリモート映像用のそれぞれのラッパービューを実装します。

TrtcLocalVideoView.swift
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とノリは同じです。

TrtcViewModel.swift
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をアクセスできるため引数でバケツリレーする必要がなくシンプルです。
image.png

ContentView.kt
ContentView.swift
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
RemoteUserView.swift
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
ControlPanelView.swift
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で公開していますので参考になれば幸いです。

おしまい!

参考

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1