我们终於来到第廿九篇,我们这次讨论的题目都是之前讨论过的东西的延伸。因为篇幅和时间有限就只好把它们合并成一篇。
我们在示范 app 一直都是在用 one-way data binding,只要在 layout XML 加上 @{ ... }
就能用到 LiveData
或 StateFlow
的值,并且能在 LiveData
或 StateFlow
的值改动时自动更新 UI(要设定好 LifecycleOwner
)。Two-way data binding 适合在一些用户输入的 UI 组件使用,例如 TextEdit
、CheckBox
之类。写法会是这样:
<EditText
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@={viewModel.query}" />
query
可以是 MutableLiveData
或 MutableStateFlow
。留意是要用 mutable 的,因为当用户改变 EditText
的值时会直接把最新的值写入去 MutableLiveData
或 MutableStateFlow
。然後你可以在 ViewModel
把那个 MutableLiveData
或 MutableStateFlow
用 map
之类的 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 前先把 CheckBox
的 OnCheckedChangeListener
设定 null
,当 call 完 setter 後就把 OnCheckedChangeListener
还原。第一个做法不合乎语义,第二个做法又太古怪。但我们用 two-way data binding 好像不用理这个问题?不是,只是基本的 view 例如 TextEdit
和 CheckBox
data binding 本身已附送 binding adapter,它背後的实作已经有处理这个问题。而 two-way data binding 的文档都有特别提及这种 setter 和 listener 之间做成的无限循环问题。它的解决方法是在 binding adapter call setter 前检查当前 view 的值是不是和要设定的值一样,如果一样就不要再 call setter,以免触发 listener 导致 data binding 发觉 view 的值有改变之後把背後的 MutableLiveData
或 MutableStateFlow
值改成 view 的值,因而再 call 多次 view 的 setter。
RecyclerView
局部更新之前示范了用 DiffUtil.ItemCallback
做自动计算新旧内容比对然後更新 RecyclerView
的 list item。但有时把 list item 的所有 view 都做一次 bind 的话可能会导致出现的效果。以 Instagram 的 news feed 为例,如果要更新 like 数的话,像我们之前的写法会在资料变动後把整个 list item 重新 bind 过,导致影片重新载入。但我们期望看到的是影片是继续播放不中断而 like 的数字转了。要做到这样的效果我们可以 override DiffUtil.ItemCallback
的 getChangePayload
。这个 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 做的东西会很怪。
当习惯用 LiveData
和 StateFlow
後很自然地会把整页的状态以 LiveData
和 Flow
的形式放到 ViewModel
内。之後就是在 ViewModel
外露一些 method 回应用户输入。例如当用户按下按钮时会触发 ViewModel
的一个 method。然後这个 method 会做一些东西(例如 call 了 backend 并等待 response)并且改变被 data binding 观察的 LiveData
和 StateFlow
的值从而改变 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
内部状态。如果要保留和还原状态就只能用 onSaveInstanceState
和 onRestoreInstanceState
,但那个 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
再塞进去。
图片撷自 Material Design 网站
这样做就不用花时间比对新旧 data 之间的差异而且不用处理那些 view 可以重用的问题。我们的示范 app 就是用了 RecyclerView
和 DiffUtil.ItemCallback
来做这些东西。但上面的例子大家应该都不会用 RecyclerView
来做。其实清空 ViewGroup
再做一堆新 view 有点像 Compose 的用法,但 Compose 能够自动帮我们做新旧对比,而且不限於 RecyclerView
这类 UI。还有是用 Compose 的话就不用做清空的动作,因为它的写法是 ViewModel
提供当前该页全部的状态,不单止是因应刚才用户按了某个 chip 而只提供那个 chip 的资料。在我们的示范 app 我们都是循这个方向去写,如果要转做 Compose 的话相信不会有大问题。
我们一直写的测试都是在电脑上执行,而不是在 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。
我们在示范 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
执行中的话就不会把原先的东西切换 threadDispatcher 的用法是用 launch
、withContext
包住想切换 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 结赛感想、期许、愿景】
在传统的登入系统中总是使用帐号密码的方式验证身份,这种方式如果密码不小心被盗取的话,帐号资料就会有被...
如果没有设定 test environment 的话 log 预设会存放在 log/test.log...
前言 通过Hero,我们可以在两个路由之间做出流畅的转场动画,Hero会在Source、Overla...
今天要写拓扑排序~~ 一个有向无环图,必定存在一种(以上)的拓扑排序 定义: 将图中所有点展开成序列...
大家好~ 接下来就用 LINEBot 当我们 Webhook 的实作练习吧! Channel 申请 ...