Leftover topics

我们终於来到第廿九篇,我们这次讨论的题目都是之前讨论过的东西的延伸。因为篇幅和时间有限就只好把它们合并成一篇。

Two-way data binding

我们在示范 app 一直都是在用 one-way data binding,只要在 layout XML 加上 @{ ... } 就能用到 LiveDataStateFlow 的值,并且能在 LiveDataStateFlow 的值改动时自动更新 UI(要设定好 LifecycleOwner)。Two-way data binding 适合在一些用户输入的 UI 组件使用,例如 TextEditCheckBox 之类。写法会是这样:

<EditText
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@={viewModel.query}" />

query 可以是 MutableLiveDataMutableStateFlow。留意是要用 mutable 的,因为当用户改变 EditText 的值时会直接把最新的值写入去 MutableLiveDataMutableStateFlow。然後你可以在 ViewModel 把那个 MutableLiveDataMutableStateFlowmap 之类的 operator 转化成其他动作(例如当用户在输入文字後就 call API 搜寻内容)。

其实 data binding 的原理是那些 @{ ... }@={ ... } 语法会在 compile 时转成 binding 的 class,入面还是会 call 那些 view 的 getter、setter、listener,只是不用我们自己写而已。这样就可以少写一部分 code。

但 data binding 还是有 Android view 既有的问题。以 CheckBox 为例,你可以设定 OnCheckedChangeListener 来得知用户改变剔选状态。如果以 code 的形式改变 CheckBox 的状态可以用 setChecked 这个 method。但 call 完 setChecked 後之前设定好的 OnCheckedChangeListener 都会收到 callback,变相很难分辨究竟那个 onCheckedChanged callback 是由用户输入行为触发还是由 code call 了 setter 触发。在 StackOverflow 有人提议可以改用 OnClickListener 取代 OnCheckedChangeListener,这样就肯定 callback 是因为用户输入而触发。另一个做法是在 call setter 前先把 CheckBoxOnCheckedChangeListener 设定 null,当 call 完 setter 後就把 OnCheckedChangeListener 还原。第一个做法不合乎语义,第二个做法又太古怪。但我们用 two-way data binding 好像不用理这个问题?不是,只是基本的 view 例如 TextEditCheckBox data binding 本身已附送 binding adapter,它背後的实作已经有处理这个问题。而 two-way data binding 的文档都有特别提及这种 setter 和 listener 之间做成的无限循环问题。它的解决方法是在 binding adapter call setter 前检查当前 view 的值是不是和要设定的值一样,如果一样就不要再 call setter,以免触发 listener 导致 data binding 发觉 view 的值有改变之後把背後的 MutableLiveDataMutableStateFlow 值改成 view 的值,因而再 call 多次 view 的 setter。

RecyclerView 局部更新

之前示范了用 DiffUtil.ItemCallback 做自动计算新旧内容比对然後更新 RecyclerView 的 list item。但有时把 list item 的所有 view 都做一次 bind 的话可能会导致出现的效果。以 Instagram 的 news feed 为例,如果要更新 like 数的话,像我们之前的写法会在资料变动後把整个 list item 重新 bind 过,导致影片重新载入。但我们期望看到的是影片是继续播放不中断而 like 的数字转了。要做到这样的效果我们可以 override DiffUtil.ItemCallbackgetChangePayload。这个 method 会在 areItemsTheSame return true 并且 areContentsTheSame return false 时执行。getChangePayload 预设是 return null,你需要在 method 内找出两个 object 之间的差异,然後把结果 return。它其实没有限定 return type,你可以 return 一个 Set 内里有表示不同 property 的 enum 表示改动过的 property。亦有其他人用 Bundle 来放两个 object 之间的差异。

改完 DiffUtil.ItemCallback 後就要更改 ListAdapter。之前我们都是 override onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int)。但我们现在 override 了 getChangePayload 後就要在 adapter override onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList<Any>)

override fun onBindViewHolder(
    holder: RecyclerView.ViewHolder,
    position: Int,
    payloads: MutableList<Any>,
) {
    if (payloads.isEmpty()) {
        // 完全不同,用回普通的 onBindViewHolder 做 bind
        super.onBindViewHolder(holder, position, payloads)
        return
    }
    // payload 第一个元素会有 getChangePayload return 出来的 object
    val diff = (payloads.first() as? Set<DisplayContent>).orEmpty()
    val item = getItem(position) ?: return
    // 之前已经 bind 过,按照 diff 有的 property 去 call 对应的 view setter
}

做了局部更新後,我们应该要把 list item 由 data binding 转为 view binding。因为用 data binding 又再直接从 code call view setter 覆写 data binding 做的东西会很怪。

由 data binding 到 unidirectional data flow 再到 Compose

