Implement thư viện Paging với Kotlin

Paging Library Architecute (Google CodeLab)

Tổng quan

Hầu hết các app khi load một danh sách dữ liệu lớn để hiển thị lên màn hình đều sử dụng việc phân trang nhằm đảm bảo hiệu năng về thời gian cũng như hiệu năng sử dụng bộ nhớ. Google đã cho ra mắt một thư viện mang tên Paging nhằm mục đích giúp chúng ta xử lý việc phân trang một cách dễ dàng và hiệu quả nhất. Thư viện Paging là một phần của Android Architecture Components.

The Paging Library makes it easier for you to load data gradually and gracefully within your app's RecyclerView.

Trong bài viết này, thay vì viết chi tiết lý thuyết của thư viện Paging, mình sẽ hướng dẫn từng bước để triển khai thư viện Paging. Ngoài ra mình cũng sẽ cung cấp tóm tắt về cách thức Paging hoạt động.

Vậy bắt đầu thôi!

Thực hiện

Trong project này, mình sẽ sử dụng API mẫu từ trang News API, Mình sẽ xây dựng một app load các tiêu đề thể thao mới nhất lên một RecycleView.

Việc đầu tiên cần làm là tạo một project Kotlin và implement các thư viện cần dùng.

//Kotlin
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"

    //Support
    implementation 'com.android.support:appcompat-v7:27.1.1'
    implementation 'com.android.support:design:27.1.1'
    implementation 'com.android.support:recyclerview-v7:27.1.1'
    implementation 'com.android.support:cardview-v7:27.1.1'

    //Architecture Components
    implementation "android.arch.lifecycle:extensions:1.1.1"

    //Paging
    implementation "android.arch.paging:runtime:1.0.1"

    //Networking
    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'

    //Rx
    implementation 'io.reactivex.rxjava2:rxjava:2.1.9'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
    
    //Image
    implementation 'com.squareup.picasso:picasso:2.71828'

Mình sẽ sử dụng Retrofit và RxJava 2 để lấy dữ liệu về.

Bây giờ, mình tạo đối tượng Response , interface NetworkService và một class enum để xử lý network authen.

data class Response(
        @SerializedName("articles") val news: List<News>
)

data class News(
        val title: String,
        @SerializedName("urlToImage") val image: String
)
interface NetworkService {

    @GET("/everything?q=sports&apiKey=aa67d8d98c8e4ad1b4f16dbd5f3be348")
    fun getNews(@Query("page") page: Int, @Query("pageSize") pageSize: Int): Single<Response>

    companion object {
        fun getService(): NetworkService {
            val retrofit = Retrofit.Builder()
                    .baseUrl("https://newsapi.org/v2/")
                    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
            return retrofit.create(NetworkService::class.java)
        }
    }
}
enum class State {
    DONE, LOADING, ERROR
}

Bây giờ, hãy tạo một trong các thành phần chính của thư viện Paging: DataSource có thể extend từ ItemKeyedDataSource, PageKeyedDataSource hoặc PositableDataSource. Đối với app này, mình sẽ sử dụng PageKeyedDataSource.

class NewsDataSource(
        private val networkService: NetworkService,
        private val compositeDisposable: CompositeDisposable)
    : PageKeyedDataSource<Int, News>() {

    var state: MutableLiveData<State> = MutableLiveData()
    private var retryCompletable: Completable? = null


    override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, News>) {
        updateState(State.LOADING)
        compositeDisposable.add(
                networkService.getNews(1, params.requestedLoadSize)
                        .subscribe(
                                { response ->
                                    updateState(State.DONE)
                                    callback.onResult(response.news,
                                            null,
                                            2
                                    )
                                },
                                {
                                    updateState(State.ERROR)
                                    setRetry(Action { loadInitial(params, callback) })
                                }
                        )
        )
    }

    override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, News>) {
        updateState(State.LOADING)
        compositeDisposable.add(
                networkService.getNews(params.key, params.requestedLoadSize)
                        .subscribe(
                                { response ->
                                    updateState(State.DONE)
                                    callback.onResult(response.news,
                                            params.key + 1
                                    )
                                },
                                {
                                    updateState(State.ERROR)
                                    setRetry(Action { loadAfter(params, callback) })
                                }
                        )
        )
    }

    override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, News>) {
    }

    private fun updateState(state: State) {
        this.state.postValue(state)
    }

    fun retry() {
        if (retryCompletable != null) {
            compositeDisposable.add(retryCompletable!!
                    .subscribeOn(Schedulers.io())
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe())
        }
    }

    private fun setRetry(action: Action?) {
        retryCompletable = if (action == null) null else Completable.fromAction(action)
    }

}

