מפת המעבדות · מעבדת HTTP · מעבדת Room
בסוף המעבדה מסך חדש טוען חמישה Todos בכל עמוד מ־API. כל תגובה מוצלחת נשמרת ב־Room, והמסך מציג רק את הרשומות שנקראו מ־Room. לחיצה על Offline מדלגת על הרשת ועדיין מציגה עמודים שנשמרו; Refresh שנכשל משאיר את הרשומות הישנות ומציג את הכשל. בדיקה עם MockWebServer מוכיחה את מקרה HTTP 503 לאחר טעינה מוצלחת.
בסיס ההשוואה בפרויקט topics הוא codex/http-client, וענף התוצאה הוא codex/paging-offline-cache. ה־HTTP החד־פריטי נשאר עובד; מוסיפים לו מסך ייעודי דרך כפתור. זו פגינציה ידנית ומפורשת כדי לראות את ההחלטות. למוצר עם רשימה גדולה, פילטרים ומקורות מורכבים כדאי לעבור אחר כך ל־Paging 3 עם PagingSource/RemoteMediator על אותו עיקרון של מקור מקומי. סקירת Paging ו־רשת עם Room מתארות את המיפוי.
חוזה הנתונים
| מצב | מה רואים? | האם קוראים לרשת? | האם מוחקים את העמוד? |
|---|---|---|---|
| אין cache, online | מסך ריק בזמן בקשה | כן | אין מה למחוק |
| cache טרי, גיל פחות מדקה | נתונים מקומיים מיד | לא, אלא ב־Refresh | לא |
| cache ישן | נתונים ישנים עם סימון Stale | כן | רק אחרי תשובה תקינה |
| Offline | נתון מקומי, אם קיים | לא | לא |
| HTTP/רשת נכשלים | נתון מקומי והסבר על הכשל | הבקשה נכשלה | לא |
| HTTP הצליח | עמוד חדש שנשמר אטומית | הבקשה הסתיימה | מחליפים רק את אותו עמוד |
הבחירה המרכזית היא Room כמקור לתצוגה, לא מערך Retrofit שמוצג במקביל. כך ה־UI אינו צריך ליישב שתי רשימות סותרות. מדיניות freshness של דקה היא ערך הדגמה, לא אמת אוניברסלית: באפליקציית תחבורה דקה עשויה להיות ישנה מדי, ובספריית ספרים קצרה מדי. גם Offline כאן הוא מצב הדגמה שמכבה קריאות רשת באפליקציה; הוא אינו משנה את מצב המכשיר.
שמירה, טריות וזמינות הן שלוש תכונות שונות
נתון שנמצא ב־Room זמין גם בלי הרשת, אבל יכול להיות ישן. תגובת רשת חדשה יכולה להיות טרייה, אבל לא זמינה בהפעלה הבאה עד שנשמרה. לכן ה־UI קוראת רק מן המסד, ומקבלת לצד הנתונים גם מידע על המקור והשלמת הבקשה. done=false אומר שאפשר להציג כבר נתון מקומי בעוד שהרענון עדיין עובד; כשל רענון אינו מחייב למחוק את מה שכבר אפשר להציג.
sequenceDiagram
participant UI
participant Repo as Repository worker
participant DB as Room
participant API as HTTP API
UI->>Repo: load page
Repo->>DB: read items and page stamp
DB-->>UI: local snapshot with freshness
Repo->>API: refresh if stale or forced, unless offline
alt valid response
API-->>Repo: new page
Repo->>DB: atomically replace page and stamp
DB-->>UI: read stored snapshot
else request fails
API-->>Repo: error
Repo-->>UI: retain old snapshot plus error
end
PageStamp קיימת גם לעמוד ריק: בלעדיה מערך ריק לא יגלה האם מעולם לא טענו את העמוד או שהשרת ענה בהצלחה שאין בו פריטים. חותמת זמן מתעדת הצלחת טעינה; אין לעדכן אותה לכעת כאשר הרענון נכשל, אחרת הנתון הישן יסומן בטעות כטרי.
בטרנזקציה מחליפים את רשומות העמוד ואת חותמתו יחד. מחיקה, insert וחותמת נפרדות יכולות לחשוף עמוד חלקי או חותמת חדשה עם נתונים ישנים. לפני הכתיבה בודקים את גוף התשובה כדי לא להחליף cache תקין בנתון פגום. TTL של דקה ב־currentTimeMillis היא מדיניות הדגמה עם מגבלה: שינוי שעון מכשיר יכול להשפיע על חישוב הגיל; מוצר צריך לבחור שעון ומדיניות שמתאימים לו.
עמוד קצר מגודל הבקשה הוא סימן לסיום בחוזה הזה. עמוד מלא אינו הוכחה שיש עוד פריטים, והוספת פריטים בשרת יכולה להזיז גבולות של עמודים ממוספרים. cache לפי page מתאים לניסוי נשלט; cursor ומפתחות remote נדרשים כשחוזה השרת מבטיח רצף אחר. Paging 3 מנהלת יותר מן התזמון והגלילה, אבל אינה מחליטה עבורנו מה נחשב טרי או איזה מידע מותר לשמור.
עצרו ונבאו
יש PageStamp עם count=0, אבל אין CachedTodo של העמוד. במה זה שונה מהיעדר PageStamp? כתבו תחזית לפני פתיחת ההסבר, ואז הצביעו על המשתנה או התנאי בקוד שמצדיקים אותה.
בדיקת ההבנה
חותמת קיימת אומרת שהעמוד נטען בהצלחה והיה ריק בזמן fetchedAt. היעדר חותמת אומר שאין לנו טעינה מוצלחת שמורה של העמוד. בשניהם הרשימה ריקה, אך מדיניות freshness והודעת המקור שונות.
1. תשתית מסד אחרי מחסום Gradle
ב־Gradle Scripts > libs.versions.toml הוסיפו Room 2.8.5, room-runtime, room-compiler ותוסף Room. הוסיפו kotlinx-serialization-core 1.8.1 כדרישת תאימות של גרסת Room הזו בפרויקט AGP הנוכחי. ב־Gradle Scripts > build.gradle.kts (Project), בתוך plugins, הוסיפו alias(libs.plugins.room) apply false. ב־Gradle Scripts > build.gradle.kts (Module :app) הפעילו alias(libs.plugins.room), הגדירו schemaDirectory("$projectDir/schemas"), הוסיפו runtime ו־annotationProcessor(libs.room.compiler). השאירו את Retrofit, Gson, MockWebServer ותלויות התבנית הקיימות. הריצו Sync ובנייה לפני כל הפניה למחלקות Room שנוצרות; שמרו את קובץ schema v1 שנוצר בבנייה. הקוד המשלים מראה את התוספות המדויקות ל־Gradle, כולל androidTestImplementation(libs.mockwebserver) עבור בדיקת המכשיר.
ב־app > kotlin+java > com.example.topics צרו את קובצי המסד החדשים:
CachedTodo.java:@Entity, מפתח ראשיid, שדותpage,userId,title,completed, ומתודתfrom(Todo,page)שמפרידה JSON ממבנה האחסון.PageStamp.java:@Entity, מפתחpage,fetchedAtב־milliseconds ו־count. השורה קיימת גם לעמוד תקין וריק — אחרת לא נדע אם הוא לא נטען או נטען ללא תוצאות.PageDao.java:items(page)עםORDER BY id,stamp(page),clearPage(page),putItems(...),putStamp(...).PageCache.java:@Database(entities={CachedTodo.class,PageStamp.class}, version=1, exportSchema=true)ומחזירהPageDao.
לא מוחקים את כל הטבלה ברענון: רק את הרשומות של העמוד המבוקש. id הוא זהות ה־Todo מהשרת, ו־page שומר את גבול העמוד שנשלף. ב־API עם פריטים שנכנסים לראש הרשימה בכל רגע, מספרי עמוד עלולים לזוז ונדרש cursor או מדיניות invalidation; זהו גבול הדוגמה.
2. API של עמוד וחוזה סוף
צרו PagedTodoApi.java:
public interface PagedTodoApi {
/**
* Describes a numbered page request; execution belongs on a worker.
*
* @param page one-based page number
* @param limit maximum requested items
* @return new single-use request for the decoded page
*/
@GET("todos")
Call<List<Todo>> page(@Query("_page") int page, @Query("_limit") int limit);
}
בדקנו שהשירות מחזיר /todos?_page=2&_limit=5 עם חמישה פריטים וכותרות Link ו־X-Total-Count. בענף הדוגמה PAGE_SIZE=5; כאשר מתקבלים פחות מחמישה פריטים, כפתור Next מושבת. אם אורך העמוד הוא בדיוק חמישה, עדיין לא מוכח שיש עמוד הבא; ייתכן שהרשימה הסתיימה בדיוק בגבול. במוצר השתמשו ב־Link/total או ב־cursor של ה־API כדי לדעת בוודאות. שרת הדוגמה משמש רק למעבדה ואינו מקור נתונים של מוצר.
3. ה־Repository מתווכת בין local ל־remote
PagedTodoRepository היא קובץ חדש בקוד המשלים. היא יוצרת Room, Retrofit ו־ExecutorService יחיד. load(page, offline, force, listener) פועלת בסדר הבא:
- מבטלת
Callקודם ומגדילהgeneration; הביטול פוסל גם עבודה שעדיין מחכה בתור לפני יצירת Call; עבודה ישנה בתור אינה מפרסמת תוצאה חדשה. - קוראת
PageStampואת רשומות העמוד בשרשור הרקע, ומחזירה אותן מיד עםdone=falseאם יש רענון צפוי. - אם offline או שהעמוד טרי והמשתמש לא ביקש Refresh, מסיימת בלי HTTP.
- אחרת מפעילה
Call<List<Todo>>.execute()ברקע, בודקת status וגוף, וממירה כלTodoל־CachedTodoאחרי בדיקתid/title. database.runInTransactionמוחקת את אותו עמוד, מוסיפה את הרשומות וכותבתPageStampעם הזמן ומספר הרשומות. אחרי העסקה קוראת שוב מ־DAO ומחזירה את הנתון המקומי.- במקרה HTTP/IOException/JSON לא תקין, מחזירה את רשומות ה־cache והודעת כשל. אין מחיקה ואין סימון כטרי.
קטע ההכרעה המרכזי:
PageStamp stamp = dao.stamp(page);
List<CachedTodo> cached = dao.items(page);
boolean fresh = stamp != null && System.currentTimeMillis() - stamp.fetchedAt < 60_000;
boolean complete = offline || (fresh && !force);
listener.accept(new Result(cached, source, complete,
stamp != null && stamp.count == PAGE_SIZE));
if (complete) return;
והחלפה האטומית:
database.runInTransaction(() -> {
// Replace one page and its timestamp as one all-or-nothing storage change.
dao.clearPage(page);
dao.putItems(incoming);
dao.putStamp(new PageStamp(page, System.currentTimeMillis(), incoming.size()));
});
List<CachedTodo> stored = dao.items(page);
אל תבצעו Room או HTTP ב־UI thread. runInTransaction מונעת מצב שבו עמוד ישן נמחק וחדש נכתב רק בחלקו; אם הכתיבה נכשלת, הנתון הקודם נשאר. ב־close() ה־Repository מבטלת בקשה פעילה, מסדרת סגירת DB אחרי העבודה בתור ומכבה executor. מדריך Room מתאר את שכבות Entity/DAO/Database.
4. מסך עמודים שמציג מקור ומצב
ב־MainActivity.java הקיים הוסיפו כפתור אחד open_paged ל־activity_main.xml וחברו Intent ל־PagedTodosActivity; ב־Manifest רשמו Activity פנימית exported=false. צרו activity_paged_todos.xml חדש עם ScrollView, כותרת, Switch id=offline, כפתורי previous, next, refresh, TextView id=status ו־TextView id=items. PagedTodosActivity מחזיקה page=1 ו־generation, קוראת ל־Repository, ומציגה כל CachedTodo כ־ID, מצב וכותרת. היא משביתה ניווט בזמן בקשה, משחזרת page/offline ב־onSaveInstanceState, ומתעלמת מ־callback של בקשה ישנה או Activity שנהרסה.
הטקסט במסך מכיל גם מקור התוצאה: Fresh local page, Stale local page, Network page saved to Room או Offline. בלי הסימון הזה, משתמש ומפתח אינם יכולים להבחין בין מידע מעודכן לנתון ישן. כפתור Refresh עוקף TTL, אבל Offline ממשיך לדלג על רשת — סדר החלטות מכוון.
הקוד המשלים במלואו
הקטעים הגלויים בשיעור ממקדים את הרעיון; הקבצים הבאים משלימים את כל הקוד הדרוש, עם תיעוד והערות. קראו את השינוי יחד עם ההסבר שמעליו. הם חלק מן השיעור ואינם דורשים פתיחת ענף דוגמה או אתר שפורסם.
libs.versions.toml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/libs.versions.toml.md ביחס לשיעור.
הקוד המלא והשינויים עבור libs.versions.toml
ב־libs.versions.toml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
constraintlayout = "2.2.2"
retrofit = "3.0.0"
mockwebserver = "4.12.0"
+room = "2.8.5"
+serialization = "1.8.1"
[libraries]
junit = { group = "junit", name = "junit", version.ref = "junit" }
⁞
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" }
+room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "serialization" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
-
+room = { id = "androidx.room", version.ref = "room" }
build.gradle.kts
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/build.gradle.kts.md ביחס לשיעור.
הקוד המלא והשינויים עבור build.gradle.kts
ב־build.gradle.kts החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
plugins {
alias(libs.plugins.android.application)
+ alias(libs.plugins.room)
+}
+
+room {
+ schemaDirectory("$projectDir/schemas")
}
android {
⁞
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
-
buildFeatures {
viewBinding = true
}
⁞
implementation(libs.material)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
+ implementation(libs.room.runtime)
+ implementation(libs.serialization.core)
+ annotationProcessor(libs.room.compiler)
testImplementation(libs.junit)
testImplementation(libs.mockwebserver)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.ext.junit)
+ androidTestImplementation(libs.mockwebserver)
}
MainActivity.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/MainActivity.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור MainActivity.java
ב־MainActivity.java החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
package com.example.topics;
import android.os.Bundle;
+import android.content.Intent;
import android.view.View;
import androidx.activity.EdgeToEdge;
⁞
EdgeToEdge.enable(this);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
+ binding.openPaged.setOnClickListener(v -> startActivity(new Intent(this, PagedTodosActivity.class)));
ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
CachedTodo.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/CachedTodo.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור CachedTodo.java
צרו CachedTodo.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity
public class CachedTodo {
@PrimaryKey public int id;
public int page;
public int userId;
public String title;
public boolean completed;
/**
* Copies a validated transport model into a row owned by one fetched page.
*
* @param todo decoded, nonnull Todo with a valid ID and title
* @param page one-based page number
* @return new database row independent of the HTTP object
*/
public static CachedTodo from(Todo todo, int page) {
CachedTodo cached = new CachedTodo();
cached.id = todo.id;
cached.page = page;
cached.userId = todo.userId;
cached.title = todo.title;
cached.completed = todo.completed;
return cached;
}
}
PageStamp.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/PageStamp.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור PageStamp.java
צרו PageStamp.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity
public class PageStamp {
@PrimaryKey public int page;
public long fetchedAt;
public int count;
/**
* Records a successful page fetch, including a valid empty page.
*
* @param page one-based page number
* @param fetchedAt successful fetch time in epoch milliseconds
* @param count number of items in that response
*/
public PageStamp(int page, long fetchedAt, int count) {
this.page = page;
this.fetchedAt = fetchedAt;
this.count = count;
}
}
PageDao.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/PageDao.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור PageDao.java
צרו PageDao.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import java.util.List;
@Dao
public interface PageDao {
/**
* Reads a deterministic snapshot of one page; call off main.
*
* @param page one-based page number
* @return stored rows ordered by Todo ID
*/
@Query("SELECT * FROM CachedTodo WHERE page = :page ORDER BY id")
List<CachedTodo> items(int page);
/**
* Reads the freshness marker, including for a cached empty page.
*
* @param page one-based page number
* @return marker, or null if this page has never been stored
*/
@Query("SELECT * FROM PageStamp WHERE page = :page")
PageStamp stamp(int page);
/**
* Deletes only this page within the replacement transaction.
*
* @param page page whose rows are being replaced
*/
@Query("DELETE FROM CachedTodo WHERE page = :page")
void clearPage(int page);
/**
* Writes replacement rows inside the same page transaction.
*
* @param items validated rows for the fetched page
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void putItems(List<CachedTodo> items);
/**
* Writes the page marker in the same transaction as the rows.
*
* @param stamp successful fetch marker
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void putStamp(PageStamp stamp);
}
PageCache.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/PageCache.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור PageCache.java
צרו PageCache.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import androidx.room.Database;
import androidx.room.RoomDatabase;
@Database(entities = {CachedTodo.class, PageStamp.class}, version = 1, exportSchema = true)
public abstract class PageCache extends RoomDatabase {
/**
* Returns Room's generated implementation of the page data contract.
*
* @return DAO whose blocking methods belong on a worker
*/
public abstract PageDao pages();
}
PagedTodoApi.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/PagedTodoApi.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור PagedTodoApi.java
צרו PagedTodoApi.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import java.util.List;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Query;
public interface PagedTodoApi {
/**
* Describes a numbered page request; execute it on a worker.
*
* @param page one-based page number
* @param limit maximum requested items
* @return new single-use request for the decoded page
*/
@GET("todos")
Call<List<Todo>> page(@Query("_page") int page, @Query("_limit") int limit);
}
PagedTodoRepository.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/PagedTodoRepository.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור PagedTodoRepository.java
צרו PagedTodoRepository.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import android.content.Context;
import androidx.room.Room;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import okhttp3.OkHttpClient;
import retrofit2.Call;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
/** A small page-by-page cache lab. Room is the only source rendered by the UI. */
public final class PagedTodoRepository {
public static final int PAGE_SIZE = 5;
private static final long FRESH_MS = 60_000;
private final ExecutorService worker = Executors.newSingleThreadExecutor();
private final PageCache database;
private final PagedTodoApi api;
private volatile Call<List<Todo>> activeCall;
private final AtomicInteger generation = new AtomicInteger();
public static final class Result {
public final List<CachedTodo> items;
public final String message;
public final boolean done;
public final boolean canNext;
/**
* Packages a database snapshot and request status for the UI.
*
* @param items database rows, treated as read-only by the receiver
* @param message source or failure explanation
* @param done whether this load has finished
* @param canNext whether this page was full; does not prove another page exists
*/
Result(List<CachedTodo> items, String message, boolean done, boolean canNext) {
this.items = items;
this.message = message;
this.done = done;
this.canNext = canNext;
}
}
/**
* Creates a database cache and HTTP client using the application Context.
* The overload accepts a test server URL without changing production configuration.
*
* @param context Context used to obtain the application Context
*/
public PagedTodoRepository(Context context) {
this(context, "https://jsonplaceholder.typicode.com/");
}
/**
* Creates a database cache and HTTP client using the application Context.
* The overload accepts a test server URL without changing production configuration.
*
* @param context Context used to obtain the application Context
* @param baseUrl HTTP base URL ending in a slash, supplied by a local test
*/
PagedTodoRepository(Context context, String baseUrl) {
database = Room.databaseBuilder(context.getApplicationContext(), PageCache.class, "todo-pages.db").build();
api = new Retrofit.Builder().baseUrl(baseUrl)
.client(new OkHttpClient.Builder().build())
.addConverterFactory(GsonConverterFactory.create()).build()
.create(PagedTodoApi.class);
}
/**
* Reads Room first and refreshes only when policy requires it.
* Callbacks run on the worker; UI consumers must post to main and check relevance.
*
* @param page positive one-based page number
* @param offline skip HTTP even when force is true
* @param force bypass freshness when online
* @param listener receiver of local and final database snapshots
*/
public void load(int page, boolean offline, boolean force, Consumer<Result> listener) {
cancel();
int expected = generation.incrementAndGet();
worker.execute(() -> {
if (expected != generation.get()) return;
PageDao dao = database.pages();
PageStamp stamp = dao.stamp(page);
List<CachedTodo> cached = dao.items(page);
boolean fresh = stamp != null && System.currentTimeMillis() - stamp.fetchedAt < FRESH_MS;
// Offline wins over forced refresh: it is a deliberate no-network mode.
boolean complete = offline || (fresh && !force);
String source = stamp == null ? "No cached page" : fresh ? "Fresh local page" : "Stale local page";
listener.accept(new Result(cached, offline ? "Offline: " + source : source,
complete, stamp != null && stamp.count == PAGE_SIZE));
if (complete || expected != generation.get()) return;
Call<List<Todo>> call = api.page(page, PAGE_SIZE);
activeCall = call;
try {
Response<List<Todo>> response = call.execute();
if (expected != generation.get()) return;
if (!response.isSuccessful() || response.body() == null) {
listener.accept(new Result(cached, "HTTP " + response.code() + "; showing local page",
true, stamp != null && stamp.count == PAGE_SIZE));
return;
}
List<CachedTodo> incoming = new ArrayList<>();
for (Todo todo : response.body()) {
if (todo == null || todo.id <= 0 || todo.title == null) throw new IllegalArgumentException("Bad todo JSON");
incoming.add(CachedTodo.from(todo, page));
}
database.runInTransaction(() -> {
// Rows and their successful-fetch marker change together.
dao.clearPage(page);
dao.putItems(incoming);
dao.putStamp(new PageStamp(page, System.currentTimeMillis(), incoming.size()));
});
List<CachedTodo> stored = dao.items(page);
listener.accept(new Result(stored, "Network page saved to Room", true,
stored.size() == PAGE_SIZE));
} catch (IOException | IllegalArgumentException error) {
if (!call.isCanceled()) listener.accept(new Result(cached,
error.getClass().getSimpleName() + "; showing local page",
true, stamp != null && stamp.count == PAGE_SIZE));
} finally {
if (activeCall == call) activeCall = null;
}
});
}
/**
* Invalidates queued work and asks an active HTTP request to stop.
*/
public void cancel() {
generation.incrementAndGet(); // Queued work may not yet own an HTTP Call.
Call<List<Todo>> call = activeCall;
if (call != null) call.cancel();
}
/**
* Invalidates pending work and queues database close after work already submitted.
* This repository must not be used after close.
*/
public void close() {
cancel();
worker.execute(database::close);
worker.shutdown();
}
}
PagedTodosActivity.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/PagedTodosActivity.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור PagedTodosActivity.java
צרו PagedTodosActivity.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import android.os.Bundle;
import androidx.activity.EdgeToEdge;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.example.topics.databinding.ActivityPagedTodosBinding;
public class PagedTodosActivity extends AppCompatActivity {
private ActivityPagedTodosBinding binding;
private PagedTodoRepository repository;
private int page = 1;
private int generation;
/**
* Creates the current Activity View tree and connects the screen's actions.
*
* @param savedInstanceState prior small UI snapshot, or null for a fresh launch
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
binding = ActivityPagedTodosBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ViewCompat.setOnApplyWindowInsetsListener(binding.main, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
return insets;
});
repository = new PagedTodoRepository(this);
if (savedInstanceState != null) {
page = savedInstanceState.getInt("page", 1);
binding.offline.setChecked(savedInstanceState.getBoolean("offline"));
}
binding.previous.setOnClickListener(v -> { page--; load(false); });
binding.next.setOnClickListener(v -> { page++; load(false); });
binding.refresh.setOnClickListener(v -> load(true));
binding.offline.setOnCheckedChangeListener((button, checked) -> load(false));
load(false);
}
/**
* Requests this page and accepts only the latest UI generation.
*
* @param force refresh online even when a cached page is fresh
*/
private void load(boolean force) {
int expected = ++generation;
int requestedPage = page;
binding.previous.setEnabled(false);
binding.next.setEnabled(false);
binding.refresh.setEnabled(false);
binding.status.setText("Page " + page + ": reading local cache…");
repository.load(page, binding.offline.isChecked(), force, result -> runOnUiThread(() -> {
if (expected != generation || isDestroyed()) return;
binding.status.setText("Page " + requestedPage + " · " + result.message);
StringBuilder text = new StringBuilder();
for (CachedTodo item : result.items) {
text.append(item.id).append(" · ").append(item.completed ? "✓ " : "○ ")
.append(item.title).append('\n');
}
binding.items.setText(text.length() == 0 ? "No items in this local page." : text.toString());
if (result.done) {
binding.previous.setEnabled(page > 1);
binding.next.setEnabled(result.canNext);
binding.refresh.setEnabled(true);
}
}));
}
/**
* Saves navigation choices, while Room owns the cached data.
*
* @param outState small Activity recreation snapshot
*/
@Override
protected void onSaveInstanceState(Bundle outState) {
outState.putInt("page", page);
outState.putBoolean("offline", binding.offline.isChecked());
super.onSaveInstanceState(outState);
}
/**
* Invalidates UI callbacks before releasing repository resources.
*/
@Override
protected void onDestroy() {
generation++;
repository.close();
super.onDestroy();
}
}
PagedTodoRepositoryTest.java
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/PagedTodoRepositoryTest.java.md ביחס לשיעור.
הקוד המלא והשינויים עבור PagedTodoRepositoryTest.java
צרו PagedTodoRepositoryTest.java במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
package com.example.topics;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
@RunWith(AndroidJUnit4.class)
public class PagedTodoRepositoryTest {
/**
* Checks that HTTP failure preserves a previously stored page and request parameters.
*
* @throws Exception if test server or synchronization setup fails
*/
@Test
public void failedRefreshKeepsCachedPage() throws Exception {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
try (MockWebServer server = new MockWebServer()) {
server.enqueue(new MockResponse().setBody("[{\"id\":701,\"userId\":1,\"title\":\"Cached lesson\",\"completed\":false}]"));
server.enqueue(new MockResponse().setResponseCode(503));
server.start();
PagedTodoRepository repository = new PagedTodoRepository(context, server.url("/").toString());
try {
AtomicReference<PagedTodoRepository.Result> first = new AtomicReference<>();
CountDownLatch loaded = new CountDownLatch(1);
repository.load(77, false, true, result -> {
if (result.done) { first.set(result); loaded.countDown(); }
});
assertTrue(loaded.await(10, TimeUnit.SECONDS));
assertEquals("Cached lesson", first.get().items.get(0).title);
AtomicReference<PagedTodoRepository.Result> second = new AtomicReference<>();
CountDownLatch failed = new CountDownLatch(1);
repository.load(77, false, true, result -> {
if (result.done) { second.set(result); failed.countDown(); }
});
assertTrue(failed.await(10, TimeUnit.SECONDS));
assertEquals("Cached lesson", second.get().items.get(0).title);
assertTrue(second.get().message.contains("HTTP 503"));
assertEquals("/todos?_page=77&_limit=5", server.takeRequest().getPath());
} finally {
repository.close();
}
}
}
}
activity_main.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/activity_main.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור activity_main.xml
ב־activity_main.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="@string/http_title" />
+ android:text="@string/http_title"
+ android:textSize="24sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
android:text="@string/http_note" />
<Button
android:id="@+id/load_todo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:layout_marginTop="24dp"
android:text="@string/load_todo" />
+
+ <Button
+ android:id="@+id/open_paged"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/open_paged" />
<Button
android:id="@+id/load_missing"
⁞
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
android:visibility="gone" />
<TextView
android:id="@+id/status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
android:accessibilityLiveRegion="polite"
- android:text="@string/http_idle" />
+ android:text="@string/http_idle"
+ android:textSize="18sp" />
<TextView
android:id="@+id/todo_detail"
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp" />
</LinearLayout>
</ScrollView>
+
</androidx.constraintlayout.widget.ConstraintLayout>
activity_paged_todos.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/activity_paged_todos.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור activity_paged_todos.xml
צרו activity_paged_todos.xml במיקום המתואר בשיעור. זהו קובץ חדש, ולכן הקוד מוצג במלואו.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main" android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView android:layout_width="0dp" android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:padding="24dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="@string/paged_title" android:textSize="22sp" />
<Switch android:id="@+id/offline" android:layout_width="match_parent"
android:layout_height="wrap_content" android:text="@string/paged_offline" />
<Button android:id="@+id/previous" android:layout_width="match_parent"
android:layout_height="wrap_content" android:text="@string/paged_prev" />
<Button android:id="@+id/next" android:layout_width="match_parent"
android:layout_height="wrap_content" android:text="@string/paged_next" />
<Button android:id="@+id/refresh" android:layout_width="match_parent"
android:layout_height="wrap_content" android:text="@string/paged_refresh" />
<TextView android:id="@+id/status" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_marginTop="12dp"
android:accessibilityLiveRegion="polite" android:text="@string/paged_status" />
<TextView android:id="@+id/items" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_marginTop="16dp"
android:textSize="18sp" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
strings.xml
פתיחת המקור ישירות — בקובץ Markdown המקורי, הקוד נמצא ב־code/25/strings.xml.md ביחס לשיעור.
הקוד המלא והשינויים עבור strings.xml
ב־strings.xml החילו את שינויי האזורים הבאים. שורות + נוספות, שורות - מוסרות, שורות ההקשר נשארות; ⁞ מסמן אזור קיים שלא הוצג. שימרו את שאר קוד התבנית.
<resources>
<string name="app_name">topics</string>
+ <string name="open_paged">Open paged offline list</string>
+ <string name="paged_title">Todos from API and local cache</string>
+ <string name="paged_status">Loading local page…</string>
+ <string name="paged_prev">Previous page</string>
+ <string name="paged_next">Next page</string>
+ <string name="paged_refresh">Refresh page</string>
+ <string name="paged_offline">Offline demo: skip network</string>
<string name="http_title">HTTP and typed JSON</string>
<string name="http_note">Load a todo, inspect a missing resource, or cancel a pending request.</string>
<string name="load_todo">GET todo 1</string>
⁞
<string name="cancel_request">Cancel request</string>
<string name="retry_request">Retry request</string>
<string name="http_idle">Choose a request.</string>
- <string name="http_loading">GET /todos/%1$d …</string>
+ <string name="http_loading">GET /todos/%1$d …</string>
<string name="http_cancelled">Request cancelled.</string>
- <string name="http_success">HTTP %1$d · %2$s</string>
- <string name="http_error">HTTP %1$d · %2$s</string>
+ <string name="http_success">HTTP %1$d · %2$s</string>
+ <string name="http_error">HTTP %1$d · %2$s</string>
<string name="http_network_error">Network error: %1$s</string>
<string name="http_decode_error">JSON conversion failed: %1$s</string>
<string name="http_invalid_body">HTTP %1$d, but the JSON is missing required fields.</string>
ראיות ובדיקות
צרו את PagedTodoRepositoryTest.java מן הקוד המשלים ב־app > kotlin+java > com.example.topics (androidTest). שרת MockWebServer משתמש ב־HTTP מקומי: במעבדת 08 MockWebServer רץ בבדיקת JVM, שאינה כפופה למדיניות רשת של Android. כאן השרת המקומי נגיש מבדיקת מכשיר ולכן ניצור לראשונה היתר cleartext של debug בלבד. ב־Terminal של Android Studio, מתוך שורש הפרויקט, השתמשו ב־PowerShell:
New-Item -ItemType Directory -Force app/src/debug
New-Item -ItemType File app/src/debug/AndroidManifest.xml
פתחו את הקובץ החדש ב־Search Everywhere לפי app/src/debug/AndroidManifest.xml וכתבו בו:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
</manifest>
Gradle ממזגת אותו רק בבניית debug. האפליקציה הרגילה במעבדה עדיין משתמשת ב־HTTPS; ההיתר נועד ל־HTTP של MockWebServer על המכשיר. ה־Manifest הראשי וגרסת release אינם מקבלים היתר HTTP גורף. הבדיקה ממתינה בשרשור הבדיקה עם CountDownLatch; אסור להמתין כך ב־UI thread. היא משתמשת בעמוד 77 כדי להפרידו מן עמודי ההדגמה.
- באמולטור, Page 1 נטען מן הרשת ונשמר עם Todos 1–5. Next טען את 6–10. סימון Offline בעמוד 2 הציג Offline: Fresh local page עם אותן רשומות.
- בדיקת
PagedTodoRepositoryTestמפעילה MockWebServer: עמוד 77 מצליח ונשמר, ואז Refresh מחזירHTTP 503. היא מאשרת שה־Todo השמור עדיין מוצג ושהבקשה השתמשה ב־_page=77&_limit=5.:app:connectedDebugAndroidTestעבר. - כבו רשת במכשיר או השתמשו במצב Offline של המעבדה, עברו לעמוד שלא נשמר, וודאו שמתקבל No cached page ללא נתונים. חזרו לעמוד שמור ואז הפעילו מחדש את התהליך: הנתון נשאר ב־Room.
- נסחו מדיניות למוצר שלכם: אחרי כמה זמן עמוד נחשב ישן? האם רענון כושל משאיר נתונים? כיצד יודעים שהגענו לסוף כאשר כמות הפריטים שווה בדיוק לגודל עמוד?
המשך ל־Paging 3: החליפו את כפתורי Previous/Next ב־Pager, PagingSource שקורא מ־Room ו־RemoteMediator שמביא עמודים ומנהל מפתחות. השאירו את האינווריאנט: ה־UI מקבלת נתונים מן המסד, והשלמת בקשת רשת משנה את המסד בעסקה. כך ספריית Paging פותרת עומס, גלילה ו־retry בלי לשנות את חוזה ה־offline של המוצר.