1. 简介
Android KTX 是针对常用 Android 框架 API、Android Jetpack 库等的一组扩展。我们构建这些扩展是为了通过利用诸如扩展函数和属性、lambda 表达式、命名和默认参数以及协程等语言特性,使从 Kotlin 代码调用基于 Java 编程语言的 API 更简洁、更符合习惯。
什么是 KTX 库?
KTX 代表 Kotlin 扩展,它本身 **不是** Kotlin 语言的特殊技术或语言特性。它只是我们为 Google 的 Kotlin 库采用的一个名称,这些库扩展了最初用 Java 编程语言编写的 API 的功能。
Kotlin 扩展的优点是,任何人都可以为自己的 API 构建自己的库,甚至为项目中使用的第三方库构建库。
本 Codelab 将引导您完成一些添加简单扩展的示例,这些扩展利用了 Kotlin 语言特性。我们还将了解如何将回调式 API 中的异步调用转换为挂起函数和 Flow(基于协程的异步流)。
您将构建什么
在本 Codelab 中,您将使用一个简单的应用程序来获取并显示用户的当前位置。您的应用程序将
|
您将学到什么
- 如何在现有类之上添加 Kotlin 扩展。
- 如何将返回单个结果的异步调用转换为协程挂起函数。
- 如何使用 Flow 从可以多次发出值的源获取数据。
您需要什么
- 最新版本的 Android Studio(建议使用 3.6 或更高版本)。
- Android 模拟器或通过 USB 连接的设备。
- Android 开发和 Kotlin 语言的基础知识。
- 对协程和挂起函数的基本了解。
2. 设置
下载代码
单击以下链接下载本 Codelab 的所有代码。
... 或者使用以下命令从命令行克隆 GitHub 存储库。
$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git
本 Codelab 的代码位于 ktx-library-codelab
目录中。
在项目目录中,您会发现几个 step-NN
文件夹,其中包含本 Codelab 每一步的预期最终状态,以供参考。
我们将所有编码工作都在 work
目录中完成。
首次运行应用程序
在 Android Studio 中打开根文件夹(ktx-library-codelab
),然后在下拉列表中选择 work-app
运行配置,如下所示
按 **运行** 按钮测试您的应用程序。
此应用程序目前还没有什么有趣的功能。它缺少一些功能才能显示数据。我们将在后续步骤中添加这些功能。
3. 扩展函数简介
一种更简单的方法来检查权限
即使应用程序运行,它也只会显示错误 - 它无法获取当前位置。
这是因为它缺少从用户那里请求运行时位置权限的代码。
打开 MainActivity.kt
,找到以下注释掉的代码。
// val permissionApproved = ActivityCompat.checkSelfPermission(
// this,
// Manifest.permission.ACCESS_FINE_LOCATION
// ) == PackageManager.PERMISSION_GRANTED
// if (!permissionApproved) {
// requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
// }
如果您取消注释代码并运行应用程序,它将请求权限并继续显示位置。但是,这段代码难以阅读的原因有很多。
- 它使用
ActivityCompat
工具类中的静态方法checkSelfPermission
,该方法仅用于保存向后兼容的方法。 - 该方法始终将
Activity
实例作为第一个参数,因为无法在 Java 编程语言中的框架类中添加方法。 - 我们始终检查权限是否为
PERMISSION_GRANTED
,因此如果权限已授予,则返回布尔值true
,否则返回false
会更好。
我们希望将上面显示的冗长代码转换为以下更简短的代码。
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
// request permission
}
我们将借助 Activity 上的扩展函数来缩短代码。在项目中,您会发现另一个名为 myktxlibrary
的模块。从该模块中打开 ActivityUtils.kt
,然后添加以下函数。
fun Activity.hasPermission(permission: String): Boolean {
return ActivityCompat.checkSelfPermission(
this,
permission
) == PackageManager.PERMISSION_GRANTED
}
让我们解释一下这里发生了什么。
- 外部范围内的
fun
(不在class
内)表示我们在文件中定义了一个顶层函数。 Activity.hasPermission
在类型为Activity
的接收器上定义了一个名为hasPermission
的扩展函数。- 它将权限作为
String
参数,并返回一个Boolean
,指示权限是否已授予。
那么,“类型为 X 的接收器”究竟是什么呢?
在阅读 Kotlin 扩展函数的文档时,您会经常看到这一点。它意味着此函数始终会在 Activity
(在本例中)或其子类的实例上调用,并且在函数体中,我们可以使用关键字 this
来引用该实例(也可以是隐式的,这意味着我们可以完全省略它)。
这实际上是扩展函数的重点:在无法或不想更改的类之上添加新功能。
让我们看看如何在我们的 MainActivity.kt
中调用它。打开它并将权限代码更改为以下内容。
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
}
如果您现在运行应用程序,您将看到屏幕上显示的位置。
用于格式化位置文本的帮助程序
位置文本看起来不太好!它使用默认的 Location.toString
方法,该方法并非为在 UI 中显示而设计。
打开 myktxlibrary
中的 LocationUtils.kt
类。此文件包含 Location
类的扩展。完成 Location.format
扩展函数以返回格式化的 String
,然后修改 ActivityUtils.kt
中的 Activity.showLocation
以使用该扩展。
如果您遇到问题,可以查看 step-03
文件夹中的代码。最终结果应如下所示。
4. 位置 API 和异步调用
来自 Google Play 服务的融合位置提供程序
我们正在使用的应用程序项目使用来自 Google Play 服务的 融合位置提供程序 来获取位置数据。API 本身非常简单,但由于获取用户位置不是瞬时操作,因此对库的所有调用都需要异步,这使得我们的代码因回调而变得复杂。
获取用户位置分为两个步骤。在本步骤中,我们将重点介绍获取 最新的已知位置(如果可用)。在下一步中,我们将了解应用程序运行时的 定期位置更新。
获取最新的已知位置
在 Activity.onCreate
中,我们正在初始化 FusedLocationProviderClient
,它将成为我们进入库的入口点。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
}
在 Activity.onStart
中,我们然后调用 getLastKnownLocation()
,它目前看起来像这样。
private fun getLastKnownLocation() {
fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
showLocation(R.id.textView, lastLocation)
}.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to get location.")
e.printStackTrace()
}
}
如您所见,lastLocation
是一个异步调用,可以以成功或失败的方式完成。对于每种结果,我们都必须注册一个回调函数,该函数将把位置设置为 UI 或显示错误消息。
由于回调,这段代码看起来并不一定很复杂,但在实际项目中,您可能希望处理位置,将其保存到数据库或上传到服务器。这些操作中的许多也是异步的,并且在回调上添加回调会很快使我们的代码难以阅读,并且可能看起来像这样。
private fun getLastKnownLocation() {
fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
getLastLocationFromDB().addOnSuccessListener {
if (it != location) {
saveLocationToDb(location).addOnSuccessListener {
showLocation(R.id.textView, lastLocation)
}
}
}.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to read location from DB.")
e.printStackTrace()
}
}.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to get location.")
e.printStackTrace()
}
}
更糟糕的是,上面的代码存在内存泄漏和操作问题,因为在包含的 Activity
完成时,侦听器从未被移除。
我们将寻找一种更好的方法来解决这个问题,使用协程,它允许您编写看起来像普通、自上而下、命令式代码块的异步代码,而无需对调用线程进行任何阻塞调用。最重要的是,协程也是可取消的,允许我们在它们超出范围时进行清理。
在下一步中,我们将添加一个扩展函数,该函数将现有的回调 API 转换为可以从与您的 UI 绑定的协程范围调用的挂起函数。我们希望最终结果看起来像这样。
private fun getLastKnownLocation() {
try {
val lastLocation = fusedLocationClient.awaitLastLocation();
// process lastLocation here if needed
showLocation(R.id.textView, lastLocation)
} (e: Exception) {
// we can do regular exception handling here or let it throw outside the function
}
}
5. 将一次性异步请求转换为协程
使用 suspendCancellableCoroutine 创建挂起函数
打开 LocationUtils.kt
,并在 FusedLocationProviderClient
上定义一个新的扩展函数。
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
suspendCancellableCoroutine { continuation ->
TODO("Return results from the lastLocation call here")
}
在进入实现部分之前,让我们先拆解一下这个函数签名。
- 您已经从本代码实验室的前面部分了解了扩展函数和接收类型:
fun FusedLocationProviderClient.awaitLastLocation
suspend
表示这将是一个挂起函数,这是一种特殊的函数类型,只能在协程中或从另一个suspend
函数中调用。- 调用它的结果类型将为
Location
,就好像它是从 API 获取位置结果的同步方式一样。
为了构建结果,我们将使用 suspendCancellableCoroutine
,这是从协程库创建挂起函数的低级构建块。
suspendCancellableCoroutine
执行作为参数传递给它的代码块,然后在等待结果时挂起协程执行。
让我们尝试将成功和失败回调添加到我们的函数体中,就像我们在之前的 lastLocation
调用中看到的那样。不幸的是,正如您在下面的注释中看到的,我们想做的事情 - 返回结果 - 在回调体中是不可能的。
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
suspendCancellableCoroutine { continuation ->
lastLocation.addOnSuccessListener { location ->
// this is not allowed here:
// return location
}.addOnFailureListener { e ->
// this will not work as intended:
// throw e
}
}
这是因为回调发生在周围函数完成很久之后,而且没有地方返回结果。这就是 suspendCancellableCoroutine
使用提供的 continuation
进入我们代码块的地方。我们可以用它在将来某个时间使用 continuation.resume
将结果提供回挂起函数。使用 continuation.resumeWithException(e)
处理错误情况,以将异常正确地传播到调用站点。
通常,您应该始终确保在将来的某个时间点,您将返回结果或异常,而不是在等待结果时永远保持协程挂起。
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
suspendCancellableCoroutine<Location> { continuation ->
lastLocation.addOnSuccessListener { location ->
continuation.resume(location)
}.addOnFailureListener { e ->
continuation.resumeWithException(e)
}
}
就是这样!我们刚刚公开了一个最后一个已知位置 API 的挂起版本,该版本可以从我们应用程序中的协程中使用。
调用挂起函数
让我们修改 MainActivity
中的 getLastKnownLocation
函数以调用最后一个已知位置调用的新协程版本。
private suspend fun getLastKnownLocation() {
try {
val lastLocation = fusedLocationClient.awaitLastLocation()
showLocation(R.id.textView, lastLocation)
} catch (e: Exception) {
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
}
如前所述,挂起函数始终需要从其他挂起函数调用,以确保它们在协程中运行,这意味着我们必须将挂起修饰符添加到 getLastKnownLocation
函数本身,否则我们将从 IDE 获取错误。
请注意,我们可以对异常处理使用常规的 try-catch 块。我们可以将这段代码从失败回调中移出,因为来自 Location
API 的异常现在被正确地传播了,就像在常规的命令式程序中一样。
为了启动协程,我们通常使用 CoroutineScope.launch
,为此我们需要一个协程范围。幸运的是,Android KTX 库为常见的生命周期对象(如 Activity
、Fragment
和 ViewModel
)提供了几个预定义的范围。
将以下代码添加到 Activity.onStart
override fun onStart() {
super.onStart()
if (!hasPermission(ACCESS_FINE_LOCATION)) {
requestPermissions(arrayOf(ACCESS_FINE_LOCATION), 0)
}
lifecycleScope.launch {
try {
getLastKnownLocation()
} catch (e: Exception) {
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
}
startUpdatingLocation()
}
您应该能够运行您的应用程序并验证它是否有效,然后再继续下一步,在下一步中我们将介绍 Flow
用于多次发出位置结果的函数。
6. 构建用于流式传输数据的 Flow
现在我们将专注于 startUpdatingLocation()
函数。在当前代码中,我们向融合位置提供程序注册一个监听器,以便在用户设备在现实世界中移动时获取周期性位置更新。
为了展示我们希望使用基于 Flow
的 API 实现的目标,让我们首先看一下我们将在此部分中删除的 MainActivity
的部分,并将它们移动到我们新扩展函数的实现细节中。
在当前代码中,有一个变量用于跟踪我们是否已开始监听更新。
var listeningToUpdates = false
还存在一个基本回调类的子类和我们对位置更新回调函数的实现。
private val locationCallback: LocationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult?) {
if (locationResult != null) {
showLocation(R.id.textView, locationResult.lastLocation)
}
}
}
我们还具有监听器的初始注册(如果用户没有授予必要的权限,则可能会失败),以及回调,因为它是一个异步调用。
private fun startUpdatingLocation() {
fusedLocationClient.requestLocationUpdates(
createLocationRequest(),
locationCallback,
Looper.getMainLooper()
).addOnSuccessListener { listeningToUpdates = true }
.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to get location.")
e.printStackTrace()
}
}
最后,当屏幕不再处于活动状态时,我们将进行清理。
override fun onStop() {
super.onStop()
if (listeningToUpdates) {
stopUpdatingLocation()
}
}
private fun stopUpdatingLocation() {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
您可以继续从 MainActivity
中删除所有这些代码片段,只保留一个空的 startUpdatingLocation()
函数,我们将在以后使用它来开始收集我们的 Flow
。
callbackFlow:用于基于回调的 API 的 Flow 构建器
再次打开 LocationUtils.kt
,并在 FusedLocationProviderClient
上定义另一个扩展函数。
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
TODO("Register a location listener")
TODO("Emit updates on location changes")
TODO("Clean up listener when finished")
}
我们这里需要做几件事来复制我们刚刚从 MainActivity
代码中删除的功能。我们将使用 callbackFlow()
,这是一个返回 Flow
的构建器函数,该函数适合从基于回调的 API 发射数据。
传递给 callbackFlow()
的块使用 ProducerScope
作为其接收器定义。
noinline block: suspend ProducerScope<T>.() -> Unit
ProducerScope
封装了 callbackFlow
的实现细节,例如存在一个支持创建的 Flow
的 Channel
。无需详细说明,Channels
在内部由一些 Flow
构建器和运算符使用,除非您正在编写自己的构建器/运算符,否则您无需关注这些低级细节。
我们将只使用 ProducerScope
公开的几个函数来发射数据并管理 Flow
的状态。
让我们从为位置 API 创建一个监听器开始。
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
for (location in result.locations) {
offer(location) // emit location into the Flow using ProducerScope.offer
}
}
}
TODO("Register a location listener")
TODO("Clean up listener when finished")
}
我们将使用 ProducerScope.offer
将位置数据通过 Flow
发送出去。
接下来,将回调注册到 FusedLocationProviderClient
,注意处理任何错误。
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
for (location in result.locations) {
offer(location) // emit location into the Flow using ProducerScope.offer
}
}
}
requestLocationUpdates(
createLocationRequest(),
callback,
Looper.getMainLooper()
).addOnFailureListener { e ->
close(e) // in case of error, close the Flow
}
TODO("Clean up listener when finished")
}
FusedLocationProviderClient.requestLocationUpdates
是一个异步函数(就像 lastLocation
一样),它使用回调来指示何时成功完成以及何时失败。
在这里,我们可以忽略成功状态,因为它只是意味着在将来的某个时间点将调用 onLocationResult
,我们将开始将结果发射到 Flow
中。
在发生故障的情况下,我们将立即使用 Exception
关闭 Flow
。
您始终需要在传递给 callbackFlow
的块内调用的最后一件事是 awaitClose
。它提供了一个方便的位置,可以在 Flow
完成或取消的情况下放置任何清理代码以释放资源(无论它是否发生在 Exception
中)。
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
for (location in result.locations) {
offer(location) // emit location into the Flow using ProducerScope.offer
}
}
}
requestLocationUpdates(
createLocationRequest(),
callback,
Looper.getMainLooper()
).addOnFailureListener { e ->
close(e) // in case of exception, close the Flow
}
awaitClose {
removeLocationUpdates(callback) // clean up when Flow collection ends
}
}
现在我们有了所有部分(注册监听器、监听更新和清理),让我们回到 MainActivity
实际使用 Flow
来显示位置!
收集 Flow
让我们修改 MainActivity
中的 startUpdatingLocation
函数以调用 Flow
构建器并开始收集它。一个简单的实现可能看起来像这样。
private fun startUpdatingLocation() {
lifecycleScope.launch {
fusedLocationClient.locationFlow()
.conflate()
.catch { e ->
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
.collect { location ->
showLocation(R.id.textView, location)
Log.d(TAG, location.toString())
}
}
}
Flow.collect()
是一个终端操作符,它启动 Flow
的实际操作。在其中,我们将收到从我们的 callbackFlow
构建器发射的所有位置更新。因为 collect
是一个挂起函数,所以它必须在协程中运行,我们使用 lifecycleScope 启动协程。
您还可以注意到 conflate
()
和 catch
()
中间操作符,它们在 Flow
上调用。协程库附带许多运算符,这些运算符允许您以声明性方式过滤和转换流。
合并 流意味着我们只希望接收最新的更新,无论何时更新比收集器可以处理的速度快。它非常适合我们的示例,因为我们只希望在 UI 中显示最新的位置。
catch
(顾名思义)将允许您处理在上游抛出的任何异常,在本例中,是在 locationFlow
构建器中。您可以将上游视为应用于当前操作之前的操作。
那么上面的代码片段中有什么问题呢?虽然它不会使应用程序崩溃,并且会正确地在活动 DESTROYED 之后进行清理(感谢 lifecycleScope
),但它没有考虑活动何时停止(例如,当它不可见时)。
这意味着我们不仅会在不需要的时候更新 UI,而且 Flow 会让位置数据订阅保持活跃,浪费电池和 CPU 循环!
解决这个问题的一种方法是使用 Flow.asLiveData
扩展从 LiveData KTX 库中将 Flow 转换为 LiveData。 LiveData
知道何时观察以及何时暂停订阅,并将根据需要重新启动底层 Flow。
private fun startUpdatingLocation() {
fusedLocationClient.locationFlow()
.conflate()
.catch { e ->
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
.asLiveData()
.observe(this, Observer { location ->
showLocation(R.id.textView, location)
Log.d(TAG, location.toString())
})
}
不再需要显式的 lifecycleScope.launch
,因为 asLiveData
将提供必要的范围以在其中运行 Flow。 observe
调用实际上来自 LiveData,与协程或 Flow 无关,它只是使用 LifecycleOwner 观察 LiveData 的标准方法。 LiveData 将收集底层 Flow 并将位置发送到其观察者。
因为现在 Flow 的重新创建和收集将自动处理,所以我们应该将 startUpdatingLocation()
方法从 Activity.onStart
(可以执行多次)移动到 Activity.onCreate
。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
startUpdatingLocation()
}
您现在可以运行您的应用程序,并检查它如何对旋转、按 Home 和 Back 按钮做出反应。检查 logcat 以查看应用程序处于后台时是否正在打印新位置。如果实现正确,当您按下 Home 然后返回应用程序时,应用程序应该正确暂停并重新启动 Flow 收集。
7. 总结
您刚刚构建了第一个 KTX 库!
恭喜!您在这个 codelab 中所取得的成就与通常在为现有的基于 Java 的 API 构建 Kotlin 扩展库时会发生的情况非常相似。
回顾我们所做的事情
- 您添加了一个用于从
Activity
检查权限的便捷函数。 - 您在
Location
对象上提供了一个文本格式化扩展。 - 您公开了使用
Flow
获取最后已知位置和定期位置更新的Location
API 的协程版本。 - 如果愿意,您可以进一步清理代码,添加一些测试并将您的
location-ktx
库分发给团队中的其他开发人员,让他们从中受益。
要构建用于分发的 AAR 文件,请运行 :myktxlibrary:bundleReleaseAar
任务。
您可以对任何其他可能从 Kotlin 扩展中受益的 API 执行类似的步骤。
使用 Flow 完善应用程序架构
我们之前提到过,从 Activity
启动操作(就像我们在这个 codelab 中所做的那样)并不总是最好的方法。您可以遵循 这个 codelab 来学习如何从 ViewModels
中的 UI 观察流,流如何与 LiveData
交互,以及如何围绕使用数据流设计您的应用程序。