0
Help us understand the problem. What are the problem?

posted at

updated at

【Zoom Video SDK】初心者による導入メモ - Android/Jetpack Compose編

今回の記事は、前回iOS/SwiftUIで実装したビデオチャットアプリをAndroid Jetpack Composeを使って再実装してみました。SwiftUIと同様の宣言的UIフレームであるJetpack Composeを使って同じように実装できたでしょうか?

使用環境

  • MacBook Air (M1, 2020) メモリ16GB
  • Android Studio Chipmunk | 2021.2.2 Patch 1
  • zoom-video-sdk-android-1.3.1

アプリの構造

前回のSwiftUI版とほぼ同等のレイアウトとモデルの実装でいけました。

レイアウトはiOS版と同様に円形部分がマイクミュートで参加しているメンバーで、四角のマス部分がミュートを解除し会話に参加しているメンバーです。画面の下部にテキストチャットを配置しています。

ViewModelから派生させたZoomViewModelクラスに、ZoomVideoSDKのリスナーを実装しています。このViewModelのインスタンスを各Viewの引数で渡すことで、Zoomセッションの状態の変化に連動してViewを自動的に更新します。
image.png

実装のポイント

Androidプロジェクトの設定

前回のプロジェクト設定を参考にプロジェクトを作成します。
今回はViewModelを使用するための参照を追加します。また、マテリアルアイコンを使用するための参照も定義しておきます。

build.gradle
...
dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
    implementation 'androidx.activity:activity-compose:1.3.1'

    // ViewModel
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0"
    implementation "androidx.compose.runtime:runtime-livedata:1.0.5"

    // composeアイコン
    implementation "androidx.compose.material:material-icons-extended:1.0.0"

    // ZOOM VIDEO SDK
    implementation project(':mobilertc')

    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_version"
    debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
    debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"

}

ビデオキャンバスをCommpose化

ZoomVideoSDKVideoCanvasをViewで使用できるようにAndroidViewでラップしたComposeを作成します。この辺りはSwiftUIのUIViewControllerRepresentableと同じような間隔です。

ZoomVideoView.kt(抜粋)
@Composable
fun ZoomVideoView(canvas: ZoomVideoSDKVideoCanvas, modifier: Modifier = Modifier) {
    AndroidView(
        factory = ::ZoomVideoSDKVideoView,
        update = { zoomVideoSDKVideoView ->
            canvas.subscribe(
                zoomVideoSDKVideoView,
                ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_PanAndScan
            )
        },
        modifier = modifier
    )
}

ArrayListの変更をComposeに検知させる

Zoomの参加メンバーやテキストチャットのメッセージをMutableLiveDataのArrayListに格納したところ、ArrayListの要素が変わってもCompose側で検知してくれない問題に遭遇しました。一旦新しいArrayListに詰め直すことで変更を検知してくれる場合もありましが、たまに機嫌を損ねることがあるので確実に検知されるように、ArrayListの要素数を別のプロパティに出して対処しました。

  • ViewModel側
ZoomViewModel.kt(抜粋)
    val messages = MutableLiveData<ArrayList<ChatMessage>>()
    val messageCount = MutableLiveData(0) // 要素数を外出し

    fun addMessage(userName: String, text: String) {
        val msg = ChatMessage(userName, text)
        messages.value?.add(msg)

        // 変更通知
        messageCount.value = messages.value?.size
    }
  • View側
ChatView.kt(抜粋)
@Composable
fun ChatView(viewModel: ZoomViewModel, modifier: Modifier = Modifier) {
    val messages = viewModel.messages.observeAsState()
    val messageCount = viewModel.messageCount.observeAsState()
 
    Column(modifier = modifier) {
        LazyColumn {
            if (messageCount.value!! >= 0) { // messageCountを監視
                items(messages) { message ->
                    MessageItemView(message)
                }
            }
        }
    }
}

あとは特につまずくことなく素直に実装できました。

サンプルコード

MainActivity.kt

MainActivity.kt
package com.example.zoomvideocompose

import android.Manifest
import android.content.Context
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.viewModels
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.example.zoomvideocompose.ui.theme.ZoomVideoComposeTheme

