Open1

【Google I/O 2023】Best practices for saving UI state on Android

watabeewatabee

Best practices for saving UI state on Android

https://www.youtube.com/watch?v=V-s4z7B_Gnc&list=PLOU2XLYxmsIIwZQkAPhJZg8jaNrrHk1DH&index=40

Losing app state (0:41~)

アプリが UI の情報を失う原因。

  1. Configuration Changes
    • 端末の回転、画面のリサイズ、マルチウィンドウモードの切り替え、ライト・ダークモードの切り替えなどの操作
    • Configuration Changes が起こるとデフォルトでは Activity が再作成され、新しい Configuration で初期化される
      • マニフェストファイルで Activity の生成を行わないように設定できる
      • ただしいくつかの Configuration Changes については必ず Activity が再作成される
    • 詳細は goo.gle/configuration-changes
  2. System needs resources
    • アプリがバックグラウンドにいる状態でシステムのリソースが枯渇してきた場合、他のアプリのためにシステムはバックグラウンドにいるアプリを終了させることがある
  3. Unexpected app dismissal
    • ユーザーはアプリの使用履歴の画面でアプリをスワイプすることによって、アプリを強制終了することができる

Best practices for saving UI state (2:08~)

  • Configuration Changes が発生してもデータを保持するためには ViewModel を使う
    • ViewModel のインスタンスはメモリにキャッシュされている
    • Navigation ライブラリでは、対象の画面がバックスタックに保持されているときに ViewModel もキャッシュするので、画面を戻った際にもデータがすぐに使える

以下はコード例。

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {
    
    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicStream()
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(availableAuthors, availableTopics)
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
        )
}

Unexpected app dismissal (4:05~)

  • 予期しないアプリの終了に対処する場合、情報をメモリではなくストレージに保持しておく
  • ストレージに保持しておくために Jetpack では DataStore と Room が利用可能
  • DataStore
    • 小さくシンプルなデータセット向き
  • Room
    • 構造化されたデータ、部分的な更新、参照整合性、巨大で複雑なデータセット向き
  • この方法だと Configuration Changes、System needing resources, Unexpected app dismissal の場合の全てに対処可能
  • I/O 操作が必要になるので読み書きが遅くなる
    • そのため UI の状態を保持することには普通は使用しない
    • UI の状態は頻繁に更新されうるので、UI への変更の反映が遅くなってしまう

System needs resources (5:35~)

  • Android では重要なデータを保存しておき、アプリのプロセスが再生成される前の状態に復元するためのメカニズムが提供されている
  • Saved State APIs
    • 内部では Bundle オブジェクトが使われている
    • Jetpack Compose、ビューシステム、ViewModel のための API がある
  • この方法だと Configuration Changes、System needing resources の場合に対処可能
  • Android OS がシリアライズされたデータのコピーをアプリのプロセス外のメモリに保持しておく
  • 保存するデータサイズは Bundle によって制限される
    • 巨大なオブジェクトを保存しようとすると RuntimeException が発生する
    • 50KB 以上のデータは保存しないことをオススメ
  • シリアライズ、デシリアライズが発生するため、読み書きは遅くなりうる
  • 保存するデータはユーザーの入力やナビゲーションに関する一時的な状態のもの
    • 例えば、リストのスクロール位置、ユーザーが詳細を知りたいアイテムの ID、ユーザー設定の選択中の状態、テキストフィールドへの入力など
  • Jetpack Compose
    • rememberSaveable
  • ビューシステム
    • onSaveInstanceState

Jetpack Compose のコード例。

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) }
    
    ClickableText(
        text = AnnotatedString(message.content),
        onClick = { showDetails = !showDetails }
    )
    
    if (showDetails) {
        Text(message.timestamp)
    }
}

ビューシステムのコード例。

class ChatBubbleView(context: Context, ...) : View(context, ...) {
    private var isExpanded = false
    
    override fun onSaveInstanceState(): Parcelable {
        super.onSaveInstanceState()
        return bundleOf(IS_EXPANDED to isExpanded)
    }
    
    override fun onRestoreInstanceState(state: Parcelable) {
        isExpanded = (state as Bundle).getBoolean(IS_EXPANDED)
        super.onRestoreInstanceState(null)
    }
    
    companion object {
        private const val IS_EXPANDED = "is_expanded"
    }
}
  • Testing Saved State APIs
    • Jetpack Compose
      • StateRestorationTester
    • ビューシステム
      • ActivityScenario.recreate()

Jetpack Compose のテストコード例。

class ChatBubbleTests {
    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)
        
        restorationTester.setContent { ChatBublle() }
        
        composeTestRule.onNodeWithText(text = "Take a look at", substring = true).performClick()
        composeTestRule.onNodeWithText(text = "8:05 PM").assertIsDisplayed()
        
        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()
        
        composeTestRule.onNodeWithText(text = "8:05 PM").assertIsDisplayed()
    }
}
  • ViewModel integration
    • SavedStateHandle
    • goo.gle/architecture-viewmodel-savedstate

