Khái niệm cơ bản về tính năng Phân trang (Paging) trong Android

1. Giới thiệu

Kiến thức bạn sẽ học được

  • Các thành phần chính của thư viện phân trang là gì?
  • Cách thêm thư viện phân trang vào dự án của bạn.

Sản phẩm bạn sẽ tạo ra

Trong lớp học lập trình này, bạn bắt đầu bằng một ứng dụng mẫu đã hiển thị danh sách các bài viết. Danh sách này ở dạng tĩnh, có 500 bài viết và tất cả đều được lưu trong bộ nhớ điện thoại:

7d256d9c74e3b3f5.png

Khi tham gia lớp học lập trình, bạn sẽ:

  • ...được giới thiệu về khái niệm phân trang,
  • ...được giới thiệu các thành phần chính trong Thư viện Paging.
  • ...được hướng dẫn cách triển khai tính năng phân trang bằng thư viện Paging.

Khi hoàn tất, bạn sẽ có một ứng dụng:

  • ...triển khai thành công tính năng phân trang.
  • ...giao tiếp hiệu quả với người dùng khi hệ thống đang tìm nạp thêm dữ liệu.

Dưới đây là bản xem trước ngắn gọn về giao diện người dùng mà chúng ta sẽ tạo ra:

6277154193f7580.gif

Bạn cần

Sẽ hữu ích hơn rất nhiều nếu bạn

2. Thiết lập môi trường

Trong bước này, bạn sẽ tải xuống toàn bộ các đoạn mã của lớp học lập trình rồi chạy một ứng dụng mẫu đơn giản.

Để bạn có thể bắt đầu nhanh chóng, chúng tôi đã chuẩn bị một dự án khởi đầu để bạn tiếp tục xây dựng ứng dụng từ đó.

Nếu đã cài đặt git, bạn có thể chỉ cần chạy lệnh bên dưới. Để kiểm tra xem git đã được cài đặt hay chưa, hãy nhập git --version vào dòng lệnh hoặc cửa sổ dòng lệnh và xác minh rằng mã này được thực thi đúng cách.

 git clone https://github.com/googlecodelabs/android-paging

Nếu chưa có git, bạn có thể nhấp vào nút sau để tải toàn bộ các đoạn mã cho lớp học lập trình này:

Mã này được sắp xếp thành hai thư mục: basicadvanced. Đối với lớp học lập trình này, chúng ta chỉ quan tâm đến thư mục basic.

Trong thư mục basic, bạn cũng có hai thư mục khác: startend. Chúng ta sẽ bắt đầu làm quen với đoạn mã trong thư mục start và khi kết thúc lớp học lập trình này, đoạn mã trong thư mục start phải giống với đoạn mã trong thư mục end.

  1. Mở dự án ở thư mục basic/start trong Android Studio.
  2. Chạy cấu hình chạy app trên một thiết bị hoặc một trình mô phỏng.

89af884fa2d4e709.png

Chúng ta sẽ thấy một danh sách các bài viết! Cuộn xuống dưới cùng để chắc chắn rằng danh sách này ở dạng tĩnh. Nghĩa là, danh sách này sẽ không tìm nạp thêm các mục khác khi người dùng cuộn xuống cuối. Cuộn lên lại trên cùng để chắc chắn rằng toàn bộ các mục vẫn như cũ.

3. Giới thiệu về tính năng phân trang

Một trong những cách phổ biến nhất để hiển thị thông tin cho người dùng là sử dụng danh sách. Tuy nhiên, đôi khi các danh sách này chỉ hiển thị trên màn hình một phần nhỏ nội dung được cung cấp cho người dùng. Khi người dùng cuộn để lướt thông tin đã hiển thị, họ thường kỳ vọng rằng danh sách này sẽ tìm nạp thêm dữ liệu để bổ sung vào thông tin họ đã xem. Ứng dụng cần phải hiệu quả và liền mạch mỗi khi tìm nạp dữ liệu để việc tải dần dữ liệu không gây ảnh hưởng xấu đến trải nghiệm người dùng. Việc tải dần dữ liệu cũng mang lại lợi ích về mặt hiệu suất, vì ứng dụng không phải đồng thời lưu giữ một lượng lớn dữ liệu trong bộ nhớ.

