[Flutter] 13. fromMap

문정준's avatar
Jun 04, 2025
[Flutter] 13. fromMap
 

1. 데이터 통신

  • 프론트와 REST API 서버는 서로 JSON을 주고 받음
    • JSON = 중간 데이터
  • 데이터를 활용하기 위해서는 자신의 언어에 맞는 데이터로 변환해서 사용해야 함
    • JSON 변환 함수가 필요
  • Flutter의 Map은 JSON과 같은 형태를 가짐
    • JSON을 Map으로 변환하는 라이브러리도 보유 (Dio)
notion image

2. fromMap

  • 통신을 통해 받아온 Map 데이터를 VM의 state에 저장하기 위해 사용하는 함수
  • 댓글의 경우 리스트를 따로 사용하지 않고 레코드를 사용
    • 이름이 있는 레코드 사용 시 값 대입이 편리
  • Initialized Keyword 사용으로 값을 바로 초기화할 수 있음
class Post { int id; int userId; String title; List<({int id, String comment, String owner})> replies; Post(this.id, this.userId, this.title, this.replies); // 1. fromJson -> fromMap (dio 통신 라이브러리) // REST API -> Map(dio) -> Post Post.fromMap(Map<String, dynamic> data) : id = data["id"], userId = data["userId"], title = data["title"], replies = (data["replies"] as List) .map( (e) => ( id: e["id"] as int, comment: e["comment"] as String, owner: e["owner"] as String, ), ) .toList(); // copyWith Post copyWith({ int? id, int? userId, String? title, List<({int id, String comment, String owner})>? replies, }) { return Post( id ?? this.id, userId ?? this.userId, title ?? this.title, replies ?? this.replies, ); } } void main() { // given Map<String, dynamic> data = { "id": 1, "userId": 1, "title": "제목1", "replies": [ {"id": 1, "comment": "댓글1", "owner": "ssar"}, {"id": 2, "comment": "댓글2", "owner": "cos"}, {"id": 3, "comment": "댓글3", "owner": "love"}, ], }; Post post = Post.fromMap(data); print(post.id); print(post.userId); print(post.title); print(post.replies); }
 
notion image
 

3. copyWith

  • 기존에 존재하던 값을 업데이트해야 할 경우 얕은 복사로는 Provider가 변화 감지를 할 수 없음
  • 객체 자체를 교체해야 변화 감지 가능
  • 미리 존재하던 객체에서 새로운 값만 대입해서 값을 바꾼 객체로 현재 상태를 바꿈
    • 바꿀 값만 넣으면 다른 값들은 이전 상태의 값 유지
// copyWith Post copyWith({ int? id, int? userId, String? title, List<({int id, String comment, String owner})>? replies, }) { return Post( id ?? this.id, userId ?? this.userId, title ?? this.title, replies ?? this.replies, ); } Post post2 = post.copyWith(userId: 5); print(post2.id); print(post2.userId); print(post2.title); print(post2.replies);
 
notion image
 

4. VM (Riverpod)

