מושגים ויישום ב-Jetpack פיתוח נייטיב
ספריית Paging מספקת יכולות מתקדמות לטעינה ולהצגה של נתונים עם חלוקה לדפים מתוך מערך נתונים גדול יותר. במדריך הזה נדגים איך להשתמש בספריית Paging כדי להגדיר סטרימינג של נתונים עם חלוקה לדפים ממקור נתונים ברשת ולהציג אותם ב-RecyclerView.
הגדרת מקור נתונים
השלב הראשון הוא להגדיר הטמעה של PagingSource כדי לזהות את מקור הנתונים. המחלקה PagingSource API כוללת את ה-method load, שמוחלפת כדי לציין איך לאחזר נתונים עם חלוקה לעמודים ממקור הנתונים המתאים.
אפשר להשתמש ישירות במחלקה PagingSource כדי להשתמש ב-coroutines של Kotlin לטעינה אסינכרונית. ספריית Paging מספקת גם מחלקות לתמיכה במסגרות אסינכרוניות אחרות:
- כדי להשתמש ב-RxJava, צריך להטמיע את
RxPagingSourceבמקום זאת. - כדי להשתמש ב-
ListenableFutureמ-Guava, צריך להטמיע אתListenableFuturePagingSourceבמקום זאת.
בחירת סוגי המפתח והערך
ל-PagingSource<Key, Value> יש שני פרמטרים של סוג: Key ו-Value. המפתח
מגדיר את המזהה שמשמש לטעינת הנתונים, והערך הוא סוג הנתונים עצמם. לדוגמה, אם טוענים דפים של User אובייקטים מהרשת על ידי העברת מספרי הדפים Int אל Retrofit, בוחרים באפשרות Int בתור סוג Key ובאפשרות User בתור סוג Value.
הגדרת ה-PagingSource
בדוגמה הבאה מוטמע PagingSource שטוען דפים של פריטים לפי מספר הדף. הסוג של Key הוא Int והסוג של Value הוא User.
Java (RxJava)
class ExamplePagingSource extends RxPagingSource<Integer, User> {
@NonNull
private ExampleBackendService mBackend;
@NonNull
private String mQuery;
ExamplePagingSource(@NonNull ExampleBackendService backend,
@NonNull String query) {
mBackend = backend;
mQuery = query;
}
@NotNull
@Override
public Single<LoadResult<Integer, User>> loadSingle(
@NotNull LoadParams<Integer> params) {
// Start refresh at page 1 if undefined.
Integer nextPageNumber = params.getKey();
if (nextPageNumber == null) {
nextPageNumber = 1;
}
return mBackend.searchUsers(mQuery, nextPageNumber)
.subscribeOn(Schedulers.io())
.map(this::toLoadResult)
.onErrorReturn(LoadResult.Error::new);
}
private LoadResult<Integer, User> toLoadResult(
@NonNull SearchUserResponse response) {
return new LoadResult.Page<>(
response.getUsers(),
null, // Only paging forward.
response.getNextPageNumber(),
LoadResult.Page.COUNT_UNDEFINED,
LoadResult.Page.COUNT_UNDEFINED);
}
@Nullable
@Override
public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
// Try to find the page key of the closest page to anchorPosition from
// either the prevKey or the nextKey; you need to handle nullability
// here.
// * prevKey == null -> anchorPage is the first page.
// * nextKey == null -> anchorPage is the last page.
// * both prevKey and nextKey are null -> anchorPage is the
// initial page, so return null.
Integer anchorPosition = state.getAnchorPosition();
if (anchorPosition == null) {
return null;
}
LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
if (anchorPage == null) {
return null;
}
Integer prevKey = anchorPage.getPrevKey();
if (prevKey != null) {
return prevKey + 1;
}
Integer nextKey = anchorPage.getNextKey();
if (nextKey != null) {
return nextKey - 1;
}
return null;
}
}
Java (Guava/LiveData)
class ExamplePagingSource extends ListenableFuturePagingSource<Integer, User> {
@NonNull
private ExampleBackendService mBackend;
@NonNull
private String mQuery;
@NonNull
private Executor mBgExecutor;
ExamplePagingSource(
@NonNull ExampleBackendService backend,
@NonNull String query, @NonNull Executor bgExecutor) {
mBackend = backend;
mQuery = query;
mBgExecutor = bgExecutor;
}
@NotNull
@Override
public ListenableFuture<LoadResult<Integer, User>> loadFuture(@NotNull LoadParams<Integer> params) {
// Start refresh at page 1 if undefined.
Integer nextPageNumber = params.getKey();
if (nextPageNumber == null) {
nextPageNumber = 1;
}
ListenableFuture<LoadResult<Integer, User>> pageFuture =
Futures.transform(mBackend.searchUsers(mQuery, nextPageNumber),
this::toLoadResult, mBgExecutor);
ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture =
Futures.catching(pageFuture, HttpException.class,
LoadResult.Error::new, mBgExecutor);
return Futures.catching(partialLoadResultFuture,
IOException.class, LoadResult.Error::new, mBgExecutor);
}
private LoadResult<Integer, User> toLoadResult(@NonNull SearchUserResponse response) {
return new LoadResult.Page<>(response.getUsers(),
null, // Only paging forward.
response.getNextPageNumber(),
LoadResult.Page.COUNT_UNDEFINED,
LoadResult.Page.COUNT_UNDEFINED);
}
@Nullable
@Override
public Integer getRefreshKey(@NotNull PagingState<Integer, User> state) {
// Try to find the page key of the closest page to anchorPosition from
// either the prevKey or the nextKey; you need to handle nullability
// here.
// * prevKey == null -> anchorPage is the first page.
// * nextKey == null -> anchorPage is the last page.
// * both prevKey and nextKey are null -> anchorPage is the
// initial page, so return null.
Integer anchorPosition = state.getAnchorPosition();
if (anchorPosition == null) {
return null;
}
LoadResult.Page<Integer, User> anchorPage = state.closestPageToPosition(anchorPosition);
if (anchorPage == null) {
return null;
}
Integer prevKey = anchorPage.getPrevKey();
if (prevKey != null) {
return prevKey + 1;
}
Integer nextKey = anchorPage.getNextKey();
if (nextKey != null) {
return nextKey - 1;
}
return null;
}
}
בדרך כלל, הטמעה של PagingSource מעבירה פרמטרים שמוגדרים בבונה שלה לשיטה load כדי לטעון נתונים מתאימים לשאילתה. בדוגמה שלמעלה, הפרמטרים האלה הם:
-
backend: מופע של שירות הקצה העורפי שמספק את הנתונים -
query: שאילתת החיפוש שרוצים לשלוח לשירות שמצוין על ידיbackend
האובייקט LoadParams מכיל מידע על פעולת הטעינה שצריך לבצע. המידע הזה כולל את המפתח לטעינה ואת מספר הפריטים לטעינה.
האובייקט LoadResult מכיל את התוצאה של פעולת הטעינה.
LoadResult הוא מחלקה אטומה שיכולה לקבל אחת משתי צורות, בהתאם להצלחה של הקריאה ל-load:
- אם הטעינה מצליחה, מחזירים אובייקט
LoadResult.Page. - אם הטעינה לא מצליחה, מחזירים אובייקט
LoadResult.Error.
באיור הבא אפשר לראות איך הפונקציה load בדוגמה הזו מקבלת את המפתח לכל טעינה ומספקת את המפתח לטעינה הבאה.
load משתמש במפתח ומעדכן אותו.
בנוסף, בהטמעה של PagingSource צריך להטמיע שיטה getRefreshKey שמקבלת אובייקט PagingState כפרמטר. היא מחזירה את המפתח להעברה לשיטה load כשהנתונים מתרעננים או נפסלים אחרי הטעינה הראשונית. ספריית Paging קוראת לשיטה הזו באופן אוטומטי ברענונים הבאים של הנתונים.
טיפול בשגיאות
בקשות לטעינת נתונים עלולות להיכשל מכמה סיבות, במיוחד כשמבצעים טעינה דרך רשת. כדי לדווח על שגיאות שמתרחשות במהלך הטעינה, מחזירים אובייקט LoadResult.Error מהשיטה load.
לדוגמה, אפשר לזהות ולדווח על שגיאות טעינה ב-ExamplePagingSource
מהדוגמה הקודמת על ידי הוספת הקוד הבא לשיטה load:
Java (RxJava)
return backend.searchUsers(searchTerm, nextPageNumber)
.subscribeOn(Schedulers.io())
.map(this::toLoadResult)
.onErrorReturn(LoadResult.Error::new);
Java (Guava/LiveData)
ListenableFuture<LoadResult<Integer, User>> pageFuture = Futures.transform(
backend.searchUsers(query, nextPageNumber), this::toLoadResult,
bgExecutor);
ListenableFuture<LoadResult<Integer, User>> partialLoadResultFuture = Futures.catching(
pageFuture, HttpException.class, LoadResult.Error::new,
bgExecutor);
return Futures.catching(partialLoadResultFuture,
IOException.class, LoadResult.Error::new, bgExecutor);
מידע נוסף על טיפול בשגיאות ב-Retrofit זמין בדוגמאות בPagingSourceהפניית API.
PagingSource אוסף ומעביר אובייקטים של LoadResult.Error לממשק המשתמש כדי שתוכלו לפעול לפיהם. מידע נוסף על הצגת מצב הטעינה בממשק המשתמש זמין במאמר ניהול והצגה של מצבי טעינה.
הגדרה של שידור נתונים של בקשות דפדוף
לאחר מכן, צריך להגדיר זרם של נתונים עם חלוקה לדפים מההטמעה של PagingSource.
מגדירים את מקור הנתונים ב-ViewModel. המחלקה Pager מספקת שיטות שחושפות זרם ריאקטיבי של אובייקטים מסוג PagingData מ-PagingSource. ספריית Paging תומכת בשימוש בכמה סוגי סטרימינג, כולל Flow, LiveData, והסוגים Flowable ו-Observable מ-RxJava.
כשיוצרים מופע Pager כדי להגדיר את הזרם הריאקטיבי, צריך לספק למופע אובייקט הגדרה של PagingConfig ופונקציה שמסבירה ל-Pager איך לקבל מופע של הטמעת PagingSource:
Java (RxJava)
// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
new PagingConfig(/* pageSize = */ 20),
() -> ExamplePagingSource(backend, query));
Flowable<PagingData<User>> flowable = PagingRx.getFlowable(pager);
PagingRx.cachedIn(flowable, viewModelScope);
Java (Guava/LiveData)
// CoroutineScope helper provided by the lifecycle-viewmodel-ktx artifact.
CoroutineScope viewModelScope = ViewModelKt.getViewModelScope(viewModel);
Pager<Integer, User> pager = Pager<>(
new PagingConfig(/* pageSize = */ 20),
() -> ExamplePagingSource(backend, query));
PagingLiveData.cachedIn(PagingLiveData.getLiveData(pager), viewModelScope);
האופרטור cachedIn מאפשר לשתף את מקור הנתונים ומאחסן במטמון את הנתונים שנטענו עם CoroutineScope שסופק. בדוגמה הזו נעשה שימוש ב-viewModelScope שסופק על ידי ארטיפקט lifecycle-viewmodel-ktx של מחזור החיים.
האובייקט Pager קורא לשיטה load מהאובייקט PagingSource, ומספק לו את האובייקט LoadParams ומקבל בתמורה את האובייקט LoadResult.
הגדרת מתאם RecyclerView
צריך גם להגדיר מתאם כדי לקבל את הנתונים לרשימת RecyclerView. ספריית ה-Paging מספקת את המחלקה PagingDataAdapter למטרה הזו.
מגדירים מחלקה שמרחיבה את PagingDataAdapter. בדוגמה, UserAdapter
מורחב מ-PagingDataAdapter כדי לספק מתאם RecyclerView לפריטים ברשימה
מהסוג User, ומשתמש ב-UserViewHolder כמחזיק תצוגה:
Kotlin (שגרות המשך)
class UserAdapter(diffCallback: DiffUtil.ItemCallback<User>) :
PagingDataAdapter<User, UserViewHolder>(diffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): UserViewHolder {
return UserViewHolder(parent)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val item = getItem(position)
// Note that item can be null. ViewHolder must support binding a
// null item as a placeholder.
holder.bind(item)
}
}
Java (RxJava)
class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
super(diffCallback);
}
@NonNull
@Override
public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new UserViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
User item = getItem(position);
// Note that item can be null. ViewHolder must support binding a
// null item as a placeholder.
holder.bind(item);
}
}
Java (Guava/LiveData)
class UserAdapter extends PagingDataAdapter<User, UserViewHolder> {
UserAdapter(@NotNull DiffUtil.ItemCallback<User> diffCallback) {
super(diffCallback);
}
@NonNull
@Override
public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new UserViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull UserViewHolder holder, int position) {
User item = getItem(position);
// Note that item can be null. ViewHolder must support binding a
// null item as a placeholder.
holder.bind(item);
}
}
בנוסף, במתאם צריך להגדיר את השיטות onCreateViewHolder ו-onBindViewHolder ולציין DiffUtil.ItemCallback. הפעולה הזו זהה לפעולה שמתבצעת בדרך כלל כשמגדירים מתאמי רשימה RecyclerView:
Kotlin (שגרות המשך)
object UserComparator : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
// Id is unique.
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem == newItem
}
}
Java (RxJava)
class UserComparator extends DiffUtil.ItemCallback<User> {
@Override
public boolean areItemsTheSame(@NonNull User oldItem,
@NonNull User newItem) {
// Id is unique.
return oldItem.id.equals(newItem.id);
}
@Override
public boolean areContentsTheSame(@NonNull User oldItem,
@NonNull User newItem) {
return oldItem.equals(newItem);
}
}
Java (Guava/LiveData)
class UserComparator extends DiffUtil.ItemCallback<User> {
@Override
public boolean areItemsTheSame(@NonNull User oldItem,
@NonNull User newItem) {
// Id is unique.
return oldItem.id.equals(newItem.id);
}
@Override
public boolean areContentsTheSame(@NonNull User oldItem,
@NonNull User newItem) {
return oldItem.equals(newItem);
}
}
הצגת הנתונים עם ההחלפה בין הדפים בממשק המשתמש
אחרי שהגדרתם PagingSource, יצרתם דרך לאפליקציה ליצור זרם של PagingData והגדרתם PagingDataAdapter, אתם מוכנים לחבר את הרכיבים האלה ולהציג נתונים עם חלוקה לדפים בפעילות.
מבצעים את השלבים הבאים בשיטה onCreate או בקטע onViewCreated של הפעילות:
- יוצרים מופע של מחלקת
PagingDataAdapter. - מעבירים את מופע
PagingDataAdapterלרשימהRecyclerViewשבה רוצים להציג את הנתונים עם חלוקה לדפים. - מתבוננים בזרם
PagingDataומעבירים כל ערך שנוצר לשיטהsubmitData()של המתאם.
Kotlin (שגרות המשך)
val viewModel by viewModels<ExampleViewModel>()
val pagingAdapter = UserAdapter(UserComparator)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter
// Activities can use lifecycleScope directly; fragments use
// viewLifecycleOwner.lifecycleScope.
lifecycleScope.launch {
viewModel.flow.collectLatest { pagingData ->
pagingAdapter.submitData(pagingData)
}
}
Java (RxJava)
ExampleViewModel viewModel = new ViewModelProvider(this)
.get(ExampleViewModel.class);
UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
R.id.recycler_view);
recyclerView.adapter = pagingAdapter
viewModel.flowable
// Using AutoDispose to handle subscription lifecycle.
// See: https://github.com/uber/AutoDispose.
.to(autoDisposable(AndroidLifecycleScopeProvider.from(this)))
.subscribe(pagingData -> pagingAdapter.submitData(lifecycle, pagingData));
Java (Guava/LiveData)
ExampleViewModel viewModel = new ViewModelProvider(this)
.get(ExampleViewModel.class);
UserAdapter pagingAdapter = new UserAdapter(new UserComparator());
RecyclerView recyclerView = findViewById<RecyclerView>(
R.id.recycler_view);
recyclerView.adapter = pagingAdapter
// Activities can use getLifecycle() directly; fragments use
// getViewLifecycleOwner().getLifecycle().
viewModel.liveData.observe(this, pagingData ->
pagingAdapter.submitData(getLifecycle(), pagingData));
ברשימה RecyclerView מוצגים עכשיו הנתונים שחולקו לדפים ממקור הנתונים, ועוד דף נטען אוטומטית כשצריך.