Tiếp theo, mình tạo class NewsDataSourceFactory để cung cấp DataSource.

class NewsDataSourceFactory(
        private val compositeDisposable: CompositeDisposable,
        private val networkService: NetworkService)
    : DataSource.Factory<Int, News>() {

    val newsDataSourceLiveData = MutableLiveData<NewsDataSource>()

    override fun create(): DataSource<Int, News> {
        val newsDataSource = NewsDataSource(networkService, compositeDisposable)
        newsDataSourceLiveData.postValue(newsDataSource)
        return newsDataSource
    }
}

Bây giờ chúng ta sẽ tạo class NewsViewModel để cấu hình PagedList và cung cấp Live List. NewsViewModel sẽ được extend từ ViewModel từ Android Architecture Components.

class NewsListViewModel : ViewModel() {

    private val networkService = NetworkService.getService()
    var newsList: LiveData<PagedList<News>>
    private val compositeDisposable = CompositeDisposable()
    private val pageSize = 5
    private val newsDataSourceFactory: NewsDataSourceFactory

    init {
        newsDataSourceFactory = NewsDataSourceFactory(compositeDisposable, networkService)
        val config = PagedList.Config.Builder()
                .setPageSize(pageSize)
                .setInitialLoadSizeHint(pageSize * 2)
                .setEnablePlaceholders(false)
                .build()
        newsList = LivePagedListBuilder<Int, News>(newsDataSourceFactory, config).build()
    }


    fun getState(): LiveData<State> = Transformations.switchMap<NewsDataSource,
            State>(newsDataSourceFactory.newsDataSourceLiveData, NewsDataSource::state)

    fun retry() {
        newsDataSourceFactory.newsDataSourceLiveData.value?.retry()
    }

    fun listIsEmpty(): Boolean {
        return newsList.value?.isEmpty() ?: true
    }

    override fun onCleared() {
        super.onCleared()
        compositeDisposable.dispose()
    }
}

Bây giờ, mình sẽ tạo những file cần thiết để sử dụng. File activity_news_list.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true" />

    <TextView
        android:id="@+id/txt_error"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="ERROR !! Tap to retry." />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:listitem="@layout/item_news" />

</RelativeLayout>

File item_list_footer.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="12dp">

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true" />

    <TextView
        android:id="@+id/txt_error"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="ERROR !! Tap to retry." />

</RelativeLayout>

File item_news.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/card_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    card_view:cardBackgroundColor="#FFF"
    card_view:cardCornerRadius="4dp"
    card_view:cardElevation="2dp"
    card_view:cardUseCompatPadding="true">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <ImageView
            android:id="@+id/img_news_banner"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:scaleType="fitXY"
            tools:src="@mipmap/ic_launcher" />

        <TextView
            android:id="@+id/txt_news_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="12dp"
            android:textColor="#333"
            android:textSize="16sp"
            tools:text="Sample news title" />

    </LinearLayout>

</android.support.v7.widget.CardView>

Để hiển thị các bài viết, ta cần sử dụng tới các class ViewHolder sau:

Class ListFooterViewHolder

class ListFooterViewHolder(view: View) : RecyclerView.ViewHolder(view) {

    fun bind(status: State?) {
        itemView.progress_bar.visibility = if (status == State.LOADING) VISIBLE else View.INVISIBLE
        itemView.txt_error.visibility = if (status == State.ERROR) VISIBLE else View.INVISIBLE
    }

    companion object {
        fun create(retry: () -> Unit, parent: ViewGroup): ListFooterViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_list_footer, parent, false)
            view.txt_error.setOnClickListener { retry() }
            return ListFooterViewHolder(view)
        }
    }
}

