요소 트리

[플러터 SDK의 계층 추상화를 단순화한 그림]

이제 플러터 내부에서 일어나는 일에 대해 알아보자.

개발자는 보통 위젯레이어 계층에서 대부분의 작업을 수행하는데, 그 아래 계층으로는 렌더링 계층과 dart:ui 라이브러리가 있다. dart:ui는 다트로 구현한 프레임워크 중 가장 낮은 수준의 기능으로 디바이스의 렌더링 엔진과 직접 소통할 수 있도록 API를 공개한다.

dart:ui는 canvas API로 화면에 직접 그리는 기능을 제공하며 hit testing을 이용해 사용자의 상호작용을 인지한다.

요소 트리가 개발자에게 중요한 이유
일반적으로 요소를 개발자가 직접 사용하고 다룰일은 없지만, 플러터 내부 동작을 이해해 특정 상황에서의 문제를 '쉽게' 해결할 수 있고 디버깅을 할 때 역시 도움이 된다.

  • 고수준으로 추상화된 위젯으로 개발자가 작업을 하면 위젯과 dart:ui 저수준 디바이스 힛 테스팅이나 픽셀 수준의 계산을 처리한다.
  • 요소 트리는 위젯 트리처럼 앱의 구조를 표현하며 위젯 트리에 모든 이젯은 요소를 포함하는 요소 트리를 갖는다.
  • 실제로 트리에 존재하고 마운트되는 위젯이 바로 요소다.
  • 플러터 앱을 실행할 때 디바이스에 실제로 표시되는 모든 것은 요소다.
  • 렌더객체(RenderObject)는 고수준 코드와 저수준(dart:ui)을 연결하는 인터페이스다.
  • 랜더객체는 실제 화면에 그리는 작업을 수행하므로 복잡하고 비싸다.
    • 덕분에 프레임워크는 내부적으로 비싼 랜더객체를 재사용하면서 동시에 비싸지 않은 위젯을 마음껏 파괴할 수 있다.
    • 이런 이유로 플러터는 세 개의 트리를 갖는다. 아래 그림을 참조하자!

[플러터의 세개 트리]

요소와 위젯

위젯이 요소를 만든다.
새 위젯을 만들면 프레임워크가 Widget.createElement(this)를 호출하는데 이 때 요소를 설정하며 요소는 자신을 만든 위젯을 참조(reference)하기 시작한다.
요소는 자체적으로 트리를 갖는데, 이 트리는 앱의 골격과 같다. 그 이유는 앱의 구조는 가지고 있지만 위젯이 제공하는 세부 사항(색상, 크기, 이벤트 등)은 가지고 있지 않기때문인데, 그렇기에 앱은 관련 위젯을 참좋여 세부 설정들을 파악한다.

  • 요소는 다시 빌드되지 않고 오직 갱신된다.
    • 위젯과의 차이점
  • 위젯을 다시 빌드하거나 트리의 부모가 다른 위젯을 삽입하면 요소의 위젯 참조를 다시 만들지 않고 갱신한다.
  • 애니메이션의 경우 매 프레임마다 build를 호출하는데, 매 프레임마다 위젯은 그대로지만 설정들이 조금씩 바뀐다. 이런상황에서 트리 자체의 구조는 바뀌지 않기에 요소는 자신을 다시 빌드할 필요가 없고 위젯 참조만 수정하기에 빌드가 계속 일어나도 성능이 유지된다.
  • 위젯이 아니라 요소가 상태 객체를 관리한다.
    • 내부적으로 위젯이 아니라 요소와 상태객체를 이용해 렌더링을 진행한다.
    • 위젯을 바꿀수 없으므로 다른 위젯과의 관계도 바꿀 수 없다.
    • 위젯은 새 부모도 가질 수 없다.
    • 위젯을 바꾸려면 파괴하고 다시 만들어야 하낟.
    • 요소를 바꿀수 있는 특징 덕분에 속도를 얻을 수 있고, 위젯을 바꿀수 없는 특징 덕분에 안전한 코드를 작성할 수 있다.

요소 트리

간단한 예제로 요소 트리가 어떻게 동작하는지 확인해보자.Reset버튼을 누르면 증가, 감소 버튼이 서로 swap되도록 만드는 예제이다.

미리 말하자면, 색이 바뀌지 않는다. 요소트리와 상태객체에 대해 알아보고 위젯키에서 자세히 다루도록 하겠다.

  • 카운터 앱을 활용하여 아래의 코드를 작성
class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

   //슈퍼클래스의 메소드 createState를 오버라이드한다.
  State<MyHomePage> createState() => _MyHomePageState();
  //StatefulWidget은 State객체를 반환하는 createState를 반드시 정의해야 한다.
}

class _MyHomePageState extends State<MyHomePage> {
  //BEGIN -- 증감 카운트 변수, swap변수, key를 private로 생성한다.
  int _counter = 0;
  bool _resersed = false; //버튼 변경 기준 논리 값(false : 증가, ture : 감소)
  List<UniqueKey> _buttonKeys = [UniqueKey(), UniqueKey()];
  //END -- 증감 카운트 변수, swap변수, key를 private로 생성한다.
  
