Android 關(guān)于 Retrofit 回調(diào)的正確處理方式

1. 疑惑

對于 Activity,發(fā)起網(wǎng)絡(luò)請求后得到一個 null,到底應(yīng)不應(yīng)該刷新列表?

或者說,應(yīng)該如何處理 Retrofit 的回調(diào)才顯得合理并且優(yōu)雅呢?

2. 結(jié)論

// 總的原則:
// 1. 返回 null 認(rèn)為是非正常情況,不刷新列表
// 2. 返回非 null 才刷新列表,如果是用戶在 PC 上清空了數(shù)據(jù)(如瀏覽記錄),
// 手機上刷新時需要服務(wù)器返回一個空列表(size 為 0)而不是 null
// 3. ViewModel 中任何 case 都要轉(zhuǎn)調(diào)到 Activity 里去,否則可能出現(xiàn)下拉刷新無法結(jié)束的問題

3. 代碼

這里進行了簡單封裝,參見 SimpleCallback,只需要一句話即可發(fā)起請求并將結(jié)果通過 LiveData 傳遞出去。

Api.mApiService.getTestData().enqueue(SimpleCallback(mTestData))

activity_retrofit_callback.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="8dp">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/linearLayout"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:listitem="@android:layout/simple_list_item_1" />

    <LinearLayout
        android:id="@+id/linearLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent">

        <Button
            android:id="@+id/normal"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="normal"
            android:textAllCaps="false" />

        <Button
            android:id="@+id/empty"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_weight="1"
            android:text="empty"
            android:textAllCaps="false" />

        <Button
            android:id="@+id/error"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_weight="1"
            android:text="error"
            android:textAllCaps="false" />

        <Button
            android:id="@+id/timeout"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_weight="1"
            android:text="timeout"
            android:textAllCaps="false" />

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

RetrofitCallbackActivity.kt

class RetrofitCallbackActivity : BaseActivity() {
    private lateinit var mAdapter: MyAdapter
    private val mViewModel: MyViewModel by viewModels()
    private var mDataType = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        initRetrofit()
        initUi()
        initViewModel()
    }

    private fun initViewModel() {
        mViewModel.mTestData.observe(this) {
            // 總的原則:
            // 1. 返回 null 認(rèn)為是非正常情況,不刷新列表
            // 2. 返回非 null 才刷新列表,如果是用戶在 PC 上清空了數(shù)據(jù)(如瀏覽記錄),
            //    手機上刷新時需要服務(wù)器返回一個空列表(size 為 0)而不是 null

            it?.let {
                // 針對本身列表為空的情況,要求在 ViewModel 中返回空列表而非 null
                mAdapter.setList(it)
            }

            // TODO 在這里結(jié)束下拉刷新
            // 這就要求 ViewModel 在所有 case 下都必須調(diào)用 MutableLiveData#setValue(),
            // 否則會出現(xiàn)下拉狀態(tài)無法結(jié)束的問題
        }

        mViewModel.mResultTestData.observe(this) {
            ToastUtils.showShort("code: ${it.code}")
        }
    }

    private fun initUi() {
        setContentView(R.layout.activity_retrofit_callback)
        val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = MyAdapter().apply {
            mAdapter = this
        }
        mAdapter.setEmptyView(TextView(this).apply {
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
            gravity = Gravity.CENTER
            text = "Empty!"
            setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
        })

        findViewById<Button>(R.id.normal).setOnClickListener {
            mDataType = 0
            mViewModel.getTestData()
        }

        findViewById<Button>(R.id.empty).setOnClickListener {
            mDataType = 1
            mViewModel.getTestData()
        }

        findViewById<Button>(R.id.error).setOnClickListener {
            mDataType = 2
            mViewModel.getResultTestData()
        }

        findViewById<Button>(R.id.timeout).setOnClickListener {
            mDataType = 3
            mViewModel.getTestData()
        }
    }

    private fun initRetrofit() {
        val client = OkHttpClient.Builder()
            .connectTimeout(3, TimeUnit.SECONDS)
            .readTimeout(3, TimeUnit.SECONDS)
            .writeTimeout(3, TimeUnit.SECONDS)
            .addInterceptor { chain ->
                // error
                if (mDataType == 3) {
                    throw SocketTimeoutException("timeout occurred.")
                }

                val body = ResponseBody.create(MediaType.parse("application/json"), mockData())
                okhttp3.Response.Builder()
                    .request(chain.request())
                    .protocol(Protocol.HTTP_1_1)
                    .code(200)
                    .body(body)
                    .message("OK")
                    .build()
            }.build()

        Retrofit.Builder()
            // 注釋掉 interceptor 后:
            // 1. 模擬 failed to connect xxx, 走 onFailure()
            // .baseUrl("https://xxx.com/")
            // 2. 模擬 404, 走 onResponse()
            //    但是 isSuccessful 為 false 且 response.body() 為空且 response.errorBody() 不為空
            .baseUrl("https://www.baidu.com/")
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ApiService::class.java).also {
                Api.mApiService = it
            }
    }

    private fun mockData(): String {
        when (mDataType) {
            // normal
            0 -> {
                return """
                    {
                        "code": 200,
                        "data": [
                            {"name": "this is 0"},
                            {"name": "this is 1"},
                            {"name-error": "this is 2"},
                            {"name": "this is 3"},
                            {"name": "this is 4"}
                        ],
                        "message": "success"
                    }
                """.trimIndent()
            }
            // empty
            1 -> {
                return """
                    {
                        "code": 200,
                        "data": [],
                        "message": "success"
                    }
                """.trimIndent()
            }
            // error
            2 -> {
                return """
                    {
                        "code": 500,
                        "d": [],
                        "message": "success"
                    }
                """.trimIndent()
            }
        }

        return ""
    }
}

