Flutter의 상태 관리 매니저 GetX 이해하기

Flutter GetX에 대해 이해하기

GetX란?

GetX는 Flutter에서 사용 가능한 StateManager이다. GetX를 사용하는 방법 중 가장 많이 보았을 방법은 바로 page route 관리이다. 우리가 페이지를 이동하려 할 때, 다음과 같은 route 기반 코드를 볼 수 있다.

Navigator.of(context).push(MaterialPageRoute(
    builder: (_) => NextPage(),
));

Navigator를 사용하는 방법도 좋긴 한데, GetX를 사용하면 더 편리하게 할 수 있다.

Get.to(NextPage());

이런 식으로 처리가 가능하다. 그러나 단순히 페이지 라우팅 기능 외에도, GetX는 상태 관리에 있어 강력한 기능들을 제공한다.

상태 관리란?

상태 관리라는 용어가 조금 낯설고 생소하다. 상태 관리, 혹은 State Management는 앱 프로세스 내에서 그 상태가 변화하는 것을 관리함을 의미한다. 이때 '상태'란, 앱이 실행되고 있는 동안 동적으로 계속해서 변화하는 어떠한 데이터 따위를 의미한다.

다음과 같은 것들이 상태 관리의 대상이 될 수 있다.

  • User Preference: main theme이나 language 등의 설정
  • 세션 정보: 유저가 로그인했을 때의 세션 정보 등
  • 기타 다양한 변화하는 정보들

사실 앱은 언제나 그 상태가 변화한다. 그래서 이러한 상태들을 모아놓고 관리하는 매니저가 필요한데, 그것이 바로 State Manager의 역할이다.

Flutter의 State Manager는 어떤 것들이 있는지 알아보자.

  1. setState: setState 역시도, Flutter가 제공하는 기본적이나 강력한 상태 관리 매니저의 일종이다. 그러나 setState는 한 dart 파일 내부에서 의미가 있으나, 여러 dart 파일에 걸쳐서 상태를 관리하는 데 한계가 있다. 그래서 우리는 별도의 State Manager Package를 사용하게 된다.
  2. Provider: GetX와 더불어 가장 대중적인 상태 관리 매니저이다.
  3. Bloc/Cubic: Flutter의 공식 팀이 밀어주기도 한 상태 관리 매니저이다.
  4. GetX: Provider, Bloc보다 더 쉬우면서도 state management, dependency injection, route management 등에 관한 풍부한 기능을 모두 제공한다.

Provider, Bloc, Cubic 등 다양한 상태 관리 패키지가 있지만, 그 중 가장 쉽다고 알려진 게 GetX여서 GetX를 배워보기로 했다.

GetX를 사용해보자.

GetX를 사용하기 위해서 해주어야 할 것은 정말 쉽다. pubspec.yaml 파일에 GetX를 추가해주면 된다.

dependencies:
  flutter:
    sdk: flutter
  get:

참고로 위와 같이 별도의 버전을 지정해주지 않고 그냥 get만 적고 flutter pub get을 해주면, 알아서 가장 최신 버전의 GetX가 설치된다.

이렇게 설치한 GetX에서는 다음과 같은 기능을 제공한다.

  1. Page Route
  2. Dependency Injection Management
  3. Simple State Management
  4. Reactive State Management

그 중 이번 글에서는 Simple State Management와 Reactive State Management에 대해서 알아보자.

Simple State Management

Simple State Management는 이름에서 알 수 있듯이 간단한 상태 관리를 위한 기능이다. observable 객체가 들어가 있지 않아서 Reactive State Management 방식보다 훨씬 가볍고, 간단하다. 그러나 단점으로는 상태 변화를 감지하기 위해서는 매번 update() 메소드를 호출해야 한다는 단점이 있다. update() 메소드는 화면 전체를 리빌드해주는데, 변화하는 컴포넌트만 리빌드해주는 것보다 비효율적이다. 그래서 자주 변화하지는 않는 상태를 관리하는데 적합하다.

Simple State Management를 사용하기 위해서, 먼저 Flutter Project를 가장 처음에 만들면 나오는 counter 앱을 사용해보자. 우리는 GetX를 사용해서 counter 앱을 만들어볼 것이다. 먼저 GetX를 사용하기 위해서는, 변화하는 상태를 관리하는 객체인 Controller_(이름은 적당히 다르게 지어도 된다.)_를 만들어야 한다. 이 Controller는 GetXController를 extends 한다.