  • Riverpod의 3가지 요소
    • 창고 관리자 : Provider
    • 창고 : 비즈니스 로직 (build, 여러 데이터 처리 함수 작성)
    • 창고 데이터 타입 : 값이 저장될 primitive or class 작성
  • 통신의 경우 비동기적으로 작동해야 하므로, 실제 값을 전달하는 것이 아닌 값이 들어갈 상자를 전달
    • 통신이 끝나면 값이 채워짐
      • 이 형태를 Dart에서는 Future 타입으로 지원
    • 자바의 경우 멀티 스레드를 지원하므로 비동기 함수를 그대로 사용할 수 있음
    • Flutter는 단일 스레드로 작동하고, 화면에 비동기적으로 데이터 표현 시 UX가 낮아질 수 있음
      • 데이터를 전부 불러온 후 화면 표시
  • 실제 받아오는 데이터의 경우 null이 들어올 수도 있기 때문에 ? 타입을 붙이는 것이 좋음
  • 비동기 함수의 경우, async와 await 부착 필요
    • 자바스크립트와 다르게 async는 함수 맨 끝에 부착
import 'package:flutter_prac_data/repository/post_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // 1. 창고 관리자 final postProvider = NotifierProvider<PostVM, Post?>(() { return PostVM(); }); // 2. 창고 (비지니스 로직) class PostVM extends Notifier<Post?> { PostRepository postRepository = PostRepository.instance; @override Post? build() { init(); return null; } Future<void> init() async { Post post = await postRepository.findById(1); state = post; } Future<void> update() async { Post post = await postRepository.update(); state = state!.copyWith(title: post.title); } } // 3. 창고 데이터 타입 class Post { int? id; int? userId; String? title; List<({int id, String comment, String owner})> replies; Post(this.id, this.userId, this.title, this.replies); // 1. fromJson -> fromMap 으로 만든다. (Dio 통신라이브러리) // 무엇을(restapi서버 -> dio -> map 데이터) -> 어떻게(post 데이터) Post.fromMap(Map<String, dynamic> data) : id = data["id"], userId = data["userId"], title = data["title"], replies = (data["replies"] != null) ? (data["replies"] as List) .map( (e) => ( id: e["id"] as int, comment: e["comment"] as String, owner: e["owner"] as String, ), ) .toList() : []; // 2. copyWith // copyWith 메서드 (특정 필드만 바꾸는 복사 생성자) Post copyWith({ int? id, int? userId, String? title, List<({int id, String comment, String owner})>? replies, }) { return Post( id ?? this.id, userId ?? this.userId, title ?? this.title, replies ?? this.replies, ); } }
 

5. Repository

  • 서버에서 데이터를 주고받는 위치
    • body 또는 에러 메시지를 파싱하여 전달
  • Repository에서 에러 캐치 시, 이를 VM 또는 Page, Main까지 전달 가능
    • VM, Page에서 에러 처리를 넘기면 Main에서 처리 가능
    • Main에서 ExceptionHandler 등을 통해 throw로 넘긴 에러들을 전부 처리 가능
    • 어떤 화면에서 에러가 발생했고, 에러가 발생한 화면에 경고창 등을 통해 알려야 하므로 context를 전달받아야 함
  • Singleton Pattern 사용 : 함수를 통해서만 Repository 생성 → 의존성 부여
import 'package:flutter_prac_data/page/post_vm.dart'; // 서버접근 + 파싱 class PostRepository { static PostRepository instance = PostRepository._single(); PostRepository._single(); Future<Post> findById(int id) async { // 1. 통신 코드 Map<String, dynamic> response = await Future.delayed( Duration(seconds: 5), () { return _mockPost; }, ); Post post = Post.fromMap(response); // 2. 리턴 return post; } Future<Post> update() async { // 1. 통신 코드 Map<String, dynamic> response = await Future.delayed( Duration(seconds: 5), () { return _mockUpdatePost; }, ); Post post = Post.fromMap(response); // 2. 리턴 return post; } } // 가짜 데이터 final Map<String, dynamic> _mockUpdatePost = {"id": 1, "userId": 3, "title": "제목10"}; // 가짜 데이터 final Map<String, dynamic> _mockPost = { "id": 1, "userId": 3, "title": "제목1", "replies": [ {"id": 1, "comment": "댓글1", "owner": "ssar"}, {"id": 2, "comment": "댓글2", "owner": "cos"}, {"id": 3, "comment": "댓글3", "owner": "love"}, ], };
 

6. Page

  • 창고 관리자 : ConsumerWidget 변경 후 WidgetRef로 호출 가능
    • state 접근 : Provider를 read 또는 watch
      • read : const
      • watch : 일반 객체 (값이 변할 수 있음)
    • 창고 접근 (함수) : Provider.notifier를 read
  • model이 통신을 통해 전달 받기 때문에 null일 수 있음
    • null 일 경우 Progress Bar 등을 노출
    • 데이터가 들어온 경우 watch를 통해 값의 변화를 인지 : 화면 작성
  • 버튼 클릭 시 update 함수가 작동
    • 창고를 받아와야 하므로 notifier read가 필요
import 'package:flutter/material.dart'; import 'package:flutter_prac_data/page/post_vm.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class PostPage extends ConsumerWidget { PostPage(); @override Widget build(BuildContext context, WidgetRef ref) { PostVM vm = ref.read(postProvider.notifier); return Scaffold( body: Column( children: [ Expanded(child: PostDetail()), ElevatedButton( onPressed: () { vm.update(); }, child: Text("값변경"), ), ], ), ); } } class PostDetail extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // 1. 창고 관리자 만들기 (watch, read) Post? model = ref.watch(postProvider); // 2. null 체크 if (model == null) { return Center(child: CircularProgressIndicator()); } else { return Column( children: [ Text("id : ${model.id}, userId: ${model.userId}, title : ${model.title}"), Expanded( child: ListView.builder( itemCount: model.replies.length, itemBuilder: (context, index) { var reply = model.replies[index]; return ListTile( leading: Text("${reply.id}"), title: Text("${reply.comment}"), trailing: Text("${reply.owner}"), ); }, ), ), ], ); } } }
 

Result

