构建 Kotlin 扩展库

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

79c2a2d2f9bbb388.png

运行 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 来引用该实例(也可以是隐式的,这意味着我们可以完全省略它)。

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

让我们看看如何在我们的 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的实现细节,例如存在一个支持创建的FlowChannel。无需详细说明,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互操作,以及如何围绕使用数据流设计您的应用程序。