DroidKaigi2022で発表しました。Compose for Deskopで始めるAndroid開発効率化ツールの作成の資料になります。
https://droidkaigi.jp/2022/timetable/364461
Compose for Deskopで始めるAndroid開発効率化ツールの作成DroidKaigi 2022 Day01Yusuke Katsuragawa / YUMEMI Inc.1
View Slide
自己紹介2
自己紹介- 桂川 祐介- 株式会社ゆめみ- Androidエンジニア- Twitter : @kaleidot725- GitHub : kaleidot7253
アジェンダ4
アジェンダ● Compose for Desktopとは● Compose for Desktopでなぜツールを作るのか● Compose for Desktopでどのようにツールを作るかa. 作成したツールb. 開発環境c. プロジェクトのセットアップd. 依存関係のセットアップe. 機能実装● Compose for Desktopでツールを使ってみて5
Compose for Desktopとは6
Compose for Desktopとは?● Compose MultiplatformはJetBrainsが開発しているデスクトップ&Web向けUIフレームワーク● Compose MultiplatformはJetpack Composeをベースにして作られているため、Jetpack Composeと同じ宣言型でUIを構築できる● Compose Multiplatformのデスクトップ向けをCompose for Desktop、Web向けをCompose for Webと呼ぶ● Compose for DesktopはJetpack Composeと同じ書き方でデスクトップアプリを作れるUIフレームワークということ7
Compose for Desktopの特徴8
Compose for Desktopの特徴複数のデスクトッププラットフォームに対応Windows macOS Linux9
Compose for Desktopの特徴Jetpack Composeと互換性のあるUIコンポーネントの提供Row Column OutlineButton Button10
Compose for Desktopの特徴デスクトップアプリ向けの機能の提供メニューバーメニュートレイコンテキストメニュー通知11
Compose for Desktopの特徴Kotlin Multiplatformを利用して、Jetpack Composeとコード共有可能Android デスクトップ※ Jetpack Composeとコード共有したサンプルアプリ例 : https://bit.ly/3BgSV7g 12
Compose for Desktopでなぜツールを作ろうと思ったのか13
Compose for Desktopでなぜツールを作ろうと思ったのかAndroidエンジニアが日々の開発で繰り返しやる作業の手間を減らしたい● 開発者オプションの設定変更● ネットワークエラー時の動作確認● ダークテーマ・ライトテーマでの動作確認● テキスト入力での動作確認これらを補助するためのツールを作りたいがどのような言語・フレームワークで作るのがよいか?14
Compose for Desktopでなぜツールを作ろうと思ったのかCompose for DesktopとAndroidエンジニアの相性は良さそう● Jetpack Composeと同じ書き方でデスクトップアプリを開発できる- Jetpack Composeが扱える人の学習コストが低い● Compose for Desktopを学ぶことが、Jetpack Composeで活かせる- Compose for Desktopで学んだことが無駄になりにくい● Compose for Desktopでのツール開発が属人化しにくそう- Jetpack Composeを書ける人であればメンテできるためCompose for Desktopでツールを作ってみることに15
Compose for Desktopでどのようにツールを作るか16
作成したツール17
作成したツール端末選択メニューリストメニュー画面※ 作成したツールのリポジトリ : https://github.com/kaleidot725/AdbPad/tree/release/droidkaigi2022 18
作成したツールメニュー画面 コマンド● 開発者オプション設定● ダークテーマON / OFF● Wi-Fi ON / OFF● データ通信 ON / OFF19
作成したツールメニュー画面 テキスト入力● 入力テキストの保存/削除● 入力テキストの送信20
作成したツールメニュー画面 スクリーンショット● 現在の画面の撮影● テーマ毎に現在の画面の撮影21
今回は作成したツールの実装を解説するのは難しいのでどのように開発したかサンプルを用いて解説します22
開発環境23
開発環境IntelliJ IDEA Community 2022.2● インストールするだけでCompose for Desktopで開発できる● JetBrainsが開発しているのでAndroid Studioと操作感に違いはない24
開発環境Compose Multiplatform IDE Support● Compose for Desktopでプレビューを可能にするIDEAプラグイン● Stable版は1.1.1ですがIDEA 2022.2と互換性が無いのでAlpha版の1.2.0を利用25
プロジェクトのセットアップ26
プロジェクトのセットアップ「New Project」からプロジェクトを新規作成を開始する27
プロジェクトのセットアップ「Compose Multiplatform」を選択し「Create」でプロジェクトを作成するデスクトップ向けなのでConfigurationをSingleplatform, PlatformをDesktopにするCompose for DesktopはJDK11以上をサポートしているのでJDK11以上を設定する必要がある28
プロジェクトのセットアップGradleプロジェクト作成され、Gradleタスクからアプリが起動するCompose for Desktopのタスク一覧が表示される。runを実行するとアプリケーションのビルド&実行ができる29
依存関係のセットアップ30
依存関係のセットアッププロジェクトで利用する以下の依存関係を追加する依存関係 バージョン 備考Compose for Desktop v1.1.1 ● Compose for Desktopのv1.2.0はアルファ版なのでv1.1.1を利用するKotlin v1.6.10 ● Compose for Desktopに合わせv1.6.10を利用するKotlin Coroutines v1.6.0Kotlin Serialization v1.3.1 ● アプリの設定ファイル(json)の書き込みに利用するAdam v0.4.5 ● ADBのヘルパーライブラリ31
依存関係のセットアップ今回のプロジェクトでは以下の方針で依存関係を整理&管理する● GradleプロジェクトのVersion Catalogを利用する● Gradleファイルに依存関係の情報を記載し整理するGradleファイル 記載内容gradle.properties 依存関係のバージョン情報setting.gradle.kts 依存関係のアーティファクト情報build.gradle.kts アプリのモジュールで扱うアーティファクト情報32
kotlin.code.style=officialkotlin.version=1.6.10kotlin.coroutines=1.6.0kotlin.serialization=1.3.1agp.version=4.2.2compose.version=1.1.1library.ktlint.plugin=10.3.0library.adam=0.4.5library.junit=5.9.0アーティファクトのバージョンを定義する依存関係のセットアップgradle.properties33
dependencyResolutionManagement {versionCatalogs {create("libs") {val adamVer = extra["library.adam"] as Stringlibrary("adam", "com.malinskiy.adam:adam:$adamVer")val coroutinesVer = extra["kotlin.coroutines"] as Stringlibrary("kotlin-coroutines", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVer")val serializationVer = extra["kotlin.serialization"] as Stringlibrary("kotlin-serialization", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVer")val junitVer = extra["library.junit"] as Stringlibrary("junit5", "org.junit.jupiter:junit-jupiter:$junitVer")}}}pluginManagement {repositories { … }plugins {kotlin("multiplatform").version(extra["kotlin.version"] as String)id("org.jetbrains.compose").version(extra["compose.version"] as String)kotlin("plugin.serialization").version(extra["kotlin.version"] as String)id("org.jlleitschuh.gradle.ktlint").version(extra["library.ktlint.plugin"] as String)}}利用するプラグインを宣言する依存関係のセットアップsetting.gradle.kts34
dependencyResolutionManagement {versionCatalogs {create("libs") {val adamVer = extra["library.adam"] as Stringlibrary("adam", "com.malinskiy.adam:adam:$adamVer")val coroutinesVer = extra["kotlin.coroutines"] as Stringlibrary("kotlin-coroutines", "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVer")val serializationVer = extra["kotlin.serialization"] as Stringlibrary("kotlin-serialization", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVer")val junitVer = extra["library.junit"] as Stringlibrary("junit5", "org.junit.jupiter:junit-jupiter:$junitVer")}}}pluginManagement {repositories { … }plugins {kotlin("multiplatform").version(extra["kotlin.version"] as String)id("org.jetbrains.compose").version(extra["compose.version"] as String)kotlin("plugin.serialization").version(extra["kotlin.version"] as String)id("org.jlleitschuh.gradle.ktlint").version(extra["library.ktlint.plugin"] as String)}}依存関係のセットアップsetting.gradle.ktsVersioni Catalogを使ってライブラリのアーティファクトを宣言する 35
kotlin {sourceSets {val jvmMain by getting {dependencies {implementation(compose.desktop.currentOs)implementation(compose.material)implementation(compose.materialIconsExtended)implementation(libs.adam)implementation(libs.kotlin.coroutines)implementation(libs.kotlin.serialization)}}}}plugins {kotlin("multiplatform")id("org.jetbrains.compose")kotlin("plugin.serialization")id("org.jlleitschuh.gradle.ktlint")}setting.gradle.ktsに定義したアーティファクトを参照する依存関係のセットアップbuild.gradle.kts36
kotlin {sourceSets {val jvmMain by getting {dependencies {implementation(compose.desktop.currentOs)implementation(compose.material)implementation(compose.materialIconsExtended)implementation(libs.adam)implementation(libs.kotlin.coroutines)implementation(libs.kotlin.serialization)}}}}plugins {kotlin("multiplatform")id("org.jetbrains.compose")kotlin("plugin.serialization")id("org.jlleitschuh.gradle.ktlint")}setting.gradle.ktsに定義したアーティファクトを参照する依存関係のセットアップbuild.gradle.kts37
kotlin {sourceSets {val jvmMain by getting {dependencies {implementation(compose.desktop.currentOs)implementation(compose.material)implementation(compose.materialIconsExtended)implementation(libs.adam)implementation(libs.kotlin.coroutines)implementation(libs.kotlin.serialization)}}}}plugins {kotlin("multiplatform")id("org.jetbrains.compose")kotlin("plugin.serialization")id("org.jlleitschuh.gradle.ktlint")}依存関係のセットアップbuild.gradle.ktsあらかじめCompose Pluginが予め依存関係の定義を用意してくれています。このように記述するとlocal.propertiesに定義したバージョンで依存関係をセットアップされる。※ Compose for Desktopの依存関係の定義 : https://bit.ly/3L2wnKN 38
機能実装 - ADBと連携する39
機能実装 - ADBと連携するADBとは● Android Debug Bridge(adb)は、デバイスと通信するための多用途のコマンドライン ツール● ADBのshellコマンドを利用することでAndroid OSのシェルを通して端末を操作できる● その他にもアプリのインストールやスクリーンキャプチャなどの操作が実行できる40
機能実装 - ADBと連携するAdamとは● AdamはKotlinで作られたADBのヘルパーライブラリ● AdamからADBを操作し、端末一覧取得・シェル実行・スクリーンショット取得などができる● Kotlin Coroutinesにも対応しているので簡単に非同期処理を実行できる● 今回はこのAdamというライブラリを利用しADBを実行します41
機能実装 - ADBと連携するAdamの使い方StartAdbInteractor().execute()val adb = AndroidDebugBridgeClientFactory().build()val output = adb.execute(request = ShellCommandRequest("echo hello"),serial = "emulator-5554")①ADBサーバーを開始する42
機能実装 - ADBと連携するAdamの使い方StartAdbInteractor().execute()val adb = AndroidDebugBridgeClientFactory().build()val output = adb.execute(request = ShellCommandRequest("echo hello"),serial = "emulator-5554")②ADBクライアントを作成する43
機能実装 - ADBと連携するAdamの使い方StartAdbInteractor().execute()val adb = AndroidDebugBridgeClientFactory().build()val output = adb.execute(request = ShellCommandRequest("echo hello"),serial = "emulator-5554")③ADBクライアントからリクエストを送信する44
機能実装 - 端末一覧をADBから取得し表示する端末リスト表示※ 端末リスト表示のサンプルコード : https://github.com/kaleidot725/AdbPad/tree/release/droidkaigi2022-sample1 45
機能実装 - 端末一覧をADBから取得し表示するレイヤ 役割 説明Model Data ● 機能を実現するデータUseCase ● 機能を実現するための処理View UI Component ● UIコンポーネントの定義State ● UIコンポーネントの状態StateHolder ● UI ComponentとStateをつなぎ合わせる● UI ComponentにStateを通知する● UI ComponentのEventに反応して処理するViewとModelの2つのレイヤに大きく分けて実装を進める46
data class Device(val serial: String, val state: DeviceState)端末情報についてはAdamで定義されているデータクラスをそのまま使う機能実装 - 端末一覧をADBから取得し表示するData47
機能実装 - 端末一覧をADBから取得し表示するclass GetDevicesFlowUseCase {operator fun invoke(coroutineScope: CoroutineScope): Flow> {val adbClient = AndroidDebugBridgeClientFactory().build()val receiveChannel = adbClient.execute(request = AsyncDeviceMonitorRequest(),scope = coroutineScope)return receiveChannel.receiveAsFlow()}}UseCase48端末一覧を購読するためのFlowを作成する
機能実装 - 端末一覧をADBから取得し表示するclass GetDevicesFlowUseCase {operator fun invoke(coroutineScope: CoroutineScope): Flow> {val adbClient = AndroidDebugBridgeClientFactory().build()val receiveChannel = adbClient.execute(request = AsyncDeviceMonitorRequest(),scope = coroutineScope)return receiveChannel.receiveAsFlow()}}UseCaseAdbClientを生成する49
機能実装 - 端末一覧をADBから取得し表示するclass GetDevicesFlowUseCase {operator fun invoke(coroutineScope: CoroutineScope): Flow> {val adbClient = AndroidDebugBridgeClientFactory().build()val receiveChannel = adbClient.execute(request = AsyncDeviceMonitorRequest(),scope = coroutineScope)return receiveChannel.receiveAsFlow()}}UseCaseAsyncDeviceMonitorRequestで端末一覧を通知してくれるChannelを作成する50
機能実装 - 端末一覧をADBから取得し表示するclass GetDevicesFlowUseCase {operator fun invoke(coroutineScope: CoroutineScope): Flow> {val adbClient = AndroidDebugBridgeClientFactory().build()val receiveChannel = adbClient.execute(request = AsyncDeviceMonitorRequest(),scope = coroutineScope)return receiveChannel.receiveAsFlow()}}UseCaseChannelよりもFlowのほうが扱いやすいのでreceiveAsFlowで変換する51
@Composablefun DropDownDeviceMenu(selectedDevice: Device?,devices: List,onSelectDevice: (Device) -> Unit,modifier: Modifier = Modifier) {var expanded by remember { mutableStateOf(false) }Box(modifier) {// 選択中のデバイス表示Box(...) { ... }// デバイス一覧表示DropdownMenu(...) { ... }}}機能実装 - 端末一覧をADBから取得し表示するUI Component選択している端末、接続している端末一覧を表示できるようにする52
@Composablefun DropDownDeviceMenu(selectedDevice: Device?,devices: List,onSelectDevice: (Device) -> Unit,modifier: Modifier = Modifier) {var expanded by remember { mutableStateOf(false) }Box(modifier) {Box(modifier = Modifier.clickable {if (!expanded && devices.isNotEmpty()) expanded = true}) {Text(...)Icon(...)}DropdownMenu(...) { ... }}}機能実装 - 端末一覧をADBから取得し表示するUI Component53選択された端末をクリックでDropdownMenuを表示できるようにする
@Composablefun DropDownDeviceMenu(selectedDevice: Device?,devices: List,onSelectDevice: (Device) -> Unit,modifier: Modifier = Modifier) {var expanded by remember { mutableStateOf(false) }Box(modifier) {Box(...) {Text(text = selectedDevice?.serial ?: StringRes.NOT_FOUND_DEVICE,modifier = Modifier.fillMaxWidth())Icon(...)}DropdownMenu(...) { ... }}}機能実装 - 端末一覧をADBから取得し表示するUI Component54選択された端末名を表示する
@Composablefun DropDownDeviceMenu(selectedDevice: Device?,devices: List,onSelectDevice: (Device) -> Unit,modifier: Modifier = Modifier) {var expanded by remember { mutableStateOf(false) }Box(modifier) {Box(...) {Text(...)Icon(imageVector = Icons.Filled.ArrowDropDown,contentDescription = "Device DropDown Icon",modifier = Modifier.align(Alignment.CenterEnd))}DropdownMenu(...) { ... }}}機能実装 - 端末一覧をADBから取得し表示するUI Component55選択できることがわかるようにアイコンを表示する
@Composablefun DropDownDeviceMenu(selectedDevice: Device?,devices: List,onSelectDevice: (Device) -> Unit,modifier: Modifier = Modifier) {var expanded by remember { mutableStateOf(false) }Box(modifier) {Box(...)DropdownMenu(expanded = expanded,onDismissRequest = { expanded = false },modifier = Modifier.fillMaxWidth()) { ... }}}機能実装 - 端末一覧をADBから取得し表示するUI Component56接続している端末リストを表示するためにDropdownMenuを定義する。expandedを渡して開閉状態を同期してやる。
@Composablefun DropDownDeviceMenu(selectedDevice: Device?,devices: List,onSelectDevice: (Device) -> Unit,modifier: Modifier = Modifier) {var expanded by remember { mutableStateOf(false) }Box(modifier) {Box(...)DropdownMenu(...) {devices.forEach { device ->DropdownMenuItem(...) {Text(text = device.serial)}}}}}機能実装 - 端末一覧をADBから取得し表示するUI Component57接続している端末の名称を表示する
@Composablefun DropDownDeviceMenu(selectedDevice: Device?,devices: List,onSelectDevice: (Device) -> Unit,modifier: Modifier = Modifier) {var expanded by remember { mutableStateOf(false) }Box(modifier) {Box(...)DropdownMenu(...) {devices.forEach { device ->DropdownMenuItem(onClick = {onSelectDevice(device)expanded = false}) {...}}}}}機能実装 - 端末一覧をADBから取得し表示するUI Component58接続している端末リストを選択する処理を実装する
data class MainState(val devices: List = emptyList(),val selectedDevice: Device? = null,)リストに渡すための情報をまとめる機能実装 - 端末一覧をADBから取得し表示するState59
機能実装 - 端末一覧をADBから取得し表示するStateHolderclass MainStateHolder(val getDevicesFlow: GetDevicesFlowUseCase = GetDevicesFlowUseCase()) {︙val state: StateFlow= combine(devices, selectedDevice) { devices, selected ->MainState(devices = devices, selectedDevice = selected)}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), MainState())︙fun setup() {...}fun selectDevice(device: Device) { ... }fun dispose() { ... }}Stateの変化をUI Componentに通知する、また画面表示時、コマンド実行時、画面非表示時に処理を実行する、というのをできるようにするためのStateHolderを作成する60
機能実装 - 端末一覧をADBから取得し表示するStateHolderclass MainStateHolder(val getDevicesFlow: GetDevicesFlowUseCase = GetDevicesFlowUseCase()) {private val coroutineContext get()= SupervisorJob() + Dispatchers.Main + Dispatchers.IOprivate val coroutineScope: CoroutineScope= CoroutineScope(coroutineContext)private val devices: MutableStateFlow>= MutableStateFlow(emptyList())private val selectedDevice: MutableStateFlow= MutableStateFlow(null)val state: StateFlow= combine(devices, selectedDevice) { devices, selected ->MainState(devices = devices, selectedDevice = selected)}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), MainState())fun setup() {...}fun selectDevice(device: Device) { ... }fun dispose() { ... }}StateをUI Componentに通知する仕組みを実装する。端末一覧と選択端末が更新されたらStateを再生成して通知する61
機能実装 - 端末一覧をADBから取得し表示するStateHolderclass MainStateHolder(val getDevicesFlow: GetDevicesFlowUseCase = GetDevicesFlowUseCase()) {︙private val devices: MutableStateFlow>= MutableStateFlow(emptyList())private val selectedDevice: MutableStateFlow= MutableStateFlow(null)︙fun setup() {coroutineScope.launch {StartAdbInteractor().execute()getAndroidDevicesFlowUseCase(coroutineScope).collect {devices.value = itval hasNotValue = !it.contains(selectedDevice.value)if (hasNotValue) selectedDevice.value = it.firstOrNull()}}}fun selectDevice(device: Device) { ... }fun dispose() { ... }}画面表示時の処理を実装する。adbサーバーを起動しFlowをcollectして端末一覧を更新する62
機能実装 - 端末一覧をADBから取得し表示するStateHolderclass MainStateHolder(val getDevicesFlow: GetDevicesFlowUseCase = GetDevicesFlowUseCase()) {︙ private val selectedDevice: MutableStateFlow= MutableStateFlow(null)︙fun setup() { ... }fun selectDevice(device: Device) {selectedDevice.value = device}fun dispose() { ... }}選択時にデバイス選択状況を更新する63
機能実装 - 端末一覧をADBから取得し表示するStateHolderclass MainStateHolder(val getDevicesFlow: GetDevicesFlowUseCase = GetDevicesFlowUseCase()) {︙private val coroutineContext get()= SupervisorJob() + Dispatchers.Main + Dispatchers.IOprivate val coroutineScope: CoroutineScope= CoroutineScope(coroutineContext)︙fun setup() {...}fun selectDevice(device: Device) { ... }fun dispose() {coroutineScope.cancel()}}画面非表示時に実行されているコルーチンをキャンセルする64
fun main() = application {Window(title = StringRes.WINDOW_TITLE, onCloseRequest = ::exitApplication) {val stateHolder by remember { mutableStateOf(MainStateHolder()) }DisposableEffect(stateHolder) {stateHolder.setup()onDispose { stateHolder.dispose() }}val state by stateHolder.state.collectAsState()DropDownDeviceMenu(devices = state.devices,selectedDevice = state.selectedDevice,onSelectDevice = { stateHolder.selectDevice(it) },modifier = Modifier.wrapContentSize())}}機能実装 - 端末一覧をADBから取得し表示するmainCompose for Desktopのメイン関数で、今まで作成したクラスを使ってアプリを組み立てる 65
fun main() = application {Window(title = StringRes.WINDOW_TITLE, onCloseRequest = ::exitApplication) {val stateHolder by remember { mutableStateOf(MainStateHolder()) }DisposableEffect(stateHolder) {stateHolder.setup()onDispose { stateHolder.dispose() }}val state by stateHolder.state.collectAsState()DropDownDeviceMenu(devices = state.devices,selectedDevice = state.selectedDevice,onSelectDevice = { stateHolder.selectDevice(it) },modifier = Modifier.wrapContentSize())}}機能実装 - 端末一覧をADBから取得し表示するmain画面表示時にsetupを実行するまた画面非表示にdisposeを実行する66
fun main() = application {Window(title = StringRes.WINDOW_TITLE, onCloseRequest = ::exitApplication) {val stateHolder by remember { mutableStateOf(MainStateHolder()) }DisposableEffect(stateHolder) {stateHolder.setup()onDispose { stateHolder.dispose() }}val state by stateHolder.state.collectAsState()DropDownDeviceMenu(devices = state.devices,selectedDevice = state.selectedDevice,onSelectDevice = { stateHolder.selectDevice(it) },modifier = Modifier.wrapContentSize())}}機能実装 - 端末一覧をADBから取得し表示するmainstateをcollectしてDropDownDeviceMenuに渡す、DropDownDeviceMenuからイベントをstateHolderに伝える67
ADBと連携する - 端末一覧を取得し表示する68
機能実装 - ADBでコマンドを実行し端末を操作する69
機能実装 - ADBでコマンドを実行し端末を操作するコマンド実行画面※ コマンド実行画面のサンプルコード : https://github.com/kaleidot725/AdbPad/tree/release/droidkaigi2022-sample2 70
機能実装 - ADBでコマンドを実行し端末を操作する分類 役割 説明Model Data ● 機能を実現するデータUseCase ● 機能を実現するための処理View UI Component ● UIコンポーネントの定義State ● UIコンポーネントの状態StateHolder ● UI ComponentとStateをつなぎ合わせる同じくこの構造を用いて作成を進める71
sealed class Command(val title: String,val details: String,val requests: List) {object DarkThemeOn : Command("ダークテーマON","端末のダークテーマ設定をONにします",listOf(ShellCommandRequest("cmd uimode night yes")))object WifiAndDataOn : Command("Wi-Fi&データ通信 ON","端末のWi-Fi設定とデータ通信設定の両方をONにします",listOf(ShellCommandRequest("svc wifi enable"),ShellCommandRequest("svc data enable")))︙}機能実装 - ADBでコマンドを実行し端末を操作するDataコマンドのタイトルと説明を定義しておく。またこのコマンドで実行したいAdamのリクエストにして定義しておく。72
sealed class Command(val title: String,val details: String,val requests: List) {object DarkThemeOn : Command("ダークテーマON","端末のダークテーマ設定をONにします",listOf(ShellCommandRequest("cmd uimode night yes")))object WifiAndDataOn : Command("Wi-Fi&データ通信 ON","端末のWi-Fi設定とデータ通信設定の両方をONにします",listOf(ShellCommandRequest("svc wifi enable"),ShellCommandRequest("svc data enable")))︙}機能実装 - ADBでコマンドを実行し端末を操作するDataダークテーマONにするためのコマンド73
sealed class Command(val title: String,val details: String,val requests: List) {object DarkThemeOn : Command("ダークテーマON","端末のダークテーマ設定をONにします",listOf(ShellCommandRequest("cmd uimode night yes")))object WifiAndDataOn : Command("Wi-Fi&データ通信 ON","端末のWi-Fi設定とデータ通信設定の両方をONにします",listOf(ShellCommandRequest("svc wifi enable"),ShellCommandRequest("svc data enable")))︙}機能実装 - ADBでコマンドを実行し端末を操作するDataもし複数リクエストがある場合にこのように2つのリクエストを定義する74
機能実装 - ADBでコマンドを実行し端末を操作するclass GetCommandListUseCase {operator fun invoke(): List {return listOf(Command.DarkThemeOn,Command.WifiAndDataOn)}}UseCase定義したコマンド情報をリストで返す75
機能実装 - ADBでコマンドを実行し端末を操作するclass ExecuteCommandUseCase {suspend operator fun invoke(serial: String?, command: Command): Boolean {return withContext(Dispatchers.IO) {val adbClient = AndroidDebugBridgeClientFactory().build()command.requests.forEach { request ->val result = adbClient.execute(request, serial)if (result.exitCode != 0) return@withContext false}return@withContext true}}}UseCase定義したコマンドをAdamで実行する76
機能実装 - ADBでコマンドを実行し端末を操作するclass ExecuteCommandUseCase {suspend operator fun invoke(serial: String?, command: Command): Boolean {return withContext(Dispatchers.IO) {val adbClient = AndroidDebugBridgeClientFactory().build()command.requests.forEach { request ->val result = adbClient.execute(request, serial)if (result.exitCode != 0) return@withContext false}return@withContext true}}}UseCaseAdbClientを生成する77
機能実装 - ADBでコマンドを実行し端末を操作するclass ExecuteCommandUseCase {suspend operator fun invoke(serial: String?, command: Command): Boolean {return withContext(Dispatchers.IO) {val adbClient = AndroidDebugBridgeClientFactory().build()command.requests.forEach { request ->val result = adbClient.execute(request, serial)if (result.exitCode != 0) return@withContext false}return@withContext true}}}UseCaseリクエストを順番に実行し、途中で失敗したらfalse、そうでなければtrueを返すようにする78
機能実装 - ADBでコマンドを実行し端末を操作するUI Component@Composablefun CommandList(commands: List,onExecute: (Command) -> Unit,modifier: Modifier = Modifier) {Box(modifier = modifier) {Column(...) {commands.forEach { command ->Card(...) { ... }}}}}受け取ったコマンドをリストで表示する、また選択したコマンドを実行できるようにする79
機能実装 - ADBでコマンドを実行し端末を操作するUI Component@Composablefun CommandList(commands: List,onExecute: (Command) -> Unit,modifier: Modifier = Modifier) {Box(modifier = modifier) {Column(...) {commands.forEach { command ->Card(...) { ... }}}}}ColumnとCardを利用してコマンドリストを作成する80
@Composablefun CommandList(commands: List,onExecute: (Command) -> Unit,modifier: Modifier = Modifier) {Box(modifier = modifier) {Column(...) {commands.forEach { command ->Card(...) {Row(...) {Column(modifier = Modifier.weight(0.9f, true)) {Text(text = command.title)Text(text = command.details)}Button(...) { ... }}}}}}}機能実装 - ADBでコマンドを実行し端末を操作するUI Componentコマンドのタイトルと説明を並べて表示する81
@Composablefun CommandList(commands: List,onExecute: (Command) -> Unit,modifier: Modifier = Modifier) {Box(modifier = modifier) {Column(...) {commands.forEach { command ->Card(...) {Row(...) {Column(...) { ... }Button(onClick = { onExecute(command) }) {Text(text = StringRes.EXECUTE)}}}}}}}機能実装 - ADBでコマンドを実行し端末を操作するUI ComponentStateHolderにコマンド実行を依頼できるようにする82
data class MainState(val commands: List = emptyList(),)機能実装 - ADBでコマンドを実行し端末を操作するStateリストに渡すための情報をまとめる83
機能実装 - ADBでコマンドを実行し端末を操作するStateHolderclass MainStateHolder(val getCommandListUseCase: GetCommandListUseCase = GetCommandListUseCase(),val executeCommandUseCase: ExecuteCommandUseCase = ExecuteCommandUseCase(),) {︙val state: StateFlow = commands.map {MainState(commands = it)}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), MainState())︙fun setup() {...}fun executeCommand(command: Command) { ... }fun dispose() { ... }}Stateの変化をUI Componentに通知する、また画面表示時、コマンド実行時、画面非表示時に処理を実行する、というのをできるようにするためのStateHolderを作成する84
機能実装 - ADBでコマンドを実行し端末を操作するStateHolderclass MainStateHolder(val getCommandListUseCase: GetCommandListUseCase = GetCommandListUseCase(),val executeCommandUseCase: ExecuteCommandUseCase = ExecuteCommandUseCase(),) {private val coroutineContext get()= SupervisorJob() + Dispatchers.Main + Dispatchers.IOprivate val coroutineScope: CoroutineScope= CoroutineScope(coroutineContext)private val commands: MutableStateFlow>= MutableStateFlow(emptyList())val state: StateFlow = commands.map {MainState(commands = it)}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(), MainState())fun setup() {...}fun executeCommand(command: Command) { ... }fun dispose() { ... }}StateをUI Componentに通知する仕組みを実装する。コマンド一覧が更新されたらStateを再生成して通知する85
機能実装 - ADBでコマンドを実行し端末を操作するStateHolderclass MainStateHolder(val getCommandListUseCase: GetCommandListUseCase = GetCommandListUseCase(),val executeCommandUseCase: ExecuteCommandUseCase = ExecuteCommandUseCase(),) {︙private val commands: MutableStateFlow>= MutableStateFlow(emptyList())︙fun setup() {coroutineScope.launch { StartAdbInteractor().execute() }commands.value = getCommandListUseCase()}fun executeCommand(command: Command) { ... }fun dispose() { ... }}画面表示時の処理を実装する。コマンド実行に備えて、adbサーバーを起動する。そのあとコマンドの一覧を更新する。86
機能実装 - ADBでコマンドを実行し端末を操作するStateHolderclass MainStateHolder(val getCommandListUseCase: GetCommandListUseCase = GetCommandListUseCase(),val executeCommandUseCase: ExecuteCommandUseCase = ExecuteCommandUseCase(),) {︙fun setup() { ... }fun executeCommand(command: Command) {coroutineScope.launch {executeCommandUseCase("XXX", command)}}fun dispose() { ... }}選択したコマンドを実行する。今回はシリアル番号は仮で固定値を入れている。87
機能実装 - ADBでコマンドを実行し端末を操作するStateHolderclass MainStateHolder(val getCommandListUseCase: GetCommandListUseCase = GetCommandListUseCase(),val executeCommandUseCase: ExecuteCommandUseCase = ExecuteCommandUseCase(),) {︙fun setup() { ... }fun executeCommand(command: Command) { ... }fun dispose() {coroutineScope.cancel()}}画面非表示時に実行されているコルーチンをキャンセルする88
fun main() = application {Window(title = StringRes.WINDOW_TITLE, onCloseRequest = ::exitApplication) {val stateHolder by remember { mutableStateOf(MainStateHolder()) }DisposableEffect(stateHolder) {stateHolder.setup()onDispose { stateHolder.dispose() }}val state by stateHolder.state.collectAsState()CommandList(commands = state.commands,onExecute = { stateHolder.executeCommand(it) },modifier = Modifier.fillMaxSize())}}機能実装 - ADBでコマンドを実行し端末を操作するmain89Compose for Desktopのメイン関数で、今まで作成したクラスを使ってアプリを組み立てる
fun main() = application {Window(title = StringRes.WINDOW_TITLE, onCloseRequest = ::exitApplication) {val stateHolder by remember { mutableStateOf(MainStateHolder()) }DisposableEffect(stateHolder) {stateHolder.setup()onDispose { stateHolder.dispose() }}val state by stateHolder.state.collectAsState()CommandList(commands = state.commands,onExecute = { stateHolder.executeCommand(it) },modifier = Modifier.fillMaxSize())}}機能実装 - ADBでコマンドを実行し端末を操作するmain画面表示時にsetupを実行する。また画面非表示にdisposeを実行する。90
fun main() = application {Window(title = StringRes.WINDOW_TITLE, onCloseRequest = ::exitApplication) {val stateHolder by remember { mutableStateOf(MainStateHolder()) }DisposableEffect(stateHolder) {stateHolder.setup()onDispose { stateHolder.dispose() }}val state by stateHolder.state.collectAsState()CommandList(commands = state.commands,onExecute = { stateHolder.executeCommand(it) },modifier = Modifier.fillMaxSize())}}機能実装 - ADBでコマンドを実行し端末を操作するmainstateをcollectしてCommandListに渡す、CommandListからイベントをstateHolderに伝える91
ADBと連携する - ADBでコマンドを実行し端末を操作する92
Demo93
Compose for Desktopを使ってみて94
Compose for Desktopを使ってみて● Jetpack Composeと同じ仕組みでUIを作れる- Jetpack Composeが使えればCompose for Destkopも簡単に使える● Androidのアプリ開発で得た知識を流用しやすい- IDE- IntelliJ IDEAとAndroid Studioは大きな違いはなく使いやすい- Coroutines・Flow- CoroutinesとFlowを使えるので非同期処理などの実装がかなり楽- アーキテクチャ- Androidアーキテクチャガイドを参考にできる良かったところ95
● Compose for DesktopのリリースはJetpack Composeの後追い- Jetpack Composeの最新版で使えるようになってもCompose for Desktopで使えない● プレビュー機能が弱い- プレビューの実行&更新は手動なので少々手間がかかる● ドキュメントが少なめ- 困ったらGitHubのIssueで解決策を調べることが多いCompose for Desktopを使ってみて辛かったところ96
まとめ● Compose for Desktop × Adamで気軽にadbを利用したツールを開発できる● Androidのアプリ開発で得た知識を流用できるので、学び直しが少なく効率的に開発ができる● Jetpack Composeと比べると開発体験は少し悪いところがある- リリース・ドキュメント・プレビューなど- Compose for Desktop 1.2.0の開発が続いているので改善に期待97
おわり98