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()), | |
), | |
); | |
} | |
} |
[유튜브 강좌 영상]