티스토리 뷰
기본 개념
- 각 기능의 UI계층은 View + ViewModel로 구성됨
- ViewModel: UI 상태 관리, 로직 처리
- View: UI 상태를 표시, ViewModel과 1:1 관계
- 예: LogOutView, LogOutViewModel
ViewModel 역할
- ViewModel은 UI 로직을 처리하는 역할 클래스
- 도메인 모델 데이터를 입력받아 View가 사용할 수 있도록 UI 상태로 변환
- 버튼 클릭 등의 이벤트 처리 로직 포함
- 이벤트를 데이터 계층으로 전달하여 변화 유도
다음 코드는 HomeViewModel이다. 이 뷰모델은 데이터를 제공받기 위해 BookingRepository와 UserRepository에 의존한다.
// home_viewmodel.dart
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) :
// 저장소(Repository)는 비공개 멤버로 수동 할당됨
_bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ...
}
핵심 구조 원칙
- ViewModel은 데이터 저장소에 의존하며, 이 저장소들은 생성자에 인자로 전달
- ViewModel은 여러 저장소에 의존할 수 있음 (다대다 관계)
- 저장소는 반드시 비공개(private) 멤버로 선언 → View가 직접 접근 못하도록 함
UI 상태
ViewModel의 출력은 View가 렌더링하는데 필요한 데이터로, UI 상태 또는 상태라고 한다.
UI 상태는 View를 렌더링하는데 필요한 불변(immutable)의 스냅샷 데이터이다.
ViewModel에서의 상태 노출 예시
// home_viewmodel.dart
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
/// [UnmodifiableListView]에 있는 항목은 직접 수정할 수 없지만,
/// 원본 리스트(_bookings)는 수정 가능함.
/// _bookings는 private이고 bookings는 public이므로,
/// view는 이 리스트를 직접 수정할 수 없음.
UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);
// ...
}
- 내부 데이터는 private으로 유지
- 외부에는 수정 불가능한 불변 형태로 제공 → 직접 수정 방지
- 상태는 변경 불가능해야 함 → 버그 방지에 효과적
package:freezed를 활용하면 깊은 불변성(deep immutability)이 보장되고, copyWith, toJson같은 유용한 메서드들의 구현을 자동 생성해준다.
// user.dart
@freezed
class User with _$User {
const factory User({
required String name,
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
- 상태가 복잡해지면, ViewModel에서 관리하는 데이터들을 하나의 객체로 묶어 표시
- 예: HomeUiState 클래스 생성
UI 상태 업데이트
ViewModel은 상태 변경을 감지하고 UI에 알려서 화면이 다시 렌더링되도록 해야 함
방법
이를 위해 ChangeNotifier를 상속하여 상태 변화를 알리게 할 수 있음
상태 변경 후 notifyListeners() 호출 → View가 다시 빌드됨
// home_viewmodel.dart
class HomeViewModel extends ChangeNotifier {
// ...
}
흐름 예시
1. 저장소에서 ViewModel로 새로운 상태가 전달됨
2. ViewModel은 UI 상태를 새로운 데이터로 갱신함
3. ViewModle.notifyListeners() 호출하여, View에 새로운 상태가 있음을 알림
4. View는 다시 렌더링됨
예를 들어, 사용자가 Home 화면으로 이동하면 ViewModel이 생성되고, _load 메서드가 호출된다. 이 메서드가 완료될 때까지 UI 상태는 비어 있으며, View는 로딩 인디케이터를 표시한다. 메서드가 성공적으로 완료되면 notifyListeners()로 View에 새로운 데이터가 있음을 알린다.
// home_viewmodel.dart
class HomeViewModel extends ChangeNotifier {
// ...
Future<Result> _load() async {
try {
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
_log.fine('Loaded user');
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
// ...
return userResult;
} finally {
notifyListeners();
}
}
}
참고
- ChangeNotifier와 ListenableBuilder는 기본적인 방법
- 이 외에도 상태 관리 패키지인 riverpod, flutter_bloc, signals 등 사용 가능
View 정의하기
View란?
View는 하나의 화면이거나 단일 UI 요소가 될 수 있다.
화면은 보통 Scaffold를 포함하고, 자체 라우트를 가짐.
하지만 재사용 가능한 작은 UI 요소(예: LogoutButton)도 View가 될 수 있음.
View와 ViewModel의 관계
View는 추상적인 용어이다. 하나의 View가 하나의 Widget과 동일하지 않다.
위젯은 조합 가능하며, 여러 개가 결합되어 하나의 View를 구성할 수 있다.
따라서 ViewModel은 1개 이상의 위젯으로 구성된 View와 1:1 관계를 가진다.
View의 역할
ViewModel의 데이터를 화면에 표시
ViewModel 상태 변화 감지 → 자동으로 재렌더링
ViewModel의 함수나 콜백을 버튼 등 이벤트에 연결
코드 구조 예시
View는 보통 key와 viewModel만을 생성자 매개변수로 받음.
// home_screen.dart
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// ...
);
}
}
View에서 UI 데이터 표시하기
View는 상태를 위해 ViewModel에 의존한다.
Compass 앱에서는 ViewModel이 View의 생성자 인자로 전달된다.
다음은 viewModel의 bookings 속성에 접근하여 UI 데이터를 만드는 예제코드이다.
@override
Widget build(BuildContext context) {
return Scaffold(
// Some code was removed for brevity.
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(...),
SliverList.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(viewModel.bookings[index].id),
booking:viewModel.bookings[index],
onTap: () => context.push(Routes.bookingWithId(
viewModel.bookings[index].id)),
onDismissed: (_) => viewModel.deleteBooking.execute(
viewModel.bookings[index].id,
UI 업데이트
이 예제에서 홈 화면 위젯은 ListenableBuilder 위젯을 사용하여 ViewModel의 업데이트를 청취합니다. ListenableBuilder 위젯 아래의 모든 위젯은 뷰모델이 변경되면 다시 렌더링됩니다. 여기서 ViewModel은 CahngeNotifier 유형입니다.
@override
Widget build(BuildContext context) {
return Scaffold(
// 일부 코드 생략
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(),
SliverList.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (_, index) =>
_Booking(
key: ValueKey(viewModel.bookings[index].id),
booking: viewModel.bookings[index],
onTap: () =>
context.push(Routes.bookingWithId(
viewModel.bookings[index].id)
),
onDismissed: (_) =>
viewModel.deleteBooking.execute(
viewModel.bookings[index].id,
),
사용자 이벤트 처리
View는 사용자로부터 이벤트를 청취해야 하며, ViewModel이 해당 이벤트를 처리할 수 있도록 해야 한다. 이는 ViewModel 클래스에 콜백 메서드를 노출시켜 모든 로직을 캡슐화하여 처리한다.
이 예제에서 홈 화면은 사용자는 저장된 여행 항목을 스와이프하여 기존에 예약한 항목을 삭제하는 할 수 있다. 아래 코드에서 _Booking이 저장된 여행 항목이고, _Booking을 스와이프하면, viewModel.deleteBooking.excute() 메서드가 실행된다.
SliverList.builder(
itemCount: widget.viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(viewModel.bookings[index].id),
booking: viewModel.bookings[index],
onTap: () => context.push(
Routes.bookingWithId(viewModel.bookings[index].id)
),
onDismissed: (_) =>
viewModel.deleteBooking.execute(widget.viewModel.bookings[index].id),
),
),
저장된 여행 항목은 뷰의 수명 이후에도 지속되는 애플리케이션 상태이므로, 이러한 상태는 레포지토리를 이용하여 관리하여야 한다. 따라서 저장된 여행 항목을 삭제 시 HomeViewModel.deleteBooking 메서드는 데이터 계층의 레포지토리의 메서드를 호출해야 한다.
Future<Result<void>> _deleteBooking(int id) async {
try {
final resultDelete = await _bookingRepository.delete(id);
switch (resultDelete) {
case Ok<void>():
_log.fine('Deleted booking $id');
case Error<void>():
_log.warning('Failed to delete booking $id', resultDelete.error);
return resultDelete;
}
// 일부 코드 생략
// final resultLoadBookings = ...;
return resultLoadBookings;
} finally {
notifyListeners();
}
}
이러한 사용자 이벤트 처리 메서드를 명령(command)이라고 부른다.
Command 객체
Command 객체란?
UI → 데이터 계층으로의 흐름을 처리하며, 메서드 실행 상태(실행 중, 완료, 오류)를 추적해 UI 업데이트를 쉽게 만들어주는 클래스야.
핵심 기능
- ChangeNotifier를 상속해서 상태 변경 시 알림을 보냄.
- execute() 메서드에서 실행 중인 상태, 결과 등을 관리하고 notifyListeners() 호출.
- running, error, completed 등의 상태 플래그로 UI 상태 제어가 간단해짐.
- 예: 실행 중일 땐 로딩 인디케이터 보여주고, 오류 땐 에러 메시지 출력.
abstract class Command<T> extends ChangeNotifier {
Command();
bool running = false;
Result<T>? _result;
/// true if action completed with error
bool get error => _result is Error;
/// true if action completed successfully
bool get completed => _result is Ok;
/// Internal execute implementation
Future<void> _execute(action) async {
if (_running) return;
// Emit running state - e.g. button shows loading state
_running = true;
_result = null;
notifyListeners();
try {
_result = await action();
} finally {
_running = false;
notifyListeners();
}
}
}
- 이 예제에서 Command는 추상 클래스이며, Command0, Command1과 같은 구체적인 클래스들로 구현하고 있음
- util 디렉토리에서 구현 클래스 확인 가능
- 직접 구현하는 대신 flutter_command 패키지 사용을 추천
데이터가 존재하기 전에 뷰가 랜더링될 수 있도록 보장하기
Command.excute 메서드는 비동기이므로, 뷰가 렌더링될 때 데이터가 항상 준비되어 있다고 보장할 수 없다. 그래서 Compass앱은 Command를 사용하여 조건부로 위젯을 렌더링한다.
child: ListenableBuilder(
listenable: viewModel.load,
builder: (context, child) {
if (viewModel.load.running) {
return const Center(child: CircularProgressIndicator());
}
if (viewModel.load.error) {
return ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingHome,
label: AppLocalization.of(context).tryAgain,
onPressed: viewModel.load.execute,
);
}
// 명령이 오류 없이 완료되었을 때
return child!;
},
),
이 패턴은 앱에서 일반적인 UI 문제를 해결하는 방법을 표준화하여 코드베이스를 더 안전하고 확장 가능하게 만든다. 그러나 이 패턴이 결대적인 것은 아니다. 다른 앱에서 Steam과 StreamBuiler를 아키텍처에서 사용한다면 AsyncSnapshot 클래스가 이 기능을 기본적으로 제공한다.
장점
복잡한 UI 상태 제어를 단순화.
비동기 처리에 강함: 뷰보다 먼저 완료돼도 상태 유지됨.
코드 재사용성 향상, 오류 줄이고 확장성 높임.
'Flutter > 아키텍처' 카테고리의 다른 글
플러터 앱 아키텍처 _ 6. 아키텍처 예제 _ 계층간 통신(의존성 주입) (0) | 2025.04.15 |
---|---|
플러터 앱 아키텍처 _ 5. 아키텍처 예제 _ 데이터 계층 (0) | 2025.04.15 |
플러터 앱 아키텍처 _ 3. 아키텍처 예제 _ 개요 (0) | 2025.04.15 |
플러터 앱 아키텍처 _ 2. 앱 아키텍처 가이드 (0) | 2025.04.15 |
플러터 앱 아키텍처 _ 1. 아키텍처 개념 (0) | 2025.04.14 |