12
2

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.

NTTテクノクロスAdvent Calendar 2023

Day 2

Compose MultiplatformでいつものQiitaビューアを作ってみた

Last updated at Posted at 2023-12-01

この記事はNTTテクノクロス Advent Calendar 2023 シリーズ1の2日目です。

こんにちは、NTTテクノクロスの戸部@etctaroと申します。

普段は社内でモバイルアプリ開発関連の技術支援や社内向けのノウハウ記事執筆、社内研修講師活動、社内コミュニティ活動などを行なっています。

毎年恒例のアドカレですが、今年も私はComposeで記事を一本を書きました。

今回のお題は「Compose Multiplatform」です。

★ところで、記事の前に宣伝です。

先日まで開催されていた技術書典15では当社の仲間と書籍を出しています。
今回私が執筆した本は以下の通り。

image.png

ぜひお手にとっていただければと思います。

はじめに

  • Compose Multiplatformは、一言で表すと、Kotlinを使い、Jetpack Composeと同じ書式で、さまざまなプラットフォームのアプリを作ることができる仕組みです。
  • Kotlinの1.9.20が2023/11にリリースされ、その中で、マルチプラットフォームの仕組みである、Kotlin Multiplatformが「Stable」と宣言されました。
  • Compose Multiplatformについても環境が少しずつ整備されて、以前より実用的になりました。
  • ということで、私のアドカレで恒例のQiitaビューアを作ってみることにします。

対象読者

  • Jetpack Composeについては知っているが、Compose Multiplatformについては知らない人
  • クロスプラットフォームアプリ開発を色々と模索している人
  • とりあえず新しいものに触ってみたい人
  • Kotlinが好きな人

読んで得られること

  • Compose Multiplatformの現状がなんとなく理解できる。
  • よくあるタイプのアプリを作ることができるようになることで、他のアプリに水平展開できる。

とりあえずの完成品

今回はこんなアプリを作ります。(Android寄せの画面になっています。)

  • リスト画面
    • FABをタップするとQiitaのAPIに対してリクエストを行いその結果として、タイトルとURLの一覧を取得する。
    • 画面にタイトルとURLを一覧表示する。
    • 一覧の各アイテムをタップすることができる。タップすると、そのURLをパラメータとして詳細画面に遷移する。
    • ついでに、アクションボタンでリストの内容を空にすることもできる。
  • 詳細画面
    • リスト画面から受け取ったURLを使ってそのURLのWebページをWebViewで表示する。
    • 今回表示するのは、対応するQiitaの記事。
    • また、詳細画面からリスト画面に戻るナビゲーションボタンもAppBarに用意する。
  • その他
    • AndroidとiOSに対応する。

画面イメージは以下の通りです。

  • リスト画面と詳細画面の遷移

リスト画面と詳細画面

※画面の選択に他意はありません。内容を見て印象に残ったので選びました。

  • iOSにも対応

iOSのリスト画面

今回のサンプルコード

  • サンプルコードはこちらのリポジトリにあります。
  • あくまでもサンプルコードですので、エラー処理などは最低限です。ただし、比較的応用しやすいと思いますので、ぜひ参考にご利用ください。そして、あなたもComposeの世界に入りましょう。

Compose Multiplatformのアプリ開発について

まず、アプリ開発を進めるまでの準備について記載します。
公式の手順が整理されていますのでこちらもご参考に。

今回の想定環境(執筆時の環境)

  • Android Studio Hedgehog RC2にて開発。
    • Kotlin Multiplatformプラグインは0.8.0でした。
  • Kotlin 1.9.20
  • Compose 1.5.x
  • Android 14 の実機/エミュレータ
  • iOS 16.x のシミュレータ
  • Xcode 14.3.1 (直接は使わないけどインストールと初回起動が必須です。 執筆中に更新が入って15.0.1にしたら動かなくなるという状況に・・・なんでや・・・ダウングレードして対処しました。 )

環境の準備