class MyViewModel : ViewModel() {
    val mTestData = MutableLiveData<List<Item>?>()

    fun getTestData() {
        Api.mApiService.getTestData().enqueue(SimpleCallback(mTestData))
    }

    val mResultTestData = MutableLiveData<Result<List<Item>?>>()

    fun getResultTestData() {
        Api.mApiService.getTestData().enqueue(ResultCallBack(mResultTestData))
    }
}

private class MyAdapter :
    BaseQuickAdapter<Item, BaseViewHolder>(android.R.layout.simple_list_item_1, null) {
    override fun convert(holder: BaseViewHolder, item: Item) {
        if (item.name == null) {
            holder.setText(android.R.id.text1, "parse error!")
        } else {
            holder.setText(android.R.id.text1, item.name)
        }
    }
}

/**
 * 通常 UI 界面只需要 data 部分
 */
class SimpleCallback<T>(private val mLiveData: MutableLiveData<T?>) : Callback<Result<T?>> {
    override fun onResponse(call: Call<Result<T?>>, response: Response<Result<T?>>) {
        mLiveData.value = Api.handleResponse(response)
    }

    override fun onFailure(call: Call<Result<T?>>, t: Throwable) {
        mLiveData.value = null
        ToastUtils.showShort(t.message)
    }
}

/**
 * 如果確實需要 code 和 message 的,可以用該類
 */
class ResultCallBack<T>(private val mLiveData: MutableLiveData<Result<T?>>) : Callback<Result<T?>> {
    override fun onResponse(call: Call<Result<T?>>, response: Response<Result<T?>>) {
        val result = response.body()
        if (result != null) {
            mLiveData.value = result
        } else {
            mLiveData.value = Result(response.message(), response.code(), null)
        }
    }

    override fun onFailure(call: Call<Result<T?>>, t: Throwable) {
        mLiveData.value = Result(t.message, -1, null)
        ToastUtils.showShort(t.message)
    }
}

object Api {
    lateinit var mApiService: ApiService

    /**
     * 根據(jù) response 獲取 Result 中的 data 數(shù)據(jù)
     */
    fun <T> handleResponse(response: Response<Result<T?>>): T? {
        val result = response.body()
        if (result != null) {
            // 成功: 如 200,即使成功的情況下 data 也可能為 null:
            // 1. 字段解析導(dǎo)致為 null,這是 GsonConverterFactory 解析 json 時導(dǎo)致的
            // 2. 服務(wù)端沒直接返回 null,理論上沒數(shù)據(jù)應(yīng)該返回空列表的,
            //    但是服務(wù)端硬是返回了 null,這里強制要求服務(wù)端返回空列表,
            //    否則客戶端不會認(rèn)為是數(shù)據(jù)清空(如歷史記錄)而會認(rèn)為是發(fā)生了錯誤。

            // 同服務(wù)約定 message 不為空時表示需要提示用戶, 也可以約定用 code 來判斷
            if (result.code != 200 && !result.message.isNullOrEmpty()) {
                ToastUtils.showShort(result.message)
            }

            // data 可能為 null, 這種情況下會不更新列表
            return result.data
        } else {
            // 失敗: 如 404/500, 這種情況下不更新列表
            // 另外:204/205 的情況下,result 也為空
            ToastUtils.showShort(response.message())
            return null
        }
    }
}

/**
 * 定義實體類 T 時,其中的非基本數(shù)據(jù)類型一定要定義為可空的,
 * 因為 GsonConverterFactory 解析 json 時完全有可能對某項數(shù)據(jù)賦值為空,
 * 如果又定義為了非空,那么使用的時候就可能發(fā)生空指針。
 */
data class Result<T>(val message: String?, val code: Int, val data: T?)

/**
 * 內(nèi)部字段必須定義為可空!
 */
data class Item(val name: String?)

interface ApiService {
    @GET("api/test")
    fun getTestData(): Call<Result<List<Item>?>>
}

4. 效果

效果.gif

5. 最后

鼓掌.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容