Weekly Flutter

[Flutter 주석 파헤치기] 03-1. Key

marsg6 2023. 3. 1. 23:58

Flutter의 Widget을 사용할 때면 항상 마주하게 되는 것이 Key이다.

 

Key는 다음과 같은 모습으로 처음 눈에 띄게 된다.

class MyWidget extends StatelessWidget {
  const MyWidget({super.key});

  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

StatelessWidget을 상속받을 때 생기는 template의 super.key가 바로 앞으로 살펴 볼 Key 타입의 파라미터이다.

 

0. Key

Key 클래스의 정의는 다음 파일에서 찾을 수 있다.

.../flutter/lib/src/foundation/key.dart

 

abstract class인 Key에 달려 있는 주석은 몇 줄 안 되기 때문에, 한 줄씩 그 의미를 파헤쳐 보겠다.

 

Key는 곧 식별자

A [Key] is an identifier for [Widget]s, [Element]s and [SemanticsNode]s.

 

위 주석을 보면 알 수 있듯, Key는 식별자로서 사용된다. Widget과 Element, 그리고 SemanticsNode에서 식별자로 사용되는 것을 알 수 있는데, [Flutter 주석 파헤치기 01]편에서 Widget의 State를 갱신하는 데 있어서 Key가 중요한 역할을 했던 것을 다시 한 번 떠올려보자.

 

Element와 연관된 Widget의 지속 여부 판단

A new widget will only be used to update an existing element if its key is
the same as the key of the current widget associated with the element.

 

타입이 같은 새로운 위젯이 동일 노드 위치에 삽입될 때, 해당 위젯의 Key가 이전 위젯의 Key와 같은 경우에는 'State'를 갱신하지만, 다른 경우에는 'State'가 'deactivated'된다고 이전 시간에 배웠다. 그러나 위의 주석을 참고하면, Key에 의해서 존속 여부가 결정되는 것은 'State'뿐만이 아닌 것으로 보인다.

ChatGPT와 다음과 같은 질의를 나눴다.

 

Q: if a key of a new widget is different from the key of a previous widget of the same node in the widget tree, the element associated with the previous widget is destructed? or only the state is deactivated?

 

A: When a new widget is created with a different key than a previous widget of the same node in the widget tree, the element associated with the previous widget is removed (i.e., destructed) from the tree and a new element is created for the new widget. This means that the state associated with the previous widget is also deactivated and disposed of, and a new state is created for the new widget. This is because keys are used to uniquely identify elements in the widget tree, and changing the key indicates that the widget has changed in a fundamental way and cannot be updated in place. Therefore, the previous element and its associated state must be removed and a new element with a new state must be created.

 

ChatGPT에 따르면 'Element' 또한 Key의 동일 여부에 따라 존속 여부가 결정된다. 혹시 거짓말을 하는 것일 수 있어 '파괴되는지'가 아니라 '존속하는지'에 대해 재차 물어도 같은 취지의 답변을 내놓았으니 어느 정도 안심하고 믿어 보자.

 

Element Tree 내에서의 유일성

Keys must be unique amongst the [Element]s with the same parent.

 

Key는 같은 부모를 가진 Element들 사이에서 유일성을 가져야 한다는 의미인데, 여기서 'parent'의 의미에 따라 두 가지로 해석할 수 있다.

우선, parent가 단순히 바로 위의 부모 노드를 가리키는 경우, Key의 유일성은 형제 노드들 사이에서만 동작한다. 그러나 parent를 조상 노드로 해석할 경우에는 Key의 유일성이 모든 Tree 내에서의 유일성으로 확장된다. 다음과 같은 테스트를 통해 Key의 유일성은 형제 노드들 사이에서만 동작한다는 것을 확인할 수 있었다.

class TestWidget extends StatelessWidget {
  const TestWidget({super.key});

