<

새로 알게된 지식들

[flutter] # WidgetsBinding 완벽 가이드

sj han 2025. 11. 1. 05:53
728x90
반응형

📋 목차

  1. WidgetsBinding이란?
  2. 핵심 개념
  3. 주요 메서드 상세 가이드
  4. 실전 사용 패턴
  5. 트러블슈팅
  6. 성능 최적화
  7. 참고 자료

1. WidgetsBinding이란?

1.1 정의

WidgetsBinding은 Flutter 프레임워크의 핵심 클래스로, Flutter 위젯 레이어와 Flutter 엔진 사이의 접착제(glue) 역할을 합니다.

// Singleton 패턴으로 구현됨
WidgetsBinding.instance  // 앱 전체에서 단 하나의 인스턴스

1.2 주요 역할

  1. 프레임 스케줄링: 언제 위젯을 다시 그릴지 결정
  2. 생명주기 관리: 앱의 생명주기 이벤트 처리
  3. 입력 처리: 터치, 키보드 입력 등을 위젯에 전달
  4. 플랫폼 통신: 네이티브 플랫폼과의 메시지 전달

1.3 계층 구조

Object
  └─ BindingBase
      └─ GestureBinding
          └─ SchedulerBinding
              └─ ServicesBinding
                  └─ PaintingBinding
                      └─ SemanticsBinding
                          └─ RendererBinding
                              └─ WidgetsBinding  ← 여기!

각 바인딩의 역할:

  • GestureBinding: 제스처 인식 및 히트 테스트
  • SchedulerBinding: 프레임 스케줄링 및 콜백 관리
  • ServicesBinding: 플랫폼 채널 및 시스템 서비스
  • PaintingBinding: 이미지 캐시 및 셰이더 관리
  • SemanticsBinding: 접근성 트리 관리
  • RendererBinding: 렌더 트리 관리
  • WidgetsBinding: 위젯 트리 관리 (최상위)

2. 핵심 개념

2.1 프레임(Frame)이란?

Flutter는 초당 60프레임(60fps) 또는 120프레임(120fps)으로 화면을 갱신합니다.

1 프레임 = 약 16.67ms (60fps 기준)

Frame Lifecycle:
┌─────────────────────────────────────┐
│ 1. Begin Frame                      │
│    - scheduleFrame() 호출됨          │
├─────────────────────────────────────┤
│ 2. Transient Frame Callbacks        │
│    - addPersistentFrameCallback()   │
├─────────────────────────────────────┤
│ 3. Build Phase                      │
│    - build() 메서드 실행             │
├─────────────────────────────────────┤
│ 4. Layout Phase                     │
│    - 위젯 크기 및 위치 계산          │
├─────────────────────────────────────┤
│ 5. Paint Phase                      │
│    - 실제 픽셀 그리기                │
├─────────────────────────────────────┤
│ 6. End Frame                        │
│    - addPostFrameCallback() 실행 ◄─ 여기!
└─────────────────────────────────────┘

2.2 콜백(Callback) 타이밍

// 타이밍 다이어그램
Time ─────────────────────────────────────▶

Frame N-1  Frame N      Frame N+1
    │         │             │
    │    ┌────┴────┐        │
    │    │ build() │        │
    │    └────┬────┘        │
    │         │             │
    │    ┌────┴────┐        │
    │    │ layout()│        │
    │    └────┬────┘        │
    │         │             │
    │    ┌────┴────┐        │
    │    │ paint() │        │
    │    └────┬────┘        │
    │         │             │
    │         ▼             │
    │  postFrameCallback    │
    │         │             │
    └─────────┼─────────────┘
              │
          다음 프레임 시작

3. 주요 메서드 상세 가이드

3.1 addPostFrameCallback()

개요

가장 많이 사용되는 메서드로, 현재 프레임의 렌더링이 완료된 직후에 한 번만 실행됩니다.

시그니처

