前回はSwiftUIの使ってSkyWayのビデオチャットを実装してみましたが、Androidアプリでも宣言的UIの世界を体験したくて、Jetpack Composeを使ってみました。SwiftUIで作成したアプリと同じ機能を、Jetpack Composeで実装すると果たしてどうなったでしょうか?
使用環境
- MacBook Air (M1, 2020)
- macOS Monterey 12.3.1
- Android Studio Chipmunk | 2021.2.1 for Mac (built on April 29, 2022)
- SkyWay Community Edition
Jetpack Composeの導入方法
Jetpack ComposeはGoogle製のAndroid向けネイティブアプリのUIを実装するためのツールキットです。と言っても正式にリリースされ、最新のAndroid Studioに内蔵されていますので、別途導入する必要はありません。
プロジェクトの作成から
-
Projetビューに切り替えて、skyway.aarをドラッグ&ドロップでapp/libsに配置します。
-
MainActivity.ktにimport文を追加します。
MainActivity.ktimport io.skyway.Peer.Browser.Canvas import io.skyway.Peer.Browser.MediaStream
-
マニフェストにアクセス権を追加します。
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" package="com.example.skywayp2ptest"> <!-- ここから --> <uses-feature android:name="android.hardware.camera" /> <uses-feature android:name="android.hardware.camera.autofocus" /> <uses-feature android:glEsVersion="0x00020000" android:required="true" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- ここまで --> <application (省略) </application> </manifest>
-
build.gradle(Module)に依存関係を追加します。JetpackのViewModelライブラリとSkyWayの依存関係をそれぞれ追加。(末尾の3行)
build.gradle(Module)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' 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" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0" // add implementation "androidx.compose.runtime:runtime-livedata:1.0.5" // add implementation fileTree(dir: 'libs', include: ['*.aar']) // add }
-
「Sync Now」をクリックします。
-
いったんビルドしてシミュレーターでアプリが起動するか確認します。
これで準備完了です。
実装ポイント
SkyWayのCanvas部品をコンポーザブルにする
SwiftUIではブリッジを介して宣言的UIに対応していない部品を使用することができましたが、Jetpack Composeも同様の手法で、ブリッジ用のコンポーザブルを作成します。AndroidView()のfactoryでCanvasクラスを関連付けて、コンポーザブルの更新時にupdateでメディアストリームのインスタンスを渡します。また、親コンポーザブルから外観を指定できるようにmofifierを引数化しました。
@Composable
fun VideoView(mediaStream: MediaStream, modifier: Modifier = Modifier) {
AndroidView(
factory = ::Canvas,
update = {
mediaStream.addVideoRenderer(it, 0)
},
modifier = modifier
)
}
ViewModelでUIを自動更新する
Jetpack Composeのデータバインディングの仕組みはSwiftUIのStateObject/Publishedモデルに似ています。まず、ViewModelとなるクラスをandroidx.lifecycle.ViewModel()から派生させ、監視したいプロパティをandroidx.lifecycle.MutableLiveData()で初期化します。MutableLiveDataはジェネリック表記ができるので、基本型とオブジェクト型のどちらでも使えます。
SwiftUIでは基本型はState、オブジェクト型はStateObjectと使い分けが必要でしたが、Jetpackではそのあたりを統一的に記述できます。
class MainViewModel : ViewModel() {
var remoteStream = MutableLiveData<MediaStream>()
var localStream = MutableLiveData<MediaStream>()
var localPeerId = MutableLiveData("")
var remotePeerId = MutableLiveData("")
:
}
View側(MainActivity.kt)でViewModelをインスタンス化するには、androidx.lifecycle.ViewModel.viewModels()を使用します。ViewModelの各プロパティはobserveAsStateを介してバインディングします。これで、ピアIDが払い出されたり、ビデオストリームが開始されると、自動的に画面が更新されます。
また、ViewModelのおかげで、デバイスを回転させてActivityが破棄〜再作成されても、ViewModeでSkyWayのコネクションは維持されるので、通話が途切れることはありません。
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SkyWayP2pDemoTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
MainView(viewModel, this)
}
}
}
}
}
@Composable
fun MainView(viewModel: MainViewModel, activity: MainActivity, modifier: Modifier = Modifier) {
val localPeerId = viewModel.localPeerId.observeAsState()
val remotelPeerId = viewModel.remotePeerId.observeAsState()
val localStream = viewModel.localStream.observeAsState()
val remoteStream = viewModel.remoteStream.observeAsState()
:
}
サンプルコード
コード全文です。Jetpack ComponsetはSwiftUIに比べて、データバインディングの指定やモディファイアの指定がやや冗長なところがありますが、コーディングスタイルはSwiftUIと良く似ています。
MainActivity.kt
コンポーザブルのレイアウトは、Columnが縦レイアウト(SwiftUIではVStack)で、Rowが横レイアウト(SwiftUIではHStack)です。SwiftUIと同じような感覚でコードベースでUIを書いていくことができます。
自分の映像と相手の映像をBox(SwiftUIではZStack)レイアウトで重ね合わせています。自分の映像を小窓表示にするために、BoxWithConstraintsで親のサイズを取得して、縦横それぞれ1/3のサイズを指定しています。SwiftUIではGeometryReaderでフレームのサイズを取得するのに似ています。
Jetpack Composeのレイアウトの指定方法については本家のサイトが図解で参考になると思います。
package com.example.skywayp2pdemo
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.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.skywayp2pdemo.ui.theme.SkyWayP2pDemoTheme
import io.skyway.Peer.Browser.Canvas
import io.skyway.Peer.Browser.MediaStream
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SkyWayP2pDemoTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
MainView(viewModel, this)
}
}
}
}
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.startPeer(this)
} else {
Toast.makeText(applicationContext, "アクセスを許可してください", Toast.LENGTH_LONG)
.show()
}
}
}
companion object {
const val PERMISSIONS_REQUEST_CODE = 1
}
}
@Composable
fun MainView(viewModel: MainViewModel, activity: MainActivity, modifier: Modifier = Modifier) {
val localPeerId = viewModel.localPeerId.observeAsState()
val remotelPeerId = viewModel.remotePeerId.observeAsState()
val localStream = viewModel.localStream.observeAsState()
val remoteStream = viewModel.remoteStream.observeAsState()
Column(modifier = modifier.padding(5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "自分: ",
modifier = Modifier
.weight(1.5f)
)
Text(
text = if (localPeerId.value != null) localPeerId.value!! else "",
modifier = Modifier
.weight(6.5f)
)
Button(
onClick = {
viewModel.setup(activity)
},
modifier = Modifier.weight(2f)
) {
Text("Init")
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "相手: ",
modifier = Modifier
.weight(1.5f)
)
var inputText by remember { mutableStateOf("") }
remotelPeerId.value?.let {
if (it.isNotEmpty()) {
inputText = it
}
}
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
placeholder = { Text("Peer ID") },
modifier = Modifier
.padding(5.dp)
.weight(6.5f)
)
Button(
onClick = {
viewModel.call(inputText)
},
modifier = Modifier.weight(2f)
) {
Text("Call")
}
}
Box(Modifier.fillMaxSize()) {
remoteStream.value?.let {
VideoView(it)
}
localStream.value?.let {
BoxWithConstraints(modifier = Modifier.align(Alignment.BottomEnd)) {
VideoView(
it,
modifier = Modifier
.width(maxWidth / 3)
.height(maxHeight / 3)
.padding(10.dp)
)
}
}
}
}
}
@Composable
fun VideoView(mediaStream: MediaStream, modifier: Modifier = Modifier) {
AndroidView(
factory = ::Canvas,
update = {
mediaStream.addVideoRenderer(it, 0)
},
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
SkyWayP2pDemoTheme {
val activity = MainActivity()
val viewModel: MainViewModel = MainViewModel()
MainView(viewModel, activity)
}
}
MainViewModel.kt
SkyWayのPeerを実装しています。ビデオストリームとピアIDはMutableLiveDataでUIとバインディングします。カメラとマイクのパーミンション要求もここに実装しています。
package com.example.skywayp2pdemo
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import io.skyway.Peer.Browser.MediaConstraints
import io.skyway.Peer.Browser.MediaStream
import io.skyway.Peer.Browser.Navigator
import io.skyway.Peer.MediaConnection
import io.skyway.Peer.OnCallback
import io.skyway.Peer.Peer
import io.skyway.Peer.PeerOption
class MainViewModel : ViewModel() {
var remoteStream = MutableLiveData<MediaStream>()
var localStream = MutableLiveData<MediaStream>()
var localPeerId = MutableLiveData("")
var remotePeerId = MutableLiveData("")
private var peer: Peer? = null
private var mediaConnection: MediaConnection? = null
fun setup(activity: MainActivity) {
if (!hasPermissions(activity)) {
requestPermissions(activity)
}
}
fun call(peerId: String) {
peer?.call(peerId, localStream.value).also {
mediaConnection = it
setupMediaCallBack()
}
}
fun startPeer(activity: MainActivity) {
val option = PeerOption()
option.key = API_KEY
option.domain = DOMAIN
option.debug = Peer.DebugLevelEnum.ALL_LOGS
this.peer = Peer(activity, option)
this.setupPeerCallBack()
}
private fun setupPeerCallBack() {
this.peer?.on(Peer.PeerEventEnum.OPEN, object : OnCallback {
override fun onCallback(p0: Any?) {
(p0 as? String)?.let { peerID ->
Log.d("debug", "peerID: $peerID")
startLocalStream()
this@MainViewModel.localPeerId.value = peerID
}
}
})
this.peer?.on(Peer.PeerEventEnum.ERROR, object : OnCallback {
override fun onCallback(p0: Any?) {
Log.d("debug", "peer error $p0")
}
})
this.peer?.on(Peer.PeerEventEnum.CALL, object : OnCallback {
override fun onCallback(p0: Any?) {
(p0 as? MediaConnection)?.let {
this@MainViewModel.mediaConnection = it
this@MainViewModel.setupMediaCallBack()
this@MainViewModel.mediaConnection?.answer(localStream.value)
}
}
})
}
private fun setupMediaCallBack() {
mediaConnection?.on(MediaConnection.MediaEventEnum.STREAM, object : OnCallback {
override fun onCallback(p0: Any?) {
(p0 as? MediaStream)?.let {
this@MainViewModel.remoteStream.value = it
this@MainViewModel.remotePeerId.value = it.peerId
}
}
})
mediaConnection?.on(MediaConnection.MediaEventEnum.CLOSE, object : OnCallback {
override fun onCallback(p0: Any?) {
this@MainViewModel.remoteStream.value = null
this@MainViewModel.remotePeerId.value = ""
}
})
}
private fun startLocalStream() {
val constraints = MediaConstraints()
constraints.maxWidth = 960
constraints.maxHeight = 540
constraints.cameraPosition = MediaConstraints.CameraPositionEnum.FRONT
Navigator.initialize(peer)
localStream.value = Navigator.getUserMedia(constraints)
}
private fun hasPermissions(context: Context) =
permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
private fun requestPermissions(activity: MainActivity) {
ActivityCompat.requestPermissions(activity, permissions, MainActivity.PERMISSIONS_REQUEST_CODE)
}
companion object {
val API_KEY = "API KEY"
val DOMAIN = "localhost"
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
}
}
完成イメージ
シミュレーターでの実行イメージです。自分から相手の発信と、相手から自分への着信に対応しています。
iOSのシミュレーターはカメラのテストは実機を使うしかなかったのでですが、Androidのシミュレーターはカメラも使える(擬似 or PCカメラ)のでGoodですね。
iOSとAndroidとも同じような手法で宣言的UI、MVVMでアプリを実装できるようになったのは素晴らしいですね。しかもサードパーティ製のライブラリをいっさい使わずに、iOSとAndroidの両方のアプリを効率よく開発できそうです。
これから普及期に入ってくると思いますので、引き続きウォッチしていきたいと思います!
P.S. SkyWay Betaも気になるところです。