透過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