void addPostFrameCallback(FrameCallback callback)

typedef FrameCallback = void Function(Duration timeStamp)

사용 시나리오

시나리오 1: Riverpod Provider 호출 (생명주기 오류 방지)
class _MyPageState extends ConsumerState<MyPage> {
  @override
  void initState() {
    super.initState();

    // ✅ 올바른 패턴: postFrameCallback 사용
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        // 위젯 트리 빌드가 완료된 후 실행
        ref.read(myProvider.notifier).loadData();
      }
    });

    // ❌ 잘못된 패턴: 직접 호출
    // ref.read(myProvider.notifier).loadData(); 
    // → "Tried to modify a provider while widget tree was building" 오류
  }
}
시나리오 2: 위젯 크기 측정
class MeasureWidget extends StatefulWidget {
  @override
  State<MeasureWidget> createState() => _MeasureWidgetState();
}

class _MeasureWidgetState extends State<MeasureWidget> {
  final GlobalKey _key = GlobalKey();
  Size? _widgetSize;

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      // ✅ 위젯이 완전히 렌더링된 후 크기 측정
      final RenderBox box = _key.currentContext!.findRenderObject() as RenderBox;
      setState(() {
        _widgetSize = box.size;
      });
      print('위젯 크기: ${_widgetSize!.width} x ${_widgetSize!.height}');
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          key: _key,
          width: 200,
          height: 100,
          color: Colors.blue,
          child: const Text('측정할 위젯'),
        ),
        if (_widgetSize != null)
          Text('크기: ${_widgetSize!.width} x ${_widgetSize!.height}'),
      ],
    );
  }
}
시나리오 3: 스크롤 위치 자동 이동
class AutoScrollPage extends StatefulWidget {
  @override
  State<AutoScrollPage> createState() => _AutoScrollPageState();
}

class _AutoScrollPageState extends State<AutoScrollPage> {
  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      // ✅ 리스트가 완전히 빌드된 후 스크롤
      _scrollController.animateTo(
        500.0,
        duration: const Duration(milliseconds: 500),
        curve: Curves.easeOut,
      );
    });
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: 100,
      itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
    );
  }
}

주의사항

// ❌ 잘못된 사용: mounted 체크 없음
WidgetsBinding.instance.addPostFrameCallback((_) {
  setState(() {}); // 위젯이 dispose되었을 수 있음!
});

// ✅ 올바른 사용: mounted 체크
WidgetsBinding.instance.addPostFrameCallback((_) {
  if (mounted) {
    setState(() {});
  }
});

3.2 addPersistentFrameCallback()

개요

매 프레임마다 영구적으로 실행되는 콜백을 등록합니다. 제거할 수 없으므로 주의해서 사용해야 합니다.

시그니처

void addPersistentFrameCallback(FrameCallback callback)

사용 시나리오

시나리오 1: 커스텀 애니메이션
class CustomAnimationWidget extends StatefulWidget {
  @override
  State<CustomAnimationWidget> createState() => _CustomAnimationWidgetState();
}

class _CustomAnimationWidgetState extends State<CustomAnimationWidget> {
  double _rotation = 0.0;
  bool _isActive = true;

  @override
  void initState() {
    super.initState();

    WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
      // ✅ 매 프레임마다 실행
      if (!_isActive || !mounted) return;

      setState(() {
        _rotation += 0.01; // 매 프레임마다 회전
        if (_rotation > 2 * 3.14159) _rotation = 0;
      });
    });
  }

  @override
  void dispose() {
    _isActive = false; // 플래그로 제어
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Transform.rotate(
      angle: _rotation,
      child: Container(
        width: 100,
        height: 100,
        color: Colors.red,
      ),
    );
  }
}
시나리오 2: 실시간 성능 모니터링
class PerformanceMonitor {
  static void init() {
    int frameCount = 0;
    DateTime lastSecond = DateTime.now();

    WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
      frameCount++;

      final now = DateTime.now();
      if (now.difference(lastSecond).inSeconds >= 1) {
        print('FPS: $frameCount'); // 초당 프레임 수 출력
        frameCount = 0;
        lastSecond = now;
      }
    });
  }
}

