[Flutter] Widget & Helper Method

leeeeeoy·2021년 12월 18일
5

Widget

flutter에서 모든 것은 위젯이다. 코드를 작성하다 보면 특정 위젯의 build 함수가 길어질 수 있고, 이걸 별도의 위젯(파일)로 분리할 필요가 있다. 위젯을 분리하는 방법은 여러가지가 있겠지만 이 글에서는 class를 생성해서 widget을 분리하는 것과 function을 사용해서 widget을 분리하는 것을 정리해보려고 한다. 결론부터 말하면 widget을 분리할 때는 class 사용을 권장한다고 한다. 차근차근 내용들을 정리해보겠다.

최근 여러가지 일들이 있어서 한동안 정리를 하지 못했었다... 정리가 조금씩 되는대로 차근차근 글을 다시 작성하려고 한다!


Widget 분리

코드를 작성하다 보면 자주 사용되는 widget들을 분리할 때가 생긴다. 반복되는 ListView에 아이템이라던지, 혹은 서버로부터 데이터를 받아올 때 error를 보여주거나 loadingIndicator라던지 등등 공통적으로, 혹은 특정 페이지 내에서 반복적으로 사용되는 widget들이 존재한다. 당연하겠지만 조금 더 보기 좋고 수정하기 쉬운 코드 유지를 위해서는 그런 widget들을 공통적으로 묶을 필요가 있다. 먼저 기존에 자주 사용하는 방식은 다음과 같이 2가지가 있다. (코드 예시는 stackoverflow를 참고했다)

1. Class로 작성

class HelperMethodPage extends StatefulWidget {
  const HelperMethodPage({Key? key}) : super(key: key);

  
  _HelperMethodPageState createState() => _HelperMethodPageState();
}

class _HelperMethodPageState extends State<HelperMethodPage> {
  bool showCircle = false;

  
  Widget build(BuildContext context) {
    return DefaultLayout(
      title: 'Helper Method vs Widget',
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedSwitcher(
              duration: const Duration(seconds: 1),
              child: showCircle ? const Circle() : const Square(),
            ),
            ElevatedButton(
              onPressed: () => setState(() => showCircle = !showCircle),
              child: const Text('Click me'),
            )
          ],
        ),
      ),
    );
  }
}

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

  
  Widget build(BuildContext context) {
    return Container(
      width: 50,
      height: 50,
      color: Colors.red,
    );
  }
}

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

  
  Widget build(BuildContext context) {
    return const SizedBox(
      width: 50,
      height: 50,
      child: Material(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(50)),
        ),
        color: Colors.red,
        clipBehavior: Clip.hardEdge,
      ),
    );
  }
}

다음과 같은 코드가 있다고 가정해보자. HelperMethodPage라는 Widget이 있고, 그 안에서 사용되는 Square와 Circle widget이 있다. Square와 Circle은 각각 StatelessWidget으로 별도의 widget class로 작성 할 수 있다.

2. function

class HelperMethodPage extends StatefulWidget {
  const HelperMethodPage({Key? key}) : super(key: key);

  
  _HelperMethodPageState createState() => _HelperMethodPageState();
}

class _HelperMethodPageState extends State<HelperMethodPage> {
  bool showCircle = false;

  
  Widget build(BuildContext context) {
    return DefaultLayout(
      title: 'Helper Method vs Widget',
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedSwitcher(
              duration: const Duration(seconds: 1),
              child: showCircle ? const Circle() : const Square(),
              // child: showCircle ? circle() : square(),
            ),
            ElevatedButton(
              onPressed: () => setState(() => showCircle = !showCircle),
              child: const Text('Click me'),
            )
          ],
        ),
      ),
    );
  }
}

Widget square() {
  return Container(
    width: 50,
    height: 50,
    color: Colors.red,
  );
}

Widget circle() {
  return const SizedBox(
    width: 50,
    height: 50,
    child: Material(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.all(Radius.circular(50)),
      ),
      color: Colors.red,
      clipBehavior: Clip.hardEdge,
    ),
  );
}

두번째 방법으로는 첫번째 방법과 비슷하지만 class로 묶는 것이 아닌, 함수를 이용해서 작성하는 방법이 있다. 실제로 지금까지 개발을 진행하면서 자주 사용했던 방식이기도 하다. class에 비해 코드가 적고, 적어도 지금까지 사용했을 땐 크게 문제점을 느끼지 못했기 때문이었다.


widget vs function

그렇다면 두 방식에는 어떤 차이점이 있을까?? 사실 그냥 보기에는 둘 다 똑같은 동작을 하고 개인적으로는 function으로 작성하는 것이 더 적은 코드를 사용하고 깔끔해보이기도(?) 하기 때문에 지금까지 그렇게 사용했었다. 하지만 여기에는 중요한 차이점들이 있었다.

