34
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Jetpack Compose】Githubリポジトリを一覧表示するサンプルアプリ作った

Last updated at Posted at 2021-07-27

About

もうすぐRelease版が出るJetpack Composeでサンプルアプリを作ったので、色々知見を書いていこうと思います!
本当はちゃんと整理された記事を書こうと思ったのですが、色々浅く広く書きたかったので思い出したままに見出し付けて書いていきます。目次を参照して興味のあるところだけ見ていただいても良いかもしれないです🍡

画面としては超シンプルな感じです。

  • 自分のGithubリポジトリを一覧表示する
  • リポジトリの詳細画面をWebViewで表示する

UIはこんな感じ。

adbeem-20210721104634.png adbeem-20210721104645.png

かなりシンプルなアプリになっているので、読みやすいコードになっているかなと思っています。
もし凝ったUIを見てみたいなという場合は、 @KKusumi さんのこの記事とコードが参考になると思います😍

アーキテクチャ

シンプルなMVVM構成になっています。

Untitled Diagram.png

ここでのポイントは、ViewModelからDataまでのコードはJetpack Composeだからといって特別何も変えていないことです。
今まで通りの書き方で大丈夫で、既存のLayoutをJetpack Composeにリプレースするときも、ViewModelは多少変更すると思いますが、Repository層以降は変更の必要がありません。

ViewModelはAndroid JetpackのViewModelです。Jetpack ComposeはViewModelとの連携がサポートされているので、基本的にはViewModelを採用するのが良いと思います。

Viewの構成

シングルActivity、No Fragment構成になっています。Jetpack ComposeのNavigationを使っていて、いわゆるルーティングで画面遷移を定義しています。FlutterやReact、Vue.jsを触っている方なら馴染みのある形式です。

@Composable
fun App() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = "home") {
        composable("home") { HomeScreen(navController, hiltViewModel()) }
        composable(
            "web-view/?url={url}",
            arguments = listOf(navArgument("url") { type = NavType.StringType }),
        ) { backStackEntry ->
            WebViewScreen(url = backStackEntry.arguments?.getString("url") ?: "")
        }
    }
}

詳細は公式を参照ですが、この手のルーティング遷移を経験したことのある人なら難しくないと思います。
Webアプリのように、URL定義をして、そのURLを指定することで画面遷移することができます。

Scaffold(
        topBar = { TopAppBar() },
    ) {
        HomeScreenBody(
            user = user,
            repositories = repositories,
            onClickUserCard = {
                navController.navigate("web-view/?url=${homeViewModel.userPageUrl}")
            },
            onClickRepository = {
                navController.navigate("web-view/?url=${homeViewModel.getRepositoryPageUrl(it)}")
            }
        )
    }

navigateする側はとってもシンプルでいい感じです。遷移に制限もなさそうなので快適に使うことができそうだなと思いました(XMLのJetpack NavigationのほうはGraphの制限などが厳しかった。。)
反面、定義側はちょっと冗長な感じがしています。もう少しかんたんに書けるようになるといいですね🤗

Jetpack Compose Navigationを使ってみて思ったところ

このSingle Activity、No Fragment構成ですが、今までと書き方が違いすぎるので、既存のプロジェクトからリプレースするのはかなり大変です。また、以下のようなデメリットもありました。

  • 画面遷移のアニメーションがいまのところサポートされていない
  • すべてActivty上なので、いままでのライフサイクルを使った処理が使えない
  • 画面単位のContextを使った処理などをどうするべきかの知見が少ない

やはり一番大きな違いはFragmentやActivityが画面とセットで存在しないので、それらが必要になったときにどうするべきかがイマイチわからなかった点です。
例えば動画リワードライブラリや広告系のSDKなどを使っている場合は、色々苦戦しそうですね。

もちろん、今まで通りのActivity、Fragment構成から、シンプルにXMLをJetpack Composeに置き換えるような使い方もできるので、僕はしばらくそっちの書き方にしようかなと思いました。

Fragmentから呼び出す方法

今回のサンプルコードでは使っていはいませんが、FragmentからXML LayoutではなくJetpack Composeを使う場合はこんな感じで使うことができます。これなら、今まで通りの処理を存分に活用することができるのでしばらくはおすすめかなと思いました。

class HomeFragment : Fragment() {