// main.dart에서 호출
void main() {
  WidgetsFlutterBinding.ensureInitialized();
  PerformanceMonitor.init();
  runApp(MyApp());
}

주의사항

⚠️ 제거 불가능: 한 번 등록하면 앱이 종료될 때까지 계속 실행됩니다!

// 해결책: 플래그로 제어
bool _isRunning = true;

@override
void initState() {
  WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
    if (!_isRunning || !mounted) return; // ✅ 플래그로 중지
    // ... 작업
  });
}

@override
void dispose() {
  _isRunning = false; // ✅ 플래그 설정
  super.dispose();
}

3.3 scheduleFrameCallback()

개요

다음 프레임에서 한 번만 실행되는 콜백을 등록합니다. addPostFrameCallback과 유사하지만, 현재 프레임이 아닌 다음 프레임에서 실행됩니다.

시그니처

int scheduleFrameCallback(FrameCallback callback, {bool rescheduling = false})

사용 시나리오

void _delayedUpdate() {
  // ✅ 다음 프레임에서 실행
  WidgetsBinding.instance.scheduleFrameCallback((timeStamp) {
    if (mounted) {
      setState(() {
        // 상태 업데이트
      });
    }
  });
}

addPostFrameCallback과의 차이

// 타이밍 비교
Frame N:
  build()
  layout()
  paint()
  addPostFrameCallback() ◄── 여기 (Frame N 끝)

Frame N+1:
  scheduleFrameCallback() ◄── 여기 (Frame N+1 시작)
  build()
  layout()
  paint()

3.4 ensureInitialized()

개요

WidgetsBinding을 명시적으로 초기화합니다. runApp() 전에 Flutter 엔진 기능을 사용해야 할 때 필수입니다.

시그니처

static WidgetsBinding ensureInitialized()

사용 시나리오

시나리오 1: Firebase 초기화
void main() async {
  // ✅ Flutter 엔진 초기화
  WidgetsFlutterBinding.ensureInitialized();

  // 이제 플랫폼 채널 사용 가능
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(MyApp());
}
시나리오 2: 화면 방향 고정
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // ✅ 세로 모드만 허용
  await SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
    DeviceOrientation.portraitDown,
  ]);

  runApp(MyApp());
}
시나리오 3: SharedPreferences 초기화
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // ✅ 앱 시작 전 설정 로드
  final prefs = await SharedPreferences.getInstance();
  final isDarkMode = prefs.getBool('dark_mode') ?? false;

  runApp(MyApp(isDarkMode: isDarkMode));
}
시나리오 4: 네이티브 스플래시 유지
void main() {
  // ✅ 바인딩 초기화 및 스플래시 유지
  final binding = WidgetsFlutterBinding.ensureInitialized();
  FlutterNativeSplash.preserve(widgetsBinding: binding);

  runApp(MyApp());
}

왜 필요한가?

// runApp() 내부 코드 (간략화)
void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized() // ◄── 자동 호출
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

runApp()이 자동으로 호출하지만, 그 이전에 플랫폼 기능을 사용하려면 명시적 호출 필요!


3.5 waitUntilFirstFrameRasterized()

개요

첫 프레임이 실제로 화면에 완전히 그려질 때까지 대기하는 Future를 반환합니다.

시그니처

Future<void> waitUntilFirstFrameRasterized()

사용 시나리오

