UIイベント管理をSharedFlowでスッキリ簡単に!Androidアプリの開発効率が向上できました!

はじめに

こんにちは!アソビューでAndroidアプリの開発をしているけんすーです。

アソビューのAndroidアプリはリリースから約2年が経ちました。その間、様々な機能追加や仕様変更を行ってきました。
当初は画面構成がシンプルで問題なかったものの、画面要素やUIイベントが増えることでComposable関数が複雑化していきました。その結果、コードの可読性や保守性が損なわれるという課題に直面しました。課題の詳細は次の章にて説明します。

今回は、その課題とどのように改善したのかを紹介します。
題材として、GitHubリポジトリを検索するアプリのサンプルコードを使用します。

サンプルコードは以下のGitHubから確認できます。 github.com

直面した課題

画面要素やUIイベントが増えることでComposable関数のコールバックが増えていき、下記のような課題が出てきました。

  • Composable関数のネストが深くなる
  • Composable関数のUIイベントの増減による修正コストの増加
  • UIイベントの処理フローが不透明
  • Composable関数とViewModelの依存関係が複雑

その結果、コードの可読性や保守性が低下し、開発効率の低下も招きました。

課題へのアプローチ

課題に対してどのような修正を行ったかサンプルコードを用いて説明していきます。

改善前のコード

以下は、改善前のコードです。

