טרנספורמציה של מקורות נתונים (תצוגות מפורטות)

מושגים ויישום ב-Jetpack פיתוח נייטיב

כשעובדים עם נתונים שמחולקים לדפים, לעיתים קרובות צריך לשנות את זרם הנתונים בזמן הטעינה שלו. לדוגמה, יכול להיות שתצטרכו לסנן רשימה של פריטים או להמיר פריטים לסוג אחר לפני שתציגו אותם בממשק המשתמש. תרחיש נפוץ נוסף לשימוש בהמרת מקור נתונים הוא הוספת מפרידים לרשימה.

באופן כללי יותר, החלת טרנספורמציות ישירות על מקור הנתונים מאפשרת לכם להפריד בין מבני המאגר לבין מבני ממשק המשתמש.

בדף הזה אנחנו מניחים שאתם מכירים את השימוש הבסיסי בספריית Paging.

החלת טרנספורמציות בסיסיות

מכיוון ש-PagingData מוכל בזרם ריאקטיבי, אפשר להחיל פעולות טרנספורמציה על הנתונים באופן מצטבר בין טעינת הנתונים לבין הצגתם.

כדי להחיל טרנספורמציות על כל אובייקט PagingData בזרם, צריך להציב את הטרנספורמציות בתוך פעולת map() בזרם:

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.

תרחיש שימוש נפוץ הוא מיפוי של אובייקט בשכבת רשת או מסד נתונים לאובייקט שמשמש ספציפית בשכבת ממשק המשתמש. בדוגמה הבאה אפשר לראות איך מבצעים מיפוי מסוג כזה:

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

המרת נתונים נפוצה נוספת היא קבלת קלט מהמשתמש, כמו מחרוזת שאילתה, והמרתו לפלט של הבקשה שיוצג. כדי להגדיר את זה, צריך להאזין לקלט של שאילתת המשתמש ולתעד אותו, לבצע את הבקשה ולהעביר את תוצאת השאילתה בחזרה לממשק המשתמש.

אפשר להאזין לקלט של השאילתה באמצעות API של סטרימינג. שומרים את ההפניה לשידור ב-ViewModel. לשכבת ממשק המשתמש לא צריכה להיות גישה ישירה אליו. במקום זאת, צריך להגדיר פונקציה כדי להודיע ל-ViewModel על השאילתה של המשתמש.

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

כשערך השאילתה משתנה במקור הנתונים, אפשר לבצע פעולות כדי להמיר את ערך השאילתה לסוג הנתונים הרצוי ולהחזיר את התוצאה לשכבת ממשק המשתמש. פונקציית ההמרה הספציפית תלויה בשפה ובמסגרת שבהן נעשה שימוש, אבל כולן מספקות פונקציונליות דומה.

Java

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

Java

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

שימוש בפעולות כמו flatMapLatest או switchMap מבטיח שרק התוצאות העדכניות ביותר יוחזרו לממשק המשתמש. אם המשתמש משנה את קלט השאילתה לפני שהפעולה במסד הנתונים מסתיימת, הפעולות האלה מבטלות את התוצאות מהשאילתה הישנה ומפעילות את החיפוש החדש באופן מיידי.

סינון נתונים

פעולה נפוצה נוספת היא סינון. אפשר לסנן נתונים לפי קריטריונים של המשתמש, או להסיר נתונים מממשק המשתמש אם צריך להסתיר אותם לפי קריטריונים אחרים.

צריך למקם את פעולות המסנן האלה בתוך הקריאה map() כי המסנן חל על האובייקט PagingData. אחרי שהנתונים מסוננים מ-PagingData, מועבר מופע חדש של PagingData לשכבת ממשק המשתמש לתצוגה.

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. ממירים את מודל ממשק המשתמש כדי להתאים אותו לפריטי ההפרדה.
  2. להפוך את מקור הנתונים כדי להוסיף באופן דינמי את התווים המפרידים בין טעינת הנתונים לבין הצגת הנתונים.
  3. עדכון ממשק המשתמש כדי לטפל בפריטים של מפרידים.

המרת מודל ממשק המשתמש

ספריית Paging מוסיפה מפרידים לרשימה ל-RecyclerView כפריטים אמיתיים ברשימה, אבל צריך להבחין בין פריטי המפריד לבין פריטי הנתונים ברשימה כדי לאפשר להם להיקשר לסוג ViewHolder אחר עם ממשק משתמש שונה. הפתרון הוא ליצור מחלקת Kotlin sealed עם מחלקות משנה שמייצגות את הנתונים ואת התו המפריד. לחלופין, אפשר ליצור מחלקה בסיסית שמוכללת במחלקה של פריט הרשימה ובמחלקה של התו המפריד.

נניח שרוצים להוסיף מפרידים לרשימה עם דפדוף של User פריטים. בקטע הקוד הבא אפשר לראות איך יוצרים מחלקה בסיסית שבה המופעים יכולים להיות UserModel או SeparatorModel:

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> עם מפרידים שנוספו:

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

הגדרת מפרידים בממשק המשתמש

השלב האחרון הוא לשנות את ממשק המשתמש כך שיתאים לסוג הפריט של המפריד. יוצרים פריסת מסך ומחזיק תצוגה עבור פריטי ההפרדה, ומשנים את מתאם הרשימה כך שישתמש ב-RecyclerView.ViewHolder כסוג מחזיק התצוגה, כדי שיוכל לטפל ביותר מסוג אחד של מחזיק תצוגה. אפשר גם להגדיר מחלקת בסיס משותפת ששתי המחלקות של מחזיקי התצוגה של הפריט והמפריד מרחיבות.

צריך גם לבצע את השינויים הבאים במתאם הרשימה:

  • מוסיפים מקרים לשיטות onCreateViewHolder() ו-onBindViewHolder() כדי להתחשב בפריטים ברשימת המפרידים.
  • הטמעה של פונקציית השוואה חדשה.

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

איך להימנע מכפילויות בעבודה

בעיה מרכזית שכדאי להימנע ממנה היא שהאפליקציה מבצעת עבודה מיותרת. שליפת נתונים היא פעולה יקרה, וגם טרנספורמציות של נתונים יכולות לגזול זמן יקר. אחרי שהנתונים נטענים ומוכנים להצגה בממשק המשתמש, צריך לשמור אותם למקרה שיתרחש שינוי בהגדרות ויהיה צורך ליצור מחדש את ממשק המשתמש.

הפעולה cachedIn() שומרת במטמון את התוצאות של כל ההמרות שמתבצעות לפני הפעולה. לכן, cachedIn() צריכה להיות הקריאה האחרונה ב-ViewModel.

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

מקורות מידע נוספים

איפה אפשר למצוא מידע נוסף על ספריית Paging?

Codelabs