Ana içeriğe atla

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:

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

Uygulamayı, detay sayfasının açılışını ve aktivitenin yenilenmesini gösteren gif.

Ö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.

lib/src/main.dart
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.
lib/src/detail_screen.dart
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()),
},
],
),
),
);
}
}

İ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.

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));
});

İ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.

bilgi

İ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.

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));
});

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:

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));
});