Quá trình tìm nạp dần thông tin này được gọi là phân trang, trong đó mỗi trang tương ứng với một phần dữ liệu cần tìm nạp. Để yêu cầu một trang, thường thì chúng ta sẽ gửi một truy vấn chứa các thông tin cần thiết đến nguồn dữ liệu đang được phân trang. Phần còn lại của lớp học này sẽ giới thiệu về Thư viện Paging và cách thư viện này có thể giúp bạn triển khai tính năng phân trang trong ứng dụng một cách nhanh chóng và hiệu quả.

Các thành phần chính trong Thư viện Paging

Dưới đây là các thành phần chính trong Thư viện Paging:

  • PagingSource – lớp cơ sở để tải các phần dữ liệu cho một truy vấn trang cụ thể. Lớp này là một phần của lớp dữ liệu (data layer) và thường hiển thị trong lớp DataSource, sau đó được Repository sử dụng trong ViewModel.
  • PagingConfig – một lớp quy định các thông số xác định hành vi phân trang. Thông tin này bao gồm kích thước trang, liệu phần giữ chỗ đã được bật hay chưa, v.v.
  • Pager – một lớp có vai trò tạo dòng tin PagingData. PagingSource chịu trách nhiệm thực hiện việc này và phải được tạo trong ViewModel.
  • PagingData – một vùng chứa dành cho dữ liệu được phân trang Mỗi lần làm mới dữ liệu sẽ có một phát xạ PagingData tương ứng được hỗ trợ bởi PagingSource riêng.
  • PagingDataAdapter – một lớp con RecyclerView.Adapter hiển thị PagingData trong RecyclerView. PagingDataAdapter có thể được kết nối với Kotlin Flow, LiveData, RxJava Flowable, RxJava Observable hoặc thậm chí là danh sách tĩnh bằng phương thức gốc. PagingDataAdapter theo dõi các sự kiện tải PagingData nội bộ và cập nhật hiệu quả giao diện người dùng khi các trang được tải.

566d0f6506f39480.jpeg

Ở những phần sau, bạn sẽ triển khai các ví dụ về từng thành phần được mô tả ở trên.

4. Tổng quan dự án

Ứng dụng ở dạng hiện tại sẽ hiển thị danh sách tĩnh các bài viết. Mỗi bài viết đều có một tiêu đề, nội dung mô tả và ngày tạo bài viết đó. Danh sách tĩnh hoạt động hiệu quả khi số lượng mục không nhiều, nhưng nó sẽ gặp khó khăn với tập dữ liệu có quy mô lớn hơn. Chúng ta sẽ khắc phục vấn đề này bằng cách triển khai tính năng phân trang bằng Thư viện Paging, nhưng trước tiên, hãy xem qua các thành phần đã có trong ứng dụng.

Ứng dụng tuân theo cấu trúc được đề xuất trong hướng dẫn về cấu trúc ứng dụng. Dưới đây là nội dung bạn sẽ tìm thấy trong mỗi gói:

Lớp dữ liệu:

  • ArticleRepository: Chịu trách nhiệm cung cấp danh sách các bài viết và lưu chúng vào bộ nhớ.
  • Article: Một lớp đại diện cho mô hình dữ liệu, đại diện cho thông tin được lấy từ lớp dữ liệu.

Lớp giao diện người dùng:

  • Activity, RecyclerView.AdapterRecyclerView.ViewHolder: Các lớp chịu trách nhiệm hiển thị danh sách trong giao diện người dùng.
  • ViewModel: Trình sở hữu trạng thái chịu trách nhiệm tạo trạng thái mà giao diện người dùng cần hiển thị.

Kho lưu trữ hiển thị tất cả các bài viết trong Flow với trường articleStream. ArticleViewModel được đọc lần lượt trong lớp giao diện người dùng, sau đó chuẩn bị để giao diện người dùng sử dụng trong ArticleActivity bằng trường state, StateFlow.

Việc hiển thị các bài viết dưới dạng Flow từ kho lưu trữ cho phép cập nhật các bài viết được trình bày khi chúng thay đổi theo thời gian. Ví dụ: nếu tiêu đề bài viết thay đổi, thay đổi đó có thể dễ dàng được thông báo với trình thu thập articleStream. Việc sử dụng StateFlow cho trạng thái giao diện người dùng trong ViewModel đảm bảo ngay cả khi chúng ta ngừng thu thập trạng thái giao diện người dùng — ví dụ: khi Activity được tạo lại trong quá trình thay đổi cấu hình, chúng ta có thể tiếp tục ngay tại nơi đã dừng lại ở thời điểm bắt đầu thu thập lại.