class Controller extends GetxController {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
  }
}

위 코드를 보자. Controller는 GetXController (GetX의 상태 관리를 위해서는 무조건 상속받아야 한다)를 extends한다. 그리고 count라는 상태를 저장하고 있고, increment() 메소드에서 counter를 증가시키고 있다. 이제 이 Controller를 사용해보자.

이때 이 Controller를 사용하기 위해서는, Controller 인스턴스를 만들때 의존성 주입(dependency injection)을 해주어야 한다. Dependency Injection에 대해서 자세히 알 필요는 없고, 간단히 말하면 우리가 사용하고자 하는 인스턴스 혹은 메소드를 Get의 메모리에 등록시키는 과정을 의미한다. DI에 관한 자세한 정보는 따로 찾아보자.

final Controller controller = Get.put(Controller());

이때 Controller()란 우리가 정의한 클래스이다. 이 클래스를 put() 메소드를 통해 DI를 했다. 그리고 controller의 타입은 Controller이다. 확실히 하기 위해서 타입명을 써주었지만 기본적으로는 final만 써줘도 알아서 타입 추론이 되기에 생략 가능하다.

이제 Controller 객체를 사용해보자.

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

class MyApp extends StatelessWidget {
  MyApp({super.key});

  final controller = Get.put(Controller());

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: Scaffold(
      appBar: AppBar(),
      body: Center(
          child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          GetBuilder<Controller>(
              builder: (_) => Text(
                    'Current Counter : ${controller.count}',
                    style: const TextStyle(fontSize: 20),
                  )),
          const SizedBox(height: 24),
          ElevatedButton(
              onPressed: () => controller.increment(),
              child: const Text(
                'Increase',
                style: TextStyle(fontSize: 24),
              ))
        ],
      )),
    ));
  }
}

위 코드에서 주목해야 할 부분은 아래이다.

GetBuilder<Controller>(
  builder: (_) => Text(
    'Current Counter : ${controller.count}',
    style: const TextStyle(fontSize: 20),
    )),

GetBuilder를 사용해서, Text 위젯을 불러오고 있다. 여기에서 위에서 초기화한 controller 인스턴스의 count 변수를 Text 위젯으로 출력하고 있다.

ElevatedButton(
  onPressed: () => controller.increment(),
  child: const Text(
    'Increase',
    style: TextStyle(fontSize: 24),
  ))

그리고 아래에 있는 버튼으로, controller의 increment() 메소드를 호출하고 있다. 이제 실행해보자.

그런데 문제가 있다. counter 버튼을 눌러도, 값이 증가하지 않는다. 그 이유는 처음 우리가 increment() 메소드를 어떻게 정의했는지 다시 보면 된다.

void increment() {
  _count++;
}

여기서 주의해야 하는 점은, simple state management에서는 반드시 update 메소드를 통해 화면을 리빌드시키는 작업을 해주어야 한다는 것이다. 그렇지 않으면 내부적으로 값이 증가해도 화면에 표시가 나지 않는다.

void increment() {
  _count++;
  update();
}

업데이트 메소드를 추가해주고, 다시 실행을 해보자.

잘 작동한다!

한편 여기서 더 나아가서, 위에서처럼 별도의 Controller 인스턴스를 초기화해주지 않고, GetBuilder 내부에서 초기화를 해주는 것도 가능하다.

final controller = Get.put(Controller());

그렇게 하면 이 부분을 생략할 수 있다.

GetBuilder<Controller>(
  init: Controller(),
  builder: (_) => Text(
    'Current Counter : ${Get.find<Controller>().count}',
    style: const TextStyle(fontSize: 20),
    )),

위와 같이 GetBuilder에서 init 요소를 Controller()로 초기화를 해주고 나면, Get.find() 메소드를 통해 인스턴스를 불러올 수 있다. 이때 find의 템플릿(제네릭)은 <Controller>로 지정해주면 된다.

ElevatedButton(
  onPressed: () => Get.find<Controller>().increment(),
  child: const Text(
    'Increase',
    style: TextStyle(fontSize: 24),
  ))

참고로 controller 인스턴스를 없애주었으니, 아래에 있는 ElavatedButton 역시 수정해야 한다.

