데이터 스트림 변환

페이징된 데이터로 작업할 때는 데이터 스트림을 로드할 때 변환해야 하는 경우가 많습니다. 예를 들어 UI에 항목을 제공하기 전에 먼저 항목 목록을 필터링하거나 항목을 다른 유형으로 변환해야 할 수도 있습니다. 데이터 스트림 변환의 또 다른 일반적인 사용 사례는 목록 구분자 추가입니다.

일반적으로 데이터 스트림에 변환을 직접 적용하면 저장소 구조와 UI 구조를 별도로 유지할 수 있습니다.

이 페이지에서는 Paging 라이브러리의 기본 사용법을 알고 있다고 가정합니다.

기본 변환 적용

PagingData는 반응형 스트림으로 캡슐화되기 때문에, 데이터 로드와 데이터 표시 사이에 점진적으로 변환 작업을 데이터에 적용할 수 있습니다.

스트림의 각 PagingData 객체에 변환을 적용하려면 스트림에서 map() 작업 내에 변환을 배치합니다.

Kotlin

pager.flow // Type is Flow<PagingData<User>>.
  // Map the outer stream so that the transformations are applied to
  // each new generation of PagingData.
  .map { pagingData ->
    // Transformations in this block are applied to the items
    // in the paged data.
}

Java

PagingRx.getFlowable(pager) // Type is Flowable<PagingData<User>>.
  // Map the outer stream so that the transformations are applied to
  // each new generation of PagingData.
  .map(pagingData -> {
    // Transformations in this block are applied to the items
    // in the paged data.
  });

Java

// Map the outer stream so that the transformations are applied to
// each new generation of PagingData.
Transformations.map(
  // Type is LiveData<PagingData<User>>.
  PagingLiveData.getLiveData(pager),
  pagingData -> {
    // Transformations in this block are applied to the items
    // in the paged data.
  });

데이터 변환

데이터 스트림에서 가장 기본적인 작업은 데이터를 다른 유형으로 변환하는 것입니다. PagingData 객체에 액세스하면 PagingData 객체 내 페이징된 목록에 있는 개별 항목에 map() 작업을 실행할 수 있습니다.

맵 작업의 일반적인 사용 사례 중 하나는 네트워크 또는 데이터베이스 레이어 객체를 UI 레이어에서 특별히 사용되는 객체에 매핑하는 것입니다. 아래 예는 이러한 맵 작업 유형의 적용 방법을 보여줍니다.

Kotlin

pager.flow // Type is Flow<PagingData<User>>.
  .map { pagingData ->
    pagingData.map { user -> UiModel(user) }
  }

Java

// Type is Flowable<PagingData<User>>.
PagingRx.getFlowable(pager)
  .map(pagingData ->
    pagingData.map(UiModel.UserModel::new)
  )

Java

Transformations.map(
  // Type is LiveData<PagingData<User>>.
  PagingLiveData.getLiveData(pager),
  pagingData ->
    pagingData.map(UiModel.UserModel::new)
)

또 다른 일반적인 데이터 변환은 쿼리 문자열과 같은 사용자 입력을 가져와서 표시할 요청 출력으로 변환하는 것입니다. 이를 설정하려면 사용자의 쿼리 입력을 수신 대기하여 캡처하고 요청을 실행하여 쿼리 결과를 UI로 다시 푸시해야 합니다.

스트림 API를 사용하여 쿼리 입력을 수신 대기할 수 있습니다. ViewModel에서 스트림 참조를 유지합니다. UI 레이어에서 여기에 직접 액세스해서는 안 됩니다. 대신 UI 레이어에서는 사용자의 쿼리를 ViewModel에 알리는 함수를 정의합니다.

Kotlin

private val queryFlow = MutableStateFlow("")

fun onQueryChanged(query: String) {
  queryFlow.value = query
}

Java

private BehaviorSubject<String> querySubject = BehaviorSubject.create("");

public void onQueryChanged(String query) {
  queryFlow.onNext(query)
}

Java

private MutableLiveData<String> queryLiveData = new MutableLiveData("");

public void onQueryChanged(String query) {
  queryFlow.setValue(query)
}

