[073] 플러터 (Flutter) 배우기 - Throttle 적용하여 리스트뷰(ListView) 페이지네이션 API 중복 호출 방지하기

2024. 5. 26. 19:31모바일어플개발/Flutter

반응형

안녕하세요 totally 개발자입니다.

 

이 포스팅에서는 플러터 패키지 중 debounce_throttle을 사용하여 Throttle 개념을 적용해보도록 하겠습니다.

 

우선 Throttle의 사용 목적에 대해서 알아야 합니다. 이 포스팅 이후에 Debounce에 대해서도 다룰 것이지만 Throttle과 Debounce는 비슷해보이나 서로 다른 목적을 가지고 있습니다. Throttle은 일정한 주기 동안 특정 함수가 여러 번 호출된 경우 함수를 한 번만 바로 실행하고 연속적으로 함수를 호출하는 것은 방지할 수 있습니다. 반대로 Denounce는 정해진 시간 동안 특정 함수가 여러 번 호출된 경우 마지막으로 실행된 함수를 호출합니다.

 

Throttle 특정 시간 동안 여러 번의 함수 호출이 있을 경우 함수를 한 번만 실행
Debounce 특정 시간 동안 여러 번의 함수 호출이 있을 경우 마지막 함수 호출만 실행

 

Throttle과 Denounce가 사용되는 예시는 아래와 같습니다.

Throttle 리스트뷰(List View), 그리드뷰(Grid View) 페이지네이션 등
Debounce 숫자를 증감시키는 경우, 텍스트를 입력할 때 변경될 때마다 호출하는 경우 등

 

이 포스팅에서는 Throttle을 사용하여 ListView의 스크롤하여 페이지네이션을 진행할 때 중복 API 호출을 방지해보도록 하겠습니다. 저희가 사용할 패키지는 아래에 있는 debounce_throttle입니다. 자세한 예제는 아래를 참고해주시면 됩니다.

https://pub.dev/packages/debounce_throttle

 

debounce_throttle | Dart package

A debouncer and throttle that works with Futures, Streams, and callbacks.

pub.dev

 

 

먼저 예전 포스팅에서 다뤘던 ListView Pagination 부분 코드를 대부분 가져와서 예제 실습을 해보도록 하겠습니다.

https://totally-developer.tistory.com/119

 

[046] 플러터 (Flutter) 배우기 - ListView.builder + 스크롤 Pagination 적용

안녕하세요~ totally 개발자입니다. ListView.builder + Pagination 이번에는 ListView.builder를 사용하여 스크롤을 내렸을 때 다음 페이지, 추가 내용을 가져와서 보여주는 스크롤 페이지네이션(Pagination) 구현

totally-developer.tistory.com

 

Step 1: 먼저 pubspec.yaml 파일에 http 패키지말고 dio 패키지로 대체하고, debounce_throttle 패키지도 추가하여 줍니다.

 

Step 2: 아래 패키지들을 import 합니다. 모든 소스 코드는 맨 아래에 있습니다.

 

 

Step 3: 아래 _HomePageState 부분처럼 변수들을 선언하여 줍니다. 그 중에서도 37-40번째 부분이 새롭게 추가된 부분으로 Throttle 변수 2개와 _count 변수도 만들어줍니다. _throttledNextLoad는 리스트뷰 페이지네이션 스크롤을 위해, _throttledButtonClick은 버튼 클릭시 숫자를 증감시킬 목적으로 선언한 변수입니다.

 

 

Step 4: 아래처럼 initState 부분을 구성해줍니다. 보통 Throttle에는 Duration(기간) 설정, initialValue(초기값) 설정, checkEquality(함수 실행할 때 전달된 값이 같은지 여부 판단) 설정 등 데이터를 초기화하도록 되어 있고 ..values.listen을 붙여서 어떤 함수를 대상으로 Throttle를 설정할 것인지 정하면 됩니다. 첫 번째 Throttle에는 _nextLoad 즉 리스트뷰에서 그 다음 리스트를 받아오는 부분이고, 56-60번째 라인은 사용자가 스크롤을 내렸을 때, 그 다음 Throttle의 _nextLoad를 호출하도록 만들어줍니다. 물론 저는 예전 예시부터 setState로 자체적으로 page 변수 등을 업데이트하였기 때문에 별도의 초기값과 setValue 파라미터를 설정해줄 필요 없이 null 값으로 진행하였으나 Provider 등 다른 상태 패키지를 사용하는 경우에는 인스턴스 형태나 다른 값이 들어가게 됩니다. Duration(milliseconds: 1000)은 1초를 의미합니다. 즉 1초 동안 아무리 많이 함수를 호출했다 하더라도 함수를 한 번만 실행하게 됩니다. 이 부분은 추후 숫자를 증가시킬 때 확실하게 보실 수 있습니다.

 

 

Step 5: _nextLoad 함수 부분인데 이 부분은 기존 포스팅에 있는 내용과 같습니다.

 

 