    private val viewModel: HomeViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View = ComposeView(requireContext()).apply {
        setContent {
            MyApplicationTheme {
                Scaffold {
                    Body()
                }
            }
        }
    }

DI

DIライブラリはHiltを使っています。
Jetpack Composeをただ使うだけなら、特別な対応は必要なく、今まで通りHiltを使って大丈夫です。

ただ、今回Jetpack ComposeのNavigationを使っているので、それの連携処理と、専用の依存を追加してあげないとビルドが通らないので注意です。

こちらを参照です。

値の自動検知

今回のサンプルコードではViewModelからComposeに状態を通知するのはFlowを使っています。LiveDataもサポートされているのでそっちでも大丈夫です。
LiveDataのように状態を管理するために、StateFlowを使っています。

class HomeViewModel : ViewModel() {
    private val _user: MutableStateFlow<User?> = MutableStateFlow(null)
    val user: StateFlow<User?> get() = _user
}

Compose側でObserveするのはこう。

@Composable
fun HomeScreen(
    navController: NavController,
    homeViewModel: HomeViewModel = viewModel(),
) {
    val user = homeViewModel.user.collectAsState().value
    val repositories = homeViewModel.repositories.collectAsState().value

これ、値が変更されたときはどうなるかというと、このComposeが自動で再生成されます。普通Viewが再生性されるとなると描画のコストとかを懸念しますが、Composeは低コストなので全然大丈夫みたいですね👍🏻

同じ理由で、Layout XMLのときはネストしないように作ったり、RecyclerViewではViewの使い回しがされたりなどしてましたが、Jetpack Composeの場合は基本的にガンガンネストしていく書き方ですし、List表示もそのまま新しいComposeが生成されるみたいです。1000件のリスト表示も使いまわしとかしないようですがパフォーマンスになんら影響はないみたいですね🥳

MutableState

Jetpack Compose専用のLiveDataもあって、 MutableState というのがあります。

LiveDataを使う場合はobserveする処理がView側で必要なのですが、そこらへんが不要になってコードが少なくなるLiveDataです。欠点としてはCompose以外では使えないので、Layout XMLなども使う想定がある場合は使わないほうがいいです。

Jetpack Composeだけからしか参照されないViewModelで、LiveDataを使うなら MutableState を使って書くほうがかなりスッキリしてかんたんに書けるようになるのでおすすめです💡

remember〇〇

Jetpack Composeを使っていて頻出するのがこの remember〇〇 系の関数。Navigationでも、 rememberNavController() とか出てきました。これは何かというと、Composeのメモリに保存させている処理です。

Androidの今までのViewは、View側で値を保持していました。例えばEditTextViewとか、View自身も値を保持していましたし、ViewModelでも値を保持していて、連動させるのに苦労したことがあると思います。
Composeでは完全にStatelessになっていて、値を何も保持していません。テキストが入力されてもCompose自身は何も保持しないので参照とかできません。
そういうときに、Composeに値を保持しておいてほしい場合に使うのがこの remember〇〇 系の関数になります。

rememberかViewModelか

Composeは値を保持していないので、かつて問題が合ったViewModelとViewの値の二重管理やズレが解消されています。ViewModelの値をComposeがobserveしているなら、ViewModelの値とComposeがずれることはありませんし、更新処理で苦戦することもありません。
とは言え、ロジックで使わないような状態値であればComposeで値管理しておいてほしい場合もあります。そういう場合はrememberが便利です。

ロジックでComposeの値を使うのであれば、rememberではなくViewModelの値をObserveするほうがよくて、ロジックで使わないって場合はrememberするのがいいのかなと思いました。(Navigationの rememberNavController() とかはちょっと別な気がしますが)

○○Screen

画面単位のトップレベルのComposeの名前は特別決まっているわけではありませんが、○○Screenという名前で定義されていることが多いような気がします。Flutterだと○○Pageが多いですかね?

このサンプルでもHome画面とWebViewの詳細画面がありますが、それぞれ HomeScreenWebViewScreen という名前にしています。

Stateless Compose

基本的にComposeはStatelessにするのが望ましいとされています。例えば、ViewModelの値をObserveしているComposeは外からの値変更をそのCompose内で処理しているのでStatefullと言えそうです。
ViewModelをそのまま子要素のComposeに渡している場合、とても便利ですが影響範囲が大きく、またテストもしづらくなってしまうため基本的には必要な分だけ引数にしてあげるのが良さそうです。

@Composable
private fun UserCard(
    user: User,
    onClick: () -> Unit,
) {
    Card(
        modifier = Modifier
            .padding(top = 16.dp)
            .padding(horizontal = 8.dp)
            .fillMaxWidth()
            .wrapContentHeight()
            .clickable(onClick = onClick),
        elevation = 4.dp,
    ) {
        // 中のコードは割愛
    }
}

例えばこのUserカードのComposeですが、この引数に取っている user: UseronClick: () -> UnitHomeViewModel をそのまま渡してあげれば個別で受け取る必要はありません。ですが、ViewModelをそのまま受け取ってしまうと、このUserCardが直接値を変更したり、参照したり、操作できてしまうため、このようにStatelessに宣言しておいたほうがいいです。これをState hoistingと言うみたいです。

LazyColumn

やっぱり、Jetpack Composeを使っていて最高だなと思うのがリスト表示、LazyColumnを使っているときです。モバイルアプリといえばスクロール可能なUIが必須と言えるのにAndroidネイティブの開発ではこのリスト表示が難解で扱いづらいところでした。RecyclerViewをそのまま使って 複雑なUIを作るのは難しいのでEpoxyやGroupieなどのライブラリを使って開発してました(それでもだいぶつらい)

Jetpack Composeでは単純にfor分で回してあげるだけのような感じで書くことができてしまいます。学習コストと実装コストの高かったAdapterやViewHolderともおさらばです。

adbeem-20210721104634.png

↑のToolbar以下のUserのカード表示と、リポジトリのリスト表示の部分がLazyColumnになっています。

LazyColumn(
    Modifier.fillMaxSize()
) {
    item { UserCard(user = user, onClick = onClickUserCard) }

    item { Spacer(modifier = Modifier.size(16.dp)) }

    if (repositories.isEmpty()) {
        item { Center(modifier = Modifier.padding(top = 16.dp)) { CircularProgressIndicator() } }
    } else {
        repositories.forEach { repository ->
            item { RepositoryItem(repository = repository, onClick = onClickRepository) }
            item { Divider(Modifier.padding(start = 16.dp)) }
        }
    }
}

item {} の中のComposeが画面にリスト表示されます。LazyColumnはリスト表示というよりかは、スクロール可能なColumn表示のComposeです。なのでLazyColumnの中に書いたComposeが繰り返して表示されるというわけではありません。Githubリポジトリをリスト表示している部分は自分でforを回して表示しています。

repositories.forEach { repository ->
    item { RepositoryItem(repository = repository, onClick = onClickRepository) }
    item { Divider(Modifier.padding(start = 16.dp)) }
}

このUIでは上部にUser情報のカード表示があり、スペースがちょっとあってリポジトリのリスト表示があります。これをRecyclerViewで実装する場合は、ViewTypeを使って頑張って実装していました。LaxyColumnの場合は上記のコードのように簡単に書くことができます。実装したいことをそのまま書けばいいというイメージでしょうか。宣言的UIって素晴らしいなと思いますね😍

Surface

SurfaceというComposeがあるのですが、これがとても便利です。例えば、Imageを丸く表示している部分で使っています。

Surface(
    modifier = Modifier
        .padding(start = 16.dp)
        .padding(end = 8.dp)
        .size(80.dp),
    shape = CircleShape,
    elevation = 4.dp,
) {
    Image(
        painter = rememberGlidePainter(request = user.avatarUrl),
        contentDescription = "avatar url"
    )
}

Imageはそのまま画像を表示するComponentなのですが、これをWrapしてSurfaceを使っています。SurfaceはMaterialデザインのフレーム的なCompsoseで、Elevationをかけたり、Shapeをかけたりなどすることができます。
今までのAndroid Viewのほうでは、ViewにElevationをかけたりするときは僕はCardViewをよく使っていました。CardViewは簡単にElevationをかけることができたので多用していたのですが、正直それ目的で使うのは誤用感があって嫌でした。また、AndroidはMaterialデザインが推奨されている割には、Viewが特別対応しているわけでもなかったのでいろいろ不便だったのでこのSurfaceはとても便利です。

Elevationを簡単にかけることができるのも素晴らしいのですが、Shapeを指定できるのも素晴らしいです。ここでは CircleShape を指定してImageを丸く切り取っています。今まではこんな簡単に実装できませんでした。しかも、Elevationもかけることができるので、丸く切り取ったImageのViewに4dp elevationをかけています。この2つを実装しようと思ったら今までは結構大変です。これ、感動ですよね💖

Divier

地味ですがこれも便利ですね。文字通り罫線を引くComposeですが、標準で用意されています。こういうのも、今まではxmlでdrawable用意して~ってやってましたし、RecyclerViewで表示するのも面倒でしたよね。

Center

ローディング表示を画面のど真ん中に表示したいことってあると思います。このサンプルプロジェクトでもたびたびローディング表示を画面の真ん中に表示しています。残念ながら(?)Center的なComposeでは標準で用意されていません。
ですが、Composeは簡単に自分で定義できるので、たったこれだけのコードで自作できます。

@Composable
fun Center(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        content()
    }
}
Center(modifier = Modifier.padding(top = 16.dp)) {
    CircularProgressIndicator()
}

詳しいことはこちらのnoteにて書いてるのでぜひに。

WebView

WebViewは残念ながらCompose対応されていませんでした。ですが、Jetpack Composeでは既存のViewを使うことができます。また、逆にComposeをAndroid View(XML Layout)から使うこともできます。

Githubの画面をWebView表示しているComposableのコードです。

@Composable
fun WebViewScreen(
    url: String,
    webViewModel: WebViewModel = viewModel(),
) {
    val isLoading = webViewModel.isLoading.collectAsState().value

    AndroidView({ context ->
        WebView(context).apply {
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
            webViewClient = object : WebViewClient() {
                override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
                    return false
                }

                override fun onPageFinished(view: WebView?, url: String?) {
                    super.onPageFinished(view, url)

                    webViewModel.onPageFinished()
                }
            }
            loadUrl(url)
        }
    })

    if (isLoading) {
        Center(modifier = Modifier.padding(top = 16.dp)) {
            CircularProgressIndicator()
        }
    }
}

AndroidView のところが、Android Viewを呼び出すComposeです。webViewClientのところが幅を取っていますが、そこを除けば簡単に呼び出せますね。

こう見るとローディング表示も簡単ですね。

##おわり

色々雑多にまとめてみました。Navigationまわりが個人的に不安感があり、どう導入していこうかなと悩ましいところではありますが、Layoutの部分だけみれば本当に書きやすくなっていてリリースが待ちどおしいですね🤗

34
17
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?