※proxyの設定など、NW環境などは整っている前提です。

  • 1.Android Studio に Kotlin Multiplatform Mobileのプラグインを入れる
    • 何はなくともプラグインが必要。
    • Hedgehog以降の方が良いかもしれません。(Giraffe対応のKotlinプラグインだと1.9.20以降には未対応。)
  • 2.kdoctor をインストール(Homebrew)して実行する
    • Flutterをご存知の人なら、flutter doctorと同じようなものだと思ってください。
  • 3.kdoctorの警告に従って対応すると自然とKotlin Multiplatformの環境が整う
    • JDK, Android Studio, Xcode, CocoaPodsなどなど

プロジェクトの作成・初回実行

※以下の手順についても、執筆時点のものになります。テンプレートのプロジェクトなどはタイミングによって変化がある可能性があります。( 実際に、私が執筆中にも変更が入ってそれに合わせて修正を入れました。

  • 1.プロジェクトウィザードを使ってプロジェクト生成、DLする。
    • 公式: https://kmp.jetbrains.com/
      • 使い方は直感的にすぐわかると思います。
      • 動作・利用するプラットフォームを選択する。(Android/iOS/Desktop/Server)
    • 非公式: https://terrakok.github.io/Compose-Multiplatform-Wizard/
      • 非公式と言ってもKotlinの中の人が主導しているので、ほぼ公式です。公式の手順に挙げられていないだけです。
      • プラットフォームの選択だけでなく、利用するライブラリも選択できるので、これも便利。
  • 2.ダウンロードしたPJの雛形をAndroid Studioで読み込む。
  • 3.そのまま、必要なライブラリ類のダウンロードが始まるため、しばらく待機する。
    • ※数ギガ単位のファイルを取得する。
  • 4.エラーなくSyncが終わって、取り合えずアプリを実行できればOK。
    • どのプラットフォーム向けの実行でも、初回は非常に時間がかかります。

実装内容の検討

今回は以下の流れで実装を考えていきます。

  • 1.Qiitaの記事のAPIをリクエストする処理を実装する。(アドカレでは毎回恒例)
  • 2.リスト画面を実装する。
  • 3.詳細画面を実装する。
  • 4.二つの画面間の遷移を実装する。
  • 5.時間があればもう少し工夫できる点を検討。

1.Qiitaの記事のAPIをリクエストする処理を実装する

  • 通信のライブラリとしては JetBrains謹製のKtorのクライアント側のAPIを使うことにします。
  • また、通信結果のデシリアライズではKotlinx.Serializationを使います。

これらのライブラリはKotlin Multiplatform対応であり、二つのライブラリを連携した実装をすることが可能です。

※なお、レスポンスがJSONの場合は実装が可能です。XMLなどの場合には自前でカスタムパーサーの実装が必要になるなど、単純にはいかないケースも出てきます。

dependencies

KtorとSerializationを使うため、以下の通り設定を加えます。
なお、雛形を使うと、Version Catalogが適用されている状態になります。
また、ビルドスクリプトはKotlin DSLが使われます。

  • gradle/libs.versions.toml

  • バージョンカタログのファイルについては、この時点では一旦手を触れず。後から手を入れましょう。

  • composeApp/src/build.gradle.kts (プラグイン部分)

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidApplication)
    alias(libs.plugins.jetbrainsCompose)

    kotlin("plugin.serialization") version "1.9.20" // ★追加
}
  • composeApp/src/build.gradle.kts (ライブラリ類の部分)

★ポイントとなる部分のみ抜粋します。

val ktorVersion = "2.3.2"
val serializationVersion = "1.5.1"

