构建 Kotlin 扩展库

1. 简介

Android KTX 是一组用于常用 Android 框架 API、Android Jetpack 库等的扩展。我们构建这些扩展是为了利用 Kotlin 语言特性(如扩展函数和属性、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 Emulator 或通过 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 运行配置,如下所示

79c2a2d2f9bbb388.png

按下 Run 35a622f38049c660.png 按钮测试您的应用

58b6a81af969abf0.png

这个应用目前还没有任何有趣的功能。它还缺少一些能够显示数据的部分。我们将在后续步骤中添加缺失的功能。

3. 扩展函数简介

一种更简单的检查权限的方法

58b6a81af969abf0.png

即使应用可以运行,它也只是显示一个错误 - 无法获取当前位置。

这是因为它缺少向用户请求运行时位置权限的代码。

打开 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 指代该实例(this 也可以是隐式的,这意味着我们可以完全省略它)。

这确实是扩展函数的全部意义:在无法或不想以其他方式更改的类之上添加新功能。

让我们看看如何在 MainActivity.kt 中调用它。打开它并将权限代码更改为

if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
   requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
}

现在运行应用,您可以看到屏幕上显示的位置。

c040ceb7a6bfb27b.png

一个用于格式化位置文本的帮助函数

不过,位置文本看起来不太好!它使用了默认的 Location.toString 方法,这个方法并不是为了在 UI 中显示而设计的。

打开 myktxlibrary 模块中的 LocationUtils.kt 类。该文件包含针对 Location 类的扩展。完成 Location.format 扩展函数以返回格式化的 String,然后修改 ActivityUtils.kt 中的 Activity.showLocation 以使用该扩展。

如果遇到困难,可以查看 step-03 文件夹中的代码。最终结果应如下所示

b8ef64975551f2a.png

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 库为常见的生命周期对象(如 ActivityFragmentViewModel)提供了一些预定义的作用域。

将以下代码添加到 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 启动它。

您还可以注意到在 Flow 上调用的 conflate()catch() 中间操作符。协程库中提供了许多操作符,可让您以声明方式过滤和转换 flow。

合并(Conflating)一个 flow 意味着我们始终只希望接收最新的更新,无论更新发出的速度是否快于收集器的处理速度。这非常适合我们的例子,因为我们只希望在 UI 中显示最新位置。

catch,顾名思义,允许您处理上游抛出的任何异常,在本例中是在 locationFlow 构建器中。您可以将上游视为在当前操作之前应用的操作。

那么上面代码片段中的问题是什么呢?虽然它不会导致应用崩溃,并且在 activity DESTROYED 后会正确清理(得益于 lifecycleScope),但它没有考虑到 activity 停止时(例如不可见时)的情况。

这意味着我们不仅会在不必要的时候更新 UI,Flow 还会保持对位置数据的订阅处于活动状态,从而浪费电量和 CPU 周期!

解决此问题的一种方法是使用来自LiveData KTX 库的 Flow.asLiveData 扩展将 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 键和返回键。查看 logcat 以了解当应用处于后台时是否正在打印新的位置信息。如果实现正确,当您按下 Home 键然后返回应用时,应用应该会正确地暂停并重新启动 Flow 收集。

7. 总结

您刚刚构建了您的第一个 KTX 库!

恭喜!您在本 Codelab 中取得的成就与通常为现有 Java API 构建 Kotlin 扩展库时发生的情况非常相似。

回顾一下我们所做的工作

  • 您添加了一个方便函数用于从 Activity 中检查权限。
  • 您在 Location 对象上提供了一个文本格式化扩展。
  • 您公开了 Location API 的协程版本,用于获取最后已知位置和使用 Flow 进行定期位置更新。
  • 如果需要,您可以进一步清理代码,添加一些测试,并将您的 location-ktx 库分发给团队中的其他开发者,以便他们从中受益。

要构建用于分发的 AAR 文件,请运行 :myktxlibrary:bundleReleaseAar 任务。

对于任何其他可以受益于 Kotlin 扩展的 API,您可以遵循类似的步骤。

使用 Flows 优化应用架构

我们之前提到过,像我们在本 Codelab 中那样从 Activity 启动操作并非总是最佳做法。您可以按照本 Codelab 学习如何在 UI 中从 ViewModels 观察 flow,flow 如何与 LiveData 交互,以及如何围绕使用数据流设计您的应用。