Slide 1

Slide 1 text

Modeling UiEvent モバチキ#5 mikan( 一瀬喜弘)

Slide 2

Slide 2 text

自己紹介 object Mikan { val name = " 一瀬喜弘" val company = "karabiner.tech" val work = Engineer.Android val hobby = listOf( " 漫画", " アニメ", " ゲーム", " 折り紙", "OSS 開発・コントリビュート", ) }

Slide 3

Slide 3 text

Compose で複雑なUI を作る際に 直面している課題

Slide 4

Slide 4 text

関数の引数が無限に増える ( 特にFunction Type)

Slide 5

Slide 5 text

実務のコードだと最大で 72 個 のパラメータを持っており、今後も増えていく予定 @Composable fun カート画面( slot: @Composable () -> Unit, aUiModel: AUiModel, contents: List, onClickA: (id: ContentId) -> Unit, onClickA2: (id: ContentId, index: Int) -> Unit, bannerUiModel: BannerUiModel, onClickBanner: () -> Unit, notifications: List, onClickNotification: (messageId: String, url: String) -> Unit, // ... )

Slide 6

Slide 6 text

問題: 新しいユーザーインタラクションが増えたとき 1. ViewModel にイベントハンドラーを追加 2. 親コンポーネントの引数を増やす 3. 親コンポーネントを呼び出している箇所を修正 4. パラメータをバケツリレーしていく… 階層が深いほどめんどくささが増す

Slide 7

Slide 7 text

問題: 既存のユーザーインタラクションを消すとき 1. ユーザーインタラクションを発火している箇所を消す 2. それを呼び出しているコードを修正 3. それを呼び出しているコードを修正 4. それを(ry 5. ViewModel 側の処理が不要になったのならそれも削除する ユーザーインタラクションを消すという単純なことなのに バケツリレーのすべてが影響を受け、変更容易性がだんだん下がる

Slide 8

Slide 8 text

ユーザーインタラクション数 = UI 要素数 × n (n: 自然数) ユーザーインタラクションの​ 数は​ UI 要素数に​ たいして​ n 倍程度​ 増える​ もの​

Slide 9

Slide 9 text

UI は本質的に複雑なもの

Slide 10

Slide 10 text

パラメータ無限増幅問題を​ 実装の​ 仕方に​ よって​ 多少改善させられないかと​ 思って​ 実践している​ ことを​ 共有します

Slide 11

Slide 11 text

解決案: ユーザーインタラクションを​ モデリングする​ ことで​ イベントハンドラーを​ 集約させる​

Slide 12

Slide 12 text

ユーザーインタラクションをモデリング ユーザーインタラクションを HogeUiEvent という sealed interface として直積的に実装する UiEvent の種類 クリック おそらくこれがほとんど キーボード入力 sealed interface CartUiEvent { data class ClickA(id: ContentId) : CartUiEvent data object ClickBanner : CartUiEvent // ... }

Slide 13

Slide 13 text

イベントハンドラーを集約させる Composable 関数に渡す関数型のパラメータは原則1 つに制限する @Composable fun カート画面( onUiEvent: (CartUiEvent) -> Unit, aUiModel: AUiModel, bUiModel: BUiModel, // ... ) { }

Slide 14

Slide 14 text

UiEvent の発火やハンドリングはどう実装するのか 1. ViewModel にイベントハンドラーを定義する 2. onUiEvent をComposable 関数に渡す 3. UiEvent を発火する

Slide 15

Slide 15 text

1. ViewModel にイベントハンドラーを定義する class CartViewModel() : ViewModel() { // エントリーポイントだけ外部に公開する fun onUiEvent(event: CartUiEvent) { // UiEvent と具体的な処理をマッピングする when (event) { is CartUiEvent.ClickA -> onClickA(event.id) CartUiEvent.ClickBanner -> onClickBanner() // ... } } // 具体的な処理はすべてprivate で隠蔽する private fun onClickA(id: ContentId) { // ... } private fun onClickBanner() { // } // ... }

Slide 16

Slide 16 text

2. onUiEvent を渡す CartScreen( // ... onUiEvent = viewModel::onUiEvent )

Slide 17

Slide 17 text

3. UiEvent を発火する onClick = { onUiEvent(CartUiEvent.ClickA(aUiModel.id)) }

Slide 18

Slide 18 text

UiEvent を​ 導入した​ ことで​ 変更は​ しやすくなったのか

Slide 19

Slide 19 text

UiEvent sealed interface CartUiEvent { data class ChangeQuantity(itemId: String, quantity: Int): CartUiEven data class DeleteItem(itemId: String) : CartUiEvent data class ClickAd(url: String) : CartUiEvent }

Slide 20

Slide 20 text

ViewModel class CartViewModel() : ViewModel() { fun onUiEvent(event: CartUiEvent) { when (event) { is ChangeQuantity -> changeQuantity(event.itemId, event.quantity) is DeleteItem -> deleteItem(event.itemId) is ClickAd -> transitionToWebView(event.url) } } private fun changeQuantity(itemId: String, quantity: Int) { // ... } private fun deleteItem(itemId: String) { // ... } private fun transitionToWebView(url: String) { // ... } }

Slide 21

Slide 21 text

Activity class CartActivity() : ComponentActivity() { pruivate val viewModel: CartViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { setContent { MyAppTheme { Surface { CartScreen( // ... onUiEvent = viewModel::onUiEvent ) } } } } }

Slide 22

Slide 22 text

Parent Component @Composable fun CartScreen( // ... onUiEvent: (CartUiEvent) -> Unit, ) { LazyColumn { items(uiModel.items, { it.id }) { item -> CartItem( // ... onUiEvent = onUiEvent ) } item { AdSection( // ... onUiEvent = onUiEvent ) } } }

Slide 23

Slide 23 text

Child Component @Composable fun CartItem( // ... onUiEvent: (CartUiEvent) -> Unit ) { // ... Spinner( onQuantityChange = { quantity -> onUiEvent(ChangeQuantity(uiModel.itemId, quantity)) } ) DeleteButton( onClick = { onUiEvent(DeleteItem(uiModel.id)) } ) }

Slide 24

Slide 24 text

シナリオ: お気に入りボタン追加

Slide 25

Slide 25 text

UiEvent イベント追加 sealed interface CartUiEvent { data class ChangeQuantity(itemId: String, quantity: Int): CartUiEven data class DeleteItem(itemId: String) : CartUiEvent data class ClickAd(url: String) : CartUiEvent data class AddToFavorite(itemId: String) : CartUiEvent }

Slide 26

Slide 26 text

ViewModel イベントハンドラー追加 UiEvent とイベントハンドラーのマッピング追加 class CartViewModel() : ViewModel() { fun onUiEvent(event: CartUiEvent) { when (event) { is ChangeQuantity -> changeQuantity(event.itemId, event.quantity) is DeleteItem -> deleteItem(event.itemId) is ClickAd -> transitionToWebView(event.url) is AddToFavorite -> addToFavorite(event.itemId) } } // ... private fun addToFavorite(itemId: String) { // ... } }

Slide 27

Slide 27 text

Activity class CartActivity() : ComponentActivity() { pruivate val viewModel: CartViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { setContent { MyAppTheme { Surface { CartScreen( // ... onUiEvent = viewModel::onUiEvent ) } } } } }

Slide 28

Slide 28 text

Parent Component @Composable fun CartScreen( // ... onUiEvent: (CartUiEvent) -> Unit, ) { LazyColumn { items(uiModel.items, { it.id }) { item -> CartItem( // ... onUiEvent = onUiEvent ) } item { AdSection( // ... onUiEvent = onUiEvent ) } } }

Slide 29

Slide 29 text

Child Component コンポーネント追加 イベント発火 @Composable fun CartItem( // ... onUiEvent: (CartUiEvent) -> Unit ) { // ... FavoriteButton( onClick = { onUiEvent(AddToFavorite(uiModel.id)) } ) }

Slide 30

Slide 30 text

変更した箇所 UiEvent ViewModel: イベントの到達点 CartItem: イベントの発火点 → Activity や親コンポーネントなどの途中の要素への変更をしな くてよくなった

Slide 31

Slide 31 text

メリット 変更箇所が減る( 複雑なUI ほど恩恵が大きい) Composable 関数内でのバケツリレーが単純になる イベントが型になるので引数が同じイベントであっても区別しやすい デメリット 単純な画面、変更がほとんど行われない画面に導入すると複雑さを無闇に増 やすだけに繋がりかねない イベント発火点を消しただけではUiEvent の参照はなくならないので実装が中 途半端に残って技術的負債化する恐れがある

Slide 32

Slide 32 text

UiEvent を構造化する: 複雑化するUI への対処 UI 要素が増えてUiEvent がどんどん複雑化していったとき ClickMoreA とClickMoreB は名前が非常に似ているので取り違える可能性が高い → 実行できるUiEvent に制限を設けて間違いを減らす sealed interface CartUiEvent { data class ChangeQuantity(val itemId: String, val quantity: Int) : C data class DeleteItem(val itemId: String) : CartUiEvent data class ClickAd(val url: String) : CartUiEvent data class AddToFavorite(itemId: String) : CartUiEvent data object ClickMoreA : CartUiEvent data object ClickMoreB : CartUiEvent }

Slide 33

Slide 33 text

UiEvent を構造化する: 複雑化するUI への対処 UiEvent を継承する sealed interface CartUiEvent sealed interface ASectionUiEvent : CartUiEvent { data object ClickMore : ASectionUiEvent } sealed interface BSectionUiEvent : CartUiEvent { data object ClickMore : BSectionUiEvent }

Slide 34

Slide 34 text

UiEvent を構造化する: 複雑化するUI への対処 Section レベルのComposable 関数に渡すUiEvent を制限する @Composable fun ASection( onUiEvent: (ASectionUiEvent) -> Unit, // ... ) { // ASectionUiEvent を実装したイベントしか発行できない } @Composable fun BSection( onUiEvent: (BSectionUiEvent) -> Unit, // ... ) { // BSectionUiEvent を実装したイベントしか発行できない }

Slide 35

Slide 35 text

UiEvent を構造化する: 複雑化するUI への対処 CartUiEvent を継承しているのでonUiEvent をそのまま渡せる 🎉🎉 @Composable fun CartScreen( onUiEvent: (CartUiEvent) -> Unit, // ... ) { // CartUiEvent は継承元なのでそのまま渡すことができる ASection( // ... onUiEvent = onUiEvent ) BSection( // ... onUiEvent = onUiEvent ) }

Slide 36

Slide 36 text

共通パーツのUiEvent 複数の​ 画面で​ 使い回す共通の​ UI は​ どうしよう? このような​ UI には​ 共通の​ UiEvent は​ 定義せずに​ 利用する​ 側で​ それぞれUiEvent を​ 定義していく​ ほうが​ いい​ 気が​ している​ → 機能要件は​ 同じでも、​ 非機能要件​ (ログとか)は​ 画面ごとに​ やりたいことが​ 若干違ったりする​

Slide 37

Slide 37 text

UiEvent がオーバーエンジニアリングしないように注意する イベントと状態の関係はステートマシンとして表現可能なので、 一貫したステー トマシンを作るための仕組みを導入したくなる 1. 自作のDSL を作る 2. 既存のMVI フレームワークを使う

Slide 38

Slide 38 text

UiEvent がオーバーエンジニアリングしないように注意する このようなMVI フレームワークでは 状態、イベント、副作用を型 引数としたインターフェースなり抽象クラス(Screen )を作っていたり、 Redux アーキテクチャよろ しくReducer という概念を導入してくるが、 これらはいまのとこ ろ個人的にはやりすぎだと思っている 一貫性をもたせるために導入するには複雑すぎる Google が方針転換したり新しい実装パターンを提示した瞬間に 技術負債と化す

Slide 39

Slide 39 text

参考 https://medium.com/@hunterfreas/handling-ui-events-in-jetpack-compose-a-clean-approach-c8fd1bfc6231 iOSDC Japan 2020: SwiftUI 時代の Functional iOS Architecture / 稲見 泰宏 https://www.youtube.com/watch?v=g_hq3qfn-O8 圏論( モナド) にまで手をだしているので上級者向け → 関数型の考えは参考になる