22/03/2021, 08:41

7 bước cài đặt thư viện Paging trong Android

Gần đây mình có tìm hiểu về thư viện Paging - một phần của Android Jetpack. Mặc dù đã có một số resource về cách triển khai thư viện này trong một ứng dụng, nhưng mình đã phải đối mặt với rất nhiều vấn đề và phải tìm hiểu kỹ hơn về nó. Vì vậy mình nghĩ mình sẽ viết về 7 bước cơ bản để triển khai ...

Gần đây mình có tìm hiểu về thư viện Paging - một phần của Android Jetpack. Mặc dù đã có một số resource về cách triển khai thư viện này trong một ứng dụng, nhưng mình đã phải đối mặt với rất nhiều vấn đề và phải tìm hiểu kỹ hơn về nó. Vì vậy mình nghĩ mình sẽ viết về 7 bước cơ bản để triển khai thư viện Paging trong ứng dụng Android.

Thư viện Paging giúp cho việc tải dữ liệu phân trang trong ứng dụng của bạn dễ dàng hơn. Thư viện Paging cũng hỗ trợ cả list dữ liệu có giới hạn lớn, list dữ liệu không bị ràng buộc (ví dụ như cập nhật liên tục các nguồn cấp dữ liệu). Thư viện phân trang dựa trên ý tưởng gửi list dữ liệu tới giao diện người dùng dưới dạng livedata của list đó và được quan sát bởi RecyclerView.Adapter.

Mình đã lựa chọn xây dựng một ứng dụng cung cấp tin tức để test thư viện Paging. Nào chúng ta quay lại với chủ đề chính thôi, 7 bước để cài đặt thư viện.

apply plugin: 'com.android.application'

android {
    compileSdkVersion 27
    defaultConfig {
        applicationId "com.an.paginglibrary.sample"
        minSdkVersion 14
        targetSdkVersion 27
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }

  /* 
   * This is just to enable Java 8 in the app
   */
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}


/* 
 * I prefer using data binding so we need to enable it first
 */
android {
    dataBinding {
        enabled = true
    }
}


dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:27.1.1'
    implementation 'com.android.support:recyclerview-v7:27.1.1'
    implementation 'com.android.support:support-v4:27.1.1'
    implementation 'com.android.support:cardview-v7:27.1.1'
    implementation 'com.android.support:design:27.1.1'

    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

    /* 
     * Step 1: Add the paging library
     */
    implementation 'android.arch.paging:runtime:1.0.0'
    
    /* 
     * Step 2: Adding ViewModel and Lifecycle  
     */
    implementation 'android.arch.lifecycle:extensions:1.1.1'


    /* 
     * Step 3: Adding rxJava to the app  
     */
    implementation 'io.reactivex.rxjava2:rxjava:2.1.9'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'

    /* 
     * Step 4: Adding retrofit to the app  
     */
    implementation 'com.squareup.retrofit2:retrofit:2.4.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
    implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0'
    implementation 'com.squareup.okhttp3:logging-interceptor:3.9.1'


    /* 
     * Step 5: We would be needing an image loading library.
     * So we are going to use Picasso
     */
    implementation 'com.squareup.picasso:picasso:2.71828'
}

Tạo 1 interface **RestApi.java ** :

public interface RestApi {


    /* 
     * We would be using the below url:
     * https://newsapi.org/v2/everything?q=movies&apiKey=079dac74a5f94ebdb990ecf61c8854b7&pageSize=20&page=2
     * The url has four query parameters.
     * We would be changing the pageSize and the page
     */
    @GET("/v2/everything")
    Call<Feed> fetchFeed(@Query("q") String q,
                         @Query("apiKey") String apiKey,
                         @Query("page") long page,
                         @Query("pageSize") int pageSize);
}

