🕊

Twitter Jetpack Compose Rulesについて

2022/12/04に公開

今回は、DroidKaigiの公式アプリでも導入されていた、Twitter Jetpack Compose Rulesについてみていきたいと思います。

Twitter Jetpack Compose Rulesとは?

Twitter Jetpack Compose Rulesはktlintの一つで、Jetpack Composeのlinterです。🧑‍🔧
規模の大きなアプリにJetpack Composeを導入するのは大変なので、その手間を少しでも省くためにTwitterが開発したlinterとなっており、後で見ていきますが独自のルールが設定されています。
そのルールによって、細かくチームの中でJetpack Composeに関するルールを定めなくても自動でチェックしてくれるようになるのです。
Twitter Jetpack Composeを使うためには、ktlintかDetektと一緒に使う必要があります。
今回は、ルールを見た後に実際にktlintをとTwitter Jetpack Compose Rulesを使ってみたいと思います。

Twitter Jetpack Compose Rulesの詳細

ここからは、Twitter Jetpack Compose Rulesについて詳しく見ていきます。
以下が、実際のルールになっています。

Jetpack ComposeのStateに関するルール

引数に関するルール

Jetpack Composeは、unidirectional data flowの考え方に基づいており、単方向のデータの流れを意識して開発を進めていく必要があります。
実際にJetpack Composeで開発をしたことがある人なら、State Hoistingという考え方を元に、Stateレスな関数を作成することが重要であるということは理解していると思います。
その考え方を元に、Twitter Jetpack Compose Rulesでは以下のルールを定めています。

  • ViewModelを子の関数に渡さない。
  • State<Some>もしくはMutableState<Some>のインスタンスを引数で渡さない

もし、ViewModelにあるメソッドを渡したい時や、State<Some>の値を渡したいときはTwitter Jetpack Compose Rulesでは以下のようにして渡すようにする必要があります。

// 呼び出し側
.....
val someState = demoViewModel.someState.collectAsState()
.....
MyComposable(
    someState = someState.value,
    event = demoViewModel::someEvent
)
.....

@Composable
fun MyComposable(
    someState: String,
    event: () -> Unit,
) {
  ......
}

上のようにcollectAsStateを使用して、その値を渡すようにしたり、ViewModelのイベントを渡したい場合は関数参照やラムダを用いて、引数に渡すようにする必要があります。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeViewModelForwarding.kt

rememberを使う

mutableStateOfなどを使って、Stateを作成するときはrememberを使っているかどうかチェックします。
rememberを使わないと無駄な再コンポーズが発生してしまうので、注意が必要です。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeRememberMissing.kt

Immutableアノテーションを使用する

コンパイラに対して、immutableであると宣言するためにできるだけオブジェクトに対して@Immutableアノテーションをつけると安全性が増すのでその部分をチェックします。
Kotlin collectionのList<T>Map<T>などは、内部ではinterfaceとして定義されており、immutableであることが保証されないので、できるだけこれらを使わないようにするのが推奨されています。なので、この場合も@Immutableアノテーションを使うことで、安全性を担保できるので、その部分もチェックしてくれます。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeUnstableCollections.kt

State HoistingなどJetpack ComposeのStateに関する内容は以下を参照して確認してください。
https://developer.android.com/jetpack/compose/state

Composablesについて

Composable関数の役割について

Composable関数は、レイアウトを表示するか値を返すがどちらか一方の役割だけを担うべきであり、その両方の役割を果たしていないかどうかをチェックします。
https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md#emit-xor-return-a-value

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeMultipleContentEmitters.kt

Composable関数でレイアウトを表示する時について

Composable関数を新しく作成して、レイアウトを構成していくと思いますが、この時にComposable関数の内部で、2つ3つとレイアウトを出力していないかどうかをチェックしてくれます。

以下のような場合、チェックに引っかかります。

@Composable
fun Parent() {
    Child(modifier = Modifier)
}

@Composable
fun Child(
    modifier: Modifier = Modifier
) {
    Text("Hello")
    Text("World")
    Button(.....)
}

この例では、Childの内部で3つレイアウトを出力しています。
これではルールに引っかかってしまうので、以下のように改善する必要があります。

@Composable
fun Parent() {
    Child(modifier = Modifier)
}

@Composable
fun Child(
    modifier: Modifier = Modifier
) {
    Colmun(
        modifier = modifier
    ) {
        Text("Hello")
        Text("World")
        Button(.....)
    }
}

Jetpack Composeでは、従来のAndroid Viewよりもレイアウトのネストに関してパフォーマンスの低下が目立たないので、UIを正しく表示するためにもレイアウトを表示するComposable関数は1つのレイアウトを表示するようにする必要がありそうです。

ただし、以下のような場合は例外として許容されているようです。

@Composable
private fun ColumnScope.ChildContent() {
    Text("Hello")
    Text("World")
    Button(.....)
}

この例では、ColumnScopeでレイアウトが定義されているのでチェックに引っかからず通すことができます。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeMultipleContentEmitters.kt

Composition Local

Composition Localを使用する際は、Localを接頭辞としているかどうかをチェックします。
以下のような例です

// OK!
val LocalTheme = staticCompositionLocalOf<Theme>()

// Bad!
val ThemeLocal = staticCompositionLocalOf<Theme>()

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeCompositionLocalNaming.kt

Preview

複数のPreviewを使用する際に、カスタムアノテーションを作成した場合は、Previewsをつけているかどうかチェックします。

カスタムPreviewの例

@Preview(
    name = "small",
    group = "font scales",
    fontScale = 0.5f
)
@Preview(
    name = "medium",
    group = "font scales",
    fontScale = 1.0f
)
@Preview(
    name = "large",
    group = "font scales",
    fontScale = 1.5f
)
annotation class FontScalePreviews

