构建 Kotlin 扩展库

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 运行配置,如下所示:

79c2a2d2f9bbb388.png

Run 35a622f38049c660.png 按钮即可测试您的应用:

58b6a81af969abf0.png

该应用目前不会执行任何有趣的操作。您需要为该应用添加一些功能,它才能够显示数据。我们会在后续步骤中添加缺少的功能。

一种更轻松的权限检查方式

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 会针对 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)
}

如果您现在运行该应用,屏幕上将显示位置。

c040ceb7a6bfb27b.png

位置文字格式设置助手

位置文字的显示效果不太理想!它使用了默认的 Location.toString 方法,但该方法并不适合在界面中显示内容。

myktxlibrary 中打开 LocationUtils.kt 类。此文件包含 Location 类的扩展。完成 Location.format 扩展函数,使其返回经过格式设置的 String,然后修改 ActivityUtils.kt 中的 Activity.showLocation,以使用此扩展。

如果您遇到问题,可以参考 step-03 文件夹中的代码。最终结果应如下所示:

b8ef64975551f2a.png

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 库附带适用于 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

接下来我们将重点介绍 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 进行互操作,以及如何围绕使用数据流设计应用。