ETA Screen (2)

SavedStateHandle

不知道大家有没有发现在「ETA Screen (1)」贴出来的 EtaViewModel 的 constructor 有一个 SavedStateHandle?在继续完成余下的错误情景前,我们先看看 SavedStateHandle 是甚麽。

大家看过不少有关 Architecture Components 讲关於 ViewModel 的特色时都一定会提到 ViewModel 内的 variable 能在 configuration change 後都能保持着,因为 Activity 或者 Fragment 在 configuration change 後经 ViewModelProvider 拿到的 ViewModel 是之前的 instance,不像其他 View 要重新 instantiate 过,用了它就好像解决到大部分 Android 开发麻烦的问题。但有没有考虑到如果装置记忆体有限时要 kill app 然後用户从 recent screen 开启之前用过的 app 又会怎样?很多人都忽略了这个环节,可能现在的装置比以前多很多 RAM,少了用户明显为意到的 kill app 情况。但有时在一些旧装置仍有可能发生。例如你的 app 用 activity result 打开了预设的相机 app 拍照,拍完就返回你的 app。但有可能在返回你的 app 那时整个 app 已经被系统杀掉而重新启动,但因为你没有特别处理这个情况导致拍照後的流程中断了。

要处理这个问题,以往都是建议大家用 Activity/FragmentonSaveInstanceState callback 来储存目前的 state 然後从 onCreate 或者 onRestoreInstanceState/onViewStateRestored callback 取回系统 kill app 前的 state。但现在有了 ViewModel 都会把 state 放入去而不是放在 Activity/Fragment,如果要把 ViewModel 的 state 交去 Activity/Fragment 路綫就会很迂回。有见及此就出了 SavedStateHandleSavedStateHandle 是从 ViewModel 的 constructor 取得,可以经它存取 key value 组合,就像 Bundle 一样 (savedStateHandle["xxx"])。但不同的是它除了取得 value 外,还可以取得 value 的 LiveData (savedStateHandle.getLiveData("xxx", "default value")),好让你把 state 直接放入去 SavedStateHandle。这做法有别於以往,因为 ViewModel 没有那些 onSaveInstanceStateonRestoreInstanceState callback。由於系统能随时 kill app,所以就要把 SavedStateHandle 视作储存当前 state 的地方,而不是待系统 kill app 前一刻才放资料进去。

SavedStateHandle 另一个用途是用来取得 Activity 的 intent extras 和 Fragment argument。获取方式跟之前的 savedStateHandle["xxx"] 一样("xxx" 是 intent extras/argument 的 key)。但我们已经用了 Navigation Component 的 Safe Args plugin,用 plugin 就是为了 Bundle 做到 type-safe,现在 SavedStateHandle 要走回头路要自已写 key 不觉得有点怪吗?但其实是可以自己写一个 delegate 将 SavedStateHandle 内储存的 key-value pair 变成 Safe Args plugin 生成的 argument class 的 object。

@MainThread
inline fun <reified Args : NavArgs> navArgs(savedStateHandle: SavedStateHandle) =
    NavArgsLazy(Args::class) {
        val pairs = savedStateHandle.keys()
            .map { Pair<String, Any?>(it, savedStateHandle[it]) }
            .toTypedArray()
        bundleOf(*pairs)
    }

用法就是在「ETA Screen (1)」贴出来的 code 找到,以下是节录:

private val args by navArgs<EtaFragmentArgs>(savedStateHandle)

// 直接用 argument 的值作为 StateFlow 的初始值
val line: StateFlow<Line> = MutableStateFlow(args.line)
val station: StateFlow<Station> = MutableStateFlow(args.station)

而我们有一个功能是让用户改变排序方式,这个设定我们会放在 SavedStateHandle 内:

// SavedStateHandle 放排序方式的 key
private const val SORT_BY = "sort_by"

// 把 SavedStateHandle 内的值以 LiveData 形式取出
// 但因为我们取得班次列表是用 Flow,所以要转为 Flow 并由 Int 转为 SortBy enum
private val sortedBy = savedStateHandle.getLiveData(SORT_BY, 0).asFlow()
        .map { GetEtaUseCase.SortBy.values()[it] }