kotlin {
    sourceSets {
        androidMain.dependencies {
            // 略
            implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
        }
        commonMain.dependencies {
            // 略
            implementation("io.ktor:ktor-client-core:$ktorVersion")
            implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
            implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")

            implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
        }
        iosMain.dependencies {
            implementation("io.ktor:ktor-client-darwin:$ktorVersion")
        }

デシリアライズ対象のモデルクラス

各記事の情報は、このクラスのオブジェクトとして保持されます。

  • commonMain/kotlin/QiitaArticle.kt
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class QiitaArticle (
    @SerialName("id")
    val id: String,

    @SerialName("title")
    val title: String,

    @SerialName("url")
    val url: String
)

通信処理の実装

さほど難しいことはしていません。Ktorの作法に則っているところです。
記事の一覧を取得するメソッドgetArticlesはsuspend関数としています。

  • commonMain/kotlin/ExampleApi.kt
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

class ExampleApi {
    private val client = HttpClient() {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
            })
        }
    }

    companion object {
        private const val QIITA_URI = "https://qiita.com/api/v2/items?page=1&per_page=20"
    }

    suspend fun getArticles(): List<QiitaArticle> {
        return client.get(QIITA_URI).body()
    }
}

呼び出す側の実装イメージ

var resultList by rememberSaveable { mutableStateOf(listOf<QiitaArticle>()) }

FloatingActionButton(onClick = {
    MainScope().launch {
        val result = ExampleApi().getArticles()
        resultList = result
    }
}) {
    Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
}

2.リスト画面を実装する

ベースとなるComposableを作成

まずは、記事の一覧(とりあえずタイトルとURL)を表示するComposeを実装してみます。
と言っても、普段のJetpack Composeの実装と大きな違いはありません。

@Composable
fun ListScreen() {
    var resultList by rememberSaveable { mutableStateOf(listOf<QiitaArticle>()) }
    LazyColumn {
        items(resultList) {
            Column {
                Text(it.title, style = MaterialTheme.typography.body1)
                Text(it.url, style = MaterialTheme.typography.body2, modifier = Modifier.padding(start = 5.dp))
            }
            Divider()
        }
    }
}

さらに、Scaffoldで囲んで FABを追加します。

@Composable
fun ListScreen() {
        var resultList by rememberSaveable { mutableStateOf(listOf<QiitaArticle>()) }

        Scaffold(
            topBar = {
                TopAppBar(title = { Text("Sample List") }
                )
            },
            floatingActionButton = { FloatingActionButton(onClick = {
            }) {
                Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
            } }
        ) {
            LazyColumn {
                items(resultList) {
                    Column {
                        Text(it.title, style = MaterialTheme.typography.body1)
                        Text(it.url, style = MaterialTheme.typography.body2, modifier = Modifier.padding(start = 5.dp))
                    }
                    Divider()
                }
            }
        }
}

API呼び出しの追加

FABを追加したので、そこに、先ほどのAPI呼び出しを追加します。
Coroutineのスコープで実行。なお、Coroutineはdependenciesに新たに追加しなくても使うことはできます。

取得した結果は resultList ステートに格納します。

        //var resultList by rememberSaveable { mutableStateOf(listOf<QiitaArticle>()) }
        // ...

            floatingActionButton = { FloatingActionButton(onClick = {
                MainScope().launch {
                    val result = ExampleApi().getArticles()
                    resultList = result
                }
            }) {
                Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
            } }

クリア機能追加

ついでとして、一覧を空にするクリア機能も追加しておきます。
ボタンを追加して、タップされた際の処理として、resultListを空にするだけです。

        //var resultList by rememberSaveable { mutableStateOf(listOf<QiitaArticle>()) }
        // ...
        
        Scaffold(
            topBar = {
                TopAppBar(title = { Text("Sample List") },
                    actions = {
                        IconButton(onClick = {
                            resultList = listOf()
                        }) {
                            Icon(imageVector = Icons.Filled.Clear, contentDescription = "clear")
                        }
                    }
                )
            },
            // 以下略

Appの更新

ここまで実施したところで、Appから先ほどのListScreenコンポーザブルを呼んでみましょう。

@OptIn(ExperimentalResourceApi::class)
@Composable
fun App() {
    MaterialTheme {
        ListScreen()
    }
}

AndroidManifestの更新

もう一つ、忘れてはならないのがAndroidのパーミッションです。

  • composeApp/androidMain/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>
    <!--以下略-->

ひと段落

さて、これで一覧画面の表示については実装できました。画面イメージは以下の通りです。

ListScreenのイメージ

3.詳細画面を実装する

続けて、詳細画面を実装しようと思います。
今回、URLを指定してそのページを表示しようと思うので、パラメータに記事のURLを設定できるようにします。

  • DetailScreen.kt
@Composable
fun DetailScreen(url: String = "") {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Sample Detail") }
            )
        },
    ){
        // ここにWebViewを入れる想定
    }
}