데이터 스트림에서 쿼리 값이 변경되면 쿼리 값을 원하는 데이터 유형으로 변환하고 결과를 UI 레이어에 반환하는 작업을 실행할 수 있습니다. 특정 변환 함수는 사용되는 언어와 프레임워크에 따라 다르지만 제공하는 기능은 모두 유사합니다.

Kotlin

val querySearchResults = queryFlow.flatMapLatest { query ->
  // The database query returns a Flow which is output through
  // querySearchResults
  userDatabase.searchBy(query)
}

Java

Observable<User> querySearchResults =
  querySubject.switchMap(query -> userDatabase.searchBy(query));

Java

LiveData<User> querySearchResults = Transformations.switchMap(
  queryLiveData,
  query -> userDatabase.searchBy(query)
);

flatMapLatestswitchMap과 같은 작업을 사용하면 최신 결과만 UI에 반환됩니다. 사용자가 데이터베이스 작업이 완료되기 전에 쿼리 입력을 변경하면 데이터베이스 작업에서 이전 쿼리의 결과를 삭제하고 즉시 새 검색을 실행합니다.

데이터 필터링

또 다른 일반적인 작업은 필터링입니다. 사용자의 기준에 따라 데이터를 필터링할 수 있습니다. 또는 다른 기준에 따라 숨겨야 한다면 UI에서 데이터를 삭제할 수 있습니다.

이러한 필터 작업은 필터가 PagingData 객체에 적용되기 때문에 map() 호출 내에 배치해야 합니다. 데이터가 PagingData에서 필터링되면 새 PagingData 인스턴스가 UI 레이어로 전달되어 표시됩니다.

Kotlin

pager.flow // Type is Flow<PagingData<User>>.
  .map { pagingData ->
    pagingData.filter { user -> !user.hiddenFromUi }
  }

Java

// Type is Flowable<PagingData<User>>.
PagingRx.getFlowable(pager)
  .map(pagingData ->
    pagingData.filter(user -> !user.isHiddenFromUi())
  )
}

Java

Transformations.map(
  // Type is LiveData<PagingData<User>>.
  PagingLiveData.getLiveData(pager),
  pagingData ->
    pagingData.filter(user -> !user.isHiddenFromUi())
)

목록 구분자 추가

Paging 라이브러리는 동적 목록 구분자를 지원합니다. 구분자를 데이터 스트림에 RecyclerView 목록 항목으로 직접 삽입하여 목록 가독성을 개선할 수 있습니다. 결과적으로 구분자는 모든 기능을 갖춘 ViewHolder 객체로서 상호작용, 접근성 포커스, View에서 제공되는 다른 모든 기능을 지원합니다.

페이징된 목록에 구분자를 삽입하는 작업은 세 단계로 이루어집니다.

  1. 구분자 항목에 대응되도록 UI 모델을 변환합니다.
  2. 데이터 로드와 데이터 표시 사이에 구분자를 동적으로 추가하도록 데이터 스트림을 변환합니다.
  3. 구분자 항목을 처리하도록 UI를 업데이트합니다.

UI 모델 변환

Paging 라이브러리는 목록 구분자를 실제 목록 항목으로 RecyclerView에 삽입하지만 구분자 항목을 목록의 데이터 항목과 구별할 수 있어야 고유한 UI로 다른 ViewHolder 유형에 바인딩할 수 있습니다. 해결 방법은 데이터 및 구분자를 나타내는 서브클래스가 있는 Kotlin 봉인 클래스를 만드는 것입니다. 또는 목록 항목 클래스와 구분자 클래스에 의해 확장되는 기본 클래스를 만들 수 있습니다.

User 항목의 페이징된 목록에 구분자를 추가하려는 경우를 가정하겠습니다. 다음 스니펫은 인스턴스가 UserModel 또는 SeparatorModel일 수 있는 기본 클래스를 만드는 방법을 보여줍니다.

Kotlin

sealed class UiModel {
  class UserModel(val id: String, val label: String) : UiModel() {
    constructor(user: User) : this(user.id, user.label)
  }

  class SeparatorModel(val description: String) : UiModel()
}

Java

class UiModel {
  private UiModel() {}

  static class UserModel extends UiModel {
    @NonNull
    private String mId;
    @NonNull
    private String mLabel;

    UserModel(@NonNull String id, @NonNull String label) {
      mId = id;
      mLabel = label;
    }

