Smile Engineering Blog

ジェイエスピーからTipsや技術特集、プロジェクト物語を発信します

ページネーションについて

今回は、ページネーションについて解説します。

最初に

検索結果一覧など、ユーザーに対して複数のコンテンツを一度に表示する手段として、リストが挙げられます。 しかし、利用できるコンテンツが膨大なのに対して、端末で表示できるコンテンツ数はごく小さいことがあります。その場合、一度にすべてのコンテンツを読み込むと、UI/UX を損ない、また端末のリソースを無駄に消耗します。 今回、この問題の解決手段として、ページネーションを説明します。

対象範囲

今回は、ページネーションの概要と実例、そして実装例を説明します。また、ページネーションはモバイルアプリにて利用されることが多いため、Android アプリを例にして説明します。 なお、サーバーサイドにおけるページネーションの実装については省略します。

ページネーションとは?

ページネーションとは、複数のコンテンツを適度な長さに区切って複数ページに分割する機能です。

例えばモバイルアプリの場合、

  • サーバーに保存されているコンテンツをダウンロードして表示する場合、すべてのコンテンツを一度に全部ダウンロードすると要領が悪い。

  • 複数ページに分割してダウンロードすることにより、効率的にアプリを動かすことができる。

実例

ページネーションの実例を示します。

以下は、Google ブックスアプリにおけるページネーションの実例です。

実装

今回は、Google Books APIs から取得した書籍情報をページネーションで表示する説明をします。

Google Books APIs

Google Books APIs とは、Google ブックスにある情報を取得できる WebAPI です。 以下に、WebAPIの呼び出し例とその結果を記載します。

呼び出し例

https://www.googleapis.com/books/v1/volumes?q=android&maxResults=30&startIndex=0

結果

{
  "kind": "books#volumes",
  "totalItems": 1387,
  "items": [
    {
      "kind": "books#volume",
      "id": "hkfWnQEACAAJ",
      "etag": "/u9dh+5dqEU",
      "selfLink": "https://www.googleapis.com/books/v1/volumes/hkfWnQEACAAJ",
      "volumeInfo": {
        "title": "Andoroido no nakami",
        "subtitle": "",
        "authors": [
          "Tae Yeon Kim",
          "Hyung Joo Song",
          "Hoon Park Ji"
        ],
        "publishedDate": "2013-12",
        "description": "Androidの内部構造を徹底解剖。ソースコードの分析を通してAndroidフレームワークの全貌に迫る。",
        // 省略
      },
      // 省略
    }
  ]
}

説明

URL:https://www.googleapis.com/books/v1/volumes

No. クエリ 説明
1 q 検索語句
2 maxResults 1 ページあたりの最大件数
3 startIndex startIndex:検索結果の開始位置(startIndex=0,1,2,3,…)

以上より、リストを一番下までスクロールするたびに、startIndex を +1 して API を呼び出せばよいことがわかります。

Paging ライブラリ

Android には、Paging というページネーションの実装を簡略化するライブラリが存在するので、それを使用します。

概念図

以下に、Android の Paging ライブラリの概念図を示します。

build.gradle

以下のライブラリを追加します。

dependencies {
    // Paging ライブラリ
    implementation "androidx.paging:paging-runtime:3.2.0"
    implementation "androidx.paging:paging-rxjava3:3.2.0"
    implementation "io.reactivex.rxjava3:rxjava:3.1.6"
    implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
    // リスト表示
    implementation "androidx.recyclerview:recyclerview:1.3.1"
    // HTTP クライアント
    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    implementation "com.squareup.retrofit2:adapter-rxjava3:2.9.0"
    implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
    implementation "com.squareup.moshi:moshi:1.15.0"
}

HTTP クライアント

以下の通り、HTTP クライアントを宣言します。

public interface GoogleBooksApisService {

        /**
         * 書籍を検索する。
         *
         * @param query      検索語句
         * @param maxResults 1ページあたりの最大件数
         * @param startIndex 検索結果の開始位置
         */
        @GET("books/v1/volumes")
        @NonNull
        Single<Books> searchBooks(@Query("q") @NonNull String query,
                                  @Query("maxResults") int maxResults,
                                  @Query("startIndex") int startIndex);
}

PagingSource

実際に WebAPI を呼び出す Paging ライブラリのクラスです。 PagingDataAdapter から要求されるたびに、インデックスを +1 加算して WebAPI を呼び出し、その結果を返します。

実装では、RxPagingSource を継承して、loadSingle をオーバーライドします。

public final class GoogleBooksPagingSource
        extends RxPagingSource<Integer, Item> {

    @NonNull
    @Override
    public Single<LoadResult<Integer, Item>> loadSingle(
            @NonNull final LoadParams<Integer> loadParams
    ) {
        // リストが一番下までスクロールされたときなどに呼び出される。
        // WebAPIの呼び出しを実装する。
    }
}

RxPagingSource#loadSingle

WebAPI の呼び出しを実装します。

@NonNull
@Override
public Single<LoadResult<Integer, Item>> loadSingle(
        @NonNull final LoadParams<Integer> loadParams
) {
    // LoadParams#getKey()から次のインデックスを取得できる。
    final Integer key = loadParams.getKey();
    final int startIndex;
    // 次のインデックスがnullの場合、先頭番号0で初期化する。
    if (key == null) {
        startIndex = 0;
    } else {
        startIndex = key;
    }
    // WebAPIを呼び出す
    return service.searchBooks(query, 20, startIndex)
            // ワーカースレッドで呼び出す。
            .subscribeOn(Schedulers.io())
            // 応答から必要な結果を取り出す。
            .map((books) -> toLoadResult(books, startIndex))
            // エラーハンドリング
            .onErrorReturn(LoadResult.Error::new);
}

GoogleBooksPagingSource#toLoadResult

次のインデックスの加算と、WebAPI の応答からの必要な結果の取り出しを行います。

@NonNull
private LoadResult<Integer, Item> toLoadResult(@NonNull final Books books,
                                               final int startIndex) {
    final List<Item> items = books.getItems();
    final Integer nextKey;
    // 次のインデックスを決定する。
    if (items.isEmpty()) {
        // データが空の場合には、nullにする。
        nextKey = null;
    } else {
        // データが空でない場合には、インデックスを+1加算する。
        nextKey = startIndex + 1;
    }

    // 読み取り結果を返す。
    return new LoadResult.Page<>(
            items,
            null,
            nextKey, // 読み取り結果に次のインデックスを格納する。
            LoadResult.Page.COUNT_UNDEFINED,
            LoadResult.Page.COUNT_UNDEFINED
    );
}

PagingDataAdapter

UI のリスト表示とデータをつなぐ Paging ライブラリのクラスです。 リストが一番下までスクロールされたことを検出して、新しいデータ要求します。 また、WebAPI 呼び出しの状態(読み取り中や読み取りエラーなど)を保持しているため、その状態を取得して、UI にローディング画面やエラー画面を表示することができます。

実装では、PagingDataAdapter を継承して、必要なメソッドをオーバーライドします。

public final class GoogleBooksAdapter
        extends PagingDataAdapter<Item, GoogleBooksAdapter.ViewHolder> {

    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent,
                                         final int viewType) {
        // ViewHolderを生成する。
    }

    @Override
    public void onBindViewHolder(@NonNull final ViewHolder holder,
                                 final int position) {
        // ViewHolderにデータをバインドする。
    }
}

参考