네트워크 및 데이터베이스의 페이지

네트워크 연결이 불안정하거나 사용자가 오프라인 상태일 때 앱을 사용할 수 있도록 하여 사용자 경험을 향상하세요. 이를 위한 한 가지 방법은 네트워크와 로컬 데이터베이스에서 동시에 페이징하는 것입니다. 이렇게 하면 앱이 로컬 데이터베이스 캐시에서 UI를 구동하고 데이터베이스에 더 이상 데이터가 없는 경우에만 네트워크에 요청을 보냅니다.

이 가이드에서는 Room 지속성 라이브러리페이징 라이브러리의 기본 사용법을 잘 알고 있다고 가정합니다.

데이터 로드 조정

페이징 라이브러리는 이 사용 사례에 맞는 RemoteMediator 구성요소를 제공합니다. RemoteMediator는 앱이 캐시된 데이터를 모두 사용한 경우 페이징 라이브러리에서 보내는 신호 역할을 합니다. 이 신호를 사용하여 네트워크에서 추가 데이터를 로드하고 로컬 데이터베이스에 저장합니다. PagingSource는 로컬 데이터베이스에서 데이터를 로드하고 표시할 UI에 제공합니다.

추가 데이터가 필요한 경우 페이징 라이브러리는 RemoteMediator 구현에서 load() 메서드를 호출합니다. 이 메서드는 정지 함수이므로 장기 작업을 안전하게 실행할 수 있습니다. 일반적으로 이 함수는 네트워크 소스에서 새로운 데이터를 가져와서 로컬 저장소에 저장합니다.

이 프로세스는 새 데이터를 사용하지만, 시간이 지나면 데이터베이스에 저장된 데이터를 무효화해야 합니다(예: 사용자가 수동으로 새로고침하는 경우). 데이터가 무효화되었는지는 load() 메서드에 전달된 LoadType 속성으로 표시됩니다. LoadTypeRemoteMediator에 기존 데이터를 새로고침할지 아니면 기존 목록의 앞이나 뒤에 추가할 데이터를 가져올지 여부를 알려 줍니다.

이런 방식으로 RemoteMediator는 앱이 사용자가 원하는 데이터를 적절한 순서로 로드하도록 합니다.

페이징 수명 주기

그림 1. PagingSource 및 PagingData를 사용하는 페이징 수명 주기 다이어그램

네트워크에서 직접 페이징하는 경우 PagingSource는 데이터를 로드하고 LoadResult 객체를 반환합니다. PagingSource 구현은 pagingSourceFactory 매개변수를 통해 Pager에 전달됩니다.

UI에 새 데이터가 필요하므로 PagerPagingSource에서 load() 메서드를 호출하고 새 데이터를 캡슐화하는 PagingData 객체 스트림을 반환합니다. 일반적으로 각 PagingData 객체는 표시할 UI로 전송되기 전에 ViewModel에 캐시됩니다.

그림 2. PagingSource 및 RemoteMediator를 사용하는 페이징 수명 주기 다이어그램

RemoteMediator는 이 데이터 흐름을 변경합니다. PagingSource는 계속 데이터를 로드하지만, 페이징된 데이터를 다 소진하면 페이징 라이브러리가 RemoteMediator를 트리거하여 네트워크 소스에서 새 데이터를 로드합니다. RemoteMediator는 새 데이터를 로컬 데이터베이스에 저장하므로 ViewModel의 인메모리 캐시는 필요하지 않습니다. 마지막으로 PagingSource는 자신을 직접 무효화하며 Pager는 새 인스턴스를 생성하여 데이터베이스에서 새 데이터를 로드합니다.

기본 사용법

앱이 항목 키 네트워크 데이터 소스에서 User 항목의 페이지를 Room 데이터베이스에 저장된 로컬 캐시에 로드하려는 경우를 가정해 보겠습니다.

RemoteMediator가 네트워크의 데이터를 데이터베이스에 로드하고 PagingSource가 데이터베이스에서 데이터를 로드함 페이저가 RemoteMediator와 PagingSource를 모두 사용하여 페이징된 데이터를 로드함
그림 3. 계층화된 데이터 소스를 사용하는 페이징 구현 다이어그램

RemoteMediator 구현은 네트워크에서 페이징된 데이터를 데이터베이스로 로드하는 데 도움이 되지만, 데이터를 UI로 직접 로드하지는 않습니다. 대신 이 앱은 데이터베이스를 정보 소스로 사용합니다. 즉, 앱은 데이터베이스에 캐시된 데이터만 표시합니다. PagingSource 구현(예: Room에서 생성된 구현)은 캐시된 데이터를 데이터베이스에서 UI로 로드하는 작업을 처리합니다.

Room 항목 만들기