@Composable
fun HomeScreen(
    navController: NavController,
    viewModel: HomeViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsState()

    HomeScreen(
        uiState = uiState,
        // UIイベントに比例して引数が増える
        onSearch = { query ->
            viewModel.search(query)
        },
        onOpen = { url ->
            navController.navigate("webView/?url=${url}")
        }
        // 他のイベント処理
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeScreen(
    uiState: HomeUiState,
    onSearch: (String) -> Unit,
    onOpen: (String) -> Unit,
    // 他のイベント処理
) {
    Scaffold(
       ...
    ) { paddingValues ->
        Column(
           ...
        ) {
            HomeHeader(
                onSearch = onSearch,
            )
            HomeContent(
                // UIイベントに比例して引数が増える
                uiState = uiState,
                onOpen = onOpen,
                // 他のイベント処理
            )
        }
    }
}

@Composable
private fun HomeHeader(
    onSearch: (String) -> Unit,
) {
    SearchBar(
        onSearch = onSearch,
    )
}

@Composable
private fun HomeContent(
    uiState: HomeUiState,
    onOpen: (String) -> Unit,
    // 他のイベント処理
) {
    Box(
        ...
    ) {
        when {
            uiState.isLoading -> {
                ...
            }

            uiState.errorMessage != null -> {
                ...
            }

            else -> {
                RepositoryItemList(
                    repositories = uiState.repositories,
                    onClick = onOpen,
                )
            }
        }
    }
}

このサンプルコードでは、各Composable関数に対して多くのコールバックを渡す必要があります。例えば、HomeScreen関数ではonSearchonOpenといったUIイベントごとに個別の関数を渡し、さらに内部のComposable関数に引き渡すことになります。

UIイベントが増えるとそれに伴い修正箇所が増え、変更時に多くの場所で手を加える必要が出てきます。また、各イベント処理がコード上で追いにくくなり、可読性が低下してしまいます。さらに、Composable関数に渡す引数が増えることで、コード全体が複雑になり、メンテナンスが難しくなるという問題も生じてしまいます。 こうした構造のために、前章で述べたような問題が発生していました。

SharedFlowの導入

KotlinのSharedFlowを導入することで、UIイベントの管理を一元化し、Composable関数をシンプルに保つことができました。以下に具体的な実装についてサンプルコードを用いて説明します。

UiEvent と SharedFlow

まず、イベントを管理するためにUiEventを用意します。 このクラスを利用して画面にまつわるイベントを1つにまとめ、管理しやすくします。

sealed class HomeUiEvent {
    data class Search(val query: String) : HomeUiEvent()
    data class Open(val url: String) : HomeUiEvent()
    data object Retry() : HomeUiEvent()
    // 他のイベント
}

次に、ViewModelでSharedFlowを使用してイベントを発行するようにします。 これによりイベント処理をViewModelに集約でき、UI側ではイベントをトリガーするだけで済むようになります。

@HiltViewModel
class HomeViewModel @Inject constructor(
   ...
) : ViewModel() {

    private val _uiEvent = MutableSharedFlow<HomeUiEvent>()
    val uiEvent = _uiEvent.asSharedFlow()

    fun search(query: String) {
        ...
    }

    fun onEvent(event: HomeUiEvent) {
        viewModelScope.launch {
            _uiEvent.emit(event)
        }
    }
}

当初はStateFlowの利用も検討しましたが、UIイベントは通常、画面遷移やスナックバー表示などのワンショットイベントであるため、状態を持たないSharedFlowを採用しました。

改善後のコード

以下は、改善後のコードです。

@Composable
fun HomeScreen(
    navController: NavController,
    viewModel: HomeViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsState()
    val lifecycleOwner = LocalLifecycleOwner.current

    LaunchedEffect(lifecycleOwner, viewModel) {
        viewModel.uiEvent
            .flowWithLifecycle(lifecycleOwner.lifecycle)
            .onEach { event ->
                when (event) {
                    is HomeUiEvent.Search -> {
                        viewModel.search(event.query)
                    }

                    is HomeUiEvent.Open -> {
                        navController.navigate("webView/?url=${event.url}")
                    }

                    // 他のイベント処理
                }
            }
            .launchIn(this)
    }

    HomeScreen(
        uiState = uiState,
        onEvent = viewModel::onEvent,
    )
}

改善後のコードでは、HomeScreenでViewModelで定義したonEventを下層のComposable関数に渡すだけで済むため、コードがシンプルになりました。また、イベントをsealed classで定義することで、when式を使って網羅的に記述でき、実装漏れを防ぎつつ、イベントの処理内容が明確になります。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HomeScreen(
    uiState: HomeUiState,
    onEvent: (HomeUiEvent) -> Unit,
) {
    Scaffold(
        ...
    ) { paddingValues ->
        Column(
            ...
        ) {
            HomeHeader(
                onEvent = onEvent,
            )
            HomeContent(
                uiState = uiState,
                onEvent = onEvent,
            )
        }
    }
}

@Composable
private fun HomeHeader(
    onEvent: (HomeUiEvent) -> Unit,
) {
    SearchBar(
        onSearch = { onEvent(HomeUiEvent.Search(it)) },
    )
}

@Composable
private fun HomeContent(
    uiState: HomeUiState,
    onEvent: (HomeUiEvent) -> Unit,
) {
    Box(
        ...
    ) {
        when {
            uiState.isLoading -> {
                ...
            }

            uiState.errorMessage != null -> {
                ...
            }

            else -> {
                RepositoryItemList(
                    repositories = uiState.repositories,
                    onClick = { onEvent(HomeUiEvent.Open(it)) },
                )
            }
        }
    }
}

子Composableでも同様にonEventを各Composableに渡すだけでUIイベントに対する処理を簡潔に記述できるようになりました。また、イベントのハンドリングが一箇所に集約されているため、修正が必要な際も容易に対応でき、コード全体の可読性と保守性が向上しました。

SharedFlowを使う際の注意点

公式ドキュメントには、KotlinのChannelや他のリアクティブストリームを使用してViewModelのイベントをUIに公開することに注意が必要であると記載されてます。 特にViewModelのライフサイクルがUIよりも長くなる場合、イベントの配信と処理が保証されなくなる可能性があります。SharedFlowやChannelの選択はプロジェクトのユースケースに合わせて検討する必要があります。

medium.com

まとめ

SharedFlowを導入することでアソビューのAndroidアプリ開発において以下の効果を得ることができました。

・コードの可読性向上
UIイベントが集約され、Composableの見通しが良くなりました。どのイベントがどこで処理されているかを追跡しやすくなり、イベントの流れを把握しやすくなりました。

・イベント処理の一元化による保守性の向上
新しいイベントの追加や既存のイベントの変更が容易になり、変更による影響範囲が明確になりました。これにより、バグの発生率を抑えることが期待でき、コードのメンテナンスがしやすくなりました。

・開発効率の向上
イベント処理のルールが統一され、開発者による実装のばらつきがなくなりました。 これにより、機能追加や修正が迅速に行えるようになりました。

さいごに

本記事では、AndroidアプリのUIイベントの改善事例を紹介しました。 今後もチームで議論を重ね、試行錯誤しながらプロダクトの成長に合わせて改善を続くていく予定です。

アソビューでは、一緒に働くメンバーを大募集しています!カジュアル面談も実施しておりますので、少しでもご興味をお持ちいただけましたら、ぜひお気軽にご応募ください!

www.asoview.com