【Android】Jetpack ComposeでGithubのリポジトリ検索アプリを作ってみる

## 概要

Androiderのみなさま、[Jetpack Compose](https://developer.android.com/jetpack/compose?hl=ja)はもう使いましたか？
今年(2021年)の7月末にようやく安定版(1.0)が出て、界隈でいっそう盛り上がりを増していますね。

https://twitter.com/AndroidDev/status/1420428978746232833


今回はComposeを使ってGithubリポジトリを検索するアプリを作成してみました。

## できたもの

https://github.com/alpha2048/JetpackComposeTest

アプリを開くと検索画面を表示し、最初は「Jetpack Compose」で検索します。
リポジトリをタップするとWebViewで該当リポジトリを表示します。
デザインはMaterial Designからグレー色を適当に散りばめてますが、少し配色ミスったかも😓


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99605/442cdd7a-6738-3214-ca8f-d83154030dd5.png" width="500">


## 構成

- Clean Architecture
- DIにHiltを使用
- View側はComposeと[Navigation](https://developer.android.com/jetpack/compose/navigation?hl=ja)を使用
- 画像ライブラリにCoilを使用
  - Coroutineをベースにしているため動作が軽いです
  - [Composeにも対応しています！](https://coil-kt.github.io/coil/compose/)


## ポイント

ここでは特徴的な実装について紹介します。
Composeの個々のコンポーネントの説明は省きますが、後追いで追加するかも。

### ViewModelのデータを用いてComposeを更新する

Composeでは[`State<T>`オブジェクトを用いて再描画を行います。](https://developer.android.com/jetpack/compose/state?hl=ja)

Coroutine Flow用の拡張関数である`collectAsState`を使うと、ViewModelが公開したデータをそのままStateオブジェクトに変換することができるため、これをCompose側で使うことで、ViewModel側でデータを更新する度に再描画をしてくれます。

また拡張関数は以下のものが用意されています。
- StateFlow用の拡張関数である`collectAsState`
- LiveData用の`observeAsState` (`runtime-livedata`のライブラリが必要)
- Rx用の`subscribeAsState` (`runtime-rxjava2`のライブラリが必要)

```kotlin
val uiState by viewModel.uiState.collectAsState()

    HomeScreen(
        uiState = uiState,
        ~略~
    )
```


### Bundleみたいに次のScreenにdata classを渡す

Bundleで次のActivityに値を渡すように、data classを渡す方法ですが、

- Action側でdata classをMoshiなどを使ってjson化し、Stringとして渡す
- NavHost側で受け取ったStringを元に戻してからScreenに渡す

で実現できます。なお、URLなどが含まれる場合のために、クエリパラメータ方式でStringを受け取っています。

```kotlin

class MainActions(navController: NavHostController) {
    val navigateToRepositoryDetail: (RepositoryEntity) -> Unit = { entity ->
        val repositoryJson = Moshi.Builder().add(KotlinJsonAdapterFactory()).build().adapter(RepositoryEntity::class.java).toJson(entity)
        navController.navigate(MainDestinations.REPOSITORY_DETAIL_ROUTE + "/?repository=" + repositoryJson)
    }
}

~~略~~

NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        composable(
            route = "${MainDestinations.REPOSITORY_DETAIL_ROUTE}/?repository={repository}",
            arguments = listOf(navArgument("repository") { type = NavType.StringType })
        ) { backStackEntry ->
            backStackEntry.arguments?.getString("repository")?.let { repositoryJson ->
                Moshi.Builder().add(KotlinJsonAdapterFactory()).build().adapter(RepositoryEntity::class.java).fromJson(repositoryJson)?.let {
                    RepositoryDetailScreen(repository = it, onBackPress = actions.upPress)
                }
            }
        }
    }

```



### SearchBarの実装について

SearchBarは`TopAppBar`のtitle部分にTextFieldを突っ込むだけで実装可能です。
内部のtextは`remember`で保持することができます。

```kotlin
    TopAppBar(
        title = {
            // SearchBar
            Row(
                verticalAlignment = Alignment.CenterVertically,
            ) {
                TextField(
                    modifier = Modifier.weight(weight = 1f),
                    leadingIcon = {
                        Icon(
                            imageVector = Icons.Filled.Search,
                            contentDescription = "Search Icon"
                        )
                    },
                    value = text,
                    onValueChange = {
                        text = it
                    },
                    maxLines = 1,
                    singleLine = true,
                )
                Button(onClick = { onSearch(text) }) {
                    Text(text = "決定")
                }
            }
        },
    )
```

もっと綺麗なSearchBarの実装が見たい方は、[airbnbのShowkaseライブラリ](https://github.com/airbnb/Showkase/)にいい感じの実装が置いてあります。

### ページング処理について

GoogleとしてはPagingライブラリを使うのを理想形にしてそうな予感がしますが、[執筆時点でpaging-composeライブラリがalpha版](https://developer.android.google.cn/jetpack/androidx/releases/paging?hl=ja#paging-compose-1.0.0-alpha12)ということでしたので、今回は[海外兄貴のListStateを使った実装](https://manavtamboli.medium.com/infinite-list-paged-list-in-jetpack-compose-b10fc7e74768)を参考にしました。

実際のコードを見ていただくとわかる通り、RecyclerViewのScrollListenerを使った方法と同じ手法です。実際にやっていることは以下の通りです。

- `remember` と `derivedStateOf`で、`LazyListState`からLoadMoreの判定Stateを作成する
- `LaunchedEffect`はLoadMoreの判定Stateの変化を検知して新しいコルーチンを作成する
- `snapshotFlow`でStateをFlowに変換して、onLoadMoreを発火させる


```kotlin
@Composable
fun LazyListState.OnBottomReached(
    onLoadMore : () -> Unit
) {
    val shouldLoadMore = remember {
        derivedStateOf {
            val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
                ?:
                return@derivedStateOf true

            lastVisibleItem.index >=  layoutInfo.totalItemsCount - 3
        }
    }

    LaunchedEffect(shouldLoadMore){
        snapshotFlow { shouldLoadMore.value }
            .collect { if (it) onLoadMore() }
    }
}
```




## 所感

※わたしはFlutter民でもあるので、Flutterと比べた感想多めです😓

一連の実装を見てみると、Presentation層はFlutterに近い印象を受けました。ViewModelからStateFlowで公開→collectAsStateで更新検知する部分なんかは、Flutterのriverpod + state_notifierを使用した構成とかなり似ていますね。

また、今回実装していく中で、もう少しプレビュー表示が早くなってくれるといいなと感じました。
FlutterはHot Reloadを採用していることもあり、実データ+実機で素早く動作確認ができるという点が非常に安心感があるのですが、Composeは従来のxml表示と同様、プレビュー設定をした上でビルドしないとレイアウトが表示されません。ビルドがもたつくと確認まで時間がかかります。
この辺は技術的な課題もあるとは思いますが、テキパキ修正できるようにしてくれると助かるな〜という気持ちがありました。

とはいえ、Googleは今後Jetpack Composeに力を入れていきそうなので、その辺りの改善も含め注目していきたいですね。

## 参考文献

- [Jetpack Compose公式](https://developer.android.com/jetpack/compose)
- [Jetpack Compose公式サンプル](https://github.com/android/compose-samples)
- [【Jetpack Compose】Githubリポジトリを一覧表示するサンプルアプリ作った](https://qiita.com/yasukotelin/items/f14def46e25e8c3f9888)
- [Infinite List / Paged List in Jetpack Compose](https://manavtamboli.medium.com/infinite-list-paged-list-in-jetpack-compose-b10fc7e74768)
- [Jetpack Compose Navigation and Passing Data | NavHost](https://www.youtube.com/watch?v=OgYfQNbl0ts)
- [SearchView Toolbar with Jetpack Compose](https://www.youtube.com/watch?v=3B9GA3ES5Qg)