ViewModel でのコード例。

class ConversationViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {
    
    // SavedStateHandle は Activity が STOP 状態になった時のみデータを保存する
    var message by savedStateHandle.saveable(stateSaver = TextFieldValue.Saver) {
        mutableStateOf(TextFieldValue(""))
    }
        private set
    
    fun update(newMessage: TextFieldValue) {
        message = newMessage
    }
    
    fun send() { /* Send current message to the data layer */ }
}

Under the hood and advanced use cases (10:42~)

Contribute to saved state from your own classes

ニュースのための再利用可能な検索 UI があり、ユーザーが検索のために入力したテキストを saved state に保存したい場合を考える。
Compose での State Holder は以下のようになる。

class NewsSearchState(
    private val newsRepository: NewsRepository,
    initialSearchInput: String
) {
    var searchInput = mutableStateOf(TextFieldValue(initialSearchInput))
        private set
    
    companion object {
        fun saver(newsRepository: NewsRepository): Saver<NewsSearchState, *> = Saver(
            save = {
                with(TextFieldValue.Saver) { save(it.searchInput) }
            },
            restore = {
                TextFieldValue.Saver.restore(it)?.let { searchInput -> 
                    NewsSearchState(newsRepository, searchInput)
                }
            }
        )
    }
}

Compose で状態を saved state にするには、rememberSaveable を使う。

@Composable
fun rememberNewsSearchState(
    newsRepository: NewsRepository,
    initialSearchInput: String = ""
) {
    return rememberSaveable(
        newsRepository, initialSearchInput,
        saver = NewsSearchState.saver(newsRepository)
    ) {
        NewsSearchState(newsRepository, initialSearchInput)
    }
}

ビューシステムで同じように実装するには SavedStateRegistry を使う。

class NewsSearchState(
    private val newsRepository: NewsRepository,
    private val initialSearchInput: String.
    registryOwner: SavedStateRegistryOwner
) : SavedStateRegistry.SavedStateProvider {
    
    private var currentQuery: String = initialSearchInput
    
    init {
        registryOwner.lifecycle.addObserver(LifecycleEventObserver { _, event -> 
            if (event == Lifecycle.Event.ON_CREATE) {
                val registry = registryOwner.savedStateRegistry
                if (registry.getSavedStateProvider(PROVIDER) == null) {
                    registry.registerSavedStateProvider(PROVIDER, this)
                }
                val savedState = registry.consumeRestoredStateForKey(PROVIDER)
                currentQuery = savedState?.getString(QUERY) ?: initialSearchInput
            }
        })
    }
    
    // ... Rest of business logic ...
    
    override fun saveState(): Bundle {
        return bundleOf(QUERY to currentQuery)
    }
    
    companion object {
        private const val QUERY = "current_query"
        private const val PROVIDER = "news_search_state"
    }
}

Fragment で使う場合は以下のように初期化する。

class NewsFragment : Fragment() {
    
    private var newsSearchState = NewsSearchState(this)
    ...
}

Control rememberSaveable value's lifecycle

Jetpack Compose では SaveableStateRegistry でコンポーネントの状態を保存、復元する。
以下は rememberSaveable のコード。

@Composable
fun <T : Any> rememberSaveable(
    vararg inputs: Any?,
    saver: Saver<T, out Any> = autoSaver(),
    key: String? = null,
    init: () -> T
): T {
    // ...
    
    val registry = LocalSaveableStateRegistry.current
    val value = remember(*inputs) {
        val restored = registry?.consumeRestored(finalKey)?.let {
            saver.restore(it)
        }
        restored ?: init()
    }

新しい SaveableStateRegistry を定義することにより、rememberSaveable が値を保持する期間を制御できる。
これは Navigation ライブラリでも行なっていて、Navigation ではバックスタックにいる間は rememberSaveable の値がレジストリに保持される。

@Composable
public fun NavHost(...) {
    // ...
    val saveableStateHolder = rememberSaveableStateHolder()
    // ...
    
    backStackEntry?.LocalOwnersProvider(saveableStateHolder) {
        content(backstackEntry)
    }
    // ...
}
@Composable
public fun NavBackStackEntry.LocalOwnersProvider(
    saveableStateHolder: SaveableStateHolder,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalViewModelStoreOwner provides this,
        LocalLifecycleOwner provides this,
        LocalSavedStateRegistryOwner provides this
    ) {
        saveableStateHolder.SaveableStateProvider(content)
    }
}

Recap (19:00~)

Survives Limited by Use for
ViewModel Configuration changes Available memory UI state
Saved State APIs Above + system needing resources Bundle Transient UI state that depends on user input or navigation
Persistent Storage Above + unexpected app dismissals Disk space Application data