flutter-presentation-mvi

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Flutter Presentation 레이어 (MVI 스타일)

Flutter Presentation 层(MVI 风格)

구성 요소

组成部分

모든 화면은 다음 4가지로 구성되며, 파일은 2개만 만든다:
  1. State — freezed immutable 클래스. UI 에 필요한 모든 필드.
  2. Action — freezed sealed class. 사용자가 유발할 수 있는 모든 동작.
  3. ViewModel
    ChangeNotifier
    를 믹스인. State 를 보관하고 Action 을 처리.
  4. Root / Screen 위젯 분리 — Root 가 DI/이벤트/리빌드 구독을, Screen 이 순수 UI를 담당.
파일포함 내용
<feature>_view_model.dart
파일 상단: State → Action, 파일 하단: ViewModel
<feature>_screen.dart
파일 상단: Root, 파일 하단: Screen
선택 요소: Event (1회성 부작용) — 스낵바/내비게이션 등 상태에 넣으면 안 되는 일회성 신호.
StreamController
로 내보낸다.

所有页面由以下4部分组成,且仅需创建2个文件
  1. State — freezed不可变类。包含UI所需的所有字段。
  2. Action — freezed密封类。包含用户可触发的所有操作。
  3. ViewModel — 混入
    ChangeNotifier
    。存储State并处理Action。
  4. Root / Screen 组件分离 — Root负责DI/事件/重建订阅,Screen负责纯UI。
文件包含内容
<feature>_view_model.dart
文件顶部:State → Action,文件底部:ViewModel
<feature>_screen.dart
文件顶部:Root,文件底部:Screen
可选组件:Event(一次性副作用) — 如Snackbar/导航等不应放入状态的一次性信号,通过
StreamController
发送。

State

State

freezed 클래스. 기본값은
@Default
로 지정한다.
dart
// 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类。默认值通过
@Default
指定。
dart
// 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

freezed
+
sealed
로 선언해
switch
가 모든 케이스를 강제하도록 만든다.
dart
// 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;
}
명명:
on<Trigger>
또는 동사형(
load...
). 각 factory는 클래스 타입(
OnTapFavorite
)도 같이 정의해
switch
패턴 매칭에서 쓴다.

通过
freezed
+
sealed
声明,强制
switch
覆盖所有分支。
dart
// 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;
}
命名规则
on<Trigger>
或动词形式(如
load...
)。每个工厂方法同时定义类类型(如
OnTapFavorite
),用于switch模式匹配。

ViewModel

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);
      // ... 나머지 케이스
    }
  }
}
규칙:
  • with ChangeNotifier
    믹스인을 쓴다. Flutter의 표준이며
    ListenableBuilder
    와 자연스럽게 결합된다.
  • 단일 공개 메서드
    onAction(Action action)
    에서
    switch
    로 디스패치한다. 개별 public 메서드를 늘리지 않는다 — Screen 이 ViewModel을 타입적으로 덜 알게 된다.
  • _state = state.copyWith(...); notifyListeners();
    의 순서를 지킨다. 동일 프레임 내 여러 변경은 한 번의
    copyWith
    로 합친다.
  • 생성자 주입으로만 의존성을 받는다.
    getIt
    을 ViewModel 내부에서 호출하지 않는다.
  • 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);
      // ... 나머지 케이스
    }
  }
}
规则:
  • 使用
    with ChangeNotifier
    混入。这是Flutter的标准方式,可与
    ListenableBuilder
    自然结合。
  • 在单个公开方法
    onAction(Action action)
    中通过switch分发。不要增加单独的公开方法——这样Screen对ViewModel的类型依赖更少。
  • 遵循
    _state = state.copyWith(...); notifyListeners();
    的顺序。同一帧内的多个变更应合并为一次
    copyWith
  • 仅通过构造函数注入依赖。不要在ViewModel内部调用
    getIt
  • 如果存在
    StreamSubscription
    ,必须在
    @override void dispose()
    中调用
    cancel()

