Skip to main content Link Search Menu Expand Document (external link)


실습 소스코드


TODO App 개요

  • TODO App 을 위 슬라이드에 나온 세 가지 방식으로 각각 총 세 번 구현한다.
  • 하나의 폴더 내에 세 앱을 각각 만들고, 소스는 폴더 최상단에서 git init 해서 관리해야겠다.


  • Independent State 의 예시로는 TODO Item 의 ‘완료여부’ 를 들 수 있다.
    • 불변하는 값이 아니고 완료가 될 경우 값이 변하는 성질이 있고, 이 변경의 여부에 따라 Widget 의 rebuild 가 필요하기 때문에 ChangeNotifierProvider 를 사용해야한다.
  • Computed State 는 다른 것(것들) 에 의존된 Computed 된 값이다. 예시로는 ‘미완료 Item 수’ 를 들 수 있다.



*이거 되게 중요한 슬라이드다.
*실습 하면서 위 슬라이드 원칙들을 준수하며 살을 붙여나가고 내면화 시키도록 한다.
*TODO App 실습 다 끝내고 위 원칙들과 내가 실습하면서 느낀 것들 종합해서 원칙 다시 정리한다.



App structure




최종적으로 StateNotifierProvider 로 앱을 구현했다. 깃허브에 푸시해두었다. 기록을 위해 Provider 주입 부분과 independent state, computed state 샘플만 코드를 남긴다.

import 'package:flutter/material.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:provider/provider.dart';

import 'providers/providers.dart';
import 'screens/screens.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        StateNotifierProvider<SearchTerm, SearchTermState>(
          create: (_) => SearchTerm(),
        ),
        StateNotifierProvider<Filter, FilterState>(
          create: (_) => Filter(),
        ),
        StateNotifierProvider<Todos, TodosState>(
          create: (_) => Todos(),
        ),
        StateNotifierProvider<ActiveTodoCount, ActiveTodoCountState>(
          create: (_) => ActiveTodoCount(),
        ),
        StateNotifierProvider<FilteredTodos, FilteredTodosState>(
          create: (_) => FilteredTodos(),
        ),
        // ChangeNotifierProvider(
        //   create: (_) => SearchTerm(),
        // ),
        // ChangeNotifierProvider(
        //   create: (_) => Filter(initialFilterType: FilterType.all),
        // ),
        // ChangeNotifierProvider(
        //   create: (_) => Todos(initialTodos: []),
        // ),
        // ProxyProvider<Todos, ActiveTodoCount>(
        //   update: (
        //     _,
        //     Todos todos,
        //     __,
        //   ) =>
        //       ActiveTodoCount(todos: todos),
        // ),
        // ProxyProvider3<Todos, Filter, SearchTerm, FilteredTodos>(
        //   update: (
        //     _,
        //     Todos todos,
        //     Filter filter,
        //     SearchTerm searchTerm,
        //     __,
        //   ) =>
        //       FilteredTodos(
        //           todos: todos, filter: filter, searchTerm: searchTerm),
        // )
      ],
      child: const MaterialApp(
        debugShowCheckedModeBanner: false,
        home: Home(),
      ),
    );
  }
}
import 'package:equatable/equatable.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';

class SearchTermState extends Equatable {
  final String? searchTerm;

  const SearchTermState({
    required this.searchTerm,
  });

  @override
  bool get stringify => true;

  @override
  List<Object> get props => [searchTerm ?? ''];

  SearchTermState copyWith(String searchTerm) {
    return SearchTermState(searchTerm: searchTerm);
  }
}

class SearchTerm extends StateNotifier<SearchTermState> {
  SearchTerm() : super(const SearchTermState(searchTerm: ''));

  void searchTermChange(String searchTerm) {
    state = SearchTermState(searchTerm: searchTerm);
  }
}

// class SearchTerm with ChangeNotifier {
//   late SearchTermState _state;
//   final String? initialSearchTerm;
//
//   SearchTermState get state => _state;
//
//   SearchTerm({
//     this.initialSearchTerm,
//   }) {
//     _state = SearchTermState(searchTerm: initialSearchTerm);
//   }
//
//   void update(String searchTerm) {
//     _state = _state.copyWith(searchTerm);
//     notifyListeners();
//   }
// }
import 'package:equatable/equatable.dart';
import 'package:state_notifier/state_notifier.dart';

import '../models/model.dart';
import '../providers/providers.dart';

class ActiveTodoCountState extends Equatable {
  final int activeTodoCount;

  const ActiveTodoCountState({
    required this.activeTodoCount,
  });

  @override
  List<Object> get props {
    return [activeTodoCount];
  }

  @override
  bool get stringify => true;

