Encapsulation attempt of network request framework of Retrofit + Kotlin + MVVM

1. Foreword

When learning Guo Lin's first line of code, I wrote a Caiyun weather App step by step. I was very impressed by the encapsulation of the network request framework inside. I like the combination of Retrofit + Kotlin + collaboration. Later, I also referred to this part of the code in my own project. However, with the in-depth writing of code and the complexity of functions, the original framework can no longer meet my needs. The primary pain points are as follows:

  • Missing failed callback
  • It is troublesome to display the animation in loading

Later, I tried to encapsulate a simple and easy-to-use framework. Unfortunately, my personal ability is limited, and the framework I encapsulated is always unsatisfactory. Fortunately, there are many excellent blogs and codes for reference. On this basis, the network request framework in Caiyun weather App has been modified to be as simple and easy to use as possible. Take the login interface of requesting to play android as an example (the user name and password are applied by myself, see the code). There is a button on the page. Click the button to initiate the login request.

Let's take a look at the callback after the request is initiated:

viewModel.loginLiveData.observeState(this) {
    onStart {
        LoadingDialog.show(activity)
        Log.d(TAG, "Request start")
    }
    onSuccess {
        Log.d(TAG, "Request succeeded")
        showToast("Login succeeded")
        binding.tvResult.text = it.toString()
    }
    onEmpty {
        showToast("Data is empty")
    }
    onFailure {
        Log.d(TAG, "request was aborted")
        showToast(it.errorMsg.orEmpty())
        binding.tvResult.text = it.toString()
    }
    onFinish {
        LoadingDialog.dismiss(activity)
        Log.d(TAG, "End of request")
    }
}

There are five types of callbacks, which will be described in detail below. The DSL writing method is adopted here. If you like the traditional writing method, you can call another extension method observeResponse(). Because its last parameter is the callback for successful request, it can be succinctly written in the following form with the help of the characteristics of Lambda expression:

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

If you need other callbacks, you can use the named parameter plus, as shown below:

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

2. Frame construction

Before starting, it must be noted that this framework is based on the Caiyun weather App in the first line of code (Third Edition). Its architecture is shown below. If you have read the first line of code or relevant Google documents, you must be familiar with it.

2.1 adding dependent Libraries

//Simplify code for declaring ViewModel in Activity
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"

//Logging Interceptor 
implementation('com.github.ihsanbal:LoggingInterceptor:3.1.0') {
    exclude group: 'org.json', module: 'json'
}

2.2 Retrofit builder

The Retrofit builder is layered here. The base class has some basic configurations. After subclass inheritance, you can add new configurations and configure your favorite log interceptors.

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)

    /**
     * Subclass customize OKHttpClient configuration
     */
    abstract fun handleOkHttpClientBuilder(builder: OkHttpClient.Builder)

    /**
     * Configure log interceptor
     */
    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 global exception handling

Unexpected situations such as network disconnection and Json parsing failure may be encountered during the request. If we have to deal with these exceptions every request, it will be too troublesome. The correct approach is to handle exceptions together.

Create an enumeration class that defines various exceptions:

enum class HttpError(val code: Int, val message: String){
    UNKNOWN(-100,"unknown error"),
    NETWORK_ERROR(1000, "The network connection timed out. Please check the network"),
    JSON_PARSE_ERROR(1001, "Json Parsing failed")
    //······
}

Create a file and define a global method in it to handle various exceptions:

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

Of course, there are more than these exceptions encountered in the actual project. Here is only a small part as an example, which can be enriched and improved in the actual opening.

2.4 callback status monitoring

There are four callback states:

  • onStart(): request start (loading animation can be shown here)
  • onSuccess(): request succeeded
  • onEmpty(): the request succeeds, but data is null or data is a collection type but empty
  • onFailure(): request failed
  • onFinish(): the request ends (you can turn off loading animation here)

Note the onSuccess standard here: it is not only that the status code of the Http request is equal to 200, but also to meet the Api request success standard. Take Api playing android as an example. When the errorCode is 0, the initiated request is executed successfully; Otherwise, it should be classified as onFailure() (refer to the mind map attached to the article).

After sorting out several callback States, you can monitor. So where to monitor? The second function of the observe() method of LiveData can pass in the observer parameter. Observer is an interface. We inherit it and customize an observer, so that we can monitor the changes of LiveData values.

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

    override fun onChanged(response: BaseResponse<T>?) {
        when (response) {
            is StartResponse -> {
                //After the onStart() callback, onFinish() cannot be called directly. You must wait for the request to end
                onStart()
                return
            }
            is SuccessResponse -> onSuccess(response.data)
            is EmptyResponse -> onEmpty()
            is FailureResponse -> onFailure(response.exception)
        }
        onFinish()
    }

    /**
     * Request start
     */
    fun onStart()

    /**
     * The request succeeded and the data is not null
     */
    fun onSuccess(data: T)

    /**
     * The request succeeded, but data is null or data is a collection type but empty
     */
    fun onEmpty()

    /**
     * request was aborted
     */
    fun onFailure(e: RequestException)

    /**
     * End of request
     */
    fun onFinish()
}

Next, we prepare an HttpRequestCallback class to implement the callback form of DSL:

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

Then declare a new listening method. Considering that you need to customize livedata sometimes (for example, to solve the problem of data backflow), here you use the writing method of extension function to facilitate expansion.

/**
 * Listen for the change of LiveData value, and the callback is in the form of 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()
        }
    })
}

/**
 * Listen for changes in the value of 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 encapsulation of repository layer

As the source of data, the Repository layer has two channels: network request and database. Only network requests are processed here for the time being.

Base class 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 {
            //If the status code is 200, continue to judge whether the errorCode is 0
            emit(
                when (it.success) {
                    true -> checkEmptyResponse(it.data)
                    false -> FailureResponse(handleException(RequestException(it)))
                }
            )
        }.onFailure { throwable ->
            emit(FailureResponse(handleException(throwable)))
        }
    }

    /**
     * data null, or data is a collection type, but the collection is empty, will enter the onEmpty callback
     */
    private fun <T> checkEmptyResponse(data: T?): ApiResponse<T> =
        if (data == null || (data is List<*> && (data as List<*>).isEmpty())) {
            EmptyResponse()
        } else {
            SuccessResponse(data)
        }
}

Subclass Repository:

object Repository : BaseRepository() {

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

}

Network request data source, call the network interface here:

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

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

2.6 encapsulation of ViewModel layer

ViewModel basically follows the writing method in the first line of code and creates two LiveData. When the user clicks the button, the value of loginAction will change and trigger the code in switchMap to request data.

class MainViewModel : ViewModel() {

    private val loginAction = MutableLiveData<Boolean>()

    /**
     * loginAction Here, only Boolean values are passed, and no password is passed. In the actual project, DataBinding will be used to bind xml layout and ViewModel,
     * There is no need to pass the password into ViewModel from Activity or Fragment
     */
    val loginLiveData = loginAction.switchMap {
        if (it) {
            Repository.login("PuKxVxvMzBp2EJM")
        } else {
            Repository.login("123456")
        }
    }

    /**
     * Click login
     */
    fun login() {
        loginAction.value = true
    }

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

Note: this method usually does not transfer data from the View to the ViewModel layer, but needs to be combined with DataBinding. If you don't want to write like this, you can modify the return value in the BaseRepository and directly return the BaseResponse.

3. Mind map and source code

Finally, a mind map is used to summarize this paper:

Source address: GitHub (note that dev1.0 should be selected for the branch)

reference resources

Keywords: Android kotlin

Added by Judas on Sun, 07 Nov 2021 06:14:26 +0200