תארו לעצמכם: משתמש פותח אפליקציה מובייל ברכבת התחתית, שבה האות נופל כל 30 שניות. ללא קאש, כל מסך הופך לספינר אינסופי, ובשגיאה — חלל ריק. הגישה של offline-first פותרת את זה: הנתונים נטענים פעם אחת כשמחוברים ונשארים זמינים מקומית. אבל קאש אינו מטה קסם: אסטרטגיית invalidation שגויה מובילה לנתונים מיושנים, וגלישת דיסק קורסת את האפליקציה. אנחנו מתכננים קאש רב-שכבתי כבר שנים, השקנו מעל 50 פרויקטים עם ארכיטקטורת offline-first, ומכירים את המלכודות. המטרה שלנו היא שמשתמשים יראו נתונים מיידית ומפתחים לעולם לא יתהו למה הקאש לא מתעדכן.
עיצוב קאש רב-שכבתי
ארכיטקטורת קאש טובה כוללת מספר רבדים:
| רמה | אחסון | מהירות | משך חיים | כלים |
|---|---|---|---|---|
| זיכרון | RAM | מיידי | הפעלה מחדש של האפליקציה | NSCache (iOS), LruCache (Android) |
| דיסק | קבצים/DB | מהיר | שעות–ימים | Room (Android), CoreData (iOS) |
| רשת | שרת | איטי | לנצח (מקור האמת) | API עם ETag |
אנחנו משתמשים באסטרטגיית stale-while-revalidate (קאש-ראשון, רענון ברקע): קודם מציגים את הקאש מיידית, ואז מעדכנים נתונים ברקע. אם הנתונים השתנו, ה-UI מצויר מחדש. זה נותן למשתמש תחושה של ביצועים מיידיים.
בחירת TTL נכון היא המפתח. לתגובות API אנחנו קובעים TTL מ-5 עד 30 דקות, לתמונות עד 7 ימים. ברשתות לא יציבות, אנחנו משתמשים ב-ETag, וחוסכים עד 80% מתעבורת הרשת (חיסכון ממוצע בעלויות שרת עד 70%, למשל, $10k לחודש לאפליקציות עם תעבורה גבוהה). זמן קריאה מ-Room הוא 1–3 אלפיות שנייה, גודל קאש דיסק טיפוסי הוא 10–50 MB.
בפרויקט אחד (אפליקציית רשת קמעונאית), יישמנו קאש תלת-שכבתי: כותרות רשימה ב-LruCache (Android), פרטים ב-Room עם TTL של 15 דקות, תמונות ב-Coil עם קאש דיסק של 100 MB. תוצאה: זמן טעינת מסך ממוצע ירד מ-3 שניות ל-200 אלפיות שנייה, ועומס השרת ירד ב-65%. צרו קשר לייעוץ לבחירת TTL אופטימליים לנתונים שלכם.
| סוג נתונים | TTL מומלץ | סיבה |
|---|---|---|
| רשימת מוצרים | 5–10 דקות | משתנה לעתים קרובות, טריות קריטית |
| תמונות מוצרים | 24 שעות–7 ימים | משתנות לעתים רחוקות, גודל גדול |
| הגדרות | 30 דקות | משתנות לעתים רחוקות, נטענות בהפעלה |
יישום קאש תגובות API בגישת Offline-First
ב-Android אנחנו משתמשים ב-Room כקאש מתמשך עם חותמות זמן ו-ETags. דוגמה:
// Entity с timestamp для инвалидации
@Entity(tableName = "products_cache")
data class ProductCacheEntity(
@PrimaryKey val id: String,
val categoryId: String,
val payload: String, // JSON-строка
val cachedAt: Long, // Unix timestamp
val etag: String? = null
)
// Repository: логика cache-first
class ProductRepository(
private val api: ProductApi,
private val dao: ProductCacheDao,
private val cacheMaxAge: Long = 5 * 60 * 1000L // 5 минут
) {
fun getProductsByCategory(categoryId: String): Flow<List<Product>> = flow {
// 1. Сразу отдаём кэш
val cached = dao.getByCategory(categoryId)
if (cached.isNotEmpty()) {
emit(cached.map { it.toProduct() })
}
// 2. Проверяем свежесть
val oldestEntry = cached.minOfOrNull { it.cachedAt } ?: 0L
val needsRefresh = System.currentTimeMillis() - oldestEntry > cacheMaxAge
if (needsRefresh || cached.isEmpty()) {
try {
val fresh = api.getProducts(categoryId)
val entities = fresh.map { it.toCacheEntity(categoryId) }
dao.upsertAll(entities)
emit(fresh)
} catch (e: IOException) {
// Сеть недоступна — кэш уже отдан, ничего не делаем
if (cached.isEmpty()) throw e // нечего показать — пробрасываем
}
}
}
}ה-UI נרשם ל-Flow ומקבל נתונים פעמיים: קודם קאש, ואז נתונים טריים. זה הבסיס של offline-first.
ETag לחיסכון בתעבורה
במקום invalidation מבוסס זמן, אפשר להשתמש ב-HTTP ETag. השרת מחזיר // Entity с timestamp для инвалидации @Entity(tableName = "products_cache") data class ProductCacheEntity( @PrimaryKey val id: String, val categoryId: String, val payload: String, // JSON-строка val cachedAt: Long, // Unix timestamp val etag: String? = null ) // Repository: логика cache-first class ProductRepository( private val api: ProductApi, private val dao: ProductCacheDao, private val cacheMaxAge: Long = 5 * 60 * 1000L // 5 минут ) { fun getProductsByCategory(categoryId: String): Flow<List<Product>> = flow { // 1. Сразу отдаём кэш val cached = dao.getByCategory(categoryId) if (cached.isNotEmpty()) { emit(cached.map { it.toProduct() }) } // 2. Проверяем свежесть val oldestEntry = cached.minOfOrNull { it.cachedAt } ?: 0L val needsRefresh = System.currentTimeMillis() - oldestEntry > cacheMaxAge if (needsRefresh || cached.isEmpty()) { try { val fresh = api.getProducts(categoryId) val entities = fresh.map { it.toCacheEntity(categoryId) } dao.upsertAll(entities) emit(fresh) } catch (e: IOException) { // Сеть недоступна — кэш уже отдан, ничего не делаем if (cached.isEmpty()) throw e // нечего показать — пробрасываем } } } } , הבקשה הבאה שולחת ETag: "v42" — אם הנתונים לא השתנו, השרת מחזיר 304 ללא גוף. חיסכון בתעבורה יכול להגיע ל-80%.
OkHttp (לקוח HTTP ל-Android ו-React Native) תומך בקאש HTTP מובנה:
val cache = Cache(
directory = File(context.cacheDir, "http-cache"),
maxSize = 10L * 1024 * 1024 // 10 МБ
)
val client = OkHttpClient.Builder()
.cache(cache)
.build()
ב-iOS, If-None-Match: "v42" עובד באופן דומה דרך val cache = Cache( directory = File(context.cacheDir, "http-cache"), maxSize = 10L * 1024 * 1024 // 10 МБ ) val client = OkHttpClient.Builder() .cache(cache) .build() . אבל קאש HTTP עובד רק עם כותרות URLSession נכונות מהשרת.
קאש תמונות: ספריות מוכחות בשטח
לתמונות אנחנו משתמשים בפתרונות מוכחים:
- Android: Coil — Kotlin-first, מוכן ל-Compose, קאש זיכרון + דיסק, מצבי placeholder/error.
- iOS: Kingfisher או SDWebImage — טעינה אסינכרונית, NSCache + דיסק, JPEG פרוגרסיבי.
- React Native: react-native-fast-image (עטיפה מעל SDWebImage/Glide).
// Coil в Compose
AsyncImage(
model = ImageRequest.Builder(context)
.data(product.imageUrl)
.memoryCacheKey(product.id)
.diskCacheKey(product.imageUrl)
.crossfade(true)
.build(),
contentDescription = product.title,
placeholder = painterResource(R.drawable.placeholder),
error = painterResource(R.drawable.error_image)
)Coil מהיר בממוצע פי 2 מ-Glide בטעינת תמונות מקאש (נבדק על Android 12). Kingfisher ב-iOS מספק זמני גישה לדיסק מתחת ל-10 אלפיות שנייה.
אסטרטגיות Invalidation לקאש: באיזו לבחור?
Invalidation הוא החלק הקשה ביותר. אנחנו משתמשים בשילוב של גישות:
- TTL (time-to-live) — פקיעה אוטומטית לאחר מרווח מוגדר. פשוט, צפוי, אבל הנתונים מתיישנים בין TTL ל-TTL.
- מבוסס אירועים — התראת push מהשרת על שינוי נתונים. מיידי, אבל דורש תמיכת שרת.
- מבוסס גרסה — השרת שולח
URLCache, השוואת מספרים. מדויק ללא העברת נתונים. - Pull-to-refresh — המשתמש יוזם עדכון. תמיד נדרש כגיבוי.
אנחנו משלבים TTL לנתונים בסיסיים, מבוסס אירועים לנתונים קריטיים (למשל, יתרת חשבון), ו-pull-to-refresh כגיבוי. היישום אורך 1–2 שבועות תלוי במספר סוגי הנתונים.
איך לבחור אסטרטגיית קאש לנתונים ספציפיים?
לנתונים המשתנים לעתים קרובות (פיד חדשות), השתמשו ב-TTL של 5–10 דקות עם עדכונים מבוססי אירועים. לנתוני ייחוס סטטיים, השתמשו בגרסתיות עם TTL ארוך. בדיקות בתרחישים אמיתיים מראות ששילוב נכון של אסטרטגיות משפר את שימור המשתמשים ב-15–25%.
שלבים ליישום קאש רב-שכבתי
- ביקורת על ה-APIs והנתונים הקיימים.
- עיצוב סכמת קאש: בחירת רבדים, TTL לכל סוג.
- יישום קאש בזיכרון (NSCache/LruCache) וקאש דיסק (Room/CoreData).
- שילוב קאש HTTP ו-ETag.
- הגדרת invalidation (מבוסס אירועים + TTL).
- בדיקת תרחישי offline: רשת כבויה, איטית, נופלת.
- תיעוד והדרכת צוות.
מה כוללת העבודה
- ביקורת על ה-APIs והנתונים הקיימים
- עיצוב ארכיטקטורת קאש (סכמת רבדים, בחירת TTL)
- יישום קאש בזיכרון ובדיסק
- שילוב קאש HTTP ו-ETag
- הגדרת invalidation (מבוסס אירועים + TTL)
- בדיקת תרחישי offline (רשת כבויה, איטית, נופלת)
- תיעוד והדרכת צוות
- תמיכה במהלך שחרור ל-App Store / Google Play
הצוות שלנו פיתח מעל 50 אפליקציות מובייל עם ארכיטקטורת offline-first. אנחנו מבטיחים ביצועי קאש יציבים בתנאי רשת לא יציבים. כל הפתרונות עוברים בדיקת קוד ובדיקות עומס. צרו קשר להערכת פרויקט — נציע לכם סכמת קאש אופטימלית לנתונים שלכם.