Như đã đề cập trước đó, articleStream hiện tại trong kho lưu trữ chỉ hiển thị tin tức cho ngày hiện tại. Một số người dùng chỉ có nhu cầu xem ngày hiện tại, nhưng số khác có thể muốn xem các bài viết cũ hơn khi họ cuộn qua tất cả các bài viết có sẵn cho ngày hiện tại. Hy vọng việc hiển thị các bài viết sẽ là lựa chọn lý tưởng cho tính năng phân trang. Ngoài ra còn có những lý do khác mà chúng ta nên tìm hiểu về cách phân trang thông qua các bài viết sau:

  • ViewModel lưu giữ tất cả mục được tải trong bộ nhớ trong items StateFlow. Đây là một mối lo ngại lớn khi tập dữ liệu thực sự lớn và nó có thể ảnh hưởng đến hiệu suất.
  • Việc cập nhật một hoặc nhiều bài viết trong danh sách khi những bài viết đó đã thay đổi sẽ trở nên tốn kém hơn khi danh sách bài viết càng lớn.

Thư viện Paging giúp giải quyết tất cả các vấn đề này, đồng thời cung cấp API nhất quán để tìm nạp dữ liệu tăng dần (phân trang) trong ứng dụng của bạn.

5. Xác định nguồn dữ liệu

Khi triển khai tính năng phân trang, chúng ta muốn chắc chắn các điều kiện sau được đáp ứng:

  • Xử lý đúng cách các yêu cầu dữ liệu từ giao diện người dùng, đảm bảo không kích hoạt nhiều yêu cầu cùng lúc cho cùng một truy vấn.
  • Lưu trữ lượng dữ liệu có thể quản lý trong bộ nhớ.
  • Kích hoạt các yêu cầu tìm nạp thêm dữ liệu để bổ sung cho dữ liệu mà chúng ta đã tìm nạp.

Chúng ta có thể thực hiện tất cả những điều đó bằng PagingSource. PagingSource xác định nguồn dữ liệu bằng cách chỉ định cách truy xuất dữ liệu trong các đoạn tăng dần. Sau đó, đối tượng PagingData lấy dữ liệu từ PagingSource để phản hồi việc tải gợi ý được tạo khi người dùng cuộn trong RecyclerView.

PagingSource của chúng ta sẽ tải các bài viết. Trong data/Article.kt, bạn sẽ thấy mô hình được xác định như sau:

data class Article(
    val id: Int,
    val title: String,
    val description: String,
    val created: LocalDateTime,
)

Để tạo PagingSource, bạn sẽ cần phải xác định những thông tin sau:

  • Loại khoá phân trang – Định nghĩa loại truy vấn trang mà chúng ta sử dụng để yêu cầu thêm dữ liệu. Trong trường hợp này, chúng ta sẽ tìm nạp bài viết sau hoặc trước một mã bài viết nhất định vì hệ thống sẽ đảm bảo mã của các bài viết đó được sắp xếp theo thứ tự tăng dần.
  • Loại dữ liệu được tải – Mỗi trang trả về List bài viết, vì vậy, loại này là Article.
  • Nơi dữ liệu được truy xuất – Thông thường, đây sẽ là cơ sở dữ liệu, tài nguyên mạng hoặc bất kỳ nguồn dữ liệu được phân trang nào khác. Tuy nhiên, trong lớp học lập trình này, chúng ta sẽ sử dụng dữ liệu được tạo cục bộ.

Trong gói data, hãy tạo một quy trình triển khai PagingSource trong tệp mới có tên ArticlePagingSource.kt:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource
import androidx.paging.PagingState

class ArticlePagingSource : PagingSource<Int, Article>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        TODO("Not yet implemented")
    }
}

PagingSource yêu cầu chúng ta triển khai 2 hàm: load()getRefreshKey().