시나리오 1: 네이티브 스플래시 제거
void main() async {
  final binding = WidgetsFlutterBinding.ensureInitialized();
  FlutterNativeSplash.preserve(widgetsBinding: binding);

  runApp(MyApp());

  // ✅ 첫 화면이 완전히 그려진 후 스플래시 제거
  await WidgetsBinding.instance.waitUntilFirstFrameRasterized();
  FlutterNativeSplash.remove();
}
시나리오 2: 앱 로딩 완료 분석 이벤트
void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final startTime = DateTime.now();

  runApp(MyApp());

  await WidgetsBinding.instance.waitUntilFirstFrameRasterized();

  final loadTime = DateTime.now().difference(startTime);

  // ✅ 앱 로딩 시간 분석
  FirebaseAnalytics.instance.logEvent(
    name: 'app_load_time',
    parameters: {'duration_ms': loadTime.inMilliseconds},
  );
}

프레임 렌더링 파이프라인

runApp() 호출
    ↓
첫 프레임 빌드 시작
    ↓
Layout 계산
    ↓
Paint (메모리에 그리기)
    ↓
Rasterization (GPU로 전송) ◄── waitUntilFirstFrameRasterized()가 대기하는 지점
    ↓
화면에 표시 완료
    ↓
Future 완료 ✓

3.6 addObserver() / removeObserver()

개요

앱 생명주기 이벤트를 감지하는 옵저버를 등록/제거합니다.

시그니처

void addObserver(WidgetsBindingObserver observer)
void removeObserver(WidgetsBindingObserver observer)

사용 시나리오

시나리오 1: 백그라운드 진입 시 타이머 중지
class TimerPage extends StatefulWidget {
  @override
  State<TimerPage> createState() => _TimerPageState();
}

class _TimerPageState extends State<TimerPage> 
    with WidgetsBindingObserver {

  Timer? _timer;
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    // ✅ 옵저버 등록
    WidgetsBinding.instance.addObserver(this);
    _startTimer();
  }

  @override
  void dispose() {
    // ✅ 옵저버 제거 (메모리 누수 방지)
    WidgetsBinding.instance.removeObserver(this);
    _timer?.cancel();
    super.dispose();
  }

  void _startTimer() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      setState(() => _counter++);
    });
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        print('앱 포그라운드 복귀');
        _startTimer(); // ✅ 타이머 재시작
        break;

      case AppLifecycleState.paused:
        print('앱 백그라운드 진입');
        _timer?.cancel(); // ✅ 타이머 중지 (배터리 절약)
        break;

      case AppLifecycleState.inactive:
        print('앱 비활성 (전화 수신 등)');
        break;

      case AppLifecycleState.detached:
        print('앱 종료 준비');
        break;

      case AppLifecycleState.hidden:
        print('앱 숨겨짐');
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('Counter: $_counter')),
    );
  }
}
시나리오 2: 포그라운드 복귀 시 데이터 새로고침
class DataListPage extends ConsumerStatefulWidget {
  @override
  ConsumerState<DataListPage> createState() => _DataListPageState();
}

class _DataListPageState extends ConsumerState<DataListPage>
    with WidgetsBindingObserver {

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      // ✅ 포그라운드 복귀 시 데이터 새로고침
      ref.read(dataProvider.notifier).refresh();
    }
  }

  @override
  Widget build(BuildContext context) {
    final data = ref.watch(dataProvider);
    return ListView(...);
  }
}
시나리오 3: 앱 생명주기 전역 관리
class AppLifecycleManager extends StatefulWidget {
  final Widget child;

  const AppLifecycleManager({required this.child, super.key});

  @override
  State<AppLifecycleManager> createState() => _AppLifecycleManagerState();
}