さて、上に書いた通り、WebViewを使って記事を表示しようと思います。

ここで、サードパーティのライブラリを利用します。

今回は compose-webview-multiplatform を利用。

  • 作者ご本人のMediumの記事
  • ライブラリ名の文字通りのライブラリです。
  • AndroidについてはAcommpanist(Deprecated)のWebView、iOSはWKWebView、DesktopはJavaFXのWebView(→最新バージョンははKotlin CEF Browser)がベースとなっています。
  • かなり精力的に開発されており、この記事を書いている途中でもバージョンが更新されていました。

もっとも簡単な使い方は以下の通りです。専用のStateにURLを指定し、それをコンポーザブルのパラメータとして渡すのみ。

val state = rememberWebViewState("https://example.com") 
WebView(state)

非常にシンプルでわかりやすいですね。
実際にはローディング途中の状況を取得するなど、もう少し複雑なことも可能です。
詳しくはリポジトリを確認してみてください。

今回は単純な使い方のみをします。

まずはdependenciesに上記のライブラリのアーティファクトを追加します。
※必要な部分のみ抜粋しています。

kotlin {
    sourceSets {
        commonMain.dependencies {
            // ・・・略
            api("io.github.kevinnzou:compose-webview-multiplatform:1.7.0")
        }
    }
}

そして、このWebViewをComposableに追加します。

@Composable
fun DetailScreen(url: String = "") {
    val state = rememberWebViewState(url.ifEmpty { "https://google.co.jp" })
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Sample Detail") }
            )
        },
    ){
        WebView(state)
    }
}

4.二つの画面間の遷移を実装する

次に、2つの画面間の遷移について追加します。
現時点では公式のナビゲーションライブラリはありませんので、サードパーティのライブラリを使うことにします。

Compose Multiplatformの公式でもナビゲーションに関するライブラリが紹介されています。

今回使って(or使おうとして)みたライブラリは以下の通りです。

PreCompose

  • AndroidXのNavigation API(navigation-compose)と同じ感覚で実装できる。

具体的な実装方法は以下の通りです。

  • dependenciesの設定
val precompose_version = "1.5.7"

kotlin {
    sourceSets {
        commonMain.dependencies {
            // ・・・略
            api("moe.tlaster:precompose:$precompose_version")
        }
    }
}
  • NavHostの実装例
@Composable
fun MyNavHost() {
    PreComposeApp {
        val navigator = rememberNavigator()
        NavHost( navigator = navigator, 
            navTransition = NavTransition(), 
            initialRoute = "/home") {
                scene( route = "/home",) { ListScreen(navigator)}
                scene(route = "/detail",) { backStackEntry ->               
                    backStackEntry.query<String>("url")?.let { url ->
                        DetailScreen(url)
                    }
                }
            }
    }
}

  • 画面遷移実行部分
Column(modifier = Modifier.clickable {
    navigator.navigate("/detail?url=${it.url}")
    }) {
    //...

上記の通り、NavHostのComposableを実装してNavGraphを構成し、Navigatorを各Composableに渡し、navigateメソッドを呼ぶことで、他の画面に遷移できます。

また、各画面に渡すパラメータについても、Navigation APIと同様に実装できます。

  • Appの修正

先ほどのNavHostのComposableを最初に呼ぶようにします。

@OptIn(ExperimentalResourceApi::class)
@Composable
fun App() {
    MaterialTheme {
        MyNavHost()
    }
}
  • JVMのターゲットを11以降にする

そのままビルドしようとすると、以下のようなエラーメッセージが出ます。

e: file://hogehoge/composeApp/src/commonMain/kotlin/App.kt:28:32 Cannot inline bytecode built with JVM target 11 into bytecode that is being built with JVM target 1.8. Please specify proper '-jvm-target' option

メッセージに従ってJVMのターゲットを11以降にします。(今回のウィザードのプロジェクトだと初期状態は8になっています。)

  • build.gradle.kts
kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "11"
            }
        }
    }
    // 略

