[Flutter+GetX] 다이얼로그는 왜 onInit에서 못 쓰지? - 1 frame의 차이

Broccolism·2021년 12월 12일
11
post-thumbnail

GetX를 사용하지 않는 분들을 위해: GetX의 onInit 함수는 flutter의 initState와 비슷한 역할을 합니다. 아래 글에서 onInit을 initState라고 생각하고 읽으셔도 무방합니다.

발단

Called 1 frame after onInit(). It is the perfect place to enter navigation events, like snackbar, dialogs, or a new route, or async request.

GetX 문서와 주석에 나와있는 onReady 함수에 대한 설명이다. 첫 문장부터 막혔다. "1 frame after onInit()." "1 frame"이 도대체 어떤 역할을 하는거지? 갑자기 왜 튀어나온거야?

"GetX onInit onReady difference"
"GetX onReady onInit frame"

아무리 구글링을 해봐도 안 나온다. 심지어 공식 문서에서도 차이점을 자세히 다루지 않는다. 그냥 "onInit이 실행된 다음 1 프레임 후에 실행되는 함수라서 다이얼로그 같은걸 넣으면 좋아요~"라고만 해놓고 왜 그런지는 안 알려준다. 그래서 직접 찾아봤다. 왜 굳이 "1 프레임 뒤"여야 할까?

"1 frame"

이 단어가 생소한 이유는 아마도 그동안 모바일 개발을 하면서 앱의 동작을 프레임 단위로 쪼개서 볼 일이 없었기 때문일 것이다. 나도 그랬다. 이 글을 써야겠다고 마음먹기 전까지는...

1. 실험

onReady에서 다이얼로그를 띄워주면 좋다고 하길래, onInit에서는 어떻게 되는지 실험해봤다. 그런데 웬걸, 에러 메세지가 나타났다.

The BuildContext.visitChildElements() method can't be called during build because the child list is still being updated at that point, so the children might not be constructed yet, or might be old children that are going to be replaced.

build 도중에는 visitChildElements 라는 함수를 실행할 수 없다고 한다. 갑자기 내가 부르지도 않은 visitChildElements를 찾고 있다니. 그리고 child, element라는 용어가 나오는걸 보면 왠지 플러터의 위젯 렌더링 과정과 관련이 있다는 느낌이 확 들었다. '위젯을 객체화 시킨게 엘리먼트'라는 말을 들은 적이 있기 때문이었다.

2. 공식 문서 탐험

그렇다면 참을 수 없지. 당장 플러터 공식 문서를 뒤져봤다. 친절하게도 플러터의 위젯 렌더링 파이프라인에 대해 다룬 글이 나왔다. 심지어 플러터에서 공식적으로 작성한 글이다! 이게 웬 떡이람. 바로 글을 정독했다. (그리고나서 쓴 포스팅이 [Flutter] 이 코드.. 화면에 어떻게 그려질까? 렌더링 원리 - 1. 트리이다. 플러터의 위젯 렌더링 파이프라인에 대한 자세한 내용이 궁금하다면 들어가보자.)

아.. 그런데 잘 모르겠다. 플러터가 트리를 써서 위젯을 렌더링하기 때문에 에러 메세지에서 child 라는 용어가 나온 것까지는 알겠다. 그런데 그게 뭐 어때서? 'frame' 이라는 단어에 대한 힌트는 하나도 얻지 못했다. 다른 글이 더 필요했다.

3. 또다른 공식 문서 탐험

렌더링 과정은 어느정도 이해했으니 이제는 프레임을 직접적으로 언급하는 글이 필요했다. 왜 굳이 꼭 '1 프레임' 뒤여야 하는지를 알아내는게 목표였으니까. 그래서 렌더링 관련 내용을 다루는 또다른 문서에 들어가서 무작정 Ctrl + F를 갈기고 "frame"이라는 단어를 검색했다.

찾았다! frame이라는 단어를 2번이나 쓰는 문단을 발견했다.

Flutter performs one layout per frame, and the layout algorithm works in a single pass. (...) Importantly, once a render object has returned from its layout method, that render object will not be visited again1 until the layout for the next frame.

첫 문장에서 이거다!라는 생각이 들었다. layout 이라는 작업을 위에서 찾았던 공식 문서에서 봤기 때문이다. layout이 어떤식으로 이루어지는지도 짚고 넘어갔던 덕분에 이 문장만 보고 그동안 궁금했던 질문 - 왜 굳이 1 프레임이지?? - 에 대한 답변을 찾을 수 있겠다는 생각이 들었던 것 같다.

4. 정리 - 왜 굳이 1 프레임 뒤에 다이얼로그를 띄워야하지?

이 질문에 대한 대답을 하기 위해서는 내가 알고 있던 것과 새롭게 알게 된 사실 (layout 작업은 1 프레임당 1번 실행된다)을 정리할 필요가 있었다. 아니... 사실 그냥 왠지 그렇게 정리해서 잘 연결하면 나올 것 같았다.

이미 알고 있던 사실

  • 플러터의 렌더링 순서
    1. build 함수를 불러서 위젯 트리 생성
    2. 위젯 트리를 보고 엘리먼트 트리 생성
    3. 엘리먼트 트리를 보고 렌더 트리 생성
    4. layout 작업
    5. paint 작업
  • onInit에서 다이얼로그를 호출했을 때 났던 에러
    • 에러 메세지: build 도중에는 visitChildElements를 호출할 수 없다. parent 입장에서 child 목록을 모두 다 알 수 없는 시점이기 때문이다.
    • 발생 위치: Get.dialog 함수 안에서 GetNavigation.overlayContext를 찾는 코드가 있고, overlayContext를 찾기 위해서는 overlay.context.visitChildElements 함수를 호출하고 있었다.
  • onInit 함수에 대한 주석: 언제 실행되나?

    Called immediately after the widget is allocated in memory.
    = 위젯이 메모리에 할당되자마자 호출됨

