ChangeNotifier, addListener를 이용해 앱 상태관리하기

Flutter로 앱을 만들고 계신가요? 앱이 점점 복잡해질수록 데이터 상태 관리에 더욱 많은 신경을 써야합니다. 페이지 안에서는 변수를 설정하여 데이터 관리가 가능하지만 페이지가 많아지고 기능이 많아지게 되면 구조 상 동떨어진 UI에서 같은 데이터를 다뤄야 할 경우가 생깁니다. 이 글에서는 ChangeNotifier, addListener를 이용해 앱 상태관리하는 방법에 대해서 구체적으로 알아보도록 하겠습니다.

ChangeNotifier 비유로 알아보기

router를 이용한 상태 관리 비유

router를 이용한 상태관리

Navigator나 Go Router 등을 이용해서 페이지를 이동할 경우 데이터를 싣어 보낼 수 있습니다. 이동한 페이지에서 업데이트 된 데이터를 다시 전달하여야 본 페이지에서는 수정된 데이터를 볼 수 있게 됩니다. 계주달리기에서 일종의 바톤을 넘기면서 데이터를 넘기는 방식으로 생각하시면 되겠습니다.

ChangeNotifier를 이용한 상태관리 비유

changeNotifier를 이용한 상태관리

이 글에서 알아볼 ChangeNotifier를 이용하는 방법은 일종의 클라우드 서비스를 이용하여 파일을 관리하는 것에 비교해 볼 수 있습니다. 파일은 클라우드 서비스에 보관되어 있고 사용자가 노트북이나 자신의 데스크탑이나 혹은 핸드폰을 이용해서 파일을 보고 싶으면 클라우드에 접속해서 파일을 확인하고 수정을 하면 이를 다시 클라우드 저장소에 업데이트하는 것처럼 중앙(가장 상위 위젯)에서 데이터를 관리하고 하위 위젯들이 이에 접속하여 데이터를 사용하는 형태가 되는 것입니다.

ChangeNotifier는 무엇일까?

ChangeNotifier는 위젯을 상태관리가 가능하도록 만들어주는 클래스 입니다.

일단 ChangeNotifier는 Flutter의 Native 패키지이며 Provider와 RiverPod 패키지에서 많이 사용됩니다.

Notifier는 ‘알림’이라는 사전적 의미를 갖고 있습니다. ChangeNotifier는 말 그대로 해석하면 ‘상태변화알림’이 됩니다. 알림이라는 단어의 의미에서도 알 수 있듯이 정보를 제공하는 쪽이 됩니다. ChangeNotifier는 클래스입니다. ChangeNotifier 클래스를 상속(extends)하거나 믹스인(with)하는 경우 ‘상태변화알림’기능을 사용할 수 있는 클래스가 됩니다.

앞선 비유에서 클라우드 서비스에 비교할 수 있겠습니다. 어느 PC든 자신의 폴더 중 하나를 클라우드 서비스와 연결시키면 그 안에 있는 데이터는 클라우드 서비스를 통해서 어느 곳에서든 접근이 가능해지게 되는 방식입니다.

NotifyListeners() 메서드

ChangeNotifier를 이용할 경우 반드시 사용하게 되는 것이 바로 NotifyListeners라는 메서드입니다. 실제로 ChangeNotifier를 상속받은 클래스가 실제 상태를 리스너들에게 전달하기 위해서 notifyListeners()라는 메서드를 이용하게 됩니다. ‘구독자들에게 알려줘’라는 의미인거죠.

ChangeNotifier를 확장한 클래스를 구독(subscribe)할 수 있고 이 클래스에 상태 변경이 있을 경우 클래스의 notifyListensers() 메서드를 호출할 수 있게됩니다. 이를 통해 모든 listener들에게 상태 변경을 전달할 수 있습니다.

notifyListeners() 메서드는 상태를 구독자들이 동기화시키는 타이밍을 결정합니다. notifyListeners() 메서드를 통해서 상태가 변화되었다는 것을 알리지 않으면 하위의 구독자들이 상태변화를 인지할 수 있는 방법이 없습니다.

ChangeNotifier 사용(Consume)하기

ChangeNotifier 클래스를 만들고 그 안에 메서드를 만들고 특정 이벤트에서 구독자들에게 알림을 주기 위한 notifyListeners() 메서드를 만든다는 것까지 알아보았습니다. ChangeNotifier를 상속받아 상태를 알려줄 수 있는 클래스를 만들었다면 이제 이를 받아 UI를 변경하는데 사용하는 Widget들에서 어떻게 소비(consume)하는지에 대해서 알아보겠습니다.