class _AppLifecycleManagerState extends State<AppLifecycleManager>
    with WidgetsBindingObserver {

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // ✅ 전역 생명주기 이벤트 처리
    switch (state) {
      case AppLifecycleState.paused:
        // 모든 네트워크 요청 취소
        // 민감한 데이터 암호화
        _handleBackground();
        break;

      case AppLifecycleState.resumed:
        // 세션 유효성 검증
        // 캐시 업데이트
        _handleForeground();
        break;

      default:
        break;
    }
  }

  void _handleBackground() {
    // 백그라운드 처리 로직
  }

  void _handleForeground() {
    // 포그라운드 복귀 로직
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

// main.dart에서 앱 전체를 감싸기
void main() {
  runApp(
    AppLifecycleManager(
      child: MyApp(),
    ),
  );
}

AppLifecycleState 상세 설명

상태 의미 발생 시점
resumed 앱이 포그라운드에서 실행 중 앱 시작, 백그라운드에서 복귀
inactive 앱이 비활성 상태 전화 수신, 시스템 대화상자 표시
paused 앱이 백그라운드로 이동 홈 버튼, 앱 전환
detached 앱이 종료 준비 중 앱 종료 직전
hidden 앱이 숨겨짐 (iOS/Android 일부) 멀티태스킹 화면 등

3.7 scheduleWarmUpFrame()

개요

"워밍업 프레임"을 실행하여 첫 프레임 렌더링 성능을 최적화합니다.

시그니처

void scheduleWarmUpFrame()

사용 시나리오

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // ✅ 첫 프레임을 미리 준비
  WidgetsBinding.instance.scheduleWarmUpFrame();

  runApp(MyApp());
}

작동 원리

일반적인 앱 시작:
  runApp() → 첫 프레임 빌드 → 렌더링 (약 100-200ms)
  ↓
  사용자가 흰 화면을 잠깐 봄

scheduleWarmUpFrame() 사용:
  워밍업 프레임 → runApp() → 첫 프레임 빌드 → 렌더링 (약 50-100ms)
  ↓
  더 빠른 초기 렌더링

3.8 handleAppLifecycleStateChanged()

개요

앱 생명주기 상태를 수동으로 변경합니다. 주로 테스트 코드에서 사용됩니다.

시그니처

@protected
void handleAppLifecycleStateChanged(AppLifecycleState state)

사용 시나리오 (테스트)

testWidgets('백그라운드 진입 시 타이머 중지 테스트', (tester) async {
  await tester.pumpWidget(TimerPage());

  // ✅ 백그라운드 상태 시뮬레이션
  tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
  await tester.pump();

  // 타이머가 중지되었는지 검증
  expect(find.text('타이머 중지됨'), findsOneWidget);

  // ✅ 포그라운드 복귀 시뮬레이션
  tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
  await tester.pump();

  expect(find.text('타이머 실행 중'), findsOneWidget);
});

4. 실전 사용 패턴

4.1 Riverpod과 함께 사용하기

패턴: initState에서 안전하게 Provider 호출

class ProductListPage extends ConsumerStatefulWidget {
  @override
  ConsumerState<ProductListPage> createState() => _ProductListPageState();
}

class _ProductListPageState extends ConsumerState<ProductListPage> {
  @override
  void initState() {
    super.initState();

    // ✅ postFrameCallback으로 생명주기 오류 방지
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        ref.read(productProvider.notifier).loadProducts();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final products = ref.watch(productProvider);

    return products.when(
      data: (data) => ListView.builder(...),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('Error: $err'),
    );
  }
}

4.2 위젯 크기 측정 및 동적 레이아웃

패턴: 자식 위젯 크기에 따라 부모 조정

class DynamicHeightContainer extends StatefulWidget {
  final Widget child;

  const DynamicHeightContainer({required this.child, super.key});

  @override
  State<DynamicHeightContainer> createState() => _DynamicHeightContainerState();
}

class _DynamicHeightContainerState extends State<DynamicHeightContainer> {
  final GlobalKey _childKey = GlobalKey();
  double _childHeight = 0;

  @override
  void initState() {
    super.initState();
    _measureChild();
  }

  void _measureChild() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;