  ActiveTodoCountState copyWith(int activeTodoCount) {
    return ActiveTodoCountState(activeTodoCount: activeTodoCount);
  }
}

class ActiveTodoCount extends StateNotifier<ActiveTodoCountState>
    with LocatorMixin {
  ActiveTodoCount() : super(const ActiveTodoCountState(activeTodoCount: 0));

  @override
  void update(Locator watch) {
    final List<Todo> todos = watch<TodosState>().todos;
    final int newActiveTodoCount =
        todos.where((todo) => !todo.isCompleted).toList().length;
    state = ActiveTodoCountState(activeTodoCount: newActiveTodoCount);

    super.update(watch);
  }
}

// class ActiveTodoCount {
//   final Todos todos;
//
//   ActiveTodoCount({
//     required this.todos,
//   });
//
//   ActiveTodoCountState get state {
//     final int newActiveTodoCount =
//         todos.state.todos.where((todo) => !todo.isCompleted).toList().length;
//
//     return ActiveTodoCountState(activeTodoCount: newActiveTodoCount);
//   }
// }

State 를 다룰때 immutable state 을 사용해야 하는 이유 (+ Equatable 원리)

Todo app 실습 중 todo 를 삭제하는 과정에서 rebuild 가 발생하지 않는 현상이 발생했다. 그래서 강사님 코드를 보고 수정했더니 rebuild 가 잘 작동했다. 덕분에 기계적으로 사용했던 Equatable 을 다시 살펴봤고, Notifier 의 state 비교 로직도 다시 살펴보았다.

Object.dart 의 == 과 hashCode

dart 의 모든 객체들은 Object 를 상속하고 있으며, Object 의 == 은 아래와 같다.

  /// The equality operator.
  ///
  /// The default behavior for all [Object]s is to return true if and
  /// only if this object and [other] are the same object.
  ///
  /// Override this method to specify a different equality relation on
  /// a class. The overriding method must still be an equivalence relation.
  /// That is, it must be:
  ///
  ///  * Total: It must return a boolean for all arguments. It should never throw.
  ///
  ///  * Reflexive: For all objects `o`, `o == o` must be true.
  ///
  ///  * Symmetric: For all objects `o1` and `o2`, `o1 == o2` and `o2 == o1` must
  ///    either both be true, or both be false.
  ///
  ///  * Transitive: For all objects `o1`, `o2`, and `o3`, if `o1 == o2` and
  ///    `o2 == o3` are true, then `o1 == o3` must be true.
  ///
  /// The method should also be consistent over time,
  /// so whether two objects are equal should only change
  /// if at least one of the objects was modified.
  ///
  /// If a subclass overrides the equality operator, it should override
  /// the [hashCode] method as well to maintain consistency.
  external bool operator ==(Object other);

The default behavior for all [Object]s is to return true if and only if this object and [other] are the same object. 핵심은 이 문구다. 주소값이 같아야 Object 의 == 는 true 를 return 한다.

종합해서 보면 dart 에서 특별히 == 을 override 하지 않는 이상, 주소값이 같아야만 == 에서 true 를 받을 수 있고 그 외에는 모두 false 이다.

또 == 과 밀접한 연관이 있는(== 에 영향을 주는) hashCode 를 살펴보자.

  /// The hash code for this object.
  ///
  /// A hash code is a single integer which represents the state of the object
  /// that affects [operator ==] comparisons.
  ///
  /// All objects have hash codes.
  /// The default hash code implemented by [Object]
  /// represents only the identity of the object,
  /// the same way as the default [operator ==] implementation only considers objects
  /// equal if they are identical (see [identityHashCode]).
  ///
  /// If [operator ==] is overridden to use the object state instead,
  /// the hash code must also be changed to represent that state,
  /// otherwise the object cannot be used in hash based data structures
  /// like the default [Set] and [Map] implementations.
  ///
  /// Hash codes must be the same for objects that are equal to each other
  /// according to [operator ==].
  /// The hash code of an object should only change if the object changes
  /// in a way that affects equality.
  /// There are no further requirements for the hash codes.
  /// They need not be consistent between executions of the same program
  /// and there are no distribution guarantees.
  ///
  /// Objects that are not equal are allowed to have the same hash code.
  /// It is even technically allowed that all instances have the same hash code,
  /// but if clashes happen too often,
  /// it may reduce the efficiency of hash-based data structures
  /// like [HashSet] or [HashMap].
  ///
  /// If a subclass overrides [hashCode], it should override the
  /// [operator ==] operator as well to maintain consistency.
  external int get hashCode;

If a subclass overrides [hashCode], it should override the [operator ==] operator as well to maintain consistency. 라는 것을 보면 == 를 override 할 경우 반드시 hashCode 도 override 해줘야 하는 것을 알 수 있다.