Sau đó tạo class RestApiFactory.java :

  private static String BASE_URL = "https://newsapi.org";

    public static RestApi create() {
        HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
        logging.setLevel(HttpLoggingInterceptor.Level.BODY);

        OkHttpClient.Builder httpClient = new OkHttpClient.Builder();
        httpClient.addInterceptor(logging);

        Retrofit retrofit = new Retrofit.Builder().baseUrl(BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .client(httpClient.build())
                .build();
        return retrofit.create(RestApi.class);
    }

DataSource - Đây là base class để tải dữ liệu, được sử dụng trong phân trang danh sách. DataSource có thể được triển khai bằng cách sử dụng bất kỳ lớp nào trong 3 lớp sau:

PageKeyedDataSource: Chúng ta có thể sử dụng lớp này nếu chúng ta cần tải dữ liệu dựa trên số lượng trang trong DataSource. Ví dụ chúng ta truyền số trang làm tham số truy vấn trong request, số trang sẽ tăng tuần tự cho đến khi tất cả các trang được lấy về và hiển thị.

ItemKeyedDataSource: Bước đầu tiên là định nghĩa khóa, mục đích là để load dữ liệu ở page kế tiếp dựa trên khóa đó. Ví dụ như data class User thì trường id thường sẽ có key là userId. Kích thức của list là không xác định và việc lấy dữ liệu tiếp theo thường phụ thuộc vào id được biết gần đây nhất. Vì vậy nếu chúng ta lấy dữ liệu các mục từ 1 đến 10 trong kết quả đầu tiên thì ItemKeyedDataSource sẽ tự động lấy dữ liệu tiếp theo từ 11-20.

PositionalDataSource: Lớp này sẽ hữu ích cho các nguồn cung cấp danh sách có kích thước cố định, có thể lấy về với vị trí và kích thước tùy ý.

Trong bài viết này, mình sẽ sử dụng PageKeyedDataSource. Hãy cùng xem đoạn code dưới đây:

public class FeedDataSource extends PageKeyedDataSource<Long, Article> {

    private static final String TAG = FeedDataSource.class.getSimpleName();
    
  /*  
   * Step 1: Initialize the restApiFactory.
   * The networkState and initialLoading variables 
   * are for updating the UI when data is being fetched
   * by displaying a progress bar
   */
    private RestApiFactory restApiFactory;

    private MutableLiveData networkState;
    private MutableLiveData initialLoading;

    public FeedDataSource() {
        this.restApiFactory = RestApiFactory.create();
      
        networkState = new MutableLiveData();
        initialLoading = new MutableLiveData();
    }


    public MutableLiveData getNetworkState() {
        return networkState;
    }

    public MutableLiveData getInitialLoading() {
        return initialLoading;
    }
  
  
  /*  
   * Step 2: This method is responsible to load the data initially 
   * when app screen is launched for the first time.
   * We are fetching the first page data from the api
   * and passing it via the callback method to the UI.
   */  
    @Override
    public void loadInitial(@NonNull LoadInitialParams<Long> params,
                            @NonNull LoadInitialCallback<Long, Article> callback) {

        initialLoading.postValue(NetworkState.LOADING);
        networkState.postValue(NetworkState.LOADING);

        appController.getRestApi().fetchFeed(QUERY, API_KEY, 1, params.requestedLoadSize)
                .enqueue(new Callback<Feed>() {
                    @Override
                    public void onResponse(Call<Feed> call, Response<Feed> response) {
                        if(response.isSuccessful()) {
                            callback.onResult(response.body().getArticles(), null, 2l);
                            initialLoading.postValue(NetworkState.LOADED);
                            networkState.postValue(NetworkState.LOADED);

                        } else {
                            initialLoading.postValue(new NetworkState(NetworkState.Status.FAILED, response.message()));
                            networkState.postValue(new NetworkState(NetworkState.Status.FAILED, response.message()));
                        }
                    }

                    @Override
                    public void onFailure(Call<Feed> call, Throwable t) {
                        String errorMessage = t == null ? "unknown error" : t.getMessage();
                        networkState.postValue(new NetworkState(NetworkState.Status.FAILED, errorMessage));
                    }
                });
    }
  
  

    @Override
    public void loadBefore(@NonNull LoadParams<Long> params,
                           @NonNull LoadCallback<Long, Article> callback) {

    }
  
  
  /*  
   * Step 3: This method it is responsible for the subsequent call to load the data page wise.
   * This method is executed in the background thread
   * We are fetching the next page data from the api
   * and passing it via the callback method to the UI.
   * The "params.key" variable will have the updated value.
   */
    @Override
    public void loadAfter(@NonNull LoadParams<Long> params,
                          @NonNull LoadCallback<Long, Article> callback) {

        Log.i(TAG, "Loading Rang " + params.key + " Count " + params.requestedLoadSize);
            
        networkState.postValue(NetworkState.LOADING);

        appController.getRestApi().fetchFeed(QUERY, API_KEY, params.key, params.requestedLoadSize).enqueue(new Callback<Feed>() {
            @Override
            public void onResponse(Call<Feed> call, Response<Feed> response) {
                /*  
                 * If the request is successful, then we will update the callback
                 * with the latest feed items and
                 * "params.key+1" -> set the next key for the next iteration.
                 */
                if(response.isSuccessful()) {
                    long nextKey = (params.key == response.body().getTotalResults()) ? null : params.key+1;
                    callback.onResult(response.body().getArticles(), nextKey);
                    networkState.postValue(NetworkState.LOADED);

                } else networkState.postValue(new NetworkState(NetworkState.Status.FAILED, response.message()));
            }

            @Override
            public void onFailure(Call<Feed> call, Throwable t) {
                String errorMessage = t == null ? "unknown error" : t.getMessage();
                networkState.postValue(new NetworkState(NetworkState.Status.FAILED, errorMessage));
            }
        });
    }
}

DataSourceFactory chịu trách nhiệm truy xuất dữ liệu bằng cách sử dụng config của DataSourcePagedList mà mình sẽ tạo sau ở trong bài viết này trong class ViewModel.

public class FeedDataFactory extends DataSource.Factory {

    private MutableLiveData<FeedDataSource> mutableLiveData;
    private FeedDataSource feedDataSource;

    public FeedDataFactory() {
        this.mutableLiveData = new MutableLiveData<FeedDataSource>();
    }

    @Override
    public DataSource create() {
        feedDataSource = new FeedDataSource();
        mutableLiveData.postValue(feedDataSource);
        return feedDataSource;
    }
  
    public MutableLiveData<FeedDataSource> getMutableLiveData() {
        return mutableLiveData;
    }
}

ViewModel sẽ chịu trách nhiệm tạo PagedList cùng với các config của nó và gửi đến Activity để Activity có thể quan sát dữ liệu khi dữ liệu thay đổi và chuyển nó đến adapter.

Chắc hẳn mọi người đang thắc mắc vậy PagedList là gì ? PagedList là một danh sách chứa các mục dữ liệu của bạn và gọi DataSource để tải các phần tử. Nó thường bao gồm một công việc thực hiện ở background (tải dữ liệu) và một công việc thực hiện ở foreground (cập nhật UI dựa trên dữ liệu tải về).

Ví dụ: giả sử có một số dữ liệu mà chúng ta thêm vào DataSource trong background thread. DataSource làm mất hiệu lực của PagedList và cập nhật giá trị. Sau đó trên main thread, PagedList sẽ thông báo cho những phần tử observe nó là nó đã có giá trị mới. Bây giờ PagedListAdapter đã biết về giá trị mới.

PageList có 4 tham số mà chúng ta cần chú ý:

a. setEnablePlaceHolder (boolean enablePlaceholders) b. setInitialLoadSizeHint(int initialLoadSizeHint) c. setPageSize(int pageSize) d. setPageSize(int pageSize)

Dưới đây là class ViewModel của project:

public class FeedViewModel extends ViewModel {

    private Executor executor;
    private LiveData<NetworkState> networkState;
    private LiveData<PagedList<Article>> articleLiveData;

    public FeedViewModel() {
        init();
    }

    /* 
     * Step 1: We are initializing an Executor class
     * Step 2: We are getting an instance of the DataSourceFactory class
     * Step 3: We are initializing the network state liveData as well.
     *         This will update the UI on the network changes that take place
     *         For instance, when the data is getting fetched, we would need
     *         to display a loader and when data fetching is completed, we 
     *         should hide the loader.
     * Step 4: We need to configure the PagedList.Config. 
     * Step 5: We are initializing the pageList using the config we created
     *         in Step 4 and the DatasourceFactory we created from Step 2
     *         and the executor we initialized from Step 1.
     */
    private void init() {
        executor = Executors.newFixedThreadPool(5);

        FeedDataFactory feedDataFactory = new FeedDataFactory();
        networkState = Transformations.switchMap(feedDataFactory.getMutableLiveData(),
                dataSource -> dataSource.getNetworkState());

        PagedList.Config pagedListConfig =
                (new PagedList.Config.Builder())
                        .setEnablePlaceholders(false)
                        .setInitialLoadSizeHint(10)
                        .setPageSize(20).build();

        articleLiveData = (new LivePagedListBuilder(feedDataFactory, pagedListConfig))
                .setFetchExecutor(executor)
                .build();
    }

    /* 
     * Getter method for the network state
     */
    public LiveData<NetworkState> getNetworkState() {
        return networkState;
    }

    /* 
     * Getter method for the pageList
     */  
    public LiveData<PagedList<Article>> getArticleLiveData() {
        return articleLiveData;
    }
}

PagedListAdapter là một triển khai của RecyclerView. Adapter lấy dữ liệu từ PagedList, nó sử dụng DiffUtil làm tham số để tính toán sự khác biệt dữ liệu và thực hiện tất các các cập nhật cho bạn. DiffUtil được định nghĩa trong Model Class trong trường hợp này:

public class Article implements Parcelable {

public static DiffUtil.ItemCallback<Article> DIFF_CALLBACK = new DiffUtil.ItemCallback<Article>() {
        @Override
        public boolean areItemsTheSame(@NonNull Article oldItem, @NonNull Article newItem) {
            return oldItem.id == newItem.id;
        }

        @Override
        public boolean areContentsTheSame(@NonNull Article oldItem, @NonNull Article newItem) {
            return oldItem.equals(newItem);
        }
    };

    @Override
    public boolean equals(Object obj) {
        if (obj == this)
            return true;

        Article article = (Article) obj;
        return article.id == this.id;
    }

Class PagedListAdapter :

public class FeedListAdapter extends PagedListAdapter<Article, RecyclerView.ViewHolder> {

    /* 
     * There are two layout types we define 
     * in this adapter:
     * 1. progrss view
     * 2. data view
     */
    private static final int TYPE_PROGRESS = 0;
    private static final int TYPE_ITEM = 1;

    private Context context;
    private NetworkState networkState;
  
    /* 
     * The DiffUtil is defined in the constructor
     */  
    public FeedListAdapter(Context context) {
        super(Article.DIFF_CALLBACK);
        this.context = context;
    }

  
    /* 
     * Default method of RecyclerView.Adapter
     */  
    @NonNull
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
        if(viewType == TYPE_PROGRESS) {
            NetworkItemBinding headerBinding = NetworkItemBinding.inflate(layoutInflater, parent, false);
            NetworkStateItemViewHolder viewHolder = new NetworkStateItemViewHolder(headerBinding);
            return viewHolder;

        } else {
            FeedItemBinding itemBinding = FeedItemBinding.inflate(layoutInflater, parent, false);
            ArticleItemViewHolder viewHolder = new ArticleItemViewHolder(itemBinding);
            return viewHolder;
        }
    }

  
    /* 
     * Default method of RecyclerView.Adapter
     */  
    @Override
    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
        if(holder instanceof ArticleItemViewHolder) {
            ((ArticleItemViewHolder)holder).bindTo(getItem(position));
        } else {
            ((NetworkStateItemViewHolder) holder).bindView(networkState);
        }
    }


    /* 
     * Default method of RecyclerView.Adapter
     */   
    @Override
    public int getItemViewType(int position) {
        if (hasExtraRow() && position == getItemCount() - 1) {
            return TYPE_PROGRESS;
        } else {
            return TYPE_ITEM;
        }
    }
  
  
     private boolean hasExtraRow() {
        if (networkState != null && networkState != NetworkState.LOADED) {
            return true;
        } else {
            return false;
        }
    }

    public void setNetworkState
                                          
0