เปลี่ยนรูปแบบสตรีมข้อมูล

เมื่อทํางานกับข้อมูลแบบแบ่งหน้า คุณมักจะต้องเปลี่ยนรูปแบบสตรีมข้อมูลขณะโหลด เช่น คุณอาจต้องกรองรายการ หรือแปลงรายการเป็นประเภทอื่นก่อนที่จะแสดงใน UI กรณีการใช้งานทั่วไปอีกอย่างหนึ่งสำหรับการเปลี่ยนรูปแบบสตรีมข้อมูลคือการเพิ่มตัวคั่นรายการ

โดยทั่วไปแล้ว การใช้การเปลี่ยนรูปแบบกับสตรีมข้อมูลโดยตรงจะช่วยให้คุณแยกองค์ประกอบของที่เก็บและองค์ประกอบ UI ออกจากกันได้

หน้านี้ถือว่าคุณคุ้นเคยกับการใช้งานพื้นฐานของไลบรารีการแบ่งหน้า

ใช้การเปลี่ยนรูปแบบพื้นฐาน

เนื่องจาก 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 แล้ว คุณจะดำเนินการ map() ได้กับแต่ละรายการในรายการแบบแบ่งหน้าภายในออบเจ็กต์ PagingData

Use Case ทั่วไปอย่างหนึ่งของการดำเนินการนี้คือเพื่อจับคู่ออบเจ็กต์เลเยอร์เครือข่ายหรือฐานข้อมูลกับออบเจ็กต์ที่ใช้ในเลเยอร์ 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 ไม่ควรเข้าถึงข้อมูลดังกล่าวโดยตรง แต่ให้กำหนดฟังก์ชันเพื่อแจ้ง 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 ได้ ฟังก์ชัน Conversion ที่เฉพาะเจาะจงจะขึ้นอยู่กับภาษาและเฟรมเวิร์กที่ใช้ แต่ฟังก์ชันทั้งหมดจะมีฟังก์ชันการทำงานที่คล้ายกัน

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)
);

การใช้การดำเนินการอย่าง flatMapLatest หรือ switchMap จะช่วยให้มั่นใจได้ว่า UI จะแสดงเฉพาะผลการค้นหาล่าสุดเท่านั้น หากผู้ใช้เปลี่ยนข้อมูลการค้นหาก่อนที่จะดําเนินการฐานข้อมูลเสร็จสมบูรณ์ การดำเนินการเหล่านี้จะทิ้งผลการค้นหาจากคำค้นหาเดิมและเริ่มการค้นหาใหม่ทันที

กรองข้อมูล

การดำเนินการที่พบบ่อยอีกอย่างหนึ่งคือการกรอง คุณสามารถกรองข้อมูลตามเกณฑ์จากผู้ใช้ หรือนำข้อมูลออกจาก UI ได้หากควรซ่อนข้อมูลตามเกณฑ์อื่นๆ

คุณต้องวางการดำเนินการตัวกรองเหล่านี้ไว้ภายในการเรียกใช้ map() เนื่องจากตัวกรองมีผลกับออบเจ็กต์ PagingData เมื่อกรองข้อมูลออกจาก 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())
)

เพิ่มตัวแบ่งรายการ

ไลบรารีการแบ่งหน้ารองรับตัวคั่นรายการแบบไดนามิก คุณสามารถปรับปรุงความง่ายในการอ่านรายการได้โดยแทรกตัวคั่นลงในสตรีมข้อมูลโดยตรงเป็นRecyclerViewรายการในรายการ ด้วยเหตุนี้ ตัวคั่นจึงเป็นออบเจ็กต์ ViewHolder ที่มีฟีเจอร์ครบถ้วน ซึ่งช่วยให้มีการโต้ตอบ โฟกัสการช่วยเหลือพิเศษ และฟีเจอร์อื่นๆ ทั้งหมดที่ View มีให้

การแทรกตัวคั่นลงในรายการแบบแบ่งหน้ามี 3 ขั้นตอนดังนี้

  1. แปลงโมเดล UI ให้รองรับรายการตัวคั่น
  2. เปลี่ยนรูปแบบสตรีมข้อมูลเพื่อเพิ่มตัวคั่นระหว่างการโหลดข้อมูลกับการแสดงข้อมูลแบบไดนามิก
  3. อัปเดต UI เพื่อจัดการรายการตัวคั่น

แปลงโมเดล UI

ไลบรารีการแบ่งหน้าจะแทรกตัวคั่นรายการลงใน RecyclerView เป็นรายการรายการจริง แต่รายการตัวคั่นต้องแยกความแตกต่างจากรายการข้อมูลในรายการเพื่อให้สามารถเชื่อมโยงกับ ViewHolder ประเภทอื่นที่มี UI ที่แตกต่างกัน วิธีแก้ปัญหาคือสร้างคลาสแบบ Sealed ของ 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 เป็นประเภทตัวยึดตำแหน่งเพื่อให้จัดการตัวยึดตำแหน่งได้มากกว่า 1 ประเภท หรือจะกําหนดคลาสพื้นฐานทั่วไปที่ทั้งคลาสตัวยึดตําแหน่งรายการและตัวยึดตําแหน่งตัวคั่นจะขยายก็ได้

นอกจากนี้ คุณต้องทำการเปลี่ยนแปลงต่อไปนี้กับอะแดปเตอร์รายการด้วย

  • เพิ่มเคสลงในเมธอด 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

แหล่งข้อมูลเพิ่มเติม

ดูข้อมูลเพิ่มเติมเกี่ยวกับไลบรารีการแบ่งหน้าได้ที่แหล่งข้อมูลเพิ่มเติมต่อไปนี้

Codelabs