Equatable 에서는 == 과 hashCode 를 override 한다

아래는 Equatable 내에 있는 == 와 hashCode 이다. 이를 보면 Equatable 를 상속할 경우 Object 의 ==, hashCode 를 사용하지 않고, 아래의 Equatable 의 ==, hashCode 를 사용하게 된다는 것을 알 수 있다.

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is Equatable &&
          runtimeType == other.runtimeType &&
          equals(props, other.props);

  @override
  int get hashCode => runtimeType.hashCode ^ mapPropsToHashCode(props);

이를 바탕으로 Equatable 을 상속하고 있는 TodosState 을 살펴보자.

    Todo todo1 = Todo(id: '1', description: 'test');
    Todo todo2 = Todo(id: '2', description: 'test');
    List<Todo> todos1 = [todo1, todo2];
    List<Todo> todos2 = [todo1, todo2];
    print(todos1.hashCode); // 807548389
    print(todos2.hashCode); // 639492342

    TodosState todosState1 = TodosState(todos: todos1);
    TodosState todosState2 = TodosState(todos: todos2);
    print(todosState1.hashCode); // 556324449
    print(todosState2.hashCode); // 556324449
    print(todosState1 == todosState2); // true

위와 같이 todos 자체는 다른 주소값을 가지고 있어도 해당 todos 의 구성이 todo1, todo2로 같기에 todosState1, todosState2 의 hashCode 는 같은 값을 return 하고 비교 역시 true 가 return 된다.

이번엔 원소의 구성에 변화를 줘보자.

    Todo todo1 = Todo(id: '1', description: 'test');
    Todo todo2 = Todo(id: '2', description: 'test');
    List<Todo> todos = [todo1, todo2];

    print(todos.length); // 2
    print(todos.hashCode); // 960134229
    TodosState todosState1 = TodosState(todos: todos);
    print(todosState1.hashCode); // 592161286

    todos.removeWhere((element) => element.id == '1');
    print(todos.length); // 1
    print(todos.hashCode); // 960134229
    TodosState todosState2 = TodosState(todos: todos);
    print(todosState1.hashCode); // 680645052
    print(todosState2.hashCode); // 680645052

    print(todosState1 == todosState2); // true

결국 최종적으로 todosState1, todosState2 각각의 원소는 todos 라는 똑같은 인스턴스다. 그래서 todos 가 어떻게 변하든지간에 해당 주소값은 동일하다. 그래서 todosState1, todosState2 는 모두 똑같은 todos 라는 인스턴스를 원소로 갖고 있으므로 항상 같을 수 밖에 없다.

내가 범한 실수는 이 포인트에서 나오는데, old state 과 new state 을 지금의 예시에서 todosState1, todosState2 로 뒀다. 즉, StateNotifier 에서 custom method 를 통해 state 에 변화를 줘야 하는데 state 자체는 그대로 두고 state 내의 원소만 바꾼 것이다.

다시 말해, state 에 변화 를 준다는 것은 identical 판정에 대해 완전히 변화를 주기 위해서 주소값 변경까지 고려했어야 하는데 동일한 객체를 두고 내부 원소만 바꿔버리니 위 예시처럼 변화를 줬다한들 결국 같은 object 가 된 것이다. todosState1 에서 todosState2 와 같이 바꿨지만 결국 같은 object 인 것이다.

그래서 state 를 다룰때는 완전히 immutable object 로 새로 만들어내야 한다. 그것이 생각하기도 편하고 버그를 줄이는 방법이다. 그럼 내가 실수한 코드를 보자.

// state 가 변경되었다고 인식되지 않음
void removeTodo(String removeTargetTodoId) {
  print('before : ${state.todos.length}');
  state.todos.removeWhere((todo) => todo.id == removeTargetTodoId);
  print('after : ${state.todos.length}');

  final List<Todo> todos = [...state.todos];
  state = state.copyWith(todos);
}

// 잘 작동하는 코드
void removeTodo(String removeTargetTodoId){
final List<Todo> todos = [...state.todos.where((todo) => todo.id != removeTargetTodoId).toList()];
state = state.copyWith(todos);
}

잘못된 코드를 보면 결국 removeTodo() 함수가 동작하기 전과 후는 state 가 가진 todos 의 내용은 바뀌었을지언정, todos 자체는 그대로이기 때문에 결국 state 의 변화는 발생하지 않았다고 판정된다. 고쳐진 코드에서는 removeWhere 이 아니라 where 을 통해서 필요한 원소들을 찾은 후 toList() 로 완전히 다른 todos 를 만들어서 결국 state 를 바꾸고 있다.

추가로 Equatable 과 관련하여 참고하기 좋았던 포스팅 링크를 남긴다.