
다시 - Composable Architecture 설계 변경
개인 광고 영역
Composable Architecture에 대한 세 편의 글에 이어 이번 글을 작성하게 되었다. 이번에는 전반적인 설계가 변경되어 사실상 새로운 아키텍처라고 볼 수 있다.
가장 큰 변화는 무엇인가?
- 싱글턴으로 활용하던
Action
을ViewModel
에 한정하여 사용하도록 변경했다. Router
를 새로 추가했으며,Action
과 독립적으로 동작하도록 개선했다.
이 글에서는
Action
활용법 개선 방안을 알아본다.- 이전 설계에서 발생했던 문제점들을 짚어본다.
설명에 포함된 샘플 화면
이 글의 설명은 다음 샘플 프로젝트를 기반으로 한다.
TComposableArchitecture - GitHub - link
Action은 그대로 사용하지만, 범위는 제한한다.
Action
자체는 이전과 동일하게 사용한다. 하지만 기존에는 싱글턴으로 어디서든 접근하고 활용할 수 있었던 점을, 이제는 Compose와 ViewModel 간의 1:1 관계로 한정했다.
이로 인해 ActionViewModel이라는 BaseViewModel이 추가되었다. 이 ViewModel은 Action을 활용하기 위한 핵심 동작 코드를 포함하고 있다. 특히, flowAction을 처리하는 코드가 이곳에 위치한다.
abstract class ActionViewModel<ACTION : Action>(
private val flowActionStream: FlowActionStream,
actionClass: KClass<ACTION>,
) : ViewModel() {
@Inject
internal lateinit var actionSender: ActionSender
@VisibleForTesting
var flowActionJob: Job? = null
@VisibleForTesting
val flowAction by lazy {
flowActionStream.flowAction()
.filterIsInstance(actionClass)
.onEach {
handleAction(action = it) // Action을 처리하기 위함
}
}
internal fun loadAction() {
cancelAction()
flowActionJob = flowAction
.launchIn(viewModelScope)
}
/**
* ViewModel created only call once.
*/
open fun onCreated() {}
internal fun cancelAction() {
if (flowActionJob?.isActive == true) {
flowActionJob?.cancel()
flowActionJob = null
}
}
fun nextAction(action: ACTION) {
flowActionStream.nextAction(action)
}
abstract suspend fun handleAction(action: ACTION)
}
Base ViewModel은 어디까지나 사용 편의성을 위한 것이므로, internal
키워드를 통해 불필요한 외부 접근을 최소한으로 제한한다.
ActionViewModel 활용 예시
SettingsViewModel - link에서 전체 코드를 확인할 수 있다. 여기서는 UiState를 통해 화면을 갱신하고, Action을 통해 이벤트를 업데이트하는 과정을 적용한다.
관련 코드는 다음과 같습니다.
@HiltViewModel
internal class SettingsViewModel @Inject constructor(
flowActionStream: FlowActionStream,
) : ActionViewModel<SettingsAction>(flowActionStream, SettingsAction::class) {
// UI의 상태를 업데이트합니다.
private val _settingsUiState = MutableStateFlow(SettingsUiState.Default)
val settingsUiState = _settingsUiState.asStateFlow()
override suspend fun handleAction(action: SettingsAction) {
when (action) {
is SettingsAction.ThemeModeChange -> { // action으로 들어온 ThemeModeChange 이벤트를 처리합니다.
_settingsUiState.update {
it.copy(
mode = action.mode,
)
}
}
}
}
}
UI에서의 사용 방법
개발하다 보면 활용성을 높이기 위한 방식으로 접근할 수밖에 없다. 이는 새로운 방식에 익숙해지는 시간과 노력이 필요하다는 의미이기도 한다.
하지만 기존 방식보다는 훨씬 쉽게 접근하면서도 사이드 이펙트 발생을 최소화하는 형태로 접근한다.
@Composable
internal fun InternalSettingsScreen(
settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
ActionSenderCompositionLocalProvider(settingsViewModel) { // 필수로 필요
val settingsUiState by settingsViewModel.settingsUiState.collectAsStateWithLifecycle()
InternalSettingsScreen(
// Your screen paramater
)
}
}
ActionSenderCompositionLocalProvider
를 추가해야 합니다. 기존에는 싱글턴이었기 때문에 Activity에서 이를 처리했지만, 이제는 ViewModel이 필요한 시점에 즉시 추가할 수 있습니다.
만약 Action을 활용하지 않겠다면 직접 구현하여 사용할 수 있지만, Lifecycle만 사용하겠다면 LaunchedLifecycleActionViewModel
을 호출해야 합니다.
이 코드에서는 CompositionLocalProvider
를 활용하여 범위가 지정된 ActionSender를 사용하기 위해 ActionSenderCompositionLocalProvider
를 활용합니다.
ActionViewModel에 대한 Unit Test
Unit Test를 통해 로직 테스트를 진행할 수 있다. 물론 ActionSenderCompositionLocalProvider
가 적용되지 않았다는 점은 직접 UI를 눌러보며 테스트하거나, UI Test를 진행해야 한다.
여기서는 Unit Test 코드를 살펴보겠다.
위 샘플 코드에서 한 가지 시나리오를 기반으로 Unit Test가 가능하다.
- View에서 Theme 옵션 중 하나를 선택한다.
- Repository를 통해 로컬 데이터 업데이트를 처리한다. (이 코드에는 포함되어 있지 않는다.)
- 업데이트가 성공하면 UI를 갱신한다.
여기서는 Mockito, Turbine, Coroutines Test를 활용하여 Unit Test를 진행한다.
internal class SettingsViewModelTest {
private val flowActionStream = mock<FlowActionStream>()
private val viewModel = SettingsViewModel(
flowActionStream = flowActionStream,
)
@Test
fun `test ThemeModeChange`() = runTest {
// UI에서 DARK 테마 변경 버튼을 눌렀다고 가정합니다.
val mockItem = SettingsAction.ThemeModeChange(mode = SettingsUiState.Mode.DARK)
// flowAction에서 이를 전달해줍니다.
whenever(flowActionStream.flowAction()).thenReturn(flowOf(mockItem))
viewModel.flowAction
.test { // 구독 및 테스트 시작
// awaitItem()을 통해 눌린 정보를 확인합니다.
Assert.assertEquals(
SettingsAction.ThemeModeChange(mode = SettingsUiState.Mode.DARK),
awaitItem()
)
// UiState 갱신을 확인합니다.
Assert.assertEquals(
SettingsUiState(
mode = SettingsUiState.Mode.DARK,
),
viewModel.settingsUiState.value
)
// Mockito를 활용하였으므로 flowAction 이벤트가 들어왔는지 확인합니다.
verify(flowActionStream).flowAction()
cancelAndIgnoreRemainingEvents()
}
}
}
설계 방향
원래의 설계는 이 Action을 활용하여 앱 전체에 하나의 Event Bus를 만들 생각이었다. 하지만 사용하다 보니 단점이 더 많아 이 부분을 제거하고, View와 ViewModel 간의 1:1 관계로 제약했다.
싱글턴을 제한하기 위해 두 번째 글을 작성했지만, 결국 그 부분도 설계상의 미스가 있었기에, 완전히 새롭게 1:1로 한정한 Action으로 개선했다.
2부 - Composable Architecture는 만들었는데 문제가 있었네? 개선해보자.
Next
이로 인해 전역 Action
을 통해 처리할 수 있었던 Alert/Toast, Router 시스템도 새로 만들어야 했다.
달라진 점이라면 이제 정말 필요할 때 Action
과 무관하게 단독으로 사용할 수 있게 되었다는 점이다.
- Router: 액티비티, 내비게이션 간 화면 이동과 처리를 담당하며, Navigator를 활용해 화면 이동이 가능하다.
- 이전에는 액션을 통해 이를 활용하던 부분은 모두 제거되어 코드에 반영되어 있다. 이 내용은 다음 글에서 자세히 다룰 예정이다.
- Alert/Toast: Action을 통해 이벤트를 전달하고 전달받는 처리를 하려고 했으나, Action 개선으로 단독 시스템으로 만들어질 예정이다. (샘플이라고 생각하는 것이 맞다.)
기반 코드가 변경되었으니, 완전히 새로운 형태의 아키텍처라고 보는 것이 더 적절할 것이다.
Router 부분에서 싱글 액티비티를 활용하는 부분은 드로이드 나이츠 코드에 반영되어 있다.
작성 글 이어보기
- 1부 - 컴포즈에 사용할 Composable Architecture 설명(리엑트?)
- 2부 - Composable Architecture는 만들었는데 문제가 있었네? 개선해보자.
- 3부 - Composable Architecture에서는 Alert/Toast는 어떻게 사용할 수 있는가?
Comments