android {
    // 略
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}
  • これでいけそうだ、と実装してみたものの、iOSでは画面遷移時に落ちるなどの状況になり、今回の記事執筆の中では他の方法を取ることにしました。
  • Androidでは上手く動いていましたので、どのようなところが問題だったかという点は改めて確認してみようと思います。

Voyager

  • Voyagerはそれぞれの画面を独自のScreenインタフェースを実装することで画面遷移を実現する。
    • 独自のと言っても、インタフェースで定義されているComposable関数を実装するだけなのでさほどの変更は入りません。
  • パラメータを使う場合はNavigation APIやPreComposeより扱いやすいかも。

具体的な実装方法は以下の通りです。

  • dependenciesの設定
val voyagerVersion = "1.0.0-rc05"

kotlin {
    sourceSets {
        commonMain.dependencies {
            // ・・・略
            implementation("cafe.adriel.voyager:voyager-navigator:$voyagerVersion") 
        }
    }
}

※なお、この変更と合わせてPreComposeの依存を削除し、ターゲットJVMを1.8に戻しても良いです。

  • ルートの部分の実装
@Composable
fun App() {
    MaterialTheme {
        Navigator(ListScreen)
    }
}
  • リスト画面の実装

上で実装したListScreenコンポーザブルから大きな変更はありません。以下がポイントです。

  • ScreenインタフェースのContent関数(Composable)を実装する。
    • ここに、先ほどのListScreenの内容をそのまま書いてあげれば良い。
  • 画面遷移に関する関数を提供してくれるnavigatorをContentの中で利用する。
    • ※実際の画面遷移の実装については後述。
object ListScreen : Screen {

    @Composable
    override fun Content() {
        val navigator = LocalNavigator.currentOrThrow
        var resultList by rememberSaveable { mutableStateOf(listOf<QiitaArticle>()) }

        Scaffold(
            topBar = {
                TopAppBar(title = { Text("Sample List") },
                    actions = {
                        IconButton(onClick = {
                            resultList = listOf()
                        }) {
                            Icon(imageVector = Icons.Filled.Clear, contentDescription = "clear")
                        }
                    }
                )
            },
            floatingActionButton = { FloatingActionButton(onClick = {
                MainScope().launch {
                    val result = ExampleApi().getArticles()
                    resultList = result
                }
            }) {
                Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
            } }
        ) {
            LazyColumn {
                items(resultList) {
                    Column(modifier = Modifier.clickable {
                        // あとで追加する
                    }) {
                        Text(it.title, style = MaterialTheme.typography.body1)
                        Text(it.url, style = MaterialTheme.typography.body2, modifier = Modifier.padding(start = 5.dp))
                    }
                    Divider()
                }
            }
        }
    }
}
  • 詳細画面の実装(パラメータが必要な場合、data classを使うと良い。)
data class DetailScreen(val url: String = "") : Screen {
    @Composable
    override fun Content() {
        val state = rememberWebViewState(url.ifEmpty { "https://google.co.jp" })
        Scaffold(
            topBar = {
                TopAppBar(title = { Text("Sample Detail") }
                )
            },
        ){
            WebView(state)
        }
    }
}

  • 画面遷移実行部分

リスト画面の各アイテムをタップした時の処理は以下のようになります。
navigator.push の引数に遷移先のScreenを設定すれば良いです。

//        val navigator = LocalNavigator.currentOrThrow
// ...
            LazyColumn {
                items(resultList) {
                    Column(modifier = Modifier.clickable {
                        navigator.push(DetailScreen(it.url)) // ★
                    }) {
                        Text(it.title, style = MaterialTheme.typography.body1)
                        Text(it.url, style = MaterialTheme.typography.body2, modifier = Modifier.padding(start = 5.dp))
                    }
                    Divider()
                }
            }
  • 詳細画面からリスト画面への戻る遷移

