Retrofit + Kotlin + MVVM 的網(wǎng)絡(luò)請(qǐng)求框架的封裝嘗試之一

1、前言

之前在學(xué)習(xí)郭霖《第一行代碼》時(shí)按部就班地寫(xiě)過(guò)一個(gè)彩云天氣 App私杜,對(duì)里面的網(wǎng)絡(luò)請(qǐng)求框架的封裝印象非常深刻,很喜歡這種 Retrofit + Kotlin + 協(xié)程的搭配使用救欧。隨后也在自己的項(xiàng)目里參考了這部分的代碼衰粹。但隨著代碼的深入編寫(xiě)和功能的復(fù)雜,原來(lái)的框架已經(jīng)無(wú)法滿足我的使用了笆怠。原主要有如下的痛點(diǎn):

  • 缺少失敗的回調(diào)
  • 顯示加載中動(dòng)畫(huà)比較麻煩

后面我自己試著努力去封裝一個(gè)簡(jiǎn)單易用的框架铝耻,可惜個(gè)人能力有限,自己封裝的框架總是不如人意蹬刷。好在還有很多優(yōu)秀的博客和代碼可供參考瓢捉。在此基礎(chǔ)上,對(duì)彩云天氣 App中的網(wǎng)絡(luò)請(qǐng)求框架做了一些修改办成,盡可能地做到簡(jiǎn)單易用泊柬。以請(qǐng)求玩安卓的登錄接口為例(用戶名和密碼是我自己申請(qǐng)的,見(jiàn)代碼)诈火,頁(yè)面上有一個(gè)按鈕,點(diǎn)擊按鈕后就發(fā)起登錄請(qǐng)求。

先來(lái)看看發(fā)起請(qǐng)求后的回調(diào)怎么寫(xiě):

viewModel.loginLiveData.observeState(this) {
    onStart {
        LoadingDialog.show(activity)
        Log.d(TAG, "請(qǐng)求開(kāi)始")
    }
    onSuccess {
        Log.d(TAG, "請(qǐng)求成功")
        showToast("登錄成功")
        binding.tvResult.text = it.toString()
    }
    onEmpty {
        showToast("數(shù)據(jù)為空")
    }
    onFailure {
        Log.d(TAG, "請(qǐng)求失敗")
        showToast(it.errorMsg.orEmpty())
        binding.tvResult.text = it.toString()
    }
    onFinish {
        LoadingDialog.dismiss(activity)
        Log.d(TAG, "請(qǐng)求結(jié)束")
    }
}

回調(diào)一共有五種冷守,會(huì)在下文詳細(xì)介紹刀崖。這里采用了DSL的寫(xiě)法,如果你喜歡傳統(tǒng)的寫(xiě)法拍摇,可以調(diào)用另外一個(gè)擴(kuò)展方法observeResponse()亮钦,由于它最后一個(gè)參數(shù)就是請(qǐng)求成功的回調(diào),所以借助 Lambda 表達(dá)式的特性充活,可以簡(jiǎn)潔地寫(xiě)成如下的形式:

viewModel.loginLiveData.observeResponse(this){
    binding.tvResult.text = it.toString()
}

如果還需要其他回調(diào)蜂莉,可以使用具名參數(shù)加上,如下所示:

viewModel.loginLiveData.observeResponse(this, onStart = {
    LoadingDialog.show(this)
}, onFinish = {
    LoadingDialog.dismiss(activity)
}) {
    binding.tvResult.text = it.toString()
}

2混卵、框架搭建

開(kāi)始之前必須說(shuō)明映穗,這個(gè)框架是基于《第一行代碼》(第三版)中的彩云天氣 App的,它的架構(gòu)圖如下所示幕随,如果你閱讀過(guò)《第一行代碼》或者谷歌的相關(guān)文檔蚁滋,那么想必對(duì)此不會(huì)陌生。

2.1 添加依賴庫(kù)

//簡(jiǎn)化在 Activity 中聲明 ViewModel 的代碼
implementation "androidx.activity:activity-ktx:1.3.1"

