🍣

compose-mppというライブラリを作った

2021/09/20に公開

compose-mppというライブラリを作った

Jetpack ComposeにはAlertDialogDropdownMenuといったMaterial Componentsの実装があって、これらはJetBrainsのCompose for Desktopでも基本的には実装されている。なので、Compose for Desktopアプリでも例えばこんな感じでDropdownMenuを使うことができる。

Material DropdownMenu on Desktop

しかし、Compose for DesktopアプリをMultiplatformで開発していると、これらを共通コードcommonMainの中で使うことができない。なぜならこれらのクラスはJetBrainsがMavenサーバで公開しているCompose for Desktopのパッケージの中では、Desktop用の実装コードだけが存在していて、共通コードのメタデータとしては存在していないからだ。

実装コードをAndroid用とDesktop用で別々に書いて別々にビルドしているのであれば、これらの機能を問題なく使うことができる。しかし一般的にはCompose for Desktopを使ってAndroidとUIを共通化するのであれば、UIコードはcommonMainにまとめておきたい。

当初「こんなのはcommonMainにAPIを追加するだけだしJetBrainsがすぐに対応するだろう」と何ヶ月か待っていたのだけど、一向に実現しないので、もう自分で対応してしまおう、と思ってcompose-jbのソースを追っかけて、どうやらそんな簡単な話ではないようだとわかった。それで最終的に出来上がったのがcompose-mppというパッケージだ。

https://github.com/atsushieno/compose-mpp/

問題の所在

なぜ共通コード用のmetadataパッケージに含まれていないのかというと、まずAPIの定義がAndroidとDesktopで互換性がない。AlertDialogのAPI定義を見てみよう:

Android:

@Composable
fun AlertDialog(
    onDismissRequest: () -> Unit,
    confirmButton: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    dismissButton: @Composable (() -> Unit)? = null,
    title: @Composable (() -> Unit)? = null,
    text: @Composable (() -> Unit)? = null,
    shape: Shape = MaterialTheme.shapes.medium,
    backgroundColor: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(backgroundColor),
    properties: DialogProperties = DialogProperties()
)

@Composable
fun AlertDialog(
    onDismissRequest: () -> Unit,
    buttons: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    title: (@Composable () -> Unit)? = null,
    text: @Composable (() -> Unit)? = null,
    shape: Shape = MaterialTheme.shapes.medium,
    backgroundColor: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(backgroundColor),
    properties: DialogProperties = DialogProperties()
)

Desktop:

@Composable
@ExperimentalMaterialApi
fun AlertDialog(
    onDismissRequest: () -> Unit,
    confirmButton: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    dismissButton: @Composable (() -> Unit)? = null,
    title: @Composable (() -> Unit)? = null,
    text: @Composable (() -> Unit)? = null,
    shape: Shape = MaterialTheme.shapes.medium,
    backgroundColor: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(backgroundColor),
    dialogProvider: AlertDialogProvider = PopupAlertDialogProvider
)

@Composable
@ExperimentalMaterialApi
fun AlertDialog(
    onDismissRequest: () -> Unit,
    buttons: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    title: (@Composable () -> Unit)? = null,
    text: @Composable (() -> Unit)? = null,
    shape: Shape = MaterialTheme.shapes.medium,
    backgroundColor: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(backgroundColor),
    dialogProvider: AlertDialogProvider = PopupAlertDialogProvider
)

Android版のAPIにはDialogProperties型の引数が存在しており、一方でDesktop版にはPopupAlertDialogProviderという型の引数が存在している。

とはいえ、これらは実装の細かい違いに過ぎない。これらのComposable関数の引数の大半は省略可能で、これらの引数もその例に漏れないので、共通APIが存在してこれらのプラットフォーム別実装を呼び出すようにすれば、問題なくcommonMainにコードを書けるようになるはずだ。とはいえ、関数の定義は異なるので、これらをシンプルにexpect/actualでまとめることはできない。

それなら、本家compose-jbのコードに手を加えて(a)新しいオーバーロードを定義したり、(b1)desktopMainDialogPropertiesを受け取るオーバーライドを追加して、プラットフォームとして対応できない部分は無視するような実装にして、(b2)commonMainはあくまでexpect宣言にしてandroidMaindesktopMainでactual実装にすれば良いのではないか、と思うかもしれない。が、(a)はJetpack Composeに対するAPIの変更になるし(パッケージがandroidx.compose.materialであることを忘れてはいけない)、(b)はAPIの破壊的変更の疑いがある(これはコンパイラとCompose Compiler Pluginの実装次第だと思う)のに加えて、どうやら既存のCompose Compiler Pluginのバグがあって実現できていないようだ(後述する)。