첫 번째 단계는 Room 지속성 라이브러리를 사용하여 네트워크 데이터 소스에서 페이징된 데이터의 로컬 캐시를 저장하는 데이터베이스를 정의하는 것입니다. Room을 사용하여 로컬 데이터베이스에 데이터 저장의 설명대로 먼저 RoomDatabase를 구현합니다.

다음으로, Room 항목을 사용하여 데이터 정의의 설명대로 목록 항목 테이블을 나타내는 Room 항목을 정의합니다. id 필드를 기본 키로 제공하고 목록 항목에 포함된 다른 정보의 필드도 제공합니다.

Kotlin

@Entity(tableName = "users")
data class User(val id: String, val label: String)

Java

@Entity(tableName = "users")
public class User {
  public String id;
  public String label;
}

Java

@Entity(tableName = "users")
public class User {
  public String id;
  public String label;
}

또한 Room DAO를 사용하여 데이터 액세스의 설명대로 이 Room 항목의 데이터 액세스 객체(DAO)를 정의해야 합니다. 목록 항목의 DAO에는 다음 메서드가 포함되어야 합니다.

  • 테이블에 항목 목록을 삽입하는 insertAll() 메서드
  • 쿼리 문자열을 매개변수로 사용하고 결과 목록의 PagingSource 객체를 반환하는 메서드. 이렇게 하면 Pager 객체가 이 테이블을 페이징된 데이터의 소스로 사용할 수 있습니다.
  • 테이블의 모든 데이터를 삭제하는 clearAll() 메서드

Kotlin

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertAll(users: List<User>)

  @Query("SELECT * FROM users WHERE label LIKE :query")
  fun pagingSource(query: String): PagingSource<Int, User>

  @Query("DELETE FROM users")
  suspend fun clearAll()
}

Java

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertAll(List<User> users);

  @Query("SELECT * FROM users WHERE mLabel LIKE :query")
  PagingSource<Integer, User> pagingSource(String query);

  @Query("DELETE FROM users")
  int clearAll();
}

Java

@Dao
interface UserDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertAll(List<User> users);

  @Query("SELECT * FROM users WHERE mLabel LIKE :query")
  PagingSource<Integer, User> pagingSource(String query);

  @Query("DELETE FROM users")
  int clearAll();
}

RemoteMediator 구현

RemoteMediator의 기본 역할은 Pager가 데이터를 모두 사용했거나 기존 데이터가 무효화되었을 때 네트워크에서 더 많은 데이터를 로드하는 것입니다. RemoteMediator는 로드 동작을 정의하기 위해 재정의해야 하는 load() 메서드를 포함합니다.

일반적인 RemoteMediator 구현에는 다음 매개변수가 포함됩니다.

  • query: 백엔드 서비스에서 검색할 데이터를 정의하는 쿼리 문자열
  • database: 로컬 캐시의 역할을 하는 Room 데이터베이스
  • networkService: 백엔드 서비스의 API 인스턴스

RemoteMediator<Key, Value> 구현을 만듭니다. Key 유형과 Value 유형은 동일한 네트워크 데이터 소스의 PagingSource를 정의하는 경우와 동일합니다. 유형 매개변수 선택에 관한 자세한 내용은 키 및 값 유형 선택을 참고하세요.

Kotlin

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    // ...
  }
}

Java

@UseExperimental(markerClass = ExperimentalPagingApi.class)
class ExampleRemoteMediator extends RxRemoteMediator<Integer, User> {
  private String query;
  private ExampleBackendService networkService;
  private RoomDb database;
  private UserDao userDao;

  ExampleRemoteMediator(
    String query,
    ExampleBackendService networkService, RoomDb database
  ) {
    query = query;
    networkService = networkService;
    database = database;
    userDao = database.userDao();
  }

  @NotNull
  @Override
  public Single<MediatorResult> loadSingle(
    @NotNull LoadType loadType,
    @NotNull PagingState<Integer, User> state
  ) {
    ...
  }
}

Java

class ExampleRemoteMediator extends ListenableFutureRemoteMediator<Integer, User> {
  private String query;
  private ExampleBackendService networkService;
  private RoomDb database;
  private UserDao userDao;
  private Executor bgExecutor;

  ExampleRemoteMediator(
    String query,
    ExampleBackendService networkService,
    RoomDb database,
    Executor bgExecutor
  ) {
    this.query = query;
    this.networkService = networkService;
    this.database = database;
    this.userDao = database.userDao();
    this.bgExecutor = bgExecutor;
  }

  @NotNull
  @Override
  public ListenableFuture<MediatorResult> loadFuture(
    @NotNull LoadType loadType,
    @NotNull PagingState<Integer, User> state
  ) {
    ...
  }
}

load() 메서드는 지원 데이터 세트를 업데이트하고 PagingSource를 무효화합니다. 페이징을 지원하는 일부 라이브러리(예: Room)는 구현하는 PagingSource 객체의 무효화를 자동으로 처리합니다.