class MainActivity : ComponentActivity() {
    private val viewModel by viewModels<ZoomViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        if (!hasPermissions(this)) {
            requestPermissions(this)
        } else {
            viewModel.initZoomSDK(this)
        }

        setContent {
            ZoomVideoComposeTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    MainView(viewModel)
                }
            }
        }
    }

    private fun hasPermissions(context: Context) =
        permissions.all {
            ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
        }

    private fun requestPermissions(activity: MainActivity) {
        ActivityCompat.requestPermissions(activity, permissions, PERMISSIONS_REQUEST_CODE)
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == PERMISSIONS_REQUEST_CODE) {
            var grantedCount = 0
            for (result in grantResults) {
                if (result == PackageManager.PERMISSION_GRANTED) {
                    grantedCount++
                }
            }
            if (grantResults.size == grantedCount) {
                // ここに成功時の処理を書く
                viewModel.initZoomSDK(this)
            } else {
                Toast.makeText(applicationContext, "アクセスを許可してください", Toast.LENGTH_LONG)
                    .show()
            }
        }
    }

    companion object {
        const val PERMISSIONS_REQUEST_CODE = 1

        val permissions = arrayOf(
            Manifest.permission.CAMERA,
            Manifest.permission.RECORD_AUDIO
        )
    }
}

