אפליקציה שפועלת קודם אופליין היא אפליקציה שיכולה לבצע את כל הפונקציונליות העיקרית שלה, או קבוצת משנה קריטית שלה, בלי גישה לאינטרנט. כלומר, היא יכולה לבצע חלק מהלוגיקה העסקית שלה או את כולה במצב אופליין.
השיקולים לפיתוח אפליקציה שפועלת קודם אופליין מתחילים בשכבת הנתונים, שמאפשרת גישה לנתוני האפליקציה וללוגיקה העסקית. מעת לעת, יכול להיות שהאפליקציה תצטרך לרענן את הנתונים האלה ממקורות חיצוניים למכשיר. כדי לעשות את זה, יכול להיות שהיא תצטרך להשתמש במשאבי רשת כדי להישאר מעודכנת.
לא תמיד יש זמינות של הרשת. בדרך כלל יש במכשירים תקופות של חיבור איטי או לא יציב לרשת. המשתמשים עלולים להיתקל בבעיות הבאות:
- רוחב פס מוגבל לאינטרנט
- הפרעות זמניות בחיבור, למשל כשנמצאים במעלית או במנהרה
- גישה מזדמנת לנתונים – לדוגמה, טאבלטים עם Wi-Fi בלבד
בלי קשר לסיבה, לעיתים קרובות אפליקציה יכולה לפעול בצורה מספקת בנסיבות האלה. כדי לוודא שהאפליקציה פועלת כראוי במצב אופליין, היא צריכה להיות מסוגלת לבצע את הפעולות הבאות:
- להמשיך לפעול גם בלי חיבור יציב לרשת
- להציג למשתמשים נתונים מחנויות מקומיות באופן מיידי במקום לחכות שהקריאה הראשונה לרשת תושלם או תיכשל.
- אחזור נתונים באופן שמתחשב במצב הסוללה ובחבילת הגלישה – לדוגמה, על ידי בקשת אחזור נתונים רק בתנאים אופטימליים, כמו בזמן טעינה או בחיבור ל-Wi-Fi.
אפליקציה שעומדת בקריטריונים האלה נקראת בדרך כלל אפליקציה שמתמקדת במצב אופליין.
תכנון אפליקציה שמתמקדת במצב אופליין
כשמתכננים אפליקציה שפועלת קודם אופליין, מתחילים בשכבת הנתונים ובשתי הפעולות העיקריות שאפשר לבצע בנתוני האפליקציה:
- קריאות: אחזור נתונים לשימוש בחלקים אחרים של האפליקציה, כמו הצגת מידע למשתמש. ב-Compose, בדרך כלל עושים את זה על ידי צפייה במצב. כשהממשק שלכם צופה במקור הנתונים מחנויות מקומיות כערך דינמי, המסך משקף את הנתונים מחנויות מקומיות העדכניים ביותר באופן אוטומטי.
- כתיבה: שמירת קלט של משתמשים לאחזור מאוחר יותר. ב-Compose, בדרך כלל עושים את זה באמצעות אירועים ופעולות שנשלחים מה-UI אל ה-ViewModel.
מאגרים בשכבת הנתונים אחראים על שילוב מקורות נתונים כדי לספק נתוני אפליקציה. באפליקציה שפועלת קודם אופליין, צריך להיות לפחות מקור נתונים אחד שלא דורש גישה לרשת כדי לבצע את המשימות הכי חשובות. אחת מהמשימות הקריטיות האלה היא קריאת נתונים.
נתוני מודל באפליקציה שפועלת קודם אופליין
באפליקציה שפועלת קודם אופליין יש לפחות 2 מקורות נתונים לכל מאגר שמשתמש במשאבי רשת:
- מקור הנתונים מחנויות מקומיות
- מקור הנתונים של הרשת
מקור הנתונים מחנויות מקומיות
מקור הנתונים המקומי הוא מקור המידע האמין הקנוני של האפליקציה. הוא צריך להיות המקור הבלעדי לכל הנתונים ששכבות גבוהות יותר של האפליקציה קוראות. כך אפשר לשמור על עקביות הנתונים בין מצבי החיבור. מקור הנתונים מחנויות מקומיות מגובה בדרך כלל באחסון שנשמר בדיסק. אלה כמה מהדרכים הנפוצות לשמירת נתונים בדיסק:
- מקורות נתונים מובְנים, כמו מסדי נתונים רלציוניים כמו Room
- מקורות נתונים לא מובְנים – לדוגמה, מאגרי אחסון לפרוטוקולים עם DataStore
- קבצים פשוטים
מקור הנתונים של הרשת
מקור הנתונים של הרשת הוא המצב בפועל של האפליקציה. במקרה הטוב, מקור הנתונים המקומי מסונכרן עם מקור הנתונים ברשת. מקור הנתונים המקומי יכול גם לפגר אחרי מקור הנתונים ברשת, ובמקרה כזה צריך לעדכן את האפליקציה כשהיא חוזרת למצב אונליין. לעומת זאת, יכול להיות שמקור הנתונים ברשת לא יתעדכן באותו קצב כמו מקור הנתונים המקומי, עד שהאפליקציה תוכל לעדכן אותו כשהחיבור יחזור. שכבות הדומיין וממשק המשתמש של האפליקציה לא יכולות לתקשר ישירות עם שכבת הרשת. האחריות לתקשורת עם ה-repository המארח ולשימוש בו כדי לעדכן את מקור הנתונים מחנויות מקומיות היא שלכם.
סנכרון מותאם אישית לעומת סנכרון מנוהל
יש שתי דרכים עיקריות ליצור שכבת נתונים שמתמקדת באופליין:
- מחסנית סנכרון בהתאמה אישית: אתם מתחזקים מסדי נתונים מקומיים באמצעות Room או קבצים באחסון מקומי, יוצרים לקוחות רשת בהתאמה אישית ומתזמנים עובדי התאמה באמצעות WorkManager. הגישה הזו מספקת שליטה מקסימלית בסכימות ובאלגוריתמים מותאמים אישית לפתרון התנגשויות.
- שירותי ענן מנוהלים: פתרונות כמו Cloud Firestore יכולים לספק מטמון מקומי וסטרימינג בענן בזמן אמת, והכול במקום אחד. ה-SDK מטפל באופן אוטומטי בשמירה במטמון בדיסק, בתור לכתיבה אופליין ובסנכרון דו-כיווני, ומעביר זרמי נתונים בזמן אמת ישירות למאגר שלכם. ב-backend רלציוני של PostgreSQL, Firebase SQL Connect יוצר ערכות SDK של לקוח Kotlin עם מניעת שגיאות הקלדה, שמשתלבות בצורה חלקה במאגרים שלכם. לנתונים לא מובְנים (כמו מדיה, קבצים גדולים או גיבויים), Cloud Storage for Firebase מספק אחסון אובייקטים שמתארח בענן עם פעולות העלאה והורדה שמושהות ומתחדשות באופן אוטומטי במקרה של הפרעות ברשת.
חשיפת משאבים
יכולים להיות הבדלים מהותיים בין מקורות נתונים מקומיים לבין מקורות נתונים ברשת, מבחינת האופן שבו האפליקציה יכולה לקרוא מהם ולכתוב אליהם. השליחה של שאילתות למקור נתונים מקומי יכולה להיות מהירה וגמישה, למשל כשמשתמשים בשאילתות SQL. לעומת זאת, מקורות נתונים ברשת יכולים להיות איטיים ומוגבלים, למשל כשניגשים למשאבי RESTful לפי מזהה באופן מצטבר. לכן, לעיתים קרובות כל מקור נתונים צריך ייצוג משלו לנתונים שהוא מספק. לכן, יכול להיות שלמקור הנתונים המקומי ולמקור הנתונים ברשת יהיו מודלים משלהם.
מבנה הספרייה הבא עוזר להמחיש את הרעיון הזה. ה-AuthorEntity מייצג מחבר שנקרא ממסד הנתונים המקומי של האפליקציה, וה-NetworkAuthor מייצג מחבר שעבר סריאליזציה ברשת:
data/
├─ local/
│ ├─ entities/
│ │ ├─ AuthorEntity
│ ├─ dao/
│ ├─ NiADatabase
├─ network/
│ ├─ NiANetwork
│ ├─ models/
│ │ ├─ NetworkAuthor
├─ model/
│ ├─ Author
├─ repository/
הפרטים של AuthorEntity ושל NetworkAuthor:
/**
* Network representation of [Author]
*/
@Serializable
data class NetworkAuthor(
val id: String,
val name: String,
val imageUrl: String,
val twitter: String,
val mediumPage: String,
val bio: String,
)
/**
* Defines an author for either an [EpisodeEntity] or [NewsResourceEntity].
* It has a many-to-many relationship with both entities
*/
@Entity(tableName = "authors")
data class AuthorEntity(
@PrimaryKey
val id: String,
val name: String,
@ColumnInfo(name = "image_url")
val imageUrl: String,
@ColumnInfo(defaultValue = "")
val twitter: String,
@ColumnInfo(name = "medium_page", defaultValue = "")
val mediumPage: String,
@ColumnInfo(defaultValue = "")
val bio: String,
)
מומלץ להשאיר את AuthorEntity וגם את NetworkAuthor פנימיים לשכבת הנתונים, ולחשוף סוג שלישי לשכבות חיצוניות. כך השכבות החיצוניות מוגנות מפני שינויים קלים במקורות הנתונים המקומיים ובמקורות הנתונים ברשת, שלא משנים באופן מהותי את ההתנהגות של האפליקציה.
הקטע הבא מדגים את זה:
/**
* External data layer representation of a "Now in Android" Author
*/
data class Author(
val id: String,
val name: String,
val imageUrl: String,
val twitter: String,
val mediumPage: String,
val bio: String,
)
לאחר מכן, מודל הרשת יכול להגדיר שיטת הרחבה כדי להמיר אותו למודל המקומי, ובאופן דומה למודל המקומי יש שיטה להמיר אותו לייצוג החיצוני, כמו בקטע הקוד הבא:
/**
* Converts the network model to the local model for persisting
* by the local data source
*/
fun NetworkAuthor.asEntity() = AuthorEntity(
id = id,
name = name,
imageUrl = imageUrl,
twitter = twitter,
mediumPage = mediumPage,
bio = bio,
)
/**
* Converts the local model to the external model for use
* by layers external to the data layer
*/
fun AuthorEntity.asExternalModel() = Author(
id = id,
name = name,
imageUrl = imageUrl,
twitter = twitter,
mediumPage = mediumPage,
bio = bio,
)
קריאות
קריאה היא הפעולה הבסיסית ביותר על נתוני אפליקציה באפליקציה שפועלת קודם אופליין. לכן, צריך לוודא שהאפליקציה יכולה לקרוא את הנתונים, ושהיא יכולה להציג נתונים חדשים ברגע שהם זמינים. אפליקציה שיכולה לעשות את זה היא אפליקציה תגובתית, כי היא חושפת ממשקי API לקריאה עם סוגים שניתנים למעקב.
בקטע הקוד הבא, OfflineFirstTopicRepository מחזיר Flow לכל ממשקי ה-API לקריאה. כך הוא יכול לעדכן את הקוראים כשהוא מקבל עדכונים ממקור הנתונים של הרשת. במילים אחרות, היא מאפשרת ל-OfflineFirstTopicRepository לדחוף שינויים כשמקור הנתונים המקומי שלו לא חוקי. לכן, כל קורא של OfflineFirstTopicRepository צריך להיות מוכן לטפל בשינויים בנתונים שיכולים להתרחש כשהחיבור לרשת משוחזר באפליקציה. בנוסף, OfflineFirstTopicRepository קורא נתונים ישירות ממקור הנתונים המקומי. הוא יכול להודיע לקוראים שלו על שינויים בנתונים מחנויות מקומיות רק על ידי עדכון מקור הנתונים המקומי שלו קודם.
class TopicsViewModel(
offlineFirstTopicsRepository: OfflineFirstTopicsRepository
) : ViewModel() {
val topics: StateFlow<List<Topic>> = offlineFirstTopicsRepository.getTopicsStream()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
}
באפליקציית Jetpack Compose, משתמשים ב-ViewModel כדי לגשר בין שכבת הנתונים לבין ממשק המשתמש.
ב-ViewModel, ממירים את Flow ל-StateFlow באמצעות האופרטור stateIn. לאחר מכן, רכיבי ה-Composable אוספים את המצבים האלה באמצעות collectAsStateWithLifecycle() ומנהלים באופן אוטומטי את המינויים באופן שמותאם למחזור החיים.
מידע נוסף על collectAsStateWithLifecycle() זמין במאמר מצב ו-Jetpack Compose.
אסטרטגיות לטיפול בשגיאות
יש דרכים ייחודיות לטפל בשגיאות באפליקציות שפועלות קודם אופליין, בהתאם למקורות הנתונים שבהם הן עלולות להתרחש. בסעיפי המשנה הבאים מפורטות האסטרטגיות האלה.
מקור נתונים מחנויות מקומיות
נסו לצמצם את השגיאות בקריאה ממקור הנתונים מחנויות מקומיות. כדי להגן על הקוראים מפני שגיאות, צריך להשתמש באופרטור catch ב-Flow שמהם הקורא אוסף נתונים.
אפשר להשתמש באופרטור catch ב-ViewModel באופן הבא:
class AuthorViewModel(
authorsRepository: AuthorsRepository,
...
) : ViewModel() {
private val authorId: String = ...
// Observe author information
private val authorStream: Flow<Author> =
authorsRepository.getAuthorStream(
id = authorId
)
.catch { emit(Author.empty()) }
}
כדי להבטיח שהאתר יפעל בצורה חלקה, כדאי להשתמש בפתרון LCE (שגיאה בטעינת תוכן). ב-LCE, כשמתרחשת שגיאה במהלך הקריאה, מוצג מצב שגיאה. בדרך כלל, כדי להשיג LCE, ממדלים את מצבי ממשק המשתמש כמחלקות אטומות של Kotlin.
// Define the LCE UI state
sealed interface AuthorUiState {
data object Loading : AuthorUiState
data class Success(val author: Author) : AuthorUiState
data object Error : AuthorUiState
}
class AuthorViewModel(
authorsRepository: AuthorsRepository,
...
) : ViewModel() {
private val authorId: String = ...
// Observe author information and map to LCE state
val authorUiState: StateFlow<AuthorUiState> =
authorsRepository.getAuthorStream(id = authorId)
.map<Author, AuthorUiState> { author ->
AuthorUiState.Success(author)
}
.catch { emit(AuthorUiState.Error) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AuthorUiState.Loading
)
}
מקור נתונים של רשת
אם מתרחשות שגיאות בקריאת נתונים ממקור נתונים ברשת, האפליקציה צריכה להשתמש בשיטה היוריסטית כדי לנסות שוב לאחזר נתונים. הנה כמה דוגמאות ליוריסטיקות נפוצות:
השהיה מעריכית לפני ניסיון חוזר (exponential backoff)
בהשהיה מעריכית לפני ניסיון חוזר (exponential backoff), האפליקציה ממשיכה לנסות לקרוא ממקור הנתונים ברשת במרווחי זמן הולכים וגדלים עד שהיא מצליחה, או עד שתנאים אחרים מכתיבים לה להפסיק.
הקריטריונים להערכה אם האפליקציה ממשיכה להפחית את קצב הבקשות כוללים:
- סוג השגיאה שצוינה במקור הנתונים של הרשת. לדוגמה, נסו שוב קריאות לרשת שמחזירות שגיאה שמציינת שאין חיבור. אל תנסו שוב לשלוח בקשות HTTP שלא אושרו עד שיהיו לכם פרטי כניסה מתאימים.
- מספר הניסיונות המקסימלי המותר.
מעקב אחרי החיבור לרשת
בגישה הזו, בקשות הקריאה נכנסות לתור עד שהאפליקציה בטוחה שהיא יכולה להתחבר למקור הנתונים ברשת. אחרי שהחיבור נוצר, בקשת הקריאה יוצאת מהתור, הנתונים נקראים ומקור הנתונים המקומי מתעדכן. ב-Android, יכול להיות שהתור הזה ינוהל באמצעות מסד נתונים של Room, ויתרוקן כעבודה מתמשכת באמצעות WorkManager.
כתיבה
הדרך המומלצת לקרוא נתונים באפליקציה שפועלת קודם אופליין היא להשתמש בסוגים שניתנים למעקב, אבל המקבילה לממשקי API לכתיבה היא ממשקי API אסינכרוניים כמו פונקציות השעיה. כך אפשר להימנע מחסימה של שרשור ה-UI, וזה עוזר לטפל בשגיאות כי כתיבה באפליקציות שפועלות קודם אופליין יכולה להיכשל כשחוצים את גבולות הרשת.
interface UserDataRepository {
/**
* Updates the bookmarked status for a news resource
*/
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}
בקטע הקוד שלמעלה, ה-API האסינכרוני שנבחר הוא Coroutines, כי השיטה מושהית.
אסטרטגיות כתיבה
כשכותבים נתונים באפליקציות שפועלות קודם אופליין, יש שלוש אסטרטגיות שכדאי לשקול. הבחירה תלויה בסוג הנתונים שנכתבים ובדרישות של האפליקציה:
כתיבה אונליין בלבד
ניסיון לכתוב את הנתונים מעבר לגבולות הרשת. אם הפעולה מצליחה, מעדכנים את מקור הנתונים המקומי. אחרת, זורקים חריגה ומשאירים למבצע הקריאה להגיב בהתאם.
האסטרטגיה הזו משמשת לעיתים קרובות לעסקאות כתיבה שחייבות להתבצע אונליין כמעט בזמן אמת, למשל העברה בנקאית. מכיוון שפעולות כתיבה יכולות להיכשל, לעיתים קרובות צריך להודיע למשתמש שפעולת הכתיבה נכשלה, או למנוע מהמשתמש לנסות לכתוב נתונים מלכתחילה. ריכזנו כאן כמה אסטרטגיות שאפשר להשתמש בהן בתרחישים כאלה:
- אם האפליקציה דורשת גישה לאינטרנט כדי לכתוב נתונים, אתם יכולים לבחור לא להציג למשתמש ממשק משתמש שמאפשר לו לכתוב נתונים, או לפחות להשבית אותו.
- כדי להודיע למשתמש שהוא במצב אופליין, אפשר להשתמש ב-
AlertDialogשהמשתמש לא יכול לסגור, או ב-Snackbar.
פעולות כתיבה בתור
כשיש לכם אובייקט שאתם רוצים לכתוב, אתם מוסיפים אותו לתור. כשהאפליקציה מתחברת מחדש לאינטרנט, מרוקנים את התור באמצעות השהיה מעריכית לפני ניסיון חוזר (exponential backoff). ב-Android, ריקון תור אופליין הוא עבודה מתמשכת שלרוב מוקצית ל-WorkManager.
הגישה הזו מתאימה בתרחישים הבאים:
- לא חובה שהנתונים ייכתבו ברשת.
- העסקה לא תלויה בזמן.
- לא חובה להודיע למשתמש אם הפעולה נכשלה.
תרחישים לדוגמה שמתאימים לגישה הזו כוללים אירועי ניתוח נתונים ורישום ביומן.
כתיבה עצלה (lazy write)
קודם כותבים למקור הנתונים המקומי, ואז מוסיפים את הכתיבה לתור כדי להודיע לרשת בהזדמנות הראשונה. זה לא פשוט, כי יכולות להיות התנגשויות בין הרשת לבין מקורות הנתונים המקומיים כשהאפליקציה חוזרת למצב אונליין. בקטע הבא, שעוסק בפתרון התנגשויות, מוסבר על כך בהרחבה.
הגישה הזו היא הבחירה הנכונה כשהנתונים קריטיים לאפליקציה. לדוגמה, באפליקציה של רשימת משימות לביצוע שפועלת קודם אופליין, חשוב שכל המשימות שהמשתמש מוסיף אופליין יישמרו באופן מקומי כדי למנוע סיכון של אובדן נתונים.
סנכרון ופתרון התנגשויות
כשהקישוריות של אפליקציה שפועלת קודם אופליין משוחזרת, היא צריכה להתאים בין הנתונים במקור הנתונים המקומי לבין הנתונים במקור הנתונים ברשת. התהליך הזה נקרא סנכרון. יש שתי דרכים עיקריות שבהן אפליקציה יכולה לסנכרן את הנתונים עם מקור הנתונים ברשת:
- סנכרון מבוסס-משיכה
- סנכרון מבוסס-דחיפה
סנכרון מבוסס-משיכה
בסנכרון מבוסס-משיכה, האפליקציה פונה לרשת כדי לקרוא את נתוני האפליקציה העדכניים ביותר על פי דרישה. גישה נפוצה לשיטה הזו היא ניווט, שבה האפליקציה מאחזרת נתונים רק לפני שהיא מציגה אותם למשתמש.
הגישה הזו יעילה במיוחד כשהאפליקציה צפויה להיות ללא חיבור לרשת לפרקי זמן קצרים עד בינוניים. הסיבה לכך היא שרענון הנתונים הוא אופורטוניסטי, ואם אין חיבור במשך תקופות ארוכות, גדל הסיכוי שהמשתמש ינסה להיכנס ליעדים באפליקציה עם מטמון מיושן או ריק.
נניח שיש אפליקציה שבה משתמשים בטוקנים של דפים כדי לאחזר פריטים ברשימה עם גלילה אינסופית במסך מסוים. ההטמעה עשויה לפנות לרשת באופן עצל, לשמר את הנתונים מחנויות מקומיות במקור הנתונים המקומי, ואז לקרוא ממקור הנתונים המקומי כדי להציג מידע למשתמש. אם אין חיבור לרשת, יכול להיות שהמאגר יבקש נתונים רק ממקור הנתונים המקומי. זהו הדפוס שבו משתמשת ספריית Jetpack Paging עם ה-API שלה, RemoteMediator.
class FeedRepository(...) {
fun feedPagingSource(): PagingSource<FeedItem> { ... }
}
class FeedViewModel(
private val repository: FeedRepository
) : ViewModel() {
private val pager = Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
remoteMediator = FeedRemoteMediator(...),
pagingSourceFactory = feedRepository::feedPagingSource
)
val feedPagingData = pager.flow
}
היתרונות והחסרונות של סנכרון מבוסס-משיכה מסוכמים בטבלה הבאה:
| יתרונות | חסרונות |
|---|---|
| קל יחסית להטמעה. | הוא עלול לצרוך הרבה נתונים. הסיבה לכך היא שביקורים חוזרים ביעד ניווט מפעילים אחזור מיותר של מידע שלא השתנה. אפשר לצמצם את הבעיה הזו באמצעות שמירה נכונה במטמון. אפשר לעשות את זה בשכבת ממשק המשתמש באמצעות האופרטור cachedIn, או בשכבת הרשת באמצעות מטמון HTTP. |
| נתונים שלא נדרשים אף פעם לא נשלפים. | לא מתאים לנתונים יחסיים כי המודל שנשלף צריך להיות עצמאי. אם המודל שמסונכרן תלוי במודלים אחרים שצריך לאחזר כדי לאכלס אותו, הבעיה של שימוש כבד בנתונים שהוזכרה קודם הופכת למשמעותית עוד יותר. בנוסף, זה יכול לגרום ליחסי תלות בין מאגרים של מודל ההורה לבין מאגרים של המודל המקונן. |
סנכרון מבוסס-דחיפה
בסנכרון מבוסס-דחיפה, מקור הנתונים מחנויות מקומיות מנסה לחקות קבוצת רפליקות של מקור הנתונים ברשת כמיטב יכולתו. הוא מאחזר באופן יזום כמות מתאימה של נתונים בהפעלה הראשונה כדי להגדיר בסיס. לאחר מכן, הוא מסתמך על התראות מהשרת כדי להתריע כשהנתונים האלה לא עדכניים.
כשהאפליקציה מקבלת את ההתראה על נתונים לא עדכניים, היא פונה לרשת כדי לעדכן רק את הנתונים שסומנו כלא עדכניים. העבודה הזו מועברת ל-Repository, שפונה למקור הנתונים ברשת ושומר את הנתונים שנשלפו למקור הנתונים המקומי. מכיוון שהמאגר חושף את הנתונים שלו באמצעות סוגים שניתן לצפות בהם, הקוראים מקבלים הודעה על כל שינוי.
class UserDataRepository(...) {
suspend fun synchronize() {
val userData = networkDataSource.fetchUserData()
localDataSource.saveUserData(userData)
}
}
בגישה הזו, האפליקציה הרבה פחות תלויה במקור הנתונים ברשת ויכולה לפעול בלעדיו לפרקי זמן ארוכים. הוא מאפשר גישת קריאה וכתיבה במצב אופליין, כי הוא מניח שהמידע העדכני ביותר ממקור הנתונים ברשת נמצא באופן מקומי.
היתרונות והחסרונות של סנכרון מבוסס-דחיפה מסוכמים בטבלה הבאה:
| יתרונות | חסרונות |
|---|---|
| האפליקציה יכולה להישאר במצב אופליין ללא הגבלת זמן. | ניהול גרסאות של נתונים לצורך פתרון התנגשויות הוא לא דבר פשוט. |
| שימוש מינימלי בנתונים. האפליקציה מאחזרת רק נתונים שהשתנו. | צריך לקחת בחשבון את ה-write concerns במהלך הסנכרון. |
| מתאים לנתונים רלציוניים. כל מאגר אחראי לאחזור נתונים רק עבור המודל שבו הוא תומך. | מקור הנתונים של הרשת צריך לתמוך בסנכרון. |
סנכרון היברידי
חלק מהאפליקציות משתמשות בגישה היברידית שמבוססת על משיכה או דחיפה, בהתאם לנתונים. לדוגמה, אפליקציה של רשת חברתית עשויה להשתמש בסנכרון מבוסס-משיכה כדי לאחזר את פיד המעקב של המשתמש על פי דרישה, בגלל התדירות הגבוהה של עדכוני הפיד. באותה אפליקציה אפשר להשתמש בסנכרון מבוסס-דחיפה לנתונים על המשתמש שמחובר לחשבון, כולל שם המשתמש, תמונת הפרופיל וכו'.
בסופו של דבר, הבחירה בסנכרון שמתמקד במצב אופליין תלויה בדרישות המוצר ובתשתית הטכנית הזמינה.
פתרון התנגשויות
אם האפליקציה כותבת נתונים באופן מקומי כשהיא במצב אופליין, והנתונים האלה לא תואמים למקור הנתונים ברשת, צריך לפתור את חוסר ההתאמה לפני שאפשר יהיה לסנכרן.
כדי לפתור התנגשויות, בדרך כלל צריך להשתמש בניהול גרסאות. האפליקציה צריכה לבצע רישום כדי לעקוב אחרי מועד השינויים, כדי שתוכל להעביר את המטא-נתונים למקור הנתונים ברשת. מקור הנתונים ברשת אחראי לספק את המקור המהימן המוחלט. יש הרבה אסטרטגיות לפתרון התנגשויות, בהתאם לצרכים של האפליקציה. באפליקציות לנייד, גישה נפוצה היא "הכתיבה האחרונה מנצחת".
הכתיבה האחרונה מנצחת
בגישה הזו, המכשירים מצרפים מטא-נתונים של חותמת זמן לנתונים שהם כותבים ברשת. כשהנתונים מתקבלים במקור הנתונים ברשת, המערכת מתעלמת מנתונים ישנים יותר מהמצב הנוכחי, ומקבלת נתונים חדשים יותר מהמצב הנוכחי.
בתמונה 9, שני המכשירים במצב אופליין ומסונכרנים בהתחלה עם מקור הנתונים ברשת. במצב אופליין, שניהם כותבים נתונים באופן מקומי ועוקבים אחרי הזמן שבו הם כתבו את הנתונים. כשהם יתחברו שוב לאינטרנט ויסתנכרנו עם מקור הנתונים ברשת, הרשת תפתור את ההתנגשות על ידי שמירת הנתונים ממכשיר ב', כי הוא כתב את הנתונים שלו מאוחר יותר.
WorkManager באפליקציות שפועלות קודם אופליין
בשתי האסטרטגיות לקריאה ולכתיבה שסקרנו קודם, יש שני כלי עזר נפוצים:
- תורים
- קריאות: משמשות כדי לדחות קריאות עד שיהיה חיבור לרשת.
- כתיבה: משמשת כדי לדחות את הכתיבה עד שיהיה חיבור לרשת, וכדי להוסיף מחדש את הכתיבה לתור לניסיונות חוזרים.
- מעקב אחרי החיבור לרשת
- קריאות: משמש כאות לרוקן את תור הקריאה כשהאפליקציה מחוברת, ולסנכרון.
- כתיבה: משמשת כאות לריקון תור הכתיבה כשהאפליקציה מחוברת, ולסנכרון.
שני המקרים הם דוגמאות לעבודה מתמשכת ש-WorkManager מצטיין בה.
לדוגמה, באפליקציה לדוגמה Now in Android, משתמשים ב-WorkManager גם כתור קריאה וגם ככלי למעקב אחרי הרשת בזמן הסנכרון של מקור הנתונים המקומי. בזמן ההפעלה, האפליקציה מבצעת את הפעולות הבאות:
- הוספה לתור של פעולת סנכרון קריאה כדי לוודא שיש התאמה בין מקור הנתונים המקומי למקור הנתונים ברשת.
- מרוקנת את תור הסנכרון של הקריאה ומתחילה לסנכרן כשהאפליקציה מחוברת לאינטרנט.
- מבצע קריאה ממקור הנתונים ברשת באמצעות השהיה מעריכית לפני ניסיון חוזר (exponential backoff).
- שומר את תוצאות הקריאה במקור הנתונים המקומי ופותר את כל ההתנגשויות שמתרחשות.
- חושפת את הנתונים ממקור הנתונים המקומי כדי ששכבות אחרות באפליקציה יוכלו להשתמש בהם.
הפעולות האלה מוצגות בתרשים הבא:
כדי להוסיף את משימת הסנכרון לתור ב-WorkManager, מגדירים אותה כמשימה ייחודית באמצעות KEEP ExistingWorkPolicy:
class SyncInitializer : Initializer<Sync> {
override fun create(context: Context): Sync {
WorkManager.getInstance(context).apply {
// Queue sync on app startup and ensure only one
// sync worker runs at any time
enqueueUniqueWork(
SyncWorkName,
ExistingWorkPolicy.KEEP,
SyncWorker.startUpSyncWork()
)
}
return Sync
}
}
SyncWorker.startupSyncWork() מוגדר כך:
/**
Create a WorkRequest to call the SyncWorker using a DelegatingWorker.
This allows for dependency injection into the SyncWorker in a different
module than the app module without having to create a custom WorkManager
configuration.
*/
fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>()
// Run sync as expedited work if the app is able to.
// If not, it runs as regular work.
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setConstraints(SyncConstraints)
// Delegate to the SyncWorker.
.setInputData(SyncWorker::class.delegatedData())
.build()
val SyncConstraints
get() = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
באופן ספציפי, לפי Constraints שמוגדר על ידי SyncConstraints, הערך של NetworkType צריך להיות NetworkType.CONNECTED. כלומר, היא ממתינה עד שהרשת תהיה זמינה לפני שהיא פועלת.
כשהרשת זמינה, ה-Worker מרוקן את תור העבודה הייחודי שצוין על ידי SyncWorkName באמצעות הקצאה למכונות Repository המתאימות. אם הסנכרון נכשל, השיטה doWork() מחזירה את הערך Result.retry(). WorkManager ינסה שוב באופן אוטומטי לסנכרן עם השהיה מעריכית לפני ניסיון חוזר (exponential backoff). אחרת, הפונקציה מחזירה את הערך Result.success() והסנכרון מסתיים.
class SyncWorker(...) : CoroutineWorker(appContext, workerParams), Synchronizer {
override suspend fun doWork(): Result = withContext(ioDispatcher) {
// First sync the repositories in parallel
val syncedSuccessfully = awaitAll(
async { topicRepository.sync() },
async { authorsRepository.sync() },
async { newsRepository.sync() },
).all { it }
if (syncedSuccessfully) Result.success()
else Result.retry()
}
}
דוגמאות
הדוגמאות הבאות של Google ממחישות אפליקציות שפועלות קודם אופליין. כדאי לעיין בהם כדי לראות את ההנחיות האלה בפועל:
מומלץ בשבילך
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- ייצור מצבים לממשקי משתמש
- שכבת ממשק המשתמש
- שכבת נתונים