스트림 구독 패턴

流订阅模式

UseCase가
Stream
을 반환하면 ViewModel 생성자 또는 action 핸들러에서 구독하고,
dispose
에서 취소한다.
dart
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返回
Stream
时,在ViewModel构造函数或action处理器中订阅,并在
dispose
中取消订阅。
dart
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、导航)

상태로 표현하면 리빌드마다 다시 실행될 수 있다.
StreamController
를 통해 한 번만 흘려보낸다.
dart
class HomeViewModel with ChangeNotifier {
  final _eventController = StreamController<NetworkError>();
  Stream<NetworkError> get eventStream => _eventController.stream;

  // 실패 시
  _eventController.add(result.error);
}
Root 위젯에서는
StreamBuilder
대신
initState
listen
으로 구독해 Snackbar 를 띄운다 (Widget 트리 밖의 부작용이므로).

如果用状态表示,每次重建都会重复执行。通过
StreamController
仅发送一次。
dart
class HomeViewModel with ChangeNotifier {
  final _eventController = StreamController<NetworkError>();
  Stream<NetworkError> get eventStream => _eventController.stream;

  // 失败时
  _eventController.add(result.error);
}
Root组件中不要用
StreamBuilder
,而是在
initState
listen
中订阅以显示Snackbar(因为属于Widget树外的副作用)。

Root / Screen 분리

Root / Screen 分离

<feature>_screen.dart
한 파일 안에 두 클래스가 공존한다:
  • 파일 상단:
    Root
    — DI + 이벤트 구독 +
    ListenableBuilder
  • 파일 하단:
    Screen
    — 순수 UI.
    state
    onAction
    만 props 로 받음
<feature>_screen.dart
文件中包含两个类:
  • 文件顶部
    Root
    — 负责DI + 事件订阅 +
    ListenableBuilder
  • 文件底部
    Screen
    — 纯UI组件,仅接收
    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
    안에서
    onAction(... load)
    을 호출하는 것은 이 프로젝트의 기존 관례다. 단, 재빌드마다 재로딩되지 않도록 ViewModel 내부에서 중복 요청을 방지하거나(이미 로드됐다면 무시), Root 를
    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
    中调用
    onAction(... load)
    进行初始数据加载是本项目的既有惯例。但为避免每次重建都重新加载,可在ViewModel内部防止重复请求(如已加载则忽略),或将Root升级为
    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
    에 대한 참조가 없다. 테스트와 위젯 프리뷰가 독립적으로 가능하다.
  • 네비게이션이나 다이얼로그가 필요한 인터랙션은
    onTapMenu
    같은 추가 콜백으로 분리해 Root 가 처리하게 한다 — Screen 이
    context.go
    같은 go_router 호출을 직접 하지 않는다.

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
    引用。测试和组件预览可独立进行。
  • 需要导航或对话框的交互通过
    onTapMenu
    等额外回调分离,由Root处理——Screen不直接调用
    context.go
    等go_router方法。

Action이 아닌 콜백을 쓸 때

使用非Action回调的场景

Screen 에서 네비게이션/다이얼로그처럼 context 가 필요한 인터랙션은 별도
onTap*
콜백을 두고 Root 에서 처리한다. 이유는 두 가지:
  1. ViewModel 은
    BuildContext
    를 모른다 (테스트 용이성).
  2. 네비게이션은 "상태 변경"이 아니라 "효과"라서 Action에 섞으면 모델이 지저분해진다.
예:
saved_recipes_root.dart
onAction
콜백 안에서
OnTapRecipe
를 가로채
context.push(...)
로 이동시키고, 나머지 Action은 ViewModel로 넘긴다.
dart
onAction: (action) {
  if (action is OnTapRecipe) {
    context.push('/Home/Ingredient/${action.recipe.id}');
    return;
  }
  viewModel.onAction(action);
},