인스턴스를 위에서 초기화해줄 것인지 혹은 GetBuilder의 init 요소를 통해 초기화를 해줄 것인지, 어느 것을 선택할 지는 전적으로 개발자의 몫이다.

Reactive State Management

사실 Simple State Management보다 더 많이 사용되는 것은 Reactive State Management이다. 먼저 Reactive State Management에 대해 알기 전에, MVC 패턴에 대해서 알아보자.

MVC(Model-View-Controller) 패턴이란 앱의 구조에 관한 패턴으로, 다음 세 가지 역할을 하는 구성 요소들로 이루어진다.

  • Model: 앱의 데이터, repository 등을 담당한다
  • View: 앱의 UI를 담당한다
  • Controller: Model과 View를 연결해주는 역할을 한다

여기서 Controller를 구현할 때 GetX를 사용할 수 있다. 먼저 Person이라는 모델을 하나 만들어보자.

// person.dart

class Person {
  Person({this.age = 0, this.name = ''});
  int age;
  String name;
}

그리고 이를 제어할 Controller 클래스를 하나 만들어보자.

// controller.dart

class Controller extends GetxController {
  final person = Person().obs;

  void updateInfo() {
    person.update((val) {
      val?.age++;
      val?.name = 'nx006';
    });
  }
}

여기서 Person 오브젝트를 생성할 때 obs가 붙었다.

final person = Person().obs;

obs는 observable의 약자로, 오브젝트의 변화를 감지하겠다는 의미이다. GetX는 이렇게 obs가 붙은 오브젝트들의 변화를 감시한다. obs가 붙은 객체는 자연히 update라는 메소드를 사용할 수 있게 되는데, 이때 update 메소드는 Simple State Management에서 소개한 화면을 리빌드시키는 update 메소드와는 그 의미가 다르다.

update 메소드는 내부적으로 콜백 인스턴스를 받는데, 이 콜백 인스턴스(위에서는 val)은 감시하고 있는 Person 오브젝트가 들어온다. 이때 val에 null 값이 들어올 수 있으므로 val? 형태로 null 체크를 해주어야 한다. 그리고 이 인스턴스의 값을 변경해주면, GetX가 update 메소드를 통한 변화를 감지하고 화면을 리빌드시킨다.

이제 이를 출력하는 페이지, 즉 View를 만들어보자.

GetX<Controller>(
  init: Controller(),
  builder: (_) => Text(
    'Name : ${Get.find<Controller>().person().name}',
    style: const TextStyle(
      fontSize: 20, color: Colors.white),
  ))
floatingActionButton: FloatingActionButton(
  child: const Icon(Icons.add),
  onPressed: () {
    Get.find<Controller>().updateInfo();
  },
)

Simple State Management와 크게 다를 것은 없다. init을 통해 Controller를 DI 해주고, Get.find<Controller>() 함수를 통해 Controller 인스턴스를 불러온다.

그리고 Controller 인스턴스에서 사용할 수 있는 Person() 객체나 updateInfo() 메소드를 사용할 수 있다.

// personal_card.dart

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
          child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Container(
            margin: const EdgeInsets.all(20),
            width: double.maxFinite,
            height: 100,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(20),
              color: Colors.lightBlue,
            ),
            child: Center(
                child: GetX<Controller>(
                    init: Controller(),
                    builder: (_) => Text(
                          'Name : ${Get.find<Controller>().person().name}',
                          style: const TextStyle(
                              fontSize: 20, color: Colors.white),
                        ))),
          ),
          Container(
            margin: const EdgeInsets.all(20),
            width: double.maxFinite,
            height: 100,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(20),
              color: Colors.lightBlue,
            ),
            child: Center(
                child: GetX(
                    init: Controller(),
                    builder: (controller) => Text(
                          'Name: ${Get.find<Controller>().person().age}',
                          style: const TextStyle(
                              fontSize: 20, color: Colors.white),
                        ))),
          ),
        ],
      )),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          Get.find<Controller>().updateInfo();
        },
      ),
    );
  }
}

잘 작동하는 것을 알 수 있다.

위 코드는 모두 코딩쉐프의 Flutter(플러터) GetX 입문 1: Simple State manager 이해하기, Flutter(플러터) GetX 입문 2: Reactive State manager 이해하기를 참고하였다.

GetX를 통해 theme 관리하기

이번에는 실전 느낌으로 GetX를 통해 dark theme, white theme을 관리해보자.

