Android Studio 4.0でJetpack Composeと戯れながらQiitaビューアを作ってみた

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

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


# はじめに

- [Jetpack Compose](https://developer.android.com/jetpack/compose)は[Google I/O 2019](https://events.google.com/io/)で発表されたAndroidアプリの宣言的UI実装のためのライブラリ群です。
- これまでAndroidで使われていたレイアウトエディタ + XMLによるUIの実現ではなく、FlutterやSwiftUIのように、画面の各UI部品を`@Composable`というアノテーションをつけた関数、およびその複数の組み合わせでUIを実現できます。
- Android Studio 4.0のCanary版にて利用できるようになりました。
- Google I/O直後は専用のIDEをリポジトリから取得する必要があり、若干ハードルが高かったところではありますが、IDEとして公式にサポートされることにより利用しやすくなりました。
- そのような経緯と、個人的な興味がありましたので、サンプル的なアプリ作成を通じて戯れて（**と言う名目の人柱になって**）みました。

# 注意点
- Android Studio 4.0、Jetpack Composeについてはプレビュー版であり、正式版では状況が変わることが予想されますので、その点はご注意ください。

# こんなアプリを作ることにした

新しい開発手法が出てきたということで、社内の伝統に則り(？)、とりあえずQiitaのAPIを叩いて画面に一覧表示するViewerを作ってみようと思います。

要件としては以下のようなイメージ。

- アプリを起動すると、空の一覧画面とリロードボタンが表示される。
- リロードボタンを押すと以下の処理を実施する。
  - Qiitaの記事取得のAPIを実行し記事の一覧(JSON)を取得する。
  - 記事の一覧(JSON)をオブジェクト化する。
  - 取得した記事の一覧を(一旦クリアしてから)画面に反映する。
- 記事の一覧をスクロールして表示できる
- 一覧をタップしたら、タップした記事のURLをブラウザで表示する

[完成形イメージ](#ここまでの完成形)

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

- Android Studio 4.0 (Canary 4)
- Target Sdk Version 29

# (ハマり)ポイント
- 宣言的に実装するのは割とわかりやすい。ただし、現時点では参考ドキュメントが不足。
- [リストをStateとして保持したい場合にはModelListを使う必要がある。](#state状態の管理)
- [スクロールするためには、`VerticalScroller`と`FlexColumn`を組み合わせる。](#縦スクロール)
- [通信の処理はこれまでと同様にできる。](#通信の処理)
- [Jetpack Composeでインテント・・・どうやって投げるの？](#記事クリック時の処理)
- [UIテストは現時点では無理。](#おまけ-uiテストについて)

# Step1: アプリ用のプロジェクトを作成する
- Android Studio 4.0(Canary)から新規にアプリのプロジェクトを作成しようとすると`Empty Compose Activity`というテンプレートが選べます。

![Jetpack Compose用のPJ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/8dc947f5-cb3d-5c48-b7fc-c4b6f3db2a08.png)

- このテンプレートを選び、適宜プロジェクト名などを埋めプロジェクトを作成します。

# Step2: アプリのUI部分を実装する

早速ですが、Jetpack ComposeでUI部分を実装します。

## 記事の一覧っぽいものを実装する

- Jetpack Composeでは`@Composable`というアノテーションをつけた関数の組みあわせでUIを表現します。
- 今回作ろうとしている例では、記事の一覧を`Text`、`Row`、`Column`という`Composable`を組み合わせて作成することにします。
- 公式サンプルの`Greeting`の例に則り、Qiita記事の一行を表す`Composable`を作ってみました。
- 一覧には`タイトル`と`記事のURL`を表示します。

### データクラスの作成
- 今回は必要となるQiitaの記事のtitleとurlだけを保持するdata classを作成します。

```
data class Qiita(val title: String, val url: String)
```

### 一つの記事を表すComposable

- 画面には`Column`を使い、各記事のタイトルとURLを縦並びで表示させることにします。
- `Row`は`Composable`を横並びで表示する際に使います。この後に何かボタンを入れようと思ってとりあえず追加してみました。(が、この記事では最後まで何も使っていません。)

```
@Composable
fun QiitaItem(title: String, url: String) {
    Row() {
        Column() {
            Text(text = title)
            Text(text = url)
        }
    }
}
```

### 複数の記事の一覧を表すComposable

`QiitaItem`を複数組み合わせて記事の一覧とします。

- データクラス`Qiita`のオブジェクトのリストを通信で取得する前提ですので、これを引数としました。
- 区切り線(`Divider`Composable)も追加してみました。
- 現時点の公式のAPIには`ListView`のようなクラスが見当たらないので、forループでQiitaItemを呼び出すようにしました。

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

### 一画面分のComposableおよび、Activityの実装

ここまでの情報を組み合わせて、一つの画面を示すComposableは以下の通りになります。
QiitaItemListの引数は適当なリストにしています。実際には、通信で取得した結果がここに入ることになります。

```
@Composable
fun HomeScreen() {
    MaterialTheme {
        QiitaItemList(listOf(Qiita("title1", "http://url1")))
    }
}
```

また、この`Composable`を使った、Activityの実装は以下の通りです。setContentに指定してあげるだけです。

```
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            HomeScreen()
        }
    }
}
```

### プレビュー表示

`@Composable`にさらに`@Preview`というアノテーションを加えることで、Android Studio上で画面イメージを見ることもできます。

```
@Preview
@Composable
fun prevQiitaItem() {
    QiitaItem("title1", "http://sample.co.jp/")
}

@Preview
@Composable
fun prevQiitaItemList() {
    QiitaItemList(
        listOf(
            Qiita("title1", "http://sample.co.jp/1"),
            Qiita("title2", "http://sample.co.jp/2"),
            Qiita("title3", "http://sample.co.jp/3")
        )
    )
}
```
![Composableのプレビュー表示](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/b0f77fef-09e6-3be2-ab94-3c75e54cc8c5.png)


## State(状態)の管理
アプリで保持している何かしらの値が変更されたのを検知し、画面に反映させる場合、FlutterのStateと同様にJetpack Composeの場合にも`State`(状態)の概念を導入することができます。
CodeLabでは以下で紹介されています。

- https://codelabs.developers.google.com/codelabs/jetpack-compose-basics/#4

自作classまたはobjectで状態を管理する場合は、`@Model`というアノテーションをつけることで、状態管理ができます。

今回はQiitaの記事の一覧を更新したいので、以下のようにQiitaのリストを持つModelをトップレベルで用意してみました。

```

@Model
object QiitaItemsStatus {
    val dataList = ArrayList<Qiita>()

    fun addItem(item: Qiita) {
        dataList.add(item)
    }

    fun clearItem() {
        dataList.clear()
    }
}
```

では適当にボタンを用意して、addItemを呼んでみます。

```
@Composable
fun SampleAddClearButton() {
    Column() {
        Row() {
            Button(text = "addItem", onClick = {
                with(QiitaItemsStatus) {
                    addItem(Qiita("1", "aaa"))
                    println(dataList.toString())
                }
            })
            Button(text = "clearItem", onClick = {
                with(QiitaItemsStatus) {
                    clearItem()
                }
            })
        }
    }
}
```

→ 何も起こらない。正確には、ボタンを押すたびにdataListの中身は増えているようですが、画面に反映されません。

## State(状態)の管理 r1

そこで、公式サンプルの[JetNewsアプリ](https://github.com/android/compose-samples/tree/master/JetNews)を確認してみました。

```
@Model
object JetnewsStatus {
    var currentScreen: Screen = Screen.Home
    val favorites = ModelList<String>()
    val selectedTopics = ModelList<String>()
}
```

- ModelListというクラスが使われていました。
  - https://developer.android.com/reference/kotlin/androidx/compose/frames/ModelList

詳細な情報が若干不足していますが、かなり怪しいのでこのクラスを使ってみることにします。

```
object QiitaItemsStatus {
    val dataList = ModelList<Qiita>()
}
```

→ビンゴでした。dataListを更新すると、画面に反映できるようになりました。

- ポイント：
  - `State`にリストを使う場合は`ModelList`を使う必要がある(らしい)

## 縦スクロール
- 今回作るようなアプリでは縦スクロールが必要となります。CodeLabでは特に触れられていませんでしたが、公式サンプルのJetNewsアプリを参照したところ、以下の通り対応することで垂直方向のスクロールが実装できます。

```
    FlexColumn() {
        flexible(flex = 1f) {
            VerticalScroller {
                FooComposable()
            }
        }
    }
```

ネーミング的には`VerticalScroller`があれば良さそうですが、FlexColumnで囲む必要がありました。

## FAB(FloatingActionButton)
FABはJetpack ComposeのAPIに用意されていますが、そのまま使うと画面の全体がボタンになります。以下のように`Align`で囲むことで、`Basic Activity`テンプレートのようなボタンになります。

```
        Align(Alignment.BottomRight) {
            Padding(padding = EdgeInsets(right = 16.dp, bottom = 16.dp)) {
                FloatingActionButton(text = "reload", onClick = {
                    // タップ時の処理
                })
            }
        }
```

ここまでで、UI周りでやりたいことの実装は概ねできました。

# Step3: 通信の処理などを実装する

## 通信の処理
通信の処理については、既存の知識の踏襲で実装できますので、ここでは省略します。
ここでは、fuel(通信)およびmoshi(JSONのシリアライズ)というライブラリを利用して実装しました。詳しくは下記を参照してください。

- fuel: https://github.com/kittinunf/Fuel/tree/master/fuel#installation
- moshi: https://github.com/square/moshi#download
- fuelとmoshiの連携: https://qiita.com/naoi/items/144950ea422cea86274d

仮に、通信結果をシリアライズし、dataListに詰めるところまでの処理を`fetchQiitaData()`という関数で実装したものとします。

この関数をFABのonClickで呼ぶようにします。

```
                FloatingActionButton(text = "reload", onClick = {
                    fetchQiitaData()
                })
```

## 記事クリック時の処理

記事の`@Composable`をクリックした時、ブラウザで記事を表示するような機能をつけようと思います。
`Row`などのように、クリック時の処理を記載するパラメータを持たない場合は、`Clickable`で囲むことによってonClickパラメータに処理を記述することができます。

```
    Clickable(onClick = {
        //ここに記述する
    }) {
        Row() {
            Column() {
                Text(text = title)
                Text(text = url)
            }
        }
    }
```

さて、クリックできるのは良いですが、URLをブラウザで開くような処理はどのようにすれば良いでしょうか。
既存であれば、startActivityにIntentを渡してあげるような実装でできますが。

ここは、Jetpack Composeの世界だけではどうにもならなかったため、
以下のようにしてみました。

- これを

```
// トップレベルに記述
var callbackOnLinkClicked : (Any) -> Unit = { }
```

- こうして

```
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        callbackOnLinkClicked = {
            startActivity(it as String)
        }

        setContent {
            HomeScreen()
        }
    }

    private fun startActivity(url: String) {
        val uri = Uri.parse(url)
        val intent = Intent(ACTION_VIEW,uri)
        startActivity(intent)
    }
}
```

- こうじゃ

```
@Composable
fun QiitaItem(title: String, url: String) {
    Clickable(onClick = {
        callbackOnLinkClicked(url)
    }) {
// 略
}
```

# ここまでの完成形
![サンプル完成形](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/8d44549d-8c7e-94f7-f06e-bdfda4413bbe.png)

- 記事執筆時のAPI( https://qiita.com/api/v2/items )実行結果です。(内容に他意はありません。)
- 見た目が若干気になったので、少しだけ文字サイズを調整してみました。

```
    Row() {
        Column() {
            Text(text = title, style = +themeTextStyle { subtitle1 })
            Text(text = url, style = +themeTextStyle { caption })
        }
    }
```

![文字サイズを調整](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/ecd0c44e-1cdf-a86f-b323-6a431a148abf.png)


# そのうちやりたいこと
- 今回はとりあえず動かすことを優先させましたが、一つのファイル内で色々とやっているのでリファクタリングを行う必要があります。
  - JetNewsのサンプルを見ていると以下のようにしているように見えます。
    - 一つの画面（タブで切り替えられたりする場合もそれぞれ1画面として数える）を1パッケージとしてディレクトリを切る。
    - 一つの`@Composable`関数が肥大化しそうなら適宜関数を分ける。
    - 一つの画面の中で`@Composable`が多くなったら適宜ファイルを分割する。
    - Stateやスタイルについても別ファイルに抽出する。

    このあたりは一般的なコーディングと同様かと思います。

- 見た目についてももう少しこだわりたいと思います。

# おまけ: UIテストについて
おまけとしてEspressoなどのUIテストツールで現時点でテストが実施できるか試してみます。

`reload`ボタンがあるので、このボタンをクリックするようにEspressoのコードを書いてみます。

```
    @Test
    fun Test_reloadButton() {
        onView(
            withText("reload")
        ).perform(click())
    }
```

実施してみると、結果として、以下の通りボタンが見つからないというエラーになります。

```
androidx.test.espresso.NoMatchingViewException: No views in hierarchy found matching: with text: is "reload"
```

`Espresso Test Recorder`を使って構造を見てみると以下の通り、Jetpack Composeの部分が全てViewGroup(AndroidComposeView)でまとめられている状態になっています。このため、ボタンを探せずエラーとなっているようです。

![Espresso Test Recorder](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/d5c10304-989b-f22c-632a-63909f03d432.png)

# 参考
- [CodeLab](https://codelabs.developers.google.com/codelabs/jetpack-compose-basics/#0)
- [公式サンプルのJetNewsアプリ](https://github.com/android/compose-samples/tree/master/JetNews)

# おわりに・雑感

そこそこ動きそうなアプリを作ってみつつ、最後にオチをつけたところで、まとめです。

Jetpack Composeについては、まだまだ公式情報が不足しているところではありますが、現時点でもそこそこ動くアプリを作ることができました。
既存のAPIの知識を活かせばもっと複雑なアプリも作れそうです。

正式版になる頃には参考ドキュメントなど、もう少し改善されていることを期待しますが、公式のサンプルは現時点でもかなり参考になりそうな感触です。

ただ、画面遷移やIntentなどこれまでも大いに使われているAPIの扱いについてはもう少し明確になってほしいと思います。

といったところで、本日の記事は以上となります。

それでは、[NTTテクノクロス Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ntt-tx) の10日目の記事も引き続きお楽しみください。