Flutter error - The method ‘inheritFromWidgetOfExactType’ was called on null 에 대한 고찰

Flutter error - The method ‘inheritFromWidgetOfExactType’ was called on null 에 대한 고찰

Introduce 🛫

필자는 현재 회사에서 Flutter로 앱 개발을 하고있으며 Sentry라는 error tracking tool을 사용하여 실제 배포된 앱에서 발생하는 error을 tracking 하고있다. QA에 들어가니 sentry에서 알수없는 error report가 오기 시작했다.

Stack trace를 살펴보면 widget tree에서 AppState라는 ChangeNotifier를 찾는 inheritFromWidgetOfExactType이라는 메소드가 null에서 call 되었다고 한다. 뭔지 모르겠지만 코드를 한번 보자.

1
2
3
4
5
6
7
@override
void initState() {
super.initState();
SomeAsyncApiRequest().request().then((response) {
Provider.of(context).setData(response.data);
});
}

[비동기 요청을 하는 부분]

1
2
3
4
5
6
static T of(BuildContext context, {bool listen = true}) {
...
context.inheritFromWidgetOfExactType(type) as _Provider
...
return provider.value;
}

[widget tree에서 AppState를 찾는 부분]

위의 코드는 다음과 같은 과정을 거친다.

  1. Stateful Widget이 init 될 때 initState 메소드가 불린다.
  2. 비동기 API call을 한다.
  3. 응답이 오면 context를 통해 inherited widget인 AppState를 widget tree에서 찾는다.
  4. 응답으로 온 데이터를 set 한다.

이 코드를 짤때는 흐름상 아무런 문제가 될 부분이 없었고, 테스트도 잘되었다. 하지만 배포를 하고 실 사용자가 사용할때는 꽤나 자주 저 Error가 발생했다.

원인을 파악해 보니 다음과 같다. 🔦

  1. stateful widget이 생성되면서 비동기 요정을 하고 context를 통해 appState를 widget tree에서 불러오려고 했다.
  2. 사용자가 비동기 함수가 끝나기 전에 back button을 눌러서 뒤로 가버린다.
  3. widget이 unmount되어서 context가 사라졌고 글 제목과 같은 에러가 났다.

사실 여기까지만 파악해도 원인은 알고있고 해결할 수 있었다. AppState를 widget tree에서 context를 통해 찾을때 비동기 함수 바깥에서 call 하여 찾으면 됐고, 찾은 AppState를 비동기 함수내에서 사용하면 context를 안쓰기때문에 문제가 없었다.

1
2
3
4
5
6
7
@override
void initState() {
super.initState();
SomeAsyncApiRequest().request().then((response) {
AppState.of(context).setData(response.data);
});
}

이렇게 쓰던걸

1
2
3
4
5
6
7
8
@override
void initState() {
super.initState();
final appState = AppState.of(context);
SomeAsyncApiRequest().request().then((response) {
appState.setData(response.data);
});
}

이런식으로!

흠 뭔가 찝찝한데… 🤔

하지만 좀 더 깊은 부분이 궁금했다. 이 현상의 원인은 뭘까? context는 뭐고 widget tree는 무엇인가? 더 깊은 내용을 파고들어보자.


Stateless Widget과 Widget tree, Element tree 🎄

이 글을 보는 사람들은 Stateless Widget에 대한 기본 지식이 있다고 생각한다. 따라서 Stateless에 대한 기본적인 내용은 생략한다.

[ MyButton(Stateless Widget)의 코드와 UI 이미지 ]

코드를 보는게 쉬우니 먼저 코드 예시를 보자. MyButton이라는 Stateless Widget을 만들고 MyHomePage라는 StatefulWidget 안에 배치하였다. 이 widget들이 배치되어있는 형태를 잘 생각해보면 tree 형태이다.

Stateless widget은 어떻게 tree 형태를 이룰까? 🤔

위젯들은 그림과 같은 형태로 Widget tree를 이루며 Center의 자식으로 Column, Column의 자식으로 MyButton이 tree 형태를 이룬다. 하지만 사실 Widget은 설계도에 불가하다. 이 설계도를 바탕으로 실제로 화면에 보여지는 것은 따로있다. 바로 Element 이다.

Center widget, column은 엄밀히 말하면 stateless widget은 아니다. 하지만 이 글에 주제는 stateless이므로 그부분의 설명은 생략하고 widget tree, element tree에 마운트되는 단계를 살펴보려 한다.

  1. Center widget은 생성된 다음 element를 생성하며 그 element는 element tree에 mount 된다. 그 다음 자신의 child widget을 생성하고 widget tree에 마운트한다.

  2. Column widget도 Center widget과 같은 과정을 거친다.

  3. MyButton widget은 생성된 다음 element 인스턴스를 생성한다.

    Stateless Widget의 내부 코드를 보면 createElement라는 메소드가 존재하며, 위젯 생성시 내부적으로 call 된다.

  4. 생성된 element는 element tree에 mount되며, widget class를 reference로 들고있다. (위의 코드를 보면 StatelessElement를 생성할 때 StatelessWidget의 instance를 넣어주는 것을 볼 수 있다.)