Hàm load() được Thư viện phân trang gọi để tìm nạp thêm dữ liệu không đồng bộ sẽ hiển thị khi người dùng cuộn qua. Đối tượng LoadParams lưu giữ thông tin liên quan đến thao tác tải, bao gồm:

  • Khoá của trang sẽ được tải – Nếu đây là lần đầu tiên load() được gọi, LoadParams.key sẽ là null. Trong trường hợp này, bạn sẽ phải xác định khoá trang ban đầu. Đối với dự án này, chúng ta sử dụng mã bài viết làm khoá. Hãy thêm một hằng số STARTING_KEY0 vào đầu tệp ArticlePagingSource cho khoá trang ban đầu.
  • Kích thước tải – số lượng mục được yêu cầu tải.

Hàm load() trả về LoadResult. LoadResult có thể là một trong những loại sau đây:

  • LoadResult.Page, nếu kết quả thành công.
  • LoadResult.Error, nếu xảy ra lỗi.
  • LoadResult.Invalid, nếu PagingSource không hợp lệ vì việc đó không còn đảm bảo tính toàn vẹn của kết quả.

LoadResult.Page có ba đối số bắt buộc:

  • data: Một List các mục đã được tìm nạp.
  • prevKey: Khoá mà phương thức load() sử dụng khi cần tìm nạp các mục phía sau trang hiện tại.
  • nextKey: Khoá mà phương thức load() sử dụng khi cần tìm nạp các mục sau trang hiện tại.

...và 2 tuỳ chọn:

  • itemsBefore: Số phần giữ chỗ sẽ hiển thị trước dữ liệu đã tải.
  • itemsAfter: Số phần giữ chỗ sẽ hiển thị sau dữ liệu được tải.

Khoá tải của chúng ta là trường Article.id. Chúng ta có thể dùng mã này làm khoá vì mã nhận dạng Article tăng thêm một lần cho mỗi bài viết; nghĩa là mã nhận dạng bài viết là những số nguyên tăng đơn điệu liên tiếp.

nextKey hoặc prevKeynull nếu không có thêm dữ liệu nào được tải theo hướng tương ứng. Trong trường hợp này, đối với prevKey:

  • Nếu startKey giống với STARTING_KEY, chúng ta sẽ trả về giá trị null (rỗng) vì không thể tải thêm mục sau khoá này.
  • Hoặc chúng ta sẽ lấy mục đầu tiên trong danh sách và tải LoadParams.loadSize phía sau để đảm bảo không phải trả lại khoá nhỏ hơn STARTING_KEY. Chúng ta thực hiện việc này bằng cách xác định phương thức ensureValidKey().

Thêm hàm sau để kiểm tra xem phím phân trang có hợp lệ không:

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
   /**
     * Makes sure the paging key is never less than [STARTING_KEY]
     */
    private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
}

Đối với nextKey:

  • Vì chúng ta hỗ trợ tải các mục vô hạn, nên sẽ truyền vào range.last + 1.

Ngoài ra, mỗi bài viết có một trường created nên cũng cần tạo giá trị cho trường đó. Thêm phần dưới đây vào đầu tệp:

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   ...
}

Chúng ta hiện có thể triển khai hàm load() với tất cả các mã đó:

import kotlin.math.max
...

private val firstArticleCreatedTime = LocalDateTime.now()

class ArticlePagingSource : PagingSource<Int, Article>() {
   override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        // Start paging with the STARTING_KEY if this is the first load
        val start = params.key ?: STARTING_KEY
        // Load as many items as hinted by params.loadSize
        val range = start.until(start + params.loadSize)

        return LoadResult.Page(
            data = range.map { number ->
                Article(
                    // Generate consecutive increasing numbers as the article id
                    id = number,
                    title = "Article $number",
                    description = "This describes article $number",
                    created = firstArticleCreatedTime.minusDays(number.toLong())
                )
            },

            // Make sure we don't try to load items behind the STARTING_KEY
            prevKey = when (start) {
                STARTING_KEY -> null
                else -> ensureValidKey(key = range.first - params.loadSize)
            },
            nextKey = range.last + 1
        )
    }

    ...
}

Tiếp theo, chúng ta cần triển khai getRefreshKey(). Phương thức này được gọi khi Thư viện phân trang cần tải lại các mục cho giao diện người dùng vì dữ liệu trong phương thức sao lưu PagingSource đã thay đổi. Trong trường hợp này, dữ liệu cơ sở cho PagingSource đã thay đổi và cần được cập nhật trong giao diện người dùng không hợp lệ. Khi đã hết hiệu lực, Thư viện Paging sẽ tạo một PagingSource mới để tải lại dữ liệu và thông báo cho giao diện người dùng bằng cách phát ra PagingData mới. Chúng ta sẽ tìm hiểu thêm về trường hợp không hợp lệ ở phần sau.

