使用Retrofit与Kotlin序列化从互联网获取数据

使用Retrofit与Kotlin序列化从互联网获取数据

1. 开始之前

大多数Android应用都会连接互联网,从后端服务器获取邮件、消息或其他信息。Gmail、YouTube和Google Photos都是通过联网展示用户数据的例子。

本实验使用开源、社区维护的库构建数据层,从后端服务器获取数据。这样可简化数据获取,也有助于遵循在后台线程执行操作等Android最佳实践。网络缓慢或不可用时,还会显示错误消息,让用户了解连接问题。

前提知识

  • 创建Composable函数的基础知识。
  • 使用Android架构组件ViewModel的基础知识。
  • 使用协程处理长时间任务的基础知识。
  • 在build.gradle.kts中添加依赖的基础知识。

学习内容

要完成的任务

  • 修改起始应用,使其发起Web服务API请求并处理响应。
  • 用Retrofit实现数据层。
  • 用kotlinx.serialization把响应解析成数据对象列表,并放入UI状态。
  • 利用Retrofit的协程支持简化代码。

所需条件

安装Android Studio的计算机,以及Mars Photos应用的起始代码。

2. 应用概览

Mars Photos应用连接Web服务,获取并显示火星地表照片。这些真实照片来自NASA的火星探测车。最终应用以网格显示照片:

最终Mars Photos应用的照片网格
最终Mars Photos应用的照片网格(原文配图)。
显示获取到的火星照片数量
显示获取到的火星照片数量(原文配图)。

3. 探索Mars Photos起始应用

下载起始代码

下载起始代码ZIP,或克隆仓库:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout starter

起始代码位于starter分支,也可在GitHub仓库浏览。

运行起始代码

  1. 在Android Studio打开下载的basic-android-kotlin-compose-training-mars-photos项目。
  2. 在Android窗格展开app > kotlin + java,可见ui包,它是应用的UI层。
项目中的ui包
项目中的ui包(原文配图)。

编译并运行应用,屏幕中央会显示占位文本;本实验结束时,将其替换成获取到的照片数量。

起始应用的占位文本
起始应用的占位文本(原文配图)。

起始代码结构

  • ui/MarsPhotosApp.kt包含MarsPhotosApp可组合函数,显示顶部应用栏与HomeScreen等内容。前面的占位文本位于其中;后续实验将展示后端数据。
  • screens/MarsViewModel.kt是对应的ViewModel,包含名为marsUiState的MutableState属性。更新该属性会更新屏幕上的占位文本。getMarsPhotos()先更新占位响应,稍后会用于显示服务器返回的数据。本实验的目标是用网络数据更新ViewModel中的MutableState。
  • screens/HomeScreen.kt包含HomeScreen与ResultScreen。后者用简单Box布局中的Text显示marsUiState。
  • MainActivity.kt只负责加载ViewModel并显示MarsPhotosApp。

4. Web服务简介

本实验创建网络服务层,与后端通信并获取所需数据。第三方库Retrofit负责这项工作;ViewModel与数据层交互,应用其余部分无需了解具体实现。

Retrofit数据层与ViewModel及其他UI类
Retrofit数据层与ViewModel及其他UI类(原文配图)。

MarsViewModel负责发起网络调用,获取火星照片数据;数据变化时,通过MutableState更新UI。后续实验会在数据层加入Repository,由它与Retrofit服务通信,并向应用其余部分公开数据。

5. Web服务与Retrofit

火星照片数据存储在Web服务器上。应用需要与互联网中的服务器建立连接并通信,才能取得这些数据。

手机客户端向服务器请求火星照片
手机客户端向服务器请求火星照片(原文配图)。
服务器向手机返回照片URL
服务器向手机返回照片URL(原文配图)。

许多Web服务采用通用、无状态的REST(Representational State Transfer,表述性状态转移)架构,称为RESTful服务。请求通过标准化的URI(统一资源标识符)发出。URI按名称标识资源,本身不一定指明资源位置或访问方式。

