ETA screen (4)

现在来到整个 app 最後一个功能:错误 banner。这个 banner 出现的目的是因为铁路隧道沿綫的电话上网讯号都接收得不太好(因为太多人同时在用),很容易出现错误。如果自动更新时有不能上网的错误会弹出全页错误画面的话效果就不太好。所以就设计了 banner 形式的显示错误方式。

Layout XML

现在先看看 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 的部分是和上一篇没分别,分别在於之後多了 scanstateIninitialValue 转做 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 的 foldreduce 差别就是 fold 可以提供 lambda 内 accumulator 的初始值,而 reduce lambda 在第一次 call 的时候 accumulatorvalue 参数会提供 collection 的第一二项元素。回到 scan 的部分,从上面的例子看到它的初始值是 empty list,每次都把 acc(上一次的结果)驳长,最终生成了一个包含所有元素的 list。我们可以借助这个 operator 把先前成功载入的结果和目前最新的结果整合成一个值交到下游。所以 scan 那个 lambda 如果现在是载入中的话那就用 data class 的 copy 保留 CachedResultlastSuccessResultlastFailResult。但当载入完成後就把 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 用。其实我们可以将 etaResultscreenState 两个 Flow 二合为一,届时 ScreenState 不会是 enum 而是 sealed interface,原先的 LOADINGETAFULL_SCREEN_ERRORETA_WITH_ERROR_BANNER 就会变成一个个 object expression 和 data class。这样就做到了 MVI (Model-View-Intent) 的 state reducer 风味的东西,而且又有类似 unidirectional data flow 的机制。现在那些 showLoadingshowFullScreenErrorshowEtaList 之类的 StateFlow 都是为了避免在 layout XML 写太复杂的逻辑(因为不方便做 unit testing 和它本身是 XML 所以某些字符要 escape)而造出来的。日後改用 Jetpack Compose 写 UI 的话相信可以减省到只外露 ScreenState sealed interface 的 Flow 就足够了。

完整的 code 可以到 GitHub repo 查阅,下一篇我们会写 ViewModel 的 unit test case。


<<:  Day 28 知识可不可以商品化?

>>:  中阶魔法 - 闭包 Closure (二)

Day 05 - 了解FOREIGN KEY 外键限制!

上一篇在创造新的资料库时,有提到PRIMARY KEY也就是主键限制!那麽此篇来继续介绍FOREIG...

#22-掰惹Gif!用Sprite雪碧图做动画! (CSS & Canvas)

有时候会碰到网站要放GIF动画,但GIF大小动辄几M起跳, 造成网页Loading慢、图片边缘锯齿,...

[Day29]用Canvas打造自己的游乐场-补充 localstorage

今天要补充一个前端的小储存空间,window.localstorage.这是什麽呢? localst...

JS [撞墙] document.querySelector("").checked

目标:点击空白处,收起左边样式标签 方法:尝试点空白处,使input被点击,进而改变样式 <i...

全端入门Day22_後端程序撰写之Node.js

介绍完前端就是要接着後端介绍阿! 今天挑Node.js Node.js入门 首先先进入到点选左边的长...