@Composable
fun MainView(viewModel: ZoomViewModel, modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        HeaderView(viewModel)

        Row(modifier = Modifier.weight(0.7f)) {
            LocalVideoView(
                viewModel,
                modifier = Modifier
                    .padding(start = 4.dp, end = 4.dp)
                    .aspectRatio(1f)
            )

            MutedVideoView(viewModel)
        }

        SpeakerVideoView(
            viewModel,
            modifier = Modifier
                .weight(4f)
        )

        ChatView(
            viewModel,
            modifier = Modifier
                .fillMaxSize()
                .weight(3f)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    ZoomVideoComposeTheme {
        val viewModel = ZoomViewModel()
        MainView(viewModel)
    }
}

ZoomVideoView.kt

ZoomVideoView.kt
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import us.zoom.sdk.ZoomVideoSDKVideoAspect
import us.zoom.sdk.ZoomVideoSDKVideoCanvas
import us.zoom.sdk.ZoomVideoSDKVideoView

@Composable
fun ZoomVideoView(canvas: ZoomVideoSDKVideoCanvas, modifier: Modifier = Modifier) {
    AndroidView(
        factory = ::ZoomVideoSDKVideoView,
        update = { zoomVideoSDKVideoView ->
            canvas.subscribe(
                zoomVideoSDKVideoView,
                ZoomVideoSDKVideoAspect.ZoomVideoSDKVideoAspect_PanAndScan
            )
        },
        modifier = modifier
    )
}

UserViewModel.kt

UserViewModel.kt
package com.example.zoomvideocompose

import android.annotation.SuppressLint
import android.app.Activity
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import us.zoom.sdk.*
import java.text.SimpleDateFormat
import java.util.*

class UserViewModel(user: ZoomVideoSDKUser) : ViewModel() {
    var isMuted = MutableLiveData(true)
    var isVideoOn = MutableLiveData(true)
    var isTalking = MutableLiveData(false)
    var zoomUser: ZoomVideoSDKUser

    init {
        zoomUser = user
    }

    fun getName(): String {
        return zoomUser.userName
    }

    fun mute() {
        ZoomVideoSDK.getInstance().audioHelper?.muteAudio(zoomUser)
    }

    fun unMute() {
        val mySelf = ZoomVideoSDK.getInstance().session.mySelf
        if (zoomUser == mySelf) {
            // 自分自身の場合、オーディオが開始していない場合は開始する
            ZoomVideoSDK.getInstance().audioHelper?.startAudio()
        }

        ZoomVideoSDK.getInstance().audioHelper?.unMuteAudio(zoomUser)
    }
}

class ChatMessage(userName: String, text: String) {
    var userName: String
    var text: String
    var date: String

    init {
        this.userName = userName
        this.text = text
        this.date = SimpleDateFormat("HH:mm").format(Date())
    }
}

class ZoomViewModel : ViewModel() {
    var isJoined = MutableLiveData(false)
    var sessionName = MutableLiveData("(未参加)")
    var mySelf = MutableLiveData<UserViewModel>()
    val users = MutableLiveData<ArrayList<UserViewModel>>()
    val mutedUsersCount = MutableLiveData(0)
    val speakerUsersCount = MutableLiveData(0)
    val messages = MutableLiveData<ArrayList<ChatMessage>>()
    val messageCount = MutableLiveData(0)
    private val myName = makeRandomString(6) // ランダムな名前を生成
    private val tag = "MyDebug#ZoomModel"

    init {
        users.value = ArrayList<UserViewModel>() // 空のリスト
        messages.value = ArrayList<ChatMessage>() // 空のリスト
    }

    fun addMessage(userName: String, text: String) {
        val msg = ChatMessage(userName, text)
        messages.value?.add(msg)

        // 変更通知
        messageCount.value = messages.value?.size
    }

    fun sendMessage(text: String) {
        val chatHelper = ZoomVideoSDK.getInstance().chatHelper
        chatHelper?.sendChatToAll(text)
    }

    fun join() {
        val audioOptions = ZoomVideoSDKAudioOption().apply {
            connect = true // Auto connect to audio upon joining
            mute = true // Auto mute audio upon joining
        }
        val videoOptions = ZoomVideoSDKVideoOption().apply {
            localVideoOn = true // Turn on local/self video upon joining
        }
        val params = ZoomVideoSDKSessionContext().apply {
            sessionName = "Session1"
            userName = myName
            sessionPassword = "123"
            token = "Your jwt"
            audioOption = audioOptions
            videoOption = videoOptions
        }

        val session = ZoomVideoSDK.getInstance().joinSession(params)
        if (session != null) {
            Log.d(tag, "joinSession")
            Log.d(tag, "  sessionName: $session.sessionName")
            Log.d(tag, "  sessionID: $session.sessionID")

            val videoHelper = ZoomVideoSDK.getInstance().videoHelper
            videoHelper.startVideo()
        }
    }

    fun leave() {
        val shouldEndSession = false
        ZoomVideoSDK.getInstance().leaveSession(shouldEndSession)
    }

    fun startVideo() {
        val videoHelper = ZoomVideoSDK.getInstance().videoHelper
        videoHelper?.startVideo()
    }

    fun stopVideo() {
        val videoHelper = ZoomVideoSDK.getInstance().videoHelper
        videoHelper?.stopVideo()
    }

    fun updateUsersCount() {
        var muters = 0
        var speakers = 0
        for (user in users.value!!) {
            if (user.isMuted.value!!) {
                muters += 1
            } else {
                speakers += 1
            }
        }
        mutedUsersCount.postValue(muters)
        speakerUsersCount.postValue(speakers)
    }

    fun initZoomSDK(activity: Activity) {
        val params = ZoomVideoSDKInitParams().apply {
            domain = "https://zoom.us" // Required
            logFilePrefix = "MyLogPrefix" // Optional for debugging
            enableLog = true // Optional for debugging
        }
        val sdk = ZoomVideoSDK.getInstance()
        val initResult = sdk.initialize(activity, params)
        if (initResult == ZoomVideoSDKErrors.Errors_Success) {
            // You have successfully initialized the SDK
            Log.d(tag, "initialize: successfully")

        } else {
            // Something went wrong, see error code documentation
            Log.d(tag, "initialize: failed")
        }

        val listener = object : ZoomVideoSDKDelegate {
            override fun onSessionJoin() {
                Log.d(tag, "onSessionJoin")

                isJoined.postValue(true)

                val session = ZoomVideoSDK.getInstance().session
                val user = UserViewModel(session.mySelf)
                sessionName.postValue(session.sessionName)
                mySelf.postValue(user)
            }

            override fun onSessionLeave() {
                Log.d(tag, "onSessionLeave")

                isJoined.postValue(false)
                sessionName.postValue("")
                users.value?.clear()
            }

            override fun onError(errorCode: Int) {
                when (errorCode) {
                    ZoomVideoSDKErrors.Errors_Session_Join_Failed -> { // 2003
                        // トークンの期限切れかも
                        Log.d(tag, "onError: Errors_Session_Join_Failed($errorCode)")
                    }

                    else -> {
                        Log.d(tag, "onError: $errorCode")
                    }
                }
            }

            @SuppressLint("WrongViewCast")
            override fun onUserJoin(
                userHelper: ZoomVideoSDKUserHelper?,
                userList: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserJoin")

//                // UI更新用に詰め直す
//                val newUsers = ArrayList<UserViewModel>()
//                if (users.value != null) {
//                    newUsers.addAll(users.value!!)
//                }

                userList?.forEach { user ->
                    Log.d(tag, "  userName: ${user.userName}")

                    val userModel = UserViewModel(user)
                    users.value?.add(userModel)
                }

//                users.value = newUsers
                updateUsersCount()
            }

            override fun onUserLeave(
                userHelper: ZoomVideoSDKUserHelper?,
                userList: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserLeave")

//                // UI更新用に詰め直す
//                val newUsers = ArrayList<UserViewModel>()
//                if (users.value != null) {
//                    newUsers.addAll(users.value!!)
//                }

                userList?.forEach { user ->
                    Log.d(tag, "  id: " + user.userID)
                    Log.d(tag, "  name: " + user.userName)

                    for (userModel in users.value!!) {
                        if (userModel.zoomUser == user) {
                            users.value?.remove(userModel)
                        }
                    }
                }

//                users.postValue(newUsers)
                updateUsersCount()
            }

            override fun onUserVideoStatusChanged(
                videoHelper: ZoomVideoSDKVideoHelper?,
                userList: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserVideoStatusChanged")

                if (userList != null) {
                    for (zoomUser in userList) {
                        val isOn = zoomUser.videoCanvas.videoStatus.isOn

                        for (user in users.value!!) {
                            if (user.zoomUser == zoomUser) {
                                user.isVideoOn.value = isOn
                            }
                        }

                        if (mySelf.value?.zoomUser == zoomUser) {
                            mySelf.value?.isVideoOn?.value = isOn
                            //mySelf.value?.isVideoOn?.postValue(isOn)
                        }
                        Log.d(tag, "  isOn: $isOn")
                    }
                }
            }

            override fun onUserAudioStatusChanged(
                audioHelper: ZoomVideoSDKAudioHelper?,
                userList: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserAudioStatusChanged")

                if (userList != null) {
                    for (zoomUser in userList) {
                        val isMuted = zoomUser.audioStatus.isMuted
                        Log.d(tag, "  isMuted: $isMuted")

                        for (user in users.value!!) {
                            if (user.zoomUser == zoomUser) {
                                user.isMuted.value = isMuted
                            }
                        }

                        if (mySelf.value?.zoomUser == zoomUser) {
                            mySelf.value?.isMuted?.postValue(isMuted)
                        }
                    }
                }

                updateUsersCount()
            }

            override fun onUserShareStatusChanged(
                shareHelper: ZoomVideoSDKShareHelper?,
                userInfo: ZoomVideoSDKUser?,
                status: ZoomVideoSDKShareStatus?
            ) {
                Log.d(tag, "onUserShareStatusChanged")
            }

            override fun onLiveStreamStatusChanged(
                liveStreamHelper: ZoomVideoSDKLiveStreamHelper?,
                status: ZoomVideoSDKLiveStreamStatus?
            ) {
                Log.d(tag, "onLiveStreamStatusChanged")
            }

            override fun onChatNewMessageNotify(
                chatHelper: ZoomVideoSDKChatHelper?,
                messageItem: ZoomVideoSDKChatMessage?
            ) {
                Log.d(tag, "onChatNewMessageNotify")

                if (messageItem != null) {
                    addMessage(
                        messageItem.senderUser.userName,
                        messageItem.content)
                }
            }

            override fun onUserHostChanged(
                userHelper: ZoomVideoSDKUserHelper?,
                userInfo: ZoomVideoSDKUser?
            ) {
                Log.d(tag, "onUserHostChanged")
            }

            override fun onUserManagerChanged(user: ZoomVideoSDKUser?) {
                Log.d(tag, "onUserManagerChanged")
            }

            override fun onUserNameChanged(user: ZoomVideoSDKUser?) {
                Log.d(tag, "onUserNameChanged")
            }

            override fun onUserActiveAudioChanged(
                audioHelper: ZoomVideoSDKAudioHelper?,
                list: MutableList<ZoomVideoSDKUser>?
            ) {
                Log.d(tag, "onUserActiveAudioChanged")

                if (list != null) {
                    for (zoomUser in list) {
                        val userName = zoomUser.userName
                        val isTalking = zoomUser.audioStatus.isTalking
                        Log.d(tag, "  user: ${userName}, isTalking: $isTalking")

                        for (user in users.value!!) {
                            if (user.zoomUser == zoomUser) {
                                user.isTalking.value = true
                            }
                        }

                        if (mySelf.value?.zoomUser == zoomUser) {
                            mySelf.value?.isTalking?.postValue(isTalking)
                        }
                    }
                }
            }

            override fun onSessionNeedPassword(handler: ZoomVideoSDKPasswordHandler?) {
                Log.d(tag, "onSessionNeedPassword")
            }

            override fun onSessionPasswordWrong(handler: ZoomVideoSDKPasswordHandler?) {
                Log.d(tag, "onSessionPasswordWrong")
            }

            override fun onMixedAudioRawDataReceived(rawData: ZoomVideoSDKAudioRawData?) {
                Log.d(tag, "onMixedAudioRawDataReceived")
            }

            override fun onOneWayAudioRawDataReceived(
                rawData: ZoomVideoSDKAudioRawData?,
                user: ZoomVideoSDKUser?
            ) {
                Log.d(tag, "onOneWayAudioRawDataReceived")
            }

            override fun onShareAudioRawDataReceived(rawData: ZoomVideoSDKAudioRawData?) {
                Log.d(tag, "onShareAudioRawDataReceived")
            }

            override fun onCommandReceived(sender: ZoomVideoSDKUser?, strCmd: String?) {
                Log.d(tag, "onCommandReceived")
            }

            override fun onCommandChannelConnectResult(isSuccess: Boolean) {
                Log.d(tag, "onCommandChannelConnectResult")
            }

            override fun onCloudRecordingStatus(status: ZoomVideoSDKRecordingStatus?) {
                Log.d(tag, "onCloudRecordingStatus")
            }

            override fun onHostAskUnmute() {
                Log.d(tag, "onHostAskUnmute")
            }

            override fun onInviteByPhoneStatus(
                status: ZoomVideoSDKPhoneStatus?,
                reason: ZoomVideoSDKPhoneFailedReason?
            ) {
                Log.d(tag, "onInviteByPhoneStatus")
            }

            override fun onMultiCameraStreamStatusChanged(
                status: ZoomVideoSDKMultiCameraStreamStatus?,
                user: ZoomVideoSDKUser?,
                videoPipe: ZoomVideoSDKRawDataPipe?
            ) {
                Log.d(tag, "onMultiCameraStreamStatusChanged")
            }

            override fun onMultiCameraStreamStatusChanged(
                status: ZoomVideoSDKMultiCameraStreamStatus?,
                user: ZoomVideoSDKUser?,
                canvas: ZoomVideoSDKVideoCanvas?
            ) {
                Log.d(tag, "onMultiCameraStreamStatusChanged")
            }
        }

        ZoomVideoSDK.getInstance().addListener(listener)
    }

    private fun makeRandomString(length: Int): String {
        val charset = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz0123456789"
        return (1..length)
            .map { charset.random() }
            .joinToString("")
    }
}

動作イメージ

image.png

まとめ

SwiftUIとほぼ同様の実装でJetpack Composeで実装することができました。Zoomのようなオンラインアプリではネットワーク越しの非同期な状態変化にUI側を同期して反映させることが重要ですが、MVVMモデルとの親和性が良いので、SwiftUIやJetpack Composeのような宣言的UIフレームワークをスムーズに利用することができました。

これまではZoomは一般のユーザーとして使う側の立場でしかありませんでしたが、Zoom Video SDKによって自分のアプリにZoom機能を組み込めるようになりました。Zoom SDKの初心者としては最初の取っ付きにくさがハードルでしたが...一度慣れてしまえば設計に一貫性があり、iOS版とAndroid版で仕様差がほとんどなく、SwiftUIやJetpack Composeとの相性もバッチリで使いやすいSDKでした。

皆さまも良いZoom SDKライフを!

関連記事

参考サイト

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
0
Help us understand the problem. What are the problem?