본문 바로가기

일::개발

Flutter: "A TextEditingController was used after being disposed." feat. Get.offAllNamed()

Get.offAllNamed() 와 textEdigingController.dispose() 가 겹쳐서 나타난 헷갈리는 문제에 대한 리뷰

  1. TextFormFIeld를 가지고 있는 화면(InputScreen)이 있음. 여기서 입력한 주소로 이메일 발송 요청
  2. 이메일의 링크를 클릭하면 앱으로 돌아와서 특정한 화면(TargetScreen)으로 이동
  3. 타겟 화면(TargetScreen)에서 작업을 마치면 다시 TextFormField를 가지고 있는 화면(InputScreen)으로 이동

위와 같은 프로세스를 만들어야 했는데, 타겟 화면에서 InputScreen 으로 다시 이동하는 부분(3번)을 처리할 때 

`Get.offAllNamed`를 이용했는데, 예상치 못한 문제가 발견되었다.

 

InputScreen 에서 다른 화면으로 갔다가 다시 돌아오면

"A TextEditingController was used after being disposed." 

exception이 발생해버렸다.

 

도대체 이게 왜 나오는 것일까 한참 찾아봤더니 아래와 같은 복합적인 문제가 있었다.

 

1. InputScreenController의 onClose() 에서 textFormFieldController.dispose()를 호출

class InputScreenController extends GetxController {
  final textFieldController = TextEditingController();	// InputScreen의 사용하는 TextFormField Widget의 controller

  @override
  void onInit() {
    super.onInit();
  }

  @override
  void onClose() {
    textFieldController.dispose();	// controller가 삭제될 때 TextEditingController 객체도 dispose()
    super.onClose();
  }
}

C++ 습관이 아직도 남아서 textFieldController.dispose() 가 어떤 일을 하는지 확인하지 않고 자연스럽게 InputScreenController의 onClose()에서 호출했다.

 

2. Get.offAllNamed()가 예상과 다르게 동작

offAllNamed()는 새로운 페이지를 push 하고 stack을 모두 비우는 방식으로 동작한다. 그런데 Navigation Stack에 타겟 페이지가 이미 있는 경우에는 예상과 다른 문제가 발생한다.

 

0) Navigation Stack: (InputScreen(1) ==> TargetScreen)  // InputScreen이 history에 있는 상태에서

1) Get.offAllNamed('InputScreen');  // InputScreen 으로 offAllNamed() 하면

2) Navigation Stack: (InputScreen(1) ==> TargetScreen ==> InputScreen(2))  // InputScreen 이 추가된 후에

3) Navigation Stack: (InputScreen(2))  // 이전 히스토리 삭제

 

(3)번 과정에서 이전에 Navigation Stack에 있던 InputScreen(1) 을 제거할 때 InputScreen(1)의 textFieldController가 dispose되지 않고, 새로 생성된 InputScreen(2) 의 textFieldController가 dispose되는 황당한 일이 발생한다.

이후에 InputScreen(2) 에 다시 들어오면 이미 dispose된 textFieldController 에 접근하려고 한다는 Exception이 발생한다.

 

즉, Get.offAllNamed('InputScreen')을 호출하면 

InputScreenController::onInit() 
InputScreenController::onClose()

순서로 호출되는데, 당연히 새로운 객체(2)를 생성하고 예전 객체(1)을 삭제할 것이라고 예상했지만, 새로 생성된 controller 객체(2)의 onClose()가 호출되어버린다.

 

InputScreenController 의 onInit(), onClose()에 각각 로그를 찍어보면 아래와 같이 나온다.

 

I/flutter (26062): ### onInit() of InputScreenController(hashCode: 575403058) is called 
I/flutter (26062): ### onInit() of InputScreenController(hashCode: 443901899) is called 
I/flutter (26062): ### onClose() of InputScreenController(hashCode: 443901899) is called 

마지막에 575403058(history stack에 있던 컨트롤러 인스턴스)의 onClose()가 아니라 443901899(새로 만들어진 컨트롤러 인스턴스)의 onClose()가 호출되는 것이다.

 

해결책은 몇 가지가 있을 것 같다.

1. InputScreenController::onClose()에서 textFormFieldController.dispose() 를 호출하지 않음.

사실 TextEditingController.dispose() 를 굳이 호출하지 않아도 된다. Flutter가 알아서 GC 해준다.

멤버 controller를 명시적으로 dispose() 하지 않으면 used after being disposed 에러도 발생하지 않는다.

하지만 이것은 미봉책. 새로운 InputScreen에서 controller를 사용하려고 할 때 문제가 발생한다.

 

2. Get.offAllNamed 를 사용하지 않고 Navigator.pushNamedAndRemoveUntil() 을 사용

Navigator.pushNamedAndRemoveUntil() 은 정상적으로 동작한다. 

이 경우에는 InputScreenController 의 onClose()가 호출되지 않는 것에 유의하자.

 

3. 그 외에 상황에 따라 Get.offAllNamed 대신 Get.until 사용

history stack 을 보고 해당 페이지가 이미 있으면 Get.until을 사용하고 없으면 Get.offAllNamed을 사용하는 식으로 해도 될 것 같다. 단, history stack 을 감시하는 부분을 추가해야 한다.