이런식으로 설계도인 Widget들에 의해 생성된 element tree가 실제 화면상에 보여지며 user와 interaction 한다. element class의 코드를 조금 더 파보자.


[Flutter framework 내부의 StatelessElement class 코드]

  • element class의 멤버 변수인 widget은 앞서 생성자에 넣어줬던 stateless widget instance이다.
  • element도 build 메소드를 가지고 있는데, element가 element tree에 마운트될 때 call 된다. 자세히보면 widget의 build 메소드에 element instance를 넣어주고있는데, widget의 build 메소드를 보면 BuildContext라는 타입으로 받고있다. 즉, Context는 결국 Element인 것이다.
  • update method도 자세히 살펴보면 재미있는것이, newWidget을 인자값으로 받고있는데 type이 StatelessWidget이다. 즉 StatelessWidget은 rebuild 될때마다 재생성되지만 element는 재생성 되지 않으며 단지 업데이트를 할 뿐이다. 더 나아가서 Flutter가 내부적으로 효율적인 rebuild를 위해 고려한 부분을 볼 수있다. 마치 react의 virtual dom 같은 역할을 한다.

Stateful Widget 과 Widget tree, Element tree 🎄

이 글을 보는 사람들은 Stateful Widget에 대한 기본 지식이 있다고 생각한다. 따라서 Stateful Widget에 대한 기본적인 내용은 생략한다.

이번엔 앞의 코드를 조금 더 발전시켜서 각 버튼을 누르면 버튼 색이 🔵 -> 🔴 으로 변화하는 코드를 만들어보자.

예시를 보자. MyButton이라는 Stateful Widget을 만들고 MyHomePage라는 StatefulWidget 안에 배치하였다. 그리고 MyButton은 _buttonColor라는 state를 들고있으며 버튼을누르면 setState를 통해 _buttonColor state를 변경하고 해당 위젯을 rebuild 한다.

Stateful widget은 어떻게 tree 형태를 이룰까? 🤔

  1. MyButtonWidget이 먼저 생성된다. 그리고 StatefulElement가 생성된 후 element tree에 mount 된다. stateless와 동일하게 StatefulElement가 Widget의 인스턴스를 들고있다.

  2. MyButtonWidget이 State 인스턴스를 생성한다.

  3. MyButton widget의 child들이 순차적으로 생성되며 widget tree, element tree에 마운트 된다

Stateless와 다른점은

  • State object가 존재하며
  • element가 widget, state 의 인스턴스를 들고있다는 점이다.

그럼 StatefulElement에서는 어떤일이 일어날까? 🤔

  • 다른 부분은 생략하고 build 메소드를 보자. state의 build 메소드가 불리고 있으며, context를 StatefulElement의 인스턴스로 주고있다! 결국 StatefulWidget의 context도 element이다!

그렇다면 state가 바뀔땐 어떤 과정이 발생할까? 🤔

  1. setState가 call 되면서 state 인스턴스 내부의 _buttonColor를 blue -> red로 변경한다.

  2. Stateful element에 dirty check를 한다.

  3. 다음 frame에 dirty 표시된 elemet에 의해 MyButtonWidget이 다시 생성된다.

  4. 이때 MyButtonWidget element의 update 메소드가 call되며 새로운 instance를 받는다.

    이때 dirty 값이 true로 바뀌며 rebuild를 진행한다. 또한 newWidget을 인자값으로 받고있는데 type이 StatefulWidget이다. 즉 StatefulWidget은 rebuild 되는 상황마다 재생성되지만 element는 재생성 되지 않으며 단지 업데이트를 할 뿐이다.

  5. 하위의 자식 StatelessWidget들은 rebuild시 모두 다시 생성된다.

  6. 이때 바뀐 _buttonColor에 대해 FlatButton Widget이 🔵 -> 🔴 로 변경된다.

  7. Stateless Element이 referece 하고있는 FlatButton이라는 위젯의 타입이 이전과 같으므로 element는 다시 생성되진 않고, 다만 widget의 rebuild 메소드를 call 한다.

  8. 모든 child에 대해서도 순차적으로 rebuild 과정이 진행된다.

정리해보면 Stateful widget의 context는 stateful element를 의미하며, 이말은 결국 context는 element와 생명주기를 같이한다는 말이다. 즉 element tree에 마운트 되어있지 않으면 context가 없는것이고, 마운트 되어있으면 context가 존재하는 것이다.


Inherited Widget 과 Widget tree, Element tree 🎄

이 글을 보는 사람들은 Inherited Widget에 대한 기본 지식이 있다고 생각한다. 하지만 글의 흐름을 위해서 간단히 설명하자

Inherited Widget은

  • 자식들이 Inherited Widget에 대한 직접적인 reference가 없어도 접근 가능한 위젯이다.
  • 어떤 자식이 Inherited Widget을 consume 하기만 해도 자동으로 listener로 등록되며 Inherited Widget이 rebuild 될때마다 listenter로 등록된 자식위젯도 notify를 받고 rebuild 된다.

