티스토리 뷰

카테고리 없음

[Flutter] State 관리(2) - riverpod

필명을...뭐로해.. 2022. 4. 5. 00:24

Provider의 확장판. flutter_hooks와 결합하여 사용이 가능하고 dart, flutter 둘 다에서 사용이 가능함.

어디서든 변경을 감지할 수 있는 상태 관리 객체

사용 하는 이유

  1. 어디서든 상태 값에 접근 가능
  2. 다른 상태값(Provider)과 결합하여 사용이 용이함
  3. 상태 변화에 영향을 받는 부분에 대해서만 부분 렌더링이 가능.
  4. 로깅 등 다른 feature와 결합하여 사용 가능.
// weatherProvider가 city를 참조함.
final cityProvider = Provider((ref) => 'London');

final weatherProvider = FutureProvider((ref) async {
  final city = ref.watch(cityProvider);
  return fetchWeather(city: city);
});
// 이런식으로 repository를 만들어서 사용하기에 유용
final repositoryProvider = Provider((ref) => UserRepositoty());

final userProvider = FutureProvider((ref){
   final repository = ref.watch(repositoryProvider);
   return repository.getUser();
});

read를 넘겨주는 방식.

final userTokenProvider = StateProvider<String>((ref) => null);

final repositoryProvider = Provider((ref) => Repository(ref.read));

class Repository {
  Repository(this.read);

  final Reader read;

  Future<Catalog> fetchCatalog() async {
    String token = read(userTokenProvider).state;

    final response = await dio.get('/path', queryParameters: {
      'token': token,
    });

    return Catalog.fromJson(response.data);
  }
}

사용하기 전 추가해야할 패키지

riverpod => dart(flutter 사용 안 함)
flutter_riverpod => flutter 앱
flutter_hooks => flutter + flutter_hooks
hooks_riverpod

 

riverpod 구성요소

  • Provider 정의하는 부분
final stateName =
    ChangeNotifierProvider<DataType>((ref) => DataType());
  • 상태 관리 방법을 정의하는 ChangeNotifier 부분
class Person {
  final name;
  final socialNumber;
  Person({this.name, this.socialNumber});
}

class PersonProvider with ChangeNotifier {
  final _personList = <Person>[];
  final _dropDownList = [];
  
  //bool type의 Fetch를 이용하여 데이터를 가져오는 중인지 체크한다.
  bool _isFetch = false;

  get isFetching => _isFetch;

  get getData => _personList;

  fetchData() async {
    if (_isFetch) return;

    _isFetch = true;
    _personList.clear();

    try {
      //Realtime Database에 있는 person Key-Value형식의 데이터를 가지고 옴.
      DataSnapshot dataSnapshot =
          await FirebaseDatabase.instance.reference().child("person").once();
          
      //Iterable에서 Map으로 변경한다.
      Map<String, dynamic> personMap =
          Map<String, dynamic>.from(dataSnapshot.value);
          
      //Person socialNumber(key)와 name(value)를 저장한다.
      personMap.forEach((keySocialNumber, valueName) {
        _personList.add(
            new Person(socialNumber: keySocialNumber, name: valueName));
      });
      
      
	//모든것이 완료되면 notifyListeners를 이용하여 연결되어있는 위젯을 업데이트 시켜준다.
    //setState와 비슷하다고 생각하면 된다.
    //만약 상태는 변경되었지만 notifyListeneres를 호출하지 않으면 위젯은 업데이트되지않는다.
      notifyListeners();
      
    } catch (error) {
    //에러가 발생하는 경우.
      print("ERROR fetchData in PersonProvider.dart : $error");
      
    } finally {
    //모든 작업이 끝나면 fetch가 false로 반환이 되게 한다.
      _isFetch = false;
    }
  }
}
  • 상태 관리와 Widget을 이어주는 ConsumerWidget 부분
class PersonWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    //watch는 Provider를 관찰하는 역할
    //watch된 Provider에서 notifyListener()가 작동하는지 확인한다.
    final watchProvider = watch(riverpodProvider);

    if (watchProvider.isFetching) {
      //Provider에서 isFetch가 true인 경우 데이터를 가져오는 중
      return Center(
        child: CupertinoActivityIndicator(),
      );
    } else {
      //Provider에서 isFetch가 false인 경우 widget을 띄운다.
      return Container();
    }
  }
}

