🚀 ニフティ’s Notion

Androidアプリ開発入門#6

第6章 アーキテクチャ

6.1 アーキテクチャについて

Jetpack ComposeはデータもViewも全部Kotlinで作成します。

そのため、データをViewをしっかり分離しておかないとスパゲッティコードが出来上がり、コードの維持保守が難しくなる可能性があります。

6.2 オーバーエンジニアリングを防ぐ

使わない機能を作り込んでしまうと、時間の無駄である上に、可読性の低下、運用コストの増加を引き起こします。

大切なのは リファクタリング です。

変化に応じて、規模に応じて、仕様を変えずに設計を変えていくことです。そして仕様を変えないことを保証するには テスト が必要です。

つまり、 テストをしやすいコードを維持することが最も重要 です。

テストがしやすい設計は、大抵パターンが存在します。

まずは、既存パターンの中から適切なパターンを選択できるようにしましょう。

6.3 レイヤードアーキテクチャ

UIのあるシステムは基本的に次のような処理をします。

  1. UIが入力を受ける
  2. 入力イベントをシステムが解釈し、処理
  3. 処理結果をUIに描画

UIとシステムには明確な関心の違いがあります。

関心が違うならレイヤーとして切り分けるべきだと思いますよね?

アプリケーションを設計するとき、全体をいくつかの役割を持ったレイヤー(層)に分けて考える、ということがよく行われます。このような設計手法を レイヤードアーキテクチャ と言います。

6.3.1 レイヤードアーキテクチャのメリット
  • 単純に理解しやすい
  • 重複コードの排除
    • 別々の画面でロジックを共通化したり、見た目が同じ画面に異なる情報を表示したり、使いまわせるようになる
  • 分業がしやすい
    • 画面作成、データ取得・整形などがファイルやクラスから分かれているので、コンフリクトを起こしにくくなる
  • テスタビリティの向上
    • ユニットテストを書きやすい

6.3.2 レイヤーの分け方

レイヤーの分け方はいろいろありますが、少ない分け方だと以下のようなものがあります。

image block
  • プレゼンテーション層
    • UIの表示、ユーザ入力の受け付けに責任を持つ
    • ビジネスロジックを持たず、画面にのみ責任を持つ
  • ドメイン層
    • ドメインロジックを持つ
    • 単純なアプリでは存在しないこともある
  • データ層
    • データの取得・更新に責任を持つ
    • DBやHTTPでのデータ取得・更新など

Icon
ドメイン、プレゼンテーションって?

「ドメイン」「プレゼンテーション」は「Model」「View」とほぼ同じ意味と捉えて問題ありません。

プレゼンテーション / View:UIに関係するロジック

ドメイン / Model:システム本来の関心領域(UIに関係しない処理全て)

6.3.3 依存性逆転の原則

ドメイン層はデータ層に依存しており、ドメインのデータ構造が永続化のためのデータ構造に引きずられてしまいます。

そうすると以下のような問題が起こります。

  • ドメイン層のテストに当たって、データベースの中身を書き換えて環境を整える必要がある
  • データ層の実装を別の永続化機構(RDBからオブジェクト指向DBなど)に移行することができない
  • データ層がデータ構造を定義していることでドメイン知識がデータ層に漏出

これを解決するために依存性逆転の原則を利用します。

ドメイン層は自分自身のために、永続化機構の実装に影響されない形で「データ層が従うべきインターフェイス」を定義します。

image block

6.4 GUIアーキテクチャ

プレゼンテーションとドメインを分けるためのアーキテクチャをGUIアーキテクチャと呼びます。

ビジネスロジックとプレゼンテーションロジックをUIから分離すると、テスト、維持補修、コードのリサイクルの効果が期待できます。

今回はいくつかあるGUIアーキテクチャの中からMVVMパターンを取り扱います。

6.5 MVVMパターン
Icon
画面の描画処理とプレゼンテーションロジックとを分離するGUIアーキテクチャ

MVVMは以下の3要素から構成されます。

Model

UIに依存しない純粋なドメインロジックやそのデータを持つ。

Model自身は他のコンポーネントに依存しない=ViewやViewModelがなくてもビルドできる。

View

ユーザ操作の受付と画面表示を担当する。