이 글의 처음에서 봤듯 AppState는 ChangeNotifierProvider내부에 존재하며, Provider는 inherited widget의 일종이다. 잠깐! Inherited Widget을 설명하기 전에 먼저 Provider를 간단히 설명하자.

Provider? 🤔

Provider는 기본적으로 어떤 것(Stream, Future, Object, ChangeNotifier..)을 자식들에게 제공하는 역할을 한다. Provider의 class diagram을 잠시 살펴보자.

  • AppState는 ChangeNotifier이고, ChangeNotifierProvider가 provide하고있다.
  • ChangeNotifierProvider는 ListenableProvider를 상속하고있다.
  • ListenableProvider는 InheritedProvider를 build, 즉 생성한다.
  • InheritedProvider는 InheritedWidget을 상속받고있다.
  • 결국 ChangeNotifierProvider는 일종의 InheritedWIdget이다.

자 다시 Inherited Widget으로 돌아오자 👋

1
2
3
4
5
class AppState extends ChangeNotifier {
...

static AppState of(BuildContext context) => Provider.of(context)
}

[AppState class]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// AppState를 Provide 하는 코드
@override
Widget build(BuildContext context) {
final theme = defaultTheme(context);
final appTheme = defaultAppTheme(context);
return MultiProvider(
providers: [
Provider.value(
value: AppTheme(
light: appTheme,
),
),
ChangeNotifierProvider(builder: (context) => AppState()),
],
...

[AppState를 자식 위젯들에게 provide 하는 코드]

  • ChangeNotifierProvider에서 AppState를 자식 Widget들에게 provide 한다.
  • 자식 Widget들은 Provider의 of 메소드를 통해 AppState를 Provide 하고있는 ChangeNotifierProvider(Inherited Widget)를 Element tree에서 찾는다
    • 여기서 Element tree인 이유는 앞서 설명했듯 context가 element를 의미하며, Inherited widget도 결국 InheritedElement를 생성하여 element tree에 마운트 되기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static T of(BuildContext context, {bool listen = true}) {
// this is required to get generic Type
final type = _typeOf<_Provider>();
final provider = listen
? context.inheritFromWidgetOfExactType(type) as _Provider
: context.ancestorInheritedElementForWidgetOfExactType(type)?.widget
as _Provider;

if (provider == null) {
throw ProviderNotFoundError(T, context.widget.runtimeType);
}

return provider.value;
}

[Provider library의 ‘of’ method 내부코드]

  • Provider의 ‘of’ 메소드의 내부를 보면 inheritFromWidgetOfExactType이라는 메소드를 통해 ChangeNotifierProvider(Inherited Widget)를 찾는다.
  • 찾은 provider의 value 즉 ChangeNotifier(AppState)를 return한다.

⚡ 정리하자면 context로 widget tree를 탐색해서 AppState를 찾는것이 아니다. Context는 Element이고 element tree를 탐색해서 AppState를 찾아냈다.


Context

앞서 설명한 내용과 같이 context는 element tree에 마운트되어있는 element이다. 잠시 State Class의 내부 코드를 보자.

  • StatefulElement 인스턴스를 _element라는 변수로 들고있다.
  • context라는 getter가 존재하며, _element를 return한다.

앞서 같이 봤던 예제를 다시한번 보자.

1
2
3
4
5
6
7
@override
void initState() {
super.initState();
SomeAsyncApiRequest().request().then((response) {
AppState.of(context).setData(response.data);
});
}
  • 비동기 함수가 끝날때 getter인 context를 call 해서 AppState의 of 메소드의 인자값으로 넣어주고있다.
  • context는 element이다.
  • element가 unmount 상태라면 element == null 이다.
  • of 메소드 내부에서는 context.inheritFromWidgetOfExactType() 이라는 메소드가 call 된다.
  • context가 null이기 때문에 inheritFromWidgetOfExactType’ was called on null 에러가 발생한다!! 😡

만일 getter를 통해 element를 사용하는 것이 아니라 element를 미리 저장해두고 위와같은 동작을 한다고 하더라도 Could not find the correct Provider above this Widget 라는 에러가 발생한다. 즉 unmounted인 element(context)로 접근하려 하면 안된다!!!

⚡ 따라서 context를 사용해서 element tree상의 inherited element을 찾을때는 element가 mounted인 상태임이 보장되어야 한다.

Conclusion 🛬

사실 세 종류의 위젯들을 공부하면서 widget tree와 element widget의 존재를 알게 되었고, context를 공부하면서 element tree에 mount 된 Inherited Element를 찾는거다라는것을 알아냈었지 inheritFromWidgetOfExactType이 왜 발생하는지, 왜 에러가 발생하는 당시에는 몰랐다.

일을 하면서 회사 사수분이 비동기처리할 때 context 사용 타이밍 이슈가 있어서 저런 에러가 난다 라는 것을 찾아내시고, 나는 그것과 위에 말한 지식들을 조합 해보니 이 글과 같은 결론이 났다.

이렇게 깊이 파고들고나니 좀더 Flutter에 대한 이해도가 깊어졌으며 더 재미있게 그리고 잘 개발할 수 있겠다는 생각이 들었다.

댓글

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×