透過API取得資料時我們會需要知道連線是成功或失敗以更新UI,當架構以LiveData來傳遞資料時,要取得連線狀態有兩種簡單的方式:
之前提到我們的API連線失敗時還沒處理,我們會選用第2種方法因為我個人覺得這樣Model和ViewModel比較簡潔,不過還是兩種方式都會說明一下。
目前的程式是這樣:
public MutableLiveData<List<Repo>> searchRepo(String query) {
final MutableLiveData<List<Repo>> repos = new MutableLiveData<>();
githubService.searchRepos(query)
.enqueue(new Callback<RepoSearchResponse>() {
@Override
public void onResponse(Call<RepoSearchResponse> call, Response<RepoSearchResponse> response) {
repos.setValue(response.body().getItems());
}
@Override
public void onFailure(Call<RepoSearchResponse> call, Throwable t) {
// TODO: error handle
}
});
return repos;
}
除了原本的repos之外再建立一個LiveData來傳遞連線狀況,概念圖:
Dealing with data state
概念程式碼:
public class DataModel {
...
MutableLiveData<List<Repo>> repos = new MutableLiveData<>();
MutableLiveData<State> state = ...;
public void searchRepo(String query) {
githubService.searchRepos(query)
.enqueue(new Callback<RepoSearchResponse>() {
@Override
public void onResponse(Call<RepoSearchResponse> call, Response<RepoSearchResponse> response) {
repos.setValue(response.body().getItems());
state.setValue(...success state);
}
@Override
public void onFailure(Call<RepoSearchResponse> call, Throwable t) {
state.setValue(...failure state);
}
});
}
}
建立另一個LiveData state,並在連線成功或失敗時setValue()
。如概念圖所示讓ViewModel同時持有資料repos和連線狀態state,View就可以依照這兩個LiveData更新畫面。
打包(wrap)的方式是建立一個class接收連線回傳的body、code和message等欄位,其中需注意的是連線成功與失敗兩者的回傳值會不同:
如上圖所示,連線成功時會收到Response,而連線失敗時則收到Throwable,這在建立class時也要考量進去。
那麼就來建立我們的wrapper class了,GitHubBrowserSample中用的是ApiResponse,而我們不需要其中的分頁功能所以將其修改一下:
/**
* Common class used by API responses.
* @param <T>
*/
public class ApiResponse<T> {
public final int code;
@Nullable
public final T body;
@Nullable
public final String errorMessage;
public ApiResponse(Throwable error) {
code = 500;
body = null;
errorMessage = error.getMessage();
}
public ApiResponse(Response<T> response) {
code = response.code();
if(response.isSuccessful()) {
body = response.body();
errorMessage = null;
} else {
String message = null;
if (response.errorBody() != null) {
try {
message = response.errorBody().string();
} catch (IOException ignored) {
Timber.e(ignored, "error while parsing response");
}
}
if (message == null || message.trim().length() == 0) {
message = response.message();
}
errorMessage = message;
body = null;
}
}
public boolean isSuccessful() {
return code >= 200 && code < 300;
}
}
ApiResponse接收code、bode和errorMessage欄位,利用Constructor區別是連線成功或失敗,當連線成功時我們就將Response的這三個欄位存起來,而當連線失敗收到Throwable時就讓code為500及data為null。
其中的Timber是一個印出log的library,可以看這裡的說明,如果不想用的話就改成一般的Log.e就可以了。
最後的isSuccessful()
用來輔助判斷連線是否"成功",因為只要連線有收到response就算成功,但response code可能是404,所以通常會用isSuccessful()
判斷response code是否為2xx來決定要不要顯示錯誤訊息。
於DataModel中加入ApiResponse:
public class DataModel {
...
public MutableLiveData<ApiResponse<RepoSearchResponse>> searchRepo(String query) {
final MutableLiveData<ApiResponse<RepoSearchResponse>> repos = new MutableLiveData<>();
githubService.searchRepos(query)
.enqueue(new Callback<RepoSearchResponse>() {
@Override
public void onResponse(@NonNull Call<RepoSearchResponse> call,
@NonNull Response<RepoSearchResponse> response) {
repos.setValue(new ApiResponse<>(response));
}
@Override
public void onFailure(@NonNull Call<RepoSearchResponse> call,
@NonNull Throwable throwable) {
repos.setValue(new ApiResponse<RepoSearchResponse>(throwable));
}
});
return repos;
}
}
把repos的類型從List<Repo>
改成ApiResponse<RepoSearchResponse>
,就是將原本連線回傳的POJO RepoSearchResponse用ApiResponse包起來,而onResponse和onFailure中用ApiResponse兩個不同的Constructor來更新value,這樣不管連線成功或失敗repos都會發出ApiResponse<RepoSearchResponse>
,View在取得value時就可以變成這樣:
viewModel.getRepos().observe(this, new Observer<ApiResponse<RepoSearchResponse>>() {
@Override
public void onChanged(@Nullable ApiResponse<RepoSearchResponse> response) {
int code = response.code;
RepoSearchResponse data = response.body;
String msg = response.errorMessage;
}
});
我們同時得到連線狀態code和回傳的資料body,就達到同時取得連線狀態及資料的效果了。
不過看看上面的DataModel,有沒有覺得它也不像開頭所說的比較簡潔?既然都已經打包讓連線成功或失敗都一樣回傳ApiResponse了,如果能讓我們的Retrofit Call直接回傳LiveData<ApiResponse<...>>
那就更好了。
而Google也對此提出了解法,自訂Retrofit Adapter來將Call轉換成LiveData。首先建立LiveDataCallAdapter
/**
* A Retrofit adapter that converts the Call into a LiveData of ApiResponse.
* @param <R>
*/
public class LiveDataCallAdapter<R> implements CallAdapter<R, LiveData<ApiResponse<R>>> {
private final Type responseType;
public LiveDataCallAdapter(Type responseType) {
this.responseType = responseType;
}
@Override
public Type responseType() {
return responseType;
}
@Override
public LiveData<ApiResponse<R>> adapt(@NonNull final Call<R> call) {
return new LiveData<ApiResponse<R>>() {
AtomicBoolean started = new AtomicBoolean(false);
@Override
protected void onActive() {
super.onActive();
if (started.compareAndSet(false, true)) {
call.enqueue(new Callback<R>() {
@Override
public void onResponse(@NonNull Call<R> call,
@NonNull Response<R> response) {
postValue(new ApiResponse<>(response));
}
@Override
public void onFailure(@NonNull Call<R> call,
@NonNull Throwable throwable) {
postValue(new ApiResponse<R>(throwable));
}
});
}
}
};
}
}
其用途是將Call的結果以LiveData<ApiResponse>
回傳,概念跟我們上面的DataModel一樣。
接著建立LiveDataCallAdapterFactory
public class LiveDataCallAdapterFactory extends CallAdapter.Factory {
@Override
public CallAdapter<?, ?> get(@NonNull Type returnType, @NonNull Annotation[] annotations, @NonNull Retrofit retrofit) {
if (getRawType(returnType) != LiveData.class) {
return null;
}
Type observableType = getParameterUpperBound(0, (ParameterizedType) returnType);
Class<?> rawObservableType = getRawType(observableType);
if (rawObservableType != ApiResponse.class) {
throw new IllegalArgumentException("type must be a resource");
}
if (! (observableType instanceof ParameterizedType)) {
throw new IllegalArgumentException("resource must be parameterized");
}
Type bodyType = getParameterUpperBound(0, (ParameterizedType) observableType);
return new LiveDataCallAdapter<>(bodyType);
}
}
並修改RetrofitManager,加上Factory:
public class RetrofitManager {
...
private RetrofitManager() {
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(new LiveDataCallAdapterFactory())
.build();
githubService = retrofit.create(GithubService.class);
}
...
}
這樣之後的連線就可以自動將Call轉換成LiveData了。
接著就來修改DataModel讓它真的變簡潔,首先將API interface中的Call改成LiveData:
public interface GithubService {
@GET("search/repositories")
LiveData<ApiResponse<RepoSearchResponse>> searchRepos(@Query("q") String query);
}
接著就是DataModel:
public class DataModel {
...
public LiveData<ApiResponse<RepoSearchResponse>> searchRepo(String query) {
return githubService.searchRepos(query);
}
}
由於LiveDataCallAdapter會幫我們執行call並轉成LiveData,所以DataModel中不用再enqueue了,這樣就很簡潔了吧!
ViewModel只要把repos從LiveData<List<Repo>>
改成LiveData<ApiResponse<RepoSearchResponse>>
就好了:
public class RepoViewModel extends ViewModel {
...
private final LiveData<ApiResponse<RepoSearchResponse>> repos;
public RepoViewModel(final DataModel dataModel) {
...
repos = Transformations.switchMap(query, new Function<String, LiveData<ApiResponse<RepoSearchResponse>>>() {
@Override
public LiveData<ApiResponse<RepoSearchResponse>> apply(String userInput) {
if (TextUtils.isEmpty(userInput)) {
return AbsentLiveData.create();
} else {
return dataModel.searchRepo(userInput);
}
}
});
}
LiveData<ApiResponse<RepoSearchResponse>> getRepos() {
return repos;
}
...
}
最後在View中就可以依照連線狀態更新畫面:
viewModel.getRepos().observe(this, new Observer<ApiResponse<RepoSearchResponse>>() {
@Override
public void onChanged(@Nullable ApiResponse<RepoSearchResponse> response) {
if (response.isSuccessful()) {
repoAdapter.swapItems(response.body.getItems());
} else {
// ...show error message
}
...
}
});
以上建立ApiResponse及進一步用LiveDataCallAdapter打包的方式,讓Model可以保持在最簡潔的狀態,且ViewModel也不用持有兩個LiveData,在一個ApiResponse裡就包含連線狀態及資料。
GitHub source code:
https://github.com/IvanBean/ITBon2018/tree/day08-wrap-api-data-state
Reference:
ViewModels and LiveData: Patterns + AntiPatterns