Class NewsViewHolder:

class NewsViewHolder(view: View) : RecyclerView.ViewHolder(view) {

    fun bind(news: News?) {
        if (news != null) {
            itemView.txt_news_name.text = news.title
            Picasso.get().load(news.image).into(itemView.img_news_banner)
        }
    }

    companion object {
        fun create(parent: ViewGroup): NewsViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.item_news, parent, false)
            return NewsViewHolder(view)
        }
    }
}

Tiếp theo, chúng ta sẽ tạo class Adapter.

class NewsListAdapter(private val retry: () -> Unit)
    : PagedListAdapter<News, RecyclerView.ViewHolder>(NewsDiffCallback) {

    private val DATA_VIEW_TYPE = 1
    private val FOOTER_VIEW_TYPE = 2

    private var state = State.LOADING

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return if (viewType == DATA_VIEW_TYPE) NewsViewHolder.create(parent) else ListFooterViewHolder.create(retry, parent)
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (getItemViewType(position) == DATA_VIEW_TYPE)
            (holder as NewsViewHolder).bind(getItem(position))
        else (holder as ListFooterViewHolder).bind(state)
    }

    override fun getItemViewType(position: Int): Int {
        return if (position < super.getItemCount()) DATA_VIEW_TYPE else FOOTER_VIEW_TYPE
    }

    companion object {
        val NewsDiffCallback = object : DiffUtil.ItemCallback<News>() {
            override fun areItemsTheSame(oldItem: News, newItem: News): Boolean {
                return oldItem.title == newItem.title
            }

            override fun areContentsTheSame(oldItem: News, newItem: News): Boolean {
                return oldItem == newItem
            }
        }
    }

    override fun getItemCount(): Int {
        return super.getItemCount() + if (hasFooter()) 1 else 0
    }

    private fun hasFooter(): Boolean {
        return super.getItemCount() != 0 && (state == State.LOADING || state == State.ERROR)
    }

    fun setState(state: State) {
        this.state = state
        notifyItemChanged(super.getItemCount())
    }
}

Bây giờ chúng ta sẽ tạo class NewsListActivity để hiển thị dữ liệu

class NewsListActivity : AppCompatActivity() {

    private lateinit var viewModel: NewsListViewModel
    private lateinit var newsListAdapter: NewsListAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_news_list)

        viewModel = ViewModelProviders.of(this)
                .get(NewsListViewModel::class.java)
        initAdapter()
        initState()
    }

    private fun initAdapter() {
        newsListAdapter = NewsListAdapter { viewModel.retry() }
        recycler_view.layoutManager = LinearLayoutManager(this, LinearLayout.VERTICAL, false)
        recycler_view.adapter = newsListAdapter
        viewModel.newsList.observe(this, Observer {
            newsListAdapter.submitList(it)
        })
    }

    private fun initState() {
        txt_error.setOnClickListener { viewModel.retry() }
        viewModel.getState().observe(this, Observer { state ->
            progress_bar.visibility = if (viewModel.listIsEmpty() && state == State.LOADING) View.VISIBLE else View.GONE
            txt_error.visibility = if (viewModel.listIsEmpty() && state == State.ERROR) View.VISIBLE else View.GONE
            if (!viewModel.listIsEmpty()) {
                newsListAdapter.setState(state ?: State.DONE)
            }
        })
    }

}

Cuối cùng, chúng ta thêm INTERNET permission vào file Manifest. Vậy là chúng ta đã hoàn thành, run app và xem kết quả nha.

Còn dưới đây là tóm tắt về cách thức hoạt động của thư viện Paging.

  • Data được lấy từ DataSource (REST API) trên một background thread.
  • DataSource sẽ cập nhật giá trị PagedList.
  • PagedList sẽ thông báo cho observers của nó về list tin bài trên mainThread.
  • PagedListAdapter sẽ nhận danh sách các tin bài.
  • PagedListAdapter sẽ tính toán các thay đổi bằng DiffUtil trên background thread & trả về kết quả cho mainThread.
  • PagedListAdapter gọi OnBindViewHolder dữ liệu đã updated.

Nguồn: https://medium.com/, https://developer.android.com/topic/libraries/architecture/paging