Previewのカスタムアノテーションを作成した場合は、今回のようなチェックがなされますが、シンプルに一つだけPreviewしたい場合は、Previewだけでも問題ありません。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposePreviewNaming.kt

Composable関数の名前

レイアウトを表示するComposable関数(Unitを返す関数)に関しては、関数の名前は大文字で始める必要があり、逆に値を返すComposable関数は小文字で名前を始めているかどうかをチェックします。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeNaming.kt

Composable関数の引数の順番について

関数に渡す引数が、必須パラメーターを最初に書いて、オプショナルなパラメーターを必須パラメーターの後に書いているかどうかチェックしてくれます。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeParameterOrder.kt

依存関係を分ける

Composable関数の内部で、ViewModelを取得するようにしていると、テスタビリティの観点でも悪影響が出てきますし、Composable関数を再利用しにくくなるので、ViewModelなどを取得したい場合は、デフォルト値としてInjectしているかどうかチェックしてくれます。

以下のような例です。

@Composable
fun DemoComposable(
    val viewModel: DemoViewModel = viewModel()
) {
    .......
}

このように引数のデフォルト値としてViewModelを指定する必要があります。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeViewModelInjection.kt

Previewがprivateかどうか

Previewでしか使用しない、Composable関数はpublicな状態にする必要がないので、privateになっているかどうかチェックしてくれます。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposePreviewPublic.kt

Modifierについて

Modifierを必ず渡す

Modifierを全ての関数に渡しているかどうかをチェックしてくれます。
なぜ、Modifierを渡す必要があるのかは以下の記事を見てみてください。
https://chris.banes.dev/posts/always-provide-a-modifier/

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeModifierMissing.kt

Modifierを使いまわさない

Modifierを渡したら、それを一番上の関数のModifierとしてだけ使っているかどうかチェックしてくれます。
Modifierを使い回すことで、意図しないレイアウトになることがあるので、ここも注意が必要です。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeModifierReused.kt

Modifierにデフォルト値を設定する

Modifierを必ず、引数に渡すようにする必要があると上で述べましたが、渡される側の関数ではModifierの引数に対してデフォルト値を指定しているかどうかをチェックしてくれます。

実際のルール
https://github.com/twitter/compose-rules/blob/main/rules/common/src/main/kotlin/com/twitter/compose/rules/ComposeModifierWithoutDefault.kt

Modifierについて、詳しくみたい方は以下を見てみてください。
https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier
https://www.youtube.com/watch?v=BjGX2RftXsU&t=1128s

実際に使ってみる

まずは、ktlintを導入していきます。

plugins {
    id("org.jmailen.kotlinter") version "3.12.0" apply false
}
plugins {
    id("org.jmailen.kotlinter")
}

ここから下記を実行してみます。

$ ./gradlew formatKotlin

大量の指摘が出てきたので、ひとまず導入できました。

/Users/s18405/StudioProjects/AndroidDoughnut/app/src/main/java/com/github/ryutaro/androiddoughnut/domain/repository/ArticleRepository.kt:6:1: Format fixed > [no-unused-imports] Unused import
/Users/s18405/StudioProjects/AndroidDoughnut/app/src/main/java/com/github/ryutaro/androiddoughnut/domain/repository/ArticleRepository.kt: Format fixed
/Users/s18405/StudioProjects/AndroidDoughnut/app/src/main/java/com/github/ryutaro/androiddoughnut/domain/usecase/FetchAllArticleUseCaseImpl.kt:7:1: Format fixed > [no-unused-imports] Unused import
/Users/s18405/StudioProjects/AndroidDoughnut/app/src/main/java/com/github/ryutaro/androiddoughnut/domain/usecase/FetchAllArticleUseCaseImpl.kt: Format fixed

> Task :app:formatKotlinTest
/Users/s18405/StudioProjects/AndroidDoughnut/app/src/test/java/com/github/ryutaro/androiddoughnut/ui/viewmodel/HomeScreenViewModelTest.kt:8:1: Format fixed > [no-unused-imports] Unused import
/Users/s18405/StudioProjects/AndroidDoughnut/app/src/test/java/com/github/ryutaro/androiddoughnut/ui/viewmodel/HomeScreenViewModelTest.kt:23:5: Format fixed > [no-unused-imports] Unused import
/Users/s18405/StudioProjects/AndroidDoughnut/app/src/test/java/com/github/ryutaro/androiddoughnut/ui/viewmodel/HomeScreenViewModelTest.kt:27:7: Format fixed > [no-unused-imports] Unused import
/Users/s18405/StudioProjects/AndroidDoughnut/app/src/test/java/com/github/ryutaro/androiddoughnut/ui/viewmodel/HomeScreenViewModelTest.kt: Format fixed

次に、Twitter Jetpack Compose Rulesを導入してみます。

 buildscript {
    dependencies {
    classpath "com.twitter.compose.rules:ktlint:0.0.18"
}

ここから下記を実行してみると

$ ./gradlew lintKotlin

無事実行できたようです🙌

最後に

今回は、Twitter Jetpack Compose Rulesについて見ていきました。
実際にチーム開発の中で、このルールを導入することによって、チーム内でJetpack Composeの秩序が保たれそうなので、積極的に導入して行ってもいいのではないかと思いました🙆‍♂️

参考

https://twitter.github.io/compose-rules/

https://twitter.com/mrmans0n/status/1507390768796909571?ref_src=twsrc^tfw|twcamp^tweetembed&ref_url=notion%3A%2F%2Fwww.notion.so%2FTwitter-Jetpack-Compose-Rules-9dff2dd1f8d8447d88d7c4bd69b0a5c5

https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md#naming-compositionlocals

https://github.com/jeremymailen/kotlinter-gradle#multi-module-and-android

Discussion