当Screen中需要依赖context的交互(如导航/对话框)时,单独设置
onTap*
回调由Root处理。原因有二:
  1. ViewModel不了解
    BuildContext
    (便于测试)。
  2. 导航属于“副作用”而非“状态变更”,混入Action会使模型变得杂乱。
示例:
saved_recipes_root.dart
onAction
回调中拦截
OnTapRecipe
,通过
context.push(...)
跳转,其余Action传递给ViewModel。
dart
onAction: (action) {
  if (action is OnTapRecipe) {
    context.push('/Home/Ingredient/${action.recipe.id}');
    return;
  }
  viewModel.onAction(action);
},

UI 모델 (필요할 때)

UI模型(必要时)

도메인 모델에 표시 포맷(
"3일 전"
,
"20 min"
)이 얽혀 있으면 Presentation 전용 UI 모델을 만든다. 기본적으로는 도메인 모델을 그대로 쓰고, 포매팅이 복잡해질 때만 UI 모델로 분리한다.

当领域模型与显示格式(如"3天前"、"20 min")耦合时,创建Presentation专用的UI模型。默认直接使用领域模型,仅在格式化逻辑复杂时分离为UI模型。

체크리스트 — 새 화면 추가

检查清单——新增页面

  • lib/presentation/<feature>/<feature>_view_model.dart
    — 상단: freezed State → sealed Action, 하단:
    with ChangeNotifier
    ViewModel,
    onAction
    단일 진입
  • lib/presentation/<feature>/<feature>_screen.dart
    — 상단: Root(
    getIt
    ,
    ListenableBuilder
    , 이벤트 구독), 하단: Screen(state + onAction 만 받는 순수 UI)
  • diSetup()
    에 ViewModel을
    registerFactory
    로 등록
  • 필요하면
    StreamController
    로 1회성 이벤트 노출
  • build_runner build
    로 freezed 파일 생성

  • lib/presentation/<feature>/<feature>_view_model.dart
    — 顶部:freezed State → sealed Action,底部:
    with ChangeNotifier
    的ViewModel,
    onAction
    作为唯一入口
  • lib/presentation/<feature>/<feature>_screen.dart
    — 顶部:Root(
    getIt
    ListenableBuilder
    、事件订阅),底部:仅接收state + onAction的纯UI Screen
  • diSetup()
    中通过
    registerFactory
    注册ViewModel
  • 必要时通过
    StreamController
    暴露一次性事件
  • 执行
    build_runner build
    生成freezed文件

안티 패턴

反模式

  • ❌ Screen 위젯이
    getIt<>()
    를 호출 → Root 위젯으로 끌어올려라.
  • ❌ ViewModel 이
    BuildContext
    GoRouter
    를 참조 → 네비게이션은 Root 콜백으로.
  • ❌ 여러 공개 메서드(
    loadRecipe()
    ,
    onTapBack()
    ,
    onSelectTab()
    )를 두고 Screen에서 호출 →
    onAction(Action)
    하나로 좁혀라.
  • ❌ State 변경 후
    notifyListeners()
    누락 → 리빌드가 일어나지 않는다.
  • StreamSubscription
    dispose
    에서 취소하지 않음 → 메모리 누수와 ghost callback.
  • ❌ 상태에 스낵바 메시지를 담아 매 리빌드마다 다시 표시 →
    StreamController
    이벤트로 내보내라.
  • ❌ Screen组件调用
    getIt<>()
    → 移至Root组件。
  • ❌ ViewModel引用
    BuildContext
    GoRouter
    → 导航通过Root回调处理。
  • ❌ 设置多个公开方法(
    loadRecipe()
    onTapBack()
    onSelectTab()
    )并由Screen调用 → 统一为
    onAction(Action)
    入口。
  • ❌ State变更后未调用
    notifyListeners()
    → 不会触发重建。
  • StreamSubscription
    未在
    dispose
    中取消 → 内存泄漏和幽灵回调。
  • ❌ 将Snackbar消息存入状态导致每次重建重复显示 → 通过
    StreamController
    事件发送。