Khi tải từ PagingSource mới, getRefreshKey() được gọi để cung cấp khoá mà PagingSource mới bắt đầu tải cùng để đảm bảo người dùng không bị mất vị trí hiện tại trong danh sách sau khi làm mới.

Việc vô hiệu hoá trong thư viện phân trang xảy ra vì một trong hai lý do sau:

  • Bạn đã gọi refresh() trên PagingAdapter.
  • Bạn đã gọi invalidate() trên PagingSource.

Khoá được trả về (trong trường hợp của chúng ta là Int) sẽ được chuyển sang lệnh gọi tiếp theo của phương thức load() trong PagingSource mới thông qua đối số LoadParams. Để ngăn các mục nhảy xung quanh sau khi hết hiệu lực, chúng ta cần đảm bảo khoá được trả về sẽ tải đủ mục để lấp đầy màn hình. Điều này làm tăng khả năng tập hợp các mục mới bao gồm các mục đã có trong dữ liệu không hợp lệ, giúp duy trì vị trí cuộn hiện tại. Hãy xem cách triển khai trong ứng dụng của chúng ta:

   // The refresh key is used for the initial load of the next PagingSource, after invalidation
   override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
        // In our case we grab the item closest to the anchor position
        // then return its id - (state.config.pageSize / 2) as a buffer
        val anchorPosition = state.anchorPosition ?: return null
        val article = state.closestItemToPosition(anchorPosition) ?: return null
        return ensureValidKey(key = article.id - (state.config.pageSize / 2))
    }

Trong đoạn mã trên, chúng ta sử dụng PagingState.anchorPosition. Nếu bạn muốn biết làm thế nào thư viện phân trang biết cách tìm nạp thêm mục thì đây là một gợi ý! Khi giao diện người dùng cố gắng đọc các mục từ PagingData, nó sẽ cố đọc tại một chỉ mục nhất định. Khi dữ liệu được đọc, dữ liệu đó sẽ được hiển thị trong giao diện người dùng. Tuy nhiên, nếu không có dữ liệu thì thư viện phân trang sẽ biết cần phải tìm nạp dữ liệu để thực hiện yêu cầu đọc không thành công. Chỉ mục cuối cùng đã tìm nạp dữ liệu thành công khi đọc là anchorPosition.

Khi làm mới, chúng ta lấy khoá của Article gần nhất với anchorPosition để dùng làm khoá tải. Theo đó, khi chúng ta bắt đầu tải lại từ PagingSource mới, tập hợp các mục đã tìm nạp bao gồm các mục đã được tải nhằm đảm bảo trải nghiệm người dùng suôn sẻ và nhất quán.

Vậy là bạn đã xác định đầy đủ về PagingSource. Bước tiếp theo là kết nối nó với giao diện người dùng.

6. Tạo PagingData cho giao diện người dùng

Trong quá trình triển khai hiện tại, chúng ta dùng Flow<List<Article>> trong ArticleRepository để hiển thị dữ liệu đã tải cho ViewModel. Đổi lại, ViewModel sẽ duy trì trạng thái dữ liệu luôn có sẵn với toán tử stateIn để hiển thị trên giao diện người dùng.

Thay vào đó, chúng ta sẽ hiển thị một Flow<PagingData<Article>> từ ViewModel với Thư viện Paging. PagingData là một loại bọc dữ liệu mà chúng ta đã tải và giúp Thư viện Paging quyết định thời điểm tìm nạp thêm dữ liệu, đồng thời đảm bảo việc chúng ta không yêu cầu hai lần cho cùng một trang.

Để tạo PagingData, chúng ta sẽ dùng một trong các phương thức trình tạo từ lớp Pager tuỳ thuộc vào API mà chúng ta muốn dùng để chuyển PagingData đến các lớp khác của ứng dụng:

  • Kotlin Flow – sử dụng Pager.flow.
  • LiveData – sử dụng Pager.liveData.
  • RxJava Flowable – sử dụng Pager.flowable.
  • RxJava Observable – sử dụng Pager.observable.

