JetPack知識(shí)點(diǎn)實(shí)戰(zhàn)系列三:使用 Coroutines, Retrofit, Moshi實(shí)現(xiàn)網(wǎng)絡(luò)數(shù)據(jù)請(qǐng)求

本節(jié)教程我們將使用Retrofit網(wǎng)絡(luò)請(qǐng)求庫(kù)實(shí)現(xiàn)網(wǎng)易云音樂(lè)的推薦歌單的數(shù)據(jù)請(qǐng)求。請(qǐng)求的過(guò)程中我們將使用Coroutines實(shí)現(xiàn)異步操作,并且利用Moshi進(jìn)行網(wǎng)絡(luò)數(shù)據(jù)的解析。

我們的接口來(lái)自于開(kāi)源庫(kù)NeteaseCloudMusicApi,這個(gè)NodeJS API 庫(kù)的文檔非常完善,并且支持的接口非常多。這個(gè)庫(kù)的安裝請(qǐng)?jiān)旈喸擁?xiàng)目的參考文檔。

網(wǎng)易音樂(lè)API

kotlin - Coroutine 協(xié)程

協(xié)程是kotlin的一個(gè)異步處理框架,是輕量級(jí)的線程。

協(xié)程的幾大優(yōu)勢(shì):

  1. 可以用寫(xiě)同步的代碼結(jié)構(gòu)樣式實(shí)現(xiàn)異步的功能
  2. 非常容易將代碼邏輯分發(fā)到不同的線程中
  3. 和作用域綁定,避免內(nèi)存泄露??梢詿o(wú)縫銜接LifeCycle和ViewModel等JetPack庫(kù)
  4. 減少模板代碼和避免了地獄回調(diào)

接下來(lái)我將詳細(xì)介紹下協(xié)程的概念和使用方法。

啟動(dòng)協(xié)程

啟動(dòng)協(xié)程使用最多的方式(主要)有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其實(shí)是Job的子類,所以這兩個(gè)啟動(dòng)方法的返回值都是Job,那Job有什么特性呢?

  • Job 代表一個(gè)異步的任務(wù)
  • Job 具有生命周期并且可以取消。
  • Job 還可以有層級(jí)關(guān)系,一個(gè)Job可以包含多個(gè)子Job,當(dāng)父Job被取消后,所有的子Job也會(huì)被自動(dòng)取消;當(dāng)子Job出現(xiàn)異常后父Job也會(huì)被取消。

Deferred有一個(gè)await方法就能取到協(xié)程的返回值,這是和Job的重要區(qū)別:

launch啟動(dòng)的協(xié)程的結(jié)果沒(méi)有返回值,async啟動(dòng)的協(xié)程會(huì)返回值.這就是Kotlin為什么設(shè)計(jì)有兩個(gè)啟動(dòng)方法的原因了。

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

總結(jié):launch 更多是用來(lái)發(fā)起一個(gè)無(wú)需結(jié)果的耗時(shí)任務(wù)(如批量文件刪除、混合圖片等),async用于異步執(zhí)行耗時(shí)任務(wù),并且需要返回值(如網(wǎng)絡(luò)請(qǐng)求、數(shù)據(jù)庫(kù)讀寫(xiě)、文件讀寫(xiě))。

調(diào)用對(duì)象 CoroutineScope

啟動(dòng)協(xié)程需要在一定的協(xié)程作用域CoroutineScope下啟動(dòng)。

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

通過(guò)CoroutineScope的構(gòu)造方法我們得知:

  1. 構(gòu)造的時(shí)候需要Job,如果沒(méi)有傳入就會(huì)在內(nèi)部新建一個(gè)Job做為這個(gè)協(xié)程的父Job來(lái)管理該協(xié)程的所有任務(wù)Job。
  2. 這兒的CoroutineContext我們可以簡(jiǎn)單的等于CoroutineDispatcher。這個(gè)稍后介紹。

協(xié)程作用域可以通過(guò)以下方式獲得:

  1. Global Scope --- 和APP的生命周期一致
  2. LiveDataScope, ViewModelScope, lifecycleScope 等 --- 和這些類的生命周期一致 (涉及到的內(nèi)容后面的教程會(huì)有解釋)
  3. 自定義 Scope --- 自己定義Scope,生命周期和定義相關(guān)。

協(xié)程作用域CoroutineScope的主要作用是規(guī)定了協(xié)程的執(zhí)行范圍,超過(guò)這個(gè)作用域范圍協(xié)程將會(huì)被自動(dòng)取消。

這就是前面提到的協(xié)程會(huì)和作用域綁定,避免內(nèi)存泄露。

協(xié)程向下文環(huán)境 CoroutineContext