load() 메서드는 다음과 같은 두 매개변수를 사용합니다.

  • PagingState: 지금까지 로드한 페이지, 가장 최근에 액세스한 색인, 페이징 스트림을 초기화하는 데 사용한 PagingConfig 객체에 관한 정보를 포함합니다.
  • LoadType: 로드의 유형을 REFRESH, APPEND, PREPEND 중 하나로 나타냅니다.

load() 메서드의 반환 값은 MediatorResult 객체입니다. MediatorResultMediatorResult.Error(오류 설명 포함) 또는 MediatorResult.Success(로드할 데이터가 더 있는지 여부를 나타내는 신호 포함)일 수 있습니다.

load() 메서드는 다음 단계를 진행해야 합니다.

  1. 로드 유형 및 지금까지 로드된 데이터에 따라 네트워크에서 로드할 페이지를 결정합니다.
  2. 네트워크 요청을 트리거합니다.
  3. 로드 작업의 결과에 따라 작업을 실행합니다.
    • 로드가 성공적이고 수신된 항목 목록이 비어 있지 않으면 목록 항목을 데이터베이스에 저장하고 MediatorResult.Success(endOfPaginationReached = false)를 반환합니다. 데이터가 저장된 후에는 데이터 소스를 무효화하여 페이징 라이브러리에 새 데이터를 알립니다.
    • 로드가 성공적이고 수신된 항목 목록이 비어 있거나 마지막 페이지 색인인 경우 MediatorResult.Success(endOfPaginationReached = true)를 반환합니다. 데이터가 저장된 후에는 데이터 소스를 무효화하여 페이징 라이브러리에 새 데이터를 알립니다.
    • 요청에서 오류가 발생한 경우 MediatorResult.Error를 반환합니다.

Kotlin

override suspend fun load(
  loadType: LoadType,
  state: PagingState<Int, User>
): MediatorResult {
  return try {
    // The network load method takes an optional after=<user.id>
    // parameter. For every page after the first, pass the last user
    // ID to let it continue from where it left off. For REFRESH,
    // pass null to load the first page.
    val loadKey = when (loadType) {
      LoadType.REFRESH -> null
      // In this example, you never need to prepend, since REFRESH
      // will always load the first page in the list. Immediately
      // return, reporting end of pagination.
      LoadType.PREPEND ->
        return MediatorResult.Success(endOfPaginationReached = true)
      LoadType.APPEND -> {
        val lastItem = state.lastItemOrNull()

        // You must explicitly check if the last item is null when
        // appending, since passing null to networkService is only
        // valid for initial load. If lastItem is null it means no
        // items were loaded after the initial REFRESH and there are
        // no more items to load.
        if (lastItem == null) {
          return MediatorResult.Success(
            endOfPaginationReached = true
          )
        }

        lastItem.id
      }
    }

    // Suspending network load via Retrofit. This doesn't need to be
    // wrapped in a withContext(Dispatcher.IO) { ... } block since
    // Retrofit's Coroutine CallAdapter dispatches on a worker
    // thread.
    val response = networkService.searchUsers(
      query = query, after = loadKey
    )

    database.withTransaction {
      if (loadType == LoadType.REFRESH) {
        userDao.deleteByQuery(query)
      }

      // Insert new users into database, which invalidates the
      // current PagingData, allowing Paging to present the updates
      // in the DB.
      userDao.insertAll(response.users)
    }

    MediatorResult.Success(
      endOfPaginationReached = response.nextKey == null
    )
  } catch (e: IOException) {
    MediatorResult.Error(e)
  } catch (e: HttpException) {
    MediatorResult.Error(e)
  }
}

Java

@NotNull
@Override
public Single<MediatorResult> loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter. For
  // every page after the first, pass the last user ID to let it continue from
  // where it left off. For REFRESH, pass null to load the first page.
  String loadKey = null;
  switch (loadType) {
    case REFRESH:
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Single.just(new MediatorResult.Success(true));
      }

      loadKey = lastItem.getId();
      break;
  }

  return networkService.searchUsers(query, loadKey)
    .subscribeOn(Schedulers.io())
    .map((Function<SearchUserResponse, MediatorResult>) response -> {
      database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
        }

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter. For
  // every page after the first, pass the last user ID to let it continue from
  // where it left off. For REFRESH, pass null to load the first page.
  String loadKey = null;
  switch (loadType) {
    case REFRESH:
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Futures.immediateFuture(new MediatorResult.Success(true));
      }

      loadKey = lastItem.getId();
      break;
  }

  ListenableFuture<MediatorResult> networkResult = Futures.transform(
    networkService.searchUsers(query, loadKey),
    response -> {
      database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
        }

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    }, bgExecutor);

  ListenableFuture<MediatorResult> ioCatchingNetworkResult =
    Futures.catching(
      networkResult,
      IOException.class,
      MediatorResult.Error::new,
      bgExecutor
    );

  return Futures.catching(
    ioCatchingNetworkResult,
    HttpException.class,
    MediatorResult.Error::new,
    bgExecutor
  );
}