  Widget build(BuildContext context) {
    void _incrementCounter() {
      setState(() {
        _counter++;
      });
    }//증가 함수

    void _decrementCounter() {
      setState(() {
        _counter--;
      });
    }//감소 함수
    
    void _swap() {
      setState(() {
        _reversed = !_reversed;
      });
    }

    void _resetCounter() {
      setState(() => _counter = 0);
      _swap();
    }    

    final incrementButton = FancyButton(
      child: Text(
        "Increment",
        style: TextStyle(color: Colors.white),
      ),
      onPressed: _incrementCounter,
    ); // 증가 FancyButton

    final decrementButton = FancyButton(
      child: Text(
        "Decrement",
        style: TextStyle(color: Colors.white),
      ),
      onPressed: _decrementCounter,
    ); // 감소 FancyButton

// 선언한 FancyButton들을 _buttons 변수에 담아서 Row로 전달해 화면에 표시
    List<Widget> _buttons = <Widget>[incrementButton, decrementButton];
    if (_resersed) {
      // _reversed 논리 값의 참/거짓에 따라 참일 경우 버튼을 바꾼다. 
      _buttons = _buttons.reversed.toList();
    }
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: _buttons, // 선언한 _buttons를 children에 인수로 넘겨준다.
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _resetCounter,
        tooltip: 'Reset',
        child: const Icon(Icons.add),
      ),
    );
  }
}

//BEGIN -- FancyButton 만들기
// 자신의 배경색을 관리하며 누르면 전달된 콜백을 호출한다.
class FancyButton extends StatefulWidget {
//RaisedButton을 상속받아 만들지 않는다! 플러터는 상속이 아닌 구성을 추천한다!(중요)

  final VoidCallback onPressed;
  final Widget child;
  const FancyButton({Key? key, required this.onPressed, required this.child})
      : super(key: key);
  
  _FancyButtonState createState() => _FancyButtonState();
}

class _FancyButtonState extends State<FancyButton> {
  
  Widget build(BuildContext context) {
    return Container(
      child: RaisedButton(
        color: _getColors(), // 자신의 색을 관리한다
        child: widget.child,
        onPressed: widget.onPressed,
      ),
    );
  }

  Color _getColors() {
    return _buttonColors.putIfAbsent(this, () => colors[next(0, 5)]);
  }
}// 모든 FancyButton 의 색을 관리한다.

Map<_FancyButtonState, Color> _buttonColors = {};
final _random = Random();
int next(int min, int max) => min + _random.nextInt(max - min);
List<Color> colors = [
  Colors.blue,
  Colors.green,
  Colors.orange,
  Colors.purple,
  Colors.amber,
  Colors.lightBlue
];
//END -- FancyButton 만들기

요소 트리와 상태 객체

  • 상태 객체는 요소 트리가 관리한다.
  • 상태 객체는 오래 산다(long-lived) 위젯과 달리 위젯을 그릴때마다 파괴하고 다시 빌드하지 않는다.
  • 상태 객체는 다시 사용할 수 있다.
  • 요소는 위젯을 참조한다.

    [요소와 위젯의 관계]

    [각 요소는 다른 위젯을 기리키며 각각의 형식을 알고 있다.]
  • 플러터가 위젯을 다시 빌드해도 요소는 기존 참조가 가리키는 위젯의 위치를 그대로 유지한다.
  • 요소는 메타 정보와 위젯 참조를 포함하지만 위젯이 바뀌었을 때 레퍼런스를 어떻게 갱신해야 하는지 모른다.
  • build를 호출한 이후 플러터가 위젯을 다시 빌드해도 요소는 기존의 위젯 참조를 유지한다.
  • 참조 대상 위젯이 이전과 같은지 비교해 다르거나 다른 위젯으로 바뀌었다면 위젯을 다시 그린다.
  • 버튼의 위치가 스왑되더라도 요소는 같은 위치를 참조하기에 요소의 참조는 새 위젯을 가르키게 된다.
  • 요소는 위젯에서 다음과 같은 일부 프로퍼티를 확인해 바뀐 내용을 판독한다.
    • 런타임의 정확한 형식
    • (키가 있다면)위젯의 키

어째서 색이 변하지 않는 것인가?
위젯의 색은 설정이 아닌 상태 객체가 가지고 있다. 요소는 새로 스왑된 위젯을 가르키며 새 설정을 표시하지만 상태 객체는 바뀌지 않는다.
그렇기에 요소는 새 위젯이 트리에 삽입되었다는 사실은 알 수 있지만 키가 존재하지 않고 형식은 기존의 FancyButton과 동일한 FancyButton 형식이기에 참조를 갱신하지않고 상태 객체를 그대로 적용한다.
플러터는 프레임워크가 위젯을 식별할 수 있도록 키(key)라는 기능을 제공한다.

위젯 키

키를 사용하면 형식은 같지만 다른 위젯을 플러터에 알릴 수 있다.
이런 부분은 여러 자식을 갖는 Row나 Column에서 유용하게 사용할 수 있다. UniqueKey를 이용해 문제를 해결해보자.

