Jetpack Composeがalpha版になったので改めてQiitaビューアを作ってみた

この記事は[NTTテクノクロス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ntt-tx)の4日目です。

こんにちは、NTTテクノクロスの戸部@etctaroと申します。
普段は社内でモバイルアプリ開発関連の技術支援や社内向けのノウハウ記事執筆、[社内研修講師活動](https://www.ntt-tx.co.jp/products/soft_dojyo/)、ハンズオン会の開催などを行っています。

# はじめに
- [Jetpack Compose](https://developer.android.com/jetpack/compose?hl=ja)については[昨年の記事](https://qiita.com/etctaro/items/587419c6edc7b19510b8)でも取り上げました。
- 一言で述べるなら、「Androidアプリ開発における新しいUIの実装方法」となります。
- 詳しく知りたい方は[公式サイト](https://developer.android.com/jetpack/compose?hl=ja)を読んでいただいたり、[CodeLab](https://developer.android.com/codelabs/jetpack-compose-basics)で手を動かしたりしてみていただいても良いかと思います。
- 昨年はプレビュー版が出たてホヤホヤでしたが、今年はアルファ版になり、少しずつ実用化が近づいてきた印象があります。
- そんなこんなで昨年の記事も振り返りつつ、現在の仕様に合わせて再びQiitaビューアを作ることにいたします。

# 注意点
- Android Studio 4.2はCanary16版、Jetpack Composeは1.0.0-alpha07版を使います。
- いずれもバージョンアップなどで変更が入る可能性が十分に想定されますのでご留意ください。
    - **などと書いていたら、[Android Studioの新しいバージョンArctic Foxがリリースされ](https://developer.android.com/studio/preview)無事フラグを回収しました。**
    - マイグレーションについては[こちら](#補足-android-studioの更新対応to-arctic-fox)を参照してください。
- ※なお、以下は動作確認のための最低限の実装となりますので、本格実装するのであればリファクタリングの呼吸壱の型全集中でお願いします。

# 目標
- まずは[昨年と同等のQiitaビューア](https://qiita.com/etctaro/items/587419c6edc7b19510b8#%E3%81%93%E3%82%93%E3%81%AA%E3%82%A2%E3%83%97%E3%83%AA%E3%82%92%E4%BD%9C%E3%82%8B%E3%81%93%E3%81%A8%E3%81%AB%E3%81%97%E3%81%9F)を作る
- あとは時間の許す限り、Jetpack Composeのユニットテストの実行などいろいろ遊ぶ。

# ところで昨年のコードは？
- 昨年の記事の中で作成したコードについては、アップグレードが必要、というメッセージとともに各種ライブラリ類を更新することになりました。
- State周りの仕様が変わっているのでビルドに失敗するはず・・・と思っていましたが無事エラーとなりました。
- （と、書きつつも、一から作り直す想定だったのでどうしようということもありませんが。）

# 前提条件
以下の環境を前提とします。

- Android Studio 4.2 (Canary 16)
- Target Sdk Version 29
- 通信、シリアライズについては、[Ktor](https://ktor.io/)および[kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization)を使います。(そしてこの選択が鬼門に。)

# 昨年からの更新内容
- [Stateについては最新の仕様に対応する。](#step3-state状態の管理)
- [リスト要素のクリックはModifier.clickableが使える。昨年はClickableで囲んでいた。](#step5-記事クリック時の処理)
- [リストのスクロール（縦方向）はScrollableColumnが使える。昨年はFlexColumnとVerticalScrollerを必要としていた。](#複数の記事の一覧を表すcomposable)
- [FABの配置はScaffoldに任せることにした。](#一画面分のcomposableの実装)
- [Contextの取得は無理矢理やらずにContextAmbientを利用](#step5-記事クリック時の処理) ([参考](https://medium.com/@m_coder/jetpack-compose-%E3%81%A7-context-%E3%82%92%E5%8F%96%E5%BE%97%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95-520358a7c08d))
- [UIテストに全集中](#uiテスト)

# 大ハマりポイント
今年も相変わらず無事ハマりました。と言っても、1つ大きくハマったくらいで、あとは公式のガイドなどをみつつ進めていけたようには思います。

- [kotlinx.serialization + Jetpack Composeの組み合わせを同じモジュール内で実装した場合、ビルドが終わらない](#kotlinxserialization--jetpack-composeの組み合わせを同じモジュール内で実装した場合ビルドが終わらない)
- [Android Studioの新バージョンがリリースされた](#補足-android-studioの更新対応to-arctic-fox)

## kotlinx.serialization + Jetpack Composeの組み合わせを同じモジュール内で実装した場合、ビルドが終わらない
- Ktorで通信してその結果をシリアライズ、画面に反映する、という割とやりがちなことをやろうとしたところ、そもそもビルドのタスクが終わらないという状況に。
- 被疑箇所を探るためにdependeniesなどを一つずつコメントアウトなどしながら確認。
- 結論として、kotlinx.serializationのアノテーション(`@Serializable`など)を使ったクラスをJetpack Composeを使ったプロジェクトに含めていると、ビルドできないことがわかりました。
- Jetpack Composeのコードで実際に使っていなくても、Entityのクラスが同じモジュールにあるだけでもNGなようです。(少なくとも私の環境では。)
- おや、と思いつつ、issueを探していたら[まさにありました]( https://github.com/Kotlin/kotlinx.serialization/issues/968)
    - そのうち修正するよ、とは書いていただいているので、本格的にはそれを待つことになるかもしれません。
    - 暫定的な対応としては、同じissueの中で以下の通り書かれている通り、別のモジュールで実装するのが良さそうです。(いいね1000個差し上げたい気分)

        ```
        This issue only exists when using compose+serialization if used within the same module. Temporary workaround is to just separate the components into different modules (ui/app+network/serialization).
        ```

- ということで、シリアライズ＋通信用の処理については別のモジュールに持っていくことにしました。
    - [この記事](https://qiita.com/yst_i/items/94343ddbc3f9ef3ed5a3)が大変わかりやすかったので、そのまま参考に。

# Step1: 通信、シリアライズ周りの実装
繰り返し述べているように、今回は`kotlinx.serialization`、`Ktor`を使います。  
以下、バージョンについては一例です。状況に応じて最新化などが必要になります。

また、ハマりポイントでも書いた通り、この処理はUIとは別モジュールとしました。

## moduleのbuild.gradleの変更
`Serialization`のプラグインを追加します。

```groovy
plugins {
    ...
    id 'org.jetbrains.kotlin.plugin.serialization' version '1.4.10' // 追記
}
```

また、以下の依存関係を追加します。(モジュールの`build.gradle`)

```groovy
dependencies {
...
    // Ktor
    implementation "io.ktor:ktor-client-core:1.4.1"
    implementation "io.ktor:ktor-client-serialization:1.4.1"
    implementation "io.ktor:ktor-client-android:1.4.1"

    // Serialization
    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1"
}
```

## Entityの作成
- `kotlinx.serialization`を使ったdataクラスにしました。

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

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

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

## 通信部分の実装
Qiitaの情報を取得するために[APIを叩きます](https://qiita.com/api/v2/docs)。
QiitaのAPIからはJSONの形で結果を受け取るので、必要な要素だけシリアライズします。  
また、HttpClientにはengineの設定が必要になります。エラーメッセージにも表示されますが、[ここ](https://ktor.io/docs/http-client-engines.html#android)を参考にしました。  

```Kotlin
class QiitaApi {
    private val httpClient = HttpClient(Android) {
        engine {
            connectTimeout = 100_000
            socketTimeout = 100_000
        }
        install(JsonFeature) {
            val json = kotlinx.serialization.json.Json {ignoreUnknownKeys = true}
            serializer = KotlinxSerializer(json)
        }
    }

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

    suspend fun getAllArticles(): List<QiitaArticle> {
        return httpClient.get(QIITA_URI)
    }
}
```

## UI側でこのモジュールをdependencyに追加する

```groovy
dependencies {
    implementation project(":mylibrary")
    (以下略)
}
```

# Step2: アプリのUI部分を実装する
さて、ここからはJetpack Composeの世界になります。

## 一つの記事を表すComposable
- タイトルとURLを縦並びで表示し、これを1つの記事として表示します。
- タップ時の処理をModifier.clickableで実装する点が昨年との違いです。これは楽。

```Kotlin
@Composable
fun QiitaItem(title: String, url: String) {
    Row(Modifier.clickable(onClick = {})) {
        Column() {
            Text(text = title)
            Text(text = url)
        }
    }
}
```

## 複数の記事の一覧を表すComposable
- 前項のQiitaItemをさらに縦並びで表示します。
- スクロール可能な一覧とする場合は、`ScrollableColumn`を使うことで実現ができます。これもとても楽になりました。

```Kotlin
@Composable
fun QiitaItemList(items: List<QiitaArticle>) {
    ScrollableColumn {
        for (item in items) {
            QiitaItem(title = "${item.title}", url = "${item.url}")
            Divider(color = Color.Black)
        }
    }
}
```

## 一画面分のComposableの実装

- ここまでで実装したComposableと、FABを組み合わせます。
- `Scaffold`を使うことでFABも手軽に配置することができます。
- FABのクリック時の処理などについては後述します。

```Kotlin
@Composable
fun HomeScreen() {
    MaterialTheme {
        Scaffold(
                bodyContent = {
                    QiitaItemList(items = listOf())
                },
                floatingActionButton = {
                    FloatingActionButton(
                            onClick = {}
                    ) {
                        Text("reload")
                    }
                }
        )
    }
}
```

# Step3: State(状態)の管理
- 昨年は`@Model`のアノテーションを付与することで監視可能な状態を表していましたが、現行の仕様では使用できません。
    - ※昨年のハマりポイントではModelListなるクラスを使っていましたが、こちらは現在はAPI上存在しません。
    - ※Stateについての公式ドキュメントは[こちら](https://developer.android.com/jetpack/compose/state?hl=ja#use-other-types-of-state-in-jetpack-compose)
- 監視可能なリストについては、[mutableStateListOf](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#mutablestatelistof)を使うことができます。本記事ではこれを使います。
- なお、State管理については他にも方法がありますが、ここでは特に考慮しません。

```Kotlin
@Composable
fun HomeScreen() {
    val qiitaArticles = remember {mutableStateListOf<QiitaArticle>()}

    MaterialTheme {
        Scaffold(
                bodyContent = {
                    QiitaItemList(items = qiitaArticles)
                },
                floatingActionButton = {
                    FloatingActionButton(
                        onClick = {
                        }
                    ) {
                        Text("reload")
                    }
                }
        )
    }
}
```

# Step4: FABの処理
- FABをタップした際の処理を実装します。
    - 通信を実行する
    - APIの結果を取得する
    - 一つ前の項で用意したqiitaArticlesに対して、取得したQiitaArticleの一覧を反映します。
- qiitaArticlesに変更が入ると、HomeScreenが書き換えられるという状態になります。

```Kotlin
    FloatingActionButton(
        onClick = {
            MainScope().launch {
                try {
                        val articles = QiitaApi().getAllArticles()
                        qiitaArticles.clear()
                        qiitaArticles.addAll(articles)
                } catch (e: Exception) {
                    Log.e(e)
                }
            }
        }
    ) {
        Text("reload")
    }
```

# Step5: 記事クリック時の処理
- 記事をクリックした際に、そのURLをブラウザで表示しようと思います。
- 昨年は結構無茶な実装をしましたが、Contextの取得は無理矢理やらずに[`ContextAmbient`](https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/package-summary#contextambient)を利用すれば比較的容易にできます。
- `Intent`を投げるだけなら、これを使えば良いです。
- ということで、`QiitaItem`の`Row`の`onClick`について実装を行います。

```Kotlin
@Composable
fun QiitaItem(title: String, url: String) {
    val context: Context = ContextAmbient.current

    Row(Modifier.clickable(onClick = {
        val uri = Uri.parse(url)
        val intent = Intent(ACTION_VIEW, uri)
        startActivity(context, intent, null)
    })) {
        Column() {
            Text(text = title)
            Text(text = url)
        }
    }
}
```

# Step6: スタイルの変更
- [公式のチュートリアル](https://developer.android.com/jetpack/compose/tutorial?hl=ja)などを参考に、QiitaItemの一行の表示を変えてみました。(サンプル通り。)
- こちらも参考になります：[Compose でのテーマ設定](https://developer.android.com/jetpack/compose/themes?hl=ja)

```Kotlin
        Column() {
            Text(text = title, style = typography.h6)
            Text(text = url, style = typography.body2)
        }
```


# ここまでの完成形
![とりあえず完成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/40b13e62-3e41-cfa3-afae-cea8180c4fa3.png)

# もう少し変更・試してみる
## FABをカスタマイズ
- FloatingActionButtonはアイコンを一つ表示するのに使われるようで、デフォルトだと円になりました。(この点は昨年と違う。)
- 文字列のように少し横長にした方が良い場合は[ExtendedFloatingActionButton](https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary?hl=ja#extendedfloatingactionbutton)を使います。
- 本来はアイコンだけで使い方が伝わった方が良いかもしれませんが。

```Kotlin
            floatingActionButton = {
                ExtendedFloatingActionButton(
                    onClick = {
                        // 略
                    },
                    text = {Text("reload")}
                )
            }
```

![横長FAB](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/89900de2-0a6e-14bf-284b-46d8766e0575.png)

色味はともかく、去年と同じように文字に合わせて横長になりました。

- テキストとアイコンを並べることもできます。

```Kotlin
            floatingActionButton = {
                ExtendedFloatingActionButton(
                    onClick = {
                        // 略
                    },
                    text = {Text("reload")},
                    icon = {Icon(asset = Icons.Filled.Refresh)}
                )
            }
```

![横長FAB with アイコン](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/92d37cf2-9da8-780e-4ead-ba79f6773463.png)

## UIテスト
昨年は諦めたUIテストですが、Jetpack Composeでも動かせるようになりつつあるので、試してみました。

- dependencyの追加

```groovy
dependencies {
    (略)
    // forTest
    androidTestImplementation("androidx.ui:ui-test:$compose_version")
}
```

- プロダクトコードへのラベル追加

Jetpack ComposeのUIテストを実施する場合、Composableのテキストを使って探すこともできますが、そもそもテキストを持たない場合(例えばアイコンやレイアウト用のComposableなど)は探せません。

このため、プロダクトコードに対してAccessibilityのテキストを付与することで、テストコード側でもこのComposableを探すことが可能となります。

```Kotlin
    ScrollableColumn(
        modifier = Modifier.semantics {accessibilityLabel = "Item List"}) {
        (略)
    }

    ExtendedFloatingActionButton(
        modifier = Modifier.semantics {accessibilityLabel = "Refresh Button"}
    )
```

- テストコードの作成

```Kotlin
class MainActivityTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun test_RefreshButton_tap_once() {
        composeTestRule.setContent {
            SampleCompose100AlphaTheme {
                HomeScreen()
            }
        }

        // Nodeのツリーを出力
        composeTestRule.onRoot().printToLog("ForComposeUITest")

        // FABをAccessibilityで探す→タップ
        composeTestRule.onNodeWithLabel("Refresh Button").performClick()

        // とりあえず少し待つ
        Thread.sleep(5000)

        // Nodeのツリーを再び出力
        composeTestRule.onRoot().printToLog("ForComposeUITest")

        // Columnを探す→子が20個あるはず
        composeTestRule.onNodeWithLabel("Item List").onChildren().assertCountEquals(20)
    }
}
```

- その他ハマったポイント

こんなエラーメッセージが出てテスト実行時に失敗しました。

```
2 files found with path 'META-INF/AL2.0' from inputs
```

メタ情報が重複している、ということですが、appの`build.gradle`に以下を追加して対応。

```groovy
android {
    packagingOptions {
        exclude 'META-INF/AL2.0'
        exclude 'META-INF/LGPL2.1'
    }
}
```

- ということで
![Jetpack Composeのuitest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/569c519f-b5ce-27cb-d784-b8a8ea01da42.gif)
やったぜ。

- UIテストについての雑感
[公式のドキュメント](https://developer.android.com/jetpack/compose/testing?hl=ja)にも書かれていた通り、Espressoに近い、というのはまさしくその通りだと感じました。
ただ、若干情報が不足している感はあり、ラベル付けの話などは自前で試行錯誤してみた結果です。
（一応、APIはあったので、多分できるんだろうと思ってはいましたが。）

# 補足: Android Studioの更新対応(to Arctic Fox)
この記事を書いている時に丁度[2020.3.1 Arctic Fox Canary1のリリース](https://androidstudio.googleblog.com/2020/12/android-studio-arctic-fox-canary-1-available.html)がありました。ここではマイグレーションについて補足します。

- ポイント
    - Arctic Foxでプロジェクトを動かす場合はGradle Pluginの更新が必要
    - 新しいGradle Pluginで動かすためにはJDK11が必要
    - マイグレーションはAndroid Studioの指示に従うと楽

## Arctic Foxでプロジェクトを動かす場合はGradle Pluginの更新が必要
- Arctic Foxでは古いバージョン(4.2以前)のGradle Pluginは動作しません。ビルドしようとすると以下の通りエラーメッセージが表示されます。

```
The project is using an incompatible version (4.2.0-alpha16) of the Android Gradle plugin.
```

- このため、バージョンをArctic Foxが対応する7系以降に変更する必要があります。
- 新規にプロジェクトを作成した際には、rootのbuild.gradleは以下のように設定されています。このように設定してください。

```groovy
    dependencies {
        classpath 'com.android.tools.build:gradle:7.0.0-alpha01'
        ...
```

- また、対応するGradleの最小バージョンが6.7.1となりますので、こちらも更新が必要です。(`gradle/wrapper/gradle-wrapper.properties`)

```
distributionUrl=https://services.gradle.org/distributions/gradle-6.7.1-bin.zip
```

なお、以前のバージョンのAndroid Studioで動かす場合は、プラグインのバージョンの設定を対応する値に戻す必要があります。

## 新しいGradle Pluginで動かすためにはJDK11が必要
- Gradle Pluginの7.0.0-alpha01（おそらくそれ以降も）を動かすためにはJDK11が必要です。8を利用していた場合は以下のエラーメッセージが表示されます。

```
Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.
You can try some of the following options:
  - changing the IDE settings.
  - changing the JAVA_HOME environment variable.
  - changing `org.gradle.java.home` in `gradle.properties`.
```
- JDKの設定については、Project Structureから変更します。MacOSなら`Command + ;`か、メニューの`File > Project Structure`です。
![スクリーンショット 2020-12-02 13.44.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/fa40138e-b202-b17e-82e8-d868821dea95.png)

## マイグレーションはAndroid Studioの指示に従うと楽
- プラグインの更新が必要なプロジェクトを開いた場合、またはビルド時のエラーメッセージで`Upgrade to the latest version`というリンクが表示されるので、それをクリックするとUpgradeを促されます。

![スクリーンショット 2020-12-02 13.14.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/2bf78c20-1196-40c7-edba-539d98a56335.png)

- 折角勧められているのでこれに従ってみますと以下の変更が入ります。
    - Gradleのバージョンが6.7.1に更新。
    - Gradle Pluginのバージョンが7.0.0-alpha01に更新。

# この記事で作成したサンプルコード
- 作成したサンプルコードは[GitHub](https://github.com/etctaro-tech/SampleComposeQiitaViewer)に掲載しています。

# おわりに
- ということで、今年もつらつらとJetpack Composeについて書いてきました。
- 昨年やり残したUIテストもついに実行できてAdvent Calendarの記事としては満足なところです。
- これからJetpack Composeがより広がっていくことになっていくと思いますので、そんな時に少しでも役に立てれば良いと思います。
- ということで、皆様、良いJetpack Composeライフを。

# 令和TXコソコソ噂話
5日目は武井@thetakeiさんが開発とセキュリティの関係について語ってくださるようですよ。
明日もぜひご覧ください！