当习惯用 LiveDataStateFlow 後很自然地会把整页的状态以 LiveDataFlow 的形式放到 ViewModel 内。之後就是在 ViewModel 外露一些 method 回应用户输入。例如当用户按下按钮时会触发 ViewModel 的一个 method。然後这个 method 会做一些东西(例如 call 了 backend 并等待 response)并且改变被 data binding 观察的 LiveDataStateFlow 的值从而改变 UI。我们的示范 app 就是用这种作法,这样就做到 unidirectional data flow 的效果。UI 只需要按照最新的状态来显示就可以了。但问题是 Android 传统的 view 本身的设计就不是让人这样做的。如果是 TextView 你不断 call setText("hello") 从用户肉眼看起来就只是一个「hello」而已,没有闪动之类的事发生。但到了 EditText call setter 那时如果本身输入法有拼写检查的候选字的话 call 了 setText 就会清空输入法候选字。所以如果刻意令 EditText 的状态和 ViewModel 完全同步(callback 一收到改动就要 call EditText.setText 来统一两边状态)的话用户就感觉到有异样。再到极端例子 WebView,它并没有外露完整的 getter 和 setter 让你能完全掌控 WebView 内部状态。如果要保留和还原状态就只能用 onSaveInstanceStateonRestoreInstanceState,但那个 Bundle 不是让你去读而是让 WebView 自己之後读的。这种设计很容易会因为 ViewModel 的状态跟 view 的状态不完全同步而造成 bug。有些用 Android 传统 view 做 unidirectional data flow 的示范 project 没有特别做这类的示范(可能和我们的示范 app 一样没有用户输入的部分),让人误以为 unidirectional data flow 是容易做到,但到了实作时就发现问题多多。像以前那些 MVP 例子也是,很多都没有考虑到 configuration change 的问题,只要一旋转装置之前的状态就会消失。当然现在有 Architecture Component 和 SavedStateHandle 情况好了不少。但我想讲的是传统的 Android view 是很难完全做到所有状态都交到 ViewModel 保留和控制,除非转用 Compose 来写 UI。

Compose 就是声明式的 UI,我们不能像以前可以 call view 的 getter 取得它的状态。相反,大部分的 Compose UI 都是没有自己的状态,状态都是由外部(即是 ViewModel)传进来,自己保留的状态都是一些 ViewModel 不太重视的东西(例如动画相关的值)。

以前我们做下面这类的 Chip 界面很多时都是在 layout XML 开一个空白的 ViewGroup 然後用 code 建构每一个 Chip 再塞进去 ViewGroup。然後当这些 Chip 更新时就把整个 ViewGroup 的 child view 清除再重新建构一堆新的 Chip 再塞进去。

https://ithelp.ithome.com.tw/upload/images/20211014/20139666gs9AzeTNKk.png

图片撷自 Material Design 网站

这样做就不用花时间比对新旧 data 之间的差异而且不用处理那些 view 可以重用的问题。我们的示范 app 就是用了 RecyclerViewDiffUtil.ItemCallback 来做这些东西。但上面的例子大家应该都不会用 RecyclerView 来做。其实清空 ViewGroup 再做一堆新 view 有点像 Compose 的用法,但 Compose 能够自动帮我们做新旧对比,而且不限於 RecyclerView 这类 UI。还有是用 Compose 的话就不用做清空的动作,因为它的写法是 ViewModel 提供当前该页全部的状态,不单止是因应刚才用户按了某个 chip 而只提供那个 chip 的资料。在我们的示范 app 我们都是循这个方向去写,如果要转做 Compose 的话相信不会有大问题。

Instrumentation test

我们一直写的测试都是在电脑上执行,而不是在 Android 装置。我们亦用了 Robolectric 来补足 Android SDK 独家的 class。如果是要拿到 Android 上面执行(不论是实机还是模拟器)的测试是叫做 instrumentation test。一般可以再细分两类:UI test 和非 UI test。UI test 应该很容易明白,就是跟 UI 有关的,例如检查界面显示的内容、转页之类是不是合符预期。而非 UI 但又要放在 Android 执行的测试的例子有即场建立 in-memory SQLite database 检查 SQL statement、foreign key constraint 是否正确。这些东西如果靠 mock 是不能真正检查到这些东西,就算写到出来亦会像誊文般把实作的 code 在 test case 中抄一遍(因为太多东西要 mock)。

如果是 UI test 的话可以参考我之前参加「Android # Best practice Challenge for MVVM x RecyclerView」而做的 GitHub repo。UI test 基本上都是用 Espresso 来控制 UI 及检查 UI 的元素,它背後是用 Hamcrest 这套 assertion 框架,跟我们之前示范用的 Strikt 有点似。不过 Espresso 难在它报错时只会提供一大串 view hierarchy 给你看,但你又看不明白。所以最好还是写了一小点就执行来看看是不是没有问题,这样就容易除错。

如果已经用 Compose 的话就不是用 Espresso,要用 Compose 专用的测试 artifact。其实 Espresso 和 Compose 的 UI test 写法都是大同小异,基本上都是靠标记一些记号(例如 view ID)然後在 view tree 找到那些元素,之後检查它的 attribute 或者是做一些用户跟 app 的互动(例如按下按钮)。为 view 加上测试的记号是 UI test 常用手法,例如 ImageView 用载入图片 library 载入图片的话可以顺带帮它 setTag 标记图片网址,然後在 UI test 核对图片网址。就算是 Compose 都是叫人用 semantics property 来做 UI test。

