[플러터,flutter] setState() or markNeedsBuild() called during build. 오류 해결

박민준·2022년 1월 15일
3

이 글을 작성하기 직전까지 끊임없는 오류의 늪에 빠져 있었다.
나를 고생시킨 오류들...

이 오류를 해결하기 어려웠던 첫번째 이유는 도대체 어느 코드가 문제인지를 알 수가 없다는 것. 오류 코드를 쭉 읽어봐도 정확히 어느 포인트에서 문제가 발생했다는 것인지를 알 수가 없었다.

추가적으로 아래와 같은 지점 때문에 더 오류를 파악하기 어려웠다.

1) 꽤 긴 코드를 작성하고 난 뒤에 실행을 시켜보았기 때문에 정확히 어느 지점이 오류인지를 알 수가 없었음. ==> hot reload, hot restart를 코드 블럭 단위로 실행하는 습관을 들여야 할 듯.

2) hot reload만 하면 문제가 발견되지 않는 형태의 오류였음. hot restart를 했을 때 즉, 앱이 처음 실행될 때에만 발견 가능한 오류였다는 것! ==> 이런 경우는 처음이었지만, 초기 화면 위젯의 build 중간에 발생하는 오류여서 일부 코드만 수정한 뒤 실행하는 hot reload에서는 초기 화면 위젯 전체를 rebuild하지 않기 때문에 오류 발견이 되지 않았다.

3) 그래서 어쩔 때는 되고, 어쩔 때는 안되니 플러터 엔진 상의 오류라고 생각을 했고 마침 구글링을 해보니 그렇다는 글들이 많아서 다들 해결책을 껐다 키라고 하드라. 그래서 계속 삽질했다.

4) 제일 빡쳤던 지점인데 hot restart를 해서 오류가 떴다 -> 오류를 발생시킨 코드를 지워서 hot reload를 한다. -> 그럼 코드 상에 문제가 없어도 오류가 뜬다. 앞선 오류가 아마 build 상의 거대한 오류니깐 reload만 해서는 오류 해결이 안되는거겠지. 그러다 보니 정확히 어떤 코드가 문젠지를 파악할 수가 없었다. ( 심지어 body를 다 지우고 그냥 빈 Container로 넣어도 오류가 떴다...! 이러니 플러터 엔진 상의 버그라고 오해할 만도... )

이후 차분하게 어떤 코드가 문제인지를 코드 세세하게 고치고 hot reload가 아닌 hot restart 위주로 결과 확인하고.. 이 과정을 반복하면 알아냈다.

일단 문제 코드를 설명하기 위해서 전체적인 코드 구조와 프로그램 구성을 살펴보자.