1. function은 widget tree가 인식하지 못한다.

첫번째 차이점은 바로 function으로 작성한 widget은 widget트리에서 인식되지 않는다는 특징이 있다. 다음 사진을 보면 알 수 있다.

상단의 사진은 class를 사용했을 때 widget tree이고, 하단의 사진은 function을 사용했을 때 widget tree이다. 얼핏 보면 비슷한 것 같지만 class로 작성한 코드의 widget tree는 AnimatedSwitcher --> Square --> Container 순으로 widget tree가 연결되어 있는 반면, function으로 작성한 widget tree는 AnimatedSwitcher --> Container로 바로 연결되어 있다. 즉 function은 widget tree에서 인식하지 못한다!

그럼 이게 왜 중요한가?

위의 코드는 간단한 예시이지만, 만약 class로 작성한 Square의 일부 widget이 rebuild된다고 가정해보자. 그럼 이 때는 Square에 속해있는 widget 중 변경된 widget만 rebuild 될 것이다. 즉 Square 아래에 있는 widget tree중 일부만 rebuild 되는 것이다. 반면 function을 이용 할 경우, function에서 특정 widget이 변할 경우, function 자체가 다시 호출되는 것이다. 즉 두가지 방식에는 rebuild되는 범위의 차이가 있다. 위의 코드처럼 간단한 예시는 상관없겠지만 코드가 복잡해지고 구조가 커진다면 이는 큰 차이로 이어지게 된다!


2. performances

import 'package:flutter/material.dart';
import 'dart:developer' as dp;
// This example showcases how extracting widgets into StatelessWidgets
// instead of functions can improve performances, by rebuilding
// only what needs to update when the state changes

void main() => runApp(const MyApp());

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

  
  Widget build(context) {
    return Counter(
      count: Count(),
      child: const MaterialApp(
        home: Home(),
      ),
    );
  }
}

class Count extends ValueNotifier<int> {
  Count() : super(0);
}

class Counter extends InheritedNotifier<Listenable> {
  const Counter({
    Key? key,
    required this.count,
    required Widget child,
  }) : super(
          key: key,
          notifier: count,
          child: child,
        );

  final Count count;