초기화 메서드 정의

RemoteMediator 구현은 또한 initialize() 메서드를 재정의하여 캐시된 데이터가 오래되었는지 점검하고 원격 새로고침을 트리거할지 결정할 수 있습니다. 이 메서드는 로드하기 전에 실행되므로 로컬 또는 원격 로드를 트리거하기 전에 데이터베이스를 조작할 수 있습니다(예: 기존 데이터 삭제).

initialize()는 비동기 함수이므로 데이터를 로드하여 데이터베이스에 있는 기존 데이터의 관련성을 확인할 수 있습니다. 가장 일반적인 사례는 캐시된 데이터가 특정 기간에만 유효한 경우입니다. RemoteMediator는 만료 기간이 지났는지 확인할 수 있으며, 이런 경우 페이징 라이브러리는 데이터를 완전히 새로고침해야 합니다. initialize() 구현은 다음과 같이 InitializeAction을 반환해야 합니다.

  • 로컬 데이터를 완전히 새로고침해야 하는 경우 initialize()InitializeAction.LAUNCH_INITIAL_REFRESH를 반환해야 합니다. 그러면 RemoteMediator가 원격으로 새로고침을 실행하여 데이터를 완전히 새로고침합니다. APPEND 또는 PREPEND의 원격 로드는 REFRESH 로드가 성공할 때까지 기다린 후에 진행됩니다.
  • 로컬 데이터를 새로고침할 필요가 없는 경우 initialize()InitializeAction.SKIP_INITIAL_REFRESH를 반환해야 합니다. 그러면 RemoteMediator가 원격 새로고침을 건너뛰고 캐시된 데이터를 로드합니다.

Kotlin

override suspend fun initialize(): InitializeAction {
  val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
  return if (System.currentTimeMillis() - db.lastUpdated() <= cacheTimeout)
  {
    // Cached data is up-to-date, so there is no need to re-fetch
    // from the network.
    InitializeAction.SKIP_INITIAL_REFRESH
  } else {
    // Need to refresh cached data from network; returning
    // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's
    // APPEND and PREPEND from running until REFRESH succeeds.
    InitializeAction.LAUNCH_INITIAL_REFRESH
  }
}

Java

@NotNull
@Override
public Single<InitializeAction> initializeSingle() {
  long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS);
  return mUserDao.lastUpdatedSingle()
    .map(lastUpdatedMillis -> {
      if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) {
        // Cached data is up-to-date, so there is no need to re-fetch
        // from the network.
        return InitializeAction.SKIP_INITIAL_REFRESH;
      } else {
        // Need to refresh cached data from network; returning
        // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's
        // APPEND and PREPEND from running until REFRESH succeeds.
        return InitializeAction.LAUNCH_INITIAL_REFRESH;
      }
    });
}

Java

@NotNull
@Override
public ListenableFuture<InitializeAction> initializeFuture() {
  long cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS);
  return Futures.transform(
    mUserDao.lastUpdated(),
    lastUpdatedMillis -> {
      if (System.currentTimeMillis() - lastUpdatedMillis <= cacheTimeout) {
        // Cached data is up-to-date, so there is no need to re-fetch
        // from the network.
        return InitializeAction.SKIP_INITIAL_REFRESH;
      } else {
        // Need to refresh cached data from network; returning
        // LAUNCH_INITIAL_REFRESH here will also block RemoteMediator's
        // APPEND and PREPEND from running until REFRESH succeeds.
        return InitializeAction.LAUNCH_INITIAL_REFRESH;
      }
    },
    mBgExecutor);
}

페이저 만들기

마지막으로 Pager 인스턴스를 만들어 페이징된 데이터 스트림을 설정해야 합니다. 간단한 네트워크 데이터 소스에서 Pager를 만드는 것과 비슷하지만 다르게 처리해야 하는 두 가지가 있습니다.

  • PagingSource 생성자를 직접 전달하는 대신 DAO에서 PagingSource 객체를 반환하는 쿼리 메서드를 제공해야 합니다.
  • RemoteMediator 구현 인스턴스를 remoteMediator 매개변수로 제공해야 합니다.

Kotlin

val userDao = database.userDao()
val pager = Pager(
  config = PagingConfig(pageSize = 50)
  remoteMediator = ExampleRemoteMediator(query, database, networkService)
) {
  userDao.pagingSource(query)
}

Java

UserDao userDao = database.userDao();
Pager<Integer, User> pager = Pager(
  new PagingConfig(/* pageSize = */ 20),
  null, // initialKey,
  new ExampleRemoteMediator(query, database, networkService)
  () -> userDao.pagingSource(query));