本课服务器为android-kotlin-fun-mars-server.appspot.com,同时服务火星房产和火星照片两个示例。URL(统一资源定位符)是URI的一个子集,指出资源所在位置及获取机制。例如:

这些URL通过HTTP协议标识可获取的网络资源。本实验使用/photos端点,即访问服务器上Web服务的URL。常见Web URL也是URI的一种,本课程会交替使用这两个称呼。

Web服务请求

请求包含URI,并使用与Chrome等浏览器相同的HTTP协议传输到服务器。HTTP操作告诉服务器要做什么:

  • GET:获取服务器数据。
  • POST:创建数据。
  • PUT:更新现有数据。
  • DELETE:删除数据。

应用用GET获取照片信息,服务器返回包含图片URL的响应。

应用向服务器发出HTTP GET请求
应用向服务器发出HTTP GET请求(原文配图)。
服务器返回包含照片URL的响应
服务器返回包含照片URL的响应(原文配图)。

响应通常采用XML或JSON等格式。JSON以键值对表示结构化数据,应用借此与REST API通信。接下来将使用已经准备好的后端,通过Retrofit建立连接、通信并接收JSON响应。

外部库

第三方库扩展了Android核心API。本课程使用的库开源、由社区开发,并由全球Android社区共同维护,帮助开发者构建更好的应用。

Retrofit库

本实验使用Retrofit与RESTful火星服务通信。原文将其作为维护良好的库的例子:可查看GitHub页面中开放和已关闭的问题、功能请求,以及开发者是否持续解决问题并回应请求。更多用法见Retrofit文档。

Retrofit生成与REST后端交互的代码;开发者仍需根据参数提供服务URI,后文会说明。

Retrofit连接客户端与后端
Retrofit连接客户端与后端(原文配图)。

添加Retrofit依赖

Android Gradle支持添加外部库;除依赖声明外,还应配置库所在仓库。打开模块级build.gradle.kts (Module :app),在dependencies中添加:

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// Retrofit with Scalar Converter
implementation("com.squareup.retrofit2:converter-scalars:2.9.0")

第一项是Retrofit2本身,第二项是标量转换器。Retrofit2是更新后的Retrofit;标量转换器可让它将JSON结果作为String返回。JSON用于客户端与服务器之间保存和传输数据,后文会进一步介绍。点击Sync Now,以新依赖重新构建项目。

6. 连接互联网

先让Retrofit获取火星Web服务的原始JSON响应,并作为String显示;占位Text会显示响应或连接错误消息。Retrofit根据服务内容创建网络API,通过转换器将响应解码成String等对象;它支持XML、JSON等常用格式,并处理在后台执行请求等细节。

Retrofit与转换器连接Web服务和ViewModel
Retrofit与转换器连接Web服务和ViewModel(原文配图)。

为项目添加ViewModel使用的数据层:创建MarsApiService数据源、指定基础URL与转换工厂的Retrofit对象、描述HTTP通信的接口,并公开服务实例。

创建服务API

  1. 右键项目窗格中的com.example.marsphotos,选择New > Package。
  2. 在建议包名末尾添加network。
  3. 在新包中新建Kotlin文件MarsApiService并打开。
  4. 添加服务基础URL常量:
private const val BASE_URL =
   "https://android-kotlin-fun-mars-server.appspot.com"

在常量下方添加Retrofit构建器:

import retrofit2.Retrofit

private val retrofit = Retrofit.Builder()

构建Web服务API需要基础URI和转换工厂。转换器决定如何处理返回数据;此处用ScalarsConverter将JSON响应作为String返回,它也支持其他基本类型。

调用addConverterFactory():

import retrofit2.converter.scalars.ScalarsConverterFactory

private val retrofit = Retrofit.Builder()
   .addConverterFactory(ScalarsConverterFactory.create())

