flutter-presentation-mvi
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFlutter Presentation 레이어 (MVI 스타일)
Flutter Presentation 层(MVI 风格)
구성 요소
组成部分
모든 화면은 다음 4가지로 구성되며, 파일은 2개만 만든다:
- State — freezed immutable 클래스. UI 에 필요한 모든 필드.
- Action — freezed sealed class. 사용자가 유발할 수 있는 모든 동작.
- ViewModel — 를 믹스인. State 를 보관하고 Action 을 처리.
ChangeNotifier - Root / Screen 위젯 분리 — Root 가 DI/이벤트/리빌드 구독을, Screen 이 순수 UI를 담당.
| 파일 | 포함 내용 |
|---|---|
| 파일 상단: State → Action, 파일 하단: ViewModel |
| 파일 상단: Root, 파일 하단: Screen |
선택 요소: Event (1회성 부작용) — 스낵바/내비게이션 등 상태에 넣으면 안 되는 일회성 신호. 로 내보낸다.
StreamController所有页面由以下4部分组成,且仅需创建2个文件:
- State — freezed不可变类。包含UI所需的所有字段。
- Action — freezed密封类。包含用户可触发的所有操作。
- ViewModel — 混入。存储State并处理Action。
ChangeNotifier - Root / Screen 组件分离 — Root负责DI/事件/重建订阅,Screen负责纯UI。
| 文件 | 包含内容 |
|---|---|
| 文件顶部:State → Action,文件底部:ViewModel |
| 文件顶部:Root,文件底部:Screen |
可选组件:Event(一次性副作用) — 如Snackbar/导航等不应放入状态的一次性信号,通过发送。
StreamControllerState
State
freezed 클래스. 기본값은 로 지정한다.
@Defaultdart
// lib/presentation/ingredient/ingredient_view_model.dart (파일 상단)
import 'package:flutter_recipe_app_course/domain/model/ingredient.dart';
import 'package:flutter_recipe_app_course/domain/model/procedure.dart';
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ingredient_view_model.freezed.dart';
part 'ingredient_view_model.g.dart';
class IngredientState with _$IngredientState {
const factory IngredientState({
Recipe? recipe,
([]) List<Ingredient> ingredients,
([]) List<Procedure> procedures,
(0) int selectedTabIndex,
}) = _IngredientState;
factory IngredientState.fromJson(Map<String, Object?> json) =>
_$IngredientStateFromJson(json);
}규칙:
- 모든 UI 상태는 State 에만 있다. ViewModel 인스턴스 필드로 UI 관련 변수를 흩뿌리지 않는다.
- State 업데이트는 항상 로 새 인스턴스를 만들고
copyWith(...)에 재할당한 뒤_state.notifyListeners()
freezed类。默认值通过指定。
@Defaultdart
// lib/presentation/ingredient/ingredient_view_model.dart (파일 상단)
import 'package:flutter_recipe_app_course/domain/model/ingredient.dart';
import 'package:flutter_recipe_app_course/domain/model/procedure.dart';
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ingredient_view_model.freezed.dart';
part 'ingredient_view_model.g.dart';
class IngredientState with _$IngredientState {
const factory IngredientState({
Recipe? recipe,
([]) List<Ingredient> ingredients,
([]) List<Procedure> procedures,
(0) int selectedTabIndex,
}) = _IngredientState;
factory IngredientState.fromJson(Map<String, Object?> json) =>
_$IngredientStateFromJson(json);
}规则:
- 所有UI状态仅存在于State中。不要将UI相关变量分散在ViewModel实例字段中。
- State更新必须始终通过创建新实例,重新赋值给
copyWith(...)后调用_state。notifyListeners()
Action
Action
freezedsealedswitchdart
// lib/presentation/ingredient/ingredient_view_model.dart (State 바로 아래)
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
sealed class IngredientAction with _$IngredientAction {
const factory IngredientAction.onTapFavorite(Recipe recipe) = OnTapFavorite;
const factory IngredientAction.onTapIngredient() = OnTapIngredient;
const factory IngredientAction.onTapProcedure() = OnTapProcedure;
const factory IngredientAction.loadRecipe(int recipeId) = LoadRecipe;
const factory IngredientAction.onTapShareMenu(String link) = OnTapShareMenu;
}명명: 또는 동사형(). 각 factory는 클래스 타입()도 같이 정의해 패턴 매칭에서 쓴다.
on<Trigger>load...OnTapFavoriteswitch通过 + 声明,强制覆盖所有分支。
freezedsealedswitchdart
// lib/presentation/ingredient/ingredient_view_model.dart (State 바로 아래)
import 'package:flutter_recipe_app_course/domain/model/recipe.dart';
sealed class IngredientAction with _$IngredientAction {
const factory IngredientAction.onTapFavorite(Recipe recipe) = OnTapFavorite;
const factory IngredientAction.onTapIngredient() = OnTapIngredient;
const factory IngredientAction.onTapProcedure() = OnTapProcedure;
const factory IngredientAction.loadRecipe(int recipeId) = LoadRecipe;
const factory IngredientAction.onTapShareMenu(String link) = OnTapShareMenu;
}命名规则:或动词形式(如)。每个工厂方法同时定义类类型(如),用于switch模式匹配。
on<Trigger>load...OnTapFavoriteViewModel
ViewModel
dart
// lib/presentation/ingredient/ingredient_view_model.dart (Action 바로 아래)
class IngredientViewModel with ChangeNotifier {
final IngredientRepository _ingredientRepository;
final ProcedureRepository _procedureRepository;
final GetDishesByCategoryUseCase _getDishesByCategoryUseCase;
final ClipboardService _clipboardService;
IngredientState _state = const IngredientState();
IngredientState get state => _state;
IngredientViewModel({
required IngredientRepository ingredientRepository,
required ProcedureRepository procedureRepository,
required GetDishesByCategoryUseCase getDishesByCategoryUseCase,
required ClipboardService clipboardService,
}) : _ingredientRepository = ingredientRepository,
_procedureRepository = procedureRepository,
_getDishesByCategoryUseCase = getDishesByCategoryUseCase,
_clipboardService = clipboardService;
void onAction(IngredientAction action) async {
switch (action) {
case LoadRecipe():
_loadRecipe(action.recipeId);
case OnTapIngredient():
_state = state.copyWith(selectedTabIndex: 0);
notifyListeners();
case OnTapProcedure():
_state = state.copyWith(selectedTabIndex: 1);
notifyListeners();
case OnTapShareMenu():
_clipboardService.copyText(action.link);
// ... 나머지 케이스
}
}
}규칙:
- 믹스인을 쓴다. Flutter의 표준이며
with ChangeNotifier와 자연스럽게 결합된다.ListenableBuilder - 단일 공개 메서드 에서
onAction(Action action)로 디스패치한다. 개별 public 메서드를 늘리지 않는다 — Screen 이 ViewModel을 타입적으로 덜 알게 된다.switch - 의 순서를 지킨다. 동일 프레임 내 여러 변경은 한 번의
_state = state.copyWith(...); notifyListeners();로 합친다.copyWith - 생성자 주입으로만 의존성을 받는다. 을 ViewModel 내부에서 호출하지 않는다.
getIt - 이 있으면
StreamSubscription에서 반드시@override void dispose()한다.cancel()
dart
// lib/presentation/ingredient/ingredient_view_model.dart (Action 바로 아래)
class IngredientViewModel with ChangeNotifier {
final IngredientRepository _ingredientRepository;
final ProcedureRepository _procedureRepository;
final GetDishesByCategoryUseCase _getDishesByCategoryUseCase;
final ClipboardService _clipboardService;
IngredientState _state = const IngredientState();
IngredientState get state => _state;
IngredientViewModel({
required IngredientRepository ingredientRepository,
required ProcedureRepository procedureRepository,
required GetDishesByCategoryUseCase getDishesByCategoryUseCase,
required ClipboardService clipboardService,
}) : _ingredientRepository = ingredientRepository,
_procedureRepository = procedureRepository,
_getDishesByCategoryUseCase = getDishesByCategoryUseCase,
_clipboardService = clipboardService;
void onAction(IngredientAction action) async {
switch (action) {
case LoadRecipe():
_loadRecipe(action.recipeId);
case OnTapIngredient():
_state = state.copyWith(selectedTabIndex: 0);
notifyListeners();
case OnTapProcedure():
_state = state.copyWith(selectedTabIndex: 1);
notifyListeners();
case OnTapShareMenu():
_clipboardService.copyText(action.link);
// ... 나머지 케이스
}
}
}规则:
- 使用混入。这是Flutter的标准方式,可与
with ChangeNotifier自然结合。ListenableBuilder - 在单个公开方法中通过switch分发。不要增加单独的公开方法——这样Screen对ViewModel的类型依赖更少。
onAction(Action action) - 遵循的顺序。同一帧内的多个变更应合并为一次
_state = state.copyWith(...); notifyListeners();。copyWith - 仅通过构造函数注入依赖。不要在ViewModel内部调用。
getIt - 如果存在,必须在
StreamSubscription中调用@override void dispose()。cancel()
스트림 구독 패턴
流订阅模式
UseCase가 을 반환하면 ViewModel 생성자 또는 action 핸들러에서 구독하고, 에서 취소한다.
Streamdisposedart
class SavedRecipesViewModel with ChangeNotifier {
StreamSubscription? _streamSubscription;
SavedRecipesViewModel({required GetSavedRecipesUseCase getSavedRecipesUseCase})
: _getSavedRecipesUseCase = getSavedRecipesUseCase {
_streamSubscription = _getSavedRecipesUseCase.execute().listen((recipes) {
_state = state.copyWith(recipes: recipes);
notifyListeners();
});
}
void dispose() {
_streamSubscription?.cancel();
super.dispose();
}
}当UseCase返回时,在ViewModel构造函数或action处理器中订阅,并在中取消订阅。
Streamdisposedart
class SavedRecipesViewModel with ChangeNotifier {
StreamSubscription? _streamSubscription;
SavedRecipesViewModel({required GetSavedRecipesUseCase getSavedRecipesUseCase})
: _getSavedRecipesUseCase = getSavedRecipesUseCase {
_streamSubscription = _getSavedRecipesUseCase.execute().listen((recipes) {
_state = state.copyWith(recipes: recipes);
notifyListeners();
});
}
void dispose() {
_streamSubscription?.cancel();
super.dispose();
}
}일회성 이벤트 (스낵바, 내비게이션)
一次性事件(Snackbar、导航)
상태로 표현하면 리빌드마다 다시 실행될 수 있다. 를 통해 한 번만 흘려보낸다.
StreamControllerdart
class HomeViewModel with ChangeNotifier {
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;
// 실패 시
_eventController.add(result.error);
}Root 위젯에서는 대신 의 으로 구독해 Snackbar 를 띄운다 (Widget 트리 밖의 부작용이므로).
StreamBuilderinitStatelisten如果用状态表示,每次重建都会重复执行。通过仅发送一次。
StreamControllerdart
class HomeViewModel with ChangeNotifier {
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;
// 失败时
_eventController.add(result.error);
}Root组件中不要用,而是在的中订阅以显示Snackbar(因为属于Widget树外的副作用)。
StreamBuilderinitStatelistenRoot / Screen 분리
Root / Screen 分离
<feature>_screen.dart- 파일 상단: — DI + 이벤트 구독 +
RootListenableBuilder - 파일 하단: — 순수 UI.
Screen와state만 props 로 받음onAction
<feature>_screen.dart- 文件顶部:— 负责DI + 事件订阅 +
RootListenableBuilder - 文件底部:— 纯UI组件,仅接收
Screen和state作为参数onAction
Root 위젯
Root组件
dart
// lib/presentation/ingredient/ingredient_screen.dart (파일 상단)
class IngredientRoot extends StatelessWidget {
final int recipeId;
const IngredientRoot({super.key, required this.recipeId});
Widget build(BuildContext context) {
final viewModel = getIt<IngredientViewModel>();
viewModel.onAction(IngredientAction.loadRecipe(recipeId));
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
if (viewModel.state.recipe == null) {
return const Center(child: CircularProgressIndicator());
}
return IngredientScreen(
state: viewModel.state,
onAction: viewModel.onAction,
onTapMenu: (menu) { /* 다이얼로그·네비게이션 */ },
);
},
);
}
}- 호출은 여기서만 일어난다.
getIt<>() - 로
ListenableBuilder(listenable: viewModel, ...)변경을 구독한다.notifyListeners() - 초기 데이터 로딩을 위해 안에서
build을 호출하는 것은 이 프로젝트의 기존 관례다. 단, 재빌드마다 재로딩되지 않도록 ViewModel 내부에서 중복 요청을 방지하거나(이미 로드됐다면 무시), Root 를onAction(... load)으로 올려StatefulWidget에서 호출해도 된다. 복잡한 초기화가 있다면initState으로 승격하는 쪽이 안전하다.StatefulWidget
dart
// lib/presentation/ingredient/ingredient_screen.dart (파일 상단)
class IngredientRoot extends StatelessWidget {
final int recipeId;
const IngredientRoot({super.key, required this.recipeId});
Widget build(BuildContext context) {
final viewModel = getIt<IngredientViewModel>();
viewModel.onAction(IngredientAction.loadRecipe(recipeId));
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
if (viewModel.state.recipe == null) {
return const Center(child: CircularProgressIndicator());
}
return IngredientScreen(
state: viewModel.state,
onAction: viewModel.onAction,
onTapMenu: (menu) { /* 다이얼로그·네비게이션 */ },
);
},
);
}
}- 仅在此处调用。
getIt<>() - 通过订阅
ListenableBuilder(listenable: viewModel, ...)的变更。notifyListeners() - 在中调用
build进行初始数据加载是本项目的既有惯例。但为避免每次重建都重新加载,可在ViewModel内部防止重复请求(如已加载则忽略),或将Root升级为onAction(... load)在StatefulWidget中调用。复杂初始化时,升级为initState更安全。StatefulWidget
Screen 위젯
Screen组件
dart
// lib/presentation/ingredient/ingredient_screen.dart (파일 하단)
class IngredientScreen extends StatelessWidget {
final IngredientState state;
final void Function(IngredientAction action) onAction;
final void Function(IngredientMenu menu) onTapMenu;
const IngredientScreen({
super.key,
required this.state,
required this.onAction,
required this.onTapMenu,
});
Widget build(BuildContext context) { /* 순수 UI */ }
}- 에 대한 참조가 없다. 테스트와 위젯 프리뷰가 독립적으로 가능하다.
ViewModel - 네비게이션이나 다이얼로그가 필요한 인터랙션은 같은 추가 콜백으로 분리해 Root 가 처리하게 한다 — Screen 이
onTapMenu같은 go_router 호출을 직접 하지 않는다.context.go
dart
// lib/presentation/ingredient/ingredient_screen.dart (파일 하단)
class IngredientScreen extends StatelessWidget {
final IngredientState state;
final void Function(IngredientAction action) onAction;
final void Function(IngredientMenu menu) onTapMenu;
const IngredientScreen({
super.key,
required this.state,
required this.onAction,
required this.onTapMenu,
});
Widget build(BuildContext context) { /* 순수 UI */ }
}- 不持有引用。测试和组件预览可独立进行。
ViewModel - 需要导航或对话框的交互通过等额外回调分离,由Root处理——Screen不直接调用
onTapMenu等go_router方法。context.go
Action이 아닌 콜백을 쓸 때
使用非Action回调的场景
Screen 에서 네비게이션/다이얼로그처럼 context 가 필요한 인터랙션은 별도 콜백을 두고 Root 에서 처리한다. 이유는 두 가지:
onTap*- ViewModel 은 를 모른다 (테스트 용이성).
BuildContext - 네비게이션은 "상태 변경"이 아니라 "효과"라서 Action에 섞으면 모델이 지저분해진다.
예: 는 콜백 안에서 를 가로채 로 이동시키고, 나머지 Action은 ViewModel로 넘긴다.
saved_recipes_root.dartonActionOnTapRecipecontext.push(...)dart
onAction: (action) {
if (action is OnTapRecipe) {
context.push('/Home/Ingredient/${action.recipe.id}');
return;
}
viewModel.onAction(action);
},当Screen中需要依赖context的交互(如导航/对话框)时,单独设置回调由Root处理。原因有二:
onTap*- ViewModel不了解(便于测试)。
BuildContext - 导航属于“副作用”而非“状态变更”,混入Action会使模型变得杂乱。
示例:在回调中拦截,通过跳转,其余Action传递给ViewModel。
saved_recipes_root.dartonActionOnTapRecipecontext.push(...)dart
onAction: (action) {
if (action is OnTapRecipe) {
context.push('/Home/Ingredient/${action.recipe.id}');
return;
}
viewModel.onAction(action);
},UI 모델 (필요할 때)
UI模型(必要时)
도메인 모델에 표시 포맷(, )이 얽혀 있으면 Presentation 전용 UI 모델을 만든다. 기본적으로는 도메인 모델을 그대로 쓰고, 포매팅이 복잡해질 때만 UI 모델로 분리한다.
"3일 전""20 min"当领域模型与显示格式(如"3天前"、"20 min")耦合时,创建Presentation专用的UI模型。默认直接使用领域模型,仅在格式化逻辑复杂时分离为UI模型。
체크리스트 — 새 화면 추가
检查清单——新增页面
- — 상단: freezed State → sealed Action, 하단:
lib/presentation/<feature>/<feature>_view_model.dartViewModel,with ChangeNotifier단일 진입onAction - — 상단: Root(
lib/presentation/<feature>/<feature>_screen.dart,getIt, 이벤트 구독), 하단: Screen(state + onAction 만 받는 순수 UI)ListenableBuilder - 에 ViewModel을
diSetup()로 등록registerFactory - 필요하면 로 1회성 이벤트 노출
StreamController - 로 freezed 파일 생성
build_runner build
- — 顶部:freezed State → sealed Action,底部:
lib/presentation/<feature>/<feature>_view_model.dart的ViewModel,with ChangeNotifier作为唯一入口onAction - — 顶部:Root(
lib/presentation/<feature>/<feature>_screen.dart、getIt、事件订阅),底部:仅接收state + onAction的纯UI ScreenListenableBuilder - 在中通过
diSetup()注册ViewModelregisterFactory - 必要时通过暴露一次性事件
StreamController - 执行生成freezed文件
build_runner build
안티 패턴
反模式
- ❌ Screen 위젯이 를 호출 → Root 위젯으로 끌어올려라.
getIt<>() - ❌ ViewModel 이 나
BuildContext를 참조 → 네비게이션은 Root 콜백으로.GoRouter - ❌ 여러 공개 메서드(,
loadRecipe(),onTapBack())를 두고 Screen에서 호출 →onSelectTab()하나로 좁혀라.onAction(Action) - ❌ State 변경 후 누락 → 리빌드가 일어나지 않는다.
notifyListeners() - ❌ 을
StreamSubscription에서 취소하지 않음 → 메모리 누수와 ghost callback.dispose - ❌ 상태에 스낵바 메시지를 담아 매 리빌드마다 다시 표시 → 이벤트로 내보내라.
StreamController
- ❌ Screen组件调用→ 移至Root组件。
getIt<>() - ❌ ViewModel引用或
BuildContext→ 导航通过Root回调处理。GoRouter - ❌ 设置多个公开方法(、
loadRecipe()、onTapBack())并由Screen调用 → 统一为onSelectTab()入口。onAction(Action) - ❌ State变更后未调用→ 不会触发重建。
notifyListeners() - ❌ 未在
StreamSubscription中取消 → 内存泄漏和幽灵回调。dispose - ❌ 将Snackbar消息存入状态导致每次重建重复显示 → 通过事件发送。
StreamController