1. 疑惑
对于 Activity,发起网络请求后得到一个 null,到底应不应该刷新列表?
或者说,应该如何处理 Retrofit 的回调才显得合理并且优雅呢?
2. 结论
// 总的原则:
// 1. 返回 null 认为是非正常情况,不刷新列表
// 2. 返回非 null 才刷新列表,如果是用户在 PC 上清空了数据(如浏览记录),
// 手机上刷新时需要服务器返回一个空列表(size 为 0)而不是 null
// 3. ViewModel 中任何 case 都要转调到 Activity 里去,否则可能出现下拉刷新无法结束的问题
3. 代码
这里进行了简单封装,参见 SimpleCallback,只需要一句话即可发起请求并将结果通过 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 认为是非正常情况,不刷新列表
// 2. 返回非 null 才刷新列表,如果是用户在 PC 上清空了数据(如浏览记录),
// 手机上刷新时需要服务器返回一个空列表(size 为 0)而不是 null
it?.let {
// 针对本身列表为空的情况,要求在 ViewModel 中返回空列表而非 null
mAdapter.setList(it)
}
// TODO 在这里结束下拉刷新
// 这就要求 ViewModel 在所有 case 下都必须调用 MutableLiveData#setValue(),
// 否则会出现下拉状态无法结束的问题
}
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
/**
* 根据 response 获取 Result 中的 data 数据
*/
fun <T> handleResponse(response: Response<Result<T?>>): T? {
val result = response.body()
if (result != null) {
// 成功: 如 200,即使成功的情况下 data 也可能为 null:
// 1. 字段解析导致为 null,这是 GsonConverterFactory 解析 json 时导致的
// 2. 服务端没直接返回 null,理论上没数据应该返回空列表的,
// 但是服务端硬是返回了 null,这里强制要求服务端返回空列表,
// 否则客户端不会认为是数据清空(如历史记录)而会认为是发生了错误。
// 同服务约定 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 时,其中的非基本数据类型一定要定义为可空的,
* 因为 GsonConverterFactory 解析 json 时完全有可能对某项数据赋值为空,
* 如果又定义为了非空,那么使用的时候就可能发生空指针。
*/
data class Result<T>(val message: String?, val code: Int, val data: T?)
/**
* 内部字段必须定义为可空!
*/
data class Item(val name: String?)
interface ApiService {
@GET("api/test")
fun getTestData(): Call<Result<List<Item>?>>
}