// lifecycle
def lifecycle_version = "2.3.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"

// retrofit2
def retrofit_version = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'

// okhttp
def okhttp_version = "4.8.1"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"

//日志攔截器
implementation('com.github.ihsanbal:LoggingInterceptor:3.1.0') {
    exclude group: 'org.json', module: 'json'
}

2.2 Retrofit構(gòu)建器

Retrofit構(gòu)建器這里做了分層赘淮,基類做了一些基本的配置辕录,子類繼承后可以添加新的配置,并配置自己喜歡的日志攔截器梢卸。

private const val TIME_OUT_LENGTH = 8L

private const val BASE_URL = "https://www.wanandroid.com/"

abstract class BaseRetrofitBuilder {

    private val okHttpClient: OkHttpClient by lazy {
        val builder = OkHttpClient.Builder()
            .callTimeout(TIME_OUT_LENGTH, TimeUnit.SECONDS)
            .connectTimeout(TIME_OUT_LENGTH, TimeUnit.SECONDS)
            .readTimeout(TIME_OUT_LENGTH, TimeUnit.SECONDS)
            .writeTimeout(TIME_OUT_LENGTH, TimeUnit.SECONDS)
            .retryOnConnectionFailure(true)
        initLoggingInterceptor()?.also {
            builder.addInterceptor(it)
        }
        handleOkHttpClientBuilder(builder)
        builder.build()
    }

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(okHttpClient)
        .build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)

    inline fun <reified T> create(): T = create(T::class.java)

    /**
     * 子類自定義 OKHttpClient 的配置
     */
    abstract fun handleOkHttpClientBuilder(builder: OkHttpClient.Builder)

    /**
     * 配置日志攔截器
     */
    abstract fun initLoggingInterceptor(): Interceptor?
}

RetrofitBuilder

private const val LOG_TAG_HTTP_REQUEST = "okhttp_request"
private const val LOG_TAG_HTTP_RESULT = "okhttp_result"

object RetrofitBuilder : BaseRetrofitBuilder() {

    override fun handleOkHttpClientBuilder(builder: OkHttpClient.Builder) {}

    override fun initLoggingInterceptor()= LoggingInterceptor
        .Builder()
        .setLevel(Level.BASIC)
        .log(Platform.INFO)
        .request(LOG_TAG_HTTP_REQUEST)
        .response(LOG_TAG_HTTP_RESULT)
        .build()
}

2.3 全局異常處理

請(qǐng)求時(shí)可能會(huì)遇到諸如網(wǎng)絡(luò)斷開(kāi)走诞、Json 解析失敗等意外情況,如果我們每次請(qǐng)求都要處理一遍這些異常蛤高,那也未免太麻煩了蚣旱。正確的做法是把異常集中到一起處理。

創(chuàng)建一個(gè)定義各種異常的枚舉類:

enum class HttpError(val code: Int, val message: String){
    UNKNOWN(-100,"未知錯(cuò)誤"),
    NETWORK_ERROR(1000, "網(wǎng)絡(luò)連接超時(shí)襟齿,請(qǐng)檢查網(wǎng)絡(luò)"),
    JSON_PARSE_ERROR(1001, "Json 解析失敗")
    //······
}

創(chuàng)建一個(gè)文件姻锁,在里面定義一個(gè)全局方法,用于處理各種異常:

fun handleException(throwable: Throwable) = when (throwable) {
    is UnknownHostException -> RequestException(HttpError.NETWORK_ERROR, throwable.message)
    is HttpException -> {
        val errorModel = throwable.response()?.errorBody()?.string()?.run {
            Gson().fromJson(this, ErrorBodyModel::class.java)
        } ?: ErrorBodyModel()
        RequestException(errorMsg = errorModel.message, error = errorModel.error)
    }
    is JsonParseException -> RequestException(HttpError.JSON_PARSE_ERROR, throwable.message)
    is RequestException -> throwable
    else -> RequestException(HttpError.UNKNOWN, throwable.message)
}

