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中定義Job,CoroutineExceptionHandlerCoroutineContext,構建一個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類似的功能。這些后續都會有介紹。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。