ViewModelが保持する状態とデータバインディングし、ユーザ入力に応じてViewModelが保持するデータを加工・更新することでバインディングした画面表示を更新する。

ViewModel

ViewとModelの仲介役。以下の責務を担う。

  • Viewに表示するためのデータを保持
  • Viewからイベントを受け取り、Modelの処理を呼び出す
  • Viewからイベントを受け取り、加工して値を更新

ビジネスロジックと独立した、画面表示のために必要な状態とロジックを担う。

6.5.1 データバインディング

2つのデータの状態を監視し同期する仕組みです。

片方のデータ変更をもう一方が検知して、データを自動的に更新します。

image block

ViewとViewModelはデータバインディングによって関連付けられています。

ViewModelの状態変更に同期してViewの状態も更新され、画面に反映されます。

宣言的なバインディングによりViewModel自身の状態を更新するだけで、Viewの描画処理が発火するため、ViewModel内にViewに対する手続的な描画指示を書く必要がなくなります。

ViewとViewModelは完全に疎結合となり、具体的なViewが存在しなくてもプレゼンテーションロジックをテストしやすくなります。

6.5.2 MVVMのメリット
  • 維持・改修の容易さ
    • 理想としては最初からいい企画、デザイン、コードを作ることですが、アプリが定める環境は常に多変しているので現実的に仕様の変更、企画変更、デザイン変更、バグ修正が常に起こります。MVVMパターンではビューモデルがビューとモデルの間でAdapterみたいな役割を果たすため、変更が生じる際にその変更の内容を最小限に抑えることができます。
  • モデルとビューモデルがビューから独立している
    • ビューモデルとモデルをプラットホーム非依存に開発できます
    • テストが容易になります。
  • 開発プロジェクト中、開発者とデザイナーが並列で作業できる
    • UIデザインが出なくても、すでに定義されたモデルとビューモデルを先に開発できるため並列的な業務プロセスが可能です。
6.5.3 MVVMのデメリット
  • 大きい、複雑なアプリケーションの作成のために考案されたデザインパターンなので小規模のアプリ制作で使用することになったらオーバーヘッドが大きくなり、変えて非効率的になる可能性があります。
  • アプリがあまりにも膨大になるとData bindingのためにメモリの消費が大きくなる可能性があります。

6.6 MVVMで記事一覧画面を実装してみる

それでは実際に、記事一覧画面のダミーデータをModelに持たせ、ViewModelを通してViewに描画されるように直してみましょう。

6.6.1 Modelの作成
  1. com.example.strongestnews パッケージの下に models パッケージを新規作成し、 Article.kt ファイルを作成します。
    image block
  2. MainActivity.kt に記述していた Article を先ほど作成した models/Article.kt に移動します( MainActivity.kt からは削除)
    package com.example.strongestnews.models
    
    import java.net.URL
    import java.util.Date
    
    data class Article(
        val id: String,
        val title: String,
        val imageURL: URL,
        val createdAt: Date,
        val detail: String
    )
    models/Article.kt

6.6.2 ViewModelの作成
  1. package com.example.strongestnews.ui.screens.articles パッケージに画面の状態を定義するための ArticlesViewState.kt を作成します