Provider 종류

Provider

riverpod의 가장 중요한 파트 프로바이더는 하나의 상태 조각의 압축된 객체이자 상태변화를 감시하는 역할을 함.

가장 간단한 기본 형태의 Provider 읽기만 가능하며 값을 변경할 수 없다.

final valueProcider = Provider<int>((ref) {
  return 0;
});

Provider를 사용하려면 먼저 전체 앱을 ProviderScope로 감싸줘야 한다.

void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}

 

StateProvider

Provider + state 프로퍼티에 직접 값 변경 => 상태 값 변경 로직이 단순한 경우 사용.

매우 간단한 use-cases에서 StateNotifier를 만들지 않아도 되도록 해줌.

 

StateProvider에서 주로 사용되는 state 들

  • enum타입(filter type 같은)
  • String(일반적으로 text field의 raw 한 데이터)
  • boolean(checkbox에 사용되는 거)
  • number(pagination이나 나이 입력 폼)

StateProvider를 사용하면 안 되는 경우

  • 검증 로직
  • 복잡한 구조의 state 객체(eg. 커스텀 클래스, List, Map,...)
  • 간단한 count++ 같은 거보다 더 복잡한 state를 수정하는 로직들은 다 안된다고 생각하면 됨. 
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: MyHomePage(),
    );
  }
}

class Product {
  Product({required this.name, required this.price});

  final String name;
  final double price;
}

final _products = [
  Product(name: 'iPhone', price: 999),
  Product(name: 'cookie', price: 2),
  Product(name: 'ps5', price: 500),
];

enum ProductSortType {
  name,
  price,
}
  
final productSortTypeProvider = StateProvider<ProductSortType>( // sortType의 name(string)만 변경
  (ref) => ProductSortType.name,
);
final productsProvider = Provider<List<Product>>((ref) { // sortType이 변경되면 리스트 갱신하는 Provider
  final sortType = ref.watch(productSortTypeProvider);
  switch (sortType) {
    case ProductSortType.name:
      return _products.sorted((a, b) => a.name.compareTo(b.name));
    case ProductSortType.price:
      return _products.sorted((a, b) => a.price.compareTo(b.price));
  }
});


class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final products = ref.watch(productsProvider); // product provider watch
    return Scaffold(
      appBar: AppBar(
        title: const Text('Products'),
        actions: [
          DropdownButton<ProductSortType>(
            value: ref.watch(productSortTypeProvider),
            // productSortTypeProvider에서 선택된 값을 리턴하면 드롭다운 위젯의 value로 넣어줌.
            onChanged: (value) =>
                ref.read(productSortTypeProvider.notifier).state = value!,
                // 드롭다운 위젯에서 변경(선택)된 값을 주면 productSortTypeProvider로 state 변경.
            items: const [
              DropdownMenuItem(
                value: ProductSortType.name,
                child: Icon(Icons.sort_by_alpha),
              ),
              DropdownMenuItem(
                value: ProductSortType.price,
                child: Icon(Icons.sort),
              ),
            ],
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text('${product.price} \$'),
          );
        },
      ),
    );
  }
}

 

StateNotifierProvider with StateNotifier

StateNotifier를 리턴하는 provider => 더 복잡한 상태 값 변경 로직을 작성하고 싶은 경우

상태뿐만 아니라 일부 로직을 저장할 때 사용됨.

일반적인 사용법

  • 커스텀 이벤트에 반응해서 시간이 지남에 따라 변경될 수 있는 변경 불가능한 상태를 반환함.
  • 한 장소에서 몇 가지 state변경 로직을 중앙집중화할 때. (시간 지남에 따라 유지보수성이 좋아짐.)
//immutable한 상태(State)
@immutable
class Todo {
  const Todo({required this.id, required this.description, required this.completed});

  // 모든 속성은 final
  final String id;
  final String description;
  final bool completed;

  // immutable 한 Todo객체 이므로 clone method 만들어주기
  Todo copyWith({String? id, String? description, bool? completed}) {
    return Todo(
      id: id ?? this.id,
      description: description ?? this.description,
      completed: completed ?? this.completed,
    );
  }
}

