现在来到整个 app 最後一个功能:错误 banner。这个 banner 出现的目的是因为铁路隧道沿綫的电话上网讯号都接收得不太好(因为太多人同时在用),很容易出现错误。如果自动更新时有不能上网的错误会弹出全页错误画面的话效果就不太好。所以就设计了 banner 形式的显示错误方式。
现在先看看 EtaFragment
layout XML 的改动:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<!-- 略 -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- 略 -->
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
isVisible="@{viewModel.showEtaList}"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:divider="?android:listDivider"
android:orientation="vertical"
android:showDividers="middle"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- banner 部分 -->
<LinearLayout
isVisible="@{viewModel.showErrorBanner}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
tools:visibility="visible">
<com.google.android.material.textview.MaterialTextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:text="@{etaPresenter.mapErrorMessage(viewModel.errorResult)}"
android:textAlignment="viewStart"
android:textAppearance="?textAppearanceBody1"
android:textColor="@color/design_default_color_error"
tools:text="@string/error" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{() -> viewModel.refresh()}"
android:text="@string/try_again" />
</LinearLayout>
<!-- 原先的 RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
tools:listitem="@layout/eta_list_eta_item" />
</LinearLayout>
<!-- 原先的全页错误显示 -->
<androidx.core.widget.NestedScrollView
isVisible="@{viewModel.showFullScreenError}"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- 略 -->
</androidx.core.widget.NestedScrollView>
<!-- 略 -->
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
主要是多了一个 LinearLayout
来放 banner 及原有的 RecyclerView
。另外就是把 viewModel.showError
改名为更明显的 viewModel.showFullScreenError
。
EtaViewModel
由於 banner 显示的同时亦都要显示上一次成功载入的班次,所以原先 etaResult
所提供的 TimedValue<Loadable<EtaResult>>
并不能同时提供现在的错误和上一次成功载入的结果。我们的目标是要把这两样资讯都由 etaResult
一并提供,因为这样做的话比起分两个 StateFlow
存放这两样东西更易控制。
由於要用一个 StateFlow
表达两样东西,我们需要造一个专门的 data class CachedResult
表达它。虽然 Kotlin Standard Library 有 Pair
可以用,但过几个月再看这段 code 的话应该都忘了是甚麽意思。
private data class CachedResult(
val lastSuccessResult: EtaResult.Success? = null,
val lastFailResult: EtaFailResult? = null,
val currentResult: TimedValue<Loadable<EtaResult>> = TimedValue(
value = Loadable.Loading,
updatedAt = Instant.EPOCH
),
)
而 etaResult
的 type 亦都改为 StateFlow<CachedResult>
。
private val etaResult: StateFlow<CachedResult> = triggerRefresh
.consumeAsFlow()
.flatMapLatest {
flowOf(
flowOf(TimedValue(value = Loadable.Loading, updatedAt = clock.instant())),
combine(
language,
line,
station,
sortedBy,
) { language, line, station, sortedBy ->
TimedValue(
value = Loadable.Loaded(getEta(language, line, station, sortedBy)),
updatedAt = clock.instant(),
)
}.onEach {
// schedule the next refresh after loading
autoRefreshScope.launch {
delay(AUTO_REFRESH_INTERVAL)
triggerRefresh.send(Unit)
}
},
).flattenConcat()
}
.scan(CachedResult()) { acc, currentValue ->
if (currentValue.value is Loadable.Loaded) {
val currentResult = currentValue.value.value
CachedResult(
lastSuccessResult = if (currentResult is EtaResult.Success) currentResult else acc.lastSuccessResult,
lastFailResult = if (currentResult is EtaFailResult) currentResult else null,
currentResult = currentValue,
)
} else {
acc.copy(currentResult = currentValue)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
initialValue = CachedResult(),
)
前段的载入中和 call API 的部分是和上一篇没分别,分别在於之後多了 scan
和 stateIn
的 initialValue
转做 CachedResult()
。
scan
就是本篇的关键所在。如果翻查 KDoc 的话,会看到以下示例:
flowOf(1, 2, 3).scan(emptyList<Int>()) { acc, value -> acc + value }.toList()
will produce[], [1], [1, 2], [1, 2, 3]]
这个 operator 有另一个别名叫 runningFold
,看到 fold
就知道它是 reduce
的意思。reduce
就是把一连串的元素压成一个元素,Kotlin collection 的 fold
跟 reduce
差别就是 fold
可以提供 lambda 内 accumulator
的初始值,而 reduce
lambda 在第一次 call 的时候 accumulator
和 value
参数会提供 collection 的第一二项元素。回到 scan
的部分,从上面的例子看到它的初始值是 empty list,每次都把 acc
(上一次的结果)驳长,最终生成了一个包含所有元素的 list。我们可以借助这个 operator 把先前成功载入的结果和目前最新的结果整合成一个值交到下游。所以 scan
那个 lambda 如果现在是载入中的话那就用 data class 的 copy
保留 CachedResult
的 lastSuccessResult
和 lastFailResult
。但当载入完成後就把 CachedResult
的所有内容换走。
由於我们这次改了 etaResult
的 type,所以又会像上一篇一样把所有用到 etaResult
的地方修改一遍。但直接用 etaResult
会比较麻烦,故此引入了另一个中途 StateFlow
和 enum 来表达现在要显示的画面:
private enum class ScreenState {
LOADING,
ETA,
FULL_SCREEN_ERROR,
ETA_WITH_ERROR_BANNER,
}
private val screenState = etaResult.map {
when (it.currentResult.value) {
is Loadable.Loaded -> {
when (it.currentResult.value.value) {
EtaResult.Delay,
is EtaResult.Incident -> ScreenState.FULL_SCREEN_ERROR
is EtaResult.Error,
EtaResult.InternalServerError,
EtaResult.TooManyRequests -> if (it.lastSuccessResult != null) {
ScreenState.ETA_WITH_ERROR_BANNER
} else {
ScreenState.FULL_SCREEN_ERROR
}
is EtaResult.Success -> ScreenState.ETA
}
}
Loadable.Loading -> when (it.lastFailResult) {
EtaResult.Delay,
is EtaResult.Incident -> ScreenState.FULL_SCREEN_ERROR
is EtaResult.Error,
EtaResult.InternalServerError,
EtaResult.TooManyRequests -> if (it.lastSuccessResult != null) {
ScreenState.ETA_WITH_ERROR_BANNER
} else {
ScreenState.FULL_SCREEN_ERROR
}
null -> if (it.lastSuccessResult != null) {
ScreenState.ETA
} else {
ScreenState.LOADING
}
}
}
}
有了这个 screenState
判断何时要显示那种画面我们就容易修改其余的地方。
private val loadedEtaResult = etaResult
.map { it.currentResult.value }
.filterIsInstance<Loadable.Loaded<EtaResult>>()
.map { it.value }
val showLoading: StateFlow<Boolean> = screenState.map { it == ScreenState.LOADING }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
initialValue = true,
)
val showFullScreenError = screenState.map { it == ScreenState.FULL_SCREEN_ERROR }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
initialValue = false,
)
val showErrorBanner = screenState.map { it == ScreenState.ETA_WITH_ERROR_BANNER }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
initialValue = false,
)
val showEtaList = screenState
.map { it == ScreenState.ETA || it == ScreenState.ETA_WITH_ERROR_BANNER }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
initialValue = false,
)
val etaList = etaResult
.map { it.lastSuccessResult?.schedule.orEmpty() }
.combine(sortedBy) { schedule, sortedBy ->
// 略
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
initialValue = emptyList(),
)
fun startAutoRefresh() {
autoRefreshScope.launch {
// 略
}
}
fun viewIncidentDetail() {
val result = etaResult.value.currentResult.value
if (result !is Loadable.Loaded) return
if (result.value !is EtaResult.Incident) return
// 略
}
这样我们就完成了显示错误 banner 功能了。本篇主要是示范了用 scan
operator 把当前和以前的值整合成一个 Flow
内,另外亦用 enum 表达目前整页的状态。其实我们不知不觉间已经将整页的状态由一个 StateFlow
表达出来(就是 etaResult
),只不过我们另外衍生一堆零碎 StateFlow
供 data binding 用。其实我们可以将 etaResult
和 screenState
两个 Flow
二合为一,届时 ScreenState
不会是 enum 而是 sealed interface,原先的 LOADING
、ETA
、FULL_SCREEN_ERROR
、ETA_WITH_ERROR_BANNER
就会变成一个个 object expression 和 data class。这样就做到了 MVI (Model-View-Intent) 的 state reducer 风味的东西,而且又有类似 unidirectional data flow 的机制。现在那些 showLoading
、showFullScreenError
、showEtaList
之类的 StateFlow
都是为了避免在 layout XML 写太复杂的逻辑(因为不方便做 unit testing 和它本身是 XML 所以某些字符要 escape)而造出来的。日後改用 Jetpack Compose 写 UI 的话相信可以减省到只外露 ScreenState
sealed interface 的 Flow
就足够了。
完整的 code 可以到 GitHub repo 查阅,下一篇我们会写 ViewModel
的 unit test case。
上一篇在创造新的资料库时,有提到PRIMARY KEY也就是主键限制!那麽此篇来继续介绍FOREIG...
有时候会碰到网站要放GIF动画,但GIF大小动辄几M起跳, 造成网页Loading慢、图片边缘锯齿,...
今天要补充一个前端的小储存空间,window.localstorage.这是什麽呢? localst...
目标:点击空白处,收起左边样式标签 方法:尝试点空白处,使input被点击,进而改变样式 <i...
介绍完前端就是要接着後端介绍阿! 今天挑Node.js Node.js入门 首先先进入到点选左边的长...