Java

UserDao userDao = database.userDao();
Pager<Integer, User> pager = Pager(
  new PagingConfig(/* pageSize = */ 20),
  null, // initialKey
  new ExampleRemoteMediator(query, database, networkService, bgExecutor),
  () -> userDao.pagingSource(query));

경합 상태 처리

여러 소스에서 데이터를 로드할 때 로컬 캐시 데이터가 원격 데이터 소스와 동기화되지 않는 경우 앱에서 이를 처리해야 합니다.

RemoteMediator 구현의 initialize() 메서드가 LAUNCH_INITIAL_REFRESH를 반환하면 데이터가 오래된 것이므로 새 데이터로 교체해야 합니다. PREPEND 또는 APPEND 로드 요청은 원격 REFRESH 로드가 성공할 때까지 기다려야 합니다. PREPEND 또는 APPEND 요청이 REFRESH 요청 전에 큐에 추가되었으므로 요청이 실행되는 시점에 이러한 로드 호출에 전달된 PagingState가 만료될 수 있습니다.

데이터가 로컬에 저장되는 방식에 따라 캐시된 데이터 변경으로 인해 데이터가 무효화되거나 새 데이터를 가져오는 경우 앱이 중복 요청을 무시할 수 있습니다. 예를 들어, Room은 모든 데이터 삽입에 관한 쿼리를 무효화합니다. 즉, 데이터베이스에 새 데이터가 삽입될 때 대기 중인 로드 요청에 새로고침한 데이터를 포함하는 새 PagingSource 객체가 제공됩니다.

사용자에게 가장 관련성 높은 최신 데이터를 표시하려면 이 데이터 동기화 문제를 반드시 해결해야 합니다. 가장 좋은 방법은 주로 네트워크 데이터 소스에서 데이터를 페이징하는 방식에 따라 달라집니다. 어떤 경우든 원격 키를 사용하면 서버에서 요청한 가장 최신 페이지의 정보를 저장할 수 있습니다. 앱은 이 정보를 사용하여 다음에 로드할 정확한 데이터 페이지를 식별하고 요청할 수 있습니다.

원격 키 관리

원격 키RemoteMediator 구현에서 다음에 로드할 데이터를 백엔드 서비스에 지시하는 데 사용하는 키입니다. 가장 간단한 경우 페이징된 각 데이터 항목에는 쉽게 참조할 수 있는 원격 키가 포함됩니다. 그러나 원격 키가 개별 항목에 상응하지 않는 경우에는 원격 키를 별도로 저장하고 load() 메서드에서 관리해야 합니다.

이 섹션에서는 개별 항목에 저장되지 않은 원격 키를 수집, 저장 및 업데이트하는 방법을 설명합니다.

항목 키

이 섹션에서는 개별 항목에 대응하는 원격 키로 작업하는 방법을 설명합니다. 일반적으로 API가 개별 항목에서 키를 끄면 항목 ID가 쿼리 매개변수로 전달됩니다. 매개변수 이름은 서버가 제공된 ID 앞 또는 뒤에 있는 항목에 응답해야 하는지 여부를 나타냅니다. User 모델 클래스의 예에서 서버의 id 필드는 추가 데이터 요청 시 원격 키로 사용됩니다.

load() 메서드에서 항목별 원격 키를 관리해야 하는 경우 이러한 키는 일반적으로 서버에서 가져온 데이터의 ID입니다. 새로고침 작업은 가장 최근 데이터만 검색하므로 로드 키가 필요하지 않습니다. 마찬가지로 새로고침을 하면 항상 서버에서 최신 데이터를 가져오므로 앞부분의 작업은 추가 데이터를 가져올 필요가 없습니다.

그러나 뒷부분에 추가되는 작업에는 ID가 필요합니다. 이렇게 하려면 데이터베이스에서 마지막 항목을 로드하고 이 항목의 ID를 사용하여 데이터의 다음 페이지를 로드해야 합니다. 데이터베이스에 항목이 없으면 endOfPaginationReached가 true로 설정되어 데이터를 새로고침해야 한다고 표시합니다.