// 用户按下 MaterialToolbar 内的排序选单项目会触发的 ViewModel method
fun toggleSorting() {
    val values = GetEtaUseCase.SortBy.values()
    val oldSortByOrdinal: Int = savedStateHandle.get<Int?>(SORT_BY) ?: 0
    savedStateHandle[SORT_BY] = (oldSortByOrdinal + 1) % values.size
}

LiveData.asFlow 这个 extension function 是由 AndroidX Lifecycle 提供,它还提供了 FlowLiveData 的 extension function。(那个五秒 timeout 就是由这里起源的)

各式错误状态

在前篇我们看过下面这张图,入面有好几个错误状态,我们先做那三个全页显示的错误状态。

https://ithelp.ithome.com.tw/upload/images/20211008/20139666vDgI2L4WIx.png

为方便分辨错误的状态,我们为 domain 的 EtaResult 内跟错误相关的 class 都帮它 implement 另一个 sealed interface EtaFailResult

sealed interface EtaFailResult

sealed interface EtaResult {
    data class Success(
        val schedule: List<Eta> = emptyList(),
    ) : EtaResult {
        // ...
    }

    object Delay : EtaResult, EtaFailResult

    data class Incident(
        val message: String = "",
        val url: String = "",
    ) : EtaResult, EtaFailResult

    object TooManyRequests : EtaResult, EtaFailResult

    object InternalServerError : EtaResult, EtaFailResult

    data class Error(val e: Throwable?) : EtaResult, EtaFailResult
}

另外,由於三款错误都是一段文字再加一个按钮,所以我们乾脆在 layout XML 共用这几个元素。

接下来就回到 EtaViewModel 的部分。XML layout 内的 NestedScrollView 包含了显示错误的 UI,我们已经用 data binding 跟 EtaViewModelshowError 绑定是否显示。以下是 showError 的内容:

val showError = etaResult
		.map { it is Loadable.Loaded && it.value is EtaFailResult }
		.stateIn(
		    scope = viewModelScope,
		    started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
		    initialValue = false,
		)

可以看到我们标注了 EtaFailResult 後整个写法变得简单了,毋须再把 EtaResult 的 class 逐一判断。

然後是 NestedScrollView 内的 TextViewButton data binding 用到的 Flow。首先是用来决定显示甚麽错误讯息的 errorResult

val errorResult = loadedEtaResult
    .filterIsInstance<EtaFailResult>()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
        initialValue = EtaResult.InternalServerError,
    )

至於实际显示甚麽文字的部分我们会交由 EtaPresenter 负责控制:

@ActivityScoped
class EtaPresenter @Inject constructor(@ActivityContext context: Context) {
    private val res = context.resources

    fun mapErrorMessage(result: EtaFailResult): String = when (result) {
        EtaResult.Delay -> res.getString(R.string.delay)
        is EtaResult.Error,
        EtaResult.InternalServerError,
        EtaResult.TooManyRequests,
        -> res.getString(R.string.error)
        is EtaResult.Incident -> result.message
    }
}

因为 data binding 写的 expression 是要用 Java、只可以单行再加上本身是 XML 档就有一堆字符要 escape 过才可以写到,所以遇上比较复杂的 expression 我都会另外找个地方写个 function 让 layout XML call,否则会比多层 Excel formula 包围的 expression 更难看(人家还会把开关括号配上不同颜色方便你看)。只要 function 的参数有 LiveData 或者 StateFlow data binding 都能自动更新(紧记要设定好 data binding 的 lifecycleOwner)。如果不喜欢开新 class 放这些东西可以把它放去 Activity 或者 Fragment 内,然後在 layout XML 加上那个 Activity 或者 Fragment<variable>。下面是这个 TextView 的 layout XML:

<com.google.android.material.textview.MaterialTextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@{etaPresenter.mapErrorMessage(viewModel.errorResult)}"
    android:textAlignment="center"
    android:textAppearance="?textAppearanceBody1"
    tools:text="@string/delay" />

然後来到文字下面的按钮,我们为了简化写法,所以分开「Try again」和「View detail」两个按钮。

