Android开发之MVVM模式实践(六):协程与网络请求的结合

前言

大家好,我是小益!在经过前两章对协程的介绍后,我们终于又回到了MVVM的封装。协程在Android开发中最常用的场景应该是网络请求了,其次是一些使用Thread的场景,本章内容我们将着重介绍如何将协程与网络请求结合。

推荐

文章将率先在公众号「Code满满」与个人博客「李益的小站」上发布,快来关注吧!

一、viewModelScope的使用

自行创建协程

var uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

在上述代码中我们创建了一个协程并指定了这个协程是在主线程中工作,之后我们就可以使用前两章提到的launch来操作了,如下:

uiScope.launch{
    ...
}

以上是我们创建协程的实现方式,我们可以通过指定Dispatchers来决定协程到底在什么线程中工作,而其实Kotlin的协程核心库中也为我们提供封装好了的scope,例如MainScope,源码如下:

@Suppress("FunctionName")
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

非常明显,Kotlin提供的MainScope内部实现与我们自行创建的CoroutineScope一模一样,MainScope在一定程度上方便了我们创建协程。

lifecycle-viewmodel-ktx

知晓协程如何创建后,我们需要思考一个问题:协程主要的使用层是MVVM的哪一层?因为协程最主要的作用是用同步编码的方式来实现异步;既然有异步,那么直接操作UI的View层明显是不太适合使用协程的,剩下的ViewModel与Model层则都很适合添加协程封装。我们先从ViewModel开始添加协程,幸运的是Google已经考虑到了这一层,并为我们提供了相关依赖,导入方式如下:

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

在导入此依赖后,会为ViewModel添加一个名为viewModelScope的扩展函数,此函数会创建一个做了优化的协程,源码如下:

val ViewModel.viewModelScope: CoroutineScope
        get() {
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) {
                return scope
            }
            return setTagIfAbsent(JOB_KEY,
                CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
        }

拿到viewModelScope后,我们就可以在BaseViewModel添加如下代码:

abstract class BaseViewModel : ViewModel(), ViewModelLifecycle, ViewBehavior {
  /**
     * 在主线程中执行一个协程
     */
    protected fun launchOnUI(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(Dispatchers.Main) { block() }
    }

    /**
     * 在IO线程中执行一个协程
     */
    protected fun launchOnIO(block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(Dispatchers.IO) { block() }
    }
}

二、与Retrofit的结合

目前在Android开发中,最主流的网络请求框架应该就是Retrofit+OkHttp+RxJava这一套了。那么下面我们就使用Retrofit来结合协程进行封装。在网络请求中,协程起的作用其实与RxJava是一致的,所以如果在别处没有使用RxJava的需求,此处可以不引入RxJava,只需引入Retrofit+OkHttp

Interface

interface FlyInterface {
    /**
     * 获取文章列表
     */
    @GET("article/")
    suspend fun get_article_list(@Query("page_size") size: Int): ApiResponse<CommonListDto<Article>>
}

interface的改造非常简单,仅仅是在函数前加上suspend修饰。

ApiResponse

abstract class HttpResponse<T>(val code: Int, val msg: String, val data: T?) {
    abstract fun isSuccess(): Boolean
}

class ApiResponse<T>(code: Int, msg: String, data: T?) : HttpResponse<T>(code, msg, data) {

    override fun isSuccess(): Boolean {
        return code == 0
    }
}

ApiResponse是上述interface中函数的返回值,实现也非常简单。因为接口返回的数据格式一般都是统一的,例如:

{
    "code": 0;
    "message": "Success";
    "data": {
        ...
    }
}

所以,我们也需要将返回的数据格式用一个统一的数据模型来处理。

HttpError

我们可以事先定义一些事先常见的网络错误,方便后续使用。

enum class HttpError(val code: Int, @StringRes val message: Int) {
    // 未知错误
    UNKNOWN(-1, R.string.fly_http_error_unknow),

    // 网络连接错误
    CONNECT_ERROR(-2, R.string.fly_http_error_connect),

    // 连接超时
    CONNECT_TIMEOUT(-3, R.string.fly_http_error_connect_timeout),

    // 错误的请求
    BAD_NETWORK(-4, R.string.fly_http_error_bad_network),

    // 数据解析错误
    PARSE_ERROR(-5, R.string.fly_http_error_parse),

    // 取消请求
    CANCEL_REQUEST(-6, R.string.fly_http_cancel_request),
}

Retrofit

相信大部分同学在使用Retrofit时都会自己做二次封装的,此处就不附上详细的代码了,主要看关键代码,需要完整代码的可以去小益的Github上自行查看。

