JetPack知识点实战系列三:使用 Coroutines, Retrofit, Moshi实现网络数据请求

本节教程我们将使用Retrofit网络请求库实现网易云音乐的推荐歌单的数据请求。请求的过程中我们将使用Coroutines实现异步操作,并且利用Moshi进行网络数据的解析。

我们的接口来自于开源库NeteaseCloudMusicApi,这个NodeJS API 库的文档非常完善,并且支持的接口非常多。这个库的安装请详阅该项目的参考文档

网易音乐API

kotlin - Coroutine 协程

协程是kotlin的一个异步处理框架,是轻量级的线程。

协程的几大优势:

  1. 可以用写同步的代码结构样式实现异步的功能
  2. 非常容易将代码逻辑分发到不同的线程中
  3. 和作用域绑定,避免内存泄露。可以无缝衔接LifeCycle和ViewModel等JetPack库
  4. 减少模板代码和避免了地狱回调

接下来我将详细介绍下协程的概念和使用方法。

启动协程

启动协程使用最多的方式(主要)有launchasync

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job
public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T> 

返回值 Job

Deferred其实是Job的子类,所以这两个启动方法的返回值都是Job,那Job有什么特性呢?

  • Job 代表一个异步的任务
  • Job 具有生命周期并且可以取消。
  • Job 还可以有层级关系,一个Job可以包含多个子Job,当父Job被取消后,所有的子Job也会被自动取消;当子Job出现异常后父Job也会被取消。

Deferred有一个await方法就能取到协程的返回值,这是和Job的重要区别:

launch启动的协程的结果没有返回值,async启动的协程会返回值.这就是Kotlin为什么设计有两个启动方法的原因了。

public interface Deferred<out T> : Job {
    public suspend fun await(): T
}

总结:launch 更多是用来发起一个无需结果的耗时任务(如批量文件删除、混合图片等),async用于异步执行耗时任务,并且需要返回值(如网络请求、数据库读写、文件读写)。

调用对象 CoroutineScope

启动协程需要在一定的协程作用域CoroutineScope下启动。

public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())

通过CoroutineScope的构造方法我们得知:

  1. 构造的时候需要Job,如果没有传入就会在内部新建一个Job做为这个协程的父Job来管理该协程的所有任务Job
  2. 这儿的CoroutineContext我们可以简单的等于CoroutineDispatcher。这个稍后介绍。

协程作用域可以通过以下方式获得:

  1. Global Scope --- 和APP的生命周期一致
  2. LiveDataScope, ViewModelScope, lifecycleScope 等 --- 和这些类的生命周期一致 (涉及到的内容后面的教程会有解释)
  3. 自定义 Scope --- 自己定义Scope,生命周期和定义相关。

协程作用域CoroutineScope的主要作用是规定了协程的执行范围,超过这个作用域范围协程将会被自动取消。

这就是前面提到的协程会和作用域绑定,避免内存泄露。

协程向下文环境 CoroutineContext

上下文环境主要是传如下Dispatchers的值,Dispatchers根据名字可以猜测它是分发器,把异步任务分发到对应的线程去执行。主要的值有以下:

  • Dispatchers.Main --- 分发任务到主线程,主要执行UI绘制等。
  • DefaultScheduler.IO --- 分发任务IO线程,它用于输入/输出的场景。主要用来执行网络请求、数据库操作、文件读写等。
  • DefaultScheduler.Default --- 主要执行CPU密集的运算操作
  • DefaultScheduler.Unconfined --- 这个分发的线程不可控的,一般不建议使用。

阶段总结

刚才我们介绍了协程launch函数的context参数,接下来看看其他两个参数:

  • start参数的意思是什么时候开始分发任务,CoroutineStart.DEFAULT代表的是协程启动的时候立即分发任务。
  • block参数的意思启动的协程需要执行的任务代码。以不写内容,直接传空{} 执行。明显这样启动的协程没有意义,暂时仅为学习。

学习到到目前为止,我们应该可以启动一个协程了

