上一篇我们完成了 StationListAdapter
,我们现在会继续车站列表的 UI 部分。
StationListViewModel
首先我们要写的 class 是 StationListViewModel
。首先来看看它的基本骨架:
@HiltViewModel
class StationListViewModel @Inject constructor(
getLinesAndStations: GetLinesAndStationsUseCase,
) : ViewModel(), StationListAdapter.Callback {
val list: StateFlow<List<StationListItem>> = TODO()
val launchEtaScreen: Flow<Pair<Line, Station>> = TODO()
override fun toggleExpanded(line: Line) {
TODO()
}
override fun onClickLineAndStation(line: Line, station: Station) {
TODO()
}
}
一开首就看到 Dagger Hilt 的 @HiltViewModel
annotation,它是用来标记 ViewModel
。如果你想用Dagger Hilt 为你的 ViewModel
做 constructor injection 的话,就要为 ViewModel
标注 @HiltViewModel
。加了它就不用再自己写 ViewModelProvider.Factory
,Dagger Hilt 会自动为我们打点好。如果你需要在 ViewModel
用到 Context
的话,可以在 constructor 加上 @ApplicationContext private val context: Context
,Dagger Hilt 就能为你提供 Application
Context
。换句话讲,用了 Dagger Hilt 就不需要再用 AndroidViewModel
。
Constructor 会看到我们之前写好的 GetLinesAndStationsUseCase
,因为我们会由那个 use case 取得车站列表然後交予 RecyclerView
显示。至於要 implement 上一篇的 StationListAdapter.Callback
是因为 ViewModel
的角色是负责接收用户的输入动作,经过处理後再以 observer pattern 通知 Fragment
改变 UI。而通知改变 UI 的形式我们会用 Kotlin Flow 而不是 LiveData
。这是因为现在 data binding 已经支援 StateFlow
而且 Flow 提供了不少现成的 operator 让我们可以直接使用,不用我们每次都要 override MediatorLiveData
。所以上面的 code 会看到我们外露了 list
和 launchEtaScreen
两个 Flow 好让 Fragment
接收。list
就是用来提交画面需要显示的车站列表;launchEtaScreen
就是通知 Fragment
开启抵站时间页。
而 implement StationListAdapter.Callback
要实作的 toggleExpanded
和 onClickLineAndStation
就是放一些 code 令 list
和 launchEtaScreen
两个 Flow 能因应用户的输入向 Fragment
发送最新的状态。
private val lineAndStations = flowOf(getLinesAndStations())
private val expandedGroups = MutableStateFlow<Set<Line>>(emptySet())
val list: StateFlow<List<StationListItem>> =
combine(lineAndStations, expandedGroups) { lineAndStations, expandedGroups ->
lineAndStations.flatMap { (line, stations) ->
sequence {
val isExpanded = expandedGroups.contains(line)
yield(
StationListItem.Group(
line = line,
isExpanded = isExpanded,
)
)
if (isExpanded) {
yieldAll(stations.map { StationListItem.Child(line = line, station = it) })
}
}.toList()
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
initialValue = emptyList(),
)
我们首先要从 GetLinesAndStationsUseCase
取得车站列表(以 Map<Line, Set<Station>
形式,key 是路綫而 value 是该路綫的车站)。由於 use case 的 invoke
只是 return Map
而不是 Flow
,所以我们要先把它转成 Flow
(lineAndStations
)。我们直接用 flowOf
就可以了,反正那个 Map
是写死的,不会突然改变。另外,因为 use case 是用 operator fun invoke()
写的,所以我们可以把 use case 的 variable 名後面加上括号就能执行那个 invoke
function,令整段 code 更为简洁。
由於我们的列表是有展合功能,所以要记录各路綫是否展开了车站列表。我们会用一个 Set
去记录那些路綫现在是展开状态 (expandedGroups
)。但由於我们最终是要以 StateFlow
的形式通知 Fragment
最新的 list item,所以这个 Set
需要放在 MutableStateFlow
内,这样只要 expandedGroups
有变更的话就能触发更新 list
。在 MutableStateFlow
constructor 我们交了这个 flow 的初始值 emptySet()
,意思是一开始时所有路綫都不会显示车站名。留意我们用 MutableStateFlow<Set<Line>>
,意思是要改变那个 Set
的内容就要透过 MutableStateFlow
的机制去更新,不能直接拿到 Set
的 reference 直接改(因为它是 immutable)。我以前看过有人写了这些东西:
StateFlow<MutableSet<Line>>
MutableStateFlow<MutableSet<Line>>
第 1 个就是拿到 MutableSet
的 reference 来改内容,但改完是不能向下游通知这个 MutableSet
改了;第 2 个是「进可攻退可守」,又可以私下拿 MutableSet
的 reference 来改内容,又可以经 MutableStateFlow
的机制向下游通知内容已被更改。千万不要为了节省每次改动内容都要 instantiate 新 object 而写成这样,这个写法会令人混淆,改了 Set
但下游又看不见,结果日後要花时间 debug,废时失事。另外我亦见过有人会把 type 定义成 nullable (MutableStateFlow<Set<Line>?>
),这个写法变相要处理 null 和 empty 两个情况。如果可以的话不如由 empty 表达没有东西的意思,不用再增加多个东西处理。而我们用 StateFlow
/MutableStateFlow
而不是单纯的 Flow
是因为我们想保存当前最新的值,普通的 Flow
就是发射了值之後就不会保存最新的值。
接着我们来看看 list
。它那一大段 code 就是按照当前那些路綫是展开了车站列表而生成对应的 list item 供 RecyclerView
显示,所以我们需要把 lineAndStations
和 expandedGroups
结合在一起(即是那句 combine
的意思)。只要两者其中一方有变动,那 combine
的 lambda 都会被执行。在 lambda 入面我们会收到两个参数:lineAndStations
和 expandedGroups
。两个参数虽然跟上面的 Flow
和 MutableStateFlow
撞名,但 lambda 参数是两个 flow 当前最新的值,所以 data type 分是 Map<Line, Set<Station>
和 Set<Line>
,不要弄错。lambda 里面就是走遍 Map<Line, Set<Station>
每一个 Map.Entry
,看看 Set<Line>
是否有这条路綫,有的话就把该路綫的车站都塞进去,做成展开的效果。flatMap
的作用就是让你逐一走进每个 Map.Entry
,然後每次都 return 一个 List
,flatMap
会将全部的 List
合并成一条 List
交予下游。而我们用了 sequence { ... }.toList()
是因为 buildList
现在仍是 experimental。在 sequence {}
中如果要提交 item 给 Sequence
的话会用到 yield
或 yieldAll
,yield
就是提交一个 item 而 yieldAll
就是提交多个 item。
combine
的下游驳住了 stateIn
就是要把 combine
生成的 Flow
转换成 StateFlow
。转成 StateFlow
的原因是如果 Fragment
经历 configuration change 的话就会重新 collect list
。如果用了普通的 Flow
那 combine
的一大段 code 就会再次执行,但用了 StateFlow
就不会,除非 lineAndStations
和 expandedGroups
有改动。另外,如果 combine
计算出来的东西跟上一次的结果是一样的话,StateFlow
就不会再通知,下游有改动,这是 StateFlow
另一大特色。这和 LiveData
效果差不多,可以说是为了取代 LiveData
而设,所以 data binding 现在支援 Flow 都是支援 StateFlow
。stateIn
要有三样东西:
scope
那个 StateFlow
值分享的范围,由於这个 StateFlow
是放在 ViewModel
内,那它的生死都是跟 ViewModel
一致,所以填了 viewModelScope
started
我们填了 SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS)
,意思是如果一直有人 subscribe (collect) 住这个 StateFlow
的话,那 StateFlow
的值就能一直被共用,但当最後一个 subscriber 退订的话,我们会多等 STATE_FLOW_STOP_TIMEOUT_MILLIS
的时间後就把 StateFlow
的值清除掉(那个 STATE_FLOW_STOP_TIMEOUT_MILLIS
的值其实是 Duration.seconds(5)
五秒钟)initialValue
初始值,由於这是一个 List
那我们就用 emptyList()
比较合适那个 SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS)
五秒钟是 Android Developers 在 Medium 文章内建议的数值。它的意思是五秒钟应该有足够时间在 configuration change 後重新 subscribe 那个 StateFlow
,这样就不用在每次 configuration change 後都要重新执行上游的 code 计算它的值。
按下後,我们要把路綫从 expandedGroups
拿走或者是加进去,从而触发重新计算 list
。留意我们用了 update
而不是用 value
来更新 MutableStateFlow
的值。这是因为我们需要建基於当前的值才能得知最新的值,用 update
就能保障 concurrency。在 update
lambda 最後 return 的值将会是 MutableStateFlow
最新的值。
override fun toggleExpanded(line: Line) {
viewModelScope.launch {
expandedGroups.update {
val newSet = it.toHashSet()
if (newSet.contains(line)) {
newSet.remove(line)
} else {
newSet.add(line)
}
newSet
}
}
}
按下後,我们要通知 Fragment
开启抵站时间页。这次我们用 Channel
来做背後发射 data 的原理,然後把 Channel
转换成 Flow
供 Fragment
subscribe。Channel
是用来在两个 coroutine 之间传送资料,跟 BlockingQueue
差不多,我们借用它来表示转页动作。这次用 Flow
而不是 StateFlow
是因为开启另一页和显示 toast 一样不需要有初始值,亦不需要在 configuration change 後获取之前的值(如果这样做就会在 configuration change 後开启另一页或显示 toast 多一次,这不是我们要的效果)。要发射资料到 Channel
要用到 send
这个 method,留意要在 coroutine scope 内执行。
private val _launchEtaScreen = Channel<Pair<Line, Station>>(Channel.BUFFERED)
val launchEtaScreen: Flow<Pair<Line, Station>> = _launchEtaScreen.receiveAsFlow()
override fun onClickLineAndStation(line: Line, station: Station) {
viewModelScope.launch {
_launchEtaScreen.send(line to station)
}
}
现在 StationListAdapter
已经完成了。接下来就转到 StationListFragment
。
跟之前的差别就是多了 RecyclerView
和由 data binding 改回用 view binding,因为这次用不着。但抵站时间页会用到 data binding,不用担心。
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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"
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.MaterialToolbar
android:id="@+id/topAppBar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="@string/app_name" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/station_list_station_item" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
StationListFragment
由於 logic 都是放在 ViewModel
,所以 Fragment
要写的东西不多,主要都是设定 view binding 和 subscribe ViewModel
外露的 Flow
。
@AndroidEntryPoint
class StationListFragment : Fragment() {
private val viewModel by viewModels<StationListViewModel>()
private var _binding: StationListFragmentBinding? = null
private val binding: StationListFragmentBinding get() = _binding!!
private var _adapter: StationListAdapter? = null
private val adapter: StationListAdapter get() = _adapter!!
@Inject
lateinit var presenter: LineStationPresenter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = StationListFragmentBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_adapter = StationListAdapter(
lifecycleOwner = viewLifecycleOwner,
callback = viewModel,
presenter = this.presenter,
)
with(binding.recyclerView) {
layoutManager = LinearLayoutManager(requireContext())
adapter = [email protected]
}
observeViewModel()
}
private fun observeViewModel() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.list.collect {
adapter.submitList(it)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.launchEtaScreen.collect { (line, station) ->
findNavController().safeNavigate(
StationListFragmentDirections.actionStationListFragmentToEtaFragment(
line,
station
)
)
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
binding.recyclerView.adapter = null
_adapter = null
_binding = null
}
}
在 observeViewModel
,我们 observe 了 list
和 launchEtaScreen
。留意我们用了 viewLifecycleOwner.lifecycleScope.launch
又用了 viewLifecycleOwner.repeatOnLifecycle
包住那句 viewModel.someFlow.collect
:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.someFlow.collect { ... }
}
}
这个写法是按照 Android 的建议来写。因为包住 viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED)
的 coroutine 会在 onStop
和 onStart
之间暂停接收,从而避免在不适当的时机接触到 view。
list
的部分我们只需要 call ListAdapter.submitList
就可以了,它会计算那些 list item 需要更新。而 launchEtaScreen
就是 call findNavController().navigate()
跳去抵站时间页。由於我们用了 Save Args,所以用了 StationListFragmentDirections.actionStationListFragmentToEtaFragment
来保证 type safe 和没有遗漏 Fragment argument。但我们 code 用了 safeNavigate
而非 navigate
,原因是避免用户在按下转页按钮後画面尚未显示到下一页时用户再次按动转页按钮从而 app crash。因为 Navigation component 觉得 findNavController().navigate()
後就已经转到新一页,即使画面尚未完成转页。所以用户重按转页按钮时 Navigation component 就会发现当前页面并没有这个导航方式,因而报错。要避免这个情况我们可以参考 Nnabueze Uhiara 提供的 safeNavigate
:
fun NavController.safeNavigate(direction: NavDirections) {
currentDestination?.getAction(direction.actionId)?.run {
navigate(direction)
}
}
来到这里车站列表页已经完成了。本篇介绍了 ViewModel
的定位:提供 Flow
供 Fragment
subscribe 来更新 UI 和提供 method 供 Fragment
通知 ViewModel
用户做了甚麽动作,从而让 ViewModel
执行适当的动作回应,例如用户按下按钮後会 call use case 并将新的状态以 Flow
通知 Fragment
。另外,我们用 Channel
做出 SingleLiveEvent
的效果。最後还介绍了 Navigation component 在转页时的陷阱。如果想对 ViewModel
的定位有更深入的了解可以看看「Don't let ViewModel know about framework level dependencies」一文。
完整的 code 可以到 GitHub repo 查阅。下一篇我们会开始做抵站时间页,届时会有更多 ViewModel
和 Flow
的示范。
>>: [Day 27] Gitea - 你的Gitea慢了吗?卡卡的?
第15天~~~完成了一半的铁人赛,之後也要继续加油! 今天要讲的内容是Spinbox,後面有几个实例...
这周的主题是Web API,也就是透过HTTP通讯协定,来请求及获得回覆,也就是透过URL来传递後端...
Spinne-下拉式选单 跟ListView很像但是比较小 ListView单选+或选到很多样 ac...
图片来源:Wikipedia 大家听过「帕拉林匹克运动会(帕奥)」吗?它是自 1960 ~ 70 ...
在通讯录或朋友列表里,我们可以搜寻一个名字,就找到电话或页面,只需要O(1)。如果想要实现这样的操作...