ChangeNotifier를 사용하는 방법에는 크게 세 가지 방법으로 나눌 수 있습니다. 이 글에서는 그 중 가장 기본이 되는 addListener를 이용하는 방법에 대해서 먼저 이야기합니다.

  • ChangeNotifier는 Listenable 타입으로 addListener() 메서드를 이용해 적용할 수 있습니다.
  • Animated Builder를 이용해 사용할 수 있습니다. 이 때도 listenable을 이용합니다.
  • 마지막으로, ChangeNotifierProvider, Consumer와 Provider를 이용하는 방법이 있습니다.

addListener

  • ChangeNotifier를 상속받은 클래스를 글로벌 변수(global variable)로 설정
  • 위젯트리(widget tree)의 종속관계(dependency)에서 분리됨
  • 사용하지 않을 경우 수동으로 dispose 해야 함

Animated Builder

  • 수동으로 setState를 호출하지 않아도 됨
  • 수동으로 dispose 할 필요 없음
  • 위젯트리(widget tree)의 종속관계(dependency)에서 분리됨

Provider

  • pubspec.yaml 파일에 provider 추가해야 함
  • widget과 같이 사용되며, ChangeNotifier를 이용하는 위젯을 모두 포함하는 위젯을 감싸도록 설정함
  • ChangeNotifierProvider는 InheritedWidgets의 간소화 버전임
  • MultiProvider를 이용해서 복수의 Provider를 적용할 수 있음

addListener를 이용한 구독/해지하기

ChangeNotifier 인스턴스 생성

ChangeNotifier를 구독하기 위해서 가장먼저 해야할 작업은 ChangeNotifier를 상속받은 Notifier 클래스를 정의하고 이 클래스의 인스턴스의 생성입니다.

addListener 이용하여 구독하기

앞서 생성한 ChangeNotifier 인스턴스에 addListener 메서드를 통해 해당 인스턴스에 대한 구독을 시작하게 됩니다. 이제 위젯 트리 상 하위에 있는 위젯들은 트리 상 연결되어 있다면 ChangeNotifier 인스턴스를 통해서 상태 접근이 가능하게 됩니다.

removeListener 이용하여 구독해지

구독 해지는 removeListener() 메서드를 이용해서 이뤄집니다. 위젯의 dispose를 정의할 때 인스턴스도 같이 dispose 함으로써 메모리를 관리할 수 있게됩니다.

예제 – 학생관리 앱

위 설명을 기반으로 실제로 작동하는 앱을 하나 만들어 보도록 하겠습니다.

DartPad를 이용해서 직접 실행해보기

addListener를 이용해 앱 상태관리

앱 구동 모습은 위와 같습니다. 메인 페이지에서는 학생의 목록이 나타납니다. 학생정보는 이름과 나이정보를 관리합니다. 오른쪽 하단의 FloatingActionButton을 클릭하면 새로운 학생을 추가할 수 있으며, 목록에 있는 학생의 이름을 클릭하면 해당 학생의 정보를 수정할 수 있습니다.

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


const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

class Student {
  Student({required this.name, required this.age});
  
  final String name;
  int age;
}

// 1
class PlayerNotifier with ChangeNotifier {
  final List<Student> _players = <Student>[];
  int _size = 0;
  
  List<Student> getPlayers() => UnmodifiableListView(_players);
  int getSize() => _size;
  
  // 2
  void add(Student student){
    _players.add(student);
    _size++;
    notifyListeners();
  }
  
  void delete(int index){
    _players.removeAt(index);
    _size--;
    notifyListeners();
  }
  
  void modify(int index, Student student){
    _players[index] = student;
    notifyListeners();
  }
}

AppBar getAppBar(String title){
  return AppBar(
    centerTitle: true,
    elevation: 0, 
    title: Text(title),
  );
}

ListTile getListTile(List<Student> players, int index){
  return ListTile(
    leading : CircleAvatar(child: Text(index.toString())),
    contentPadding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15.0),
    title: Text('${players[index].name}, age : ${players[index].age}'),
  );
}


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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: ListenChangeNotifier()
    );
  }
}


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

  @override
  State<ListenChangeNotifier> createState() =>
      _ListenChangeNotifierState();
}

// 3
PlayerNotifier playerNotifier = PlayerNotifier();

