🐈‍⬛

Compose Multiplatformを日本一レベルで使い込んだかもしれないので知見共有

2024/05/13に公開

こんにちは!sugitaniと申します。

Black Cat Carnival という新型SNSを開発中です。
リリースはまだ先なのですがティザーサイトを先日公開しました

Black Cat CarnivalはiOS/Androidアプリで、Compose Multiplatformを使って開発をしています。ティザーサイトではWasmを使い、サービスを体験できるBlack Cat Carnival Simulatorも公開しています。是非お試しください


https://bcc.cc/ja/simulator

このシミュレーターはシミュレーターとして作ったものではなく、一人で開発してる都合で先に作り込まれたクライアントにダミーデータを埋め込んだ、という代物で正真正銘の正規クライアントです。

wasm活用までふくめてここまでやるのは他にはまだなさそう…と思うので"日本一レベルで"と大きく出たタイトルをつけさせていただきました。

本稿ではクライアント開発で得られたCompose Multiplatformの知見を共有いたします。

commonMainのコードはプレビューできないが、小細工するとできる

元情報

https://github.com/JetBrains/compose-multiplatform/issues/2045

commonMain(全プラットフォーム共通コード)では@Previewが現時点では使えません。

が、
androidx.compose.desktop.ui.tooling.preview.Preview

@Retention(AnnotationRetention.SOURCE)
@Target(
    AnnotationTarget.FUNCTION,
)
annotation class Preview

をおいて、Android StudioのCompose Multiplatform IDE Support pluginを入れるとDesktop版としてプレビューが利用できるようになり大変便利です。

Android以外で、テキストセンタリングを指定したTextField系で入力内容が空のときカーソルがセンタリングされない

BasicTextField(
    // ...
    textStyle = TextStyle.Default.copy(
        textAlign = TextAlign.Center,
    ),
)

このようなコードで発生します。

いったんは入力内容が空か、文字列の最後が改行だったらゼロ幅スペース\u200Bを挿入する、で回避しています。

バグレポ済みです。

TextFieldでEnterキーを押すと親コンテナのclickableが呼ばれる

Box(
    modifier =
    Modifier
        // ...
        .clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = null,
            onClick = {
                background = Color.Red
                focusManager.clearFocus()
            },
        ),
    contentAlignment = Alignment.Center
) {
    OutlinedTextField(
        value = text,
        onValueChange = { text = it },
    )
}

このようなときにフォームに文字入力してEnterを押すと、親のBoxのclickableがは発動します。

TextField以外をタップしたときにフォーカスを外す処理をしたくてこうしたのときに遭遇しました。MultiplatformではなくComposeの不具合か仕様のようです。

雑で恥ずかしいのですが、以下のようなコード回避しました

var isDescriptionFocused by remember { mutableStateOf(false) }
var requireIgnoreClose by remember { mutableStateOf(false) }

Box {
    Column(
        modifier =
            modifier
                //...
                .onPreviewKeyEvent {
                    if (isDescriptionFocused && it.key == Key.Enter) {
                        requireIgnoreClose = true
                    }
                    false
                }.clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null,
                    onClick = {
                        if (requireIgnoreClose) {
                            requireIgnoreClose = false
                        } else {
                            focusManager.clearFocus()
                        }
                    },
                ),
    )
 }{
  // ...
OutlinedTextField(
        // ...
        modifier =
            Modifier
                .onFocusChanged { isDescriptionFocused = it.isFocused },
    )

 }

iOSでDialog などのオーバーレイ表示する系で広い面積(スクロールが必要な長文テキストなど)を表示すると CAMetalLayer ignoring invalid setDrawableSize width=大きな値 height=大きな値 と出て死ぬ

ライセンス表示のAboutLibrariesを使わせていただいた時に遭遇しました。

ライセンス表示数を絞ったら発生しなくなったので、iOSでのDialog表示は大きなCAMetalLayer一枚を使う & CAMetalLayerは大きなサイズを受け付けないのだろう、と推測しました。

AboutLibrariesのライセンス表示部分のコードをコピペしてDialogを使わずに表示するようにして回避しました。

wasm版で文字列リソースを使うと、リージョンのないLocaleが来たときクラッシュする

Compose Mutiplatformの1.6.0からリソースの取り扱いがずいぶんと便利になり、Androidとほぼ同等の機能を持つようになりました。