實(shí)際項(xiàng)目中遇到的異常當(dāng)然不止這幾個(gè)猜欺,這里只是作為舉例寫(xiě)了少部分位隶,實(shí)際開(kāi)放中把它豐富完善即可。

2.4 回調(diào)狀態(tài)監(jiān)聽(tīng)

回調(diào)狀態(tài)一共有四種:

  • onStart():請(qǐng)求開(kāi)始(可在此展示加載動(dòng)畫(huà))
  • onSuccess():請(qǐng)求成功
  • onEmpty():請(qǐng)求成功开皿,但datanull或者data是集合類型但為空
  • onFailure():請(qǐng)求失敗
  • onFinish():請(qǐng)求結(jié)束(可在此關(guān)閉加載動(dòng)畫(huà))

這里要注意onSuccess的標(biāo)準(zhǔn):并不僅僅是 Http 請(qǐng)求的結(jié)果碼(status code)等于 200涧黄,而且要達(dá)到Api請(qǐng)求成功的標(biāo)準(zhǔn),以玩安卓的Api 為例赋荆,errorCode 為 0時(shí)笋妥,發(fā)起的請(qǐng)求才是執(zhí)行成功;否則窄潭,都應(yīng)該歸為onFailure()的情況(可以參考文章附帶的思維導(dǎo)圖)春宣。

理清楚有幾種回調(diào)狀態(tài)后,就可以實(shí)施監(jiān)聽(tīng)了。那么在哪里監(jiān)聽(tīng)呢月帝?LiveDataobserve()方法的第二個(gè)函數(shù)可以傳入Observer參數(shù)躏惋。Observer是一個(gè)接口,我們繼承它自定義一個(gè)Oberver嚷辅,借此我們就可以監(jiān)聽(tīng)LiveData的值的變化簿姨。

interface IStateObserver<T> : Observer<BaseResponse<T>> {

    override fun onChanged(response: BaseResponse<T>?) {
        when (response) {
            is StartResponse -> {
                //onStart()回調(diào)后不能直接就調(diào)用onFinish(),必須等待請(qǐng)求結(jié)束
                onStart()
                return
            }
            is SuccessResponse -> onSuccess(response.data)
            is EmptyResponse -> onEmpty()
            is FailureResponse -> onFailure(response.exception)
        }
        onFinish()
    }

    /**
     * 請(qǐng)求開(kāi)始
     */
    fun onStart()

    /**
     * 請(qǐng)求成功簸搞,且 data 不為 null
     */
    fun onSuccess(data: T)

    /**
     * 請(qǐng)求成功扁位,但 data 為 null 或者 data 是集合類型但為空
     */
    fun onEmpty()

    /**
     * 請(qǐng)求失敗
     */
    fun onFailure(e: RequestException)

    /**
     * 請(qǐng)求結(jié)束
     */
    fun onFinish()
}


接下來(lái)我們準(zhǔn)備一個(gè)HttpRequestCallback類,用于實(shí)現(xiàn)DSL的回調(diào)形式:

typealias OnSuccessCallback<T> = (data: T) -> Unit
typealias OnFailureCallback = (e: RequestException) -> Unit
typealias OnUnitCallback = () -> Unit

class HttpRequestCallback<T> {

    var startCallback: OnUnitCallback? = null
    var successCallback: OnSuccessCallback<T>? = null
    var emptyCallback: OnUnitCallback? = null
    var failureCallback: OnFailureCallback? = null
    var finishCallback: OnUnitCallback? = null

    fun onStart(block: OnUnitCallback) {
        startCallback = block
    }

    fun onSuccess(block: OnSuccessCallback<T>) {
        successCallback = block
    }

    fun onEmpty(block: OnUnitCallback) {
        emptyCallback = block
    }

    fun onFailure(block: OnFailureCallback) {
        failureCallback = block
    }

    fun onFinish(block: OnUnitCallback) {
        finishCallback = block
    }
}

