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
/Fragment
的 onSaveInstanceState
callback 来储存目前的 state 然後从 onCreate
或者 onRestoreInstanceState
/onViewStateRestored
callback 取回系统 kill app 前的 state。但现在有了 ViewModel
都会把 state 放入去而不是放在 Activity
/Fragment
,如果要把 ViewModel
的 state 交去 Activity
/Fragment
路綫就会很迂回。有见及此就出了 SavedStateHandle
。SavedStateHandle
是从 ViewModel
的 constructor 取得,可以经它存取 key value 组合,就像 Bundle
一样 (savedStateHandle["xxx"]
)。但不同的是它除了取得 value 外,还可以取得 value 的 LiveData
(savedStateHandle.getLiveData("xxx", "default value")
),好让你把 state 直接放入去 SavedStateHandle
。这做法有别於以往,因为 ViewModel
没有那些 onSaveInstanceState
、onRestoreInstanceState
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 提供,它还提供了 Flow
转 LiveData
的 extension function。(那个五秒 timeout 就是由这里起源的)
在前篇我们看过下面这张图,入面有好几个错误状态,我们先做那三个全页显示的错误状态。
为方便分辨错误的状态,我们为 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 跟 EtaViewModel
的 showError
绑定是否显示。以下是 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
内的 TextView
和 Button
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 找到,下一篇我们会把这页做成自动更新,不用先出去再进入班次页才能看到最新内容。
前言 今天文章的标题完完全全打脸了笔者在 Day27 的结语,没想到在最後一天仍然还是介绍了早午餐给...
这篇文将介绍资料库中的预设帐户之一OE并介绍各个表格和他们之间的关系。 纲目:所有的资料库物件。OE...
http://www.nltk.org/ NLTK 是一个主流用於自然语言处理的 Python 库 ...
当应用程序为了执行耗时任务而无法处里使用者操作时,就会产生ANR,解决方式就是用非同步处理。 执行绪...
首先,先前往官网,可以透过 GitHub 登入连结帐号。 登入以後,可以看到我们有一个 Spaces...