今回の記事は、前回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を自動的に更新します。
実装のポイント
Androidプロジェクトの設定
前回のプロジェクト設定を参考にプロジェクトを作成します。
今回はViewModelを使用するための参照を追加します。また、マテリアルアイコンを使用するための参照も定義しておきます。
...
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と同じような間隔です。
@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側
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側
@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
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
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
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("")
}
}
動作イメージ
まとめ
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ライフを!
関連記事
- 【Zoom Video SDK】初心者による導入メモ - iOS/Swift編
- 【Zoom Video SDK】初心者による導入メモ - iOS/SwiftUI編
- 【Zoom Video SDK】初心者による導入メモ - Android/Kotlin編
- 【Zoom Video SDK】初心者による導入メモ - Android/Jetpack Compose編 (この記事)