Flutter'da Liste ve Grid: ListView.builder, Sliver'lar, Lazy Rendering ve Bookstore Katalog Ekranı
ListView'ın varsayılan constructor'ı da yalnız görünen satırları build eder ama bütün satır widget'larını önceden oluşturur; veriye bağlı listeler ListView.builder ya da sliver'larla kurulur, shrinkWrap ise bin satırın hepsini build ettirir. Bookstore katalog ekranını CustomScrollView, SliverAppBar ve liste/grid geçişiyle kuruyor, sayfalı yüklemeyi, pull-to-refresh'i, boş/hata durumlarını ve cacheWidth'in belleği 8 MB'tan 122 KB'a indirmesini gerçek test çıktılarıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Flutter 3.47.8 (Dart 3.13.7) ve material_ui 1.11.0 ile denenmiştir. Bütün çıktılar flutter test koşularından; emulator ve cihazda çalıştırma bu yazı için yapılmadı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) katalog ekranında bin kitaplık bir liste gösteriyoruz. Her satır, build method'u her çalıştığında bir sayacı artıran küçük bir widget. Liste, ListView'ın varsayılan constructor'ıyla, bütün satırlar önceden hazırlanarak kuruluyor:
ListView(children: [for (var i = 0; i < 1000; i++) row(i)])
Ekran 800 x 600, her satır 56 piksel. İlk frame'den sonra satırların build method'u kaç kez çalışmıştır: 1000 mi, yoksa ekrana sığan 11 civarı mı? Cevap ikisi de değil, ve doğru sayıyı görünce ListView.builder'ın neyi farklı yaptığını da tam olarak görüyoruz.
ListView, ListView.builder ve Lazy Rendering
Ölçmek için iki sayaç tuttuk: created, satır widget'ını oluşturan fonksiyon her çağrıldığında; built, satırın build method'u her çalıştığında artıyor. alive ise o anda ağaçta bulunan satır element'lerinin sayısı:
class CountingRow extends StatelessWidget {
const CountingRow(this.index, {super.key});
final int index;
@override
Widget build(BuildContext context) {
built++;
return SizedBox(height: 56, child: Text('Book $index'));
}
}
Widget row(int index) {
created++;
return CountingRow(index);
}
children: created 1000, built 16, alive 16
builder : created 16, built 16, alive 16
builder after scrolling 5000 px: created 105, built 105, alive 21
"Önce Tahmin Et"in cevabı 16. ListView(children: ...) da element ve render object'leri lazy oluşturuyor: yalnız görünen 11 satırı ve görünür alanın 250 piksel ötesine kadar olanları (cache extent, varsayılan değeri 250) build ediyor. Fark ilk sütunda: varsayılan constructor bin widget object'inin bin tanesini de oluşturdu ve listede tutuyor. Gerçek bir katalogda bu, bin kitabın her biri için satır widget'ı ve onun alt ağacındaki parametreleri hazırlamak demek. Flutter dokümanı da varsayılan constructor'ı az sayıda child'ı olan listeler için öneriyor.
ListView.builder ise satırı yalnız ihtiyaç olduğunda, itemBuilder'ı çağırarak oluşturuyor. 5000 piksel kaydırdıktan sonra 105 satır oluşturulmuş ama ağaçta yalnız 21'i yaşıyor: ekrandan çıkan satırların element'leri atılıyor. Ekranın ortasında görünür alanın hem üstünde hem altında 250 piksellik cache olduğu için sayı 16'dan 21'e çıktı. Kural basit: uzunluğu veriye bağlı her liste builder ile kurulur.
Bir önceki yazıda Column içindeki ListView için sık önerilen shrinkWrap: true'nun lazy çalışmayı bozduğunu söylemiştik. Aynı bin satırlık ListView.builder'ı bir SingleChildScrollView içindeki Column'a koyup shrinkWrap: true verdik:
shrinkWrap: created 1000, built 1000, alive 1000
shrinkWrap, listeden kendi yüksekliğini içeriğine göre hesaplamasını istiyor; bunun için bütün satırları layout'a sokmak zorunda. Dışarıdaki kaydırma alanı ise bin satırın hepsini "görünür" sayıyor. Sonuç: builder kullansan bile bin satır build ediliyor ve bellekte kalıyor. Bir ekranda listenin üstünde ve altında başka içerik varsa doğru araç, hepsini tek bir kaydırma alanında birleştiren sliver'lar.
Sliver'lar ve CustomScrollView
Sliver, kaydırılabilir bir alanın bir parçası: kendisine "görünür alan şu, şu kadar kaydırıldı" bilgisi verilir ve yalnız görünen kısmını layout'a sokar. ListView de aslında bir scroll view; içinde tek bir SliverList kuruyor. Kendi CustomScrollView'ını kurduğunda başlık, liste, grid ve alt bilgi aynı kaydırma alanına girer ve her biri lazy kalır. Katalog ekranı şöyle:
@override
Widget build(BuildContext context) {
return Scaffold(
body: RefreshIndicator(
onRefresh: _refresh,
child: NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
floating: true,
title: const Text('Bookstore'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Text('Books in cart: ${widget.cartCount}'),
),
),
],
),
if (_books.isEmpty)
SliverFillRemaining(hasScrollBody: false, child: _emptyState())
else ...[
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverLayoutBuilder(
builder: (context, constraints) =>
constraints.crossAxisExtent < 600
? SliverList.builder(
itemCount: _books.length,
itemBuilder: (context, i) => _card(_books[i]),
)
: SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 420,
mainAxisExtent: 120,
),
itemCount: _books.length,
itemBuilder: (context, i) => _card(_books[i]),
),
),
),
SliverToBoxAdapter(child: _footer()),
],
],
),
),
),
);
}
Parçaları tek tek: SliverAppBar(floating: true), aşağı kaydırınca kaybolan, yukarı kaydırmaya başlar başlamaz geri gelen başlık. SliverFillRemaining, liste boşken kalan alanın tamamını yükleniyor, hata ya da boş durum mesajına veriyor. SliverToBoxAdapter, sliver olmayan tek bir widget'ı (sayfanın altındaki yükleniyor göstergesi) kaydırma alanına koyuyor. Column içinde shrinkWrap'li bir liste yerine kurulması gereken yapı bu.
Liste ile grid arasındaki karar SliverLayoutBuilder'da. Bir önceki yazıdaki LayoutBuilder'ın sliver karşılığı; constraints.crossAxisExtent bu sliver'a ayrılan genişliği veriyor. 600'ün altında tek sütunlu SliverList, üstünde SliverGrid. SliverGridDelegateWithMaxCrossAxisExtent sütun sayısını sabitlemek yerine bir sütunun en fazla 420 piksel olmasını söylüyor; sütun sayısını genişlik belirliyor. GridView.builder da aynı delegate'i alan, tek sliver'lık hazır bir kısayol.
Grid'e geçince bir layout hatası yaşadık. Grid hücresinin yüksekliği sabit (mainAxisExtent: 120), ve test 800 piksel genişlikte grid düzenini açtığında The Hobbit'in iki satırı aşan başlığı kartın içindeki Column'u taşırdı:
A RenderFlex overflowed by 36 pixels on the bottom.
Hücre yüksekliği sabitse içeriği de sınırlamak gerekir. BookCard'daki başlığa maxLines: 2, yazar satırına maxLines: 1 ve ikisine de TextOverflow.ellipsis verdik.
Sayfalı Yükleme ve Pull-to-Refresh
Bookstore API'ye bağlanana kadar katalog, 20'şer kitaplık sayfalar halinde ve her sayfayı 300 ms gecikmeyle veren geçici bir SampleCatalog'dan geliyor. İlk üç kitap önceki yazılardaki gerçek örnekler, kalanı listenin kaydırılabilmesi için numaralı yer tutucular. Ekran, sayfayı yükleyen fonksiyonu parametre olarak alıyor; mimari yazısında bu iş ViewModel'e ve repository'ye taşınacak. Yükleme mantığı:
final _books = <Book>[];
var _nextPage = 0;
var _hasMore = true;
var _isLoading = false;
Object? _error;
@override
void initState() {
super.initState();
unawaited(_loadMore());
}
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
final page = await widget.loadPage(_nextPage);
if (!mounted) return;
setState(() {
_books.addAll(page);
_nextPage++;
_hasMore = page.isNotEmpty;
_isLoading = false;
});
} catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_isLoading = false;
});
}
}
İlk satırdaki koşul önemli: kullanıcı kaydırırken scroll event'leri saniyede onlarca kez gelir ve aynı sayfa için on istek atılmamalı. _isLoading devam eden isteği, _hasMore ise boş sayfa gelince bitişi işaretliyor. Scroll sonunu algılamak için ayrı bir ScrollController yerine ağaçtan yukarı çıkan scroll bildirimlerini dinledik:
bool _onScroll(ScrollNotification notification) {
if (notification.metrics.extentAfter < loadAheadPixels && _error == null) {
unawaited(_loadMore());
}
return false;
}
extentAfter, görünür alanın altında kalan ve henüz görülmemiş içeriğin piksel cinsinden uzunluğu. 500 pikselin altına inince bir sonraki sayfayı istiyoruz; kullanıcı listenin sonuna gelmeden yeni kitaplar hazır oluyor. false döndürmek, bildirimin yukarıdaki dinleyicilere de (burada RefreshIndicator) gitmeye devam etmesi demek. 60 kitaplık katalogla yazdığımız testte sayfa isteklerini kaydettik:
after start: calls [0]
after scrolling to book 20: calls [0, 1]
after the last book: calls [0, 1, 2, 3]
20. kitaba gelmeden ikinci sayfa (1) istenmiş. Son sayfa (3) boş döndü ve _hasMore false oldu; sonraki kaydırmalarda istek gitmiyor. API'n toplam sayıyı ya da bir sonraki sayfanın adresini dönüyorsa bu fazladan isteği de kaldırabilirsin.
Pull-to-refresh için RefreshIndicator yeterli: onRefresh'e verdiğin Future tamamlanana kadar dönen göstergeyi gösteriyor. _refresh ilk sayfayı yeniden yükleyip listeyi onunla değiştiriyor, başarısız olursa eski listeyi koruyup bir SnackBar gösteriyor. Testte listeyi aşağı çektik:
after pull to refresh: calls [0, 0]
Liste kısa olsa bile çekilebilmesi için CustomScrollView'a AlwaysScrollableScrollPhysics verdik; aksi halde içerik ekrana sığdığında kaydırma olmaz ve gösterge hiç açılmaz. material_ui dokümanının da belirttiği gibi RefreshIndicator masaüstü ve web'de fareyle çalışmaz, çünkü fareyle sürükleme varsayılan olarak bir scroll hareketi sayılmaz. Uygulaman masaüstüne de gidiyorsa ayrıca bir yenile butonu koy.
Boş, Yükleniyor, Hata: Ortak Widget'lar
Her liste ekranı aynı üç durumu yaşar: veri yolda, veri boş, veri gelmedi. Bunları her ekranda yeniden yazmak yerine resmî mimari rehberinin önerdiği lib/ui/core/ui klasörüne üç küçük widget koyduk:
class ErrorView extends StatelessWidget {
const ErrorView({super.key, required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton(onPressed: onRetry, child: const Text('Try again')),
],
),
),
);
}
}
LoadingView ortalanmış bir CircularProgressIndicator, EmptyView bir ikon ve mesaj. Hata iki yerde gösteriliyor: liste boşken ekranın tamamında, liste doluyken sonraki sayfa gelmezse listenin altında. İkinci durumda kullanıcı yüklediği kitapları kaybetmiyor. İlk isteği bir kez başarısız olan bir loader'la test ettik; hata ekranı geldi, "Try again"e basınca liste yüklendi:
error then retry: calls [0, 0]
Hatanın ham metnini (Exception: timeout) kullanıcıya gösterme. Kullanıcıya ne olduğunu ve ne yapabileceğini söyleyen bir cümle göster, ayrıntıyı log'a yaz.
Ağdan Görsel: Image.network ve cacheWidth
Kartın solunda kitabın kapağı var. Kapak adresi yoksa ya da görsel yüklenemezse yer tutucu bir ikon gösteriliyor:
/// Liste satırındaki küçük kapak. Görseli çizildiği boyutta decode eder.
class BookThumbnail extends StatelessWidget {
const BookThumbnail({super.key, this.url, this.width = 48});
final String? url;
final double width;
@override
Widget build(BuildContext context) {
final height = width * 3 / 2;
final placeholder = SizedBox(
width: width,
height: height,
child: ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.menu_book),
),
);
final url = this.url;
if (url == null) return placeholder;
final dpr = MediaQuery.devicePixelRatioOf(context);
return Image.network(
url,
width: width,
height: height,
fit: BoxFit.cover,
cacheWidth: (width * dpr).round(),
loadingBuilder: (context, child, progress) =>
progress == null ? child : placeholder,
errorBuilder: (context, error, stackTrace) => placeholder,
);
}
}
width ve height görselin ekranda kaplayacağı alanı, cacheWidth ise görselin bellekte hangi çözünürlükte tutulacağını belirliyor. Fark büyük. Kapak taramaları genelde 1200 x 1800 civarında; bir test içinde bu boyutta bir PNG (yaygın, kayıpsız bir görsel formatı) üretip 48 x 72'lik bir alana önce cacheWidth'siz, sonra cacheWidth: 144 ile çizdik (48 dp, 3x yoğunluklu bir ekranda 144 fiziksel piksel):
without cacheWidth: decoded 1200x1800, 8438 KB in memory
cacheWidth: 144: decoded 144x216, 122 KB in memory
Decode edilmiş bir görsel piksel başına 4 byte tutar. cacheWidth olmadan 48 dp'lik bir küçük resim için 8 MB'tan fazla bellek gidiyor; listede 20 kapak görünürken bu 160 MB demek. cacheWidth ile aynı kapak 122 KB. Yükseklik verilmediği için oran korunuyor (144 x 216). Görselin çizildiği boyutu MediaQuery.devicePixelRatioOf ile fiziksel piksele çevirmeyi unutma; yalnız dp değerini verirsen yüksek yoğunluklu ekranda görsel bulanık olur.
Testte bir şeye daha dikkat et: widget testleri gerçek ağa çıkmaz. flutter_test her HTTP isteğine 400 döndürüyor ve errorBuilder çalışıyor:
errorBuilder ran: true
HTTP request failed, statusCode: 400, https://covers.example.com/9780141439518.jpg
Bu yüzden görsel içeren ekranların testinde hata yolunu doğal olarak görürsün; başarılı yolu test etmek istiyorsan görseli Image.memory ya da bir test asset'iyle ver.
Üretimde Dikkat
Listeyi profile modunda ölç. Debug build'de Dart JIT (kodu çalışırken derleyen mod) ile çalışır ve kaydırma her zaman olduğundan yavaş görünür; performans kararını flutter run --profile ile gerçek cihazda, DevTools'un performans görünümüne bakarak ver. Bu yazı için cihazda profil ölçümü yapılmadı.
Sayfalamayı API ile birlikte tasarla. Offset tabanlı sayfalamada (page=2&size=20) kullanıcı kaydırırken kataloğa yeni kitap eklenirse bir kitap iki kez görünebilir ya da hiç görünmeyebilir. Key'ler ISBN'den (kitapların uluslararası kimlik numarası) geldiği için Flutter tarafında aynı kitabın iki kartı state karışıklığı yaratır; listeyi eklerken ISBN'e göre tekrarları ayıkla ya da API'de cursor (son görülen kaydın kimliği) tabanlı sayfalama kullan. Mekaniği ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek" yazısında anlatılmıştı.
Görsel cache'inin sınırını bil. Flutter'ın ImageCache'i varsayılan olarak 1000 görsel ve 100 MB ile sınırlı (SDK kaynağındaki sabitler); cacheWidth kullanmazsan bu sınır birkaç büyük kapakla dolar ve görseller sürekli yeniden decode edilir.
Sık Düşülen Tuzaklar
Birinci tuzak, uzun listeyi ListView(children: ...) ile kurmak. Build lazy olsa da bütün satır widget'ları önceden oluşturulur; veriye bağlı her liste builder ile kurulur.
İkinci tuzak, iç içe kaydırma alanlarını shrinkWrap: true ve NeverScrollableScrollPhysics ile birleştirmek. Ölçtüğümüz gibi bütün satırlar build edilir; doğru çözüm tek bir CustomScrollView ve sliver'lar.
Üçüncü tuzak, sayfa isteğini korumasız tetiklemek. Scroll bildirimi her frame'de gelebilir; devam eden istek ve "daha veri yok" durumu kontrol edilmezse aynı sayfa defalarca istenir.
Dördüncü tuzak, büyük görseli tam çözünürlükte decode etmek. cacheWidth ya da cacheHeight vermek bir satırlık iş, karşılığı ölçtüğümüz gibi 70 kat daha az bellek.
Küçük Bir Deneme
★☆☆ lazy_list_test.dart'taki ListView.builder'a cacheExtent: 0 ver. İlk ölçümde built kaç oldu, neden?
★★☆ _onScroll'daki _error == null koşulunu sil. İkinci sayfa her istekte hata veren bir loader'la kaydırma testini çalıştır ve calls listesine bak. Hatalı istekler nasıl tekrarlanıyor?
★★★ Katalog ekranına SliverPersistentHeader ile kaydırırken üstte sabit kalan bir "Format: All / Paperback / E-book" filtre şeridi ekle. Filtre değişince liste ilk sayfadan yeniden yüklensin; bunu bir widget testiyle doğrula.
Bir sonraki yazıda uygulamanın görünüşüne geçiyoruz: ThemeData, ColorScheme.fromSeed, dark mode, ThemeExtension ile kendi token'larımız, material_ui ve cupertino_ui paketleri, adaptive widget'lar ve Türkçe locale.