ETA Screen (3)

我们这次会为班次页加上自动更新和顺带为下一篇实作错误 banner 做准备。

我们这页除非显示不能连接到互联网这类错误外,都不会出现重新载入按钮,这是因为这页就应该自动更新。按照 API 的介绍,它是每十秒更新一次。我们先准备一个 constant 来表示这个数值:

import kotlin.time.Duration as KotlinDuration

private val AUTO_REFRESH_INTERVAL = KotlinDuration.seconds(10)

由於我们会用 Kotlin Coroutine 的 delay 来做延时的效果,它是用 Kotlin 的 Duration 作为参数,故此这里就用 Kotlin 的 Duration。而因为之前我们用了 Java 的 Instant 来表示抵站时间,在换算站时间「X 分钟」的数字时会用到 Java 的 Duration,为了避免混淆所以我们预先用 import alias 分开两个 Duration

纪录载入时间

我们做自动更新除了考虑用户一直停留在该页时能自动更新外,还要考虑 Android 的 lifecycle 问题。如果用户在班次页按 Home button 的话,我们应该暂停自动更新;而当用户由其他 app 切换到该页的时候就要回复自动更新。Fragment 的话就是要留意 onPauseonResume callback,在 onPause 时停止下次再 call API 的排程而在 onResume 重新 call API 一次。但如果用户很快速地做 onPauseonResume 的话那可能会导致 call API 太密。所以我们应该在每次收到 response 时都记录时间,然後在 onResume 检查上次的载入的时间来决定要马上 call API 还是要隔一会才 call API。这样的话就做一个 data class 记录那个时间:

private data class TimedValue<out T>(
    val value: T,
    val updatedAt: Instant,
)

自动更新

然後 etaResult 就变成这样:

private lateinit var _autoRefreshScope: CoroutineScope
private val autoRefreshScope: CoroutineScope
    get() {
        if (!::_autoRefreshScope.isInitialized || !_autoRefreshScope.isActive) {
            _autoRefreshScope =
                CoroutineScope(
                    viewModelScope.coroutineContext +
                            SupervisorJob(viewModelScope.coroutineContext.job) +
                            CoroutineName("auto-refresh")
                )
        }
        return _autoRefreshScope
    }

private val etaResult: StateFlow<TimedValue<Loadable<EtaResult>>> = combineTransform(
    language,
    line,
    station,
    sortedBy,
    triggerRefresh.receiveAsFlow(),
) { language, line, station, sortedBy, _ ->
    emit(TimedValue(value = Loadable.Loading, updatedAt = clock.instant()))
    emit(
        TimedValue(
            value = Loadable.Loaded(getEta(language, line, station, sortedBy)),
            updatedAt = clock.instant(),
        )
    )
}.onEach {
    autoRefreshScope.launch {
        delay(AUTO_REFRESH_INTERVAL)
        triggerRefresh.send(Unit)
    }
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
    initialValue = TimedValue(value = Loadable.Loading, updatedAt = clock.instant()),
)

onEach 是会在 combineTransform 做完後触发的,然後我们特意为了能取消之前排程好的 delay 就做了一个 CoroutineScope (autoRefreshScope)。因为将会有好几个地方都会有 delay,如果开 variable 储存每个 Job 会比较难搞,所以就开了一个专门的 CoroutineScope 来 launch 这些包含 delay 的 coroutine,如果要取消 CoroutineScope 的话都是跟 Job 一样 call cancel 就可以了。以下就是除了 onEach 以外有关 autoRefreshScope 的地方:

// 当用户改变排序方式时会令 etaResult 马上 call 多次 API,所以要把原有的排程清除
private val sortedBy = savedStateHandle.getLiveData(SORT_BY, 0).asFlow()
    .map { GetEtaUseCase.SortBy.values()[it] }
    .onEach { autoRefreshScope.cancel() }

// 当用户按下错误页的「Try again」按钮时
fun refresh() {
    autoRefreshScope.cancel()
    autoRefreshScope.launch {
        triggerRefresh.send(Unit)
    }
}

// Fragment.onResume 时
fun startAutoRefresh() {
    autoRefreshScope.launch {
        val delayDuration =
            JavaDuration.between(etaResult.value.currentResult.updatedAt, clock.instant())
        if (delayDuration >= AUTO_REFRESH_INTERVAL.toJavaDuration()) {
            triggerRefresh.send(Unit)
        } else {
            // schedule the next refresh base on the previous loaded time
            delay(delayDuration.toKotlinDuration())
            triggerRefresh.send(Unit)
        }
    }
}

