我们这次会为班次页加上自动更新和顺带为下一篇实作错误 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
的话就是要留意 onPause
和 onResume
callback,在 onPause
时停止下次再 call API 的排程而在 onResume
重新 call API 一次。但如果用户很快速地做 onPause
和 onResume
的话那可能会导致 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,否则就多等十秒以内的时间来维持十秒间距。留意我们在 etaResult
的 combineTransform
和 startAutoRefresh
都是用 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,我们就看看效果如何:
发现不似预期!在第一次 call API 後隔了十秒会变了两个 API call,再隔十秒会变了四个 API call,再之後变十六个 API call……原来我们没有为意到 etaResult
的 combineTransform
是先 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
触发整个 combine
。flatMapLatest
就是把 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通知
Trello 作为专业的专案管理软件,在开源的世界中也会随之诞生一些类似操作的工具。今天要简介的 W...
USART介绍 USART全名为通用同步/非同步收发传输器(universal synchronou...
CEH (Certificated Ethical Hacke)上课中教了满多的工具 , 今天我们先...
算数运算符 + - * / % // 以上都是常用的算术运算符 举个例子 python = 5 c ...
前言 今天来学习SwiftUI 的按钮 — Button。 实作 宣告一个 text 按钮 打开一个...