上下文環(huán)境主要是傳如下Dispatchers的值,Dispatchers根據(jù)名字可以猜測(cè)它是分發(fā)器,把異步任務(wù)分發(fā)到對(duì)應(yīng)的線程去執(zhí)行。主要的值有以下:

  • Dispatchers.Main --- 分發(fā)任務(wù)到主線程,主要執(zhí)行UI繪制等。
  • DefaultScheduler.IO --- 分發(fā)任務(wù)IO線程,它用于輸入/輸出的場(chǎng)景。主要用來(lái)執(zhí)行網(wǎng)絡(luò)請(qǐng)求、數(shù)據(jù)庫(kù)操作、文件讀寫(xiě)等。
  • DefaultScheduler.Default --- 主要執(zhí)行CPU密集的運(yùn)算操作
  • DefaultScheduler.Unconfined --- 這個(gè)分發(fā)的線程不可控的,一般不建議使用。

階段總結(jié)

剛才我們介紹了協(xié)程launch函數(shù)的context參數(shù),接下來(lái)看看其他兩個(gè)參數(shù):

  • start參數(shù)的意思是什么時(shí)候開(kāi)始分發(fā)任務(wù),CoroutineStart.DEFAULT代表的是協(xié)程啟動(dòng)的時(shí)候立即分發(fā)任務(wù)。
  • block參數(shù)的意思啟動(dòng)的協(xié)程需要執(zhí)行的任務(wù)代碼。以不寫(xiě)內(nèi)容,直接傳空{(diào)} 執(zhí)行。明顯這樣啟動(dòng)的協(xié)程沒(méi)有意義,暫時(shí)僅為學(xué)習(xí)。

學(xué)習(xí)到到目前為止,我們應(yīng)該可以啟動(dòng)一個(gè)協(xié)程了

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

總結(jié)如下:

  1. 創(chuàng)建一個(gè)父Job,作為協(xié)程的父Job
  2. 使用 myJobDispatchers.Main 這個(gè)協(xié)程向下文環(huán)境創(chuàng)建一個(gè)myScope協(xié)程作用域
  3. myScope這個(gè)協(xié)程作用域下啟動(dòng)協(xié)程
  4. 執(zhí)行異步任務(wù)

協(xié)程中的異步操作 --- suspend函數(shù)

suspend函數(shù)的流程

實(shí)現(xiàn)異步操作的核心關(guān)鍵就是掛起函數(shù)suspend函數(shù),那究竟什么是掛起函數(shù)。

掛起函數(shù)的申明是在普通的函數(shù)前面加上suspend關(guān)鍵字,掛起函數(shù)執(zhí)行的時(shí)候會(huì)中斷協(xié)程,當(dāng)掛起函數(shù)執(zhí)行完成后,會(huì)把結(jié)果返回到當(dāng)前協(xié)程的中,然后執(zhí)行接下來(lái)的代碼。

上面這段話說(shuō)起來(lái)很枯燥,我們接下來(lái)利用代碼來(lái)解釋:

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")
}
  • 掛起函數(shù)執(zhí)行的時(shí)候會(huì)中斷協(xié)程: suspend函數(shù)login("1111", "111111")執(zhí)行的時(shí)候到會(huì)切換新的線程即IO線程去執(zhí)行,當(dāng)前的協(xié)程所在的主線程的流程被掛起中止了,主線程可以接著處理其他的事情。
  • 當(dāng)掛起函數(shù)執(zhí)行完成后,會(huì)把結(jié)果返回到當(dāng)前協(xié)程中: login("1111", "111111")在IO線程執(zhí)行完成后返回user,并且返回到主線程。即協(xié)程所在的線程。
  • 然后執(zhí)行接下來(lái)的代碼: 接下來(lái)打印println("$user")是在協(xié)程所在的主線程執(zhí)行。

結(jié)果如下所示:

結(jié)果

withContext 函數(shù)

我們?cè)谏厦娴?strong>login函數(shù)中使用了withContext函數(shù),這個(gè)函數(shù)是非常實(shí)用和常見(jiàn)的suspend函數(shù)。 使用它能非常容易的實(shí)現(xiàn)線程的切換,從而實(shí)現(xiàn)異步操作。

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

我們看到withContext函數(shù)也是個(gè)掛起函數(shù),那我們就沒(méi)有必要在掛起函數(shù)中調(diào)用掛起函數(shù),可以直接調(diào)用withContext的簡(jiǎn)寫(xiě):

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")
}

協(xié)程中的異常處理機(jī)制

協(xié)程提供了一個(gè)異常處理的回調(diào)函數(shù)CoroutineExceptionHandler??梢詷?gòu)造一個(gè)函數(shù)對(duì)象,賦值給協(xié)程作用域,這樣協(xié)程中的異常就能被捕獲了。

private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    Log.i("錯(cuò)誤信息", "${throwable.message}")
}

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

提示:這里的 + 號(hào)不是數(shù)學(xué)意義的加號(hào),是把這些對(duì)象一起組合成一個(gè)協(xié)程向下文環(huán)境(鍵值對(duì))。