Androidのリソースは使うデータをLocaleによって切り替えられるのですが、このLocaleを読み込む処理に不備があり、 ブラウザのwindow.navigator.languageen-USなどリージョンがあるコードを返すと大丈夫ですがjaenなどのリージョン無しコードを出力する状態だとリソースを読み込んだ瞬間に例外になります(wasm版はリソースが必要になったときに、オンデマンドでhttpで取得する!)

他言語対応のためにstrings.xmlを使ったらこの不具合に遭遇したので、文字列はいったんソースコードに埋め込みました

具体的には以下のようにしました

    fun common_cancel(locale: SupportingLocale = SupportingLocale.current): String =
        when (locale) {
            SupportingLocale.EN -> "Cancel"
            SupportingLocale.JA -> "キャンセル"
        }

美しくないのですが、自分で使う言語を切り替えられたり、文字列リソース取得がsuspend関数じゃなくなったりするので使い勝手はこの方が良いかも…

Androidとしての@Previewからリソースが読み込めない

元情報

https://github.com/JetBrains/compose-multiplatform/issues/4476

プロジェクトを整えるとandroidMain部分ではAndroidとしてのLive Previewが利用できますが、Live Previewからはリソースにアクセスできないようです。

Android Studioの問題なのでCompose Multiplatformからはどうにもできない、とのことで…

iOSだとバーチャルキーボード由来の高さ取得タイミングが異なる

WindowInsets.Companion.ime.getBottom を使って描画位置を調整するコードを手組みで、おそらくよろしくないやり方でやったコードがあるのですが、iOSだとタイミングによっては取得できる値が不味くバグる、という場面がありました

やりたいことは複雑なのでさておき、根本解決できる方法が見つからなかったため

    LaunchedEffect(showImageViewer, showGallery, showTakeCamera) {
        if (showImageViewer != null || showGallery || showTakeCamera) {
            focusManager.clearFocus()
        } else {
            delay(500) // ちょっと遅延させないとiOSで下のバーが描画されない+プレースホルダ文字を読ませたい
            focusRequester.requestFocus()
        }
    }

こういう悲しいコメントが残っています。

iOSでTextFieldにフォーカスしたとき自動でスクロール制御がされるのを無くしたい

Kotlin Multiplatform Wizardで生成するプロジェクトでは、テキストフィールドにフォーカスしたときに自動で拡大するのがデフォルトです。

自力で制御するにはiOS側のメインViewControllerで

fun MainViewController() =
    ComposeUIViewController(
        configure = {
            onFocusBehavior = OnFocusBehavior.DoNothing
            // キーボード表時時のフォーカス制御は完全に自力で行う
        },
    ) { App() }

とする必要があったのがなかなか特定できず苦しみました

画像リソースで高さ指定せずに使うとiOSで尋常じゃなく重くなった

リソース画像を沢山表示する処理(Imageに対してpainter=painterResourceを渡す)の時

Modifier
    .fillMaxWidth()
    .heightIn(max = 500.dp, min = 40.dp)

とするとAndroidでは快適なのにiOSでは以上にカクカクする現象に遭遇しました

Modifier
    .fillMaxWidth()
    .height(250.dp)

など固定にすると軽快のになりました

Wasm版では日本語が表示できない

元情報

https://github.com/JetBrains/compose-multiplatform/issues/3967

wasmビルドでは使えるフォントが最低限のようで、日本語や絵文字などが描画できません。issueにあるようにフォントを追加読み込みさせれば描画できるようになります。

ただしFont Familyで日本語フォントと絵文字フォントを読み込ませても、日本語と絵文字が混ざったテキストの描画はできませんでした。

回避方法は見つけられませんでした

シミュレーターでは絵文字が使われる場面は絵文字だけのフォントを指定して描画するようにして回避しました

Wasm版で日本語入力ができない

できません…

Wasm版でドラッグ操作が効かない

Lazy系でマウスドラッグによるスクロールが効きません
トラックパッドであれば利用できます。

HorizontalPagerに対しては以下のModifierへのextensionを書いて無理矢理対応させています。

