Ağ isteklerini debounce etme/iptal etme
Uygulamalar karmaşıklaştıkça, aynı anda birden fazla ağ isteğinin havada olması sıkça karşılaşılan bir durumdur. Örneğin bir kullanıcı arama kutusuna yazı yazarken her tuş vuruşunda yeni bir istek tetikleyebilir. Kullanıcı hızlı yazarsa, uygulamada aynı anda çok sayıda istek beklemede olabilir.
Ya da bir kullanıcı bir isteği tetikleyip, istek tamamlanmadan başka bir sayfaya geçebilir. Bu durumda uygulamada artık ihtiyaç duyulmayan bir istek beklemede kalır.
Bu durumlarda performansı optimize etmek için kullanabileceğiniz birkaç teknik vardır:
- İstekleri "debounce" etmek. Bu, isteği göndermeden önce kullanıcının belirli bir süre boyunca yazmayı bırakmasını beklemek anlamına gelir. Böylece kullanıcı hızlı yazsa bile belirli bir girdi için yalnızca tek bir istek gönderilir.
- İstekleri "iptal etmek". Bu, istek tamamlanmadan kullanıcı sayfadan ayrılırsa isteği iptal etmek anlamına gelir. Böylece kullanıcının asla göremeyeceği bir yanıtı işlemek için zaman harcamazsınız.
Riverpod'da bu tekniklerin ikisi de benzer şekilde uygulanabilir.
Kilit nokta, istenen davranışa ulaşmak için ref.onDisposeu "otomatik yok etme"
veya ref.watch ile birlikte kullanmaktır.
Bunu göstermek için iki sayfalı basit bir uygulama yapacağız:
- Yeni bir sayfa açan bir butona sahip bir ana ekran
- Bored API'den rastgele bir aktivite gösteren ve
aktiviteyi yenileme imkânı sunan bir detay sayfası.
Pull-to-refresh'in nasıl uygulanacağı hakkında bilgi içinPull-to-refresh (aşağı çekerek yenileme) uygulama sayfasına bakın.
Ardından şu davranışları uygulayacağız:
- Kullanıcı detay sayfasını açıp hemen geri dönerse, aktivite isteğini iptal edeceğiz.
- Kullanıcı aktiviteyi arka arkaya birden fazla kez yenilerse, istekleri debounce edeceğiz; böylece yalnızca kullanıcı yenilemeyi bıraktıktan sonra tek bir istek göndereceğiz.
Uygulama

