Flutter에서 MVI 적용기 (compose와 비교)

love&peace·2025년 7월 6일

Flutter

목록 보기
1/1

(compose) MVI 적용기
예전에 블로그에 compose 프로젝트에 올렸던 걸 flutter 프로젝트에도 적용해보겠습니다.

파일구조(Flutter vs Compose)

우선 두 프로젝트 모두 클린 아키텍쳐를 사용했습니다.
compose에 경우 hilt, flutter에 경우 getit을 사용해 의존성 주입(DI)를 적용했습니다.

MVI를 비교하는 편이므로 프리젠테이션 계층 위주로 비교하겠습니다.

jetpack Compose - Contract

class HomeContract {

    data class HomeViewState(
        val loadState: LoadState = LoadState.SUCCESS,
        val groupList: List<GroupDummy> = listOf(),
    ): ViewState

    sealed class HomeSideEffect : ViewSideEffect {
        object NaviMembersInviteScreen : HomeSideEffect()
        object NaviAcceptInviteScreen : HomeSideEffect()
        data class NaviGroupDetail(val id : Long) : HomeSideEffect()
    }

    sealed class HomeEvent : ViewEvent {
        object InitHomeScreen : HomeEvent()
        object OnAddGroupInBoxClicked : HomeEvent()
        object OnAddGroupClicked : HomeEvent()
        object OnEnterGroupClicked : HomeEvent()
        object OnPagingGroupList : HomeEvent()
        data class OnGroupListClicked(val id : Long) : HomeEvent()
    }
}

Flutter - Contract

part 'home_state.freezed.dart';

@freezed
abstract class HomeState with _$HomeState {
  const factory HomeState({
    @Default('All') String selectedCategory,
    @Default([]) List<String> categories,
    @Default([]) List<Recipe> dishes,
    @Default([]) List<Recipe> newRecipes,
    @Default('') String name,
  }) = _HomeState;
}
//-----------------------------------------//


part 'home_side_effect.freezed.dart';

@freezed
sealed class HomeSideEffect with _$HomeSideEffect {
  const factory HomeSideEffect.showSnackBar(String message) = ShowSnackBar;
  const factory HomeSideEffect.navigateToDetail(int id) = NavigateToDetail;
}
//-----------------------------------------//

part 'home_intent.freezed.dart';

@freezed
sealed class HomeIntent with _$HomeIntent {
  const factory HomeIntent.onTapSearchField() = OnTapSearchField;
  const factory HomeIntent.onSelectCategory(String category) = onSelectCategory;
  const factory HomeIntent.onTabFavorite(Recipe recipe) = onTapFavorite;

}

Kotlin에서 사용하던 sealed class, data class를 Dart에서는 freezed 패키지를 활용해 동일하게 구현했습니다.

Freezed란?

Dart에서 불변(Immutable) 클래스를 간단하게 생성할 수 있도록 도와주는 코드 생성 라이브러리입니다.
copyWith, 등등 메서드가 자동 생성되어 안전하게 상태를 복사하고 관리할 수 있습니다.
또한, when과 map을 통한 패턴 매칭을 지원해 타입 기반 분기 처리가 매우 간편합니다.

