2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SkyWayをJetpack Composeで実装する

Last updated at Posted at 2022-05-15

image.png
前回は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に内蔵されていますので、別途導入する必要はありません。

プロジェクトの作成から

  1. Empty Compose Activityを選択してプロジェクトを作成します。
    image.png

  2. Projetビューに切り替えて、skyway.aarをドラッグ&ドロップでapp/libsに配置します。
    image.png

  3. MainActivity.ktにimport文を追加します。

    MainActivity.kt
    import io.skyway.Peer.Browser.Canvas
    import io.skyway.Peer.Browser.MediaStream
    
  4. マニフェストにアクセス権を追加します。

    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>
    
  5. 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
    }
    
  6. 「Sync Now」をクリックします。

  7. いったんビルドしてシミュレーターでアプリが起動するか確認します。

これで準備完了です。

実装ポイント

SkyWayのCanvas部品をコンポーザブルにする

SwiftUIではブリッジを介して宣言的UIに対応していない部品を使用することができましたが、Jetpack Composeも同様の手法で、ブリッジ用のコンポーザブルを作成します。AndroidView()のfactoryでCanvasクラスを関連付けて、コンポーザブルの更新時にupdateでメディアストリームのインスタンスを渡します。また、親コンポーザブルから外観を指定できるようにmofifierを引数化しました。

MainActivity.kt
@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ではそのあたりを統一的に記述できます。

MainViewModel.kt(抜粋)
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のコネクションは維持されるので、通話が途切れることはありません。

MainActivity.kt(抜粋)
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のレイアウトの指定方法については本家のサイトが図解で参考になると思います。

MainActivity.kt
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とバインディングします。カメラとマイクのパーミンション要求もここに実装しています。

MainViewModel.kt
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ですね。

image.png

iOSとAndroidとも同じような手法で宣言的UI、MVVMでアプリを実装できるようになったのは素晴らしいですね。しかもサードパーティ製のライブラリをいっさい使わずに、iOSとAndroidの両方のアプリを効率よく開発できそうです。
これから普及期に入ってくると思いますので、引き続きウォッチしていきたいと思います!

P.S. SkyWay Betaも気になるところです。

参考サイト

関連サイト

2
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?