外部パッケージとして提供する

androidx.compose.materialのAPIに変更を加えることはできないが、独自のパッケージであれば何を作り出しても良いだろう。そういうわけで、dev.atsushieno.composempp.materialというパッケージでAlertDialogDropdownMenuを提供するcompose-mppというパッケージを作ることにした。

やっていることはそれなりに単純だ:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AlertDialog(
    onDismissRequest: () -> Unit,
    confirmButton: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    dismissButton: @Composable (() -> Unit)? = null,
    title: @Composable (() -> Unit)? = null,
    text: @Composable (() -> Unit)? = null,
    shape: Shape = MaterialTheme.shapes.medium,
    backgroundColor: Color = MaterialTheme.colors.surface,
    contentColor: Color = contentColorFor(backgroundColor)
) = AlertDialogEx(onDismissRequest, confirmButton, modifier, dismissButton, title, text, shape, backgroundColor, contentColor)

@Composable
internal expect fun AlertDialogEx(
    onDismissRequest: (() -> Unit)?,
    confirmButton: @Composable (() -> Unit)?,
    modifier: Modifier?,
    dismissButton: @Composable (() -> Unit)?,
    title: @Composable (() -> Unit)?,
    text: @Composable (() -> Unit)?,
    shape: Shape?,
    backgroundColor: Color?,
    contentColor: Color?
)

commonMainでは上記のようにinternal expectとして独自の関数を定義して、androidMaindesktopMainでinternal actualを定義しているだけだ。

Compose Compiler Pluginの既知の問題と回避策

えっ? そもそも独自パッケージで関数を定義するのであれば、expect/actualでAlertDialog()を直接定義すれば良いのでは?と思うかもしれない。しかしこれはうまくいかない。そうやって定義されたAlertDialog()を呼び出すと、実行時にNoSuchMethodErrorが発生する:

Exception in thread "main" java.lang.NoSuchMethodError: 'void dev.atsushieno.composempp.material.AlertDialogKt.AlertDialog-EN_aC5E(kotlin.jvm.functions.Function0, kotlin.jvm.functions.Function2, androidx.compose.ui.Modifier, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function2, kotlin.jvm.functions.Function2, androidx.compose.ui.graphics.Shape, long, long, androidx.compose.runtime.Composer, int, int)'
	at dev.atsushieno.augene.gui.AppKt$App$1.invoke(App.kt:95)
	at dev.atsushieno.augene.gui.AppKt$App$1.invoke(App.kt:31)

この挙動はおかしい。この関数は実装コード上にも存在しているべきだ。これが実行時に解決できないのは、Compose Compiler Pluginがこれらの関数を正しく追跡できていないせいだ。

Compose Compiler PluginがComposableを正しく追跡できない条件はいくつかありそうだが、今回関わっている問題は2つあるようだ。

  • デフォルト引数値が定義されていると失敗する
  • 引数がnullableでないと失敗する

後者はissuetrackerに登録されていた(前者はKotlin Slackで教えられた)。これらをAPIに反映すればAlertDialog()そのものをexpect/actualで使うことも可能だろう。しかしそれでは使い勝手が悪い。そういうわけで、public APIではこれらの引数型をJetpack Composeとソースレベルで互換にしておいて、プラットフォーム別実装を呼び出すexpect/actualの関数は、nonpublicで、nullable引数だらけ、デフォルト引数なしで実装している。

この問題は、JetBrainsがcommonMainAlertDialog()を独自にでも定義していない問題と関連していると言えそうだ(伏線回収)。このバグに遭遇して再現条件が判明しなかったら、commonMainでの実装を諦めるしか無い。自分の場合はたまたま運良く再現条件を知ることが出来たに過ぎない。

まとめ

そういうわけで、compose-mppを使うとCompose for DesktopのMultiplatformプロジェクトのcommonMainでもAlertDialogやDropdownMenuが使えるようになった。ごくごく実装コードの少ないライブラリだけど、動作するコードを書けるに至るまでは割と難儀だったので、なかなか真似できないかもしれないし、真似したくなったらここに書いたことを注意点として参考にされたい。

追記: 「compose-mppってライブラリ名めっちゃ一般的すぎね?」という印象もあるけど、個人ドメインで一見明白に一意だから問題ないだろう。

Discussion