Android KTX 是常用 Android 框架 API、Android Jetpack 库等资源的一组扩展。我们之所以构建这些扩展,是为了利用扩展函数和属性、lambda、指定参数和默认参数以及协程等语言功能,以更简洁、更惯用的方式通过 Kotlin 代码调用基于 Java 编程语言的 API。
什么是 KTX 库?
KTX 代表 Kotlin 扩展,它本身并不是 Kotlin 语言的特殊技术或语言功能。KTX 只是我们为 Google 的一些 Kotlin 库采用的名称,这些库可扩展最初以 Java 编程语言开发的 API 的功能。
Kotlin 扩展的优点是:任何人都可以针对自己的 API 构建自己的库,甚至可以针对在项目中使用的第三方库构建自己的库。
本 Codelab 将通过一些示例介绍如何添加能够充分利用 Kotlin 语言功能的简单扩展,并将介绍如何将基于回调的 API 中的异步调用转换为挂起函数和流(一种基于协程的异步流)。
您将构建的内容
在本 Codelab 中,您将构建一个能够获取并显示用户当前位置的简单应用。您的应用将:
|
您将学习的内容
- 如何在现有类的基础上添加 Kotlin 扩展
- 如何将返回单个结果的异步调用转换为协程挂起函数
- 如何使用流从可以多次发出值的来源获取数据
需要满足的条件
- 使用较新版本的 Android Studio(建议使用 3.6 或更高版本)
- 使用 Android 模拟器或通过 USB 连接的设备
- 对 Android 开发和 Kotlin 语言有基本了解
- 对协程和挂起函数有基本了解
下载代码
点击下面的链接可下载本 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
运行配置,如下所示:
按 Run 按钮即可测试您的应用:
该应用目前不会执行任何有趣的操作。您需要为该应用添加一些功能,它才能够显示数据。我们会在后续步骤中添加缺少的功能。
一种更轻松的权限检查方式
该应用虽然能够运行,但只会显示错误,因为它无法获取当前位置。
原因是它缺少用于请求用户授予运行时位置信息获取权限的代码。
打开 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
方法,但该方法并不适合在界面中显示内容。
在 myktxlibrary
中打开 LocationUtils.kt
类。此文件包含 Location
类的扩展。完成 Location.format
扩展函数,使其返回经过格式设置的 String
,然后修改 ActivityUtils.kt
中的 Activity.showLocation
,以使用此扩展。
如果您遇到问题,可以参考 step-03
文件夹中的代码。最终结果应如下所示:
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
是一种异步调用,完成时的结果可能是成功,也可能是失败。对于每种结果,我们都需要注册一个回调函数,以便将位置发送到界面或显示错误消息。
鉴于目前的回调较少,此代码现在看上去可能不太复杂,但在实际项目中,您可能需要处理位置信息,将其保存到数据库或者上传到服务器。许多此类操作也是异步执行的,并且在回调的基础上添加回调很快会使我们的代码失去可读性,并且可能会如下所示:
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
完成时,监听器一律不会被移除。
我们将寻找一种更好的利用协程解决此问题的方式。通过协程,您可以编写异步代码,这些代码看起来就像是常规、自上而下的命令式代码块,但不会在调用线程中执行任何阻塞调用。除此之外,协程也是可取消的,这样一来,每当它们超出作用域时,我们都可以将其清理掉。
在下一步中,我们将添加一个扩展函数,用于将现有的 callback API 转换为可从与您的界面关联的协程作用域调用的挂起函数。我们希望最终结果如下所示:
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
}
}
使用 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
函数本身添加挂起修饰符,否则会收到来自 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
。
接下来我们将重点介绍 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
再次打开 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
。某些 Flow
构建器和运算符会在内部使用 Channels
,此处不再详细介绍。除非您要编写自己的构建器/运算符,否则无需关注这些低层级详情。
我们将直接使用 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
来显示位置!
收集流
我们来修改 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 启动该函数。
您还会注意到,我们针对 Flow
调用了 conflate
()
和 catch
()
中间运算符。协程库附带很多运算符,以便您以声明式的方式过滤和转换流。
将流合并意味着:当发出更新的速度快于收集器可以处理更新的速度时,我们只希望接收最新更新。该函数非常适合我们的示例,因为我们只希望在界面中显示最新位置。
顾名思义,catch
可让您处理上游抛出的所有异常,在本示例中,是在 locationFlow
构建器中进行处理。您可以将“上游”想象成在当前操作之前采取的操作。
那么,上面的代码段存在什么问题?虽然它不会导致应用崩溃,并且会在 Activity 销毁后妥善进行清理(这要归功于 lifecycleScope
),但它并没有考虑 Activity 停止时(例如,当 Activity 不可见时)的情形。
这意味着,我们不仅会在非必要时更新界面,而且流会使位置数据订阅持续处于活跃状态,这会浪费电池电量和 CPU 周期!
解决此问题的一种方法是,使用 LiveData KTX 库中的 Flow.asLiveData
扩展将流转换为 LiveData。LiveData
知道何时观察以及何时暂停订阅,并会根据需要重启底层流。
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
将提供必需的作用域,以便在其中运行流。observe
调用实际上来自 LiveData,与协程或流毫无关系,这正是通过 LifecycleOwner 观察 LiveData 的标准方法。LiveData 将收集底层流并将位置发给观察器。
由于流的重新创建和收集现在是自动处理的,因此我们应将 startUpdatingLocation()
方法从 Activity.onStart
(可以执行多次)移至 Activity.onCreate
:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
startUpdatingLocation()
}
现在,您可以运行自己的应用,检查它对旋转屏幕、按主屏幕按钮以及按返回按钮有何反应。检查 logcat,看看该应用在后台时是否会在屏幕上显示新的位置。如果实现正确,在您按主屏幕按钮后再返回该应用时,该应用应正确暂停并重启流收集。
您已构建自己的第一个 KTX 库!
恭喜!您在本 Codelab 中实现的情况非常类似于针对基于 Java 的现有 API 构建 Kotlin 扩展库时通常会出现的情况。
总结一下我们做了些什么:
- 您添加了一个用于通过
Activity
检查权限的便捷函数。 - 您针对
Location
对象提供了一个文字格式设置扩展。 - 您公开了
Location
API 的协程版本,该版本可用于获取最新的已知位置,并可利用Flow
获取定期位置信息更新。 - 如果需要,您可以进一步清理代码,添加一些测试,然后将您的
location-ktx
库分发给团队中的其他开发者,以便他们能从中受益。
如需构建 AAR 文件以进行分发,请运行 :myktxlibrary:bundleReleaseAar
任务。
对于可能会受益于 Kotlin 扩展的任何其他 API,您可以按照类似步骤操作。
借助流优化应用架构
我们之前提到过,按照本 Codelab 中的做法从 Activity
启动操作有时并不合适。您可以通过此 Codelab 来了解如何在界面中通过 ViewModels
观察流,流如何与 LiveData
进行互操作,以及如何围绕使用数据流设计应用。