協(xié)程總結(jié)

  • 協(xié)程作用域可以界定生命周期,避免內(nèi)存泄露
  • suspend函數(shù)可以讓我們寫(xiě)同步代碼的結(jié)構(gòu)去實(shí)現(xiàn)異步功能
  • withContext等函數(shù)能非常容易將代碼模塊分發(fā)的不同的線程中去。
  • 協(xié)程還有良好的異常處理機(jī)制,

用協(xié)程和Retrofit實(shí)現(xiàn)網(wǎng)絡(luò)請(qǐng)求

Retrofit是負(fù)責(zé)網(wǎng)絡(luò)請(qǐng)求接口的封裝,通過(guò)大量的注解實(shí)現(xiàn)超級(jí)解耦。真正的網(wǎng)絡(luò)請(qǐng)求是OKHttp庫(kù)去實(shí)現(xiàn)。Retrofit常規(guī)使用方法不是本教程的講解范圍,本教程主要講Retrofit怎樣和協(xié)程無(wú)縫銜接實(shí)現(xiàn)網(wǎng)絡(luò)請(qǐng)求。

Moshi是一個(gè)JSON解析庫(kù),天生對(duì)Kotlin友好,特別是Kotlin的data數(shù)據(jù)類非常適合它。所以建議選擇它來(lái)解析JSON。

本地服務(wù)器環(huán)境搭建后好,訪問(wèn)http://localhost:3000/top/playlist/hot?limit=1&offset=0就能得到一系列的播單playlists

播單接口

讓我們接下來(lái)寫(xiě)代碼吧。

  • AndroidManifest.xml中加入網(wǎng)絡(luò)請(qǐng)求權(quán)限
<uses-permission android:name="android.permission.INTERNET"/>
  • 新建network_security_config.xml文件配置,內(nèi)容如下
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>

  • 然后在AndroidManifest.xml中配置,這樣APP就能通過(guò)HTTP協(xié)議訪問(wèn)服務(wù)器了
<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"
  • 新建請(qǐng)求常量類MusicApiConstant
object MusicApiConstant {
    const val BASE_URL = "http://10.0.2.2:3000" // BASEURL
    const val PLAYLIST_HOT = "/top/playlist"    // 推薦歌單
}

注意:我現(xiàn)在用的模擬器開(kāi)發(fā)測(cè)試,10.0.2.2代表的是模擬器所在機(jī)器的localhost地址,如果請(qǐng)求localhost訪問(wèn)的是模擬器的地址。

MusicApiConstant主要存放BASE_URL,各個(gè)請(qǐng)求的路徑等常量

  • 新建網(wǎng)絡(luò)請(qǐng)求類 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有一個(gè)伴生對(duì)象,里面有個(gè)create方法,是Retrofit的生成方法。其中配置了baseUrl,配置OKHttp為真正的請(qǐng)求類,配置了MoshiConverterFactory為JSON的轉(zhuǎn)換工廠。這個(gè)方法返回的對(duì)象是請(qǐng)求的發(fā)起者。

  • 定義播單的數(shù)據(jù)類
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
)

  • 配置請(qǐng)求接口
interface MusicApiService {

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

MusicApiService中加入所示代碼。
和普通寫(xiě)法的兩點(diǎn)重要區(qū)別:

  1. 需要定義接口為suspend函數(shù)
  2. 返回的直接是數(shù)據(jù),不是CallBack。
  • Fragment中請(qǐng)求

Fragment中定義Job,CoroutineExceptionHandlerCoroutineContext,構(gòu)建一個(gè)CoroutineScope。代碼如下:

private val myJob = Job()
private val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    Log.i("請(qǐng)求錯(cuò)誤信息", "${throwable.message}")
}
private val myScope = CoroutineScope(myJob + Dispatchers.Main + exceptionHandler)
  • 在Fragment的onViewCreated方法中創(chuàng)建協(xié)程請(qǐng)求
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

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

目前為止,請(qǐng)求結(jié)果就得到了。

請(qǐng)求結(jié)果
  • 及時(shí)取消協(xié)程
override fun onDestroy() {
    super.onDestroy()
    myScope.cancel()
}

在Fragment的onDestroy方法中要取消協(xié)程,否則有可能造成程序崩潰。

結(jié)語(yǔ) - 協(xié)程值得一學(xué)

協(xié)程是非常優(yōu)秀的異步處理框架,已經(jīng)和很多JetPack的庫(kù)無(wú)縫連接。使用起來(lái)非常方便。

譬如可以直接利用ViewModel的ViewModelScope感知Fragment的lifecycle,不需要手動(dòng)取消協(xié)程。此外Room和協(xié)程的Flow也能無(wú)縫連接,實(shí)現(xiàn)輕量級(jí)的RxJava類似的功能。這些后續(xù)都會(huì)有介紹。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容