Coroutine

我们在示范 app 已经用了 Coroutine 和 Flow。但其实我们一直都没有主动切换 thread。除了 Ktor client 那部分之外剩下的东西(包括那些 mapper)都是在 UI thread 执行。Coroutine 的运作方式就是在一条 thread 内跑到调用 suspending function 的指令时切换到不同的 Coroutine 来营造出几个 Coroutine 在同时执行的效果。我们之在做自动更新时用的是 Coroutine 提供的 delay 而不是 Java 的 Thread.sleep 是因为 Thread.sleep 真是会把执行 Coroutine 的 thread(即是 UI thread)卡死,但 delay 就是会切换执行另一个 Coroutine,直至时间到为止。亦因为 Coroutine 不会在 suspending function 内每一句 statement 之间帮我们检查现在 Coroutine 是不是已被取消,所以 Kotlin 的文档有提到如果是写循环语句的话最好在每次迭代时都检查一次 [isActive](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/is-active.html) 以确保我们在 Coroutine 取消後就停止执行。

Ktor client 和 OkHttp client 已经为我们处理了 thread 的事宜,否则我们一进去班次页就出现 NetworkOnMainThreadException。如果真的想把部分流程放在另一条 thread 执行的话,就要用到 coroutine dispatcher。这跟 RxJava 的 scheduler 和 Java 的 thread pool 有点似。常用的 dispatcher 有:

  • Dispatchers.Main 在 main thread 上行(视乎你用了甚麽 Coroutine 的 artifact,Android 的话是 UI thread)
  • Dispatchers.Default 按装置 CPU 核心数量来决定开多少条 thread 的 thread pool,但最少会有两条,适合用来执行偏向 CPU 运算的东西
  • Dispatchers.IO 都是 thread pool,但是专为 I/O 处理而设。如果它的 thread pool 不够 thread 用的话可以随时开新的 thread,用完後会销毁。其实背後都是跟 Dispatchers.Default 共用 thread pool,如果本身有东西在 Dispatchers.Default 执行中的话就不会把原先的东西切换 thread

Dispatcher 的用法是用 launchwithContext 包住想切换 thread 的部分:

suspend fun example() {
    println("这句在 call example() 的 dispatcher 执行")
    withContext(Dispatchers.Default) {
        println("这句在 Dispatchers.Default 执行")
    }
    println("现在回到原先的 dispatcher 执行")
}

// 如果整个 function 都想在 Dispatchers.Default 执行可以这样写
suspend fun example2() = withContext(Dispatchers.Default) {
    println("这句在 Dispatchers.Default 执行")
}

如果是 Flow 的话,亦可以用 flowOn operator 指明 dispatcher:

val myFlow: Flow<Int> = flowOf(1, 2, 3)
    .map { it * 2 }
    .onEach { println(it) }
    .flowOn(Dispatchers.Default) // 这句以上 (map, onEach) 是在 Dispatchers.Default 执行
    .filter { it % 2 == 0 } // 这句还是在 collect 那边所在的 context 执行

不过为了方使测试,我们会用 dependency injection 取得 dispatcher。以下是用来提供 CoroutineDispatcher 的 Dagger module:

@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class IoDispatcher()

@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class DefaultDispatcher

@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class MainDispatcher

@Module
@InstallIn(SingletonComponent::class)
object CoroutinesModule {
    @Provides
    @IoDispatcher
    fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

    @Provides
    @DefaultDispatcher
    fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default

    @Provides
    @MainDispatcher
    fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
}

以上的 code 可以在 GitHub repo 找到。

习惯上我们会在实际需要开 thread 的地方指明 dispatcher,例如 withContext(Dispatchers.IO) { ... } 包住读写档案的 code。因为在那个位置是最清楚自己需要用那个 dispatcher。如果把指明 dispatcher 的工作放到跟实际操作很远的位置(例如 Activity)的话调用的时候就要额外花时间检查那些 code 是不是要用其他 dispatcher 执行,在 Android Developers 的文档亦建议 suspending function 应写成能安全地在 main thread 上调用。

参考


<<:  【第三十天 - Flutter 结赛感想、期许、愿景】

>>:  魔法终曲 - 魔法学习纪录暨结赛感言

[C#] 如何使用 MOTP 搭配 OTP Authenticator App 产生一次性密码登入(附范例)

在传统的登入系统中总是使用帐号密码的方式验证身份,这种方式如果密码不小心被盗取的话,帐号资料就会有被...

Day26 测试写起乃 - test log

如果没有设定 test environment 的话 log 预设会存放在 log/test.log...

Day 20 Hero动画

前言 通过Hero,我们可以在两个路由之间做出流畅的转场动画,Hero会在Source、Overla...

DAY14 - 拓扑排序

今天要写拓扑排序~~ 一个有向无环图,必定存在一种(以上)的拓扑排序 定义: 将图中所有点展开成序列...

Day12-Webhook 实作(一)LINEBot Channel 申请、SDK 安装

大家好~ 接下来就用 LINEBot 当我们 Webhook 的实作练习吧! Channel 申请 ...