(compose) MVI 적용기
예전에 블로그에 compose 프로젝트에 올렸던 걸 flutter 프로젝트에도 적용해보겠습니다.
우선 두 프로젝트 모두 클린 아키텍쳐를 사용했습니다.
compose에 경우 hilt, flutter에 경우 getit을 사용해 의존성 주입(DI)를 적용했습니다.
MVI를 비교하는 편이므로 프리젠테이션 계층 위주로 비교하겠습니다.
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()
}
}
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 패키지를 활용해 동일하게 구현했습니다.
Dart에서 불변(Immutable) 클래스를 간단하게 생성할 수 있도록 도와주는 코드 생성 라이브러리입니다.
copyWith, 등등 메서드가 자동 생성되어 안전하게 상태를 복사하고 관리할 수 있습니다.
또한, when과 map을 통한 패턴 매칭을 지원해 타입 기반 분기 처리가 매우 간편합니다.
@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 }
)
}
.......
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을 구현해 상태 변경과 화면 갱신을 구현했습니다.
@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)
}
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를 구독해서 오면 원하는 행동을 해줍니다.