key는 기본적으로 Stateless 에서는 필요없다.

import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(const MyApp()); //앱의 진입점
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

   //슈퍼클래스의 메소드 createState를 오버라이드한다.
  State<MyHomePage> createState() => _MyHomePageState();
  //StatefulWidget은 State객체를 반환하는 createState를 반드시 정의해야 한다.
}

class _MyHomePageState extends State<MyHomePage> {
  //BEGIN -- 증감 카운트 변수, swap변수, key를 private로 생성한다.
  int _counter = 0;
  bool _reversed = false; //버튼 변경 기준 논리 값(false : 증가, ture : 감소)
  List<UniqueKey> _buttonKeys = [UniqueKey(), UniqueKey()];
  //END -- 증감 카운트 변수, swap변수, key를 private로 생성한다.

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  } //증가 함수

  void _decrementCounter() {
    setState(() {
      _counter--;
    });
  } //감소 함수

  void _swap() {
    setState(() {
      _reversed = !_reversed;
    });
  }

  void _resetCounter() {
    setState(() => _counter = 0);
    _swap();
  }

  
  Widget build(BuildContext context) {
    List<Widget> _buttons = <Widget>[
      FancyButton(
        key: _buttonKeys.first,//고유키를 생성한다.
        child: Text(
          "Increment",
          style: TextStyle(color: Colors.white),
        ),
        onPressed: _incrementCounter,
      ),
      FancyButton(
        key: _buttonKeys.last,//고유키를 생성한다.
        child: Text(
          "Decrement",
          style: TextStyle(color: Colors.white),
        ),
        onPressed: _decrementCounter,
      )
    ];
    if (_reversed) {
      // _reversed 논리 값의 참/거짓에 따라 참일 경우 버튼을 바꾼다.
      _buttons = _buttons.reversed.toList();
    }
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: _buttons, // 선언한 _buttons를 children에 인수로 넘겨준다.
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _resetCounter,
        tooltip: 'Reset',
        child: const Icon(Icons.add),
      ),
    );
  }
}

//BEGIN -- FancyButton 만들기
// 자신의 배경색을 관리하며 누르면 전달된 콜백을 호출한다.
class FancyButton extends StatefulWidget {
//RaisedButton을 상속받아 만들지 않는다! 플러터는 상속이 아닌 구성을 추천한다!(중요)

  final VoidCallback onPressed;
  final Widget child;
  const FancyButton({Key? key, required this.onPressed, required this.child})
      : super(key: key);
  
  _FancyButtonState createState() => _FancyButtonState();
}

class _FancyButtonState extends State<FancyButton> {
  
  Widget build(BuildContext context) {
    return Container(
      child: RaisedButton(
        color: _getColors(), // 자신의 색을 관리한다
        child: widget.child,
        onPressed: widget.onPressed,
      ),
    );
  }

  Color _getColors() {
    return _buttonColors.putIfAbsent(this, () => colors[next(0, 5)]);
  }
} // 모든 FancyButton 의 색을 관리한다.

Map<_FancyButtonState, Color> _buttonColors = {};
final _random = Random();
int next(int min, int max) => min + _random.nextInt(max - min);
List<Color> colors = [
  Colors.blue,
  Colors.green,
  Colors.orange,
  Colors.purple,
  Colors.amber,
  Colors.lightBlue
];
//END -- FancyButton 만들기

색이 바뀌지 않는 문제를 해결했다. 위의 예제와 비교해보자!
tip : List<'Widget> _buttons 부분만 보면 된다.

글로벌 키

위젯 트리에서 상태를 관리하고 위젯을 이동할 때 글로벌 키를 사용한다.
전체 어플리케이션에서 고유한지 확인한다.
하지만 플러터 팀은 글로벌 키로 상태를 관리하는 것을 권장하지 않는다. 다른 기법으로 더 안전하게 상태를 관리할 수 있다.
글로벌 키는 성능에 영향을 미치므로 자주 사용하지 않는다.

로컬키

로컬 키는 키를 생성한 빌드콘텍스트의 영역을 갖는다.

ValueKey

상수를 갖는 객체에 키를 추가할 때 ValueKey를 사용한다.
(예: 할일 목록 앱에서 할 일을 표시하는 위젯은 고유한 상수 Todo.text를 포함한다.)

ObjectKey

같은 형식의 객체지만 프로퍼티 값이 다른 여러 객체가 있을 때 ObjectKey를 사용한다.
(예: 전자상거래 앱에서 두 제품의 이름이 같을 경우나 한 판매자가 여러 제품을 판다면 제품명과 판매자명을 조합해 특정 제품을 식별할 수 있다. 즉, ObjectKey로 전달하는 리터럴객체가 키다.)

UniqueKey

컬렉션에 자식이 있고 이들이 만들어지기 전까지 자식의 값을 모르는 상황이라면 UniqueKey를 자식에 추가한다. (우리가 예제로 풀어본 key)

PageStorageKey

스크롤 위치 등 페이지 정보를 저장하는 특수 키

0개의 댓글