  static Count of(
    BuildContext context, {
    bool listen = true,
  }) {
    if (listen) {
      return context.dependOnInheritedWidgetOfExactType<Counter>()!.count;
    } else {
      final Counter counter = context
          .getElementForInheritedWidgetOfExactType<Counter>()!
          .widget as Counter;
      return counter.count;
    }
  }
}

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

  
  Widget build(BuildContext context) {
    dp.log('Home build');
    return Scaffold(
      body: Center(
        // By extracting Title in a StatelessWidget, Home doesn't rebuild when the counter changes
        // child: Title(),
        // Uncommenting this code causes Home to rebuild when the counter changes
        child: title(context),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Counter.of(context, listen: false).value++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

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

  
  Widget build(context) {
    dp.log('build Title');
    return Text('Count ${Counter.of(context).value}');
  }
}

Widget title(context) {
  dp.log('build Title');
  return Text('Count ${Counter.of(context).value}');
}

첫번째 이유와 연결되는 두번째 이유는 성능의 차이이다. 앞서 언급했듯이 같은 widget을 class로 작성하느냐, function으로 작성하느냐에 따라 rebuild 되는 범위가 달라지기 때문에 이것은 자연스럽게 성능으로 연결된다.

위 예시는 InheritedWidget을 이용해서 counter를 구현한 것이다. 사실 IngeritedWidget를 실제 개발을 하면서 사용해본적이 거의 없었지만, 이 예시에서 중점적으로 볼 것은 Home build 주기이다. 먼저 첫번째 사진은 class를 이용해서 widget을 분리한 경우인데, 이 경우 Home은 rebuild 되지 않고 자식인 Title만 rebuild되는 것을 볼 수 있다. 반면 function을 이용해서 분리한 경우에는 자식인 title이 변경될 때마다 Home도 같이 rebuild 되는 것을 볼 수 있다. build를 줄이는 것은 성능과 직접적으로 연결되기 때문에 매우 중요한 부분이다. 불필요한 build 호출은 무조건 피하는 게 좋기 때문에 이와 같은 중요한 차이점이 있는 것을 명심해야 한다!

3. reduce bugs

세번째 이유는 개발자가 미처 놓친 버그들을 줄일 수 있다는 것이다.

import 'dart:developer' as dp;
import 'package:flutter/material.dart';

// This example showcases how by using functions instead of StatelessWidgets,
// this can cause bugs when using InheritedWidgets

void main() {
  runApp(const MyApp());
}

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

  
  Widget build(context) {
    dp.log('this build method is called only once');
    return Counter(
      count: Count(),
      child: const MaterialApp(
        home: Home(),
        // Uncomment to make your app crash
        // And if you fix the error, rebuilding the counter will rebuild MyApp
        // home: home(context),
      ),
    );
  }
}

class Count extends ValueNotifier<int> {
  Count() : super(0);
}

class Counter extends InheritedNotifier {
  const Counter({
    Key? key,
    required this.count,
    required Widget child,
  }) : super(key: key, child: child, notifier: count);

  final Count count;

  static Count of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<Counter>()!.count;
  }
}

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

  
  Widget build(BuildContext context) {
    dp.log('Home is re-executed without re-executing MyApp');
    return Scaffold(
      body: Center(
        child: Text('Count ${Counter.of(context).value}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Counter.of(context).value++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

Widget home(BuildContext context) {
  // Throws by default
  // To fix, either use a Builder (which wouldn't solve the AnimatedSwitcher issue in https://dartpad.dev/1870e726d7e04699bc8f9d78ba71da35)
  // Alternatively, you could move Counter() above MyApp, but then MyApp would
  // rebuild whenever the counter changes
  return Scaffold(
    body: Center(
      child: Text('Count ${Counter.of(context).value}'),
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: () => Counter.of(context).value++,
      child: const Icon(Icons.add),
    ),
  );
}

위 예시 코드를 실행시켜 보면 class로 분리한 Home을 child로 넣었을 때는 오류 없이 실행이 잘 되는 것을 볼 수 있다. 반면 function으로 분리한 home을 child로 넣게 되면 null check error가 난다. 즉 home의 context에 null값이 들어갔다는 것이다. 즉 비어있는 context를 사용하고 있기 때문에 에러가 난 것이다. 사실 2번 예시와 마찬가지로 자주 작성해본 유형의 코드가 아니라 실제 겪어보진 않았다. 다만 명심해야 될 것은 function을 사용하는 것은 예상치 못한 실수를 유발할 수 있으며, 이는 치명적으로 작용할 수 있다는 것이다.


그렇다면 어느 것을 사용해야 되는가??

결론부터 말하면 class를 이용한 widget을 사용하는걸 권장하고 있다. class를 통한 widget의 분류는 다음과 같은 여러가지 이점을 가진다고 한다

  • 성능의 최적화
  • context api 사용
  • hot reload 동작 확인
  • widget tree 통합
  • 에러 메세지의 구체화
  • 서로 다른 두 widget간에 resources 처리 확인
  • const 사용 유무 (main thread의 성능에 영향)

즉 종합해보면 개발자로 하여금 실수를 줄이게 해주고, 이는 궁극적으로 성능의 향상으로 이어진다고 볼 수 있다. function을 이용해서 widget을 분리하는 것은 보다 적은 양의 코드로 작성할 수 있다는 것 외에는 별다른 장점이 없기 때문에 class를 권장하고 있다!


정리

그동안 개발을 해오면서 크게 신경쓰지 않았던 부분 중에 하나이기도 하다. 앞서 말했듯이 비교적 간결하게 작성할 수 있고, 또 개인적이었지만 깔끔하다는 이유로 줄곧 사용해왔다. 하지만 앞으로는 class를 이용한 widget 분리를 사용하려고 한다. 내용을 정리하면서 최근 진행하고 있던 프로젝트들을 모두 수정하고 있었는데, 어쩌면 이런 사소한 부분에서도 큰 결과의 차이를 보인다는 걸 새삼 느끼게 되었다. 만약 보다 더 큰 규모의 프로젝트에서 이렇게 하고 있었다면... 돌이켜보면 그동안 시간에 쫒겨서 급하게 개발을 했던 것도 있고, 한 번 작성한 코드가 동작하는데 크게 이상이 없으면 신경을 많이 쓰지 못했던 것 같다. 앞으로는 조금 더 세심하게, 그리고 사소한 것이라도 한번 더 고민해볼 필요성을 느꼈다. 당장 진행하고 있는 프로젝트들 부터 고치면서 반성해야겠다...

참고자료

profile
100년 후엔 풀스택

2개의 댓글

comment-user-thumbnail
2023년 3월 7일

어마어마한 팁이네요
대단하십니다
좋은거 하나 배워갑니다
감사합니다

답글 달기
comment-user-thumbnail
2023년 10월 13일

functional-widget 사용해보세요

답글 달기