再通过baseUrl()设置基础URL,调用build()创建对象:

private val retrofit = Retrofit.Builder()
   .addConverterFactory(ScalarsConverterFactory.create())
   .baseUrl(BASE_URL)
   .build()

在构建器之后定义MarsApiService接口,描述Retrofit如何用HTTP请求与服务器通信:

interface MarsApiService {
}

添加获取响应的getPhotos()函数:

interface MarsApiService {
    fun getPhotos()
}

用@GET标明GET请求,并指定photos端点:

import retrofit2.http.GET


interface MarsApiService {
    @GET("photos")
    fun getPhotos()
}

调用方法时,Retrofit会把photos追加到构建器中的基础URL以发起请求。将返回类型设置为String:

interface MarsApiService {
    @GET("photos")
    fun getPhotos(): String
}

对象声明

Kotlin的object声明可定义单例:只创建一个实例,并提供全局访问点。对象在首次访问时进行线程安全的初始化。object关键字后必须有名称。示例如下,无需复制:

// Example for Object declaration, do not copy over

object SampleDataProvider {
    fun register(provider: SampleProvider) {
        // ...
    }
​
    // ...
}

// To refer to the object, use its name directly.
SampleDataProvider.register(...)

Retrofit对象的create()调用在内存、速度和性能方面成本较高。此应用只需要一个API服务实例,因此暂用object声明向其他部分公开服务。

在接口外定义公共对象MarsApi:

object MarsApi {}

添加类型为MarsApiService、名为retrofitService的惰性初始化属性;暂时忽略错误,下一步解决:

object MarsApi {
    val retrofitService : MarsApiService by lazy {}
}

惰性初始化把对象创建延迟到实际使用时,避免不必要的计算与资源占用。Kotlin对此提供一等支持。

调用retrofit.create()并传入接口,初始化该属性:

object MarsApi {
    val retrofitService : MarsApiService by lazy {
       retrofit.create(MarsApiService::class.java)
    }
}

设置完成。每次调用MarsApi.retrofitService都会访问同一个实现接口的单例服务对象,它在首次访问时创建。

在MarsViewModel中调用Web服务

接下来实现getMarsPhotos(),调用REST服务并处理JSON字符串。推荐从Repository调用服务,将数据层与其他部分隔离;后续实验会加入Repository。

viewModelScope

viewModelScope是每个ViewModel内置的协程作用域。ViewModel被清理时,作用域内启动的协程自动取消。可以在此启动协程并发起网络请求;作用域属于ViewModel,因此配置变更时请求仍可继续。

在MarsApiService.kt中将getPhotos()改为挂起函数,以异步调用而不阻塞调用线程:

@GET("photos")
suspend fun getPhotos(): String

打开ui/screens/MarsViewModel.kt,删除getMarsPhotos()中设置"Set the Mars API Response here!"的语句:

private fun getMarsPhotos() {}

通过viewModelScope.launch启动协程:

import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch


private fun getMarsPhotos() {
    viewModelScope.launch {}
}

在其中调用单例服务的getPhotos(),将结果保存为listResult:

import com.example.marsphotos.network.MarsApi

viewModelScope.launch {
    val listResult = MarsApi.retrofitService.getPhotos()
}

把结果赋给表示最近一次请求状态的可变状态对象marsUiState:

val listResult = MarsApi.retrofitService.getPhotos()
marsUiState = listResult

此时运行应用,它可能立即关闭,并可能弹出错误提示,这就是崩溃。查看Android Studio的Logcat,日志以类似下列内容开头:

    --------- beginning of crash
22803-22865/com.example.android.marsphotos E/AndroidRuntime: FATAL EXCEPTION: OkHttp Dispatcher
    Process: com.example.android.marsphotos, PID: 22803
    java.lang.SecurityException: Permission denied (missing INTERNET permission?)
...

