공식 사이트 참조
https://api.flutter.dev/flutter/dart-async/Timer-class.html
스케줄 앱을 만들면서 datePicker 와 timePicker 를 사용하고 있었다. 그런데 사용자가 날짜나 시간을 빠르게 조정할 경우 onDateTimeChanged 콜백이 지나치게 자주 호출되어 앱이 다운되는 문제가 발견되었다. 로그를 찍어보니 원인이 명확했다. 사용자가 스크롤을 통해 값을 계속 바꾸는 상황에서 콜백이 실시간으로 호출되었기 때문이었다.
이 문제를 해결하기 위해, 사용자가 값을 지속적으로 변경하는 동안에는 콜백을 실행하지 않고 일정 시간 동안 변경이 멈추면 그때 콜백을 실행하도록 하는 'debounce' 를 적용해 보았다.
사용 예시를 들자면 이렇다. 날짜를 선택하느라 datePicker 를 스크롤하는 동안에는 값을 콜백하지 않고, 스크롤을 멈춘 후 1초 동안 새로운 움직임이 없는 경우에만 값을 콜백해주는 것이다.
debounce는 연속적으로 발생하는 이벤트들 중 마지막 이벤트만 처리하는 방식이다. 이를 통해 이벤트 과부하를 방지하고, 성능을 최적화할 수 있다. Dart의 Timer 클래스를 사용하여 debounce를 구현했다.
(1) debounce 구현을 위한 Timer 선언
debounce를 구현하려면 Timer 타입의 변수를 클래스 내에서 선언한다. 이 변수는 콜백이 지연되어 호출될 때까지 대기하는 데 사용된다.
Timer를 null로 선언하여 처음에는 타이머가 없는 상태로 시작하고, 이후 필요할 때 인스턴스를 생성해준다.
Timer? _debounce; // debounce에 사용될 Timer 변수
(2) debounce 타이머 사용 및 콜백 호출
debounce는 이벤트가 발생할 때마다 타이머를 재설정해, 마지막 이벤트 후 일정 시간(예: 500밀리초)이 지난 후에만 콜백을 실행한다.
사용자가 빠르게 날짜를 변경하면, 기존의 타이머는 취소되고 새로운 타이머가 생성된다.
일정 시간 동안 추가 변경이 없을 경우, 타이머가 완료되고 콜백이 실행된다.
void _onDateChanged(DateTime dateTime) {
// 이전에 생성된 debounce 타이머가 있다면 취소
if (_debounce?.isActive ?? false) {
_debounce?.cancel();
}
// 새로운 타이머를 생성하여, 500ms 후에 콜백 실행
_debounce = Timer(Duration(milliseconds: 500), () {
_checkEndDate(dateTime); // debounce 이후 실행될 메서드
});
}
(3) debounce 해제
타이머를 사용한 debounce는 필요한 경우 수동으로 해제해야 한다. 특히, 위젯이 dispose될 때 타이머가 활성화된 상태라면, 이를 취소해 메모리 누수를 방지할 수 있다.
아래는 debounce 를 적용 완료한 코드이다. 이제 datePicker 에서 막 스크롤을 돌려도 앱이 꺼지지 않는다!
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:schedule_with/assets/colors/color.dart';
import 'package:schedule_with/ui/schedule/controller/schedule_controller.dart';
class StartDatePicker extends StatefulWidget {
final Widget backPage;
final String title;
final DateTime? initialDate;
final VoidCallback? onPressed;
const StartDatePicker({
required this.backPage,
required this.title,
required this.initialDate,
this.onPressed,
super.key,
});
State<StartDatePicker> createState() => _StartDatePickerState();
}
class _StartDatePickerState extends State<StartDatePicker> {
final ScheduleController controller = Get.find<ScheduleController>();
Timer? _debounce; // 사용자가 빠르게 값을 변경하는 동안에는 콜백을 실행하지 않기 위한 Timer
void initState() {
super.initState();
}
void dispose() {
_debounce?.cancel(); // 타이머 해제
super.dispose();
}
// DatePicker 로 "Start Date" 선택 시 동작하는 메서드
void onStartDateChanged(DateTime dateTime) {
controller.isInit.value
? _debounceEditDateUpdate(dateTime) // 스케줄 수정 BottomSheet 일 때, editStartDt 값 업데이트
: _debounceAddDateUpdate(dateTime); // 스케줄 추가 BottomSheet 일 때, addStartDt 값 업데이트
}
// 뒤로가기 버튼 클릭 시, 해당 페이지로 이동
void _defaultOnPressed() {
Get.back();
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (BuildContext context) {
return widget.backPage;
},
);
}
// 스케줄 add 시 선택한 날짜로 delay update
void _debounceAddDateUpdate(DateTime dateTime) {
if(_debounce?.isActive ?? false) _debounce?.cancel(); // 이전 타이머가 활성화 된 경우 취소
_debounce = Timer(const Duration(milliseconds: 500), () { // 새로운 타이머 설정
_addDateChanged(dateTime); // 날짜 변경
_resetLineThrough(); // 취소선 설정
});
}
Widget build(BuildContext context) {
return Container(
// 바텀 시트 높이 조절
height: MediaQuery.of(context).size.height * 0.3,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
color: Colors.white,
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: widget.onPressed ?? _defaultOnPressed,
icon: Icon(CupertinoIcons.back),
color: grey4,
),
Container(
alignment: Alignment.center,
height: 45,
child: Text(
widget.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 50),
],
),
Container(
height: 200,
child: CupertinoTheme(
data: CupertinoThemeData(
textTheme: CupertinoTextThemeData(
dateTimePickerTextStyle: TextStyle(
color: Colors.black,
fontSize: 20,
),
),
),
child: CupertinoDatePicker(
initialDateTime: widget.initialDate,
maximumYear: 2300,
minimumYear: 2000,
mode: CupertinoDatePickerMode.date,
onDateTimeChanged: onStartDateChanged,
),
),
),
],
),
);
}
}