  @override
  Widget build(BuildContext context) {
    final zeroKey = ValueKey(0);
    final oneKey = ValueKey(1);

    return SizedBox(
      key: zeroKey,
      child: Column(
        children: [
          SizedBox(
            key: zeroKey,
          ),
          SizedBox(
            key: oneKey, // zeroKey를 사용할 경우 build 실패.
          ),
        ],
      ),
    );
  }
}

 

이제, Key의 하위 클래스들을 살펴보자.

Subclasses of [Key] should either subclass [LocalKey] or [GlobalKey].

 

1. GlobalKey

GlobalKey를 본격적으로 살펴보기 전에, Widget.key의 주석을 먼저 확인해보자.

If the [runtimeType] and [key] properties of the two widgets are [operator==], respectively, then the new widget replaces the old widget by updating the underlying element (i.e., by calling [Element.update] with the new widget). Otherwise, the old element is removed from the tree, the new widget is inflated into an element, and the new element is inserted into the tree.
In addition, using a [GlobalKey] as the widget's [key] allows the element to be moved around the tree (changing parent) without losing state. When a new widget is found (its key and type do not match a previous widget in the same location), but there was a widget with that same global key elsewhere in the tree in the previous frame, then that widget's element is moved to the new location.

 

우선, 첫 번째 문단을 통해 앞서 ChatGPT와의 문답을 통해 확인한 내용이 사실이라는 것을 알 수 있다.

두 번째 문단은 지금부터 살펴볼 GlobalKey를 Widget의 key로 사용하는 경우에 대해서 더 상세하게 설명하고 있는데, 주석에 따르면, GlobalKey를 key로 사용하면 해당 key를 가진 Element가 '부모를 바꿔가며' '상태를 잃지 않고' 트리 내에서의 위치를 변경할 수 있다고 한다. 즉, 이전 프레임에서의 GlobalKey와 같은 GlobalKey를 가진 Widget이 이번 프레임에서 트리에서의 다른 위치에 나타난다면, 이전 프레임에서의 Widget의 Element가 새로운 위치로 이동된다는 것이다.

 

Generally, a widget that is the only child of another widget does not need an explicit key.

 

일반적으로 '형제'가 없는 Widget은 명시적인 Key가 필요 없다고 하는 주석과, 앞서의 GlobalKey에 대한 설명으로부터, GlobalKey는 '형제' 노드들 사이에서만 유일성을 가져야 한다는 제약 조건에 해당되지 않을 것이라고 추측해볼 수 있다.

 

class TestWidget extends StatelessWidget {
  const TestWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // final zeroKey = ValueKey(0);
    // final oneKey = ValueKey(1);
    final zeroKey = GlobalKey<_MyWidgetState>();
    final oneKey = GlobalKey<_MyWidgetState>();

    return Column(
      children: [
        MyWidget(
          key: zeroKey,
        ),
        Center(
          child: MyWidget(
            key: zeroKey, // ValueKey일 경우 error가 발생하지 않는다.
          ),
        ),
      ],
    );
  }
}

 

그리고 실제로 그렇다. GlobalKey의 유일성 제약 조건은, '모든 위젯 트리' 내에서 작용한다. 어디선가 GlobalKey의 사용을 지양하라는 지침을 본 적이 있다면, 그 이유는 바로 여기에 있다. 형제 노드들만을 확인하는 것보다는 전체 트리를 확인하는 것이 부하가 더 클 것이 자명하다.

 

Widget, State, 그리고 BuildContext

GlobalKey가 제공하는 public getter는 다음 세 가지가 있다.

  • currentWidget
  • currentState
  • currentContext

세 가지 getter는, GlobalKey가 조회 시점에 연결된 각각의 인스턴스를 반환한다.

여기서 한 가지 짚고 넘어갈 사항은, GlobalKey가 'State'에 대한 정보를 갖는다는 점이다. GlobalKey의 정의를 주의 깊게 살펴 보면, template class인데, 그 타입이 State<StatefulWidget>으로 제한된 것을 확인할 수 있다. GlobalKey는 기본적으로 State에만 사용할 수 있다.

 

삭제되는 그 순간에...

a widget must arrive at its new location in the tree in the same animation frame in which it was removed from its old location in the tree

 

GlobalKey를 가진 Widget은 트리 내에서 위치를 바꿔야 할 때, 본인의 하위 트리를 재배치하는데, 이를 달성하기 위해 삭제가 일어나는 애니메이션 프레임과 동일한 프레임에 새로운 위치로 이동하게 된다. 삭제하고 난 다음 프레임에 위치를 이동하게 되면, 삭제된 위치의 하위 트리는 부모(루트 노드)가 없어져 미아가 되어 버리기 때문이다.

 

Global은 비싸다

Reparenting an [Element] using a global key is relatively expensive, as this operation will trigger a call to [State.deactivate] on the associated [State] and all of its descendants; then force all widgets that depends on an [InheritedWidget] to rebuild.

 

위치를 바꾸는 행위는 매우 값비싸다. 그도 그럴 것이, 본인을 포함한 모든 하위 위젯의 State를 deactivate시키고, 새로운 element들을 새로운 위치에 생성해야 하기 때문이다. 또한 하위에 있는 모든 InheritedWidget에 관련된 Widget들도 rebuild가 된다.

 

 

진짜 비싸다

Creating a new GlobalKey on every build will throw away the state of the subtree associated with the old key and create a new fresh subtree for the new key

 

특히, build time에 새로운 GlobalKey를 생성해서 위젯에 할당하는 방식은 더 위험하다. 새로운 GlobalKey가 할당되는 순간, 해당 위젯이 속한 노드의 하위 트리가 전부 새로 생성되기 때문이다.

 

Besides harming performance, this can also cause unexpected behavior in widgets in the subtree. For example, a [GestureDetector] in the subtree will be unable to track ongoing gestures since it will be recreated on each build.

 

심지어, 이런 상황이 발생할 때, 하위 트리에 속한 GestureDetector같은 경우에는, 추적하고 있는 제스쳐를 더이상 추적할 수 없게 되는 등(얼마 전, 해당 현상을 경험했다...), 예상치 못한 여러 문제가 발생할 수 있다.

 

A good practice is to let a State object own the GlobalKey, and instantiate it outside the build method, such as in [State.initState]

 

가능하면, State.initState등과 같은, build method 바깥에서 GlobalKey를 초기화하고 가지고 있게 하는 전략이 좋은 습관이다.

 

... 너무 졸리다 ...

 

2부에 계속...