詳細からリストへ戻る遷移も追加しておきます。
戻る場合はpushで積んだスタックをpopで取り除くことになるので、以下の通りです。

data class DetailScreen(val url: String = "") : Screen {
    @Composable
    override fun Content() {
        val navigator = LocalNavigator.currentOrThrow
        val state = rememberWebViewState(url.ifEmpty { "https://google.co.jp" })

        Scaffold(
            topBar = {
                TopAppBar(title = { Text("Sample Detail") }
                    , navigationIcon = {
                        IconButton(onClick = {navigator.pop()}){ // ★
                            Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "back")
                        }
                    }
                )
            },
        ){
            WebView(state)
        }
    }
}

Version Catalogに対応

さて、最初にdepndenciesに追加して放置していましたが、Version Catalogでライブラリ類のバージョン管理をしておきましょう。

このような状況になっているかと思います。

depndencies before

Version Catalogの機能が導入されたての時は移行が面倒だった記憶がある人もいると思いますが、
下記の通り Alt + Enter (などのショートカット)のメニューから変換することができます。

version catalogへ変換

変換後に文字列が赤くなっていたら、Syncを実施すると解消されるはずです。

赤文字表示

  • libs.versions.toml(追加したもののみ抜粋)
[versions]
composeWebviewMultiplatform = "1.6.0"
ktorVersion = "2.3.2"
serializationVersion = "1.5.1"
voyagerVersion = "1.0.0-rc05"

