왜 Flutter를 다시 기초부터 보게되었는가
옛날에 Dr. Angela yu가 강의하는 flutter 강의를 들은 적이 있습니다. 그 때에는 29시간의 시간동안 단순히 앱을 제작하기 위해 강의를 수강했던 것이라면, 이번에는 다시 마음먹고 강의를 하나 더 구매하여 아키텍처에 대해 고민해보기로 했습니다. 이전에는 StatefulWidget을 여러 군데 사용하며 상태를 남발하기도 했고, 위젯을 가능한 계속 분리하여 상태 관리를 하기도 했습니다.
그러다 보니 가장 큰 문제점이 무엇이었냐면, 새로운 개발자와 만나게 되었을 때 나의 코드를 이해시킬 수 없다는 것입니다. 계산기 앱을 제작한다고 했을 때 숫자마다 고유의 위젯으로 되어있고, 그 숫자를 클릭했을 때 main에서 값이 저장되어야 한다는 구조를 생각해보면, 그 앱을 제작한 나에게는 쉬울 지 몰라도 듣는이 또는 개발자는 이해할 수 없다는 것이 가장 큰 문제였습니다. 이렇게 앱을 제작하는 것이 맞는지도 모르고 더더욱 협업에서는 큰일 날 문제가 될 수도 있음을 직감했습니다.
대학교 축제에서 각 부스 위치나 또는 주점 위치에 대해 알려주는 앱을 제작한 적이 있습니다. 이 때에는 내가 제작하고 싶은대로 막 제작했습니다. 순전히 나의 규칙대로였습니다. 이후 시간이 지나 코드를 복기하기 위해 다시 보았는데 개발한 나조차도 이해하기 힘들었습니다. 내가 Udemy 강의를 또 하나 구매하여 기초부터 다시 보는 이유는 생략되었던 개념들과 또 다른 이유는 Dart를 하지 않고 Flutter를 했던 나를 다시 돌아보기 위함이었습니다.
첫 번째 강의는 Quiz App이었습니다. 3개의 문항을 선택한 이후 결과 값을 알려주는 앱이었는데, 간단한 문법들이 여러 개 있었으나 다시 한 번 돌아보니 많은 것들의 개념을 알지 못한 채 사용했던 것이 많아 블로그에 남겨 다시 한 번 살펴보는 것을 목표로 하겠습니다.
main.dart
void main() => runApp(MyApp());
class MyApp extends StatefulWidget {
@override
State<StatefulWidget> createState() {
// TODO: implement createState
return _MyAppState();
}
}
class _MyAppState extends State<MyApp> {
final _questions = const [
{
'questionText': 'What\'s your favorite color?',
'answers': [
{'text': 'Black', 'score': 10},
{'text': 'Red', 'score': 5},
{'text': 'Green', 'score': 3},
{'text': 'White', 'score': 1},
],
},
{
'questionText': 'What\'s your favorite animal?',
'answers': [
{'text': 'Rabbit', 'score': 3},
{'text': 'Snake', 'score': 11},
{'text': 'Elephant', 'score': 5},
{'text': 'Lion', 'score': 9},
],
},
{
'questionText': 'Who\'s your favorite instructor?',
'answers': [
{'text': 'Max', 'score': 1},
{'text': 'Max', 'score': 1},
{'text': 'Max', 'score': 1},
{'text': 'Max', 'score': 1},
],
},
];
var _questionIndex = 0;
var _totalScore = 0;
void _resetQuiz() {
setState(() {
_questionIndex = 0;
_totalScore = 0;
});
}
void _answerQuestion(int score) {
_totalScore += score;
setState(() {
_questionIndex = _questionIndex + 1;
});
print(_questionIndex);
if (_questionIndex < _questions.length) {
print('We have more questions!');
} else {
print('No more questions!');
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('My First App'),
),
body: _questionIndex < _questions.length
? Quiz(
answerQuestion: _answerQuestion,
questionIndex: _questionIndex,
questions: _questions,
)
: Result(_totalScore, _resetQuiz),
),
);
}
}
Stateful, Stateless
MyApp 클래스는 StatefulWidget을 상속받고 있습니다. 초기의 input data를 저장하고 그 상태를 유지하는 것이 StatelessWidget 이었습니다. 여기서 StatefulWidget을 상속받는 이유는 Widget 자체 내에서 UI를 rendering 시키기 위함입니다. Stateless는 내부적으로 상태를 관리할 수 없기 때문에 데이터가 변경되어도 화면을 변경시켜 줄 수 없지만, Stateful은 상태를 관리할 수 있기 때문에 화면이 바뀌게 되는 곳에서는 StatefulWidget을 상속받습니다.
그러다보니, class 파일이 두 개가 생겨버리게 되었습니다. 아래쪽에 있는 class는 우리가 기존에 StatelessWidget과 작업하는 방식과 별 차이가 없습니다. 즉, 아래의 _MyAppState는 기존의 Widget을 그려내듯 사용하는 클래스입니다. 그러나 위의 class는 StatefulWidget을 사용하게 되면서 추가적으로 기재해주어야 하는데(물론 자동완성을 쓰면 필요없습니다) 이는 state를 다시 생성하는 createState() 메소드를 사용하여 아래의 _MyAppState를 rendering 해주는 class입니다. 그러다보니 State<StatefulWidget>이라는 객체를 반환해주어야 하는데 이에 따라 _MyAppState가 State<MyApp>이라는 녀석을 상속받아 최후에 createState에서 반환하게 될 수 있습니다.
Architecture
또한 중요한 특징으로는 화면 안에서 관리되어야할 변수나 메소드는 그 화면의 가장 핵심 파일이 갖고 있어야 한다는 것입니다. 제가 가장 많은 실수를 했던 부분입니다. 예를 들어 퀴즈 문제 텍스트가 점점 길어져 하나의 Widget 파일로 생성하여 관리할 수는 있겠지만, questionIndex나 totalScore 변수들까지 가져가지는 않아야만 합니다. 모두 포인터를 넘겨줄 뿐 모든 변수나 메소드는 가장 화면의 부모 State에서 관리를 해야합니다. 따라서 _resetQuiz와 같은 메소드도 Result라는 class에서 사용해야 하지만 Result 내부에서 제작하지 않고 메인에서 보내주는 이유도 이와 같습니다.
Quiz.dart
class Quiz extends StatelessWidget {
final List<Map<String, Object>> questions;
final int questionIndex;
final Function answerQuestion;
Quiz({
@required this.questions,
@required this.answerQuestion,
@required this.questionIndex,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Question(
questions[questionIndex]['questionText'],
),
...(questions[questionIndex]['answers'] as List<Map<String, Object>>).map((answer) {
return Answer(() => answerQuestion(answer['score']), answer['text']);
}).toList()
],
);
}
}
강의를 들으셨다면 웬만하면 이해가 가겠지만,
...(questions[questionIndex]['answers'] as List<Map<String, Object>>).map((answer) { ~~
이 부분이 어려웠을 것이라고 생각합니다. 하나씩 살펴보겠습니다.
main에서 쓰인 _questions에 일부를 발췌합니다.
final questions = const [
{
'questionText': 'What\'s your favorite color?',
'answers': [
{'text': 'Black', 'score': 10},
{'text': 'Red', 'score': 5},
{'text': 'Green', 'score': 3},
{'text': 'White', 'score': 1},
],
}
];
타입 Map과 method인 map
Map에서는 key와 value를 갖고 있는데 value를 가져오기 위해서는 다음과 같이 사용합니다.
question['questionText'] 또는 question['answer']과 같은 방식입니다. 그러나 해당 _question은 list로 제작이 되어있고(대괄호로 시작했기 때문입니다.) 그 안에 Map이 있는 구조이기 때문에 몇 번째 요소를 가져올건지와 어떤 key로 value를 가져올 것인지 명시해야 합니다. 따라서 questions[questionIndex]['answers']와 같이 사용됩니다. questions[questionIndex]['answers'] 를 사용하면 반환되는 형식은 다음과 같습니다.
[
{'text': 'Black', 'score': 10},
{'text': 'Red', 'score': 5},
{'text': 'Green', 'score': 3},
{'text': 'White', 'score': 1}
]
이 또한 list이기 때문에 하나씩 접근하여 그에 해당하는 위젯을 만들어줄 필요가 있습니다.
여기서 forEach나 for의 사용방법은 위를 참고하시면 됩니다.
map도 위와 같지만 return을 할 수가 있습니다. menus의 각 모든 요소에서 하나씩 접근하고, 이를 return함으로써 menuList에 넣어주고 있습니다. 그러나 여기서 menuList에 타입을 찍어보면
입니다. 우리는 List를 제작해주어야 합니다. 따라서 맨 마지막에 .toList()를 추가해주어 다음과 같이 만듭니다.
타입도 List<String>으로 바뀌게 됩니다.
... sepread operator
이후에 ...(spread operator)를 사용하는 이유는 list는 [ 1, 2, 3 ] 과 같이 대괄호로 엮이게 되는데 위의 코드에서는 지금 Column 안의 children [] 안에서 실행되고 있습니다. [ 1, 2, 3, [4, 5, 6] ] 이런 구문이 만들어 질 수 있을까요?
만들어질 수 없기 때문에 위의 4, 5, 6 양 옆 대괄호를 없애주어야 합니다. list를 벗어나는 것이죠. 아래 코드를 보면 더 쉽게 이해할 수 있습니다.
따라서 ...를 사용하여 리스트를 탈피해야합니다.
익명함수
그럼 왜 이런 문장을 사용할까요?
return Answer(() => answerQuestion(answer['score']), answer['text']);
원래는 메소드를 포인터로 전달해야 합니다. answerQuestion 처럼 말이죠. 여기서 answerQuestion()으로 보내지 않는 이유는 당연하게도 후자로 보낼 경우 메소드를 보내는 것이 아닌, 메소드를 실행시킨다는 의미가 되어버리죠. 그런데 여기서는 포인터로 보내지도 않고, 뭔가 실행시켜서 보낸다(?) 처럼 보이기도 합니다.
answerQuestion는 하나의 파라미터를 받게 되는데 여기서 answer['score']라는 정보는 현재 widget에서 가지고 있습니다. 만약 answer['score']의 정보를 Answer라는 클래스 내에서 사용할 수 있다면 아마 메소드를 포인터로 보내주어도 될겁니다. 하지만 그 정보는 현재 클래스 내에서 가지고 있으며, 이 안에서 어떤 메소드를 사용할 것이다라고 Answer에게 보내주어야 합니다. 이것을 미리 정의하지 않고 익명으로 보내주는 방법이 () => ~~ 의 형태인 것입니다.