然后聲明新的監(jiān)聽(tīng)方法趁俊,考慮到某些時(shí)候需要自定義的LiveData(比如為了解決數(shù)據(jù)倒灌的問(wèn)題)域仇,這里采用擴(kuò)展函數(shù)的寫(xiě)法,便于擴(kuò)展则酝。

/**
 * 監(jiān)聽(tīng) LiveData 的值的變化殉簸,回調(diào)為 DSL 的形式
 */
inline fun <T> LiveData<BaseResponse<T>>.observeState(
    owner: LifecycleOwner,
    crossinline callback: HttpRequestCallback<T>.() -> Unit
) {
    val requestCallback = HttpRequestCallback<T>().apply(callback)
    observe(owner, object : IStateObserver<T> {
        override fun onStart() {
            requestCallback.startCallback?.invoke()
        }

        override fun onSuccess(data: T) {
            requestCallback.successCallback?.invoke(data)
        }

        override fun onEmpty() {
            requestCallback.emptyCallback?.invoke()
        }

        override fun onFailure(e: RequestException) {
            requestCallback.failureCallback?.invoke(e)
        }

        override fun onFinish() {
            requestCallback.finishCallback?.invoke()
        }
    })
}

/**
 * 監(jiān)聽(tīng) LiveData 的值的變化
 */
inline fun <T> LiveData<BaseResponse<T>>.observeResponse(
    owner: LifecycleOwner,
    crossinline onStart: OnUnitCallback = {},
    crossinline onEmpty: OnUnitCallback = {},
    crossinline onFailure: OnFailureCallback = { e: RequestException -> },
    crossinline onFinish: OnUnitCallback = {},
    crossinline onSuccess: OnSuccessCallback<T>
) {
    observe(owner, object : IStateObserver<T> {
        override fun onStart() {
            onStart()
        }

        override fun onSuccess(data: T) {
            onSuccess(data)
        }

        override fun onEmpty() {
            onEmpty()
        }

        override fun onFailure(e: RequestException) {
            onFailure(e)
        }

        override fun onFinish() {
            onFinish()
        }
    })
}

2.5 Repository 層的封裝

Repository層作為數(shù)據(jù)的來(lái)源沽讹,有個(gè)兩個(gè)渠道:網(wǎng)絡(luò)請(qǐng)求和數(shù)據(jù)庫(kù)般卑。這里暫時(shí)只處理了網(wǎng)絡(luò)請(qǐng)求爽雄。

基類Repository

abstract class BaseRepository {

    protected fun <T> fire(
        context: CoroutineContext = Dispatchers.IO,
        block: suspend () -> BaseResponse<T>
    ): LiveData<BaseResponse<T>> = liveData(context) {
        this.runCatching {
            emit(StartResponse())
            block()
        }.onSuccess {
            //status code 為200叹谁,繼續(xù)判斷 errorCode 是否為 0
            emit(
                when (it.success) {
                    true -> checkEmptyResponse(it.data)
                    false -> FailureResponse(handleException(RequestException(it)))
                }
            )
        }.onFailure { throwable ->
            emit(FailureResponse(handleException(throwable)))
        }
    }

    /**
     * data 為 null,或者 data 是集合類型析苫,但是集合為空都會(huì)進(jìn)入 onEmpty 回調(diào)
     */
    private fun <T> checkEmptyResponse(data: T?): ApiResponse<T> =
        if (data == null || (data is List<*> && (data as List<*>).isEmpty())) {
            EmptyResponse()
        } else {
            SuccessResponse(data)
        }
}

子類Repository:

object Repository : BaseRepository() {

    fun login(pwd: String) = fire {
        NetworkDataSource.login(pwd)
    }

}

網(wǎng)絡(luò)請(qǐng)求數(shù)據(jù)源穿扳,在這里調(diào)用網(wǎng)絡(luò)接口:

object NetworkDataSource {
    private val apiService = RetrofitBuilder.create<ApiService>()

    suspend fun login(pwd: String) = apiService.login(password = pwd)
}

