🕌

Androidのアーキテクチャガイドから学ぶ、UIの状態管理手法

2022/03/22に公開

2021年末頃にAndroid公式ドキュメントにある アーキテクチャガイド が大幅にアップデートされました。
今回はその中でも UI Layerについての章 を取り上げて、アプリにおいてUIの状態をどのように管理するのが適切かについてまとめます。

UIとは?

そもそも UI とは、TextやButtonのような視覚的に表現される UI Elements に、ユーザーが目にするべきアプリの状態を示す UI State を反映することで構築されています。

状態とイベントの管理

UI Stateはユーザーインタラクションなどのイベントの発生によって、時間と共に変化していきます。
各イベントを適切にハンドリングしつつ、Data Layerから渡ってきたデータを適切な形に変換する必要があります。

これらをUI Elementsで行うことも可能ですが、責務が集中して複雑度が増し、手に負えなくなっていく懸念があります。
UI Stateの生成とそれに必要なロジックを実装する責務を持つ State Holder クラスを用意するのが良いとされています。

例えば、画面全体を管理対象とするState Holderには、Android開発だと一般的にViewModelのインスタンスが用いられます。

State HolderからUI Elementsへと状態が下に流れ、UI ElementsからState Holderへとイベントが上に流れるパターンを、 単方向データフロー(Unidirectional Data Flow) と呼びます

UDFでは以下のようなことが実現されます。

  • データの整合性
    • UI に対して、信頼できる唯一のデータソースが存在します。
  • テストのしやすさ
    • 状態のソースが分離されるため、UI から独立してテストを行うことができます。
  • メンテナンスのしやすさ
    • 状態の変化は、ユーザー イベントおよびデータソースからのデータ取得の結果であるという、明確に定義されたパターンに従います。

UI Stateの管理手法

UI Stateの管理について、ニュース記事の一覧を読者に提供するアプリを例に具体的な手法を考えていきます。
このアプリには、ユーザーが読むことのできる記事を提示する記事画面があり、ログインしているユーザーは目を引く記事をブックマークすることもできます。

まずは、ニュース記事の一覧とブックマーク数を2つの異なるストリームで管理することを考えてみます。

class NewsViewModel(...) : ViewModel() {
    private val _newsList = MutableStateFlow(NewsItem())
    val newsList = _newsList.asStateFlow()
    private val _bookmarkCount = MutableStateFlow(0)
    val bookmarkCount = _bookmarkCount.asStateFlow()

異なる2つのストリームで関連する状態を管理してしまうと、一方が更新されてもう一方が更新されない、というような状態の不整合が生じる可能性があります。
回避策として、ブックマーク数をニュース一覧のストリームから分岐させる形で実装する方法が考えられます

class NewsViewModel(...) : ViewModel() {
    private val _newsItems = MutableStateFlow(listOf<NewsItem>())
    val newsItems = _newsItems.asStateFlow()
    val bookmarkCount = newsItems.map { newsItems ->
        newsItems.count { news -> news.bookmarked }
    }

しかし、それも数が増えていった場合にはどうでしょう?
管理が煩雑になり、不整合が生まれやすい状態ができてしまいます。

class NewsViewModel(...) : ViewModel() {
    private val _isSignedIn = MutableStateFlow(false)
    val isSignedIn = _isSignedIn.asStateFlow()
    private val _isPremium = MutableStateFlow(false)
    val isPremium = _isPremium.asStateFlow()
    val canBookmarkNews = isSignedIn.combine(isPremium) { isSignedIn, isPremium ->
        isSignedIn && isPremium
    }
    private val _newsItems = MutableStateFlow(listOf<NewsItem>())
    val newsItems = _newsItems.asStateFlow()
    val bookmarkCount = newsItems.map { newsItems ->
        newsItems.count { item -> item.bookmarked }
    }
    val hasReadCount = newsItems.map { newsItems ->
        newsItems.count { item -> item.hasRead }
    }

解決策として、状態の表現に必要なすべてのフィールドを唯一のUI Stateオブジェクトに定義して管理します。
これにより、状態の複雑度を抑えつつ、整合性を保ちやすくなります。

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItem> = listOf()
)

val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
val NewsUiState.bookmarkCount: Int get() = newsItems.count { item -> item.bookmarked }
val NewsUiState.hasReadCount: Int get() = newsItems.count { item -> item.hasRead }

class NewsViewModel(...) : ViewModel() {
    var uiState by mutableStateOf(NewsUiState())
        private set
    ...
}

このような理由から、 1つの UI Stateオブジェクトで、互いに関連付けられた複数の状態を処理する のが良いとされています。
(もちろんフィールドの更新のたびにUI Elements側に更新が伝搬されるため、Jetpack Composeのような差分更新のレイアウトシステムがあることが望ましいです)

また、扱う状態が複雑化してきた場合、UI Stateの定義を工夫して管理しやすくすることもできます。

例えば、リストの状態を管理する際に、空の時、コンテンツがある時、などいくつか表現すべき状態とそれに対応して必要なフィールドが変わってきます。
そのような場合は ViewModelState を唯一の情報源として用意し、 sealed class で複数定義したUI Stateにマッピングして公開するという管理パターンが考えられます。

sealed interface HomeUiState {

