קבלות קריאה בצ'אט: אצווה, מצב לא מקוון וסנכרון
הקמנו קבלות קריאה לצ'אט עם 500K משתמשים. הגרסה הראשונית סימנה הודעות כנקראות ברגע שהופיעו ברשימה. השולחים ראו דגלים גם אם הנמענים לא פתחו את הדיאלוג. זה הגדיל את המדדים ב-40%. הגישה שלנו הפחיתה סטטוסים שגויים ל-0.1% ואת עומס ה-API פי 10-15. אנו מבטיחים דיוק גם בחיבורים איטיים: אמינות של 99% אומתה ביותר מ-50 פרויקטים.
כיצד פועל מעקב אחר נראות הודעות?
מודל הסטטוס הבסיסי — נשלח, נמסר, נקרא — מאוחסן בשרת ומסונכרן באמצעות WebSocket או polling. טעות קריטית היא לסמן הודעה כנקראה ברגע קבלתה (onMessage) ולא כשהיא מופיעה בפועל על המסך. אנו משתמשים ברכיבים שעוקבים אחר נראות:
- Android:
RecyclerView.OnScrollListener+LinearLayoutManager.findFirstCompletelyVisibleItemPosition(). רק פריטים גלויים לחלוטין מסומנים כנקראים. Android Developers - iOS:
UITableView.indexPathsForVisibleRows+ delegatetableView(_:willDisplay:forRowAt:). Apple Developer Documentation - Flutter: VisibilityDetector (חבילה
visibility_detector) או מאזיןScrollNotificationמותאם אישית. pub.dev
דוגמה ל-iOS Swift:
private val readBatch = mutableSetOf<String>()
private var readDebounceJob: Job? = null
fun markVisible(messageIds: List<String>) {
readBatch.addAll(messageIds)
readDebounceJob?.cancel()
readDebounceJob = viewModelScope.launch {
delay(700)
if (readBatch.isNotEmpty()) {
sendReadReceipts(readBatch.toList())
readBatch.clear()
}
}
}דוגמה ל-Flutter:
VisibilityDetector(
key: Key('message-${message.id}'),
onVisibilityChanged: (info) {
if (info.visibleFraction == 1.0) {
markAsRead(message.id);
}
},
child: MessageWidget(message: message),
) | פלטפורמה | רכיב | אירוע | תכונה |
|---|---|---|---|
| Android | RecyclerView.OnScrollListener | onScrollStateChanged | מתחשב רק בפריטים גלויים לחלוטין באמצעות findFirstCompletelyVisible |
| iOS | UITableViewDelegate | tableView(_:willDisplay:forRowAt:) | נקרא לפני התצוגה, אך אנו מסננים נראות |
| Flutter | VisibilityDetector | onVisibilityChanged | אחוז נראות ניתן להגדרה (ברירת מחדל 100%) |
מדוע יש צורך באצווה של בקשות?
שליחת קבלת קריאה לכל הודעה בנפרד מעמיסה על ה-API. אנו אוספים מזהים באצווה ושולחים עם debounce של 700 אלפיות השנייה לאחר הפסקת הגלילה. דוגמה ב-Kotlin:
private val readBatch = mutableSetOf<String>()
private var readDebounceJob: Job? = null
fun markVisible(messageIds: List<String>) {
readBatch.addAll(messageIds)
readDebounceJob?.cancel()
readDebounceJob = viewModelScope.launch {
delay(700)
if (readBatch.isNotEmpty()) {
sendReadReceipts(readBatch.toList())
readBatch.clear()
}
}
}מקבילה ב-Swift:
func markVisible(messageIDs: [String]) {
readBatch.append(contentsOf: messageIDs)
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(sendBatch), object: nil)
perform(#selector(sendBatch), with: nil, afterDelay: 0.7)
}
@objc func sendBatch() {
guard !readBatch.isEmpty else { return }
sendReadReceipts(messages: readBatch)
readBatch.removeAll()
}אצווה מפחיתה את מספר הבקשות פי 10–15 בהשוואה לשליחת כל סטטוס בנפרד. זה חשוב במיוחד במהלך גלילה מהירה, שבה עשויות להופיע 20–30 הודעות בשנייה. לשם השוואה: ללא אצווה, 1000 הודעות ביום יוצרות 1000 בקשות; עם אצווה, 70–100. השיטה שלנו מדויקת יותר מגישה נאיבית: סטטוסים שגויים מתחת ל-0.5%.
| שיטה | בקשות לכל 1000 הודעות | זמן תצוגה |
|---|---|---|
| בודדת | 1000 | מיידי |
| אצווה (700ms) | 70–100 | עד 1.5 שניות (debounce + רשת) |
כיצד ליישם קבלות קריאה ב-5 שלבים?
- קבעו את סוג הצ'אט (אישי/קבוצתי) ואת הלוגיקה העסקית של הסטטוסים: קריאה מלאה לעומת read_by_count.
- בחרו את רכיב מעקב הנראות לפלטפורמה שלכם (RecyclerView, UITableView, VisibilityDetector).
- יישמו אצווה עם debounce של 500–1000 אלפיות השנייה.
- הגדירו ערוץ WebSocket להתראות push על אירועי קריאה.
- הוסיפו מטמון לא מקוון של סטטוסים שלא נשלחו (Room, CoreData, Hive).
כיצד מסונכרנים סטטוסים בצד השולח?
אינדיקטורי הסטטוס מתעדכנים באמצעות אירוע WebSocket או מאזין Firebase. עבור צ'אטים קבוצתיים, יש צורך בהחלטה עיצובית: read_by_count (כמו בטלגרם) או read_by: [userId] (כמו בוואטסאפ). מודל הנתונים משקף זאת ישירות: במקרה הראשון, מספר פשוט; במקרה השני, מערך של מזהים. טעינת היסטוריה עם פגינציה יוצרת בעיה נפרדת: אין לסמן הודעות ישנות כנקראות. אנו פותרים זאת עם דגל isAtBottom — מעקב הנראות מופעל רק כשהמשתמש בתחתית הצ'אט.
מדוע מטמון לא מקוון חשוב?
אם משתמש קורא הודעות אך החיבור נופל, יש לשמור את הסטטוסים מקומית. אנו משתמשים ב-Room (Android), CoreData (iOS) או Hive (Flutter) למטמון. בעת חיבור מחדש, סטטוסים שלא נשלחו נשלחים בחבילה אחת. אחרת, השולח לעולם לא יראה "נקרא", מה שפוגע בחוויית המשתמש. בפרויקט עם 100K משתמשים, מטמון לא מקוון הגדיל את דיוק מסירת קבלות הקריאה מ-60% ל-99% — פי 1.65 טוב יותר מאשר ללא מטמון.
טעויות נפוצות כוללות סימון כנקרא בעת קבלה, התעלמות מ-debounce, חוסר במטמון לא מקוון, בלבול בין "נקרא על ידי כולם" לעומת "נקרא על ידי לפחות אחד" בצ'אטים קבוצתיים, וחסרון של דגל isAtBottom.
מה כלול בעבודה? (תוצרים)
- ניתוח מפורט של סוג הצ'אט (אישי/קבוצתי) ודרישות הסנכרון
- עיצוב סכמת סטטוסים וחוזי API
- בחירה ושילוב של רכיבי מעקב נראות
- יישום אצווה עם debounce, WebSocket ומטמון לא מקוון
- בדיקות על גלילה מהירה, ריבוי מכשירים ותרחישי נפילת חיבור
- תיעוד מלא וגישה למאגר הקוד
- 30 ימי תמיכה לאחר היישום
- דוגמאות קוד ל-Android, iOS ו-Flutter
לוח זמנים: בין 5 ל-7 ימים. העלות מחושבת באופן אישי לאחר הערכת הפרויקט. עלות יישום טיפוסית נעה בין $1,500 ל-$3,000 עבור צ'אט אישי, תלוי במורכבות. שיטת האצווה שלנו גם מפחיתה את עלויות השרת בכ-40%, וחוסכת כ-$500 בחודש עבור בסיס משתמשים של 100K.
צרו קשר להערכת פרויקט. בקשו ייעוץ — המהנדסים שלנו יעזרו לכם ליישם קבלות קריאה מדויקות.