// StateNotifierProvider클래스에 StateNotifier가 던져짐..
// state속성에 외부의 상태를 밖으로 내보내면 안됨.(public한 getter나 속성 없음!)
// 이 클래스에 public 메소드는 UI가 상태를 수정하게하는거 뿐임.
class TodosNotifier extends StateNotifier<List<Todo>> {
  TodosNotifier(): super([]); // empty list로 초기화

  void addTodo(Todo todo) { // immutable 해서 .add()가 안됨 대신 이전 값을 포함한 새로운 리스트를 만듬.
    state = [...state, todo];
    // "state = ~~" 이렇게 하면 state가 변경돼서 notifyListeners가 필요 없음.
  }

  void removeTodo(String todoId) { // todo 리스트를 지우는 메소드
    state = [
      for (final todo in state)
        if (todo.id != todoId) todo,
    ];
  }

  void toggle(String todoId) { // completed 마킹하는 메소드
    state = [
      for (final todo in state)
        if (todo.id == todoId) // completed에 변화가 있을 때
          todo.copyWith(completed: !todo.completed)
        else // 없을 때
          todo,
    ];
  }
}

// UI 와 TodosNotifier클래스가 상호작용 하게 하는 StateNotifierProvider.
final todosProvider = StateNotifierProvider<TodosNotifier, List<Todo>>((ref) {
  return TodosNotifier();
});

Provider 읽기

WidgetRef(ref)?

Widget과 Provider 사이에 상호 작용을 도와주는 역할을 함.(특정 Widget에서 특정 Provider에 자유롭게 접근 가능.)

모든 Provider는 ref 객체를 파라미터로 받게 됨.

// 사용할 Provider
final valueProvider = Provider<int>((ref) {
  return 0;
});

// Stateless --> ComsumerWidget
class MyHomePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final value = ref.watch(valueProvider);
    return Scaffold(
      body: Center(
        child: Text(
          'Value: $value',
        ),
      ),
    );
  }
}

WidgetRef를 얻기 위해 Consumer, ConsumerWidget, ComsumerStatefulWidget을 제공함.

riverpod에서 정의한 위젯들로 WidgetRef를 포함하고 있음. StatelessWidget, StatefulWidget을 대신하여 사용함.

  • StatelessWidget => ConsumerWidget

위젯 생성시, stateless 대신 consumerWidget을 사용함.(extends 함)

build메서드에서 Widget을 리턴하는 것은 똑같으나 ref(WidgetRef)를 파라미터로 받음.

class HomeView extends ConsumerWidget {
  const HomeView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref를 사용해 프로바이더 구독(listen)하기
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}
  • StatefulWidget+state => ConsumerStatefulWidget+ConsumerState

ConsumerWidget과 마찬가지로 위젯 생성 시 stateful대신 사용함.

그런데 build 메소드에서 ConsumerState 객체 속성의 파라미터를 받는다는 것이 다름.

class HomeView extends ConsumerStatefulWidget {
  const HomeView({Key? key}) : super(key: key);

  @override
  HomeViewState createState() => HomeViewState();
}

class HomeViewState extends ConsumerState<HomeView> { //State 대신 ConsumerState<T>
  @override
  void initState() {
    super.initState();
    // "ref"는 StatefulWidget의 모든 생명주기 상에서 사용
    ref.read(counterProvider);
  }

  @override
  Widget build(BuildContext context) {
    // "ref"는 build 메소드 안에서 프로바이더를 변화 감지(watch)
    final counter = ref.watch(counterProvider);
    return Text('$counter');
  }
}

 

  • Consumer와 HookConsumer 위젯

위젯 안에서 ref 객체를 얻기 위한 마지막 방안. ConsumerWidget/HookConsumerWidget과 동일한 속성을 가짐

extends하지 않고 class를 별도로 정의 할 필요가 없음.

Scaffold(
      body: HookConsumer(
        builder: (context, ref, child) {
          // HookConsumerWidget과 같이, builder안에서 hooks을 사용할 수 있습니다.
          final state = useState(0);

          // 프로바이더를 사용/구독(listen)하기 위해서 ref 매개변수도 사용할 수 있습니다.
          final counter = ref.watch(counterProvider);
          return Text('$counter');
        },
      ),
    );

나는 사용 안할거지만

HookWidget대신 HookConsumerWidget 사용하면 됨. 참고 링크

 

WidgetRef를 이용해 읽기