[libraries]
compose-webview-multiplatform = { module = "io.github.kevinnzou:compose-webview-multiplatform", version.ref = "composeWebviewMultiplatform" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationVersion" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorVersion" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktorVersion" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorVersion" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorVersion" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorVersion" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyagerVersion" }
  • build.gradle.kts
kotlin {
    sourceSets {
        androidMain.dependencies {
            // ...
            implementation(libs.ktor.client.okhttp)
        }
        commonMain.dependencies {
            //...
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.serialization.kotlinx.json)
            implementation(libs.ktor.client.content.negotiation)

            implementation (libs.kotlinx.serialization.json)

            // Navigator
            implementation(libs.voyager.navigator)

            // WebView
            api(libs.compose.webview.multiplatform)
            
            // ...
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }

また、プラグインだけはショートカットのメニューから変換することはできないようなので、以下の通り追記・修正します。

  • libs.versions.toml
[versions]
plugin-serialization = "1.9.20"

[plugins]
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "plugin-serialization"}
  • build.gradle.kts
plugins {
    //...
    alias(libs.plugins.serialization)
}

あとは、build.gradle.ktsの中にバージョンの値が残っていたら削除しておきます。

ここまでの状況

ここまでで概ね元々作りたかったものは作ることができました。

Android StudioにKotlin Multiplatformのプラグインが入っていれば、Android / iOSでの実行は通常のRun Appと同様に実行できます。

Run on iOS

画面は概ね以下の通り。

リスト画面と詳細画面

その他、工夫できる点を検討

さて、もう少しだけ触ってみましょう。
たとえば以下のような点が挙げられるかと思います。

  • プラットフォームごとの表示だし分け
  • Android13からの「予測型バックジェスチャー」に対応する
  • ローディングの追加
  • エラー処理、ログ出力
  • 永続化とアーキテクチャ追加
  • Android/iOSのネイティブ機能の実行
  • 既存のアプリに対するMultiplatformの導入
  • テーマの設定
  • ・・・

とは言え、単独で記事にできそうなレベルのトピックもあるので、
ここでは軽めな内容だけ触れておきます。

続きはアドカレ後半の記事にて。

プラットフォームごとの表示だし分け

それぞれのプラットフォームごとに画像の出しわけをしてみましょう。

今回は以下のような画像を使ってみます。
いずれもいらすとやの画像を利用しています。

robo_icon.png

apple_icon.png

利用する画像の配置

利用するリソース類は、commonMain/resourcesに配置することで容易に扱えます。
ウィザードで作成すると、デフォルトでComposeのロゴ画像(XML)が用意されていますが、pngなどのファイルでも扱うことができます。

以下の通りに準備しました。

画像の配置

ロジックの実装

Kotlin Multiplatformで共通で呼び出すロジックを、プラットフォームごとに異なる内容で実装する場合、
それぞれcommonMainにexpectをつけた中身のない関数を定義し、
androidMainとiosMainにactualをつけた関数を実装します。
(インタフェースを扱うのと同様です。)

今回の場合は以下の通りです。

  • commonMain/kotlin/Platform.kt
expect fun imageFileName(): String
  • androidMain/kotlin/Platform.android.kt
actual fun imageFileName(): String = "robo_icon.png"
  • androidMain/kotlin/Platform.ios.kt
actual fun imageFileName(): String = "apple_icon.png"

画像の呼び出し

リスト画面のTopAppBarで上記の画像を呼び出すことにします。
ポイントは以下の通り。

  • painterResourceを呼ぶと、resources配下の画像が扱える。
  • 共通のコードとして先ほどのimageFileNameを呼び出す。
  • リソースを扱う場合、現時点ではOptInのアノテーションが必要。
object ListScreen : Screen {

    @OptIn(ExperimentalResourceApi::class)
    @Composable
    override fun Content() {
        // ...
        Scaffold(
            topBar = {
                TopAppBar(title = {
                    Row(verticalAlignment = Alignment.CenterVertically) {
                        Image(painter = painterResource(imageFileName()), contentDescription = "")
                        Text("Sample List")
                    }},
                // 以下略

画面イメージは以下の通りとなります。上記のソースコードの通り、縦方向の調整もしました。

ロボアイコン追加

リンゴアイコン追加

Android13からの「予測型バックジェスチャー」に対応する

Androidの独自部分にも少しだけ触れようと思います。

Android13以降で追加された「予測型バックジェスチャー」は、ジェスチャーで戻る画面遷移をするときに、
戻る遷移を操作途中で止めて、キャンセルできる機能です。

以下の通り、AndroidManifestに以下の通り、enableOnBackInvokedCallbackを追加します。

<manifest xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar"
        android:enableOnBackInvokedCallback="true"
        tools:targetApi="tiramisu">
        <!--以下略-->

また、利用する場合、ユーザが開発者ツールで機能を有効にする必要があります。

アニメーション有効化

画面イメージは以下の通り。

バックジェスチャーの動作

ここまで試してみての所感

いいところ

  • Composeで実装するそのままの感覚でマルチプラットフォーム対応アプリが実装可能
  • 通信・非同期処理のロジックもなど含め大体のことはKotlinで実装できる。

まだまだなところ

  • マルチプラットフォーム対応ライブラリを利用する設定がなかなか複雑
    • とはいえ、その点はそれぞれのライブラリ作者が認識しているところでもあり、READMEなどに細かく書かれていることも多いです。
  • マルチプラットフォーム対応ライブラリが見つからない
  • 対応していない機能・コンポーネントについては個別対応が必要
    • 前よりも少しずつ対応が増えている!
  • syncなどでのライブラリ取得、ビルドなどに時間がかかる。
  • PreviewやLive EditなどComposeの便利機能がまだ使えない。(Desktopなど、一部は対応しているので、今後対応が広がる可能性はあり。)
  • 見た目がAndroid(MaterialUI)寄りになる。これは如何ともし難い。
  • iOSでエラーが起きた時、エラーの理由がよくわからない、ということもあります。これが一番厳しいかもしれませんね。

おわりに

ということで、今年もComposeを使った記事を書いてきました。

Kotlinで全体的に色々と対応できる点は非常に強いです。
また、以前触れた時よりもさらに対応しやすくなっていると感じています。

この話がどこまで広がるか、という点は不透明感はありますが、現時点でもかなりサクサク実装して動かせる点はなかなかです。

それでは、皆様、良いComposeライフを。

令和TXこそこそ噂話

シリーズ1の3日目は@Dahaさんが「ITカタカナ用語」について語ってくださるようですよ。
明日もぜひご覧ください!

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?