Kotlin

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      // The network load method takes an optional String
      // parameter. For every page after the first, pass the String
      // token returned from the previous page to let it continue
      // from where it left off. For REFRESH, pass null to load the
      // first page.
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        // In this example, you never need to prepend, since REFRESH
        // will always load the first page in the list. Immediately
        // return, reporting end of pagination.
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        // Get the last User object id for the next RemoteKey.
        LoadType.APPEND -> {
          val lastItem = state.lastItemOrNull()

          // You must explicitly check if the last item is null when
          // appending, since passing null to networkService is only
          // valid for initial load. If lastItem is null it means no
          // items were loaded after the initial REFRESH and there are
          // no more items to load.
          if (lastItem == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          lastItem.id
        }
      }

      // Suspending network load via Retrofit. This doesn't need to
      // be wrapped in a withContext(Dispatcher.IO) { ... } block
      // since Retrofit's Coroutine CallAdapter dispatches on a
      // worker thread.
      val response = networkService.searchUsers(query, loadKey)

      // Store loaded data, and next key in transaction, so that
      // they're always consistent.
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query)
        }

        // Insert new users into database, which invalidates the
        // current PagingData, allowing Paging to present the updates
        // in the DB.
        userDao.insertAll(response.users)
      }

      // End of pagination has been reached if no users are returned from the
      // service
      MediatorResult.Success(
        endOfPaginationReached = response.users.isEmpty()
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

Java

@NotNull
@Override
public Single>MediatorResult< loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState>Integer, User< state
) {
  // The network load method takes an optional String parameter. For every page
  // after the first, pass the String token returned from the previous page to
  // let it continue from where it left off. For REFRESH, pass null to load the
  // first page.
  Single>String< remoteKeySingle = null;
  switch (loadType) {
    case REFRESH:
      // Initial load should use null as the page key, so you can return null
      // directly.
      remoteKeySingle = Single.just(null);
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when
      // appending, since passing null to networkService is only
      // valid for initial load. If lastItem is null it means no
      // items were loaded after the initial REFRESH and there are
      // no more items to load.
      if (lastItem == null) {
        return Single.just(new MediatorResult.Success(true));
      }
      remoteKeySingle = Single.just(lastItem.getId());
      break;
  }

  return remoteKeySingle
    .subscribeOn(Schedulers.io())
    .flatMap((Function<String, Single<MediatorResult>>) remoteKey -> {
      return networkService.searchUsers(query, remoteKey)
        .map(response -> {
          database.runInTransaction(() -> {
            if (loadType == LoadType.REFRESH) {
              userDao.deleteByQuery(query);
            }
            // Insert new users into database, which invalidates the current
            // PagingData, allowing Paging to present the updates in the DB.
            userDao.insertAll(response.getUsers());
          });

          return new MediatorResult.Success(response.getUsers().isEmpty());
        });
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter.
  // For every page after the first, pass the last user ID to let it continue
  // from where it left off. For REFRESH, pass null to load the first page.
  ResolvableFuture<String> remoteKeyFuture = ResolvableFuture.create();
  switch (loadType) {
    case REFRESH:
      remoteKeyFuture.set(null);
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Futures.immediateFuture(new MediatorResult.Success(true));
      }

      remoteKeyFuture.set(lastItem.getId());
      break;
  }

  return Futures.transformAsync(remoteKeyFuture, remoteKey -> {

    ListenableFuture<MediatorResult> networkResult = Futures.transform(
      networkService.searchUsers(query, remoteKey),
      response -> {
        database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
        }

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getUsers().isEmpty());
    }, bgExecutor);

    ListenableFuture<MediatorResult> ioCatchingNetworkResult =
      Futures.catching(
        networkResult,
        IOException.class,
        MediatorResult.Error::new,
        bgExecutor
      );

    return Futures.catching(
      ioCatchingNetworkResult,
      HttpException.class,
      MediatorResult.Error::new,
      bgExecutor
    );
  }, bgExecutor);
}

페이지 키

이 섹션에서는 개별 항목에 대응하지 않는 원격 키로 작업하는 방법을 설명합니다.

원격 키 테이블 추가

원격 키가 목록 항목과 직접적으로 관련이 없는 경우 로컬 데이터베이스의 별도 테이블에 원격 키를 저장하는 것이 가장 좋습니다. 원격 키 테이블을 나타내는 Room 항목을 정의하세요.

Kotlin

@Entity(tableName = "remote_keys")
data class RemoteKey(val label: String, val nextKey: String?)

Java

@Entity(tableName = "remote_keys")
public class RemoteKey {
  public String label;
  public String nextKey;
}

Java

@Entity(tableName = "remote_keys")
public class RemoteKey {
  public String label;
  public String nextKey;
}

RemoteKey 항목의 DAO도 정의해야 합니다.

Kotlin

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  suspend fun insertOrReplace(remoteKey: RemoteKey)

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  suspend fun remoteKeyByQuery(query: String): RemoteKey

  @Query("DELETE FROM remote_keys WHERE label = :query")
  suspend fun deleteByQuery(query: String)
}

Java

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertOrReplace(RemoteKey remoteKey);

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  Single<RemoteKey> remoteKeyByQuerySingle(String query);

  @Query("DELETE FROM remote_keys WHERE label = :query")
  void deleteByQuery(String query);
}

Java

@Dao
interface RemoteKeyDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE)
  void insertOrReplace(RemoteKey remoteKey);

  @Query("SELECT * FROM remote_keys WHERE label = :query")
  ListenableFuture<RemoteKey> remoteKeyByQueryFuture(String query);

  @Query("DELETE FROM remote_keys WHERE label = :query")
  void deleteByQuery(String query);
}