<com.google.android.material.button.MaterialButton
    isVisible="@{viewModel.showViewDetail}"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="16dp"
    android:onClick="@{() -> viewModel.viewIncidentDetail()}"
    android:text="@string/incident_cta" />

<com.google.android.material.button.MaterialButton
    isVisible="@{viewModel.showTryAgain}"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="16dp"
    android:onClick="@{() -> viewModel.refresh()}"
    android:text="@string/try_again" />

下面是控制是否显示那两个按钮的 StateFlow

val showViewDetail = loadedEtaResult.map { it is EtaResult.Incident }.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
    initialValue = false,
)
val showTryAgain = loadedEtaResult.map {
    when (it) {
        EtaResult.Delay,
        is EtaResult.Incident,
        is EtaResult.Success -> false
        is EtaResult.Error,
        EtaResult.InternalServerError,
        EtaResult.TooManyRequests -> true
    }
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
    initialValue = false,
)

其实这些 StateFlow 的出现都是因为 data binding 不能写太复杂的 code,所以就把这些 logic 放在 ViewModel 内,然後 data binding 只需要接到 Boolean 值来控制 visibility 是 VISIBLE 还是 GONE

然後来到按下两个按钮的 click listener。「Try again」那个的做法非常简单,只需向 triggerRefresh 发射东西就能踢起 etaResult 整串东西:

fun refresh() {
    viewModelScope.launch {
        triggerRefresh.send(Unit)
    }
}

而「View detail」要做的东西是开启浏览器前往 API response 提供的网址。同样地,因为开启浏览器不应在 configuration change 後再次接收到上次的值,所以我们要用 Channel 来送知开启的网址。

private val _viewIncidentDetail = Channel<String>(Channel.BUFFERED)
val viewIncidentDetail: Flow<String> = _viewIncidentDetail.receiveAsFlow()

fun viewIncidentDetail() {
    val result = etaResult.value
    if (result !is Loadable.Loaded) return
    if (result.value !is EtaResult.Incident) return
    viewModelScope.launch {
        _viewIncidentDetail.send(result.value.url)
    }
}

EtaFragment 我们会 collect viewIncidentDetail

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.viewIncidentDetail.collect {
            try {
                requireActivity().startActivity(Intent(Intent.ACTION_VIEW).apply {
                    data = Uri.parse(it)
                })
            } catch (e: ActivityNotFoundException) {
                Toast.makeText(
                    requireContext(),
                    R.string.cannot_launch_browser,
                    Toast.LENGTH_SHORT
                ).show()
            }
        }
    }
}

留意不是所有 Android 装置都有内置浏览器,为谨慎起见我们要 catch ActivityNotFoundException,并显示 toast 提示用户不能开启浏览器。

小结

来到这里应该可以顺利地执行 app 并运用上篇介绍的 Whistle proxy server 造出不同的 response 来测试这页。这篇我们讨论了 SavedStateHandle 和避免在 layout XML 写复杂 binding expression 的方法。完整的 code 可以在 GitHub repo 找到,下一篇我们会把这页做成自动更新,不用先出去再进入班次页才能看到最新内容。


<<:  [Day_24]函式与递回_(3)

>>:  中阶魔法 - Callback Function

[Day 30] 永和美食纪录-向日葵早午餐 国中店

前言 今天文章的标题完完全全打脸了笔者在 Day27 的结语,没想到在最後一天仍然还是介绍了早午餐给...

[Day4] 预设范例帐户:OE

这篇文将介绍资料库中的预设帐户之一OE并介绍各个表格和他们之间的关系。 纲目:所有的资料库物件。OE...

[Python]Natural Language Toolkit

http://www.nltk.org/ NLTK 是一个主流用於自然语言处理的 Python 库 ...

Day 12 | 同步与非同步执行

当应用程序为了执行耗时任务而无法处里使用者操作时,就会产生ANR,解决方式就是用非同步处理。 执行绪...

【Day29】Git 版本控制 - GitBook 使用教学

首先,先前往官网,可以透过 GitHub 登入连结帐号。 登入以後,可以看到我们有一个 Spaces...