      final RenderBox? box = _childKey.currentContext?.findRenderObject() as RenderBox?;
      if (box != null) {
        setState(() {
          _childHeight = box.size.height;
        });
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: _childHeight > 0 ? _childHeight + 20 : null, // 패딩 추가
      decoration: BoxDecoration(
        border: Border.all(color: Colors.blue),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Padding(
        padding: const EdgeInsets.all(10),
        child: KeyedSubtree(
          key: _childKey,
          child: widget.child,
        ),
      ),
    );
  }
}

4.3 main() 함수에서 초기화

패턴: 완벽한 앱 초기화 시퀀스

void main() async {
  // 1. Flutter 바인딩 초기화
  final binding = WidgetsFlutterBinding.ensureInitialized();

  // 2. 네이티브 스플래시 유지
  FlutterNativeSplash.preserve(widgetsBinding: binding);

  // 3. 화면 방향 고정
  await SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
  ]);

  // 4. 상태 바 스타일 설정
  SystemChrome.setSystemUIOverlayStyle(
    const SystemUiOverlayStyle(
      statusBarColor: Colors.transparent,
      statusBarIconBrightness: Brightness.dark,
    ),
  );

  // 5. Firebase 초기화
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 6. 로컬 저장소에서 설정 로드
  final prefs = await SharedPreferences.getInstance();
  final String? token = prefs.getString('auth_token');

  // 7. 앱 실행
  runApp(MyApp(initialToken: token));

  // 8. 첫 프레임 렌더링 대기
  await binding.waitUntilFirstFrameRasterized();

  // 9. 스플래시 제거
  FlutterNativeSplash.remove();

  // 10. 앱 로딩 시간 분석
  FirebaseAnalytics.instance.logEvent(name: 'app_loaded');
}

4.4 앱 생명주기 전역 관리

패턴: Provider와 함께 생명주기 관리

// 1. 생명주기 상태를 관리하는 Provider
final appLifecycleProvider = StateProvider<AppLifecycleState?>((ref) => null);

// 2. 생명주기 관리 위젯
class AppLifecycleProvider extends ConsumerStatefulWidget {
  final Widget child;

  const AppLifecycleProvider({required this.child, super.key});

  @override
  ConsumerState<AppLifecycleProvider> createState() => _AppLifecycleProviderState();
}

class _AppLifecycleProviderState extends ConsumerState<AppLifecycleProvider>
    with WidgetsBindingObserver {

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // Provider 상태 업데이트
    ref.read(appLifecycleProvider.notifier).state = state;

    // 전역 처리
    if (state == AppLifecycleState.paused) {
      // 모든 타이머 중지
      ref.read(timerManagerProvider.notifier).pauseAll();
      // 네트워크 요청 취소
      ref.read(networkManagerProvider.notifier).cancelAll();
    } else if (state == AppLifecycleState.resumed) {
      // 타이머 재시작
      ref.read(timerManagerProvider.notifier).resumeAll();
      // 데이터 새로고침
      ref.read(dataProvider.notifier).refresh();
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

// 3. main.dart에서 사용
void main() {
  runApp(
    ProviderScope(
      child: AppLifecycleProvider(
        child: MyApp(),
      ),
    ),
  );
}

// 4. 다른 위젯에서 생명주기 상태 구독
class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final lifecycleState = ref.watch(appLifecycleProvider);

    return Text('앱 상태: ${lifecycleState?.toString() ?? "Unknown"}');
  }
}

4.5 성능 모니터링

패턴: FPS 측정 및 로깅

class FPSMonitor {
  static int _frameCount = 0;
  static DateTime _lastSecond = DateTime.now();
  static final List<int> _fpsHistory = [];

