[플러터 SDK의 계층 추상화를 단순화한 그림]
이제 플러터 내부에서 일어나는 일에 대해 알아보자.
개발자는 보통 위젯레이어 계층에서 대부분의 작업을 수행하는데, 그 아래 계층으로는 렌더링 계층과 dart:ui 라이브러리가 있다. dart:ui는 다트로 구현한 프레임워크 중 가장 낮은 수준의 기능으로 디바이스의 렌더링 엔진과 직접 소통할 수 있도록 API를 공개한다.
dart:ui는 canvas API로 화면에 직접 그리는 기능을 제공하며 hit testing을 이용해 사용자의 상호작용을 인지한다.
요소 트리가 개발자에게 중요한 이유
일반적으로 요소를 개발자가 직접 사용하고 다룰일은 없지만, 플러터 내부 동작을 이해해 특정 상황에서의 문제를 '쉽게' 해결할 수 있고 디버깅을 할 때 역시 도움이 된다.
[플러터의 세개 트리]
위젯이 요소를 만든다.
새 위젯을 만들면 프레임워크가 Widget.createElement(this)를 호출하는데 이 때 요소를 설정하며 요소는 자신을 만든 위젯을 참조(reference)하기 시작한다.
요소는 자체적으로 트리를 갖는데, 이 트리는 앱의 골격과 같다. 그 이유는 앱의 구조는 가지고 있지만 위젯이 제공하는 세부 사항(색상, 크기, 이벤트 등)은 가지고 있지 않기때문인데, 그렇기에 앱은 관련 위젯을 참좋여 세부 설정들을 파악한다.
간단한 예제로 요소 트리가 어떻게 동작하는지 확인해보자.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 만들기
어째서 색이 변하지 않는 것인가?
위젯의 색은 설정이 아닌 상태 객체가 가지고 있다. 요소는 새로 스왑된 위젯을 가르키며 새 설정을 표시하지만 상태 객체는 바뀌지 않는다.
그렇기에 요소는 새 위젯이 트리에 삽입되었다는 사실은 알 수 있지만 키가 존재하지 않고 형식은 기존의 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를 사용한다.
(예: 할일 목록 앱에서 할 일을 표시하는 위젯은 고유한 상수 Todo.text를 포함한다.)
같은 형식의 객체지만 프로퍼티 값이 다른 여러 객체가 있을 때 ObjectKey를 사용한다.
(예: 전자상거래 앱에서 두 제품의 이름이 같을 경우나 한 판매자가 여러 제품을 판다면 제품명과 판매자명을 조합해 특정 제품을 식별할 수 있다. 즉, ObjectKey로 전달하는 리터럴객체가 키다.)
컬렉션에 자식이 있고 이들이 만들어지기 전까지 자식의 값을 모르는 상황이라면 UniqueKey를 자식에 추가한다. (우리가 예제로 풀어본 key)
스크롤 위치 등 페이지 정보를 저장하는 특수 키