如何使用 Kotlin 构建安卓应用(三)
原文:
zh.annas-archive.org/md5/AFA545AAAFDFD0BBAD98F56388586295译者:飞龙
第五章:必要的库:Retrofit、Moshi 和 Glide
概述
在本章中,我们将介绍呈现来自远程服务器获取的动态内容所需的步骤。您将了解到检索和处理此动态数据所需的不同库。
在本章结束时,您将能够使用 Retrofit 从网络端点获取数据,使用 Moshi 将 JSON 有效负载解析为 Kotlin 数据对象,并使用 Glide 将图像加载到ImageViews中。
介绍
在上一章中,我们学习了如何在应用程序中实现导航。在本章中,我们将学习如何在用户在我们的应用程序中导航时向他们呈现动态内容。
向用户呈现的数据可以来自不同的来源。它可以硬编码到应用程序中,但这会带来一些限制。要更改硬编码数据,我们必须发布应用程序的更新。某些数据由于其性质而无法硬编码,例如货币汇率、资产的实时可用性和当前天气等。其他数据可能会过时,例如应用程序的使用条款。
在这种情况下,您通常会从服务器获取相关数据。用于提供此类数据的最常见架构之一是表现状态转移(REST)架构。REST 架构由一组六个约束定义:客户端-服务器架构、无状态性、可缓存性、分层系统、按需代码(可选)和统一接口。要了解有关 REST 的更多信息,请访问medium.com/extend/what-is-rest-a-simple-explanation-for-beginners-part-1-introduction-b4a072f8740f。
当应用于 Web 服务应用程序编程接口(API)时,我们得到了基于超文本传输协议(HTTP)的 RESTful API。HTTP 协议是互联网数据通信的基础,也被称为万维网。它是全球各地服务器用来向用户提供 HTML 文档、图像、样式表等的协议。有关此主题的有趣文章,可以在developer.mozilla.org/en-US/docs/Web/HTTP/Overview找到。
RESTful API 依赖于标准的 HTTP 方法——GET、POST、PUT、DELETE和PATCH——来获取和转换数据。这些方法允许我们在远程服务器上获取、存储、删除和更新数据实体。
要执行这些 HTTP 方法,我们可以依赖于内置的 Java HttpURLConnection类,或者使用诸如OkHttp之类的库,它提供了额外的功能,如 gzip 压缩、重定向、重试以及同步和异步调用。有趣的是,从 Android 4.4 开始,HttpURLConnection只是OkHttp的一个包装器。如果我们选择OkHttp,我们也可以选择Retrofit,正如我们将在本章中所做的那样,以从其类型安全中受益,这更适合处理 REST 调用。
最常用的数据表示形式是JavaScript 对象表示法(JSON)。JSON 是一种基于文本的数据传输格式。顾名思义,它源自 JavaScript。然而,它已经成为了最流行的数据传输标准之一,大多数现代编程语言都有编码或解码数据到 JSON 或从 JSON 的库。一个简单的 JSON 有效负载可能如下所示:
{"employees":[
{"name": "James", "email": "james.notmyemail@gmail.com"},
{"name": "Lea", "email": "lea.dontemailme@gmail.com"},
{"name": "Steve", "email": "steve.notreally@gmail.com"}
]}
RESTful 服务常用的另一种数据结构是可扩展标记语言(XML),它以一种人类和机器可读的格式对文档进行编码。XML 比 JSON 更冗长。以 XML 表示的与前述相同的数据结构可能如下所示:
<employees>
<employee>
<name>James</name>
<email>james.notmyemail@gmail.com</email>
</employee>
<employee>
<name>Lea</name>
<email>lea.dontemailme@gmail.com</email>
</employee>
<employee>
<name>Steve</name>
<email>steve.notreally@gmail.com</email>
</employee>
</employees>
在本章中,我们将专注于 JSON。
当获取 JSON 有效负载时,我们实质上是接收一个字符串。要将该字符串转换为数据对象,我们有一些选项,其中最流行的是org.json包等库。由于其轻量级特性,我们将专注于 Moshi。
最后,我们将研究如何从网络加载图像。这样做不仅可以让我们提供最新的图像,还可以为用户的设备加载正确的图像。这样做还可以让我们在需要时才加载图像,从而保持 APK 大小较小。
从网络端点获取数据
为了完成本节的目的,我们将使用 TheCatAPI(thecatapi.com/)。这个 RESTful API 为我们提供了大量关于猫的数据。
要开始,我们将创建一个新项目。然后我们必须授予我们的应用程序互联网访问权限。这是通过在您的AndroidManifest.xml文件中,在Application标签之前添加以下代码来完成的:
<uses-permission android:name="android.permission.INTERNET" />
接下来,我们需要设置我们的应用程序以包含 Retrofit。OkHttp HTTP 客户端。 Retrofit 帮助我们生成统一资源定位符(URL),这些是我们要访问的服务器端点的地址。它还通过与几个解析库的集成,使 JSON 有效负载的解码更容易。使用 Retrofit 发送数据到服务器也更容易,因为它有助于对请求进行编码。您可以在这里阅读更多关于 Retrofit 的信息:square.github.io/retrofit/。
要将 Retrofit 添加到我们的项目中,我们需要将以下代码添加到我们应用程序的build.gradle文件的dependencies块中:
implementation 'com.squareup.retrofit2:retrofit:(insert latest version)'
注意
您可以在这里找到最新版本:github.com/square/retrofit。
在我们的项目中包含了 Retrofit 后,我们可以继续设置它。
首先,要访问 HTTP(S)端点,我们首先要定义与该端点的合同。访问https://api.thecatapi.com/v1/images/search端点的合同如下:
interface TheCatApiService {
@GET("images/search")
fun searchImages(
@Query("limit") limit: Int,
@Query("size") format: String
): Call<String>
}
这里有几点需要注意。首先,您会注意到合同被实现为一个接口。这是您为 Retrofit 定义合同的方式。接下来,您会注意到接口的名称暗示着这个接口最终可以涵盖对 TheCatAPI 服务的所有调用。遗憾的是 Square 选择了Service作为这些合同的常规后缀,因为在 Android 世界中,服务一词有不同的含义,您将在第八章,服务、广播接收器和通知中看到。尽管如此,这是惯例。
要定义我们的端点,我们首先要声明使用适当注释进行调用的方法。在我们的情况下,是@GET。传递给注释的参数是要访问的端点的路径。您会注意到https://api.thecatapi.com/v1/从该路径中删除了。这是因为这是 TheCatAPI 所有端点的常用地址,因此将在构建时传递给我们的 Retrofit 实例。接下来,我们选择一个有意义的函数名字,比如在这种情况下,我们将调用图像搜索端点,所以searchImages似乎是合适的。searchImages函数的参数定义了我们在进行调用时可以传递给 API 的值。
有多种方式可以将数据传输到 API。@Query允许我们定义添加到请求 URL 查询的值(这是 URL 中问号后面的可选部分)。它接受一个键值对(在我们的例子中,我们有limit和size)和一个数据类型。如果数据类型不是字符串,那么该类型的值将被转换为字符串。传递的任何值都将被 URL 编码。
另一种方法是使用@Path。此注释可用于将路径中用大括号括起来的标记替换为提供的值。@Header、@Headers和@HeaderMap注释将允许我们向请求添加或删除 HTTP 标头。@Body可用于在POST/PUT请求的正文中传递内容。
最后,我们有一个返回类型。在这个阶段,为了保持简单,我们将接受响应作为字符串。我们将字符串包装在Call接口中。Call是 Retrofit 执行网络请求的机制,可以同步(通过execute())或异步(通过enqueue(Callback))执行。当使用 RxJava(ReactiveX 的 Java 实现,或者叫做 Reactive Extensions;您可以在https://reactivex.io/上了解更多关于 ReactiveX 的信息)时,我们可以适当地将结果包装在Observable类(发出数据的类)或Single类(一次发出数据的类)中(有关 RxJava 的更多信息,请参见第十三章,RxJava 和协程)。
定义了我们的合同,我们可以让 Retrofit 实现我们的服务接口:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.thecatapi.com/v1/")
.build()
val theCatApiService = retrofit.create(TheCatApiService::class.java)
如果我们尝试使用此代码运行应用程序,应用程序将崩溃并显示IllegalArgumentException。这是因为 Retrofit 需要我们告诉应用程序如何将服务器响应处理为字符串。这个处理是通过 Retrofit 调用的ConverterFactory实例来完成的,我们需要向我们的retrofit实例添加以下内容:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.thecatapi.com/v1/")
.addConverterFactory(ScalarsConverterFactory.create())
.build()
为了使我们的项目识别ScalarsConverterFactory,我们需要通过添加另一个依赖项来更新我们的应用程序的build.gradle文件:
implementation 'com.squareup.retrofit2:converter-scalars:(insert latest version)'
现在,我们可以通过调用val call = theCatApiService.searchImages(1, "full")来获得一个Call实例。通过以这种方式获得的实例,我们可以通过调用call.enqueue(Callback)来执行异步请求。
我们的Callback实现将有两个方法:onFailure(Call, Throwable)和onResponse(Call, Response)。请注意,如果调用了onResponse,我们不能保证会有一个成功的响应。只要我们成功地从服务器接收到任何响应并且没有发生意外异常,就会调用onResponse。因此,为了确认响应是成功的响应,我们应该检查response.isSuccessful属性。在网络错误或沿途某处发生意外异常的情况下,将调用onFailure函数。
那么我们应该在哪里实现 Retrofit 代码呢?在干净的架构中,数据由存储库提供。存储库又有数据源。这样的数据源可以是网络数据源。这就是我们将实现网络调用的地方。然后,我们的 ViewModel(在Model-View-ViewModel(MVVM)的情况下,ViewModel 是暴露属性和命令的视图的抽象)将通过用例从存储库请求数据。
对于我们的实现,我们将简化流程,通过在 Activity 中实例化 Retrofit 和服务来完成。这不是一个好的做法。在生产应用中不要这样做。它不具有良好的可扩展性,并且非常难以测试。相反,采用一种将视图与业务逻辑和数据解耦的架构。参见第十四章,架构模式,了解一些想法。
练习 5.01:从 API 读取数据
在接下来的章节中,我们将开发一个为一家全球特工机构的虚构应用程序,该机构拥有一个遍布全球的特工网络,拯救世界免受无数危险。所涉及的秘密机构非常独特:它运营秘密猫特工。在这个练习中,我们将创建一个应用程序,该应用程序将向我们展示来自 TheCatAPI 的一个随机秘密猫特工。在向用户呈现 API 数据之前,您首先必须获取该数据。让我们开始:
-
首先创建一个新的
Empty Activity项目(文件|新建|新项目|空活动)。然后点击下一步。 -
将应用程序命名为
Cat Agent Profile。 -
确保您的包名称为
com.example.catagentprofile。 -
将保存位置设置为您要保存项目的位置。
-
将其他所有内容保留为默认值,然后点击“完成”。
-
确保你在
Project窗格中处于Android视图下:
图 5.1:项目窗格中的 Android 视图
- 打开你的
AndroidManifest.xml文件。像这样为你的应用程序添加互联网权限:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.catagentprofile">
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
...
</application>
</manifest>
- 要向你的应用程序添加 Retrofit 和标量转换器,打开应用程序模块的
build.gradle(Gradle Scripts|build.gradle (Module: app)),并在dependencies块的任何位置添加以下行:
dependencies {
...
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
...
}
你的dependencies块现在应该看起来像这样:
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib
:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.navigation:navigation-fragment
-ktx:2.2.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.2.2'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core
:3.3.0'
}
在你进行这个练习的时间和实施之间,一些依赖关系可能已经发生了变化。你应该仍然只添加前面代码块中加粗的行。这些将添加 Retrofit 和支持读取服务器响应作为单个字符串的功能。
注意
值得注意的是,Retrofit 现在要求至少 Android API 21 或 Java 8。
-
在 Android Studio 中点击
Sync Project with Gradle Files按钮。 -
以
Text模式打开你的activity_main.xml文件。 -
为了能够使用标签来呈现最新的服务器响应,你需要为它分配一个 ID:
<TextView
android:id="@+id/main_server_response"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
-
在左侧的
Project窗格中,右键单击你的应用程序包(com.example.catagentprofile),然后选择New|Package。 -
将你的包命名为
api。 -
现在,在新创建的包(
com.example.catagentprofile.api)上右键单击,然后选择New|Kotlin File/Class。 -
给你的新文件命名为
TheCatApiService。对于Kind,选择Interface。 -
在
interface块中添加以下内容:
interface TheCatApiService {
@GET("images/search")
fun searchImages(
@Query("limit") limit: Int,
@Query("size") format: String
) : Call<String>
}
这定义了图像搜索的端点。确保导入所有必需的 Retrofit 依赖项。
-
打开你的
MainActivity文件。 -
在
MainActivity类块的顶部添加以下内容:
class MainActivity : AppCompatActivity() {
lazy to make sure the instances are only created when needed.
- 将
serverResponseView添加为一个字段:
class MainActivity : AppCompatActivity() {
main_server_response ID the first time serverRespnseView is accessed and then keep a reference to it.
- 现在,在
onCreate(Bundle?)函数之后添加getCatImageResponse()函数:
override fun onCreate(savedInstanceState: Bundle?) {
...
}
private fun getCatImageResponse() {
val call = theCatApiService.searchImages(1, "full")
call.enqueue(object : Callback<String> {
override fun onFailure(call: Call<String>, t: Throwable) {
Log.e("MainActivity", "Failed to get search results", t)
}
override fun onResponse(
call: Call<String>,
response: Response<String>
) {
if (response.isSuccessful) {
serverResponseView.text = response.body()
} else {
Log.e(
"MainActivity",
"Failed to get search results\n
${response.errorBody()?.string() ?: ""}"
)
}
}
})
}
这个函数将发出搜索请求并处理可能的结果——成功的响应、错误的响应和任何其他抛出的异常。
- 在
onCreate()中调用getCatImageResponse()。这将在活动创建时触发调用:
override fun onCreate(savedInstanceState: Bundle?) {
...
getCatImageResponse()
}
-
添加缺失的导入。
-
通过点击
Run 'app'按钮或按下Ctrl + R来运行你的应用程序。在模拟器上,它应该看起来像这样:
图 5.2:应用程序呈现服务器响应 JSON
因为每次运行应用程序都会进行一次新的调用并返回一个随机的响应,所以你的结果可能会有所不同。然而,无论你的结果如何,如果成功的话,它应该是一个 JSON 负载。接下来,我们将学习如何解析该 JSON 负载并从中提取我们想要的数据。
解析 JSON 响应
现在我们已经成功从 API 中检索到了 JSON 响应,是时候学习如何使用我们获取到的数据了。为了做到这一点,我们需要解析 JSON 负载。这是因为负载是一个表示数据对象的纯字符串,我们对该对象的特定属性感兴趣。如果你仔细看图 5.2,你可能会注意到 JSON 包含品种信息、图像 URL 和一些其他信息。然而,为了让我们的代码使用这些信息,首先我们需要提取它。
如介绍中所述,存在多个库可以解析 JSON 负载。最流行的是 Google 的 GSON(github.com/google/gson)和最近更受欢迎的是 Square 的 Moshi(github.com/square/moshi)。Moshi 非常轻量级,这就是为什么我们选择在本章中使用它的原因。
JSON 库的作用是什么?基本上,它们帮助我们将数据类转换为 JSON 字符串(序列化)以及反之(反序列化)。这帮助我们与理解 JSON 字符串的服务器进行通信,同时允许我们在代码中使用有意义的数据结构。
要在 Retrofit 中使用 Moshi,我们需要将 Moshi Retrofit 转换器添加到我们的项目中。这是通过将以下行添加到我们应用程序的build.gradle文件的dependencies块中来完成的:
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
由于我们将不再接受字符串作为响应,我们可以继续删除标量 Retrofit 转换器。
接下来,我们需要创建一个数据类,将服务器 JSON 响应映射到该类。一个惯例是给 API 响应数据类的名称添加后缀Data——所以我们将我们的数据类称为ImageResultData。另一个常见的后缀是Entity。
当我们设计服务器响应数据类时,我们需要考虑两个因素:JSON 响应的结构和我们的数据需求。第一个将影响我们的数据类型和字段名称,而第二个将允许我们省略我们当前不需要的字段。JSON 库知道它们应该忽略我们在数据类中未定义的字段中的数据。
JSON 库为我们做的另一件事是,如果它们恰好具有完全相同的名称,它们会自动将 JSON 数据映射到字段。虽然这是一个很好的功能,但也有问题。如果我们完全依赖它,我们的数据类(以及访问它们的代码)将与 API 命名紧密耦合。因为并非所有 API 都设计良好,您可能最终会得到毫无意义的字段名称,例如fn或last,或者不一致的命名。幸运的是,这个问题有解决办法。Moshi 为我们提供了一个@field:Json注解。它可以用来将 JSON 字段名称映射到有意义的字段名称:
data class UserData(
@field:Json(name = "fn") val firstName: String,
@field:Json(name = "last") val lastName: String
)
有人认为,出于一致性考虑,即使 API 名称与字段名称相同,也最好包括该注解。当字段名称足够清晰时,我们更喜欢直接转换的简洁性。当我们混淆我们的代码时,这种方法可能会受到挑战。如果我们这样做,我们要么排除我们的数据类,要么确保对所有字段进行注释。
虽然我们并不总是幸运地拥有适当记录的 API,但当我们拥有时,最好在设计我们的模型时咨询文档。我们的模型将是一个数据类,其中我们进行的所有调用的 JSON 数据将被解码。TheCatAPI 图像搜索端点的文档可以在docs.thecatapi.com/api-reference/images/images-search找到。您经常会发现文档是部分的或不准确的。如果是这种情况,您能做的最好的事情就是联系 API 的所有者,并要求他们更新文档。不幸的是,您可能不得不尝试使用端点。这是有风险的,因为未记录的字段或结构不能保证保持不变,所以在可能的情况下,尽量获取文档更新。
根据从上述链接获取的响应模式,我们可以定义我们的模型如下:
data class ImageResultData(
@field:Json(name = "url") val imageUrl: String,
val breeds: List<CatBreedData>
)
data class CatBreedData(
val name: String,
val temperament: String
)
请注意,响应结构实际上是结果列表。这意味着我们需要将我们的响应映射到List<ImageResultData>,而不仅仅是ImageResultData。
现在,我们需要更新TheCatApiService。现在我们可以有Call<List<ImageResultData>>,而不是Call<String>。
接下来,我们需要更新我们的 Retrofit 实例的构造。现在我们将有MoshiConverterFactory,而不是ScalarsConverterFactory。
最后,我们需要更新我们的回调,因为它不再处理字符串调用,而是处理List<ImageResultData>:
@GET("images/search")
fun searchImages(
@Query("limit") limit: Int,
@Query("size") format: String
) : Call<List<ImageResultData>>
练习 5.02:从 API 响应中提取图像 URL
因此,我们有一个作为字符串的服务器响应。现在,我们想从该字符串中提取图像 URL,并仅在屏幕上显示该 URL:
- 打开应用程序的
build.gradle文件,并用 Moshi 转换器替换标量转换器实现:
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
testImplementation 'junit:junit:4.12'
-
单击“与 Gradle 文件同步”按钮。
-
在您的应用程序包(
com.example.catagentprofile)下,创建一个model包。 -
在
com.example.catagentprofile.model包中,创建一个名为CatBreedData的新的 Kotlin 文件。 -
使用以下内容填充新创建的文件:
package com.example.catagentprofile.model
data class CatBreedData(
val name: String,
val temperament: String
)
-
接下来,在同一个包下创建
ImageResultData。 -
将其内容设置为以下内容:
package com.example.catagentprofile.model
import com.squareup.moshi.Json
data class ImageResultData(
@field:Json(name = "url") val imageUrl: String,
val breeds: List<CatBreedData>
)
- 打开
TheCatApiService文件并更新searchImages返回类型:
@GET("images/search")
fun searchImages(
@Query("limit") limit: Int,
@Query("size") format: String
) : Call<List<ImageResultData>>
-
最后,打开
MainActivity。 -
更新 Retrofit 初始化块以使用 Moshi 转换器进行反序列化 JSON:
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl("https://api.thecatapi.com/v1/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
}
- 更新
getCatImageResponse()函数以处理List<ImageResultData>的请求和响应:
private fun getCatImageResponse() {
val call = theCatApiService.searchImages(1, "full")
call.enqueue(object : Callback<List<ImageResultData>> {
override fun onFailure(call: Call<List<ImageResultData>>, t: Throwable) {
Log.e("MainActivity", "Failed to get search results", t)
}
override fun onResponse(
call: Call<List<ImageResultData>>,
response: Response<List<ImageResultData>>
) {
if (response.isSuccessful) {
val imageResults = response.body()
val firstImageUrl = imageResults?.firstOrNull() ?.imageUrl ?: "No URL"
serverResponseView.text = "Image URL: $firstImageUrl"
} else {
Log.e(
"MainActivity",
"Failed to get search results\n${response.errorBody()?.string() ?: ""}"
)
}
}
})
}
-
现在,您需要检查的不仅是成功的响应,还要确保至少有一个
ImageResultData实例。然后,您可以读取该实例的imageUrl属性并将其呈现给用户。 -
运行您的应用程序。现在它应该看起来像下面这样:
图 5.3:应用程序呈现解析后的图像 URL
- 由于 API 响应的随机性,您的 URL 可能会不同。
您现在已成功从 API 响应中提取了特定属性。接下来,我们将学习如何从 API 提供的 URL 加载图像。
从远程 URL 加载图像
我们刚学会了如何从 API 响应中提取特定数据。很多时候,这些数据将包括我们想要呈现给用户的图像的 URL。这需要相当多的工作。首先,您必须从 URL 中获取图像作为二进制流。然后,您需要将该二进制流转换为图像(可以是 GIF、JPEG 或其他几种图像格式之一)。然后,您需要将其转换为位图实例,可能调整大小以使用更少的内存。
您可能还希望在此时对其进行其他转换。然后,您需要将其设置为ImageView。听起来是很多工作,不是吗?幸运的是,有一些库可以为我们完成所有这些工作(甚至更多)。最常用的库是 Square 的Picasso(square.github.io/picasso/)和 Bump Technologies 的Glide(github.com/bumptech/glide)。Facebook 的Fresco(frescolib.org/)相对不太受欢迎。我们将继续使用 Glide,因为它始终是加载图像的两者中更快的一个,无论是来自互联网还是缓存。值得注意的是 Picasso 更轻量级,所以这是一个权衡,两个库都非常有用。
要在项目中包含 Glide,请将其添加到应用程序的build.gradle文件的dependencies块中:
dependencies {
implementation 'com.github.bumptech.glide:glide:4.10.0'
...
}
实际上,因为我们可能在以后改变主意,这是一个很好的机会来将具体库抽象出来,以拥有更简单的接口。所以,让我们从定义我们的ImageLoader接口开始:
interface ImageLoader {
fun loadImage(imageUrl: String, imageView: ImageView)
}
这是一个天真的实现。在生产实现中,您可能希望添加参数(或多个函数)以支持不同的裁剪策略或具有加载状态。
我们的接口实现将依赖于 Glide,因此看起来会像这样:
class GlideImageLoader(private val context: Context) : ImageLoader {
override fun loadImage(imageUrl: String, imageView: ImageView) {
Glide.with(context)
.load(imageUrl)
.centerCrop()
.into(imageView)
}
}
我们在类名前加上Glide以区别于其他潜在的实现。使用context构建GlideImageLoader允许我们实现清晰的loadImage(String, ImageView)接口,而不必担心 Glide 所需的上下文,这实际上是 Glide 对 Android 上下文的智能处理。这意味着我们可以针对Activity和Fragment范围有单独的实现,而 Glide 会知道何时图像加载请求超出范围。
由于我们尚未在布局中添加ImageView,现在让我们这样做:
<TextView
...
app:layout_constraintBottom_toTopOf="@+id/main_profile_image"
... />
<ImageView
android:id="@+id/main_profile_image"
android:layout_width="150dp"
android:layout_height="150dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/main_server_response" />
这将在我们的TextView下方添加一个 ID 为main_profile_image的ImageView。
现在我们可以在MainActivity中创建GlideImageLoader的实例:
private val imageLoader: ImageLoader by lazy { GlideImageLoader(this) }
在生产应用中,您将注入依赖项,而不是内联创建。
接下来,我们告诉我们的 Glide 加载器加载图像,并且一旦加载完成,就在提供的ImageView内居中裁剪它。这意味着图像将被放大或缩小以完全填充ImageView,任何多余的内容都将被裁剪掉。由于我们之前已经获得了图像 URL,所以我们需要做的就是调用:
val firstImageUrl = imageResults?.firstOrNull()?.imageUrl ?: ""
if (!firstImageUrl.isBlank()) {
imageLoader.loadImage(firstImageUrl, profileImageView)
} else {
Log.d("MainActivity", "Missing image URL")
}
我们必须确保结果包含一个不为空或由空格组成的字符串(在前面的代码块中使用isBlank())。然后,我们可以安全地将 URL 加载到我们的ImageView中。然后就完成了。如果现在运行我们的应用程序,应该会看到类似以下的东西:
图 5.4:服务器响应图像 URL 与实际图像
请记住,API 返回随机结果,因此实际图像可能会有所不同。如果我们幸运的话,甚至可能会得到一个动画 GIF,然后我们就会看到它动画。
练习 5.03:从获取的 URL 加载图像
在上一个练习中,我们从 API 响应中提取了图像 URL。现在,我们将使用该 URL 从网络获取图像并在我们的应用程序中显示它:
- 打开应用程序的
build.gradle文件并添加 Glide 依赖项:
dependencies {
...
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
implementation 'com.github.bumptech.glide:glide:4.11.0'
testImplementation 'junit:junit:4.12'
...
}
将项目与 Gradle 文件同步。
-
在左侧的
Project面板上,右键单击您的项目包名称(com.example.catagentprofile),然后选择New|Kotlin File/Class。 -
在
Name字段中填写ImageLoader。对于Kind,选择Interface。 -
打开新创建的
ImageLoader.kt文件,并像这样更新它:
interface ImageLoader {
fun loadImage(imageUrl: String, imageView: ImageView)
}
这将是应用程序中任何图像加载器的接口。
-
右键单击项目包名称,然后再次选择
New|Kotlin File/Class。 -
将新文件命名为
GlideImageLoader,并选择Class作为Kind。 -
更新新创建的文件:
class GlideImageLoader(private val context: Context) : ImageLoader {
override fun loadImage(imageUrl: String, imageView: ImageView) {
Glide.with(context)
.load(imageUrl)
.centerCrop()
.into(imageView)
}
}
- 打开
activity_main.xml。
像这样更新它:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/main_server_response"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toImageView named main_profile_image below your TextView.
-
打开
MainActivity.kt文件。 -
在您的类顶部添加一个新添加的
ImageView字段:
private val serverResponseView: TextView
by lazy { findViewById(R.id.main_server_response) }
private val profileImageView: ImageView
by lazy { findViewById(R.id.main_profile_image) }
- 在
onCreate(Bundle?)函数的上方定义ImageLoader:
private val imageLoader: ImageLoader by lazy { GlideImageLoader(this) }
override fun onCreate(savedInstanceState: Bundle?) {
- 像这样更新您的
getCatImageResponse()函数:
private fun getCatImageResponse() {
val call = theCatApiService.searchImages(1, "full")
call.enqueue(object : Callback<List<ImageResultData>> {
override fun onFailure(call: Call<List<ImageResultData>>, t: Throwable) {
Log.e("MainActivity", "Failed to get search results", t)
}
override fun onResponse(
call: Call<List<ImageResultData>>,
response: Response<List<ImageResultData>>
) {
if (response.isSuccessful) {
val imageResults = response.body()
val firstImageUrl = imageResults?.firstOrNull()?.imageUrl ?: ""
if (firstImageUrl.isNotBlank()) {
imageLoader.loadImage(firstImageUrl,
profileImageView)
} else {
Log.d("MainActivity", "Missing image URL")
}
serverResponseView.text = "Image URL: $firstImageUrl"
} else {
Log.e(
"MainActivity",
"Failed to get search results\n
${response.errorBody()?.string() ?: ""}"
)
}
}
})
}
-
现在,一旦您有一个非空的 URL,它将被加载到
profileImageView中。 -
运行应用程序:
图 5.5:练习结果-显示随机图像及其源 URL
以下是额外的步骤。
- 像这样更新您的布局:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/main_agent_breed_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="Agent breed:"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/main_agent_breed_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="16dp"
app:layout_constraintStart_toEndOf=
"@+id/main_agent_breed_label"
app:layout_constraintTop_toTopOf=
"@+id/main_agent_breed_label" />
<ImageView
android:id="@+id/main_profile_image"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_margin="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf=
"@+id/main_agent_breed_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
这将添加一个Agent breed标签并整理视图布局。现在,您的布局看起来更像是一个合适的猫代理配置文件应用程序。
- 在
MainActivity.kt中,找到以下行:
private val serverResponseView: TextView
by lazy { findViewById(R.id.main_server_response) }
用以下内容替换该行以查找新的名称字段:
private val agentBreedView: TextView
by lazy { findViewById(R.id.main_agent_breed_value) }
- 更新
getCatImageResponse()如下:
private fun getCatImageResponse() {
val call = theCatApiService.searchImages(1, "full")
call.enqueue(object : Callback<List<ImageResultData>> {
override fun onFailure(call: Call<List<ImageResultData>>, t: Throwable) {
Log.e("MainActivity", "Failed to get search results", t)
}
override fun onResponse(
call: Call<List<ImageResultData>>,
response: Response<List<ImageResultData>>
) {
if (response.isSuccessful) {
val imageResults = response.body()
val firstImageUrl = imageResults?.firstOrNull()?.imageUrl ?: ""
if (!firstImageUrl.isBlank()) {
imageLoader.loadImage(firstImageUrl,
profileImageView)
} else {
Log.d("MainActivity", "Missing image URL")
}
agentBreedView.text =
imageResults?.firstOrNull()?.breeds? .firstOrNull()?.name ?: "Unknown"
} else {
Log.e(
"MainActivity",
"Failed to get search results\n
${response.errorBody()?.string() ?:""}"
)
}
}
})
}
这是为了将 API 返回的第一个品种加载到agentNameView中,如果没有则回退到Unknown。
- 在撰写本文时,TheCatAPI 中没有太多带有品种数据的图片。但是,如果您运行应用程序足够多次,最终会看到类似这样的东西:
图 5.6:显示猫代理图像和品种
在本章中,我们学习了如何从远程 API 获取数据。然后,我们学习了如何处理这些数据并从中提取我们需要的信息。最后,我们学习了如何在给定图像 URL 时在屏幕上呈现图像。
在接下来的活动中,我们将应用我们的知识开发一个应用程序,告诉用户纽约的当前天气,并向用户展示相关的天气图标。
活动 5.01:显示当前天气
假设我们想要构建一个应用程序,显示纽约的当前天气。此外,我们还想显示代表当前天气的图标。
这个活动旨在创建一个应用程序,它会轮询一个 API 端点以获取 JSON 格式的当前天气,将这些数据转换为本地模型,并使用该模型呈现当前天气。它还会提取代表当前天气的图标的 URL,并获取该图标以在屏幕上显示。
我们将使用免费的 OpenWeatherMap.org API 来完成这个活动的目的。文档可以在www.metaweather.com/api/找到。要注册 API 令牌,请转到home.openweathermap.org/users/sign_up。您可以在home.openweathermap.org/api_keys找到您的密钥,并根据需要生成新的密钥。
步骤如下:
-
创建一个新的应用程序。
-
授予应用程序互联网权限,以便能够进行 API 和图像请求。
-
将 Retrofit、Moshi 转换器和 Glide 添加到应用程序中。
-
更新应用程序布局,以支持以文本形式(简短和长描述)呈现天气以及天气图标图像。
-
定义模型。创建包含服务器响应的类。
-
为 OpenWeatherMap API 添加 Retrofit 服务,
api.openweathermap.org/data/2.5/weather。 -
使用 Moshi 转换器创建一个 Retrofit 实例。
-
调用 API 服务。
-
处理成功的服务器响应。
-
处理不同的失败场景。
预期输出如下:
图 5.7:最终的天气应用程序
注意
此活动的解决方案可以在此处找到:packt.live/3sKj1cp
总结
在本章中,我们学会了如何使用 Retrofit 从 API 获取数据。然后我们学会了如何使用 Moshi 处理 JSON 响应,以及纯文本响应。我们还看到了如何处理不同的错误场景。
后来我们学会了如何使用 Glide 从 URL 加载图像,以及如何通过ImageView呈现给用户。
有很多流行的库可以从 API 中获取数据,以及加载图像。我们只涵盖了一些最流行的库。您可能想尝试一些其他库,找出哪些最适合您的目的。
在下一章中,我们将介绍RecyclerView,这是一个强大的 UI 组件,我们可以用它来向用户呈现项目列表。
第六章:RecyclerView
概述
在这一章中,您将学习如何向您的应用程序添加项目列表和网格,并有效地利用RecyclerView的回收功能。您还将学习如何处理屏幕上项目视图的用户交互,并支持不同的项目视图类型,例如标题。在本章的后面,您将动态添加和删除项目。
通过本章结束时,您将具备呈现交互式丰富项目列表所需的技能。
介绍
在上一章中,我们学习了如何从 API 中获取数据,包括项目列表和图像 URL,并如何从 URL 加载图像。将这些知识与显示项目列表的能力结合起来是本章的目标。
通常,您会希望向用户呈现项目列表。例如,您可能希望向他们显示设备上的图片列表,或者让他们从所有国家的列表中选择自己的国家。为此,您需要填充多个视图,所有这些视图共享相同的布局,但呈现不同的内容。
在历史上,这是通过使用ListView或GridView来实现的。虽然这两者仍然是可行的选择,但它们不具备RecyclerView的健壮性和灵活性。例如,它们不太好地支持大型数据集,不支持水平滚动,并且不提供丰富的分隔符自定义。使用RecyclerView.ItemDecorator可以轻松实现对RecyclerView中项目之间的分隔符进行自定义。
那么,RecyclerView是做什么的呢?RecyclerView协调创建、填充和重用(因此得名)表示项目列表的视图。要使用RecyclerView,您需要熟悉其两个依赖项:适配器(以及通过它的视图持有者)和布局管理器。这些依赖项为我们的RecyclerView提供要显示的内容,并告诉它如何呈现该内容以及如何在屏幕上布置它。
适配器为RecyclerView提供子视图(RecyclerView中用于表示单个数据项的嵌套 Android 视图),绑定这些视图到数据(通过ViewHolder实例),并报告用户与这些视图的交互。布局管理器告诉RecyclerView如何布置其子项。我们默认提供了三种布局类型:线性、网格和交错网格,分别由LinearLayoutManager、GridLayoutManager和StaggeredGridLayoutManager管理。
在本章中,我们将开发一个列出秘密特工及其当前活动状态或休眠状态(因此不可用)的应用程序。然后,该应用程序将允许我们添加新特工或通过滑动将现有特工删除。不过,有一个转折,正如您在第五章中看到的,基本库:Retrofit、Moshi 和 Glide,我们所有的特工都将是猫。
将 RecyclerView 添加到我们的布局中
在第三章,屏幕和 UI中,我们看到了如何向我们的布局中添加视图,以便由活动、片段或自定义视图膨胀。RecyclerView只是另一个这样的视图。要将其添加到我们的布局中,我们需要向我们的布局添加以下标签:
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:listitem="@layout/item_sample" />
您应该已经能够识别android:id属性,以及android:layout_width和android:layout_height属性。
我们可以使用可选的tools:listitem属性告诉 Android Studio 在我们的预览工具栏中膨胀哪个布局作为列表项。这将让我们对RecyclerView在我们的应用程序中的外观有一个概念。
向我们的布局添加RecyclerView标签意味着我们现在有一个空容器来容纳表示我们列表项目的子视图。一旦填充,它将为我们处理子视图的呈现、滚动和回收。
练习 6.01:向主活动添加一个空的 RecyclerView
要在应用程序中使用RecyclerView,您首先需要将其添加到您的布局之一中。让我们将其添加到我们的主活动膨胀的布局中:
-
首先创建一个新的空活动项目(
文件|新建|新项目|空活动)。将应用程序命名为My RecyclerView App。确保您的包名称为com.example.myrecyclerviewapp。 -
将保存位置设置为您要保存项目的位置。将其他所有内容保持默认值,然后单击
完成。确保您在项目窗格中处于Android视图下:
图 6.1:项目窗格中的 Android 视图
-
在
Text模式下打开您的activity_main.xml文件。 -
将您的标签转换为屏幕顶部的标题,您可以在其下添加您的
RecyclerView,为TextView添加一个 ID,并将其对齐到顶部,如下所示:
<TextView
android:id="@+id/hello_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
- 在
TextView标签之后添加以下内容,以在您的hello_labelTextView标题下方添加一个空的RecyclerView元素:
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/hello_label" />
您的布局文件现在应该看起来像这样:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/hello_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/hello_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
- 通过单击
运行应用程序按钮或按Ctrl + R(在 Windows 上为Shift + F10)来运行您的应用程序。在模拟器上,它应该看起来像这样:
图 6.2:带有空的 RecyclerView 的应用程序(为节省空间而裁剪的图像)
正如您所看到的,我们的应用程序正在运行,并且我们的布局显示在屏幕上。但是,我们没有看到我们的RecyclerView。为什么呢?在这个阶段,我们的RecyclerView没有内容。默认情况下,没有内容的RecyclerView不会呈现—因此,虽然我们的RecyclerView确实在屏幕上,但它是不可见的。这就带我们到下一步—填充RecyclerView,以便我们实际上可以看到内容。
填充 RecyclerView
因此,我们将RecyclerView添加到我们的布局中。为了从RecyclerView中受益,我们需要向其中添加内容。让我们看看如何做到这一点。
正如我们之前提到的,要向我们的RecyclerView添加内容,我们需要实现一个适配器。适配器将我们的数据绑定到子视图。简单来说,这意味着它告诉RecyclerView如何将数据插入到设计用于呈现该数据的视图中。
例如,假设我们想要呈现一个员工列表。
首先,我们需要设计我们的 UI 模型。这将是一个数据对象,其中包含视图呈现单个员工所需的所有信息。因为这是一个 UI 模型,一个惯例是在其名称后缀中加上UiModel:
data class EmployeeUiModel(
val name: String,
val biography: String,
val role: EmployeeRole,
val gender: Gender,
val imageUrl: String
)
我们将定义EmployeeRole和Gender如下:
enum class EmployeeRole {
HumanResources,
Management,
Technology
}
enum class Gender {
Female,
Male,
Unknown
}
这些值仅供参考。请随意添加更多!
图 6.3:模型的层次结构
现在我们知道在绑定视图时可以期望什么样的数据,因此,我们可以设计我们的视图来呈现这些数据(这是实际布局的简化版本,我们将其保存为item_employee.xml)。我们将从ImageView开始:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp">
<ImageView
android:id="@+id/item_employee_photo"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@string/item_employee_photo"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@color/colorPrimary" />
然后为每个TextView添加:
<TextView
android:id="@+id/item_employee_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:textStyle="bold"
app:layout_constraintStart_toEndOf="@+id/item_employee_photo"
app:layout_constraintTop_toTopOf="parent"
tools:text="Oliver" />
<TextView
android:id="@+id/item_employee_role"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/colorAccent"
app:layout_constraintStart_toStartOf="@+id/item_employee_name"
app:layout_constraintTop_toBottomOf="@+id/item_employee_name"
tools:text="Exotic Shorthair" />
<TextView
android:id="@+id/item_employee_biography"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/item_employee_role"
app:layout_constraintTop_toBottomOf="@+id/item_employee_role"
tools:text="Stealthy and witty. Better avoid in dark alleys." />
<TextView
android:id="@+id/item_employee_gender"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="30sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="♂" />
</androidx.constraintlayout.widget.ConstraintLayout>
到目前为止,没有什么新的。您应该能够从第二章 构建用户屏幕流中识别出所有不同的视图类型:
图 6.4:item_cat.xml 布局文件的预览
有了数据模型和布局,我们现在拥有了将数据绑定到视图所需的一切。为此,我们将实现一个视图持有者。通常,视图持有者有两个职责:它保存对视图的引用(正如其名称所暗示的那样),但它也将数据绑定到该视图。我们将实现我们的视图持有者如下:
private val FEMALE_SYMBOL by lazy {
HtmlCompat.fromHtml("♁", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private val MALE_SYMBOL by lazy {
HtmlCompat.fromHtml("♂", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private const val UNKNOWN_SYMBOL = "?"
class EmployeeViewHolder(
containerView: View,
private val imageLoader: ImageLoader
) : ViewHolder(containerView) {
private val employeeNameView: TextView
by lazy { containerView.findViewById(R.id.item_employee_name) }
private val employeeRoleView: TextView
by lazy { containerView.findViewById(R.id.item_employee_role) }
private val employeeBioView: TextView
by lazy { containerView.findViewById(R.id.item_employee_bio) }
private val employeeGenderView: TextView
by lazy { containerView.findViewById(R.id.item_employee_gender) }
fun bindData(employeeData: EmployeeUiModel) {
imageLoader.loadImage(employeeData.imageUrl, employeePhotoView)
employeeNameView.text = employeeData.name
employeeRoleView.text = when (employeeData.role) {
EmployeeRole.HumanResources -> "Human Resources"
EmployeeRole.Management -> "Management"
EmployeeRole.Technology -> "Technology"
}
employeeBioView.text = employeeData.biography
employeeGenderView.text = when (employeeData.gender) {
Gender.Female -> FEMALE_SYMBOL
Gender.Male -> MALE_SYMBOL
else -> UNKNOWN_SYMBOL
}
}
}
在上述代码中有一些值得注意的事情。首先,按照惯例,我们在视图持有者的名称后缀为ViewHolder。其次,请注意EmployeeViewHolder需要实现抽象的RecyclerView.ViewHolder类。这是必需的,以便我们的适配器的通用类型可以是我们的视图持有者。最后,我们懒惰地保留对我们感兴趣的视图的引用。当第一次调用bindData(EmployeeUiModel)时,我们将在布局中找到这些视图并保留对它们的引用。
接下来,我们引入了一个bindData(EmployeeUiModel)函数。这个函数将被我们的适配器调用,将数据绑定到视图持有者持有的视图上。最后但最重要的一点是,我们始终确保为任何可能的输入设置所有修改视图的状态。
设置了我们的视图持有者后,我们可以继续实现我们的适配器。我们将首先实现最少所需的函数,再加上一个设置数据的函数。我们的适配器将看起来像这样:
class EmployeesAdapter(
private val layoutInflater: LayoutInflater,
private val imageLoader: ImageLoader
) : RecyclerView.Adapter<EmployeeViewHolder>() {
private val employeesData = mutableListOf<EmployeeUiModel>()
fun setData(employeesData: List<EmployeeUiModel>) {
this.employeesData.clear()
this.employeesData.addAll(employeesData)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmployeeViewHolder {
val view = layoutInflater.inflate(R.layout.item_employee, parent, false)
return EmployeeViewHolder(view, imageLoader)
}
override fun getItemCount() = employeesData.size
override fun onBindViewHolder(holder: EmployeeViewHolder, position:Int) {
holder.bindData(employeesData[position])
}
}
让我们来看看这个实现。首先,我们通过构造函数向适配器注入我们的依赖项。这将使测试我们的适配器变得更容易,但也将允许我们轻松地更改一些其行为(例如,替换图像加载库)。实际上,在这种情况下,我们根本不需要更改适配器。
然后,我们定义一个私有的可变的EmployeeUiModel列表,用于存储适配器当前提供给RecyclerView的数据。我们还引入了一个设置该列表的方法。请注意,我们保留一个本地列表并设置其内容,而不是直接允许employeesData被设置。这主要是因为 Kotlin 和 Java 一样,通过引用传递变量。通过引用传递变量意味着对适配器传入的列表内容的更改将改变适配器持有的列表。因此,例如,如果在适配器外部删除了一个项目,适配器也会将该项目删除。这成为一个问题,因为适配器不会意识到这种变化,因此无法通知RecyclerView。列表从适配器外部修改的其他风险,但涵盖它们超出了本书的范围。
将数据修改封装在一个函数中的另一个好处是,我们避免了忘记通知RecyclerView数据集已更改的风险,我们通过调用notifyDataSetChanged()来实现这一点。
我们继续实现适配器的onCreateViewHolder(ViewGroup, Int)函数。当RecyclerView需要一个新的ViewHolder来在屏幕上呈现数据时,将调用此函数。它为我们提供了一个容器ViewGroup和一个视图类型(我们将在本章后面讨论视图类型)。然后,该函数期望我们返回一个使用视图(在我们的情况下是一个膨胀的视图)初始化的视图持有者。因此,我们膨胀我们之前设计的视图,并将其传递给一个新的EmployeeViewHolder实例。请注意,膨胀函数的最后一个参数是false。这确保我们不将新膨胀的视图附加到父视图上。附加和分离视图将由布局管理器管理。将其设置为true或省略将导致IllegalStateException被抛出。最后,我们返回新创建的EmployeeViewHolder。
要实现getItemCount(),我们只需返回我们的employeesData列表的大小。
最后,我们实现了onBindViewHolder(EmployeeViewHolder, Int)。这是通过将存储在catsData中的EmployeeUiModel在给定位置传递给我们的视图持有者的bindData(EmployeeUiModel)函数来完成的。我们的适配器现在已经准备好了。
如果我们尝试在这一点上将我们的适配器插入我们的RecyclerView并运行我们的应用程序,我们仍然看不到任何内容。这是因为我们仍然缺少两个小步骤:向我们的适配器设置数据和为我们的RecyclerView分配布局管理器。完整的工作代码将如下所示:
class MainActivity : AppCompatActivity() {
private val employeesAdapter by lazy {
EmployeesAdapter(layoutInflater, GlideImageLoader(this)) }
private val recyclerView: RecyclerView by lazy
{ findViewById(R.id.main_recycler_view) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recyclerView.adapter = employeesAdapter
recyclerView.layoutManager =
LinearLayoutManager(this, LinearLayoutManager.VERTICAL,
false)
employeesAdapter.setData(
listOf(
EmployeeUiModel(
"Robert",
"Rose quickly through the organization",
EmployeeRole.Management,
Gender.Male,
"https://images.pexels.com/photos/220453 /pexels-photo-220453.jpeg?auto =compress&cs=tinysrgb&h=650&w=940"
),
EmployeeUiModel(
"Wilma",
"A talented developer",
EmployeeRole.Technology,
Gender.Female,
"https://images.pexels.com/photos/3189024 /pexels-photo-3189024.jpeg?auto=compress&cs =tinysrgb&h=650&w=940"
),
EmployeeUiModel(
"Curious George",
"Excellent at retention",
EmployeeRole.HumanResources,
Gender.Unknown,
"https://images.pexels.com/photos/771742 /pexels-photo-771742.jpeg?auto =compress&cs=tinysrgb&h=750&w=1260"
)
)
)
}
}
现在运行我们的应用程序,我们会看到我们的员工列表。
请注意,我们对员工列表进行了硬编码。在生产应用程序中,应遵循ViewModel。还要注意,我们保留了对employeesAdapter的引用。这样我们可以在以后确实将数据设置为不同的值。一些实现依赖于从RecyclerView本身读取适配器——这可能导致不必要的强制转换操作和适配器尚未分配给RecyclerView的意外状态,因此这通常不是一种推荐的方法。
最后,请注意,我们选择使用LinearLayoutManager,为其提供活动上下文、VERTICAL方向标志和false来告诉它我们不希望列表中的项目顺序被颠倒。
练习 6.02:填充您的 RecyclerView
RecyclerView如果没有任何内容就不太有趣。现在是时候通过向其中添加您的秘密猫代理来填充RecyclerView了。
在你开始之前,让我们快速回顾一下:在上一个练习中,我们介绍了一个空列表,用于保存用户可以使用的秘密猫代理的列表。在这个练习中,您将填充该列表,以向用户展示机构中可用的秘密猫代理:
- 为了保持文件结构的整洁,我们将首先创建一个模型包。右键单击我们应用程序的包名称,然后选择
New|Package:
图 6.5:创建新的包
-
将新包命名为
model。单击OK以创建该包。 -
要创建我们的第一个模型数据类,请右键单击新创建的模型包,然后选择
New|Kotlin File/Class。 -
在
name下,填写CatUiModel。将kind保持为File,然后单击OK。这将是包含我们对每个猫代理的数据的类。 -
将以下内容添加到新创建的
CatUiModel.kt文件中,以定义具有所有相关属性的数据类的猫代理:
data class CatUiModel(
val gender: Gender,
val breed: CatBreed,
val name: String,
val biography: String,
val imageUrl: String
)
除了他们的姓名和照片之外,我们还想知道每个猫代理的性别、品种和传记。这将帮助我们为任务选择合适的代理。
-
再次右键单击模型包,然后转到
New|Kotlin File/Class。 -
这次,将新文件命名为
CatBreed,并将kind设置为Enum类。这个类将保存我们不同的猫品种。 -
使用一些初始值更新您新创建的枚举,如下所示:
enum class CatBreed {
AmericanCurl,
BalineseJavanese,
ExoticShorthair
}
-
重复步骤 6和7,只是这一次将文件命名为
Gender。这将保存猫代理的性别的接受值。 -
像这样更新
Gender枚举:
enum class Gender {
Female,
Male,
Unknown
}
- 现在,通过右键单击
layout,然后选择New|Layout resource file来定义包含有关每个猫代理数据的视图布局资源文件:
图 6.6:创建新的布局资源文件
-
将您的资源命名为
item_cat。将所有其他字段保持不变,然后单击OK。 -
更新新创建的
item_cat.xml文件的内容。(以下代码块已经被截断以节省空间。使用下面的链接查看您需要添加的完整代码。)
item_cat.xml
10 <ImageView
11 android:id="@+id/item_cat_photo"
12 android:layout_width="60dp"
13 android:layout_height="60dp"
14 android:contentDescription="@string/item_cat_photo"
15 app:layout_constraintStart_toStartOf="parent"
16 app:layout_constraintTop_toTopOf="parent"
17 tools:background="@color/colorPrimary" />
18
19 <TextView
20 android:id="@+id/item_cat_name"
21 android:layout_width="wrap_content"
22 android:layout_height="wrap_content"
23 android:layout_marginStart="16dp"
24 android:layout_marginLeft="16dp"
25 android:textStyle="bold"
26 app:layout_constraintStart_toEndOf="@+id/item_cat_photo"
27 app:layout_constraintTop_toTopOf="parent"
28 tools:text="Oliver" />
The complete code for this step can be found at http://packt.live/3sopUjo.
这将创建一个布局,其中包含用于列表中使用的名称、品种和传记的图像和文本字段。
- 您会注意到第 14 行被标记为红色。这是因为您还没有在
res/values文件夹下的strings.xml中声明item_cat_photo。现在通过将文本光标放在item_cat_photo上,然后按Alt + Enter(Mac 上为Option + Enter),然后选择Create string value resource 'item_cat_photo'来进行声明:
图 6.7:尚未定义的字符串资源
-
在
Resource value下,填写Photo。按下OK。 -
你需要一个
ImageLoader.kt的副本,它在第五章 Essential Libraries: Retrofit, Moshi, and Glide中介绍,所以右键单击你的应用程序的包名称,导航到New|Kotlin File/Class,然后将名称设置为ImageLoader,kind设置为Interface,然后点击OK。 -
与第五章 Essential Libraries: Retrofit, Moshi, and Glide类似,你只需要在这里添加一个函数:
interface ImageLoader {
fun loadImage(imageUrl: String, imageView: ImageView)
}
确保导入ImageView。
-
再次右键单击你的应用程序的包名称,然后选择
New|Kotlin File/Class。 -
将新文件命名为
CatViewHolder。点击OK。 -
要实现
CatViewHolder,它将把猫特工数据绑定到你的视图,用以下内容替换CatViewHolder.kt文件的内容:
private val FEMALE_SYMBOL by lazy {
HtmlCompat.fromHtml("♁", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private val MALE_SYMBOL by lazy {
HtmlCompat.fromHtml("♂", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
private const val UNKNOWN_SYMBOL = "?"
class CatViewHolder(
containerView: View,
private val imageLoader: ImageLoader
) : ViewHolder(containerView) {
private val catBiographyView: TextView
by lazy { containerView.findViewById(R.id.item_cat_biography) }
private val catBreedView: TextView
by lazy { containerView.findViewById(R.id.item_cat_breed) }
private val catGenderView: TextView
by lazy { containerView.findViewById(R.id.item_cat_gender) }
private val catNameView: TextView
by lazy { containerView.findViewById(R.id.item_cat_name) }
private val catPhotoView: ImageView
by lazy { containerView.findViewById(R.id.item_cat_photo) }
fun bindData(catData: CatUiModel) {
imageLoader.loadImage(catData.imageUrl, catPhotoView)
catNameView.text = catData.name
catBreedView.text = when (catData.breed) {
CatBreed.AmericanCurl -> "American Curl"
CatBreed.BalineseJavanese -> "Balinese-Javanese"
CatBreed.ExoticShorthair -> "Exotic Shorthair"
}
catBiographyView.text = catData.biography
catGenderView.text = when (catData.gender) {
Gender.Female -> FEMALE_SYMBOL
Gender.Male -> MALE_SYMBOL
else -> UNKNOWN_SYMBOL
}
}
}
-
在我们的应用程序包名称下,创建一个名为
CatsAdapter的新的 Kotlin 文件。 -
要实现
CatsAdapter,它负责存储RecyclerView的数据,以及创建视图持有者的实例并使用它们将数据绑定到视图,用以下内容替换CatsAdapter.kt文件的内容:
package com.example.myrecyclerviewapp
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.myrecyclerviewapp.model.CatUiModel
class CatsAdapter(
private val layoutInflater: LayoutInflater,
private val imageLoader: ImageLoader
) : RecyclerView.Adapter<CatViewHolder>() {
private val catsData = mutableListOf<CatUiModel>()
fun setData(catsData: List<CatUiModel>) {
this.catsData.clear()
this.catsData.addAll(catsData)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup,
viewType: Int): CatViewHolder {
val view = layoutInflater.inflate(R.layout.item_cat,
parent, false)
return CatViewHolder(view, imageLoader)
}
override fun getItemCount() = catsData.size
override fun onBindViewHolder(holder: CatViewHolder,
position: Int) {
holder.bindData(catsData[position])
}
}
- 在这一点上,你需要在你的项目中包含 Glide。首先,在你的应用程序的
gradle.build文件的dependencies块中添加以下代码行:
implementation 'com.github.bumptech.glide:glide:4.11.0'
- 在你的应用程序包路径中创建一个
GlideImageLoader类,包含以下内容:
package com.example.myrecyclerviewapp
import android.content.Context
import android.widget.ImageView
import com.bumptech.glide.Glide
class GlideImageLoader(private val context: Context) : ImageLoader {
override fun loadImage(imageUrl: String, imageView: ImageView) {
Glide.with(context)
.load(imageUrl)
.centerCrop()
.into(imageView)
}
}
这是一个简单的实现,假设加载的图像应始终是中心裁剪的。
- 更新你的
MainActivity文件:
class MainActivity : AppCompatActivity() {
private val recyclerView: RecyclerView
by lazy { findViewById(R.id.recycler_view) }
private val catsAdapter by lazy { CatsAdapter(layoutInflater, GlideImageLoader(this)) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
recyclerView.adapter = catsAdapter
recyclerView.layoutManager = LinearLayoutManager(this,
LinearLayoutManager.VERTICAL, false)
catsAdapter.setData(
listOf(
CatUiModel(
Gender.Male,
CatBreed.BalineseJavanese,
"Fred",
"Silent and deadly",
"https://cdn2.thecatapi.com/images/DBmIBhhyv.jpg"
),
CatUiModel(
Gender.Female,
CatBreed.ExoticShorthair,
"Wilma",
"Cuddly assassin",
"https://cdn2.thecatapi.com/images/KJF8fB_20.jpg"
),
CatUiModel(
Gender.Unknown,
CatBreed.AmericanCurl,
"Curious George",
"Award winning investigator",
"https://cdn2.thecatapi.com/images/vJB8rwfdX.jpg"
)
)
)
}
}
这将定义你的适配器,将它附加到RecyclerView,并用一些硬编码的数据填充它。
- 在你的
AndroidManifest.xml文件中,在应用程序标签之前的manifest标签中添加以下内容:
<uses-permission android:name="android.permission.INTERNET" />
这将允许你的应用程序从互联网上下载图像。
- 为了一些最后的修饰,比如给我们的标题视图一个合适的名称和文本,像这样更新你的
activity_main.xml文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/main_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/main_title"
android:textSize="24sp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/main_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
- 还要更新你的
strings.xml文件,给你的应用程序一个合适的名称和标题:
<resources>
<string name="app_name">SCA - Secret Cat Agents</string>
<string name="item_cat_photo">Cat photo</string>
<string name="main_title">Our Agents</string>
</resources>
- 运行你的应用程序。它应该是这样的:
图 6.8:具有硬编码秘密猫特工的 RecyclerView
如你所见,RecyclerView现在有内容,你的应用程序开始成形。请注意,相同的布局用于根据绑定到每个实例的数据呈现不同的项目。正如你所期望的,如果你添加了足够的项目使它们超出屏幕,滚动是有效的。接下来,我们将研究允许用户与我们的RecyclerView中的项目进行交互。
在 RecyclerView 中响应点击
如果我们想让用户从呈现的列表中选择一个项目怎么办?为了实现这一点,我们需要将点击事件传递回我们的应用程序。
实现点击交互的第一步是在ViewHolder级别捕获项目的点击。
为了保持视图持有者和适配器之间的分离,我们在视图持有者中定义了一个嵌套的OnClickListener接口。我们选择在视图持有者内定义接口,因为它们是紧密耦合的。在我们的情况下,接口只有一个功能。这个功能的目的是通知视图持有者的所有者有关点击的信息。视图持有者的所有者通常是一个 Fragment 或一个 Activity。由于我们知道视图持有者可以被重用,我们知道在构造时定义它可能会很具有挑战性,因为它会告诉我们点击了哪个项目(因为该项目会随着重用而随时间变化)。我们通过在点击时将当前呈现的项目传递回视图持有者的所有者来解决这个问题。这意味着我们的接口看起来像这样:
interface OnClickListener {
fun onClick(catData: CatUiModel)
}
我们还将把这个监听器作为参数添加到我们的ViewHolder构造函数中:
class CatViewHolder(
containerView: View,
private val imageLoader: ImageLoader,
private val onClickListener: OnClickListener
) : ViewHolder(containerView) {
.
.
.
}
它将被用于这样:
containerView.setOnClickListener { onClickListener.onClick(catData) }
现在,我们希望我们的适配器传递一个监听器。反过来,该监听器将负责通知适配器的所有者点击事件。这意味着我们的适配器也需要一个嵌套的监听器接口,与我们在视图持有者中实现的接口非常相似。
虽然这似乎是可以通过重用相同的监听器来避免的重复,但这并不是一个好主意,因为它会导致视图持有者和适配器之间通过监听器的紧密耦合。当您希望适配器也通过监听器报告其他事件时会发生什么?即使视图持有者实际上没有实现这些事件,您也必须处理来自视图持有者的这些事件。
最后,为了处理点击事件并显示对话框,我们在活动中定义一个监听器并将其传递给适配器。我们设置该监听器在点击时显示对话框。在 MVVM 实现中,您现在将通知ViewModel点击。ViewModel然后更新其状态,告诉视图(我们的活动)应该显示对话框。
练习 6.03:响应点击
您的应用程序已经向用户显示了一组秘密猫代理。现在是时候允许用户通过点击其视图选择秘密猫代理。点击事件是从视图持有者委托给适配器再委托给活动的,如图 6.9所示:
图 6.9:点击事件的流程
以下是您需要遵循的步骤来完成此练习:
- 打开您的
CatViewHolder.kt文件。在最终的闭合大括号之前添加一个嵌套接口:
interface OnClickListener {
fun onClick(catData: CatUiModel)
}
这将是监听器必须实现的接口,以便在单个猫项目上注册点击事件。
- 更新
CatViewHolder构造函数以接受OnClickListener并使 containerView 可访问:
class CatViewHolder(
CatViewHolder constructor, you also register for clicks on item views.
- 在您的
bindData(CatUiModel)函数顶部,添加以下内容以拦截点击并将其报告给提供的监听器:
containerView.setOnClickListener { onClickListener.onClick(catData) }
- 现在,打开您的
CatsAdapter.kt文件。在最终的闭合大括号之前添加此嵌套接口:
interface OnClickListener {
fun onItemClick(catData: CatUiModel)
}
这定义了监听器必须实现的接口,以接收来自适配器的项目点击事件。
- 更新
CatsAdapter构造函数,以接受刚刚定义的OnClickListener适配器的调用:
class CatsAdapter(
private val layoutInflater: LayoutInflater,
private val imageLoader: ImageLoader,
private val onClickListener: OnClickListener
) : RecyclerView.Adapter<CatViewHolder>() {
- 在
onCreateViewHolder(ViewGroup, Int)中,按照以下方式更新视图持有者的创建:
return CatViewHolder(view, imageLoader, ViewHolder click events to the adapter listener.
- 最后,打开您的
MainActivity.kt文件。按照以下方式更新您的catsAdapter构造,以通过显示对话框处理点击事件来为适配器提供所需的依赖项:
private val catsAdapter by lazy {
CatsAdapter(
layoutInflater,
GlideImageLoader(this),
object : CatsAdapter.OnClickListener {
override fun onClick(catData: CatUiModel) = onClickListener.onItemClick(catData)
}
)
}
- 在最终的闭合大括号之前添加以下函数:
private fun showSelectionDialog(catData: CatUiModel) {
AlertDialog.Builder(this)
.setTitle("Agent Selected")
.setMessage("You have selected agent ${catData.name}")
.setPositiveButton("OK") { _, _ -> }
.show()
}
此函数将显示一个对话框,其中包含传递的猫数据的名称。
-
确保导入正确版本的
AlertDialog,即androidx.appcompat.app.AlertDialog,而不是android.app.AlertDialog。这通常是支持向后兼容的更好选择。 -
运行您的应用程序。现在点击其中一只猫应该会显示一个对话框:
图 6.10:显示已选择代理的对话框
尝试点击不同的项目并注意呈现的不同消息。您现在知道如何响应用户点击RecyclerView中的项目。接下来,我们将看看如何支持列表中的不同项目类型。
支持不同的项目类型
在前面的部分中,我们学习了如何处理单一类型的项目列表(在我们的情况下,所有项目都是CatUiModel)。如果您想要支持多种类型的项目会发生什么?一个很好的例子是在我们的列表中有组标题。
假设我们不是获取一组猫的列表,而是获取一个包含快乐猫和悲伤猫的列表。每组猫之前都有相应组的标题。我们的列表现在不再包含CatUiModel实例,而是包含ListItem实例。ListItem可能如下所示:
sealed class ListItem {
data class Group(val name: String) : ListItem()
data class Cat(val data: CatUiModel) : ListItem()
}
我们的项目列表可能如下所示:
listOf(
ListItem.Group("Happy Cats"),
ListItem.Cat(
CatUiModel(
Gender.Female,
CatBreed.AmericanCurl,
"Kitty",
"Kitty is warm and fuzzy.",
"https://cdn2.thecatapi.com/images/..."
)
),
ListItem.Cat(
CatUiModel(
Gender.Male,
CatBreed.ExoticShorthair,
"Joey",
"Loves to cuddle.",
"https://cdn2.thecatapi.com/images/..."
)
),
ListItem.Group("Sad Cats"),
ListItem.Cat(
CatUiModel(
Gender.Unknown,
CatBreed.AmericanCurl,
"Ginger",
"Just not in the mood.",
"https://cdn2.thecatapi.com/images/..."
)
),
ListItem.Cat(
CatUiModel(
Gender.Female,
CatBreed.ExoticShorthair,
"Butters",
"Sleeps most of the time.",
"https://cdn2.thecatapi.com/images/..."
)
)
)
在这种情况下,只有一个布局类型是不够的。幸运的是,正如您可能已经在我们早期的练习中注意到的那样,RecyclerView.Adapter为我们提供了处理这种情况的机制(记得onCreateViewHolder(ViewGroup, Int)函数中使用的viewType参数吗?)。
为了帮助适配器确定每个项目需要哪种视图类型,我们重写了它的getItemViewType(Int)函数。一个对我们来说可以解决问题的实现示例如下:
override fun getItemViewType(position: Int) = when (listData[position]) {
is ListItem.Group -> VIEW_TYPE_GROUP
is ListItem.Cat -> VIEW_TYPE_CAT
}
在这里,VIEW_TYPE_GROUP和VIEW_TYPE_CAT的定义如下:
private const val VIEW_TYPE_GROUP = 0
private const val VIEW_TYPE_CAT = 1
这个实现将给定位置的数据类型映射到表示我们已知布局类型之一的常量值。在我们的情况下,我们知道标题和猫,因此有两种类型。我们使用的值可以是任何整数值,因为它们会原样传递给我们在onCreateViewHolder(ViewGroup, Int)函数中。我们只需要确保不重复相同的值超过一次。
现在我们已经告诉适配器需要哪些视图类型以及在哪里需要,我们还需要告诉它对于每种视图类型使用哪种视图持有者。这是通过实现onCreateViewHolder(ViewGroup, Int)函数来完成的:
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
VIEW_TYPE_GROUP -> {
val view = layoutInflater.inflate(R.layout.item_title,
parent, false)
GroupViewHolder(view)
}
VIEW_TYPE_CAT -> {
val view = layoutInflater.inflate(R.layout.item_cat, parent, false)
CatViewHolder(view, imageLoader, object :
CatViewHolder.OnClickListener {
override fun onClick(catData: CatUiModel) = onClickListener.onItemClick(catData)
})
}
else -> throw IllegalArgumentException("Unknown view type requested: $viewType")
}
与此函数的早期实现不同,我们现在考虑viewType的值。
正如我们现在知道的,viewType预计是我们从getItemViewType(Int)返回的值之一。
对于这些值(VIEW_TYPE_GROUP和VIEW_TYPE_CAT),我们会填充相应的布局并构建一个合适的视图持有者。请注意,我们永远不希望收到任何其他值,因此如果遇到这样的值,我们会抛出异常。根据您的需求,您也可以返回一个显示错误或根本不显示任何内容的默认视图持有者。记录这些值也可能是一个好主意,以便您调查为什么收到它们并决定如何处理它们。
对于我们的标题布局,一个简单的TextView可能就足够了。item_cat.xml布局可以保持不变。
现在到了视图持有者。我们需要为标题创建一个视图持有者。这意味着我们现在将有两个不同的视图持有者。然而,我们的适配器只支持一种适配器类型。最简单的解决方案是定义一个通用的视图持有者,GroupViewHolder和CatViewHolder都将扩展它。让我们称之为ListItemViewHolder。ListItemViewHolder类可以是抽象的,因为我们永远不打算直接使用它。为了方便绑定数据,我们还可以在我们的抽象视图持有者中引入一个函数——abstract fun bindData(listItem: ListItemUiModel)。我们的具体实现可以期望接收特定类型,因此我们可以分别向GroupViewHolder和CatViewHolder添加以下行:
require(listItem is ListItemUiModel.Cat) {
"Expected ListItemUiModel.Cat"
}
我们还可以添加以下内容:
require(listItem is ListItemUiModel.Cat) { "Expected ListItemUiModel.Cat" }
具体来说,在CatViewHolder中,由于一些 Kotlin 魔法,我们可以使用define val catData = listItem.data,并且保持类的其余部分不变。
做出这些更改后,我们现在可以期望看到“快乐的猫”和“悲伤的猫”组标题,每个标题后面跟着相关的猫。
练习 6.04:向 RecyclerView 添加标题
现在我们希望能够在两个组中呈现我们的秘密猫特工:可部署到现场的活跃特工和目前无法部署的沉睡特工。我们将通过在活跃特工上方添加一个标题,并在沉睡特工上方添加另一个标题来实现这一点:
-
创建一个名为
ListItemUiModel的新的 Kotlin 文件。 -
在
ListItemUiModel.kt文件中添加以下内容,定义我们的两种数据类型——标题和猫:
sealed class ListItemUiModel {
data class Title(val title: String) : ListItemUiModel()
data class Cat(val data: CatUiModel) : ListItemUiModel()
}
-
在
com.example.myrecyclerviewapp中创建一个名为ListItemViewHolder的新的 Kotlin 文件。这将是我们的基本视图持有者。 -
在
com.example.myrecyclerviewapp.model下,用以下内容填充ListItemViewHolder.kt文件。
abstract class ListItemViewHolder(
containerView: View
) : RecyclerView.ViewHolder(containerView) {
abstract fun bindData(listItem: ListItemUiModel)
}
-
打开
CatViewHolder.kt文件。 -
使
CatViewHolder扩展ListItemViewHolder:
class CatViewHolder(
...
) : ListItemViewHolder(containerView) {
- 用
ListItemUiModel替换bindData(CatUiModel)参数,并使其覆盖ListItemViewHolder的抽象函数:
override fun bindData(listItem: ListItemUiModel)
- 在
bindData(ListItemUiModel)函数的顶部添加以下两行,以强制将ListItemUiModel转换为ListItemUiModel.Cat并从中获取猫数据:
require(listItem is ListItemUiModel.Cat) {
"Expected ListItemUiModel.Cat" }
val catData = listItem.data
保持文件的其余部分不变。
-
创建一个新的布局文件。将布局命名为
item_title。 -
用以下内容替换新创建的
item_title.xml文件的默认内容:
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/item_title_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Sleeper Agents" />
这个新的布局只包含一个带有 16sp 大小的粗体字体的TextView,将承载我们的标题:
图 6.11:item_title.xml 布局的预览
- 在
com.example.myrecyclerviewapp下的同名文件中实现TitleViewHolder:
class TitleViewHolder(
containerView: View
) : ListItemViewHolder(containerView) {
private val titleView: TextView
by lazy { containerView .findViewById(R.id.item_title_title) }
override fun bindData(listItem: ListItemUiModel) {
require(listItem is ListItemUiModel.Title) {
"Expected ListItemUiModel.Title"
}
titleView.text = listItem.title
}
}
这与CatViewHolder非常相似,但由于我们只在TextView上设置文本,因此它也简单得多。
-
现在,为了使事情更整洁,选择
CatViewHolder,ListItemViewHolder和TitleViewHolder。 -
将所有文件移动到新的命名空间:右键单击其中一个文件,然后选择
重构|移动(或按F6)。 -
将
/viewholder附加到预填的到目录字段。保持搜索引用和更新包指令(Kotlin 文件)选中,不选中在编辑器中打开移动的文件。单击确定。 -
打开
CatsAdapter.kt文件。 -
现在,将
CatsAdapter重命名为ListItemsAdapter。重命名变量,函数和类的命名以反映其实际用途是很重要的,以避免将来的混淆。在代码窗口中右键单击CatsAdapter类名,然后选择重构|重命名(或Shift + F6)。 -
当
CatsAdapter被突出显示时,键入ListItemsAdapter并按Enter。 -
将适配器通用类型更改为
ListItemViewHolder:
class ListItemsAdapter(
...
) : RecyclerView.Adapter<ListItemViewHolder>() {
- 更新
listData和setData(List<CatUiModel>)以处理ListItemUiModel:
private val listData = mutableListOf<ListItemUiModel>()
fun setData(listData: List<ListItemUiModel>) {
this.listData.clear()
this.listData.addAll(listData)
notifyDataSetChanged()
}
- 更新
onBindViewHolder(CatViewHolder)以符合适配器合同更改:
override fun onBindViewHolder(holder: ListItemViewHolder, position: Int) {
holder.bindData(listData[position])
}
- 在文件顶部,在导入之后和类定义之前,添加视图类型常量:
private const val VIEW_TYPE_TITLE = 0
private const val VIEW_TYPE_CAT = 1
- 实现
getItemViewType(Int)如下:
override fun getItemViewType(position: Int) = when (listData[position]) {
is ListItemUiModel.Title -> VIEW_TYPE_TITLE
is ListItemUiModel.Cat -> VIEW_TYPE_CAT
}
- 最后,更改您的
onCreateViewHolder(ViewGroup, Int)实现如下:
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
VIEW_TYPE_TITLE -> {
val view = layoutInflater.inflate(R.layout.item_title, parent, false)
TitleViewHolder(view)
}
VIEW_TYPE_CAT -> {
val view = layoutInflater.inflate(R.layout.item_cat, parent, false)
CatViewHolder(
view,
imageLoader,
object : CatViewHolder.OnClickListener {
override fun onClick(catData: CatUiModel) =
onClickListener.onItemClick(catData)
})
}
else -> throw IllegalArgumentException("Unknown view type requested: $viewType")
}
- 更新
MainActivity以使用适当的数据填充适配器,替换先前的catsAdapter.setData(List<CatUiModel>)调用。(请注意,以下代码已经被截断以节省空间。请参考下面的链接以访问您需要添加的完整代码。)
MainActivity.kt
32 listItemsAdapter.setData(
33 listOf(
34 ListItemUiModel.Title("Sleeper Agents"),
35 ListItemUiModel.Cat(
36 CatUiModel(
37 Gender.Male,
38 CatBreed.ExoticShorthair,
39 "Garvey",
40 "Garvey is as a lazy, fat, and cynical orange cat.",
41 "https://cdn2.thecatapi.com/images/FZpeiLi4n.jpg"
42 )
43 ),
44 ListItemUiModel.Cat(
45 CatUiModel(
46 Gender.Unknown,
47 CatBreed.AmericanCurl,
48 "Curious George",
49 "Award winning investigator",
50 "https://cdn2.thecatapi.com/images/vJB8rwfdX.jpg"
51 )
52 ),
53 ListItemUiModel.Title("Active Agents"),
The complete code for this step can be found at http://packt.live/3icCrSt.
-
由于
catsAdapter不再持有CatsAdapter而是ListItemsAdapter,因此相应地进行重命名。将其命名为listItemsAdapter。 -
运行应用程序。您应该看到类似以下的内容:
图 6.12:带有休眠代理/活动代理标题视图的 RecyclerView
如您所见,我们现在在两个代理组上方有标题。与Our Agents标题不同,这些标题将随内容滚动。接下来,我们将学习如何滑动项目以将其从RecyclerView中移除。
滑动以删除项目
在之前的部分中,我们学习了如何呈现不同的视图类型。但是,直到现在,我们一直在使用固定的项目列表。如果您想要能够从列表中删除项目怎么办?有一些常见的机制可以实现这一点-固定的删除按钮,滑动删除,长按选择然后点击删除按钮等。在本节中,我们将专注于“滑动删除”方法。
让我们首先向我们的适配器添加删除功能。要告诉适配器删除一个项目,我们需要指示要删除的项目。实现这一点的最简单方法是提供项目的位置。在我们的实现中,这将直接对应于listData列表中项目的位置。因此,我们的removeItem(Int)函数应该如下所示:
fun removeItem(position: Int) {
listData.removeAt(position)
notifyItemRemoved(position)
}
注意
就像设置数据时一样,我们需要通知RecyclerView数据集已更改-在这种情况下,已删除一个项目。
接下来,我们需要定义滑动手势检测。这是通过利用ItemTouchHelper来完成的。现在,ItemTouchHelper通过回调向我们报告某些触摸事件,即拖动和滑动。我们通过实现ItemTouchHelper.Callback来处理这些回调。此外,RecyclerView提供了ItemTouchHelper.SimpleCallback,它消除了大量样板代码的编写。
我们希望响应滑动手势,但忽略移动手势。更具体地说,我们希望响应向右滑动。移动用于重新排序项目,这超出了本章的范围。因此,我们的SwipToDeleteCallback的实现将如下所示:
inner class SwipeToDeleteCallback :
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) = if (viewHolder is CatViewHolder) {
makeMovementFlags(
ItemTouchHelper.ACTION_STATE_IDLE,
ItemTouchHelper.RIGHT
)or makeMovementFlags(
ItemTouchHelper.ACTION_STATE_SWIPE,
ItemTouchHelper.RIGHT
)
} else {
0
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder,
direction: Int) {
val position = viewHolder.adapterPosition
removeItem(position)
}
}
由于我们的实现与我们的适配器及其视图类型紧密耦合,因此我们可以将其舒适地定义为内部类。我们获得的好处是能够直接在适配器上调用方法。
正如您所看到的,我们从onMove(RecyclerView, ViewHolder, ViewHolder)函数中返回false。这意味着我们忽略移动事件。
接下来,我们需要告诉ItemTouchHelper哪些项目可以被滑动。我们通过重写getMovementFlags(RecyclerView, ViewHolder)来实现这一点。当用户即将开始拖动或滑动手势时,将调用此函数。ItemTouchHelper希望我们返回所提供的视图持有者的有效手势。我们检查ViewHolder类,如果是CatViewHolder,我们希望允许滑动,否则不允许。我们使用makeMovementFlags(Int, Int),这是一个帮助函数,用于以ItemTouchHelper可以解析的方式构造标志。请注意,我们为ACTION_STATE_IDLE定义了规则,这是手势的起始状态,因此允许手势从左侧或右侧开始。然后我们将其与ACTION_STATE_SWIPE标志结合起来(使用or),允许进行中的手势向左或向右滑动。返回0意味着对于所提供的视图持有者,既不会发生滑动也不会移动。
一旦滑动操作完成,将调用onSwiped(ViewHolder, Int)。然后,我们通过调用adapterPosition从传入的视图持有者中获取位置。现在,adapterPosition很重要,因为这是获取视图持有者呈现的项目的真实位置的唯一可靠方法。
有了正确的位置,我们可以通过在适配器上调用removeItem(Int)来移除项目。
为了公开我们新创建的SwipeToDeleteCallback实现,我们在适配器中定义一个只读变量,即swipeToDeleteCallback,并将其设置为SwipeToDeleteCallback的新实例。
最后,为了将我们的callback机制插入RecyclerView,我们需要构造一个新的ItemTouchHelper并将其附加到我们的RecyclerView上。我们应该在设置我们的RecyclerView时执行此操作,我们在主活动的onCreate(Bundle?)函数中执行此操作。这是创建和附加的方式:
val itemTouchHelper = ItemTouchHelper(listItemsAdapter.swipeToDeleteCallback)
itemTouchHelper.attachToRecyclerView(recyclerView)
现在我们可以滑动项目以将其从列表中移除。请注意,我们的标题无法被滑动,这正是我们想要的。
您可能已经注意到一个小故障:在动画向上播放时,最后一个项目被切断了。这是因为RecyclerView在动画开始之前会缩小以适应新的(较小)项目数量。快速修复这个问题的方法是通过将其底部限制在其父级的底部来固定我们的RecyclerView的高度。
练习 6.05:添加滑动删除功能
我们之前向我们的应用程序添加了RecyclerView,然后向其中添加了不同类型的项目。现在,我们将允许用户通过向左或向右滑动来删除一些项目(我们希望让用户删除秘密猫特工,但不是标题):
- 要向我们的适配器添加项目移除功能,请在
setData(List<ListItemUiModel>)函数之后添加以下函数到ListItemsAdapter中:
fun removeItem(position: Int) {
listData.removeAt(position)
notifyItemRemoved(position)
}
- 接下来,在您的
ListItemsAdapter类的闭合大括号之前,添加以下callback实现,以处理用户向左或向右滑动猫特工的操作:
inner class SwipeToDeleteCallback :
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) = if (viewHolder is CatViewHolder) {
makeMovementFlags(
ItemTouchHelper.ACTION_STATE_IDLE,
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
) or makeMovementFlags(
ItemTouchHelper.ACTION_STATE_SWIPE,
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
)
} else {
0
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
removeItem(position)
}
}
我们实现了一个ItemTouchHelper.SimpleCallback实例,传入我们感兴趣的方向——LEFT和RIGHT。通过使用or布尔运算符来连接这些值。
我们已经重写了getMovementFlags函数,以确保我们只处理猫代理视图上的滑动,而不是标题。为ItemTouchHelper.ACTION_STATE_SWIPE和ItemTouchHelper.ACTION_STATE_IDLE分别创建标志,允许我们拦截滑动和释放事件。
一旦滑动完成(用户从屏幕上抬起手指),onSwiped将被调用,作为响应,我们将删除拖动视图持有者提供的位置处的项目。
- 在你的适配器顶部,暴露刚刚创建的
SwipeToDeleteCallback类的一个实例:
class ListItemsAdapter(
...
) : RecyclerView.Adapter<ListItemViewHolder>() {
val swipeToDeleteCallback = SwipeToDeleteCallback()
- 最后,通过实现
ItemViewHelper并将其附加到我们的RecyclerView来将所有内容绑定在一起。在为适配器分配布局管理器之后,将以下代码添加到MainActivity文件的onCreate(Bundle?)函数中:
recyclerView.layoutManager = ...
val itemTouchHelper = ItemTouchHelper(listItemsAdapter .swipeToDeleteCallback)
itemTouchHelper.attachToRecyclerView(recyclerView)
- 为了解决当项目被移除时会出现的小视觉故障,通过更新
activity_main.xml中的代码来缩放RecyclerView以适应屏幕。更改在RecyclerView标签中,在app:layout_constraintTop_toBottomOf属性之前:
android:layout_height="0dp. The latter change tells our app to calculate the height of RecyclerView based on its constraints:Figure 6.13: RecyclerView taking the full height of the layout
- 运行你的应用。现在你应该能够向左或向右滑动秘密猫代理,将它们从列表中移除。请注意,
RecyclerView会为我们处理折叠动画:
图 6.14:一只猫被向右滑动
请注意,即使标题是项目视图,它们也不能被滑动。您已经实现了一个用于滑动手势的回调,它区分不同的项目类型,并通过删除被滑动的项目来响应滑动。现在我们知道如何交互地移除项目。接下来,我们将学习如何添加新项目。
交互式添加项目
我们刚刚学会了如何交互地移除项目。那么添加新项目呢?让我们来看看。
与我们实现移除项目的方式类似,我们首先向适配器添加一个函数:
fun addItem(position: Int, item: ListItemUiModel) {
listData.add(position, item)
notifyItemInserted(position)
}
您会注意到,这个实现与我们之前实现的removeItem(Int)函数非常相似。这一次,我们还收到要添加的项目和要添加的位置。然后我们将它添加到我们的listData列表中,并通知RecyclerView我们在请求的位置添加了一个项目。
要触发对addItem(Int, ListItemUiModel)的调用,我们可以在我们的主活动布局中添加一个按钮。这个按钮可以是这样的:
<Button
android:id="@+id/main_add_item_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add A Cat"
app:layout_constraintBottom_toBottomOf="parent" />
应用现在看起来是这样的:
图 6.15:主布局,带有一个添加猫的按钮
不要忘记更新您的RecyclerView,以便其底部将受到此按钮顶部的约束。否则,按钮和RecyclerView将重叠。
在生产应用中,您可以添加关于新项目的理由。例如,您可以为用户填写不同的细节提供一个表单。为了简单起见,在我们的示例中,我们将始终添加相同的虚拟项目——一个匿名的女性秘密猫代理。
要添加项目,我们在我们的按钮上设置OnClickListener:
addItemButton.setOnClickListener {
listItemsAdapter.addItem(
1,
ListItemUiModel.Cat(
CatUiModel(
Gender.Female,
CatBreed.BalineseJavanese,
"Anonymous",
"Unknown",
"https://cdn2.thecatapi.com/images/zJkeHza2K.jpg"
)
)
)
}
就是这样。我们在位置 1 添加项目,这样它就会添加在我们的第一个标题下面,也就是位置 0 的项目。在生产应用中,您可以有逻辑来确定插入项目的正确位置。它可以在相关标题下方,或者始终添加在顶部、底部,或者在正确的位置以保留一些现有的顺序。
现在我们可以运行应用程序。现在我们将有一个新的“添加猫”按钮。每次点击按钮时,一个匿名的秘密猫代理将被添加到RecyclerView中。新添加的猫可以被滑动移除,就像它们之前的硬编码猫一样。
练习 6.06:实现一个“添加猫”按钮
在实现了删除项目的机制之后,现在是时候实现添加项目的机制了:
- 向
ListItemsAdapter添加一个支持添加项目的函数。将其添加到removeItem(Int)函数下面:
fun addItem(position: Int, item: ListItemUiModel) {
listData.add(position, item)
notifyItemInserted(position)
}
- 在
activity_main.xml中添加一个按钮,就在RecyclerView标签后面:
<Button
android:id="@+id/main_add_item_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add A Cat"
app:layout_constraintBottom_toBottomOf="parent" />
-
您会注意到
android:text="Add A Cat"被突出显示。如果您将鼠标悬停在上面,您会发现这是因为硬编码的字符串。点击Add单词将编辑光标放在上面。 -
按Option + Enter(iOS)或Alt + Enter(Windows)显示上下文菜单,然后再次按Enter显示“提取资源”对话框。
-
将资源命名为
add_button_label。按下“确定”。 -
更改
RecyclerView上的底部约束,以便按钮和RecyclerView不重叠,在RecyclerView标签内部,找到以下内容:
app:layout_constraintBottom_toBottomOf="parent"
用以下代码行替换它:
app:layout_constraintBottom_toTopOf="@+id/main_add_item_button"
- 在类的顶部添加一个引用按钮的惰性字段,就在
recyclerView的定义之后:
private val addItemButton: View
by lazy { findViewById(R.id.main_add_item_button) }
注意addItemButton被定义为一个 View。这是因为在我们的代码中,我们不需要知道 View 的类型来为其添加点击监听器。选择更抽象的类型允许我们以后更改布局中视图的类型,而无需修改此代码。
- 最后,更新
MainActivity以处理点击。找到以下内容的行:
itemTouchHelper.attachToRecyclerView(recyclerView)
在此之后,添加以下内容:
addItemButton.setOnClickListener {
listItemsAdapter.addItem(
1,
ListItemUiModel.Cat(
CatUiModel(
Gender.Female,
CatBreed.BalineseJavanese,
"Anonymous",
"Unknown",
"https://cdn2.thecatapi.com/images/zJkeHza2K.jpg"
)
)
)
这将在每次点击按钮时向RecyclerView添加一个新项目。
- 运行应用程序。您应该在应用程序底部看到一个新按钮:
图 6.16:点击按钮添加一个匿名猫
- 尝试点击几次。每次点击时,都会向您的
RecyclerView添加一个新的匿名秘密猫特工。您可以像删除硬编码的猫一样滑动删除新添加的猫。
在这个练习中,您通过用户交互向RecyclerView添加了新项目。您现在知道如何在运行时更改RecyclerView的内容。了解如何在运行时更新列表很有用,因为在应用程序运行时,您向用户呈现的数据经常会发生变化,您希望向用户呈现一个新鲜、最新的状态。
活动 6.01:管理项目列表
想象一下,您想开发一个食谱管理应用程序。您的应用程序将支持甜食和咸食食谱。您的应用程序的用户可以添加新的甜食或咸食食谱,浏览已添加的食谱列表(按口味分组为甜食或咸食),点击食谱以获取有关它的信息,最后,他们可以通过滑动将食谱删除。
这个活动的目的是创建一个带有RecyclerView的应用程序,列出食谱的标题,按口味分组。RecyclerView将支持用户交互。每个食谱都将有一个标题、一个描述和一个口味。交互将包括点击和滑动。点击将向用户显示一个对话框,显示食谱的描述。滑动将从应用程序中删除已滑动的食谱。最后,通过两个EditText字段(参见第三章,屏幕和 UI)和两个按钮,用户可以分别添加新的甜食或咸食食谱,标题和描述设置为EditText字段中设置的值。
完成的步骤如下:
-
创建一个新的空活动应用程序。
-
在应用程序的
build.gradle文件中添加RecyclerView支持。 -
在主布局中添加
RecyclerView、两个EditText字段和两个按钮。您的布局应该看起来像这样:
图 6.17:带有 RecyclerView、两个 EditText 字段和两个按钮的布局
-
为口味标题和食谱添加模型,并为口味添加枚举。
-
添加一个口味标题的布局。
-
为食谱标题添加一个布局。
-
为口味标题和食谱标题添加视图持有者,以及一个适配器。
-
添加点击监听器以显示带有食谱描述的对话框。
-
更新
MainActivity以构建新的适配器并连接按钮,用于添加新的咸味和甜味食谱。确保在添加食谱后清除表单。 -
添加一个滑动助手来移除项目。
最终输出如下:
图 6.18:食谱书应用
注意
这个活动的解决方案可以在以下网址找到:packt.live/3sKj1cp
总结
在本章中,我们学习了如何将RecyclerView添加到我们的项目中。我们还学习了如何将其添加到我们的布局中,并如何用项目填充它。我们介绍了添加不同类型的项目,这对于标题特别有用。我们涵盖了与RecyclerView的交互:响应单个项目的点击和响应滑动手势。最后,我们学习了如何动态地向RecyclerView添加和删除项目。RecyclerView的世界非常丰富,我们只是触及了表面。进一步的探索将超出本书的范围。然而,强烈建议您自行调查,以便在应用程序中拥有旋转木马、设计分隔线和更花哨的滑动效果。您可以从这里开始您的探索:awesomeopensource.com/projects/recyclerview-adapter。
在下一章中,我们将探讨代表我们的应用程序请求特殊权限,以便执行某些任务,例如访问用户的联系人列表或其麦克风。我们还将研究如何使用谷歌的地图 API 和访问用户的物理位置。