    UserModel(@NonNull User user) {
      mId = user.id;
      mLabel = user.label;
    }

    @NonNull
    public String getId() {
      return mId;
    }

    @NonNull
    public String getLabel() {
      return mLabel;
      }
    }

    static class SeparatorModel extends UiModel {
    @NonNull
    private String mDescription;

    SeparatorModel(@NonNull String description) {
      mDescription = description;
    }

    @NonNull
    public String getDescription() {
      return mDescription;
    }
  }
}

Java

class UiModel {
  private UiModel() {}

  static class UserModel extends UiModel {
    @NonNull
    private String mId;
    @NonNull
    private String mLabel;

    UserModel(@NonNull String id, @NonNull String label) {
      mId = id;
      mLabel = label;
    }

    UserModel(@NonNull User user) {
      mId = user.id;
      mLabel = user.label;
    }

    @NonNull
    public String getId() {
      return mId;
    }

    @NonNull
    public String getLabel() {
      return mLabel;
      }
    }

    static class SeparatorModel extends UiModel {
    @NonNull
    private String mDescription;

    SeparatorModel(@NonNull String description) {
      mDescription = description;
    }

    @NonNull
    public String getDescription() {
      return mDescription;
    }
  }
}

데이터 스트림 변환

데이터 스트림을 로드한 후 표시하기 전에 변환을 데이터 스트림에 적용해야 합니다. 변환은 다음을 처리해야 합니다.

  • 새 기본 항목 유형을 반영하도록 로드된 목록 항목을 변환합니다.
  • PagingData.insertSeparators() 메서드를 사용하여 구분자를 추가합니다.

변환 작업에 관한 자세한 내용은 기본 변환 적용을 참고하세요.

다음 예에서는 PagingData<User> 스트림을 구분자가 추가된 PagingData<UiModel> 스트림으로 업데이트하는 변환 작업을 보여줍니다.

Kotlin

pager.flow.map { pagingData: PagingData<User> ->
  // Map outer stream, so you can perform transformations on
  // each paging generation.
  pagingData
  .map { user ->
    // Convert items in stream to UiModel.UserModel.
    UiModel.UserModel(user)
  }
  .insertSeparators<UiModel.UserModel, UiModel> { before, after ->
    when {
      before == null -> UiModel.SeparatorModel("HEADER")
      after == null -> UiModel.SeparatorModel("FOOTER")
      shouldSeparate(before, after) -> UiModel.SeparatorModel(
        "BETWEEN ITEMS $before AND $after"
      )
      // Return null to avoid adding a separator between two items.
      else -> null
    }
  }
}

Java

// Map outer stream, so you can perform transformations on each
// paging generation.
PagingRx.getFlowable(pager).map(pagingData -> {
  // First convert items in stream to UiModel.UserModel.
  PagingData<UiModel> uiModelPagingData = pagingData.map(
    UiModel.UserModel::new);

  // Insert UiModel.SeparatorModel, which produces PagingData of
  // generic type UiModel.
  return PagingData.insertSeparators(uiModelPagingData,
    (@Nullable UiModel before, @Nullable UiModel after) -> {
      if (before == null) {
        return new UiModel.SeparatorModel("HEADER");
      } else if (after == null) {
        return new UiModel.SeparatorModel("FOOTER");
      } else if (shouldSeparate(before, after)) {
        return new UiModel.SeparatorModel("BETWEEN ITEMS "
          + before.toString() + " AND " + after.toString());
      } else {
        // Return null to avoid adding a separator between two
        // items.
        return null;
      }
    });
});

Java

// Map outer stream, so you can perform transformations on each
// paging generation.
Transformations.map(PagingLiveData.getLiveData(pager),
  pagingData -> {
    // First convert items in stream to UiModel.UserModel.
    PagingData<UiModel> uiModelPagingData = pagingData.map(
      UiModel.UserModel::new);

    // Insert UiModel.SeparatorModel, which produces PagingData of
    // generic type UiModel.
    return PagingData.insertSeparators(uiModelPagingData,
      (@Nullable UiModel before, @Nullable UiModel after) -> {
        if (before == null) {
          return new UiModel.SeparatorModel("HEADER");
        } else if (after == null) {
          return new UiModel.SeparatorModel("FOOTER");
        } else if (shouldSeparate(before, after)) {
          return new UiModel.SeparatorModel("BETWEEN ITEMS "
            + before.toString() + " AND " + after.toString());
        } else {
          // Return null to avoid adding a separator between two
          // items.
          return null;
        }
      });
  });