class MainScreen extends GetView<MainController> {
  MainScreen({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: controller.onWillPop,
      child: Obx(()=>Scaffold(
        appBar: AppBar(
        .
        .
        생략
        .
          
        ) // AppBar,
        body: Container(
          padding: EdgeInsets.symmetric(horizontal: 0.05.sw),
          color: Color(0xFFf1f8f7),
          child: IndexedStack(
            index: controller.selectedIndex.value,
            children: [
              Navigator(
                  key: Get.nestedKey(0),
                  observers: [GetObserver(MiddleWare.observer0)],
                  onGenerateRoute: (settings){
                    return GetPageRoute(
                        page: () => HomeScreen()
                    );
                  }
              ),
              Navigator(
                  key: Get.nestedKey(1),
                  observers: [GetObserver(MiddleWare.observer1)],
                  onGenerateRoute: (settings){
                    return GetPageRoute(
                        page: () => ScheduleScreen()
                    );
                  }
              ),
              Navigator(
                  key: Get.nestedKey(2),
                  observers: [GetObserver(MiddleWare.observer2)],
                  onGenerateRoute: (settings){
                    return GetPageRoute(
                        page: () => ProfileScreen()
                    );
                  }
              ),
              Navigator(
                  key: Get.nestedKey(3),
                  observers: [GetObserver(MiddleWare.observer3)],
                  onGenerateRoute: (settings){
                    return GetPageRoute(
                        page: () => FeedScreen()
                    );
                  }
              )
            ],
          ),
        ),
        bottomNavigationBar: BottomNavigationBar(
          .
          .
          생략
          .
          .
          ) // BottomNavigationBar
}

잔바리 위젯은 제끼고 핵심만 설명하면 Scaffold 내부 body에 IndexedStack으로 4개의 화면을 넣어줬다. 전형적인 바텀 네비게이션 바 활용한 화면 형식이다. 근데 화면 구성에 단순 위젯을 넣는 게 아니라 Navigator 위젯을 다 넣어준다. NestedNavigation을 하기 위한 목적이다. (이것도 구현하느라 삽질을 많이 해서 추후에 글로 써봐도 좋겠다.)

그렇다면 이제 Navigator 위젯 안을 잘 들여다 보자. 문제가 되는 부분은 observers였다.
observers를 추가해준 이유는 화면 변경시마다 업데이트해야 하는 변수가 있었기 때문이다. 그래서 observers안에 getXcontroller 활용해서 특정 변수를 변경시키는 콜백을 넣어놨다. 아래 코드를 참고하자

class MiddleWare {
// 이 클래스 자체에 큰 의미는 없고 그냥 옵저버 콜백을 관리하기 위한 용도다.
// getX observer 활용 공식 문서에서 이렇게 했길래 이렇게 했다. 
  static observer0 (Routing? routing) {
    Get.find<MainController>().changeCanPop(0);
  }
  static observer1 (Routing? routing) {
    Get.find<MainController>().changeCanPop(1);
  }
  static observer2 (Routing? routing) {
    Get.find<MainController>().changeCanPop(2);
  }
  static observer3 (Routing? routing) {
    Get.find<MainController>().changeCanPop(3);
  }
}

문제가 되는 부분은 콜백이 되는 changeCanPop이다. 해당 함수에 대한 코드도 살펴보자

MainController 내부 메소드. 

void changeCanPop(int index) {
    final canPop = Get.nestedKey(index)!.currentState!.canPop();
    canPopEachNavigator[index] = canPop;
}

간단히 설명하면 index를 받아서 Get.nestedKey(index)를 호출하고 (이를 통해 해당하는 NestedNavigator를 불러온다.) 그 네비게이터가 현재 어떤 상태인지를 메소드로 불러온 뒤 그 상태를 RxList 변수인 canPopEachNavigator에 업데이트 해주는 것!

이게 왜 문제가 되느냐! 구체적으로는 canPopEachNavigator[index] = canPop; 이 부분이 문제다. getX는 잘은 모르지만.. 구글링한 걸 바탕으로 설명하자면 변수값이 변경될 때 setState를 해준다. 아마 내부적으로 해주겠지 싶다. 근데 이 setState가 위젯 빌드가 끝나지 않은 상태에서 실행되면 당연히 오류가 생긴다. setState 메소드는 빌드를 다시 하라고 하는 명령인데 첫번째 빌드도 안 끝났는데 또 하라 하니 오류가 생기는 것...

문제 발생과정
1. MainScreen 화면 빌드 중
2. Naviagator의 옵저버를 반영하는데, 옵저버가 컨트롤러 통해서 값 변경을 명령.
3. 빌드 안 끝났는데 setState실행
4. 또 빌드하라고? 첫번째 빌드도 안 했는데?
5. 때려쳐!

setState() or markNeedsBuild() called during build.

오류 구문을 다시 읽어보니 정말 직관적이었다는 것을 깨달았다. 처음엔 이게 무슨 개소린지 싶었는데...

아마 getX 처음 배울 때 setState에서 탈출하게 해준다고 지들 자랑을 엄청 하지 않는가... 그러니 getX랑은 상관 없는 오류라고 생각했는데 알고 보니 내부적으론 다 setState를 활용했던 것이다...!

이 과정을 이해하니 왜 처음에 hot reload만 하면 문제가 발견이 안됐는지를 알 수 있었다. 문제가 되는 옵저버 코드를 추가해도 이미 초기 화면 빌드가 끝난 상태니깐 아무 문제가 없었던 것.

그럼 이걸 어떻게 해결했느냐!
도움을 받은 게시글...
https://www.didierboelens.com/2019/04/addpostframecallback/
일단 이 글을 읽고 오시오..
이 오류와 직접적 연관이 있는 widget의 라이프사이클에 대해 다룬다.

WidgetsBinding.instance!.addPostFrameCallback 이 콜백은 무조건 빌드가 끝난 다음에 실행될 수 있도록 한다. 그러니 문제가 되는 부분을 위 콜백에 넣어준다면...! 해결되지 않을까 싶었다. 수정 사항은 아래와 같다.

그랬더니 드.디.어!!! 아주 자아아알 실행된다 ㅎㅎ

예히~ 삽질한 보람이 있었다. 삽질하는 동안 getX의 원리나 위젯 라이프사이클 등등 요상한 부분들에 대해 더 알게 되었다.

profile
코린이

0개의 댓글