class BaseHttpClient {
    ......
   /**
     * 获取service对象
     *
     * @param service api所在的interface
     */
    fun <T> getService(service: Class<T>): T {
        var retrofitService: T? = serviceCache.get(service.canonicalName) as T
        if (retrofitService == null) {
            retrofitService = retrofitClient.create(service)
            serviceCache.put(service.canonicalName, retrofitService)
        }
        return retrofitService!!
    }

    /**
     * 建议调用此方法发送网络请求
     * 因为协程中出现异常时,会直接抛出异常,所以使用try...catch方法捕获异常
     */
    suspend fun <T : Any, D : Any> requestSafely(
        apiInterface: Class<T>,
        call: suspend (service: T) -> HttpResponse<D>
    ): ParseResult<D> {
        try {
            val s = getService(apiInterface)
            val response = call(s)
            return if (response.isSuccess()) {
                ParseResult.Success(response.data)
            } else {
                ParseResult.Failure(response.code, response.msg)
            }
        } catch (ex: Throwable) {
            return ParseResult.ERROR(ex, parseException(ex))
        }
    }
    ......
}
  • getService:获取我们定义的interface
  • requestSafely:此方法中最值得注意的是try...catch,因为使用协程来进行网络请求时,如遇到问题会抛出异常,所以此处使用try...catch捕获。另外,此方法也对返回的Response做了简单的解析处理,并返回具体的ParseResult

ParseResult

sealed class ParseResult<out T : Any> {
    /* 请求成功,返回成功响应  */
    data class Success<out T : Any>(val data: T?) : ParseResult<T>()

    /* 请求成功,返回失败响应 */
    data class Failure(val code: Int, var msg: String? = null) :
        ParseResult<Nothing>()

    /* 请求失败,抛出异常 */
    data class ERROR(val ex: Throwable, val error: HttpError) : ParseResult<Nothing>()

    private var successBlock: (suspend (data: T?) -> Unit)? = null
    private var failureBlock: (suspend (code: Int, msg: String?) -> Unit)? = null
    private var errorBlock: (suspend (ex: Throwable, error: HttpError) -> Unit)? = null
    private var cancelBlock: (suspend () -> Unit)? = null

    /**
     * 设置网络请求成功处理
     */
    fun doSuccess(successBlock: (suspend (data: T?) -> Unit)?): ParseResult<T> {
        this.successBlock = successBlock
        return this
    }

    /**
     * 设置网络请求失败处理
     */
    fun doFailure(failureBlock: (suspend (code: Int, msg: String?) -> Unit)?): ParseResult<T> {
        this.failureBlock = failureBlock
        return this
    }

    /**
     * 设置网络请求异常处理
     */
    fun doError(errorBlock: (suspend (ex: Throwable, error: HttpError) -> Unit)?): ParseResult<T> {
        this.errorBlock = errorBlock
        return this
    }

    /**
     * 设置网络请求取消处理
     */
    fun doCancel(cancelBlock: (suspend () -> Unit)?): ParseResult<T> {
        this.cancelBlock = cancelBlock
        return this
    }

    suspend fun procceed() {
        when (this) {
            is Success<T> -> successBlock?.invoke(data)
            is Failure -> failureBlock?.invoke(code, msg)
            is ERROR -> {
                if (this.error == HttpError.CANCEL_REQUEST) {
                    cancelBlock?.invoke()
                } else {
                    errorBlock?.invoke(ex, error)
                }
            }
        }
    }
}

ParseResult是对HttpResponse解析后返回的类。ParseResult解析HttpResponse后出现三种返回:

  • Success:继承于ParseResult,网络请求成功并且返回的的Response状态也是成功,持有具体的Response数据
  • Failure:继承于ParseResult,网络请求成功但是返回的Response状态是失败,持有失败的Code码与Message
  • Error:继承于ParseResult,网络请求异常,未成功,持有异常信息

ParseResultdo开头的函数都是设置对应处理的代码块,另外有个procceed函数是真正执行响应处理。其中在对Error处理时分为了两种情况:

  • 一种是因为网络请求被取消产生的异常(经测试,网络请求取消会抛出取消异常)
  • 另一种是非网络请求取消产生的异常

因为网络请求取消从一定程度上来说不应该当作错误处理,所以要分开处理;防止项目中对异常错误进行了集中处理,比如弹出toast提示,此时如果用户取消了网络请求,也弹出一个网络请求取消的提示,这样的用户体验就比较糟糕了。

具体使用