错误提示缺少INTERNET权限。下一步添加权限解决此问题。

7. 添加互联网权限与异常处理

Android权限

权限用于保护用户隐私。应用访问联系人、通话记录等敏感数据,或相机、互联网等系统功能时,需要声明或请求权限。

互联网连接涉及安全问题,应用默认不能联网;必须显式声明INTERNET,它属于普通权限。更多类型见Android权限文档。

在manifests/AndroidManifest.xml的<application>之前添加:

<uses-permission android:name="android.permission.INTERNET" />

重新编译运行。网络正常时会看到照片数据JSON,每条记录都有id和img_src,后文介绍其格式。

应用显示原始JSON响应
应用显示原始JSON响应(原文配图)。

点击设备或模拟器的返回按钮关闭应用。

异常处理

代码仍有一个问题。将设备或模拟器设为飞行模式,从最近任务重新打开应用,或从Android Studio运行,再检查Logcat中的致命异常:

3302-3302/com.example.android.marsphotos E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.example.android.marsphotos, PID: 3302

该错误表明应用尝试连接并超时。这类异常很常见;与权限问题不同,无法直接修复外部连接,但可以处理异常。

异常是什么

异常在运行时而非编译时发生。未处理的异常可能让应用突然终止,造成糟糕体验。异常处理可阻止这种终止,并友好地应对问题。原因可能简单到除以零,也可能是网络错误,与之前实验中的IllegalArgumentException类似。

连接服务器的潜在问题包括:API中的URL或URI错误、服务器不可用、网络延迟、设备连接较差或没有网络。可用try-catch在运行时处理,更多信息见Kotlin异常文档。

基本语法:

try {
    // some code that can cause an exception.
}
catch (e: SomeException) {
    // handle the exception to avoid abrupt termination.
}

将可能出错的网络调用放在try块中,在catch中防止应用突然终止,并执行恢复逻辑。在getMarsPhotos()的launch块中,用try包围MarsApi调用,再添加catch:

import java.io.IOException


viewModelScope.launch {
   try {
       val listResult = MarsApi.retrofitService.getPhotos()
       marsUiState = listResult
   } catch (e: IOException) {

   }
}

再次运行应用,此时不会因该异常崩溃。

添加UI状态

目前marsUiState只保存最近请求的状态值,不能区分加载、成功和失败。加载表示等待数据,成功表示已获取数据,错误表示网络或连接失败。使用密封接口限制可能的值,更容易管理状态。以下仅为概念示例,无需复制:

// No need to copy over
sealed interface MarsUiState {
   data class Success : MarsUiState
   data class Loading : MarsUiState
   data class Error : MarsUiState
}

成功响应需要保存照片信息,因此给Success数据类添加构造参数。Loading和Error无需新数据或每次创建新对象,将其改为object。

在MarsViewModel文件的import之后添加密封接口:

sealed interface MarsUiState {
    data class Success(val photos: String) : MarsUiState
    object Error : MarsUiState
    object Loading : MarsUiState
}

将ViewModel中的状态类型改为MarsUiState,默认值设为Loading,并将setter设为私有以保护写入:

var marsUiState: MarsUiState by mutableStateOf(MarsUiState.Loading)
  private set

获取成功后,传入listResult:

val listResult = MarsApi.retrofitService.getPhotos()
marsUiState = MarsUiState.Success(listResult)

在catch中设置错误状态:

catch (e: IOException) {
   marsUiState = MarsUiState.Error
}

可以把赋值移到try-catch外,完整函数为:

private fun getMarsPhotos() {
   viewModelScope.launch {
       marsUiState = try {
           val listResult = MarsApi.retrofitService.getPhotos()
           MarsUiState.Success(listResult)
       } catch (e: IOException) {
           MarsUiState.Error
       }
   }
}

在screens/HomeScreen.kt中对状态使用when;成功时调用ResultScreen并传入photos,暂时忽略缺少其他分支的错误:

import androidx.compose.foundation.layout.fillMaxWidth

fun HomeScreen(
   marsUiState: MarsUiState,
   modifier: Modifier = Modifier
) {
    when (marsUiState) {
        is MarsUiState.Success -> ResultScreen(
            marsUiState.photos, modifier = modifier.fillMaxWidth()
        )
    }
}

状态已不再是String,而是具有Loading、Success、Error三种取值的密封接口。添加另外两个分支,分别显示后续实现的LoadingScreen和ErrorScreen:

import androidx.compose.foundation.layout.fillMaxSize

fun HomeScreen(
   marsUiState: MarsUiState,
   modifier: Modifier = Modifier
) {
    when (marsUiState) {
        is MarsUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
        is MarsUiState.Success -> ResultScreen(
            marsUiState.photos, modifier = modifier.fillMaxWidth()
        )

        is MarsUiState.Error -> ErrorScreen( modifier = modifier.fillMaxSize())
    }
}

打开res/drawable/loading_animation.xml。该资源描述让loading_img.xml绕中心旋转的动画,预览中看不到动画。

加载图像资源
加载图像资源(原文配图)。

在HomeScreen下方加入LoadingScreen;起始代码已包含loading_img:

import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.Image


@Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
    Image(
        modifier = modifier.size(200.dp),
        painter = painterResource(R.drawable.loading_img),
        contentDescription = stringResource(R.string.loading)
    )
}

再加入显示错误信息的ErrorScreen:

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding

@Composable
fun ErrorScreen(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_connection_error), contentDescription = ""
        )
        Text(text = stringResource(R.string.loading_failed), modifier = Modifier.padding(16.dp))
    }
}

保持飞行模式运行,应用不会再突然关闭,而会显示错误消息:

网络连接失败界面
网络连接失败界面(原文配图)。

关闭飞行模式,再运行并测试,确认能看到JSON字符串:

恢复联网后的应用状态
恢复联网后的应用状态(原文配图)。
恢复联网后显示的JSON
恢复联网后显示的JSON(原文配图)。

8. 用kotlinx.serialization解析JSON响应

JSON

服务通常以XML或JSON返回结构化数据,应用必须知道其结构才能读取。本例访问照片端点,浏览器中会显示JSON格式的照片ID与图片URL列表。

示例JSON结构

JSON数组及键值对结构
JSON数组及键值对结构(原文配图)。
  • 响应是由方括号包围的数组,包含JSON对象。
  • 对象由花括号包围。
  • 各对象包含一组以逗号分隔的键值对。
  • 键和值之间用冒号分隔。
  • 名称用引号包围。
  • 值可以是数字、字符串、布尔值、数组、对象或null。

例如img_src是URL字符串,粘贴到浏览器可看到火星地表照片:

浏览器打开火星照片URL
浏览器打开火星照片URL(原文配图)。

现在应用已能收到JSON,但显示图片需要Kotlin对象,而非一大段JSON字符串。将外部数据读取为运行时对象叫反序列化;序列化则把应用数据转成可经网络传输的格式。两者都是网络数据交换的重要环节。

kotlinx.serialization提供把JSON字符串转换为Kotlin对象的库,并有社区开发的Retrofit Kotlin Serialization Converter与其配合。接下来把响应解析为表示火星照片的对象,显示照片数量。

添加依赖

打开模块级build.gradle.kts,在plugins中加入:

id("org.jetbrains.kotlin.plugin.serialization") version "1.8.10"

在dependencies中加入Kotlin JSON序列化依赖:

// Kotlin serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")

找到原标量转换器依赖:

// Retrofit with scalar Converter
implementation("com.squareup.retrofit2:converter-scalars:2.9.0")

替换为:

// Retrofit with Kotlin serialization Converter

implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.11.0")

点击Sync Now。删除标量依赖后可能出现编译错误,下一步处理。