원격 키를 사용하여 로드

load() 메서드가 원격 페이지 키를 관리해야 하는 경우 RemoteMediator기본 사용법과 비교하여 다음과 같이 다르게 정의해야 합니다.

  • 원격 키 테이블의 DAO 참조를 보유하는 추가 속성을 포함합니다.
  • PagingState를 사용하는 대신 원격 키 테이블을 쿼리하여 다음에 로드할 키를 결정합니다.
  • 페이징된 데이터 자체 외에, 네트워크 데이터 소스에서 반환된 데이터 키를 삽입하거나 저장합니다.

Kotlin

@OptIn(ExperimentalPagingApi::class)
class ExampleRemoteMediator(
  private val query: String,
  private val database: RoomDb,
  private val networkService: ExampleBackendService
) : RemoteMediator<Int, User>() {
  val userDao = database.userDao()
  val remoteKeyDao = database.remoteKeyDao()

  override suspend fun load(
    loadType: LoadType,
    state: PagingState<Int, User>
  ): MediatorResult {
    return try {
      // The network load method takes an optional String
      // parameter. For every page after the first, pass the String
      // token returned from the previous page to let it continue
      // from where it left off. For REFRESH, pass null to load the
      // first page.
      val loadKey = when (loadType) {
        LoadType.REFRESH -> null
        // In this example, you never need to prepend, since REFRESH
        // will always load the first page in the list. Immediately
        // return, reporting end of pagination.
        LoadType.PREPEND -> return MediatorResult.Success(
          endOfPaginationReached = true
        )
        // Query remoteKeyDao for the next RemoteKey.
        LoadType.APPEND -> {
          val remoteKey = database.withTransaction {
            remoteKeyDao.remoteKeyByQuery(query)
          }

          // You must explicitly check if the page key is null when
          // appending, since null is only valid for initial load.
          // If you receive null for APPEND, that means you have
          // reached the end of pagination and there are no more
          // items to load.
          if (remoteKey.nextKey == null) {
            return MediatorResult.Success(
              endOfPaginationReached = true
            )
          }

          remoteKey.nextKey
        }
      }

      // Suspending network load via Retrofit. This doesn't need to
      // be wrapped in a withContext(Dispatcher.IO) { ... } block
      // since Retrofit's Coroutine CallAdapter dispatches on a
      // worker thread.
      val response = networkService.searchUsers(query, loadKey)

      // Store loaded data, and next key in transaction, so that
      // they're always consistent.
      database.withTransaction {
        if (loadType == LoadType.REFRESH) {
          remoteKeyDao.deleteByQuery(query)
          userDao.deleteByQuery(query)
        }

        // Update RemoteKey for this query.
        remoteKeyDao.insertOrReplace(
          RemoteKey(query, response.nextKey)
        )

        // Insert new users into database, which invalidates the
        // current PagingData, allowing Paging to present the updates
        // in the DB.
        userDao.insertAll(response.users)
      }

      MediatorResult.Success(
        endOfPaginationReached = response.nextKey == null
      )
    } catch (e: IOException) {
      MediatorResult.Error(e)
    } catch (e: HttpException) {
      MediatorResult.Error(e)
    }
  }
}

Java

@NotNull
@Override
public Single<MediatorResult> loadSingle(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional String parameter. For every page
  // after the first, pass the String token returned from the previous page to
  // let it continue from where it left off. For REFRESH, pass null to load the
  // first page.
  Single<RemoteKey> remoteKeySingle = null;
  switch (loadType) {
    case REFRESH:
      // Initial load should use null as the page key, so you can return null
      // directly.
      remoteKeySingle = Single.just(new RemoteKey(mQuery, null));
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Single.just(new MediatorResult.Success(true));
    case APPEND:
      // Query remoteKeyDao for the next RemoteKey.
      remoteKeySingle = mRemoteKeyDao.remoteKeyByQuerySingle(mQuery);
      break;
  }

  return remoteKeySingle
    .subscribeOn(Schedulers.io())
    .flatMap((Function<RemoteKey, Single<MediatorResult>>) remoteKey -> {
      // You must explicitly check if the page key is null when appending,
      // since null is only valid for initial load. If you receive null
      // for APPEND, that means you have reached the end of pagination and
      // there are no more items to load.
      if (loadType != REFRESH && remoteKey.getNextKey() == null) {
        return Single.just(new MediatorResult.Success(true));
      }

      return networkService.searchUsers(query, remoteKey.getNextKey())
        .map(response -> {
          database.runInTransaction(() -> {
            if (loadType == LoadType.REFRESH) {
              userDao.deleteByQuery(query);
              remoteKeyDao.deleteByQuery(query);
            }

            // Update RemoteKey for this query.
            remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey()));

            // Insert new users into database, which invalidates the current
            // PagingData, allowing Paging to present the updates in the DB.
            userDao.insertAll(response.getUsers());
          });

          return new MediatorResult.Success(response.getNextKey() == null);
        });
    })
    .onErrorResumeNext(e -> {
      if (e instanceof IOException || e instanceof HttpException) {
        return Single.just(new MediatorResult.Error(e));
      }

      return Single.error(e);
    });
}