2.6 ViewModel層的封裝

ViewModel基本遵循了《第一行代碼》中的寫(xiě)法衩侥,創(chuàng)建了兩個(gè)LiveData。用戶點(diǎn)擊按鈕時(shí)矛物,loginAction的值就會(huì)發(fā)生改變茫死,觸發(fā)switchMap中的代碼,從而達(dá)到請(qǐng)求數(shù)據(jù)的目的履羞。

class MainViewModel : ViewModel() {

    private val loginAction = MutableLiveData<Boolean>()

    /**
     * loginAction 在這里只傳遞布爾值峦萎,不傳遞密碼屡久,在實(shí)際項(xiàng)目中,會(huì)使用 DataBinding 綁定 xml 布局和 ViewModel骨杂,
     * 不需要從 Activity 或者 Fragment 中把密碼傳入 ViewModel
     */
    val loginLiveData = loginAction.switchMap {
        if (it) {
            Repository.login("PuKxVxvMzBp2EJM")
        } else {
            Repository.login("123456")
        }
    }

    /**
     * 點(diǎn)擊登錄
     */
    fun login() {
        loginAction.value = true
    }

    fun loginWithWrongPwd() {
        loginAction.value = false
    }
}

注意:這種寫(xiě)法通常不從View向ViewModel層傳遞數(shù)據(jù)涂身,是需要搭配DataBinding 的。如果你不想這樣寫(xiě)搓蚪,可以修改BaseRepository中的返回值,直接返回BaseResponse丁鹉。

3妒潭、思維導(dǎo)圖及源碼

最后,用一張思維導(dǎo)圖總結(jié)本文:

源碼地址:GitHub (注意分支要選擇 dev1.0)

推薦

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末揣钦,一起剝皮案震驚了整個(gè)濱河市雳灾,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌冯凹,老刑警劉巖谎亩,帶你破解...
    沈念sama閱讀 218,546評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異宇姚,居然都是意外死亡匈庭,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,224評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén)浑劳,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)阱持,“玉大人,你說(shuō)我怎么就攤上這事魔熏≈匝剩” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 164,911評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵蒜绽,是天一觀的道長(zhǎng)镶骗。 經(jīng)常有香客問(wèn)我,道長(zhǎng)躲雅,這世上最難降的妖魔是什么鼎姊? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,737評(píng)論 1 294
  • 正文 為了忘掉前任,我火速辦了婚禮吏夯,結(jié)果婚禮上此蜈,老公的妹妹穿的比我還像新娘。我一直安慰自己噪生,他們只是感情好裆赵,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,753評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著跺嗽,像睡著了一般战授。 火紅的嫁衣襯著肌膚如雪页藻。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,598評(píng)論 1 305
  • 那天植兰,我揣著相機(jī)與錄音石窑,去河邊找鬼朱躺。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的耐亏。 我是一名探鬼主播,決...
    沈念sama閱讀 40,338評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼噩斟,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼阳欲!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起毡咏,我...
    開(kāi)封第一講書(shū)人閱讀 39,249評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤驮宴,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后呕缭,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體堵泽,經(jīng)...
    沈念sama閱讀 45,696評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,888評(píng)論 3 336
  • 正文 我和宋清朗相戀三年恢总,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了迎罗。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,013評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡离熏,死狀恐怖佳谦,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情滋戳,我是刑警寧澤钻蔑,帶...
    沈念sama閱讀 35,731評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站奸鸯,受9級(jí)特大地震影響咪笑,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜娄涩,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,348評(píng)論 3 330
  • 文/蒙蒙 一窗怒、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧蓄拣,春花似錦扬虚、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,929評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至咽斧,卻和暖如春堪置,著一層夾襖步出監(jiān)牢的瞬間躬存,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,048評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工舀锨, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留岭洲,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,203評(píng)論 3 370
  • 正文 我出身青樓坎匿,卻偏偏與公主長(zhǎng)得像盾剩,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子替蔬,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,960評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容