Önce, herhangi bir debounce veya iptal olmadan uygulamayı oluşturalım.
Burada süslü bir şey kullanmayacağız; detay sayfasını açmak için Navigator.push
içeren sade bir FloatingActionButton ile yetineceğiz.
İlk olarak ana ekranımızı tanımlayarak başlayalım. Her zamanki gibi,
uygulamamızın köküne bir ProviderScope yerleştirmeyi unutmayalım.
void main() => runApp(const ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
routes: {
'/detail-page': (_) => const DetailPageView(),
},
home: const ActivityView(),
);
}
}
class ActivityView extends ConsumerWidget {
const ActivityView({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Home screen')),
body: const Center(
child: Text('Click the button to open the detail page'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => Navigator.of(context).pushNamed('/detail-page'),
child: const Icon(Icons.add),
),
);
}
}
Ardından detay sayfamızı tanımlayalım. Aktiviteyi çekmek ve pull-to-refresh uygulamak için
Pull-to-refresh (aşağı çekerek yenileme) uygulama örnek çalışmasına bakın.- riverpod
- riverpod_generator
class Activity {
Activity({
required this.activity,
required this.type,
required this.participants,
required this.price,
});
factory Activity.fromJson(Map<Object?, Object?> json) {
return Activity(
activity: json['activity']! as String,
type: json['type']! as String,
participants: json['participants']! as int,
price: json['price']! as double,
);
}
final String activity;
final String type;
final int participants;
final double price;
}
final activityProvider = FutureProvider.autoDispose<Activity>((ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(json);
});
class DetailPageView extends ConsumerWidget {
const DetailPageView({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Detail page'),
),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
AsyncValue(:final value?) => Text(value.activity),
AsyncValue(:final error?) => Text('Error: $error'),
_ => const Center(child: CircularProgressIndicator()),
},
],
),
),
);
}
}
sealed class Activity with _$Activity {
factory Activity({
required String activity,
required String type,
required int participants,
required double price,
}) = _Activity;
factory Activity.fromJson(Map<String, dynamic> json) =>
_$ActivityFromJson(json);
}
Future<Activity> activity(Ref ref) async {
final response = await http.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
class DetailPageView extends ConsumerWidget {
const DetailPageView({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final activity = ref.watch(activityProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Detail page'),
),
body: RefreshIndicator(
onRefresh: () => ref.refresh(activityProvider.future),
child: ListView(
children: [
switch (activity) {
AsyncValue(:final value?) => Text(value.activity),
AsyncValue(:final error?) => Text('Error: $error'),
_ => const Center(child: CircularProgressIndicator()),
},
],
),
),
);
}
}
İstekleri iptal etme
Artık çalışan bir uygulamamız olduğuna göre, iptal mantığını uygulayalım.
Bunun için, kullanıcı sayfadan ayrıldığında isteği iptal etmek üzere
ref.onDisposeu kullanacağız. Bunun çalışması için provider'ların
otomatik yok edilmesinin etkin olması önemlidir.
İsteği iptal etmek için gereken kod, HTTP client'ına göre değişir.
Bu örnekte package:http kullanacağız, ancak aynı prensip
diğer client'lar için de geçerlidir.
Buradaki kilit nokta, kullanıcı sayfadan ayrıldığında ref.onDisposeun çağrılacak olmasıdır.
Çünkü provider'ımız artık kullanılmadığından, otomatik yok etme sayesinde
yok edilir.
Dolayısıyla bu geri çağırmayı isteği iptal etmek için kullanabiliriz. package:http
kullanırken bu, HTTP client'ımızı kapatarak yapılabilir.
- riverpod
- riverpod_generator
final activityProvider = FutureProvider.autoDispose<Activity>((ref) async {
// package:http kullanarak bir HTTP client'ı oluşturuyoruz
final client = http.Client();
// Yok edilirken client'ı kapatıyoruz.
// Bu, client'ın bekleyen tüm isteklerini iptal edecektir.
ref.onDispose(client.close);
// Artık isteği yapmak için "get" fonksiyonu yerine client'ı kullanıyoruz.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
// Kodun geri kalanı öncekiyle aynı
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
});
Future<Activity> activity(Ref ref) async {
// package:http kullanarak bir HTTP client'ı oluşturuyoruz
final client = http.Client();
// Yok edilirken client'ı kapatıyoruz.
// Bu, client'ın bekleyen tüm isteklerini iptal edecektir.
ref.onDispose(client.close);
// Artık isteği yapmak için "get" fonksiyonu yerine client'ı kullanıyoruz.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
// Kodun geri kalanı öncekiyle aynı
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
İstekleri debounce etme
İptal işlemini uyguladığımıza göre, şimdi de debounce'u uygulayalım.
Şu anda kullanıcı aktiviteyi arka arkaya birden fazla kez yenilerse,
her yenileme için bir istek göndeririz.
Teknik olarak bakıldığında, iptal işlemini uyguladığımız için bu bir sorun değil. Kullanıcı aktiviteyi arka arkaya birden fazla kez yenilerse, yeni bir istek yapıldığında önceki istek iptal edilecektir.
Yine de bu ideal değil. Hâlâ birden fazla istek gönderiyor,
bant genişliğini ve sunucu kaynaklarını boşa harcıyoruz.
Bunun yerine yapabileceğimiz şey, kullanıcı belirli bir süre boyunca aktiviteyi
yenilemeyi bırakana kadar isteklerimizi geciktirmektir.
Buradaki mantık iptal mantığına çok benziyor. Yine
ref.onDisposeu kullanacağız. Ancak buradaki fikir, bir HTTP client'ını
kapatmak yerine, isteği başlamadan önce iptal etmek için onDisposea
güvenmemizdir.
Ardından isteği göndermeden önce keyfi olarak 500ms bekleyeceğiz.
Kullanıcı 500ms dolmadan aktiviteyi tekrar yenilerse,
onDispose çağrılacak ve istek iptal edilecektir.
İstekleri iptal etmek için yaygın bir uygulama, bilinçli olarak hata fırlatmaktır.
Provider yok edildikten sonra provider'ın içinde hata fırlatmak güvenlidir.
Bu exception doğal olarak Riverpod tarafından yakalanır ve yok sayılır.
- riverpod
- riverpod_generator
final activityProvider = FutureProvider.autoDispose<Activity>((ref) async {
// Provider'ın şu anda yok edilmiş olup olmadığını yakalıyoruz.
var didDispose = false;
ref.onDispose(() => didDispose = true);
// Kullanıcının yenilemeyi bırakmasını beklemek için isteği 500ms geciktiriyoruz.
await Future<void>.delayed(const Duration(milliseconds: 500));
// Gecikme sırasında provider yok edildiyse, bu kullanıcının tekrar
// yenilediği anlamına gelir. İsteği iptal etmek için bir exception fırlatıyoruz.
// Burada exception kullanmak güvenlidir, çünkü Riverpod tarafından yakalanacaktır.
if (didDispose) {
throw Exception('Cancelled');
}
// Aşağıdaki kod bir önceki snippet'e göre değişmedi
final client = http.Client();
ref.onDispose(client.close);
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
});
Future<Activity> activity(Ref ref) async {
// Provider'ın şu anda yok edilmiş olup olmadığını yakalıyoruz.
var didDispose = false;
ref.onDispose(() => didDispose = true);
// Kullanıcının yenilemeyi bırakmasını beklemek için isteği 500ms geciktiriyoruz.
await Future<void>.delayed(const Duration(milliseconds: 500));
// Gecikme sırasında provider yok edildiyse, bu kullanıcının tekrar
// yenilediği anlamına gelir. İsteği iptal etmek için bir exception fırlatıyoruz.
// Burada exception kullanmak güvenlidir, çünkü Riverpod tarafından yakalanacaktır.
if (didDispose) {
throw Exception('Cancelled');
}
// Aşağıdaki kod bir önceki snippet'e göre değişmedi
final client = http.Client();
ref.onDispose(client.close);
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}
Daha ileri gitmek: İkisini aynı anda yapmak
Artık istekleri nasıl debounce edeceğimizi ve iptal edeceğimizi biliyoruz.
Ancak şu anda başka bir istek yapmak istersek, aynı mantığı
birden fazla yere kopyalayıp yapıştırmamız gerekiyor. Bu ideal değil.
Ne var ki daha ileri gidip, ikisini aynı anda yapan, yeniden kullanılabilir bir yardımcı yazabiliriz.
Buradaki fikir, Ref üzerinde hem iptali hem de debounce'u tek bir metotta
ele alan bir extension metodu yazmaktır.
extension DebounceAndCancelExtension on Ref {
/// [duration] süresi kadar (varsayılan 500ms) bekler ve ardından istek yapmak
/// için kullanılabilecek bir [http.Client] döndürür.
///
/// Bu client, provider yok edildiğinde otomatik olarak kapatılır.
Future<http.Client> getDebouncedHttpClient([Duration? duration]) async {
// Önce debounce işlemini ele alıyoruz.
var didDispose = false;
onDispose(() => didDispose = true);
// Kullanıcının yenilemeyi bırakmasını beklemek için isteği 500ms geciktiriyoruz.
await Future<void>.delayed(duration ?? const Duration(milliseconds: 500));
// Gecikme sırasında provider yok edildiyse, bu kullanıcının tekrar
// yenilediği anlamına gelir. İsteği iptal etmek için bir exception fırlatıyoruz.
// Burada exception kullanmak güvenlidir, çünkü Riverpod tarafından yakalanacaktır.
if (didDispose) {
throw Exception('Cancelled');
}
// Şimdi client'ı oluşturuyoruz ve provider yok edildiğinde kapatıyoruz.
final client = http.Client();
onDispose(client.close);
// Son olarak, provider'ımızın isteği yapabilmesi için client'ı döndürüyoruz.
return client;
}
}
Bu extension metodunu provider'larımızda şu şekilde kullanabiliriz:
- riverpod
- riverpod_generator
final activityProvider = FutureProvider.autoDispose<Activity>((ref) async {
// Daha önce oluşturduğumuz extension'ı kullanarak bir HTTP client'ı elde ediyoruz.
final client = await ref.getDebouncedHttpClient();
// Artık isteği yapmak için "get" fonksiyonu yerine client'ı kullanıyoruz.
// İsteğimiz doğal olarak debounce edilecek ve kullanıcı sayfadan ayrılırsa
// iptal edilecektir.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
});
Future<Activity> activity(Ref ref) async {
// Daha önce oluşturduğumuz extension'ı kullanarak bir HTTP client'ı elde ediyoruz.
final client = await ref.getDebouncedHttpClient();
// Artık isteği yapmak için "get" fonksiyonu yerine client'ı kullanıyoruz.
// İsteğimiz doğal olarak debounce edilecek ve kullanıcı sayfadan ayrılırsa
// iptal edilecektir.
final response = await client.get(
Uri.https('www.boredapi.com', '/api/activity'),
);
final json = jsonDecode(response.body) as Map;
return Activity.fromJson(Map.from(json));
}