새롭게 알게 된 사실

  • 플러터는 1 프레임을 그리기 위해서 layout 작업을 1번 실행한다.

조합

왜 onInit 도중에는 visitChildElement 함수를 실행할 수 없나?

➡️ onInit 함수는 자신을 부른 위젯이 메모리에 할당된 직후에 호출된다. 즉, 플러터가 렌더링을 위해 내부적으로 만드는 트리 중 하나인 위젯 트리를 아직 덜 완성했을 때 onInit을 호출한다. 그렇다면 엘리먼트 트리는 완성이 되었을 리가 없고, 엘리먼트 트리에서 자신의 child를 찾는 함수인 visitChildElement 함수는 당연히 쓸수 없다.

왜 다이얼로그를 띄우기 위해서는 visitChildElement가 필요했나?

➡️ 다이얼로그는 현재 유저가 보고 있는 화면 바로 위에 새로운 위젯을 삽입한 것이다. 이를 위해 현재 위젯의 overlayContext를 찾아서 그걸 들고 Navigator에다가 다이얼로그용 라우트를 push 해야한다. 아래는 overlayContext를 리턴하는 getter 함수의 코드다.

BuildContext? get overlayContext {
    BuildContext? overlay;
    key.currentState?.overlay?.context.visitChildElements((element) {
      overlay = element;
    });
    return overlay;
  }

여기서 visitChildElement 함수를 사용한다. 아쉽게도 visitChildElement 내부까지는 못 들어다보게 되어있다. 대신 어떤 일을 하는지 주석으로 적혀있다.

Walks the children of this widget.
= 대충 이 위젯의 child로 어떤게 있는지를 파악해서 뭔가 동작을 하는 함수인 것 같다.

아무튼 요약하자면 다이얼로그를 띄우기 위해서는 네비게이터에 새 라우트를 push 해야하는데 이 네비게이터를 찾기 위해 visitChildElements 함수가 필요하다.

그렇다면 왜 onReady에서는 다이얼로그를 띄울 수 있지?

➡️ 플러터는 1 프레임당 1번 레이아웃 작업을 실행한다. GetX에서는 onInit을 실행하고 딱 1 프레임을 보여준 다음에 onReady를 실행한다. 즉, onReady가 실행된 시점에는 레이아웃 작업을 이미 수행한 상태다. 레이아웃 작업을 수행하기 위해서는 플러터가 만드는 위젯 트리, 엘리먼트 트리, 렌더 트리가 모두 완성되어 있어야 한다.
다시 말해 onReady가 실행되는 시점에 이미 위젯 트리, 엘리먼트 트리, 렌더 트리를 모두 완성했기 때문에 이 때는 visitChildElment 를 실행해도 에러가 나지 않는다.

5. 그렇다면 그냥 순수 플러터에도 onReady 같은 함수가 있을거야: addPostFrameCallBack

그게 바로 addPostFrameCallBack 함수다. 이 친구의 역할은 이번 프레임이 끝난 다음에 콜백 함수를 실행해주는 것이다.

Schedule a callback for the end of this frame. Does not request a new frame.

GetX를 쓰지 않는다면 stateful widget의 initState 함수에서 이렇게 사용하면 된다.


void initState() {
  super.initState();
  SchedulerBinding.instance?.addPostFrameCallback((_) async {
    await showDialog<String>(
      context: context,
      builder: _dialogBuilder
    );
  });
}

코드를 좀 더 까보면 GetX의 onReady 함수도 이 함수로 구현되어있는걸 볼 수 있다.

abstract class DisposableInterface extends GetLifeCycle {
  /// Called immediately after the widget is allocated in memory.
  /// You might use this to initialize something for the controller.
  
  
  void onInit() {
    super.onInit();
    SchedulerBinding.instance?.addPostFrameCallback((_) => onReady());
  }

결론

  • onInit 함수는 위젯이 메모리에 할당된 직후 실행되고, onReady 함수는 그 후 1 프레임을 보여준 다음 실행된다.
  • 다이얼로그를 띄워주기 위해서는 현재 context에 접근해야하는데, 이는 렌더링 트리가 모두 완성된 다음에 가능한 일이다.
  • 플러터는 1 프레임을 보여주기 위해 레이아웃 작업을 1번 실행한다. 레이아웃 작업을 위해서는 렌더링 트리가 필요하기 때문에 1프레임이 지났다는건 이미 렌더링 트리가 완성되었음을 의미한다.
  • 따라서 onInit 실행 후 1 프레임 이상이 지나야 다이얼로그를 띄워줄 수 있다.
  • 같은 이유로 새 화면을 보여준 즉시 새 라우트를 추가하거나 스낵바를 보여주는 함수도 onReady 이후에 해주는게 좋다.

앞으로...

  • onReady 함수에 대한 설명 중 "navigation event"를 처리하기에 좋은 이유를 알아봤다. 다음에는 나머지 부분인 async event도 왜 onReady에서 해야 하는지 알아볼 예정이다.
profile
코드도 적고 그림도 그리고 글도 씁니다. 넓고 얕은 경험을 쌓고 있습니다.

2개의 댓글

comment-user-thumbnail
2022년 2월 17일

재밌게 읽었습니다.

1개의 답글