Vì đã sử dụng Flow trong ứng dụng nên chúng ta sẽ tiếp tục áp dụng phương pháp này; nhưng thay vì dùng Flow<List<Article>>, chúng ta sẽ dùng Flow<PagingData<Article>>.

Bất kể bạn tạo trình tạo PagingData nào, bạn cũng phải chuyển các thông số sau:

  • PagingConfig. Lớp này sẽ đặt các tuỳ chọn liên quan đến cách tải nội dung từ PagingSource, chẳng hạn như mức tải trước, yêu cầu kích thước cho lần tải ban đầu và các tuỳ chọn khác. Tham số bắt buộc duy nhất bạn phải xác định là kích thước trang — số lượng mục phải tải trong mỗi trang. Theo mặc định, tính năng Paging sẽ giữ tất cả các trang bạn tải trong bộ nhớ. Để đảm bảo bạn không lãng phí bộ nhớ khi người dùng cuộc di chuyển, hãy đặt tham số maxSize trong PagingConfig. Theo mặc định, tính năng Paging sẽ trả về các mục null (rỗng) làm trình giữ chỗ cho nội dung chưa tải nếu tính năng Paging có thể tính các mục chưa tải và nếu cờ cấu hình enablePlaceholderstrue. Bằng cách đó, bạn có thể hiển thị chế độ xem trình giữ chỗ trong bộ chuyển đổi. Để đơn giản hoá nội dung trong lớp học lập trình này, hãy tắt trình giữ chỗ bằng cách truyền enablePlaceholders = false.
  • Một hàm xác định cách tạo PagingSource. Trong trường hợp này, chúng ta sẽ tạo một ArticlePagingSource, vì vậy cần có một hàm giúp cho Thư viện Paging biết cách thực hiện việc đó.

Hãy chỉnh sửa ArticleRepository của chúng ta!

Cập nhật ArticleRepository

  • Xoá trường articlesStream.
  • Thêm một phương thức có tên là articlePagingSource() để trả về ArticlePagingSource chúng ta vừa tạo.
class ArticleRepository {

    fun articlePagingSource() = ArticlePagingSource()
}

Dọn dẹp ArticleRepository

Thư viện Paging hỗ trợ chúng ta với rất nhiều chức năng:

  • Xử lý bộ nhớ đệm trong bộ nhớ.
  • Yêu cầu dữ liệu khi người dùng ở gần cuối danh sách.

Điều này có nghĩa mọi nội dung khác trong ArticleRepository đều có thể bị xoá, ngoại trừ articlePagingSource(). Bây giờ, tệp ArticleRepository của bạn sẽ có dạng như sau:

package com.example.android.codelabs.paging.data

import androidx.paging.PagingSource

class ArticleRepository {
    fun articlePagingSource() = ArticlePagingSource()
}

Bạn hiện sẽ có lỗi biên dịch trong ArticleViewModel. Hãy xem cần phải thực hiện những thay đổi nào ở đó!

7. Yêu cầu và lưu bộ nhớ đệm PagingData trong ViewModel

Trước khi giải quyết các lỗi biên dịch, hãy xem lại ViewModel.

class ArticleViewModel(...) : ViewModel() {

    val items: StateFlow<List<Article>> = ...
}

Để tích hợp thư viện Paging trong ViewModel, chúng ta sẽ thay đổi loại dữ liệu trả về items từ StateFlow<List<Article>> thành Flow<PagingData<Article>>. Để làm việc này, trước tiên hãy thêm một hằng số riêng tư có tên là ITEMS_PER_PAGE vào đầu tệp:

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel {
    ...
}

Tiếp theo, chúng ta cập nhật items thành kết quả đầu ra của thực thể Pager. Chúng ta làm điều này bằng cách chuyển tới Pager hai tham số:

  • PagingConfigpageSizeITEMS_PER_PAGE và trình giữ chỗ đã bị tắt
  • PagingSourceFactory cung cấp một bản sao của ArticlePagingSource mà chúng ta vừa tạo.
class ArticleViewModel(...) : ViewModel() {

   val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        ...
}

Tiếp theo, để duy trì trạng thái phân trang thông qua các thay đổi về cấu hình hoặc điều hướng, chúng ta sử dụng phương thức cachedIn() truyền hàm đó androidx.lifecycle.viewModelScope.

Sau khi hoàn tất những thay đổi nêu trên, ViewModel của chúng ta sẽ có dạng như sau:

package com.example.android.codelabs.paging.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.android.codelabs.paging.data.Article
import com.example.android.codelabs.paging.data.ArticleRepository
import com.example.android.codelabs.paging.data.ITEMS_PER_PAGE
import kotlinx.coroutines.flow.Flow

private const val ITEMS_PER_PAGE = 50

class ArticleViewModel(
    private val repository: ArticleRepository,
) : ViewModel() {

    val items: Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = ITEMS_PER_PAGE, enablePlaceholders = false),
        pagingSourceFactory = { repository.articlePagingSource() }
    )
        .flow
        .cachedIn(viewModelScope)
}

Một lưu ý khác nữa về PagingData: Đây là loại vùng chứa độc lập chứa một luồng cập nhật có thể thay đổi cho dữ liệu sẽ hiển thị trong RecyclerView. Mỗi lượt phát PagingData là hoàn toàn độc lập, và nhiều thực thể PagingData có thể được phát ra cho một truy vấn nếu PagingSource sao lưu không hợp lệ do các thay đổi trong tập dữ liệu cơ bản. Do đó, Flows/PagingData phải được hiển thị độc lập với Flows khác.

Vậy là xong! Chúng ta hiện có chức năng phân trang trong ViewModel!

8. Thiết lập để Bộ chuyển đổi hoạt động với PagingData

Để liên kết PagingData với một RecyclerView, hãy sử dụng PagingDataAdapter. PagingDataAdapter sẽ nhận được thông báo bất cứ khi nào nội dung PagingData được tải, sau đó sẽ báo hiệu RecyclerView để cập nhật.

Cập nhật ArticleAdapter để hoạt động với luồng PagingData:

  • Hiện tại, ArticleAdapter sẽ triển khai ListAdapter. Thay vào đó, hãy triển khai PagingDataAdapter. Phần còn lại của phần nội dung lớp học sẽ không thay đổi:
import androidx.paging.PagingDataAdapter
...

class ArticleAdapter : PagingDataAdapter<Article, RepoViewHolder>(ARTICLE_DIFF_CALLBACK) {
// body is unchanged
}

Chúng ta đã thực hiện rất nhiều thay đổi cho đến thời điểm này, nhưng giờ chỉ cần thực hiện một bước nữa thôi là có thể chạy ứng dụng – chúng ta cần kết nối giao diện người dùng!

9. Sử dụng PagingData trong giao diện người dùng

Trong quá trình triển khai hiện tại, chúng ta đã có phương thức với tên binding.setupScrollListener(). Phương thức này gọi ViewModel để tải thêm dữ liệu nếu đáp ứng một số điều kiện nhất định. Thư viện Paging sẽ tự động thực hiện tất cả những việc đó, vì vậy, chúng ta có thể xoá phương thức này cũng như dữ liệu sử dụng của nó.

Tiếp theo, do ArticleAdapter không còn làListAdapter mà là PagingDataAdapter, chúng ta sẽ thực hiện 2 thay đổi nhỏ:

  • Chúng ta chuyển đổi toán tử đầu cuối trên Flow từ ViewModel sang collectLatest thay vì collect.
  • Chúng t sẽ thông báo cho ArticleAdapter về những thay đổi với submitData() thay vì submitList().

Chúng ta sử dụng collectLatest trên pagingData Flow để việc thu thập dữ liệu phát pagingData trước đó bị huỷ khi phát một thực thể pagingData mới.

Với những thay đổi đó, Activity sẽ có dạng như sau:

import kotlinx.coroutines.flow.collectLatest

class ArticleActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityArticlesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        val viewModel by viewModels<ArticleViewModel>(
            factoryProducer = { Injection.provideViewModelFactory(owner = this) }
        )

        val items = viewModel.items
        val articleAdapter = ArticleAdapter()

        binding.bindAdapter(articleAdapter = articleAdapter)

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                items.collectLatest {
                    articleAdapter.submitData(it)
                }
            }
        }
    }
}

private fun ActivityArticlesBinding.bindAdapter(
    articleAdapter: ArticleAdapter
) {
    list.adapter = articleAdapter
    list.layoutManager = LinearLayoutManager(list.context)
    val decoration = DividerItemDecoration(list.context, DividerItemDecoration.VERTICAL)
    list.addItemDecoration(decoration)
}