实现MarsPhoto数据类

服务响应中的一个条目类似:

[
    {
        "id":"424906",
        "img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000ML0044631300305227E03_DXXX.jpg"
    },
...]

id用引号包围,因此是String而非整数;img_src也是String,保存图片URL。kotlinx.serialization需要一个Kotlin数据类保存解析结果。

右键network包,选择New > Kotlin File/Class,选择Class并命名MarsPhoto。在类定义前添加data,将花括号改为圆括号:

data class MarsPhoto()

数据类必须至少有一个属性,因此先会报错。添加以下属性:

data class MarsPhoto(
    val id: String,  val img_src: String
)

再加上Serializable注解:

import kotlinx.serialization.Serializable

@Serializable
data class MarsPhoto(
    val id: String,  val img_src: String
)

每个变量对应JSON中的一个键,按本例响应均使用String。解析时,库按键名匹配并填充对象。

@SerialName注解

JSON键名不一定符合Kotlin编码风格。例如img_src使用下划线,而Kotlin属性通常采用驼峰式。通过SerialName可使用不同的属性名,同时映射到原JSON键。替换对应属性:

import kotlinx.serialization.SerialName

@SerialName(value = "img_src")
val imgSrc: String

更新MarsApiService和MarsViewModel

现在用kotlinx.serialization转换器把JSON转换为Kotlin对象。打开network/MarsApiService.kt,因前面修改依赖,ScalarsConverterFactory引用无法解析。删除:

import retrofit2.converter.scalars.ScalarsConverterFactory

修改Retrofit构建器,使用序列化转换器:

import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType

private val retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(BASE_URL)
        .build()
原文中的序列化API警告
原文中的序列化API警告(原文配图)。

现在可让Retrofit把JSON数组转换为MarsPhoto列表,而非JSON字符串。修改接口:

interface MarsApiService {
    @GET("photos")
    suspend fun getPhotos(): List<MarsPhoto>
}

打开MarsViewModel中的getMarsPhotos。此时listResult是List<MarsPhoto>,其size就是收到并解析的照片数量。修改状态:

val listResult = MarsApi.retrofitService.getPhotos()
marsUiState = MarsUiState.Success(
   "Success: ${listResult.size} Mars photos retrieved"
)

关闭设备或模拟器的飞行模式,编译并运行。此时显示照片数量,不再显示整段JSON:

应用显示获取到的照片数量
应用显示获取到的照片数量(原文配图)。

若无法联网,先确认飞行模式已关闭。

9. 完成代码

可使用以下命令获取完成代码:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout repo-starter

也可以下载ZIP,解压后在Android Studio打开。完成代码位于repo-starter分支,可在GitHub浏览。

10. 小结

REST Web服务

  • Web服务通过互联网提供功能,让应用发出请求并获取数据。
  • RESTful服务采用REST架构,建立在标准Web组件与协议之上。
  • 请求通过URI标准化地发出。
  • 应用需要建立网络连接、与服务通信,再接收响应并解析成可用格式。
  • Retrofit是让应用请求REST服务的客户端库。
  • 转换器决定如何处理发送和接收的数据,例如ScalarsConverter将数据视为String或其他基本类型。
  • 联网需在清单声明android.permission.INTERNET。
  • 惰性初始化将对象创建推迟到首次实际使用,之后复用该对象。

JSON解析

  • 服务响应常用JSON表示结构化数据。
  • JSON对象是键值对集合;对象集合可组成JSON数组,本实验响应即为数组。
  • 键用引号包围,值可为数值或字符串等。
  • Kotlin的序列化工具位于独立的kotlinx.serialization组件,可把JSON转为Kotlin对象。
  • 社区提供与Retrofit配合的Kotlin序列化转换器。
  • 解析时,JSON键与同名对象属性匹配。
  • 需要使用不同属性名时,用@SerialName指定对应JSON键。

11. 延伸阅读

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容