기능 구현 시 가급적으로 ref.watch 사용을 권장함.(reactive와 선언형에 가까워지고 애플리케이션 유지보수 용이해짐)

  • ref.watch
    • 반응형으로 Provider의 값이 변경되면 자체적으로 다시 build 됨.
    • 비동기적으로 호출하거나, onTab, initState 등의 생명주기에서는 사용을 하면 안 됨. => 여기에서는 read 사용 권장.
    • 다른 Provider와 결합할 때 아주 유용하게 쓰임.
final filterTypeProvider = StateProvider<FilterType>((ref) => FilterType.none);
final todosProvider =
    StateNotifierProvider<TodoList, List<Todo>>((ref) => TodoList());

final filteredTodoListProvider = Provider((ref) {
  // 할일(todos)목록과 필터(filter)상태 값을 취득합니다.
  final FilterType filter = ref.watch(filterTypeProvider);
  final List<Todo> todos = ref.watch(todosProvider);

  switch (filter) {
    case FilterType.completed:
      // 완료된(completed) 할일 목록을 반환합니다.
      return todos.where((todo) => todo.isCompleted).toList();
    case FilterType.none:
      // 필터링되지 않은 목록을 반환합니다.
      return todos;
  }
});
  • ref.listen
    • Provider의 값이 변경되면 값을 구독하거나 상태 값 변화에 대응하는 함수를 호출함.
    • watch와 마찬가지로 build 안이나 Provider안에서 사용되어야 함.
    • 두 개의 위치 인자(positional arguments)가 필요. 프로바이더와 콜백 함수(상태변화에 대응).
    • 콜백 함수에는 이전 값과 현재 갱신된 상태 값 두 가지를 넘겨줌.
    • SnackBar나 Dialog를 처리하는데 유용함.
final counterProvider = StateNotifierProvider<Counter, int>((ref) => Counter(ref));

final anotherProvider = Provider((ref) {
  ref.listen<int>(counterProvider, (int? previousCount, int newCount) {
    print('The counter changed $newCount');
  });
  // ...
});
  • ref.read
    • Provider의 값을 읽어오기만 한다. 값이 변경되어도 별다른 동작을 하지 않음.(변경 감지가 아니라 그냥 읽어옴.)
    • 공식문서에 따르면 특별한 경우가 아니면 사용을 하지 않는 것 같다.(영원히 값이 변경되지 않는다는 보장이 있나?라고 물어봄)
    • 공식문서에서는 watch 사용을 권장하고 read를 build 내에서 사용하지 말라고 권장되어있다.
    • 리빌딩되는  횟수를 줄이기 위해 read를 사용하는 경우도 watch도 똑같은 효과를 얻을 수 있기 때문.
      (똑같은 효과를 얻을 수 있도록 Provider가 다양한 방식을 제공함)
final counterProvider = StateProvider((ref) => 0);

// 얘보다는
Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.read(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}
// 이렇게 사용하기~, 이렇게 사용하는게 .refresh를 호출해 카운터 값이 초기화 되어도 대응이 가능함.
Widget build(BuildContext context, WidgetRef ref) {
  StateController<int> counter = ref.watch(counterProvider.notifier);
  return ElevatedButton(
    onPressed: () => counter.state++,
    child: const Text('button'),
  );
}
  • provider.select 관찰하고 싶은 상태 값을 선택하여 선택한 속성의 값이 변화했을 때만 사용할 수 있음.
  • ref.refresh 상태 초기화

StateNotifierProvider 사용(두 가지)

final provider = StateNotifierProvider<MyStateNotifier, MyModel>((ref) {
  return MyStateNotifier();
});
Widget build(BuildContext context, ScopedReader watch) {
  // 방식1 Provider 객체에 접근
  MyStateNotifier notifier = watch(provider.notifier); 
  // 방식2 Provider의 상태(값) 반환
  MyModel state = watch(provider);
}

.family

Provider를 생성할 때, 외부 값을 참조하기 위한 용도

final messagesFamily = FutureProvider.family<Message, String>((ref, id) async {
  return dio.get('http://my_api.dev/messages/$id');
});

Widget build(BuildContext context, WidgetRef ref) {
  final response = ref.watch(messagesFamily('id'));
}

.autoDispose

자동으로 해당 Provider를 삭제. FutureProvider, StreamProvider와 함께 사용됨.

final userProvider = StreamProvider.autoDispose<User>((ref) {
 // 로직 로직
});