    val isLoading: Boolean
    val errorMessages: List<ErrorMessage>
    val searchInput: String

    data class NoPosts(
        override val isLoading: Boolean,
        override val errorMessages: List<ErrorMessage>,
        override val searchInput: String
    ) : HomeUiState

    data class HasPosts(
        val postsFeed: PostsFeed,
        val selectedPost: Post,
        val isArticleOpen: Boolean,
        val favorites: Set<String>,
        override val isLoading: Boolean,
        override val errorMessages: List<ErrorMessage>,
        override val searchInput: String
    ) : HomeUiState
}

private data class HomeViewModelState(
    val postsFeed: PostsFeed? = null,
    val selectedPostId: String? = null,
    val isArticleOpen: Boolean = false,
    val favorites: Set<String> = emptySet(),
    val isLoading: Boolean = false,
    val errorMessages: List<ErrorMessage> = emptyList(),
    val searchInput: String = "",
) {
    fun toUiState(): HomeUiState =
        if (postsFeed == null) {
            HomeUiState.NoPosts(
                isLoading = isLoading,
                errorMessages = errorMessages,
                searchInput = searchInput
            )
        } else {
            HomeUiState.HasPosts(
                postsFeed = postsFeed,
                selectedPost = postsFeed.allPosts.find {
                    it.id == selectedPostId
                } ?: postsFeed.highlightedPost,
                isArticleOpen = isArticleOpen,
                favorites = favorites,
                isLoading = isLoading,
                errorMessages = errorMessages,
                searchInput = searchInput
            )
        }
}

class HomeViewModel(...) : ViewModel() {
    private val viewModelState = MutableStateFlow(HomeViewModelState(isLoading = true))

    val uiState = viewModelState
        .map { it.toUiState() }
        .stateIn(
            viewModelScope,
            SharingStarted.Eagerly,
            viewModelState.value.toUiState()
        )

(https://github.com/android/compose-samples/blob/main/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt より)

消費型のUI Eventの管理

UI ElementsはState Holderの公開するUI Stateを監視し、更新のたびにViewに反映しています。
しかし、例えば Snackbar のメッセージやナビゲーションのような、一度だけ消費したいUI Eventについてはどう管理するのが良いでしょう。

Kotlin Flowの MutableStateFlow を使用する方法だと、「一度だけ消費」という要件を満たすことができません。
1つの回避策として、 Event Wrapper を用いるという方法が考えられます。

open class Event<out T>(private val content: T) {

    var hasBeenHandled = false
        private set // Allow external read but not write

    /**
     * Returns the content and prevents its use again.
     */
    fun getContentIfNotHandled(): T? {
        return if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    }

    /**
     * Returns the content, even if it's already been handled.
     */
    fun peekContent(): T = content
}
class LatestNewsViewModel(...) : ViewModel() {
    private val _showSnackbar = MutableStateFlow<Event<String>>()
    val showSnackbar : StateFlow<Event<String>>
        get() = _showSnackbar

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
	        _showSnackbar.value = Event("No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }
}
@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    viewModel.showSnackbar.getContentIfNotHandled()?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage.message)
        }
    }
}

(https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150 より)

しかしこの方法だと、「状態が下方に流れ、イベントが上方に流れる」というUDFの原則に反してしまうため、アーキテクチャガイドでは推奨されていません。

解決策として、消費型のUI EventはUI Stateとして定義し、UI Elementsはイベントを消費したことをState Holderへ伝え、別のUI Stateへと更新されるようにする方法が示されています。

// Models the message to show on the screen.
data class UserMessage(val id: Long, val message: String)

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessages: List<UserMessage> = emptyList()
)
class LatestNewsViewModel(...) : ViewModel() {

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                val messages = uiState.userMessages + UserMessage(
                    id = UUID.randomUUID().mostSignificantBits,
                    message = "No Internet connection"
                )
                uiState = uiState.copy(userMessages = messages)
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown(messageId: Long) {
        val messages = uiState.userMessages.filterNot { it.id == messageId }
        uiState = uiState.copy(userMessages = messages)
    }
}
@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show the first one and notify the ViewModel.
    viewModel.uiState.userMessages.firstOrNull()?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage.message)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown(userMessage.id)
        }
    }
}

上記の例だと Snackbar へのメッセージ表示のみを扱っていますが、当然他にもダイアログ表示やナビゲーションなど、様々な処理パターンが考えられます。
これらの消費型イベントに対してアーキテクチャガイドに倣って愚直に実装していくと、かなり冗長なコードが増えていきます。

下記の すたぜろさん の記事では、消費型UI Eventを抽象化して定義することで、この問題への解決策を示しています。
https://star-zero.medium.com/viewmodelイベントの実装-74dd814deb97

まとめ

Androidのアーキテクチャガイドをもとに、UIの状態管理について書きました。

しかし、あくまでもこれはガイドであり、要件の複雑度やチームの規模に応じて適切に使い分けていく必要があると思っています。
例えば、管理すべき状態が単純なケースで今後も要件が複雑化することのない画面あればViewModelは不要かもしれませんし、チームの規模が大きく各人の実装に統一性を持たせてコードの保守性を担保したいのであればViewModelの定義を必須化するのが良いでしょう。

本記事がそういった判断をする上での一助となれば幸いです。

Discussion