  static void start() {
    WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
      _frameCount++;

      final now = DateTime.now();
      final diff = now.difference(_lastSecond);

      if (diff.inSeconds >= 1) {
        final fps = _frameCount;
        _fpsHistory.add(fps);

        // 최근 10초 평균 FPS 계산
        if (_fpsHistory.length > 10) {
          _fpsHistory.removeAt(0);
        }
        final avgFps = _fpsHistory.reduce((a, b) => a + b) / _fpsHistory.length;

        // 로깅
        debugPrint('Current FPS: $fps | Avg FPS: ${avgFps.toStringAsFixed(1)}');

        // 성능 저하 감지
        if (avgFps < 50) {
          debugPrint('⚠️ 성능 저하 감지! 평균 FPS: ${avgFps.toStringAsFixed(1)}');
        }

        _frameCount = 0;
        _lastSecond = now;
      }
    });
  }

  static List<int> get fpsHistory => _fpsHistory;
}

// main.dart에서 활성화
void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // Debug 모드에서만 FPS 모니터링
  if (kDebugMode) {
    FPSMonitor.start();
  }

  runApp(MyApp());
}

5. 트러블슈팅

5.1 흔한 오류와 해결책

오류 1: "Tried to modify a provider while widget tree was building"

원인: initState()에서 직접 Provider 호출

// ❌ 잘못된 코드
@override
void initState() {
  super.initState();
  ref.read(myProvider.notifier).loadData(); // 오류!
}

// ✅ 올바른 코드
@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) {
      ref.read(myProvider.notifier).loadData();
    }
  });
}

오류 2: "setState() called after dispose()"

원인: 비동기 콜백에서 mounted 체크 누락

// ❌ 잘못된 코드
WidgetsBinding.instance.addPostFrameCallback((_) {
  setState(() {}); // 위젯이 이미 dispose되었을 수 있음!
});

// ✅ 올바른 코드
WidgetsBinding.instance.addPostFrameCallback((_) {
  if (mounted) {
    setState(() {});
  }
});

오류 3: "PlatformException: Binding not initialized"

원인: runApp() 전에 플랫폼 기능 사용

// ❌ 잘못된 코드
void main() async {
  await Firebase.initializeApp(); // 오류!
  runApp(MyApp());
}

// ✅ 올바른 코드
void main() async {
  WidgetsFlutterBinding.ensureInitialized(); // 먼저 초기화!
  await Firebase.initializeApp();
  runApp(MyApp());
}

오류 4: "Cannot add observer after dispose"

원인: dispose() 후 옵저버 제거 누락

// ❌ 잘못된 코드
class _MyState extends State<MyWidget> with WidgetsBindingObserver {
  @override
  void initState() {
    WidgetsBinding.instance.addObserver(this);
  }

  // dispose에서 제거 안 함!
}

// ✅ 올바른 코드
class _MyState extends State<MyWidget> with WidgetsBindingObserver {
  @override
  void initState() {
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this); // 필수!
    super.dispose();
  }
}

5.2 디버깅 팁

팁 1: 프레임 콜백 로깅

void debugFrameCallbacks() {
  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
    debugPrint('[PostFrame] Callback executed at $timeStamp');
  });

  WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
    debugPrint('[PersistentFrame] Frame rendered at $timeStamp');
  });
}

팁 2: 생명주기 상태 추적

class LifecycleDebugger with WidgetsBindingObserver {
  void init() {
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    debugPrint('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
    debugPrint('App Lifecycle Changed: $state');
    debugPrint('Timestamp: ${DateTime.now()}');
    debugPrint('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
  }
}

// main.dart
void main() {
  WidgetsFlutterBinding.ensureInitialized();

  if (kDebugMode) {
    LifecycleDebugger().init();
  }

  runApp(MyApp());
}

6. 성능 최적화

6.1 불필요한 콜백 제거

// ❌ 성능 저하: 매 프레임마다 복잡한 계산
WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
  // 매우 무거운 작업
  _expensiveComputation();
});

// ✅ 최적화: 필요할 때만 실행
bool _isComputationNeeded = true;

WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
  if (!_isComputationNeeded) return; // 조기 종료

  _expensiveComputation();
  _isComputationNeeded = false; // 플래그 설정
});

6.2 프레임 드롭 감지

