Slide 1

Slide 1 text

Mori Atsushi LINEヤフー株式会社 / Androidアプリエンジニア 2025/06/21 DroidKaigi.collect { #19@Osaka } 原則から考える 保守しやすいComposable関数設計

Slide 2

Slide 2 text

LINEヤフー株式会社 / Android 2019年度 未踏スーパークリエータ 『良いコードの道しるべ』著者 Mori Atsushi 森 篤史 X: @at_sushi_at 詳解 Kotlin Coroutines [2021] mori-atsushi/compose-callable

Slide 3

Slide 3 text

良いコードの道しるべ 変化に強いソフトウェアを作る原則と実践 森 篤史 (著), 久野 文菜 (イラスト)

Slide 4

Slide 4 text

今日の内容 1. ʲ関心の分離ʳUIの描画と状態管理を分けよう 2. ʲ凝集度ʳスロットパターンを使う 3. ʲ凝集度ʳカスタムModi fi erを作ろう 📝

Slide 5

Slide 5 text

ʲ関心の分離ʳ UIの描画と状態管理を分けよう 1

Slide 6

Slide 6 text

関心の分離 分割する際はそのコードが何をしようとしているのか、 その関心事に着目し、関心毎に分割してください。 3.1 関心やクラスを分ける l

Slide 7

Slide 7 text

Composable関数の場合 UIの描画と状態の管理を分けよう State Composable State Event

Slide 8

Slide 8 text

ドラッ グ 可能なアイテム

Slide 9

Slide 9 text

o ff setの保持 o ff setの設定 @Composable fun DraggableScreen() { val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) } val coroutineScope = rememberCoroutineScope() Box(modifier = Modifier.fillMaxSize()) { Box( Modifier .offset { offset.value.round() } .background(Color.Blue) .size(60.dp) .clickable { coroutineScope.launch { offset.animateTo(Offset(0f, 0f)) } } // … クリック時に初期位置にリセット 改善前

Slide 10

Slide 10 text

// … .pointerInput(Unit) { detectDragGestures { change, dragAmount -> change.consume() coroutineScope.launch { offset.snapTo( Offset( x = offset.value.x + dragAmount.x, y = offset.value.y + dragAmount.y, ) ) } } } ) } } drag時の処理 改善前

Slide 11

Slide 11 text

class DraggableState { private val animatable = Animatable(INITIAL_OFFSET, Offset.VectorConverter) val offset: IntOffset get() = animatable.value.round() suspend fun drag(dragAmount: Offset) { animatable.snapTo( Offset( x = animatable.value.x + dragAmount.x, y = animatable.value.y + dragAmount.y, ) ) } suspend fun reset() { animatable.animateTo(INITIAL_OFFSET) } companion object { private val INITIAL_OFFSET = Offset(0f, 0f) } } drag時の処理 Click時の処理 改善後

Slide 12

Slide 12 text

drag時の処理 Click時の処理 改善後 @Composable fun DraggableScreen() { val state = remember { DraggableState() } val coroutineScope = rememberCoroutineScope() Box(modifier = Modifier.fillMaxSize()) { Box( Modifier .offset { state.offset } .background(Color.Blue) .size(60.dp) .clickable { coroutineScope.launch { state.reset() } } .pointerInput(Unit) { detectDragGestures { change, dragAmount -> change.consume() coroutineScope.launch { state.drag(dragAmount) } } }, ) } }

Slide 13

Slide 13 text

💡 • Composable関数が短くなる • 各アクションに名前がつくので、 何をやっているのか分かりやすくなる • UIだけ、状態管理だけを独立して 変更しやすくなる • 状態のみのテストがかける 何が嬉しい?

Slide 14

Slide 14 text

ʲ凝集度ʳ スロットパターンを使う 2

Slide 15

Slide 15 text

凝集度 「凝集度」は、1つの関数やクラス内のコードがどの程度、 密接に結 び ついているかを示す言葉です。 (中略)凝集度が高い関数やクラスは、読みやすく、壊れに くく、また再利用しやすいという傾向にあります。 3.4 凝集度を高める l

Slide 16

Slide 16 text

関数の凝集度のレベル 3.4 凝集度を高める 偶発的凝集 論理的凝集 時間的凝集 手続き的凝集 通信的凝集 逐次的凝集 機能的凝集 凝集度が低い 凝集度が高い

Slide 17

Slide 17 text

論理的凝集 3.4 凝集度を高める fun output(text: String, target: Target) { when (target) { Target.Email -> /* ϝʔϧͷૹ৴ॲཧ */ Target.Log -> / * ϩάͷग़ྗॲཧ * / } } enum class Target { Email, Log } コードを書く上でその機能や目的ではなく、 論理的に関連しているものを集めた状態を指します。 l メール送信とロ グ 出力は本質的に異なる。 共通するのは「テキストを出力する」という点のみ

Slide 18

Slide 18 text

アイコンの場合 改善前 @Composable fun ContentWithBackground(content: Content, modifier: Modifier = Modifier) { Box( modifier = modifier .background(Color(0xFFBBDEFB), RoundedCornerShape(8.dp)) .padding(16.dp) ) { when (content) { is Content.Icon -> Icon(imageVector = content.imageVector, contentDescription = null) is Content.Text -> Text(text = content.text, fontSize = 16.sp) } } } sealed interface Content { data class Icon(val imageVector: ImageVector) : Content data class Text(val text: String) : Content } テキストの場合

Slide 19

Slide 19 text

改善後 @Composable fun RounededBlueBackground(modifier: Modifier = Modifier, content: @Composable () -> Unit) { Box( modifier = modifier .background(Color(0xFFBBDEFB), RoundedCornerShape(8.dp)) .padding(16.dp) ) { content() } } @Composable fun IconContent(imageVector: ImageVector, modifier: Modifier = Modifier) { Icon(modifier = modifier, imageVector = imageVector, contentDescription = null) } @Composable fun TextContent(text: String, modifier: Modifier) { Text(modifier = modifier, text = text, fontSize = 16.sp) } スロットパターン

Slide 20

Slide 20 text

改善後 (別のアイ デ ィア) @Composable fun IconWithBackground(imageVector: ImageVector, modifier: Modifier = Modifier) { Icon( modifier = modifier.roundedBlueBackground(), imageVector = imageVector, contentDescription = null, ) } @Composable private fun TextWithBackground(text: String, modifier: Modifier) { Text( modifier = modifier.roundedBlueBackground(), text = text, fontSize = 16.sp, ) } private fun Modifier.roundedBlueBackground(): Modifier = this .background(Color(0xFFBBDEFB), RoundedCornerShape(8.dp)) .padding(16.dp) 共通背景を定義

Slide 21

Slide 21 text

💡 • アイコンとテキストのレイアウトを独立し て更新できる 例外:一つのリスト内の要素として並べて表 示したい場合、こういったsealed classが必 要になる場合もある 何が嬉しい?

Slide 22

Slide 22 text

ʲ凝集度ʳ カスタムModi fi erを作ろう 3

Slide 23

Slide 23 text

時間的凝集 3.4 凝集度を高める fun initApp() { initConfig() initLogger() initDatabase() } 同じタイミン グ に動作するものを集めた状態を指します。 (中略)中身の実行順序を入れ替えても動作するという特徴があります。 l アプリ起動時に行われる処理を集めた関数

Slide 24

Slide 24 text

手続き的凝集 3.4 凝集度を高める fun outputFile(file: File) { checkPermission() writeFile(file) } 処理の順序に意味がある操作を1つにまとめた状態を指します。 先ほどの時間的凝集とは異なり、順序を入れ替えると 正しく動作しなくなるという特徴があります。 l ファイルを書き込む前に権限をチェックする必要がある

Slide 25

Slide 25 text

改善前 @Composable fun ShadowButton( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val shape = RoundedCornerShape(8.dp) Text( modifier = modifier .shadow(elevation = 3.dp, shape = shape) .background(Color(0xFFBBDEFB), shape) .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), text = label, ) }

Slide 26

Slide 26 text

改善前 @Composable fun ShadowButton( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Text( modifier = modifier.shadowButton(onClick = onClick), text = label, ) } private fun Modifier.shadowButton(onClick: () -> Unit): Modifier { val shape = RoundedCornerShape(8.dp) return this .shadow(elevation = 3.dp, shape = shape) .background(Color(0xFFBBDEFB), shape) .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), } ボ タン専用Modi fi er

Slide 27

Slide 27 text

@Composable fun ShadowButton( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Text( modifier = modifier .roundedBackgroundWithShadow() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), text = label, ) } private fun Modifier.roundedBackgroundWithShadow(): Modifier { val shape = RoundedCornerShape(8.dp) return this .shadow(elevation = 3.dp, shape = shape) .background(Color(0xFFBBDEFB), shape) } 背景のみのModi fi er 改善後

Slide 28

Slide 28 text

💡 • 動作内容に基づいて名前付けされてるの で、動作が理解しやすくなる • 装飾と挙動のModi fi erが分かれているの で、どこを変更すれば良いかわかりやすく なる • 再利用がしやすくなる 何が嬉しい?

Slide 29

Slide 29 text

まとめ Composable関数を整理する際も、 関心事や凝集度に基づくことで保守性が高まる コードの保守性に関する原則を知ることで、 自信を持ってコードを改善したり、 コードレ ビ ューで指摘できるようになる。