image block
  1. 記事一覧画面では記事のリストを持っていたいので、 ArticleViewState データクラスにそのように定義します。
    package com.example.strongestnews.ui.screens.articles
    
    import com.example.strongestnews.models.Article
    
    data class ArticlesViewState (
        val articles: List<Article>
    )
    ArticleViewState.kt
  2. com.example.strongestnews.ui.screens.articles パッケージに状態と実行すべき機能を管理するため ArticlesViewModel.kt を作成します。
    image block
  3. ArticlesViewModel.kt に状態トラッキングされる値をダミーデータで初期化して宣言します

    ダミーデータは MainActivity.kt から引越ししています( MainActivity.kt からは削除)

    package com.example.strongestnews.ui.screens.articles
    
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.setValue
    import com.example.strongestnews.models.Article
    import java.net.URL
    import java.util.Date
    
    class ArticlesViewModel {
        var viewState by mutableStateOf<ArticlesViewState>(
            ArticlesViewState(
                articles = listOf(
                    Article(
                        id = "20230101",
                        title = "すごいニュース",
                        createdAt = Date(),
                        imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                        detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                    ),
                    Article(
                        id = "20230102",
                        title = "やばいニュース",
                        createdAt = Date(),
                        imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                        detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                    ),
                    Article(
                        id = "20230103",
                        title = "えぐいニュース",
                        createdAt = Date(),
                        imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                        detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                    ),
                    Article(
                        id = "20230104",
                        title = "しぶいニュース",
                        createdAt = Date(),
                        imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                        detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                    ),
                )
            )
        )
    }
    ArticlesViewModel.kt
  4. MainApp.kt を開き、 ArticlesViewModel() を作成します
    package com.example.strongestnews.ui
    
    import androidx.compose.runtime.Composable
    import androidx.navigation.NavType
    import androidx.navigation.compose.NavHost
    import androidx.navigation.compose.composable
    import androidx.navigation.compose.rememberNavController
    import androidx.navigation.navArgument
    import com.example.strongestnews.MainRoute
    import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailScreen
    import com.example.strongestnews.ui.screens.articles.ArticlesScreen
    import com.example.strongestnews.ui.screens.articles.ArticlesViewModel
    import com.example.strongestnews.ui.screens.comments.CommentsScreen
    
    @Composable
    fun MainApp() {
        val navController = rememberNavController()
    
        NavHost(
            navController = navController,
            startDestination = MainRoute.Articles.route
        ) {
            composable(MainRoute.Articles.route) {
                val viewModel = ArticlesViewModel()
                ArticlesScreen(
                    articles = articles,
                    onNavigateToArticleDetail = { articleID ->
                        navController.navigate("${MainRoute.ArticleDetail.route}/$articleID")
                    }
                )
            }
    
    /* ... */
    MainApp.kt

6.6.3 ViewModelの状態をViewに伝播させる
  1. ArticlesScreen で、ダミーデータを受け取っていた部分を削除し、 ViewModel を受け取れるように変更します
    /* ... */
    
    @Composable
    fun ArticlesScreen(
    //    articles: List<Article>,
        viewModel: ArticlesViewModel,
        onNavigateToArticleDetail: (String) -> Unit,
    ){
        ArticlesContainer(
            articles = articles,
            onClickSeeArticleDetail = onNavigateToArticleDetail
        )
    }
    
    /* ... */
    ArticlesScreen.kt
  2. ViewModel から ViewState を取り出し、さらに ViewState から articles を取り出して ArticlesContainer コンポーザブルに渡します
    /* ... */
    
    @Composable
    fun ArticlesScreen(
        viewModel: ArticlesViewModel,
        onNavigateToArticleDetail: (String) -> Unit,
    ){
        val viewState = viewModel.viewState // viewStateを取り出す
        
        ArticlesContainer(
            articles = viewState.articles,
            onClickSeeArticleDetail = onNavigateToArticleDetail
        )
    }
    
    /* ... */
    ArticlesScreen.kt
  3. プレビューにダミーデータを直接渡すように変更します
    /* ... */
    
    @Preview(showBackground = true)
    @Composable
    fun ArticlesPreview() {
        StrongestNewsTheme {
            ArticlesContainer(
                articles = listOf(
                    Article(
                        id = "20230101",
                        title = "すごいニュース",
                        createdAt = Date(),
                        imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                        detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                    ),
                    Article(
                        id = "20230102",
                        title = "やばいニュース",
                        createdAt = Date(),
                        imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                        detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                    )
                ),
                onClickSeeArticleDetail = {}
            )
        }
    }
    ArticlesScreen.kt
  4. MainApp.kt を開き、 viewModel ArticlesScreen に渡すように変更します
    /* ... */
    composable(MainRoute.Articles.route) {
        val viewModel = ArticlesViewModel()
        ArticlesScreen(
    //			articles = articles,
            viewModel = viewModel,
            onNavigateToArticleDetail = { articleID ->
                navController.navigate("${MainRoute.ArticleDetail.route}/$articleID")
            }
        )
    }
    /* ... */
    MainApp.kt

Icon
この時点ではビルドは通りません。次の記事詳細の演習も行うとビルドができるようになります。
6.7 MVVMで記事詳細画面を実装してみる

記事一覧と同様に記事詳細もMVVM構成に変更しましょう。記事詳細ではArticleのModelを使い回すため作成不要です。

  1. ArticleDetailViewState を作成します
    実装例
    package com.example.strongestnews.ui.screens.articleDetail
    
    import com.example.strongestnews.models.Article
    
    data class ArticleDetailViewState (
        val article: Article?
    )
    ArticleDetailViewState.kt
  2. ArticleDetailViewModel を作成します
    実装例
    package com.example.strongestnews.ui.screens.articleDetail
    
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.setValue
    import com.example.strongestnews.models.Article
    import java.net.URL
    import java.util.Date
    
    class ArticleDetailViewModel {
        var viewState by mutableStateOf<ArticleDetailViewState>(
            ArticleDetailViewState(
                article = Article(
                    id = "20230101",
                    title = "すごいニュース",
                    createdAt = Date(),
                    imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                    detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                )
            )
        )
    }
    ArticleDetailViewModel.kt
  3. ArticleDetailScreenでViewModelを受け取って表示するように変更します
    • viewStateには、ArticleのModelをオプショナル型で定義しているため、 viewState.article?.hoge のように呼び出します。
    • Textにはオプショナル型を渡せないため、以下の様にnullの時は空文字を渡すように場合分けをしています
      text = article?.title ?: ""
    実装例
    package com.example.strongestnews.ui.screens.articleDetail
    
    import androidx.compose.foundation.layout.Arrangement
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.Row
    import androidx.compose.foundation.layout.Spacer
    import androidx.compose.foundation.layout.aspectRatio
    import androidx.compose.foundation.layout.fillMaxWidth
    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.layout.size
    import androidx.compose.foundation.rememberScrollState
    import androidx.compose.foundation.shape.RoundedCornerShape
    import androidx.compose.foundation.verticalScroll
    import androidx.compose.material.icons.Icons
    import androidx.compose.material.icons.filled.ArrowBack
    import androidx.compose.material.icons.filled.Favorite
    import androidx.compose.material.icons.filled.Share
    import androidx.compose.material3.AssistChip
    import androidx.compose.material3.AssistChipDefaults
    import androidx.compose.material3.ExperimentalMaterial3Api
    import androidx.compose.material3.Icon
    import androidx.compose.material3.IconButton
    import androidx.compose.material3.MaterialTheme
    import androidx.compose.material3.MediumTopAppBar
    import androidx.compose.material3.OutlinedButton
    import androidx.compose.material3.Scaffold
    import androidx.compose.material3.Text
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.remember
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.draw.clip
    import androidx.compose.ui.layout.ContentScale
    import androidx.compose.ui.platform.LocalContext
    import androidx.compose.ui.res.stringResource
    import androidx.compose.ui.tooling.preview.Preview
    import androidx.compose.ui.unit.dp
    import coil.compose.AsyncImage
    import coil.request.ImageRequest
    import com.example.strongestnews.R
    import com.example.strongestnews.models.Article
    import com.example.strongestnews.ui.theme.StrongestNewsTheme
    import java.net.URL
    import java.util.Date
    
    @Composable
    fun ArticleDetailScreen(
        articleID: String,
        viewModel: ArticleDetailViewModel,
        onNavigateToBack: () -> Unit,
        onNavigateToComment: (String) -> Unit,
    ) {
        val viewState = viewModel.viewState
    
        ArticleDetailContainer(
            article = viewState.article,
            onNavigateToBack = onNavigateToBack,
            onNavigateToComment = onNavigateToComment,
        )
    }
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun ArticleDetailContainer(
        article: Article?,
        onNavigateToBack: () -> Unit,
        onNavigateToComment: (String) -> Unit,
    ){
        // Composeによる追跡がされる値
        var favoriteCount = remember { mutableStateOf(0) }
    
        Scaffold(
            topBar = {
                MediumTopAppBar(
                    title = { Text(stringResource(R.string.articles_screen_title)) },
                    navigationIcon = {
                        IconButton(
                            onClick = onNavigateToBack
                        ) {
                            Icon(
                                Icons.Filled.ArrowBack,
                                contentDescription = "Navigation Back"
                            )
                        }
                    }
                )
            },
        ) { innerPadding ->
            Column(
                modifier = Modifier
                    .verticalScroll(rememberScrollState())
                    .fillMaxWidth()
                    .padding(innerPadding)
                    .padding(20.dp),
                verticalArrangement = Arrangement.spacedBy(15.dp)
            ) {
                AsyncImage(
                    modifier = Modifier
                        .fillMaxWidth()
                        .aspectRatio(16f / 9f)
                        .clip(RoundedCornerShape(10.dp)),
                    contentScale = ContentScale.FillWidth,
                    model = ImageRequest
                        .Builder(LocalContext.current)
                        .data(article?.imageURL.toString())
                        .crossfade(true)
                        .placeholder(R.drawable.placeholder)
                        .error(R.drawable.placeholder)
                        .build(),
                    contentDescription = article?.title ?: ""
                )
                Column {
                    Text(
                        text = article?.createdAt.toString(),
                        style = MaterialTheme.typography.labelLarge
                    )
                    Spacer(modifier = Modifier.height(5.dp))
                    Text(
                        text = article?.title ?: "",
                        style = MaterialTheme.typography.titleLarge
                    )
                }
                Row(
                    horizontalArrangement = Arrangement.spacedBy(10.dp)
                ) {
                    AssistChip(
                        onClick = { favoriteCount.value++ },
                        label = { Text(text = favoriteCount.value.toString())},
                        leadingIcon = {
                            Icon(
                                Icons.Filled.Favorite,
                                contentDescription = "favorite",
                                Modifier.size(AssistChipDefaults.IconSize)
                            )
                        }
                    )
                    AssistChip(
                        onClick = {},
                        label = { Text(text = "Share") },
                        leadingIcon = {
                            Icon(
                                Icons.Filled.Share,
                                contentDescription = "Share",
                                Modifier.size(AssistChipDefaults.IconSize)
                            )
                        }
                    )
                }
                OutlinedButton(
                    modifier = Modifier
                        .fillMaxWidth(),
                    onClick = { onNavigateToComment(article?.id!!) }
                ) {
                    Text(text = "コメントを表示")
                }
                Text(
                    text = article?.detail ?: "",
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }
    }
    
    @Preview(showBackground = true)
    @Composable
    fun ArticleDetailScreenPreview() {
        StrongestNewsTheme {
            ArticleDetailContainer(
                article = Article(
                    id = "20230101",
                    title = "すごいニュース",
                    createdAt = Date(),
                    imageURL = URL("https://images.pexels.com/photos/1071882/pexels-photo-1071882.jpeg"),
                    detail = "ニフティから超絶爆裂に早い回線がリリースされました!やばい!すごい!スゴツヨ!"
                ),
                onNavigateToBack = {},
                onNavigateToComment = {}
            )
        }
    }
    ArticleDetailScreen.kt
  4. MainApp.ktでviewModelを作成し、ArticleDetailScreenに渡すように変更します
    実装例
    package com.example.strongestnews.ui
    
    import androidx.compose.runtime.Composable
    import androidx.navigation.NavType
    import androidx.navigation.compose.NavHost
    import androidx.navigation.compose.composable
    import androidx.navigation.compose.rememberNavController
    import androidx.navigation.navArgument
    import com.example.strongestnews.MainRoute
    import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailScreen
    import com.example.strongestnews.ui.screens.articleDetail.ArticleDetailViewModel
    import com.example.strongestnews.ui.screens.articles.ArticlesScreen
    import com.example.strongestnews.ui.screens.articles.ArticlesViewModel
    import com.example.strongestnews.ui.screens.comments.CommentsScreen
    
    @Composable
    fun MainApp() {
        val navController = rememberNavController()
    
        NavHost(
            navController = navController,
            startDestination = MainRoute.Articles.route
        ) {
            composable(MainRoute.Articles.route) {
                val viewModel = ArticlesViewModel()
                ArticlesScreen(
                    viewModel = viewModel,
                    onNavigateToArticleDetail = { articleID ->
                        navController.navigate("${MainRoute.ArticleDetail.route}/$articleID")
                    }
                )
            }
            composable(
                "${MainRoute.ArticleDetail.route}/{articleID}",
                arguments = listOf(
                    navArgument("articleID") {
                        type = NavType.StringType
                    }
                )
            ) { backStackEntry ->
                val receivedArticleID = backStackEntry.arguments?.getString("articleID")!!
                val viewModel = ArticleDetailViewModel()
                ArticleDetailScreen(
                    articleID = receivedArticleID,
                    viewModel = viewModel,
                    onNavigateToBack = {
                        navController.popBackStack()
                    },
                    onNavigateToComment = { articleID ->
                        navController.navigate("${MainRoute.Comments.route}/$articleID")
                    }
                )
            }
            composable(
                "${MainRoute.Comments.route}/{articleID}",
                arguments = listOf(
                    navArgument("articleID") {
                        type = NavType.StringType
                    }
                )
            ){ backStackEntry ->
                val receivedArticleID = backStackEntry.arguments?.getString("articleID")!!
                CommentsScreen(
                    articleID = receivedArticleID,
                    onNavigateToBack = {
                        navController.popBackStack()
                    }
                )
            }
        }
    }
    MainApp.kt

ここまでできたらビルドしてみましょう。現在はViewModelで決め打ちの値を初期値として設定しているので、どの記事を選択しても同じ記事詳細が表示されます。

image block

6.8 MVVMでコメント画面を実装してみる

コメント画面もMVVMで実装してみましょう。

  1. CommentのModelを作成する
    実装例
    package com.example.strongestnews.models
    
    import java.util.Date
    
    data class Comment(
        val id: String,
        val name: String,
        val comment: String,
        val createdAt: Date
    )
    models/Comment.kt
  2. CommentsViewState を作成する
    実装例
    package com.example.strongestnews.ui.screens.comments
    
    import com.example.strongestnews.models.Comment
    
    data class CommentsViewState(
        val comments: List<Comment>
    )
    CommentsViewState.kt
  3. CommentsViewModel を作成する
    実装例
    package com.example.strongestnews.ui.screens.comments
    
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.setValue
    import com.example.strongestnews.models.Comment
    import java.util.Date
    
    class CommentsViewModel {
        var viewState by mutableStateOf<CommentsViewState> (
            CommentsViewState(
                comments = listOf(
                    Comment(
                        id = "20230101",
                        name = "ニフ山",
                        comment = "すごい!",
                        createdAt = Date()
                    ),
                    Comment(
                        id = "20230102",
                        name = "山田",
                        comment = "やばい!",
                        createdAt = Date()
                    ),
                    Comment(
                        id = "20230103",
                        name = "田中",
                        comment = "えぐい!",
                        createdAt = Date()
                    ),
                    Comment(
                        id = "20230104",
                        name = "中村",
                        comment = "しぶい!",
                        createdAt = Date()
                    ),
                )
            )
        )
    }
    CommentViewModel.kt
  4. CommentsScreenでViewModelを受け取って表示するように変更する
    実装例
    package com.example.strongestnews.ui.screens.comments
    
    import androidx.compose.foundation.layout.Column
    import androidx.compose.foundation.layout.fillMaxWidth
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.rememberScrollState
    import androidx.compose.foundation.text.KeyboardActions
    import androidx.compose.foundation.text.KeyboardOptions
    import androidx.compose.foundation.verticalScroll
    import androidx.compose.material.icons.Icons
    import androidx.compose.material.icons.filled.ArrowBack
    import androidx.compose.material.icons.filled.Send
    import androidx.compose.material3.Divider
    import androidx.compose.material3.ExperimentalMaterial3Api
    import androidx.compose.material3.Icon
    import androidx.compose.material3.IconButton
    import androidx.compose.material3.MediumTopAppBar
    import androidx.compose.material3.Scaffold
    import androidx.compose.material3.Text
    import androidx.compose.material3.TextField
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.saveable.rememberSaveable
    import androidx.compose.runtime.setValue
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.res.stringResource
    import androidx.compose.ui.text.TextRange
    import androidx.compose.ui.text.input.ImeAction
    import androidx.compose.ui.text.input.KeyboardType
    import androidx.compose.ui.text.input.TextFieldValue
    import androidx.compose.ui.tooling.preview.Preview
    import androidx.compose.ui.unit.dp
    import com.example.strongestnews.R
    import com.example.strongestnews.models.Comment
    import com.example.strongestnews.ui.screens.comments.components.CommentItem
    import com.example.strongestnews.ui.theme.StrongestNewsTheme
    import java.util.Date
    
    @Composable
    fun CommentsScreen(
        viewModel: CommentsViewModel,
        articleID: String,
        onNavigateToBack: () -> Unit,
    ) {
        val viewState = viewModel.viewState
    
        CommentsContainer(
            comments = viewState.comments,
            onNavigateToBack = onNavigateToBack
        )
    }
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun CommentsContainer(
        comments: List<Comment>,
        onNavigateToBack: () -> Unit,
    ){
        var text by rememberSaveable(
            stateSaver = TextFieldValue.Saver
        ) {
            mutableStateOf(TextFieldValue("", TextRange(0, 7)))
        }
    
        Scaffold(
            topBar = {
                MediumTopAppBar(
                    title = { Text(stringResource(R.string.articles_screen_title)) },
                    navigationIcon = {
                        IconButton(
                            onClick = onNavigateToBack
                        ) {
                            Icon(
                                Icons.Filled.ArrowBack,
                                contentDescription = "Navigation Back"
                            )
                        }
                    }
                )
            },
        ) {innerPadding ->
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(innerPadding)
            ) {
                // コメントリスト
                Column(
                    modifier = Modifier
                        .verticalScroll(rememberScrollState())
                        .weight(1f)
                        .padding(20.dp)
                ) {
                    comments.forEach {comment ->
                        CommentItem(comment = comment)
                    }
                }
    
                // 下の部分
                Column {
                    Divider()
                    TextField(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(20.dp),
                        value = text,
                        onValueChange = { text = it },
                        placeholder = { Text(text = "Comment") },
                        trailingIcon = {
                            IconButton(
                                onClick = {},
                                enabled = text.text.isNotEmpty()
                            ) {
                                Icon(
                                    Icons.Filled.Send,
                                    contentDescription = "Send"
                                )
                            }
                        },
                        keyboardOptions = KeyboardOptions(
                            keyboardType = KeyboardType.Text,
                            imeAction = ImeAction.Send
                        ),
                        keyboardActions = KeyboardActions(onSend = {})
                    )
                }
            }
        }
    }
    
    @Preview(showBackground = true)
    @Composable
    fun CommentsScreenPreview(){
        StrongestNewsTheme {
            CommentsContainer(
                comments = listOf(
                    Comment(
                        id = "20230101",
                        name = "ニフ山",
                        comment = "すごい!",
                        createdAt = Date()
                    ),
                    Comment(
                        id = "20230102",
                        name = "山田",
                        comment = "やばい!",
                        createdAt = Date()
                    )
                ),
                onNavigateToBack = {}
            )
        }
    }
    CommentsScreen.kt

    Commentコンポーザブルも修正する

    実装例
    package com.example.strongestnews.ui.screens.comments.components
    
    import androidx.compose.material3.ExperimentalMaterial3Api
    import androidx.compose.material3.ListItem
    import androidx.compose.material3.Text
    import androidx.compose.runtime.Composable
    import androidx.compose.ui.tooling.preview.Preview
    import com.example.strongestnews.models.Comment
    import com.example.strongestnews.ui.theme.StrongestNewsTheme
    import java.util.Date
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun CommentItem(
        comment: Comment
    ){
        ListItem(
            overlineText = { Text(comment.createdAt.toString()) },
            headlineText = { Text(text = comment.name) },
            supportingText = { Text(text = comment.comment) }
        )
    }
    
    @Preview
    @Composable
    fun CommentPreview(){
        StrongestNewsTheme {
            CommentItem(
                comment = Comment(
                    id = "20230101",
                    name = "ニフ山",
                    comment = "すごい!",
                    createdAt = Date()
                )
            )
        }
    }
  5. MainApp.ktでviewModelを作成し、CommentScreenに渡すように変更する
    実装例
    /* ... */
    
    composable(
        "${MainRoute.Comments.route}/{articleID}",
        arguments = listOf(
            navArgument("articleID") {
                type = NavType.StringType
            }
        )
    ){ backStackEntry ->
        val receivedArticleID = backStackEntry.arguments?.getString("articleID")!!
        val viewModel = CommentsViewModel()
        CommentsScreen(
            viewModel = viewModel,
            articleID = receivedArticleID,
            onNavigateToBack = {
                navController.popBackStack()
            }
        )
    }
    
    /* ... */
  6. ビルドして、データが表示されることを確認します
    image block