Step 6: dispose 메소드 부분으로 해제할 부분들 해제하시면 됩니다.

 

 

Step 7: 다른 Throttle 부분인 _increaseCount 부분입니다.

 

 

Step 8: Widget build 부분으로 맨 아래에 floatingActionButton만 추가하였습니다. 모든 소스 코드는 맨 아래에 있습니다.

 

 

Step 9: 실행해본 모습입니다. 아래와 같이 표시됩니다.

 

 

Step 10: 숫자 증가시키는 부분도 1초에 몇 번을 클릭하든지, 함수가 한 번만 실행되어 업데이트됩니다. 물론 이 숫자 관련된 업데이트는 Debounce가 상황에 따라 더 적절할 수도 있는 것이 이 Throttle은 일정한 기간 내 함수 한 번만 실행되어 어차피 마지막에 업데이트된 값을 추가로 업데이트시켜야 하는데 비해, Debounce를 사용하면 특정 시간 내에 같은 함수가 여러 번 호출되었을 때 마지막의 값을 전달하는 함수만 업데이트 되므로 효율적으로 데이터를 처리할 수 있습니다. 이 Debounce 관련해서는 다음 포스팅에서 진행해보도록 하겠습니다.

 

[전체 소스 코드]

 

import 'package:debounce_throttle/debounce_throttle.dart';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Flutter App', home: HomePage());
}
}
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _url = 'https://jsonplaceholder.typicode.com/albums';
int _page = 1;
final int _limit = 10;
bool _hasNextPage = true;
bool _isFirstLoadRunning = false;
bool _isLoadMoreRunning = false;
List _albumList = [];
late ScrollController _controller;
final dio = Dio();
late Throttle _throttledNextLoad;
late Throttle _throttledButtonClick;
int _count = 0;
@override
void initState() {
super.initState();
_initLoad();
_controller = ScrollController();
_throttledNextLoad = Throttle(
const Duration(milliseconds: 1000),
initialValue: null,
checkEquality: false,
)..values.listen((_) => _nextLoad());
_controller.addListener(() {
if (_controller.position.extentAfter < 200) {
_throttledNextLoad.setValue(null);
}
});
_throttledButtonClick = Throttle(
const Duration(milliseconds: 1000),
initialValue: 0,
checkEquality: false,
)..values.listen((_) => _increaseCount());
}
void _initLoad() async {
setState(() {
_isFirstLoadRunning = true;
});
try {
final res = await dio.get("$_url?_page=$_page&_limit=$_limit");
debugPrint("$_url?_page=$_page&_limit=$_limit");
setState(() {
_albumList = res.data;
});
} catch (e) {
print(e.toString());
}
setState(() {
_isFirstLoadRunning = false;
});
}
void _nextLoad() async {
if (_hasNextPage &&
!_isFirstLoadRunning &&
!_isLoadMoreRunning &&
_controller.position.extentAfter < 200) {
setState(() {
_isLoadMoreRunning = true;
});
_page += 1;
try {
final res = await dio.get("$_url?_page=$_page&_limit=$_limit");
debugPrint("$_url?_page=$_page&_limit=$_limit");
final List fetchedPosts = res.data;
if (fetchedPosts.isNotEmpty) {
setState(() {
_albumList.addAll(fetchedPosts);
});
} else {
setState(() {
_hasNextPage = false;
});
}
} catch (e) {
print(e.toString());
}
setState(() {
_isLoadMoreRunning = false;
});
}
}
@override
void dispose() {
_controller.removeListener(() {
if (_controller.position.extentAfter < 200) {
_throttledNextLoad.setValue(null);
}
});
_controller.dispose();
super.dispose();
}
void _increaseCount() {
setState(() {
_count++;
});
debugPrint("_increaseCount() 호출: 현재 count: $_count");
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ListView Pagination'),
),
body: _isFirstLoadRunning
? const Center(
child: CircularProgressIndicator(),
)
: Column(
children: [
Expanded(
child: ListView.builder(
controller: _controller,
itemCount: _albumList.length,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.symmetric(
vertical: 8, horizontal: 10),
child: ListTile(
title: Text(_albumList[index]['id'].toString()),
subtitle: Text(_albumList[index]['title']),
),
),
),
),
if (_isLoadMoreRunning == true)
Container(
padding: const EdgeInsets.all(30),
child: const Center(
child: CircularProgressIndicator(),
),
),
if (_hasNextPage == false)
Container(
padding: const EdgeInsets.all(20),
color: Colors.blue,
child: const Center(
child: Text('No more data to be fetched.',
style: TextStyle(color: Colors.white)),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_throttledButtonClick.setValue(_count);
},
child: Text(_count.toString()),
),
);
}
}
view raw 073_main.dart hosted with ❤ by GitHub

 

[유튜브 강좌 영상]

 

https://youtu.be/NhW7JprHZ7w

 

 

반응형