class ThemeController extends GetxController {
  final _box = GetStorage();
  final _key = 'isDarkMode';

  ThemeMode get theme => _loadTheme() ? ThemeMode.dark : ThemeMode.light;
  bool _loadTheme() => _box.read(_key) ?? false;

  void saveTheme(bool isDarkMode) => _box.write(_key, isDarkMode);
  void changeTheme(ThemeData theme) => Get.changeTheme(theme);
  void changeThemeMode(ThemeMode themeMode) => Get.changeThemeMode(themeMode);
}

ThemeController를 만들어주었다. GetStorage()를 통해, theme에 대한 User Configuration이 앱 스토리지 자체에 저장을 하고 있다. 이제 이 컨트롤러를 통해 theme을 관리하자.

class MyApp extends StatelessWidget {
  MyApp({Key? key}) : super(key: key);
  final themeController = Get.put(ThemeController());

  @override
  Widget build(BuildContext context) {
    return GetMaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'GetX Store',
      initialBinding: StoreBinding(),
      theme: Themes.lightTheme,
      darkTheme: Themes.darkTheme,
      themeMode: themeController.theme,
    );
  }
}
class Home extends GetView<StoreController> {
 Home({Key? key}) : super(key: key);
 final themeController = Get.find<ThemeController>();

 @override
 Widget build(BuildContext context) {
   return Scaffold(backgroundColor: AppColors.spaceCadet,
     appBar: AppBar(title: const Text("GetX Store"),
       actions: [IconButton(
           onPressed: () {
             if (Get.isDarkMode) {
               themeController.changeTheme(Themes.lightTheme);
               themeController.saveTheme(false);
             } else {
               themeController.changeTheme(Themes.darkTheme);
               themeController.saveTheme(true); }},
           icon: Get.isDarkMode
               ? const Icon(Icons.light_mode_outlined)
               : const Icon(Icons.dark_mode_outlined),),], ),
  // some codes below

이런 식으로, themeController를 통해 theme을 관리하는 식도 가능하다.

GetX의 Page Route, SnackBar, Dialog 등

Page Route

GetX의 Page Route 기능에 대한 내용은 간단하게만 소개하겠다. 다만 GetX의 Page Route 기능이 굉장히 편리하다는 것은 누구나 공감할 것이다.

Get.to(() => const SecondPage());
Get.off(() => const SecondPage());
Get.offAll(() => const SecondPage());

Get.toNamed('/second');

Get.back();

이런 식으로 사용 가능하다. Get.to() 는 일종의 Stack 쌓기 느낌으로 페이지가 쌓인다. 그래서 뒤로 가기를 누르면 SecondPage를 호출한 그 이전 페이지, firstPage가 나온다.

반면 Get.off()는 바로 직전 페이지 정보를 무시한다. 정확히는 페이지 스택에서, firstPage를 pop하고 새롭게 SecondPage를 쌓는 느낌이다. 그래서 뒤로 가기를 누르면 firstPage 이전에 있던, 예를 들어 homePage()가 나오는 식이다. 마지막으로 Get.offAll()은 모든 이전 페이지 정보를 무시한다. 즉 스택 위에 쌓인 페이지들을 전부 pop 한다.

SnackBar

스낵바를 이용하기 위해서는, 기존(Navigator 사용)에는 다음과 같이 써야 했다.

ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(
    content: const Column(
      children: [
        Text('title'),
        Text('message'),
      ],
    ),
  ),
);

그런데 이제는 다음과 같이 쓰면 된다.

Get.snackbar(
  'title',
  'message',
  snackPosition: SnackPosition.TOP,
  colorText: Colors.white,
  backgroundColor: Colors.black,
  borderColor: Colors.white);

dialog

여기 dialog의 예제이다.

Get.defaultDialog(
   radius: 10.0,
   contentPadding: const EdgeInsets.all(20.0),
   title: 'title',
   middleText: 'content',
   textConfirm: 'Okay',
   confirm: OutlinedButton.icon(
     onPressed: () => Get.back(),
     icon: const Icon(
       Icons.check,
       color: Colors.blue,     ),
     label: const Text('Okay',
       style: TextStyle(color: Colors.blue),
     ),   ),
 cancel: OutlinedButton.icon(
     onPressed: (){},
     icon: Icon(),
     label: Text(),),);

references