Station list screen (2)

上一篇我们完成了 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 会看到我们外露了 listlaunchEtaScreen 两个 Flow 好让 Fragment 接收。list 就是用来提交画面需要显示的车站列表;launchEtaScreen 就是通知 Fragment 开启抵站时间页。

而 implement StationListAdapter.Callback 要实作的 toggleExpandedonClickLineAndStation 就是放一些 code 令 listlaunchEtaScreen 两个 Flow 能因应用户的输入向 Fragment 发送最新的状态。

车站列表 flow

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)。我以前看过有人写了这些东西:

  1. StateFlow<MutableSet<Line>>
  2. 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 显示,所以我们需要把 lineAndStationsexpandedGroups 结合在一起(即是那句 combine 的意思)。只要两者其中一方有变动,那 combine 的 lambda 都会被执行。在 lambda 入面我们会收到两个参数:lineAndStationsexpandedGroups。两个参数虽然跟上面的 FlowMutableStateFlow 撞名,但 lambda 参数是两个 flow 当前最新的值,所以 data type 分是 Map<Line, Set<Station>Set<Line>,不要弄错。lambda 里面就是走遍 Map<Line, Set<Station> 每一个 Map.Entry,看看 Set<Line> 是否有这条路綫,有的话就把该路綫的车站都塞进去,做成展开的效果。flatMap 的作用就是让你逐一走进每个 Map.Entry,然後每次都 return 一个 ListflatMap 会将全部的 List 合并成一条 List 交予下游。而我们用了 sequence { ... }.toList() 是因为 buildList 现在仍是 experimental。在 sequence {} 中如果要提交 item 给 Sequence 的话会用到 yieldyieldAllyield 就是提交一个 item 而 yieldAll 就是提交多个 item。

combine 的下游驳住了 stateIn 就是要把 combine 生成的 Flow 转换成 StateFlow。转成 StateFlow 的原因是如果 Fragment 经历 configuration change 的话就会重新 collect list。如果用了普通的 Flowcombine 的一大段 code 就会再次执行,但用了 StateFlow 就不会,除非 lineAndStationsexpandedGroups 有改动。另外,如果 combine 计算出来的东西跟上一次的结果是一样的话,StateFlow 就不会再通知,下游有改动,这是 StateFlow 另一大特色。这和 LiveData 效果差不多,可以说是为了取代 LiveData 而设,所以 data binding 现在支援 Flow 都是支援 StateFlowstateIn 要有三样东西:

  1. scope 那个 StateFlow 值分享的范围,由於这个 StateFlow 是放在 ViewModel 内,那它的生死都是跟 ViewModel 一致,所以填了 viewModelScope
  2. 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) 五秒钟)
  3. 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 转换成 FlowFragment 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

Fragment layout XML

跟之前的差别就是多了 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 了 listlaunchEtaScreen。留意我们用了 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 会在 onStoponStart 之间暂停接收,从而避免在不适当的时机接触到 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 的定位:提供 FlowFragment 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 查阅。下一篇我们会开始做抵站时间页,届时会有更多 ViewModelFlow 的示范。


<<:  DAY20 本日尚未签到、时刻表按钮实现

>>:  [Day 27] Gitea - 你的Gitea慢了吗?卡卡的?

Day15 用python写UI-聊聊Spinbox

第15天~~~完成了一半的铁人赛,之後也要继续加油! 今天要讲的内容是Spinbox,後面有几个实例...

{CMoney战斗营} 的第十四周 # Web API

这周的主题是Web API,也就是透过HTTP通讯协定,来请求及获得回覆,也就是透过URL来传递後端...

第16天~ListView

Spinne-下拉式选单 跟ListView很像但是比较小 ListView单选+或选到很多样 ac...

Day 14 「不残而废」单元测试、Code Smell 与重构 - Data Class 篇

图片来源:Wikipedia 大家听过「帕拉林匹克运动会(帕奥)」吗?它是自 1960 ~ 70 ...

Day 13:杂凑表(hash table)

在通讯录或朋友列表里,我们可以搜寻一个名字,就找到电话或页面,只需要O(1)。如果想要实现这样的操作...