יישום היסטוריית גרסאות באפליקציה לנייד
תארו לעצמכם עורך דין שעורך חוזה בטאבלט, מוחק בטעות פסקה ושומר. שעה לאחר מכן – החתימה, אבל הנתונים נעלמו. ללא היסטוריית גרסאות, זה אסון. יישמנו מערכת ששומרת כל עריכה ומאפשרת חזרה לכל גרסה תוך שניות. בפרויקטים עם מסמכים בעלי חשיבות משפטית, עבודה עריכתית או טבלאות מורכבות, כל שינוי חייב להיות מתועד וניתן לשחזור. איך לתכנן מאגר היסטוריה שלא תופח ועובד במכשיר נייד עם משאבים מוגבלים? אנו מפרקים גישות מרכזיות, את היתרונות והחסרונות שלהן, ומספקים סכמות וקוד מוכנים. הניסיון הרב שלנו בפיתוח נייד הוביל אותנו ליישם גרסאות בעשרות פרויקטים.
שתי גישות: Snapshot מול Event Sourcing
Snapshot — יצירת עותק מלא של האובייקט בכל שמירה. פשוט ליישום ולשחזור, אבל מוביל לגידול נפחי במסד הנתונים. אם מסמך הוא 50 KB והמשתמש שומר אותו 50 פעמים ביום, במשך שנה מצטברים 50 × 50 × 365 ≈ 912 MB עבור מסמך אחד. עבור אפליקציה עם אלפי משתמשים, זה בלתי מתקבל על הדעת.
Delta (event sourcing) — שמירת ההפרש בין גרסאות בלבד. קומפקטי, אבל השחזור דורש הפעלה חוזרת של כל הדלתות מהמצב ההתחלתי. לטקסט, ניתן להשתמש ב-diff-match-patch (ספרייה חינמית זמינה ב-iOS וב-Android). עם זאת, המורכבות גדלה עם עריכה מקבילה.
גישה היברידית — שילוב: snapshots כל 10 גרסאות או כל 7 ימים, עם דלתות ביניהם. שחזור: לקיחת ה-snapshot הקרוב ביותר והחלת הדלתות. שיטה זו מספקת חיסכון של עד 90% בשטח בהשוואה ל-snapshots טהורים ומאיצה את השחזור פי 5–10 ביחס לדלתות טהורות.
להלן השוואה בין שלוש הגישות.
| מאפיין | Snapshot | Delta | היברידי |
|---|---|---|---|
| נפח אחסון | ענק | מינימלי | בינוני |
| מהירות שחזור | מיידי | איטי | מהיר |
| מורכבות יישום | נמוכה | גבוהה | בינונית |
| עמידות לקונפליקטים | גבוהה | נמוכה | בינונית |
סכמת נתונים
עבור מסד נתונים SQLite ב-Android או CoreData ב-iOS, אנו משתמשים בטבלה כזו:
@Entity(tableName = "document_versions")
data class DocumentVersion(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val documentId: String,
val versionNumber: Int,
val deltaJson: String?, // null если snapshot
val snapshotJson: String?, // null если delta
val authorId: String,
val deviceId: String,
val createdAt: Long = System.currentTimeMillis(),
val comment: String? = null // "Автосохранение" / "Сохранено вручную"
)אינדקס על @Entity(tableName = "document_versions") data class DocumentVersion( @PrimaryKey(autoGenerate = true) val id: Long = 0, val documentId: String, val versionNumber: Int, val deltaJson: String?, // null если snapshot val snapshotJson: String?, // null если delta val authorId: String, val deviceId: String, val createdAt: Long = System.currentTimeMillis(), val comment: String? = null // "Автосохранение" / "Сохранено вручную" ) הוא חובה — אחרת, שליפת ההיסטוריה הופכת לצוואר בקבוק. ב-Android אנו משתמשים ב-(documentId, versionNumber) ב-Room, וב-iOS באינדקס מורכב ב-CoreData.
איך לבחור אסטרטגיית אחסון גרסאות?
הבחירה תלויה בסוג הנתונים ובתדירות השינויים. עבור מסמכים עם עריכות נדירות (1–2 פעמים ביום), snapshot טהור הוא פשוט ואמין. עבור עורכי טקסט שבהם שינויים מתרחשים כל שנייה, היברידי עדיף. תמיד יש להעריך את העומס: אם מסד הנתונים גדל מהר יותר מאשר משתמשים יוצרים תוכן, יש לשנות את הגישה.
הגבלת עומק ההיסטוריה
שמירת כל הגרסאות לנצח אינה מעשית. אנו מציעים שלוש אסטרטגיות:
| אסטרטגיה | תיאור | דוגמת גבול |
|---|---|---|
| מספר קבוע | שמירת N הגרסאות האחרונות | 50 גרסאות |
| חלון זמן | גרסאות לא ישנות מ-M ימים | 30 ימים |
| דילול חכם | גישה משולבת | Snapshots מלאים ליום, אחד ליום לשבוע ישן יותר |
דוגמה למחיקת גרסאות ישנות ב-Android עם WorkManager:
@Transaction
suspend fun pruneVersions(documentId: String, keepCount: Int) {
val versions = getVersionsByDocument(documentId)
if (versions.size > keepCount) {
val toDelete = versions.drop(keepCount)
deleteVersions(toDelete.map { it.id })
}
} למה להגביל את עומק ההיסטוריה?
ללא הגבלות, מסד הנתונים יכול לתפוח לג'יגה-בייט במכשיר. משתמשים ממעטים לחזור לגרסאות ישנות מחודש. הניסיון שלנו מראה שהגבלה סבירה של 50 גרסאות או 30 ימים מכסה 99% מהצרכים, תוך שמירה על שליטה באחסון. חיסכון באחסון של עד 90% מנפח מסד הנתונים מפחית ישירות את עלויות התשתית.
ממשק משתמש להיסטוריית גרסאות
רשימת גרסאות עם תאריך, מחבר וסוג (שמירה אוטומטית/ידנית). הקשה לתצוגה מקדימה, שני כפתורים: 'שחזור' ו'השוואה עם הנוכחית'. תצוגת diff עם הדגשה: ירוק להוספה, אדום להסרה. בנייד אנו משתמשים ב-diff מובנה דרך @Index (Android) או @Transaction suspend fun pruneVersions(documentId: String, keepCount: Int) { val versions = getVersionsByDocument(documentId) if (versions.size > keepCount) { val toDelete = versions.drop(keepCount) deleteVersions(toDelete.map { it.id }) } } (iOS) — קומפקטי ואינטואיטיבי.
שמירה אוטומטית ו-Debounce
שמירה אוטומטית לא צריכה ליצור גרסה בכל הקלדה. Debounce של 2–3 שניות לאחר השינוי האחרון:
private var saveTask: Task<Void, Never>?
func textDidChange(_ text: String) {
saveTask?.cancel()
saveTask = Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !Task.isCancelled else { return }
await saveVersion(text, type: .auto)
}
} מה כלול בעבודה
- ניתוח נתונים ובחירת אסטרטגיה (snapshot / delta / היברידי) — אנו מעריכים עומס ומבטיחים ביצועים.
- עיצוב סכמת נתונים עם אינדקסים ומפתחות זרים.
- יישום שמירה אוטומטית עם debounce.
- פיתוח ממשק רשימת גרסאות עם מטא-דאטה.
- יצירת תצוגת diff להשוואת גרסאות.
- הגדרת ניקוי רקע של גרסאות ישנות בלוח זמנים.
דוגמה מלאה לניקוי עם WorkManager (Android)
class VersionPruneWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val dao = AppDatabase.getInstance(applicationContext).documentVersionDao()
dao.pruneAllVersions(keepCount = 50)
return Result.success()
}
} לוח זמנים
יישום בסיסי (snapshot + ממשק פשוט) — 1.5–2 ימים. היברידי מלא עם דלתות, תצוגת diff ודילול חכם — 4–5 ימים. העלות מחושבת באופן אישי לפי מורכבות הנתונים ודרישות עומק ההיסטוריה. אנו מספקים פתרון מלא: מעיצוב ועד פרסום בחנות. קבלו ייעוץ — צרו קשר להערכת פרויקט. הזמינו שילוב היסטוריית גרסאות — נציע את הפתרון האופטימלי.







