iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 8
0
Software Development

Android Architecture系列 第 8

LiveData整合API資料與連線狀態

透過API取得資料時我們會需要知道連線是成功或失敗以更新UI,當架構以LiveData來傳遞資料時,要取得連線狀態有兩種簡單的方式:

  1. 除了傳遞資料的LiveData之外,再創另一個LiveData來傳遞連線狀態。
  2. 建立一個class將資料和連線狀態打包(wrap)在一起傳遞。

之前提到我們的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;
    }

1.新增LiveData傳遞連線狀態

除了原本的repos之外再建立一個LiveData來傳遞連線狀況,概念圖:
Dealing with data state
        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更新畫面。

2.打包資料和連線狀態

打包(wrap)的方式是建立一個class接收連線回傳的body、code和message等欄位,其中需注意的是連線成功與失敗兩者的回傳值會不同:
https://ithelp.ithome.com.tw/upload/images/20171227/20103849EDLIe7wqZm.png
如上圖所示,連線成功時會收到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


上一篇
Transform LiveData
下一篇
Dependency Injection with Dagger2: Part 1
系列文
Android Architecture30

尚未有邦友留言

立即登入留言