// 1 
private val myJob = Job()
// 2  
private val myScope = CoroutineScope(myJob + Dispatchers.Main)
// 3 
myScope.launch() {
    // 4 TODO
}

总结如下:

  1. 创建一个父Job,作为协程的父Job
  2. 使用 myJobDispatchers.Main 这个协程向下文环境创建一个myScope协程作用域
  3. myScope这个协程作用域下启动协程
  4. 执行异步任务

协程中的异步操作 --- suspend函数

suspend函数的流程

实现异步操作的核心关键就是挂起函数suspend函数,那究竟什么是挂起函数。

挂起函数的申明是在普通的函数前面加上suspend关键字,挂起函数执行的时候会中断协程,当挂起函数执行完成后,会把结果返回到当前协程的中,然后执行接下来的代码。

上面这段话说起来很枯燥,我们接下来利用代码来解释:

suspend fun login(username: String, password: String): User = withContext(Dispatchers.IO) {
    println("threadname = ${Thread.currentThread().name}")
    return@withContext User("Johnny")
}

myScope.launch() {
    println("threadname = ${Thread.currentThread().name}")
    val user = login("1111", "111111")
    println("threadname = ${Thread.currentThread().name}")
    println("$user")
}
  • 挂起函数执行的时候会中断协程: suspend函数login("1111", "111111")执行的时候到会切换新的线程即IO线程去执行,当前的协程所在的主线程的流程被挂起中止了,主线程可以接着处理其他的事情。
  • 当挂起函数执行完成后,会把结果返回到当前协程中: login("1111", "111111")在IO线程执行完成后返回user,并且返回到主线程。即协程所在的线程。
  • 然后执行接下来的代码: 接下来打印println("$user")是在协程所在的主线程执行。

结果如下所示:

结果

withContext 函数

我们在上面的login函数中使用了withContext函数,这个函数是非常实用和常见的suspend函数。 使用它能非常容易的实现线程的切换,从而实现异步操作。

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T

我们看到withContext函数也是个挂起函数,那我们就没有必要在挂起函数中调用挂起函数,可以直接调用withContext的简写:

myScope.launch() {
    println("threadname = ${Thread.currentThread().name}")
    val user = withContext(Dispatchers.IO) {
        println("threadname = ${Thread.currentThread().name}")
        return@withContext User("Johnny")
    }
    println("threadname = ${Thread.currentThread().name}")
    println("$user")
}

协程中的异常处理机制

协程提供了一个异常处理的回调函数CoroutineExceptionHandler。可以构造一个函数对象,赋值给协程作用域,这样协程中的异常就能被捕获了。

private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    Log.i("错误信息", "${throwable.message}")
}

private val myScope = CoroutineScope(myJob + Dispatchers.Main + exceptionHandler)

提示:这里的 + 号不是数学意义的加号,是把这些对象一起组合成一个协程向下文环境(键值对)。

协程总结

  • 协程作用域可以界定生命周期,避免内存泄露
  • suspend函数可以让我们写同步代码的结构去实现异步功能
  • withContext等函数能非常容易将代码模块分发的不同的线程中去。
  • 协程还有良好的异常处理机制,

用协程和Retrofit实现网络请求

Retrofit是负责网络请求接口的封装,通过大量的注解实现超级解耦。真正的网络请求是OKHttp库去实现。Retrofit常规使用方法不是本教程的讲解范围,本教程主要讲Retrofit怎样和协程无缝衔接实现网络请求。

Moshi是一个JSON解析库,天生对Kotlin友好,特别是Kotlin的data数据类非常适合它。所以建议选择它来解析JSON。

本地服务器环境搭建后好,访问http://localhost:3000/top/playlist/hot?limit=1&offset=0就能得到一系列的播单playlists

播单接口

让我们接下来写代码吧。

  • AndroidManifest.xml中加入网络请求权限
<uses-permission android:name="android.permission.INTERNET"/>
  • 新建network_security_config.xml文件配置,内容如下
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>

  • 然后在AndroidManifest.xml中配置,这样APP就能通过HTTP协议访问服务器了
<application ...
android:networkSecurityConfig="@xml/network_security_config"
...>
</application>
  • 添加依赖
