1. 简介
Android KTX 是一组针对常用 Android 框架 API、Android Jetpack 库等的扩展。我们构建这些扩展是为了通过利用扩展函数和属性、lambda 表达式、命名和默认参数以及协程等语言特性,使从 Kotlin 代码调用基于 Java 编程语言的 API 更简洁、更符合习惯。
什么是 KTX 库?
KTX 代表 Kotlin 扩展,它本身**不是** Kotlin 语言的特殊技术或语言特性。它只是我们为 Google 的 Kotlin 库采用的名称,这些库扩展了最初使用 Java 编程语言编写的 API 的功能。
Kotlin 扩展的好处是,任何人都可以为自己的 API 甚至为项目中使用的第三方库构建自己的库。
本代码实验室将引导您完成一些添加简单扩展的示例,这些扩展利用了 Kotlin 语言特性。我们还将了解如何将基于回调的 API 中的异步调用转换为挂起函数和 Flow(基于协程的异步流)。
您将构建的内容
在本代码实验室中,您将开发一个简单的应用程序,用于获取并显示用户的当前位置。您的应用程序将:
|
您将学到的知识
- 如何在现有类之上添加 Kotlin 扩展
- 如何将返回单个结果的异步调用转换为协程挂起函数
- 如何使用 Flow 从可以多次发出值的源获取数据
您需要的东西
- 最新版本的 Android Studio(推荐使用 3.6 或更高版本)
- Android 模拟器或通过 USB 连接的设备
- Android 开发和 Kotlin 语言的基础知识
- 对协程和挂起函数的基本了解
2. 设置
下载代码
单击以下链接下载本代码实验室的所有代码
…或者使用以下命令从命令行克隆 GitHub 存储库
$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git
本代码实验室的代码位于 ktx-library-codelab
目录中。
在项目目录中,您将找到几个 step-NN
文件夹,其中包含本代码实验室每个步骤的预期最终状态,供参考。
我们将所有的编码工作都在 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
定义了一个名为hasPermission
的扩展函数,其接收者类型为Activity
。- 它将权限作为
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")
}
在我们进入实现部分之前,让我们来解释一下这个函数签名
- 你已经从本codelab的前面部分了解了扩展函数和接收器类型:
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
函数本身添加一个suspend修饰符,否则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
构建器中。您可以将上游视为当前操作之前应用的操作。
那么上面代码片段中的问题是什么?虽然它不会导致应用程序崩溃,并且它会在活动销毁后正确清理(感谢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()
}
您现在可以运行您的应用程序并检查它如何对旋转、按下主页和返回按钮做出反应。检查logcat以查看应用程序在后台时是否正在打印新位置。如果实现正确,当您按下主页然后返回应用程序时,应用程序应该正确暂停和重新启动Flow收集。
7. 总结
您刚刚构建了您的第一个KTX库!
恭喜!您在这个代码实验室中取得的成就与通常在为现有的基于Java的API构建Kotlin扩展库时发生的情况非常相似。
回顾我们所做的
- 您添加了一个方便的函数,用于从
Activity
检查权限。 - 您在
Location
对象上提供了一个文本格式化扩展。 - 您公开了
Location
API的协程版本,用于使用Flow
获取最新的已知位置和定期位置更新。 - 如果需要,您可以进一步清理代码,添加一些测试,并将您的
location-ktx
库分发给团队中的其他开发人员,以便他们可以从中受益。
要构建用于分发的AAR文件,请运行:myktxlibrary:bundleReleaseAar
任务。
您可以对任何其他可以从Kotlin扩展中受益的API执行类似步骤。
使用 Flow 优化应用程序架构
我们之前提到过,像在这个代码实验室中一样从Activity
启动操作并不总是最好的方法。您可以遵循此代码实验室来学习如何从UI中的ViewModels
观察流,流如何与LiveData
互操作,以及如何围绕使用数据流设计您的应用程序。