// Fragment.onPause 时
fun stopAutoRefresh() {
    autoRefreshScope.cancel()
}

startAutoRefresh 那个 if 就是看看上次 API response 的时间是不是超过了十秒,如果是就马上 call API,否则就多等十秒以内的时间来维持十秒间距。留意我们在 etaResultcombineTransformstartAutoRefresh 都是用 Clock 来取得当前时间。那个 Clock 是经 dependency injection 取得,以便之後可以做 unit testing。以下是对应的 Dagger module:

@Module
@InstallIn(SingletonComponent::class)
object CommonModule {
    @Provides
    fun provideClock(): Clock = Clock.systemDefaultZone()
}

另外,由於我们会在 onResume 触发更新排程,所以原先在 init block 触发更新的 code 都可以删走。同时因应我们包多了一层 TimedValue,所有跟 etaResult 有关的地方都要做对应的修改:

private val loadedEtaResult = etaResult
    .map { it.value }
    .filterIsInstance<Loadable.Loaded<EtaResult>>()
    .map { it.value }
val showLoading: StateFlow<Boolean> = etaResult
    .map { it.value == Loadable.Loading }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
        initialValue = true,
    )
val showError = etaResult
    .map { it.value is Loadable.Loaded && it.value.value is EtaFailResult }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
        initialValue = false,
    )
val showEtaList = etaResult
    .map { it.value is Loadable.Loaded && it.value.value is EtaResult.Success }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
        initialValue = false,
    )

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

那我们就试试效果如何。由於我们之前已经为 app 整合了 Flipper,我们就看看效果如何:

https://ithelp.ithome.com.tw/upload/images/20211009/20139666pxWf6D2WIz.png

发现不似预期!在第一次 call API 後隔了十秒会变了两个 API call,再隔十秒会变了四个 API call,再之後变十六个 API call……原来我们没有为意到 etaResultcombineTransform 是先 emit 载入中然後待 API response 来到时 emit 另一个值,所以两个值各自触发了 delay 就变了一开二的效果。这样的话我们要改改写法:

private val etaResult: StateFlow<TimedValue<Loadable<EtaResult>>> = 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()
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
        initialValue = TimedValue(value = Loadable.Loading, updatedAt = Instant.EPOCH),
    )

现在我们不把 triggerRefresh 放入 combineTransform/combine 内,改为以 triggerRefresh 触发整个 combineflatMapLatest 就是把 lambda 内的 Flow 转交去下游。flatMapLatest 内的 Flow 是把两个 Flow(载入中和 call API 加下次更新排程)接驳为一个 Flow(用 flattenConcat)。因为用了 flattenConcat,所以载入中那个值会比 API response 那个值来得早,正正就是我们想要的效果。

小结

现在我们已经做了定时自动更新的功能了,亦都用了 custom scope 来停止之前的 delay,感觉有点像 RxJava 的 CompositeDisposable。另外亦经 Dagger inject Java Time 的 Clock 获取当前时间而不是用 System.currentTimeMillis 或者 Calendar.getInstance() 获取,这个做法是为了方便写 unit test。至於实际如何写 unit test 我们会待其余功能完成後示范。下一篇会完成当成功载入班次後更新出现错误时会显示的 banner,这次的 code 可以到 GitHub repo 找寻「Auto refresh」commit 就会找到。


<<:  Day 24 - Input filtering 相关攻击

>>:  Day25:25 - 优化 - 後端 - 订单Email通知

Day 25 似 Trello 的开源看板管理工具 - Wekan

Trello 作为专业的专案管理软件,在开源的世界中也会随之诞生一些类似操作的工具。今天要简介的 W...

6. STM32-NVIC USART

USART介绍 USART全名为通用同步/非同步收发传输器(universal synchronou...

Day1:第一天来点简单的先安装Parrot_Security

CEH (Certificated Ethical Hacke)上课中教了满多的工具 , 今天我们先...

运算与表达

算数运算符 + - * / % // 以上都是常用的算术运算符 举个例子 python = 5 c ...

Day19:SwiftUI—Button

前言 今天来学习SwiftUI 的按钮 — Button。 实作 宣告一个 text 按钮 打开一个...