class FrameDropDetector {
  static Duration? _lastFrameTime;
  static const Duration _targetFrameTime = Duration(milliseconds: 16); // 60fps

  static void start() {
    WidgetsBinding.instance.addPersistentFrameCallback((timeStamp) {
      if (_lastFrameTime != null) {
        final frameDuration = timeStamp - _lastFrameTime!;

        if (frameDuration > _targetFrameTime * 2) {
          debugPrint('⚠️ Frame drop detected: ${frameDuration.inMilliseconds}ms');
        }
      }

      _lastFrameTime = timeStamp;
    });
  }
}

6.3 메모리 누수 방지 체크리스트

  • addObserver()를 사용했다면 removeObserver() 호출했는가?
  • addPersistentFrameCallback() 사용 시 플래그로 제어하는가?
  • 모든 비동기 콜백에 mounted 체크를 했는가?
  • dispose()에서 모든 리소스를 정리했는가?

7. 참고 자료

7.1 공식 문서

  1. WidgetsBinding API 문서
  2. WidgetsBindingObserver API 문서
  3. SchedulerBinding API 문서
  4. Flutter 아키텍처 개요
  5. Flutter 성능 최적화 가이드

7.2 심화 학습

  1. Flutter YouTube - The Boring Show
  2. Flutter Cookbook
  3. Flutter Performance Profiling

7.3 커뮤니티

  1. Stack Overflow - Flutter WidgetsBinding
  2. Flutter Dev Community
  3. Flutter Community Medium

7.4 프로젝트 내 참고 파일

  • .github/troubleshooting/riverpod_lifecycle_error_fix.md - Riverpod 생명주기 오류 해결
  • .github/data_fetching_strategy.md - Provider 사용 전략
  • .github/copilot-instructions.md - 프로젝트 컨벤션

부록

A. 전체 메서드 요약표

메서드 실행 시점 실행 횟수 주요 용도 제거 가능
addPostFrameCallback 현재 프레임 끝 1회 initState에서 context 접근 자동
scheduleFrameCallback 다음 프레임 1회 프레임 지연 실행 자동
addPersistentFrameCallback 매 프레임 영구적 애니메이션, 게임 루프 불가
ensureInitialized main() 시작 시 1회 플랫폼 API 초기화 -
waitUntilFirstFrameRasterized 첫 프레임 완료 후 1회 스플래시 제거 -
addObserver 생명주기 변화 시 이벤트마다 백그라운드/포그라운드 감지 필수
scheduleWarmUpFrame 앱 시작 시 1회 초기 렌더링 최적화 -

B. 빠른 참조 코드

B.1 기본 템플릿

import 'package:flutter/material.dart';

class MyPage extends StatefulWidget {
  @override
  State<MyPage> createState() => _MyPageState();
}

class _MyPageState extends State<MyPage> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();

    // 생명주기 옵저버 등록
    WidgetsBinding.instance.addObserver(this);

    // 프레임 완료 후 초기화
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        _initialize();
      }
    });
  }

  @override
  void dispose() {
    // 옵저버 제거 (필수!)
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // 생명주기 이벤트 처리
  }

  void _initialize() {
    // 초기화 로직
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('My Page')),
      body: Container(),
    );
  }
}

B.2 Riverpod 템플릿

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class MyPage extends ConsumerStatefulWidget {
  @override
  ConsumerState<MyPage> createState() => _MyPageState();
}

class _MyPageState extends ConsumerState<MyPage> 
    with WidgetsBindingObserver {

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted) {
        // ✅ 안전한 Provider 호출
        ref.read(myProvider.notifier).loadData();
      }
    });
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      ref.read(myProvider.notifier).refresh();
    }
  }

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(myProvider);

    return Scaffold(
      body: state.when(
        data: (data) => ListView(...),
        loading: () => CircularProgressIndicator(),
        error: (err, stack) => Text('Error: $err'),
      ),
    );
  }
}
728x90
반응형
LIST