jetpack Compose - viewModel

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val groupListReferUsecase: GroupListReferUsecase,
    private val application: Application
) : BaseViewModel<HomeContract.HomeViewState, HomeContract.HomeSideEffect, HomeContract.HomeEvent>(
    HomeContract.HomeViewState()
) {

    override fun handleEvents(event: HomeContract.HomeEvent) {
        when (event) {
            is HomeContract.HomeEvent.InitHomeScreen -> {
                showRefreshGroupList()
            }

            is HomeContract.HomeEvent.OnAddGroupInBoxClicked -> {
                sendEffect(
                   {HomeContract.HomeSideEffect.NaviMembersInviteScreen }
                )
            }

            .......

Flutter - viewModel

HomeViewModel({
    required GetCategoriesUseCase getCategoriesUseCase,
    required GetDishesByCategoryUsecase getDishesByCategoryUsecase,
    required GetNewRecipesUsecase getNewRecipesUsecase,
    required ToggleBookmarkRecipeUsecase toggleBookmarkRecipeUsecase,
  }) : _getCategoriesUseCase = getCategoriesUseCase,
       _getDishesByCategoryUsecase = getDishesByCategoryUsecase,
       _getNewRecipesUsecase = getNewRecipesUsecase,
       _toggleBookmarkRecipeUsecase = toggleBookmarkRecipeUsecase {
    _fetchCategories();
    _fetchNewRecipes();
  }

  HomeState _state = const HomeState(name: '');
  HomeState get state => _state;

  void onAction(HomeIntent intent) async {
    switch (intent) {
      case OnTapSearchField():
        return;
      case onSelectCategory():
        _onSelectedCategory(intent.category);
      case onTapFavorite() :
        _onTapFavorite(intent.recipe);
    }
  }
  
  ...
  

event(compose), intent(flutter) 를 vm 에서 받아서 처리합니다.
Flutter에서 ChangeNotifier를 사용해 VM을 구현해 상태 변경과 화면 갱신을 구현했습니다.

  • 추후에 bloc를 사용한 버전도 포스트 할 예정입니다.

jetpack Compose - Screen(UI)

@Composable
fun HomeScreen(
    navigationMyPage: () -> Unit,
    viewModel: HomeViewModel = hiltViewModel(),
    mainViewModel: MainViewModel = composableActivityViewModel()
) {
	//상태
    val viewState by viewModel.viewState.collectAsState()
    val context = LocalContext.current as Activity

//sideEffect 처리
    LaunchedEffect(key1 = viewModel.effect) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is HomeContract.HomeSideEffect.NaviMembersInviteScreen -> {
                	navigationToMembersInvite()
                }

                is HomeContract.HomeSideEffect.NaviGroupDetail -> {
                    context.startActivity(GroupDetailActivity.newIntent(context, effect.id))
                }

                else -> Unit
            }
        }
    }
    ...
      @Composable
fun GroupListScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    groupList: List<GroupDummy>,
    modifier: Modifier = Modifier
) {

    val lazyListState = rememberLazyListState()
    lazyListState.OnBottomListener(2) {
    // 이벤트 보내기
    	viewModel.setEvent(HomeContract.HomeEvent.OnPagingGroupList)
    }
    

Flutter - Screen(UI)

class HomeRoot extends StatefulWidget {
  const HomeRoot({super.key});

  @override
  State<HomeRoot> createState() => _HomeRootState();
}

class _HomeRootState extends State<HomeRoot> {
  late HomeViewModel viewModel;
  StreamSubscription? eventSubscription;
  @override
  void initState() {
    super.initState();
    viewModel = getIt<HomeViewModel>();

// sideEffect 처리 
    eventSubscription = viewModel.eventStream.listen((event) {
      if (!mounted) return;

      event.when(
        showSnackBar: (message) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(message)),
          );
        },
        navigateToDetail: (id) {
          Navigator.of(context).push(
            MaterialPageRoute(builder: (_) => DetailPage(id: id)),
          );
        },
      );
    });
  }

  @override
  void dispose() {
    eventSubscription?.cancel();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      builder: (context, snapshot) {
        return HomeScreen(
        // 상태 
          state: viewModel.state,
          onIntent: (HomeIntent intent) {
            if (intent is OnTapSearchField) {
              context.push(RoutePaths.search);
              return;
            }
            //event 처리
            viewModel.onAction(intent);
          },
        );
      },
      listenable: viewModel,
    );
  }
}

UI에선 동일하게 State 받고, VM으로 event(intent) 보내고, sideEffect를 구독해서 오면 원하는 행동을 해줍니다.

0개의 댓글