fun get_article_list() {
        launchOnUI {
            ApiClient.getInstance()
                .requestSafely(FlyInterface::class.java) {
                    it.get_article_list(20)
                }.doSuccess {
                    articleList.value = it!!.results
                }
                .doFailure { code, msg -> showToast(msg ?: "获取文章列表失败") }
                .doError { ex, error -> showToast(error.message) }
                .procceed()
        }
    }

此处的ApiClientBaseHttpClient的子类即对Retrofit+OkHttp的封装,并做了单例处理,整个请求流程呈现链式结构。虽然doSuccessdoFailure以及doError看上去有些像回调,但其实都是同步的。我们完全可以这么写:

fun get_article_info() {
        launchOnUI {
            println(">>>>>>  开始")
            var articles = ArrayList<Article>()
            ApiClient.getInstance()
                .requestSafely(FlyInterface::class.java) {
                    it.get_article_list(20)
                }.doSuccess {
                    println(">>>>>>  第一次")
                    articles = it!!.results
                }
                .procceed()
            ApiClient.getInstance()
                .requestSafely(FlyInterface::class.java) {
                    it.get_article(articles[0].id)
                }.doSuccess {
                    println(">>>>>>  第二次")
                }
                .procceed()
            println(">>>>>>  结束")
        }
    }

先获取文章列表,再从文章列表中提取列表头部的文章ID用于获取文章详情,最后打印的结果为:

开始
第一次
第二次
结束

可以看出,完全是顺序执行。

请求并发

fun get_info() {
    launchOnUI {
        val listAsync = async {
            var articles = ArrayList<Article>()
            ApiClient.getInstance()
                .requestSafely(FlyInterface::class.java) {
                    it.get_article_list(20)
                }.doSuccess {
                    articles = it!!.results
                }
                .procceed()
            return@async articles
        }
        val detailAsync = async {
            var article: Article? = null
                ApiClient.getInstance()
                    .requestSafely(FlyInterface::class.java) {
                        it.get_article(2)
                    }.doSuccess {
                        article = it
                    }
                    .procceed()
            return@async article!!
        }
        val articles = listAsync.await()
        val articleDetail = detailAsync.await()
    }
}

使用async实现并发,同时请求文章列表和文章详情,并获取对应的值。

三、老项目使用协程

协程很香,这毋庸置疑,但是对于一些已经使用了回调形式的网络请求的老项目来说,将所有的网络请求改为上述的协程形式是不现实的,而如果既想不改动原来的回调形式,又想使用协程,有没有办法呢?当然是有的!

首先我们看下回调形式下的网络请求:

 HttpClient.getInstance().addGetDataCallback(url:String, object :SimpleAppGetCallback<T>(){
            override fun onSuccess(data: T?) {
            }

            override fun onFailure(code: Int, msg: String) {
            }

            override fun onError(ex: Throwable, error: HttpError) {
            }
})

上述的代码形式应该是大部分网络请求回调的形式了,下面我们改造一下:

suspend fun <T : SimpleAppGetCallback<E>, E> T.await(url:String) =
    suspendCoroutine<ParseResult<E>> { coroutine ->
        HttpClient.getInstance().addGetDataCallback(url, object :SimpleAppGetCallback<E>(){
        override fun onSuccess(data: E?) {
            coroutine.resume(ParseResult.Success(data))
        }
        override fun onFailure(code: Int, msg: String) {
            coroutine.resume(ParseResult.Failure(code,msg))
        }
        override fun onError(ex: Throwable, error: HttpError) {
            coroutine.resume(ParseResult.Error(code,msg))
        }
    })
}

上述改造中,我们对SimpleAppGetCallback增加一个扩展函数await()await()返回的是一个suspendCoroutine<ParseResult<E>>(即一个协程,ParseResult是前面内容中提到的类),在suspendCoroutine中进行了完整的回调式网络请求,并在回调中使用coroutine.resume()方法将请求的结果传递给协程,最后看一下使用:

SimpleAppGetCallback<UserInfo>().await("https://www.liyisite.com")
        .doSuccess{ }
        .doFailure{ code,msg -> }
        .doError{ ex, error ->}
        .procceed()

可以看到使用方法与我们定义的协程式请求几乎一样。

四、小结

本章内容主要是介绍协程在Android开发中的实际应用。因为文章主要是关于MVVM架构的,一些协程的特性并未详细讲解,比如父协程取消,未执行完毕的子协程也会被取消等等。感兴趣的同学可以自行去实践或者去看小益的Github项目【Fly-Android】。本文的全部代码已上传至Github,项目地址为:https://github.com/albert-lii/Fly-Android,欢迎大家关注!

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

推荐阅读更多精彩内容