@OptIn(ExperimentalFoundationApi::class)
fun Modifier.horizontalPagerSwipeModifierIfWasm(
    enabled: () -> Boolean,
    pagerState: PagerState,
    coroutineScope: CoroutineScope,
): Modifier =
    if (!isWasm()) {
        this
    } else {
        var dragOffset = 0f
        var isDragging = false
        var thresholdOffset = 100f
        onGloballyPositioned {
            thresholdOffset = it.size.width * 0.5f
        }.pointerInput(Unit) {
            detectHorizontalDragGestures(
                onDragStart = {
                    if (enabled()) {
                        isDragging = true
                    }
                },
                onDragEnd = {
                    if (enabled()) {
                        isDragging = false
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(pagerState.currentPage)
                        }
                        dragOffset = 0f
                    }
                },
                onDragCancel = {
                    if (enabled()) {
                        isDragging = false
                        coroutineScope.launch {
                            pagerState.animateScrollToPage(pagerState.currentPage)
                        }
                        dragOffset = 0f
                    }
                },
                onHorizontalDrag = { _, dragAmount ->
                    if (isDragging && enabled()) {
                        dragOffset += dragAmount
                        coroutineScope.launch {
                            pagerState.scrollBy(-dragAmount)
                        }
                    }
                },
            )
        }
    }

Compose Multiplatformは大丈夫。

なんか尋常じゃない量の不具合に遭遇していますが、それでもCompose Multiplatformは素晴らしいです。

wasm版を触ってみていただけると分かると思うのですが、普通に戦える品質のプロダクトが既に作れます。結構凝ったUIですがiOSでも高速に動作します。オススメできます。

オススメする理由は大きく二つです。

①仮にCompose Multiplatformが下火になってもAndroid資産として残る

マルチプラットフォームというとFlutterが盛況ですが、活発さがずっと続くのか?を考えると不安を感じます。ComposeはAndroidの現時点での最新UIフレームワークなので、Multiplatformとしては下火になる可能性は大いにありますが、Compose自体はそう簡単に滅びることはないでしょう。

②Xcodeとのお付き合いを小さくできる

自身が開発を担当しているSUGARではiOSとAndroidの実装も担っています。iOSはUIKitメインで一部SwiftUI / AndroidはDataBindingメインで一部Compose、という構成でコードの規模はほぼ同等です。

自分はIntel版のMacbook Proをまだ使っているのですが、似たような規模にもかかわらずAndroid Studioに比べるとXcodeは動作が尋常ではない重さで、かなりストレスを感じています。

GitHub Copilotも支援も受けづらいのも辛いところです。
[Copilot for Xcode]を使えばそこそこ効きますが、純正ではないので使い勝手としては微妙です。

M3どころかM4でましたよ買い換えたら?とは思いますがAndroid側(Android Studio)やScala側(VSCode + Metals / IDEA)では一切困っていないので二の足を踏んでいます。 (※もちろんM4搭載MBPが出るのはもう少し先)

呪詛が長くなりましたが、Compose MultiplatformであればXcodeとのお付き合いは最小になります。というかiOS部分が小さいのでXcodeも重くなりません。Android Studioの、JetBrains製らしい素晴らしい使い勝手を存分に享受できます。

Xcodeに対する恨みが高まっているのでSUGARもCompose Multiplatformに移行しようかな…と考えています(どのみち DataBindingが滅びる予定なのでAndroid側はComposeに移行しなければならない、iOS側もSwiftUIの割合を増やしたい、というかUIKit部分を減らしたい、ならばCompose Multiplatformで作り直した方が効率が良いのでは?という考え)

終わりに

苦労はしていますが、現時点で得られている開発体験は非常に良好です。

しかし、まだ全てと作りきったわけではない、というかUI部分しかできていないので、今後の作業では、まだまだ多くの不具合に遭遇するとはおもいます。

好きな不具合がまた出てきたその時は
発表したい
発表したい
(Youtube)


読んでくださいまして、ありがとうございました

Black Cat Carnivalに関してしずかなインターネットで沢山つぶやいているので、よろしければそちらもご覧ください

https://sizu.me/sugitani/posts/fd4v8d9k7ro5

https://sizu.me/sugitani/posts/xbnes4t1nzob

https://sizu.me/sugitani/posts/0xmncrx86ttt

https://sizu.me/sugitani/posts/ctcizz9s9mov

https://sizu.me/sugitani/posts/fmm5bm2vmh98

Black Cat Carnival

Discussion