本節教程我們將使用Retrofit網絡請求庫實現網易云音樂的推薦歌單的數據請求。請求的過程中我們將使用Coroutines實現異步操作,并且利用Moshi進行網絡數據的解析。
我們的接口來自于開源庫NeteaseCloudMusicApi,這個NodeJS API 庫的文檔非常完善,并且支持的接口非常多。這個庫的安裝請詳閱該項目的參考文檔。
kotlin - Coroutine 協程
協程是kotlin的一個異步處理框架,是輕量級的線程。
協程的幾大優勢:
- 可以用寫同步的代碼結構樣式實現異步的功能
- 非常容易將代碼邏輯分發到不同的線程中
- 和作用域綁定,避免內存泄露。可以無縫銜接LifeCycle和ViewModel等JetPack庫
- 減少模板代碼和避免了地獄回調
接下來我將詳細介紹下協程的概念和使用方法。
啟動協程
啟動協程使用最多的方式(主要)有launch和async
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的構造方法我們得知:
- 構造的時候需要Job,如果沒有傳入就會在內部新建一個Job做為這個協程的父Job來管理該協程的所有任務Job。
- 這兒的CoroutineContext我們可以簡單的等于CoroutineDispatcher。這個稍后介紹。
協程作用域可以通過以下方式獲得:
- Global Scope --- 和APP的生命周期一致
- LiveDataScope, ViewModelScope, lifecycleScope 等 --- 和這些類的生命周期一致 (涉及到的內容后面的教程會有解釋)
- 自定義 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
}
總結如下:
- 創建一個父Job,作為協程的父Job
- 使用 myJob 和 Dispatchers.Main 這個協程向下文環境創建一個myScope協程作用域
- 在myScope這個協程作用域下啟動協程
- 執行異步任務
協程中的異步操作 --- 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中加入所示代碼。
和普通寫法的兩點重要區別:
- 需要定義接口為suspend函數
- 返回的直接是數據,不是CallBack。
- Fragment中請求
在Fragment中定義Job,CoroutineExceptionHandler 和 CoroutineContext,構建一個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類似的功能。這些后續都會有介紹。