Ứng dụng hiện sẽ biên dịch và khởi chạy. Bạn đã chuyển thành công ứng dụng sang Thư viện Paging!

f97136863cfa19a0.gif

10. Hiển thị các trạng thái tải trong giao diện người dùng

Khi Thư viện Paging đang tìm nạp thêm mục để hiển thị trong giao diện người dùng, phương pháp hay nhất là cho người dùng biết rằng có nhiều dữ liệu hơn đang được chuyển đến. May mắn là thư viện Paging cung cấp cách thuận tiện để truy cập trạng thái tải với loại CombinedLoadStates.

Các thực thể CombinedLoadStates mô tả trạng thái tải của tất cả thành phần trong Thư viện Paging có tải dữ liệu. Trong trường hợp này, chúng ta chỉ quan tâm đến LoadState trong ArticlePagingSource, vì vậy chúng ta sẽ thao tác chủ yếu với loại LoadStates trong trường CombinedLoadStates.source. Bạn có thể truy cập CombinedLoadStates qua PagingDataAdapter thông qua PagingDataAdapter.loadStateFlow.

CombinedLoadStates.source là một loại LoadStates, với các trường cho 3 loại LoadState khác nhau:

  • LoadStates.append: Đối với LoadState mục được tìm nạp sau vị trí hiện tại của người dùng.
  • LoadStates.prepend: Dành cho mục LoadState được tìm nạp trước vị trí hiện tại của người dùng.
  • LoadStates.refresh: Đối với LoadState của tải ban đầu.

Mỗi LoadState có thể có một trong các trạng thái sau:

  • LoadState.Loading: Các mục đang được tải.
  • LoadState.NotLoading: Các mục chưa được tải.
  • LoadState.Error: Đã xảy ra lỗi khi tải.

Trong trường hợp này, chúng ta chỉ quan tâm nếu LoadStateLoadState.LoadingArticlePagingSource không bao gồm trường hợp lỗi.

Điều đầu tiên cần làm là thêm thanh tiến trình vào đầu và cuối giao diện người dùng để cho biết trạng thái tải cho các lần tìm nạp theo một trong hai hướng.

Trong activity_articles.xml, hãy thêm hai thanh LinearProgressIndicator như sau:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.ArticleActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:scrollbars="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/prepend_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.progressindicator.LinearProgressIndicator
        android:id="@+id/append_progress"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Tiếp theo, chúng ta tương tác với CombinedLoadState bằng cách thu thập LoadStatesFlow từ PagingDataAdapter. Thu thập trạng thái trong ArticleActivity.kt:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                articleAdapter.loadStateFlow.collect {
                    binding.prependProgress.isVisible = it.source.prepend is Loading
                    binding.appendProgress.isVisible = it.source.append is Loading
                }
            }
        }
        lifecycleScope.launch {
        ...
    }

Cuối cùng, chúng ta thêm độ trễ trong ArticlePagingSource để mô phỏng tải:

private const val LOAD_DELAY_MILLIS = 3_000L

class ArticlePagingSource : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val start = params.key ?: STARTING_KEY
        val range = startKey.until(startKey + params.loadSize)

        if (start != STARTING_KEY) delay(LOAD_DELAY_MILLIS)
        return ...

}

Chạy ứng dụng một lần nữa và di chuyển xuống cuối danh sách. Bạn sẽ thấy thanh tiến trình ở dưới cùng hiển thị trong khi thư viện phân trang tìm nạp thêm mục và biến mất khi hoàn tất!

6277154193f7580.gif

11. Kết thúc

Cùng tóm tắt nhanh những nội dung chúng ta đã đề cập. Chúng ta....

  • ... đã khám phá tổng quan về tính năng phân trang và tại sao nó quan trọng.
  • ... đã thêm tính năng phân trang vào ứng dụng bằng cách tạo một Pager, xác định PagingSource và phát PagingData.
  • ... đã lưu vào bộ nhớ đệm PagingData trong ViewModel bằng toán tử cachedIn.
  • ... đã sử dụng PagingData trong giao diện người dùng bằng cách sử dụng PagingDataAdapter.
  • ...đã dùng PagingDataAdapter.loadStateFlow tương tác với CombinedLoadStates.

Vậy là xong! Để xem các khái niệm phân trang nâng cao khác, hãy tham khảo nội dung lớp học lập trình về Paging nâng cao!