  • 실행 시간부터 5초 동안 대기 상태
    • 통신 시간 고려
notion image
 
  • 데이터 로딩 완료 시
    • id, userId, 제목, 댓글 리스트 표시
notion image
 
  • 값 변경 클릭 시 5초 후 제목 변경
    • 통신 시간 고려
notion image
 

7. Recycling Data

  • 위의 경우는 body 내에서 글, 댓글을 한 번에 받아오기 때문에, 부분적인 업데이트가 어려움
    • 케이스 별로 생성자를 계속 만들어야 하는 상황이 생김
  • 이를 위해 Post의 내부 모델을 구분하여 저장할 수 있도록 모델의 분리가 필요
    • Post : id, userId, title만 저장
    • List<Reply> : id, comment, owner가 담긴 Reply 리스트 저장
    • 변경된 값을 전송받았을 때에 Map으로 변환하는 fromMap 생성자를 각각 작성
 
class Post { int? id; int? userId; String? title; void update(String title) { this.title = title; } // 일반 생성자 Post(this.id, this.userId, this.title); // fromMap 생성자 (initializer list 사용) Post.fromMap(Map<String, dynamic> data) : id = data['id'], userId = data['userId'], title = data['title']; }
class Reply { int? id; String? comment; String? owner; // 일반 생성자 Reply(this.id, this.comment, this.owner); // fromMap 생성자 Reply.fromMap(Map<String, dynamic> data) : id = data['id'], comment = data['comment'], owner = data['owner']; }
 
  • Post로 받던 데이터는 Post와 List<Reply>를 합친 PostModel로 변경
    • copyWith를 통해 update한 post 또는 replies를 사용한 새 객체 생성으로 state 갱신
    • 업데이트 된 데이터를 불러올 때에는 데이터 반환 여부에 따라 다르게 수행
      • 반환되는 데이터가 없을 경우 : init() 함수 호출로 새로 데이터를 불러옴
      • 반환되는 데이터가 있을 경우 : init() 함수 호출 또는 fromMap() 함수로 새 모델 생성 후 copyWith() 사용하여 state 갱신
import 'package:flutter_prac_data/model/post.dart'; import 'package:flutter_prac_data/model/reply.dart'; import 'package:flutter_prac_data/repository/post_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // 1. 창고 관리자 final postProvider = NotifierProvider<PostVM, PostModel?>(() { return PostVM(); }); // 2. 창고 (비지니스 로직) class PostVM extends Notifier<PostModel?> { PostRepository postRepository = PostRepository.instance; @override PostModel? build() { init(); return null; } Future<void> updateV2() async { // 통신 (제목10) await postRepository.update(); Post prevPost = state!.post; prevPost.update("제목10"); state = state!.copyWith(post: prevPost); } Future<void> update() async { // 통신 final response = await postRepository.update(); Post nextPost = Post.fromMap(response); state = state!.copyWith(post: nextPost); } Future<void> init() async { final response = await postRepository.findById(1); state = PostModel.fromMap(response); } } // 3. 창고 데이터 타입 class PostModel { final Post post; final List<Reply> replies; // 레코드 // 생성자 PostModel({required this.post, required this.replies}); // fromMap 생성자 PostModel.fromMap(Map<String, dynamic> data) : post = Post.fromMap(data), replies = (data["replies"] as List<dynamic>).map((e) => Reply.fromMap(e)).toList(); // copyWith PostModel copyWith({ Post? post, List<Reply>? replies, }) { return PostModel( post: post ?? this.post, replies: replies ?? this.replies, ); } }
 
Share article

sxias