class _ListenChangeNotifierState extends State<ListenChangeNotifier> {
  @override
  void initState() {
    super.initState();
    // 4
    playerNotifier.addListener(() => mounted ? setState(() {}) : null);
  }

  @override
  void dispose() {
    // 5
    playerNotifier.removeListener(() {});
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: getAppBar('Default Change Notifier Example'),
        floatingActionButton: FloatingActionButton(
          onPressed: () => Navigator.of(context).push(
            MaterialPageRoute(builder: (context) => const AddPage()),
          ),
          child: const Icon(Icons.add),
        ),
        
        
        body: _getListView(),
      ),  
    );
  }
  
  ListView _getListView() {
    return ListView.builder(
      // 6
      itemCount: playerNotifier.getSize(),
      itemBuilder: (context, index) {
        return GestureDetector(
          onTap: () => Navigator.of(context).push(
            MaterialPageRoute(builder: (context) => ModifyPage(index: index)),
          ),
          // 7
          child: getListTile(playerNotifier.getPlayers(), index),
        );
      },
    );
  }
  
}

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

  @override
  Widget build(BuildContext context) {
    TextEditingController textEditingController =
        TextEditingController(text: "Name");
    TextEditingController ageEditingController =
        TextEditingController(text: "Age");

    return Scaffold(
      appBar: getAppBar("Add Student"),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Student stu = Student(
            name: textEditingController.text, 
            age: int.parse(ageEditingController.text),);
          // 8 
          playerNotifier.add(stu);
          Navigator.pop(context);
        },
        child: const Icon(Icons.add),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 15.0),
          child: Column(
            children : [
              TextField(
                controller: textEditingController,
              ),
              const SizedBox(height : 16),
              TextField(
                controller: ageEditingController,
              ),
            ]
          ), 
        ),
      ),
    );
  }
}


class ModifyPage extends StatelessWidget {
  const ModifyPage({
    Key? key,
    required this.index,
  }) : super(key: key);
  final int index;

  @override
  Widget build(BuildContext context) {
    // 9 
    TextEditingController textEditingController =
        TextEditingController(text: playerNotifier.getPlayers()[index].name);
    TextEditingController ageEditingController =
        TextEditingController(text: playerNotifier.getPlayers()[index].age.toString());
    
    
    return Scaffold(
      appBar: getAppBar("Update Player"),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Student stu = Student(
            name: textEditingController.text, 
            age: int.parse(ageEditingController.text),);
          // 10
          playerNotifier.modify(index, stu);
          Navigator.pop(context);
        },
        child: const Icon(Icons.add),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 15.0),
          child: Column(
            children : [
              TextField(
                controller: textEditingController,
              ),
              const SizedBox(height : 16),
              TextField(
                controller: ageEditingController,
              ),
            ]
          ), 
        ),
      ),
    );
  }
}

앞서 설명드린 과정이 코드에서 어떻게 구현이 되었는지 살펴보도록 하겠습니다.

  1. ChangeNotifier를 상속받은 PlayerNotifier라는 클래스를 생성하였습니다.
  2. 내부 메서드로 add, delete, modify가 정의되어 있습니다. 각각의 메서드는 이벤트를 발생시키고 상황을 전파하는 nofityListeners()가 각각 정의되어 있습니다.
  3. PlayerNotifier 타입의 playerNotifier 인스턴스가 정의됩니다.
  4. 초기화 과정에서 playerNotifier 인스턴스가 addListener를 통해서 상태 구독을 시작합니다.
  5. 구독을 종료할 경우 removeListener를 이용해서 구독을 해지합니다.
  6. ListView.builder에서 itemCount값을 playerNotifier에 정의된 getSize 함수를 이용해서 받아옵니다.
  7. PlayerNotifier 클래스에 정의된 getPlyaers() 함수는 리스트 형식으로 정의된 학생 목록을 반환합니다.
  8. AddPage에서는 playerNotifier의 add 메서드를 통해 학생을 목록에 추가합니다.
  9. ModifyPage에서는 ListView의 index값을 받아서 playerNotifier의 학생정보를 받습니다.
  10. playerNotifier 내 modify 함수를 통해서 학생의 정보를 수정합니다.

결론

결과적으로 복잡한 구조의 앱에서 상태관리를 효율적으로 하기 위해서는 ChangeNotifier를 사용해야 하며, 상태를 사용하는 방법 중 가장 기본이 되는 addListener를 이용한 방법을 알아보았습니다. 위 코드를 다양하게 변형하시면서 자신에게 맞는 상태관리 방법을 찾으시길 바랍니다.

Leave a Comment