親に依存しないComposable functionの設計

カンカクでAndroidエンジニアをやっている @haru067です。好きな焼肉は牛タンです。

今回はJetpack Composeの設計の話をします。

よくある実装

アプリ開発で非常によくあるパターンとして、リスト画面があり、タップすると詳細画面に遷移する、というのがあります。例えば、記事の一覧があり、タップすると記事の詳細に遷移するような画面を作るとしましょう。

こういうの

記事に対応するUIステートをArticleとし、

data class Article(
    val id: String,
    val title: String,
)

記事一覧のUIをArticleList、一覧の子要素をArticleItemとします。

@Composable
fun ArticleItem(
    article: Article,
    onClick: ..., // ここをどうするか
    modifier: Modifier = Modifier,
) {
    Column(modifier.clickable { ... }) {
        Text(text = article.title)
    }
}

@Composable
fun ArticleList(
    articles: List<Article>,
    navigateToArticleDetail: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(modifier) {
        items(articles) { article ->
            ArticleItem(
                article = article,
                onClick = ...
            )
        }
    }
}

navigateToArticleDetailは記事IDを引数にとり、記事の詳細に遷移する関数とします。 このとき、タップ時のイベントであるonClickの型はどうすべきでしょうか?

深く考えなければArticleListの引数navigateToArticleDetailをそのまま渡してしまうのが楽そうです。これを実装Aとします。

// 実装A

@Composable
fun ArticleItem(
    article: Article,
    onClick: (String) -> Unit, // String: 記事ID
    modifier: Modifier = Modifier,
) {
    Column(modifier.clickable { onClick(article.id) }) { // ここでIDを指定
        Text(text = article.title)
    }
}

@Composable
fun ArticleList(
    articles: List<Article>,
    navigateToArticleDetail: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(modifier) {
        items(articles) { article ->
            ArticleItem(
                article = article,
                onClick = navigateToArticleDetail, // そのまま渡す
            )
        }
    }
}

一方、記事のIDを外部から注入する形にして、()-> Unitで実装することもできます。これを実装Bとします。

// 実装B

@Composable
fun ArticleItem(
    article: Article,
    onClick: () -> Unit, // 引数にあったStringを削除
    modifier: Modifier = Modifier,
) {
    Column(modifier.clickable { onClick() }) { // ここも削除
        Text(text = article.title)
    }
}

@Composable
fun ArticleList(
    articles: List<Article>,
    navigateToArticleDetail: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(modifier) {
        items(articles) { article ->
            ArticleItem(
                article = article,
                onClick = { navigateToArticleDetail(article.id) }, // 注入する
            )
        }
    }
}

さて、実装Aと実装B、どちらで実装すべきでしょうか?

依存性を考える

両者の違いの一つに依存性があります。 ArticleItem単体の実装を考えたとき、実装AはArticleListに依存しているが、実装Bは依存していないと言えます。

🐦 < つまり、どういうこと?

実装Aでは、ArticleItem内で、記事のIDをonClickに渡していました。

Column(modifier.clickable { onClick(article.id) }) { …

記事のIDを渡す必要が何故あるのか考えてみると、「リスト内のどの要素が押されたのか区別したい」からです。「リスト内の」という言葉にあるように、これはArticleListの実装の都合で、ArticleItem単体で見たときには関係のない話です。従ってArticleItemはこれに依存すべきではないでしょう。

例えば、タップしたときに画面遷移するのではなく、記事名を点滅させたくなった場合を考えてみます。実装AではArticleListArticleItemの両方に変更が必要になりますが、実装BではArticleListを変更するだけで対応できるはずです。

// 実装B

@Composable
fun ArticleList(
    articles: List<Article>,
-   navigateToArticleDetail: (String) -> Unit, // String: 記事ID
+   blink: (String) -> Unit, // String: 記事タイトル
    modifier: Modifier = Modifier,
) {
    LazyColumn(modifier) {
        items(articles) { article ->
            ArticleItem(
                article = artice,
-               onClick = { navigateToArticleDetail(article.id) },
+               onClick = { blink(article.title) },
            )
        }
    }
}

このように、親に依存しないよう設計することで、変更に強く、再利用の高いComposable functionを作ることができます。従って、依存性の観点では実装Bが望ましい、という結論になります。

いかがでしたか?

些細な部分ではありますが、割と気にせず実装している人も多いのではないでしょうか。 カンカクでは、こうした些細な部分に気を使って実装できるAndroidエンジニアを募集しています。 興味のある方はご連絡ください。