final userFutureFamilyProvider =
    FutureProvider.family.autoDispose<UserState, User>(
  (ref, user) {
    return UserState(user);
  },
);

// family와 함께 사용
final shopDetailProvider =
    FutureProvider.family.autoDispose<ShopDetailData, int>(
  (ref, shopId) {
    return ref.watch(shopRepositroyProvider).getShopDetailData(shopId);
  },
);

final detailData = ref.watch(shopDetailProvider(widget.shopId));

return SafeArea(
  child: detailData.when( // when을 사용해 데이터 상태 별 화면을 처리
    // 로딩 화면
    loading: (pre) => const Scaffold(
      body: Center(
        child: CircularProgressIndicator())),
    // 에러 화면
    error: (error, stack, pre) => Scaffold(
      body: Center(
        child: Text(
          error.toString(),
          style: const TextStyle(fontSize: 32)))),
    data: (shopData) { // 데이터 상태별 화면 구현
    	// 결과 화면 구현
    }

 ProviderObserver

보통 Logger를 만들 때 많이 사용함. ProviderContainer의 변화를 관찰한다.
제공하는 메서드

  • didAddProvider: 프로바이더 초기화 시마다 호출됨. 
  • didDisposeProvider: 프로바이더가 disposed 될 때마다 호출됨
  • dodUpdateProvider: 프로바이더 값이 변경될 때마다 호출됨.
class Logger extends ProviderObserver {
  @override
  void didUpdateProvider(
    ProviderBase provider,
    Object? previousValue,
    Object? newValue,
    ProviderContainer container,
  ) {
    print('''
{
  "provider": "${provider.name ?? provider.runtimeType}",
  "newValue": "$newValue"
}''');
  }
}

void main() {
  runApp(
    ProviderScope(observers: [Logger()], child: const MyApp()),
  );
}

추가

Future & Stream Provider

Future Provider

FutureBuilder+Provider라고 생각하면 됨.

null 값이 넘어오지 않도록 값이 준비되지 않았을 때 그 준비기간 동안의 값을 제공함. => initial Value가 있음.

  • 비동기 작업을 캐싱하고 수행함.
  • 비동기 작업의 에러/로딩 상태를 관리하기 좋다.
  • 다른 값에다가 다양한 비동기 값들을 합칠 수 있다.

simple use-case에 맞게 설계된 거라 유저 상호작용이 있는 복잡한 수정은 StateNotifierProvider를 사용하기를 권장함.

사용 예: configureation파일 읽기

final configProvider = FutureProvider<Configuration>((ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
});

Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<Configuration> config = ref.watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(), // unCompleted 상태
    error: (err, stack) => Text('Error: $err'), // 실패
    data: (config) { // 성공
      return Text(config.host);
    },
  );
}

Future complete가 되면 UI를 자동으로 리빌드함. 동시에 다양한 위젯들이 속성 파일을 원할 때 한 번만 읽으면 됨.

 

Stream Provider

StreamBuilder+Provider라고 생각하면 됨.

Stream 타입 값을 제공함. FutureProvider처럼 다른 값이 들어오면 자동으로 값을 리턴한다.

FutureProvider와 다른 점은 값이 필요한 만큼 리빌드 한다.

  • Firebase나 web-sockets의 콜백을 등록
  • 매 몇 초마다 다른 provider가 다시 생성됨.

예시

final futureProvider = FutureProvider<int>((ref) {
  return Future.delayed(const Duration(seconds: 3), () => 5);
});

final streamProvider = StreamProvider<int>((ref) {
  int count = 0;
  return Stream.periodic(const Duration(seconds: 2), (_) => count++);
});

class HomePage extends ConsumerWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final streamAsyncValue = ref.watch(streamProvider);

    final futureAsyncValue = ref.watch(futureProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text('HomePage'),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          Center(
            child: streamAsyncValue.when(
              data: (data) => Text('Value: $data'),
              loading: (_) => const CircularProgressIndicator(),
              error: (e, st, _) => Text('Error: $e'),
            ),
          ),
          Center(
            child: futureAsyncValue.when(
              data: (data) => Text('Value: $data'),
              loading: (_) => const CircularProgressIndicator(),
              error: (e, st, _) => Text('Error: $e'),
            ),
          ),
        ],
      ),
    );
  }
}
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함