הבעיה של אופטימיזציית רשימות (RecyclerView/UITableView) היא אחת הנפוצות ביותר בפיתוח מובייל. UITableView מגמגם במהלך גלילה מהירה — וכמעט תמיד הסיבה אינה חומרה איטית, אלא פענוח JPEG סינכרוני על ה-thread הראשי ב-cellForRowAt. Prefetch מופעל מאוחר מדי, התא כבר מבוקש, ובזמן שהתמונה מתפענחת, הפריים נשמט. ב-iPhone SE דור 2 עם הזיכרון המצומצם שלו, זה משתחזר באופן אמין במקום שבו ב-Pro זה לא מורגש. אנו נתקלים במצב הזה בכל פרויקט שני — ויש לנו שיטות מוכחות לפתרון.
למה רשימות מגמגמות אפילו במכשירי דגל?
הסיפור הנפוץ ביותר באנדרואיד: RecyclerView עם LinearLayoutManager ומאות פריטים שבהם ב-onBindViewHolder אתם עושים Picasso.get().load(url).into(imageView) ללא placeholder מפורש וללא ביטול הבקשה הקודמת דרך tag. במהלך גלילה מהירה, בקשות מצטברות, ישנות לא מבוטלות, וה-UI thread נחסם מעת לעת על ידי callbacks. מעבר ל-Glide עם RequestManager המקושר למחזור החיים ו-preload() ב-onScrollStateChanged פותר זאת ללא שינויי לוגיקה. במדידות שלנו, זה מפחית את זמן טעינת הפריים ב-40% במכשירים בינוניים.
ב-iOS, מצב דומה: SDWebImage ללא SDWebImageAvoidAutoSetImage מחיל את התמונה על ה-thread הראשי מיד לאחר הטעינה, ללא קשר לשאלה אם התא גלוי. אתם מוסיפים sd_setImageWithURL:placeholderImage:options:SDWebImageAvoidAutoSetImage ומחילים ב-completion רק אם indexPath == self.tableView.indexPathForCell(cell) — והגמגום נעלם.
הבעיה השנייה הנפוצה ביותר היא חישובי גובה כבדים של תאים. UITableView.automaticDimension נוח, אבל עם layout מורכב הכולל מספר UILabels, הוא מפעיל systemLayoutSizeFitting מלא על כל תא גלוי. מטמון גבהים באמצעות [IndexPath: CGFloat] וחישוב מחדש רק כשהנתונים משתנים פותר את הבעיה. ב-Jetpack Compose, LazyColumn ללא key {} אינו יכול לעשות שימוש חוזר נכון ב-composables כשהנתונים משתנים — עם submitList של פריטים ששונו, כל התאים הגלויים מצוירים מחדש במקום רק אלה ששונו.
מה מונע השגת 60 fps?
אפילו במכשירי דגל, רשימות מגמגמות בגלל חוסר prefetch. לדוגמה, ב-iOS ללא יישום UITableViewDataSourcePrefetching, תמונות מתחילות להיטען רק כשהתא מופיע. הגדרת prefetchDataSource עם טעינת נתונים עבור 3-5 מסכים קדימה מספקת חיץ זמן. באופן דומה באנדרואיד: הגדרת setInitialPrefetchItemCount() עבור RecyclerViews אופקיים בתוך אנכיים מבטלת עיכובי גלילה. אנו ממליצים להגדיל את cacheExtent ב-Flutter ל-500 פיקסלים — זה טוען מראש תוכן מעבר לאזור התצוגה.
איך DiffUtil מאיץ עדכוני רשימות?
DiffUtil מחשב את ההפרש בין שתי רשימות על thread רקע ומייצר קבוצה מינימלית של עדכונים עבור RecyclerView. זה מבטל ציורים מחדש מיותרים: לדוגמה, בעדכון פריט אחד מתוך 100, רק אותו פריט מצויר מחדש, לא כל הגלויים. AsyncListDiffer עושה זאת באופן אסינכרוני ללא חסימת ה-UI. בפועל, שימוש ב-DiffUtil מפחית את זמן העדכון ב-50% והופך את הגלילה לחלקה.
מה אנחנו עושים ספציפית
Android RecyclerView:
- setHasFixedSize(true) אם גודל ה-RecyclerView אינו משתנה בעדכון נתונים
- setItemViewCacheSize(20) להגדלת מטמון התאים מחוץ למסך
- RecycledViewPool.setMaxRecycledViews(type, count) כאשר מספר RecyclerViews חולקים את אותו סוג תא — שיתוף pool
- AsyncListDiffer או ListAdapter עם DiffUtil.ItemCallback — diff על thread רקע הוא חובה עבור כל רשימה דינמית
- Prefetch דרך LinearLayoutManager.setInitialPrefetchItemCount() עבור רשימות אופקיות מקוננות
iOS UITableView / UICollectionView:
- prefetchDataSource — פענוח ומטמון נתונים לפני cellForRowAt
- estimatedRowHeight עם ערך אמיתי (לא 44 עבור תאים בגובה 120) — estimatedRowHeight שגוי גורם לקפיצות במהלך גלילה
- prepareForReuse() — ביטול חובה של כל הפעולות האסינכרוניות: imageLoadTask?.cancel()
- עיבוד מחוץ למסך של תאים דרך UIGraphicsImageRenderer עבור תוכן סטטי (avatars, אייקונים מונחים)
Flutter LazyColumn (ListView.builder):
- itemExtent — אם לכל הפריטים יש אותו גובה, ציון itemExtent קבוע מסיר את הצורך במדידת כל תא
- cacheExtent — הגדלה ל-500–1000 פיקסלים לטעינה מוקדמת מחוץ לאזור התצוגה
- AutomaticKeepAliveClientMixin — שמירת מצב התא בעת גלילה חזרה
איך אנחנו מייעלים רשימות מקוננות? מקרה בוחן
RecyclerView אופקי בתוך אנכי הוא דפוס נפוץ לממשקים בסגנון Netflix. הטעות האופיינית: כל RecyclerView אופקי יוצר RecycledViewPool משלו. בעת גלילת הרשימה האנכית, RecyclerViews אופקיים ממוחזרים יחד עם רכיבי הילד, ובחזרה, המצב שלהם (מיקום הגלילה) אובד.
מהניסיון שלנו: לקוח עם אפליקציית קטלוג תוכן התלונן על אובדן מיקום במהלך גלילה. העברנו את RecycledViewPool לרמת ה-activity והעברנו אותו לכל RecyclerView אופקי דרך setRecycledViewPool(). אנו שומרים LinearLayoutManager.onSaveInstanceState() ב-ViewModel עם מפתח לפי מיקום. התוצאה — גלילה חלקה ומיקום שנשמר בעת גלילת הרשימה האנכית.
השוואת ספריות טעינת תמונות
| קריטריון | Android (Glide לעומת Picasso) | iOS (SDWebImage לעומת Kingfisher) |
|---|---|---|
| טעינה אסינכרונית | Glide: Dispatcher מובנה, Picasso: רק לאחרונה | שתיהן אסינכרוניות |
| מטמון דיסק | Glide: רב-תהליכי, Picasso: חד-תהליכי | SDWebImage: דיסק/זיכרון, Kingfisher: ניתן להגדרה |
| מודע למחזור חיים | Glide: כן (RequestManager), Picasso: לא | לא רלוונטי |
| Prefetch | Glide: preload(), Picasso: לא | SDWebImage: prefetchURLs, Kingfisher: לא |
| ביצועי טעינה המונית | Glide מהיר ב-40% | SDWebImage מהיר ב-30% בהפעלה קרה |
מה כוללת העבודה
| שלב | מה אנחנו עושים | תוצאה |
|---|---|---|
| ביקורת | פרופיל גלילה במכשירים אמיתיים (3+ דגמים), זיהוי צווארי בקבוק | דוח עם מדדים (fps, זמן עיבוד) והמלצות |
| אופטימיזציה | יישום מטמון, prefetch, הגדרות RecyclerView/UITableView | גלילה חלקה (60 fps) בכל המכשירים |
| בדיקות | אימות על 5+ מכשירים בעלי עוצמות שונות | יציבות מאומתת |
| תיעוד | תיאור השינויים, הכנת קוד ל-CI | אינטגרציה ללא רגרסיות |
לוחות זמנים ואחריות
ביקורת ואופטימיזציה של רשימה בעייתית אחת: 2–4 ימים. עבודה מערכתית על כל הרשימות באפליקציה: 1–2 שבועות. אנו מבטיחים ביטול גמגום בגלילה: אם ההשפעה נמשכת, אנו עובדים מחדש בחינם. רקורד שלנו: 50+ פרויקטים עם אופטימיזציית רשימות ב-iOS, Android ו-Flutter. צרו קשר לביקורת — נעריך את הפרויקט שלכם תוך יומיים.