UI에서 구분자 처리

마지막 단계는 구분자 항목 유형을 수용하도록 UI를 변경하는 것입니다. 구분자 항목의 레이아웃과 뷰 홀더를 만들고 RecyclerView.ViewHolder를 뷰 홀더 유형으로 사용하도록 목록 어댑터를 변경하여 두 개 이상의 뷰 홀더 유형을 처리할 수 있도록 합니다. 또는 항목 클래스와 구분자 뷰 홀더 클래스가 모두 확장하는 공통 기본 클래스를 정의할 수 있습니다.

목록 어댑터도 다음과 같이 변경해야 합니다.

  • 구분자 목록 항목을 고려하여 onCreateViewHolder() 메서드와 onBindViewHolder() 메서드에 사례를 추가합니다.
  • 새 비교 연산자를 구현합니다.

Kotlin

class UiModelAdapter :
  PagingDataAdapter<UiModel, RecyclerView.ViewHolder>(UiModelComparator) {

  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ) = when (viewType) {
    R.layout.item -> UserModelViewHolder(parent)
    else -> SeparatorModelViewHolder(parent)
  }

  override fun getItemViewType(position: Int) {
    // Use peek over getItem to avoid triggering page fetch / drops, since
    // recycling views is not indicative of the user's current scroll position.
    return when (peek(position)) {
      is UiModel.UserModel -> R.layout.item
      is UiModel.SeparatorModel -> R.layout.separator_item
      null -> throw IllegalStateException("Unknown view")
    }
  }

  override fun onBindViewHolder(
    holder: RecyclerView.ViewHolder,
    position: Int
  ) {
    val item = getItem(position)
    if (holder is UserModelViewHolder) {
      holder.bind(item as UserModel)
    } else if (holder is SeparatorModelViewHolder) {
      holder.bind(item as SeparatorModel)
    }
  }
}

object UiModelComparator : DiffUtil.ItemCallback<UiModel>() {
  override fun areItemsTheSame(
    oldItem: UiModel,
    newItem: UiModel
  ): Boolean {
    val isSameRepoItem = oldItem is UiModel.UserModel
      && newItem is UiModel.UserModel
      && oldItem.id == newItem.id

    val isSameSeparatorItem = oldItem is UiModel.SeparatorModel
      && newItem is UiModel.SeparatorModel
      && oldItem.description == newItem.description

    return isSameRepoItem || isSameSeparatorItem
  }

  override fun areContentsTheSame(
    oldItem: UiModel,
    newItem: UiModel
  ) = oldItem == newItem
}

Java

class UiModelAdapter extends PagingDataAdapter<UiModel, RecyclerView.ViewHolder> {
  UiModelAdapter() {
    super(new UiModelComparator(), Dispatchers.getMain(),
      Dispatchers.getDefault());
  }

  @NonNull
  @Override
  public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
    int viewType) {
    if (viewType == R.layout.item) {
      return new UserModelViewHolder(parent);
    } else {
      return new SeparatorModelViewHolder(parent);
    }
  }

  @Override
  public int getItemViewType(int position) {
    // Use peek over getItem to avoid triggering page fetch / drops, since
    // recycling views is not indicative of the user's current scroll position.
    UiModel item = peek(position);
    if (item instanceof UiModel.UserModel) {
      return R.layout.item;
    } else if (item instanceof UiModel.SeparatorModel) {
      return R.layout.separator_item;
    } else {
      throw new IllegalStateException("Unknown view");
    }
  }

  @Override
  public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder,
    int position) {
    if (holder instanceOf UserModelViewHolder) {
      UserModel userModel = (UserModel) getItem(position);
      ((UserModelViewHolder) holder).bind(userModel);
    } else {
      SeparatorModel separatorModel = (SeparatorModel) getItem(position);
      ((SeparatorModelViewHolder) holder).bind(separatorModel);
    }
  }
}