Java

@NotNull
@Override
public ListenableFuture<MediatorResult> loadFuture(
  @NotNull LoadType loadType,
  @NotNull PagingState<Integer, User> state
) {
  // The network load method takes an optional after=<user.id> parameter. For
  // every page after the first, pass the last user ID to let it continue from
  // where it left off. For REFRESH, pass null to load the first page.
  ResolvableFuture<RemoteKey> remoteKeyFuture = ResolvableFuture.create();
  switch (loadType) {
    case REFRESH:
      remoteKeyFuture.set(new RemoteKey(query, null));
      break;
    case PREPEND:
      // In this example, you never need to prepend, since REFRESH will always
      // load the first page in the list. Immediately return, reporting end of
      // pagination.
      return Futures.immediateFuture(new MediatorResult.Success(true));
    case APPEND:
      User lastItem = state.lastItemOrNull();

      // You must explicitly check if the last item is null when appending,
      // since passing null to networkService is only valid for initial load.
      // If lastItem is null it means no items were loaded after the initial
      // REFRESH and there are no more items to load.
      if (lastItem == null) {
        return Futures.immediateFuture(new MediatorResult.Success(true));
      }

      // Query remoteKeyDao for the next RemoteKey.
      remoteKeyFuture.setFuture(
        remoteKeyDao.remoteKeyByQueryFuture(query));
      break;
  }

  return Futures.transformAsync(remoteKeyFuture, remoteKey -> {
    // You must explicitly check if the page key is null when appending,
    // since null is only valid for initial load. If you receive null
    // for APPEND, that means you have reached the end of pagination and
    // there are no more items to load.
    if (loadType != LoadType.REFRESH && remoteKey.getNextKey() == null) {
      return Futures.immediateFuture(new MediatorResult.Success(true));
    }

    ListenableFuture<MediatorResult> networkResult = Futures.transform(
      networkService.searchUsers(query, remoteKey.getNextKey()),
      response -> {
        database.runInTransaction(() -> {
        if (loadType == LoadType.REFRESH) {
          userDao.deleteByQuery(query);
          remoteKeyDao.deleteByQuery(query);
        }

        // Update RemoteKey for this query.
        remoteKeyDao.insertOrReplace(new RemoteKey(query, response.getNextKey()));

        // Insert new users into database, which invalidates the current
        // PagingData, allowing Paging to present the updates in the DB.
        userDao.insertAll(response.getUsers());
      });

      return new MediatorResult.Success(response.getNextKey() == null);
    }, bgExecutor);

    ListenableFuture<MediatorResult> ioCatchingNetworkResult =
      Futures.catching(
        networkResult,
        IOException.class,
        MediatorResult.Error::new,
        bgExecutor
      );

    return Futures.catching(
      ioCatchingNetworkResult,
      HttpException.class,
      MediatorResult.Error::new,
      bgExecutor
    );
  }, bgExecutor);
}

새로고침 배치

이전 예와 같이 앱이 목록 상단의 네트워크 새로고침만 지원해야 하는 경우 RemoteMediator가 prepend 로드 동작을 정의할 필요가 없습니다.

그러나 앱이 네트워크에서 로컬 데이터베이스로 증분 로드를 지원해야 하는 경우에는 사용자의 스크롤 위치인 앵커부터 페이지 나누기를 재개하는 기능을 지원해야 합니다. Room의 PagingSource 구현에서 이 작업이 자동으로 처리되지만, Room을 사용하지 않는 경우에는 PagingSource.getRefreshKey()를 재정의하면 됩니다. getRefreshKey()의 구현 예는 PagingSource 정의를 참고하세요.

그림 4는 먼저 로컬 데이터베이스에서 데이터를 로드한 후 데이터베이스의 데이터를 모두 사용하면 네트워크에서 데이터를 로드하는 프로세스를 보여 줍니다.

데이터베이스에 데이터가 없어질 때까지 PagingSource가 데이터베이스에서 UI로 데이터를 로드합니다. 그런 다음 RemoteMediator가 네트워크에서 데이터베이스로 로드한 후 PagingSource가 로드를 계속합니다.
그림 4. PagingSource와 RemoteMediator가 연동하여 데이터를 로드하는 방법을 보여주는 다이어그램

추가 리소스

Paging 라이브러리에 관한 자세한 내용은 다음과 같은 추가 리소스를 참고하세요.

Codelab

샘플