def coroutines_version = '1.3.9'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"

// Api - Retrofit (with Moshi) and OkHttp
def retrofit_version = '2.7.1'
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-moshi:$retrofit_version"
def  okhttp_version = '4.2.1'
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
  • 新建请求常量类MusicApiConstant
object MusicApiConstant {
    const val BASE_URL = "http://10.0.2.2:3000" // BASEURL
    const val PLAYLIST_HOT = "/top/playlist"    // 推荐歌单
}

注意:我现在用的模拟器开发测试,10.0.2.2代表的是模拟器所在机器的localhost地址,如果请求localhost访问的是模拟器的地址。

MusicApiConstant主要存放BASE_URL,各个请求的路径等常量

  • 新建网络请求类 MusicApiService
interface MusicApiService {

    companion object {
        private const val TAG = "MusicApiService"
        
        // 1
        fun create(): MusicApiService {
            val retrofit = Retrofit.Builder()
                .baseUrl(MusicApiConstant.BASE_URL)
                .client(okHttpClient)
                .addConverterFactory(MoshiConverterFactory.create())
                .build()
            return retrofit.create(MusicApiService::class.java)
        }
        
        // 2
        private val okHttpClient: OkHttpClient
            get() = OkHttpClient.Builder()
                .addInterceptor(loggingInterceptor)
                .build()
        // 3
        private val loggingInterceptor: HttpLoggingInterceptor
            get() {
                val interceptor = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger{
                    override fun log(message: String) {
                        Log.i(TAG, message)
                    }
                })
                interceptor.level = HttpLoggingInterceptor.Level.BASIC
                return interceptor
            }

    }

}

MusicApiService有一个伴生对象,里面有个create方法,是Retrofit的生成方法。其中配置了baseUrl,配置OKHttp为真正的请求类,配置了MoshiConverterFactory为JSON的转换工厂。这个方法返回的对象是请求的发起者。

  • 定义播单的数据类
data class PlayListResponse(
    val code: Int,
    val playlists: List<PlayItem>
)

data class PlayItem(val name: String,
                    val id: String,
                    val coverImgUrl: String,
                    val coverImgId: String,
                    val description: String,
                    val playCount: Int,
                    val highQuality: Boolean,
                    val shareCount: Int,
                    val subscribers: List<User>,
                    val creator: User
)

data class User(val nickname: String,
                val userId: String,
                val avatarUrl: String,
                val gender: Int,
                val followed: Boolean
)

  • 配置请求接口
interface MusicApiService {

    @GET(MusicApiConstant.PLAYLIST_HOT)
    suspend fun getHotPlaylist(@Query("limit") limit: Int, @Query("offset") offset: Int) : PlayListResponse
    
    ....
}

MusicApiService中加入所示代码。
和普通写法的两点重要区别:

  1. 需要定义接口为suspend函数
  2. 返回的直接是数据,不是CallBack。
  • Fragment中请求

Fragment中定义JobCoroutineExceptionHandlerCoroutineContext,构建一个CoroutineScope。代码如下:

private val myJob = Job()
private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    Log.i("请求错误信息", "${throwable.message}")
}
private val myScope = CoroutineScope(myJob + Dispatchers.Main + exceptionHandler)
  • 在Fragment的onViewCreated方法中创建协程请求
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    myScope.launch {
        val response = MusicApiService.create().getHotPlaylist(1, 0)
        println("$response")
    }
}

目前为止,请求结果就得到了。

请求结果
  • 及时取消协程
override fun onDestroy() {
    super.onDestroy()
    myScope.cancel()
}

在Fragment的onDestroy方法中要取消协程,否则有可能造成程序崩溃。

结语 - 协程值得一学

协程是非常优秀的异步处理框架,已经和很多JetPack的库无缝连接。使用起来非常方便。

譬如可以直接利用ViewModel的ViewModelScope感知Fragment的lifecycle,不需要手动取消协程。此外Room和协程的Flow也能无缝连接,实现轻量级的RxJava类似的功能。这些后续都会有介绍。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342