class UiModelComparator extends DiffUtil.ItemCallback<UiModel> {
  @Override
  public boolean areItemsTheSame(@NonNull UiModel oldItem,
    @NonNull UiModel newItem) {
    boolean isSameRepoItem = oldItem instanceof UserModel
      && newItem instanceof UserModel
      && ((UserModel) oldItem).getId().equals(((UserModel) newItem).getId());

    boolean isSameSeparatorItem = oldItem instanceof SeparatorModel
      && newItem instanceof SeparatorModel
      && ((SeparatorModel) oldItem).getDescription().equals(
      ((SeparatorModel) newItem).getDescription());

    return isSameRepoItem || isSameSeparatorItem;
  }

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

Java

class UiModelAdapter extends PagingDataAdapter<UiModel, RecyclerView.ViewHolder> {
  UiModelAdapter() {
    super(new UiModelComparator(), Dispatchers.getMain(),
      Dispatchers.getDefault());
  }

  @NonNull
  @Override
  public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
    int viewType) {
    if (viewType == R.layout.item) {
      return new UserModelViewHolder(parent);
    } else {
      return new SeparatorModelViewHolder(parent);
    }
  }

  @Override
  public int getItemViewType(int position) {
    // Use peek over getItem to avoid triggering page fetch / drops, since
    // recycling views is not indicative of the user's current scroll position.
    UiModel item = peek(position);
    if (item instanceof UiModel.UserModel) {
      return R.layout.item;
    } else if (item instanceof UiModel.SeparatorModel) {
      return R.layout.separator_item;
    } else {
      throw new IllegalStateException("Unknown view");
    }
  }

  @Override
  public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder,
    int position) {
    if (holder instanceOf UserModelViewHolder) {
      UserModel userModel = (UserModel) getItem(position);
      ((UserModelViewHolder) holder).bind(userModel);
    } else {
      SeparatorModel separatorModel = (SeparatorModel) getItem(position);
      ((SeparatorModelViewHolder) holder).bind(separatorModel);
    }
  }
}

class UiModelComparator extends DiffUtil.ItemCallback<UiModel> {
  @Override
  public boolean areItemsTheSame(@NonNull UiModel oldItem,
    @NonNull UiModel newItem) {
    boolean isSameRepoItem = oldItem instanceof UserModel
      && newItem instanceof UserModel
      && ((UserModel) oldItem).getId().equals(((UserModel) newItem).getId());

    boolean isSameSeparatorItem = oldItem instanceof SeparatorModel
      && newItem instanceof SeparatorModel
      && ((SeparatorModel) oldItem).getDescription().equals(
      ((SeparatorModel) newItem).getDescription());

    return isSameRepoItem || isSameSeparatorItem;
  }

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

중복 작업 방지

앱이 불필요한 작업을 실행하는 것을 방지해야 합니다. 데이터 가져오기는 비용이 많이 드는 작업이고 데이터 변환에도 귀중한 시간이 소요될 수 있습니다. 데이터가 로드되어 UI에 표시될 준비가 되면 구성이 변경되거나 UI를 다시 만들어야 할 때를 대비해 데이터를 저장해야 합니다.

cachedIn() 작업은 이 작업 이전에 발생한 변환의 결과를 캐시합니다. 따라서 cachedIn()은 ViewModel의 마지막 호출이어야 합니다.

Kotlin

pager.flow // Type is Flow<PagingData<User>>.
  .map { pagingData ->
    pagingData.filter { user -> !user.hiddenFromUi }
      .map { user -> UiModel.UserModel(user) }
  }
  .cachedIn(viewModelScope)

Java

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
PagingRx.cachedIn(
  // Type is Flowable<PagingData<User>>.
  PagingRx.getFlowable(pager)
    .map(pagingData -> pagingData
      .filter(user -> !user.isHiddenFromUi())
      .map(UiModel.UserModel::new)),
  viewModelScope);
}

Java

// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
PagingLiveData.cachedIn(
  Transformations.map(
    // Type is LiveData<PagingData<User>>.
    PagingLiveData.getLiveData(pager),
    pagingData -> pagingData
      .filter(user -> !user.isHiddenFromUi())
      .map(UiModel.UserModel::new)),
  viewModelScope);

cachedIn()PagingData 스트림과 함께 사용하는 방법에 관한 자세한 내용은 PagingData 스트림 설정을 참고하세요.

추가 리소스

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

Codelab

샘플