위젯의 상태관리를 위젯 외부의 store에서 하는 여러 방법중에서 Riverpod
을 이용해서 CRUD를 구현해보자
먼저 아키텍처를 구상한다.
스프링을 배워 MVC패턴에 익숙해져 있기 때문에 MVC를 변형한 MVCS아키텍처를 구상했다.
일반적인 MVC아키텍쳐에서 Store를 추가한 아키텍처인데 뷰에서 특정 이벤트를 발생시키면 컨트롤러와 레파지토리를 통해 Store를 갱신한다.
뷰는 Provider
를 구독중이므로 변경감지를 통해 화면을 새롭게 렌더링한다.
각각의 역할은 ?
뷰는 로직을 알지 못하고 화면을 렌더링하고 컨트롤러를 호출하는 코드만 존재한다.
레파지토리는 데이터를 송수신하고 Dart오브젝트로 파싱하는 역할만 한다.
컨트롤러는 레파지토리를 호출하고 리턴된 Dart오브젝트를 이용해 Provider
의 상태를 제어 한다.
Provider(Store)
는 관리중인 state
(클래스)를 CRUD로 제어하는 로직을 구현한다.
dependencies:
flutter:
sdk: flutter
dio: ^5.1.1
flutter_riverpod: ^2.3.2
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
ConsumerWidget
를 상속해서 Provider
를 구독하는 클래스를 만든다.
class PostHomePage extends ConsumerWidget {
const PostHomePage({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
PostController pc = ref.read(postController);
PostHomePageModel? pm = ref.watch(postHomePageProvider); // Provider를 지켜본다.
return Scaffold(
body: Column(
children: [
Expanded(
child: pm != null ? buildListView(pm.posts) : buildListView([])), // 리스트
buildButton(pc), // 버튼
],
),
);
}
Riverpod
에서 정의된 추상클래스 WidgetRef
를 통해서 Provider
에 접근 가능하고 ref.watch
를 통해서 변경을 감지하면 해당 위젯을 다시 build
한다.
postController
는 컨트롤러의 Provider
레퍼런스다.
뷰에서는 Provider
레퍼런스를 이용하여 의존성을 주입받은 것처럼 컨트롤러의 메소드를 호출할 수 있게 된다.
PostController pc = ref.read(postController);
컨트롤러는 레파지토리를 호출하고 Provider를 갱신한다.
// PostController 위에 작성
final postController = Provider<PostController>((ref) {
return PostController(ref);
});
Provider
에서 관리할 상태(클래스)의 타입으로 컨트롤러를 등록한다.
매개변수 ref
는 ProviderContainer
의 레퍼런스를 의미하고 내부의 Provider
에 접근이 가능해진다.
Provider
는 다른 Provider
를 포함하거나 조합하여 반환할 수 있는데 이때 ref
를 이용하여 다른 Provider
에 접근한다.
class PostController {
Ref ref;
PostController(this.ref);
// 밑과 이어짐
생성자 인자로 ProviderContainer
의 레퍼런스를 주입해서 컨트롤러가 의존하도록 한다.
이를 통해 컨트롤러는 내부 Provider
의 상태를 관리하게 된다.
PostController
의 상태를 관리 -> 게시글들의 상태를 crud로 관리할 수 있다.
I/O같이 시간이 많이 걸리는 작업은 비동기적으로 처리하고 결과를 반환한다.
비동기적으로 연속된 데이터를 처리하는 클래스로 Stream
도 있다.
Future
클래스는 제네릭을 통해 반환받을 타입을 지정한다.
Future
클래스는 비동기 작업을 하므로 async/await
와 함께 사용된다.
아래의 코드에서는 Repository
를 호출하고 반환받은 결과를 Provider
에 갱신하고 있다.
Future<void> findPosts() async {
List<Post> homePagePostDto = await PostRepository().findAll();
ref.read(postHomePageProvider.notifier).state = PostHomePageModel(posts: homePagePostDto);
}
ref.read
를 이용해서 ProviderContainer
내부의 Provider
객체를 가져온다.
notifier
를 이용해서 StateNotifier
객체의 상태를 변경하거나, 다른 위젯에서 상태를 읽을 수 있게 된다.
state
는 StateNotifier
의 기본 메소드로서 상태를 저장한다.
Future<void> addPost(String title) async {
Post post = await PostRepository().save(title);
ref.read(postHomePageProvider.notifier).add(post);
}
Future<void> removePost(int id) async {
PostRepository().deleteById(id);
ref.read(postHomePageProvider.notifier).remove(id);
}
Future<void> updatePost(Post post) async {
Post postPS = await PostRepository().update(post);
ref.read(postHomePageProvider.notifier).update(postPS);
}
}
데이터를 CRUD로 관리하기 위해 add
, remove
, update
메소드를 이용한다.
위 메소드들은 StateNotifier
의 메소드가 아니므로 직접 구현해야 한다.
컨트롤러의 메소드를 뷰에서 다음처럼 호출하게 된다.
// 이벤트 - 각각의 버튼
onPressed: () {
pc.findPosts(); // read
// pc.addPost("제목추가"); // create
// pc.removePost(1); // delete
// pc.updatePost(Post(id: 2, title: "제목수정")); // update
},
레파지토리는 데이터를 송수신하고 json과 Dart오브젝트를 서로 파싱해주는 역할만 한다.
I/O 과정은 시간이 소요되므로 Future
클래스를 이용해서 비동기 통신을 진행한다.
class PostRepository {
// 싱글톤 생성 ( IoC 컨테이너에 등록된 것 같은 효과 )
static PostRepository _instance = PostRepository._single();
PostRepository._single(); // 기본 생성자
factory PostRepository(){ // factory가 메모리에 주소가 없다면 새로 만들고 있다면 재사용한다.
return _instance;
}
// DB는 생략함, 더미데이터를 만들어 데이터를 송수신한다고 가정 !!
Future<List<Post>> findAll() {
return Future.delayed(Duration(seconds: 1), () { // I/O에 1초가 걸렸다고 가정
return [
Post(id: 1, title: "제목1"),
Post(id: 2, title: "제목2"),
Post(id: 3, title: "제목3"),
];
});
}
// save할때 데이터 영속화 -> REST API를 통해 저장된 데이터를 반환받음
Future<Post> save(String title) {
return Future.delayed(Duration(seconds: 1), () {
return Post(id: 4, title: title); // id는 임의 - 자동생성키로 가정
});
}
Future<void> deleteById(int id) {
return Future.delayed(Duration(seconds: 1));
}
// 마찬가지로 save -> 영속화 객체 존재(더티체킹) or update -> 데이터 반환
Future<Post> update(Post post) {
return Future.delayed(Duration(seconds: 1), () {
return post;
});
}
}
MVC+S아키텍처에서 상태를 관리할 Provider
를 생성한다.
Store
에서 관리할 상태 데이터를 등록한다.
json을 파싱한 여러 Dart오브젝트가 들어갈 수 있다.
class PostHomePageModel {
List<Post> posts;
PostHomePageModel({required this.posts});
}
상태 데이터 PostHomePageModel
를 관리할 Store
를 생성한다.
상태를 관리하기 위한 클래스인 StateNotifier
를 상속하여 상태를 관리하는 메소드를 정의할 수 있게 되고 상태 변경 시점을 제어할 수 있게 된다.
class PostHomePageViewModel extends StateNotifier<PostHomePageModel?>{
PostHomePageViewModel(super.state);
}
스토어를 관리할 StateNotifierProvider
를 등록한다.
StateNotifierProvider
는 StateNotifier
를 Provider
로 제공해준다.
즉, 어느 위치에서도 Provider
를 통해 상태를 제어할 수 있게 된다.
final postHomePageProvider = StateNotifierProvider<PostHomePageViewModel, PostHomePageModel?>((ref) {
return PostHomePageViewModel(null);
});
첫번째 인자는 관리하려는 스토어를 넣는다. -> StateNotifier
타입
두번째 인자는 스토어에서 관리할 상태를 넣는다. (스토어가 제어하는 상태 클래스)
상태데이터가 처음에는 없을수도 있기 때문에 null을 이용해서 초기화를 시켰다.
스토어에 들어갈 타입을 구체적으로 지정한다면 매번 코드를 수정해야 하는 불편함이 생길수도 있어 상태클래스를 만들고 상태클래스의 필드를 변화를 준다.
특정한 페이지에서 여러 테이블을 조합한 데이터를 필요로 한다면 해당 데이터를 받을 수 있는 상태클래스를 새로 만들고 위 과정으로 새로운 Provider
를 생성해서 관리하면 된다.
레파지토리에서 Dart오브젝트와 dio라이브러리를 이용해서 파싱을 한다.
class Post {
int? userId;
int? id;
String? title;
String? body;
User? user; // User 오브젝트를 포함
Post({
this.userId,
this.id,
this.title,
this.body,
this.user,
});
factory Post.fromJson(Map<String, dynamic> json) => Post(
userId: json["userId"],
id: json["id"],
title: json["title"],
body: json["body"],
user: User.fromJson(json["user"]), // 오브젝트를 넣어줌
);
Map<String, dynamic> toJson() => {
"userId": userId,
"id": id,
"title": title,
"body": body,
"user": user?.toJson(), // null 이 아닐때 메소드 접근
};
}
StateNotifier
에서는 상태를 제어할 메소드를 등록할 수 있다.
class PostHomePageViewModel extends StateNotifier<PostHomePageModel?>{
PostHomePageViewModel(super.state);
// 초기화 ( 화면 새로 고침 한다면 )
void init(List<Post> postDtoList){
state = PostHomePageModel(posts: postDtoList);
}
// save 기능 추가
void add(Post post) {
List<Post> posts = state!.posts;
// 스프레드 연산자로 새로운 컬렉션 생성 - 깊은 복사
List<Post> newPosts = [...posts, post]; // 복사 + 추가
state = PostHomePageModel(posts: newPosts);
}
state
를 이용해서 상태클래스 PostHomePageModel
에 접근한다.
변경감지는 레퍼런스 주소가 달려져야 작동한다.
스프레드 연산자를 이용해서 새로운 컬렉션을 생성하면 레퍼런스 주소가 달라진다.
레퍼런스 주소를 다르게 하여 변경감지를 하는 이점을 예로들면
10만개의 리스트가 있는 컬렉션의 데이터를 하나만 변경했을때 변경감지를 한다면 리스트를 모두 돌면서 데이터를 비교하는해야 해야하지만 컬렉션의 레퍼런스를 비교하면 훨씬 효율적으로 변경감지를 하게 된다.
또한 새로운 컬렉션을 만들게 되면 개발자의 실수도 줄여준다.
하지만 레퍼런스가 변경되더라도 내부 데이터가 그대로라면 플러터는 다시 렌더링 하지 않는다
// delete
void remove(int id){
List<Post> posts = state!.posts;
List<Post> newPosts = posts.where((e) => e.id != id).toList();
state = PostHomePageModel(posts: newPosts);
}
스트림의 where
는 지정한 조건을 만족하는 요소를 추출하여 새로운 스트림을 생성한다.
따라서 where
조건을 이용해서 제거를 하거나 검색된 오브젝트를 반환받을 수 있다.
// update
void update(Post post){
List<Post> posts = state!.posts;
List<Post> newPosts = posts.map((e) => e.id == post.id ? post : e).toList();
state = PostHomePageModel(posts: newPosts);
}
}
스트림의 map
은 값을 변환하여 새로운 스트림을 반환한다.
특정 게시글만 수정하고 나머지는 그대로 복사한뒤 상태에 저장한다.
이러한 과정을 통해서 MVC+S아키텍처에서는 어디서든 원하는 모델의 상태를 제어하고 변화가 필요한 위젯만 build하게 되어 쓸데 없이 많은 위젯을 렌더링 하지 않아 효율적인 메모리 관리와 자원을 사용하게 된다.