안녕하세요. 이번 포스팅에서는 멀티 모듈에서 컴포즈를 사용할 때 주의해야할 사항에 대해서 알아보겠습니다.
https://qiita.com/takahirom/items/6907e810d3661e19cfcf
이 포스팅을 보고 작성하게 되었습니다.
요즘 클린 아키텍처를 구성하는 멀티 모듈 프로젝트가 많이 늘었습니다. 더하여, 컴포즈를 도입하는 프로젝트도 많이 늘어났죠.
클린 아키텍처를 구성하면 도메인 레이어에서 모델을 구성하여 프레젠터 레이어에서 해당 모델을 사용하는 경우가 많습니다.
하지만, 프레젠터 레이어에서 컴포즈를 사용할 경우에 숙지해두어야 할 주의사항을 모르고 사용하기 마련입니다.
간단히 말하면, 컴포즈에서 Recomposition이 일어날 때 도메인 레이어에서 사용하는 모델이 파라미터로 들어갈 경우 해당 Composable의 스킵이 일어나지 않게 됩니다.
이러한 경우를 재현하기 위해 다음과 같이 프로젝트를 구성해보았습니다.
data class User(
val name: String
)
interface UserRepository {
fun observeUser(): Flow<User>
suspend fun setUser()
}
class SetUserUseCase @Inject constructor(
private val userRepository: UserRepository
) {
suspend operator fun invoke() = userRepository.setUser()
}
class ObserveUserUseCase @Inject constructor(
private val userRepository: UserRepository
) {
operator fun invoke() = userRepository.observeUser()
}
@Module
@InstallIn(SingletonComponent::class)
interface RepositoryModule {
@Binds
fun bindUserRepository(userRepository: UserRepositoryImpl): UserRepository
}
@Singleton
class UserApiExecutor @Inject constructor() {
private val _user: MutableSharedFlow<User> = MutableSharedFlow()
val user: SharedFlow<User> get() = _user
var cnt = 2
suspend fun setUser() {
_user.emit(User(cnt++.toString()))
}
}
class UserRepositoryImpl @Inject constructor(
private val userApiExecutor: UserApiExecutor
) : UserRepository {
override fun observeUser(): Flow<User> = userApiExecutor.user
override suspend fun setUser() = userApiExecutor.setUser()
}
@HiltAndroidApp
class ModelTestApplication : Application()
@Immutable
data class UserUiState(
val user: User = User("1")
)
@HiltViewModel
class MainViewModel @Inject constructor(
observeUserUseCase: ObserveUserUseCase,
private val setUserUseCase: SetUserUseCase
) : ViewModel() {
val user = observeUserUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), User("1"))
val wrappedUser = observeUserUseCase()
.map(::UserUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserUiState())
private val _otherState: MutableStateFlow<Int> = MutableStateFlow(0)
val otherState: StateFlow<Int> = _otherState
fun onButtonClick() {
viewModelScope.launch {
setUserUseCase()
}
}
fun onOtherAction() {
_otherState.update {
it + 1
}
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val viewModel: MainViewModel = viewModel()
val otherState by viewModel.otherState.collectAsState()
val userState by viewModel.wrappedUser.collectAsState()
val user by viewModel.user.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween
) {
UserProfile(user)
WrappedUserProfile(userUiState = userState)
Button(viewModel::onButtonClick) {
Text(text = "Set User")
}
OtherContent(otherState = otherState)
Button(viewModel::onOtherAction) {
Text(text = "Other Action")
}
}
}
}
}
@Composable
fun UserProfile(user: User) {
println("User Profile Composable")
Text(text = user.name)
}
@Composable
fun WrappedUserProfile(userUiState: UserUiState) {
println("Wrapped User Profile Composable")
Text(text = userUiState.user.name)
}
@Composable
fun OtherContent(otherState: Int) {
println("Other Content Composable")
Text(text = otherState.toString())
}
Domain Layer에서 구성한 모델을 직접 쓰는 user와 @Immutable Annotation을 적용한 wrappedUser, 프레젠터 레이어에서 구성한 otherState 세 변수를 선언하여 사용합니다.
두 개의 버튼을 만들어 하나는 user 값을 바꾸어 user, wrappedUser 값 모두 변경이 일어나게 만들고, 나머지 하나는 otherState 값을 변경시키게 했습니다.
보통 의도한 대로 Skip이 일어나려면 user 값이 변경될 때 Profile, WrappedUserProfile의 Composable이, otherState 값이 변경될 때 OtherContent만 Composable이 실행돼야 할 것입니다.
결과를 보겠습니다.
2022-04-07 16:04:18.866 8922-8922/com.example.multimodulecomposemodeltest I/System.out: User Profile Composable
2022-04-07 16:04:18.873 8922-8922/com.example.multimodulecomposemodeltest I/System.out: Wrapped User Profile Composable
2022-04-07 16:04:18.893 8922-8922/com.example.multimodulecomposemodeltest I/System.out: Other Content Composable
2022-04-07 16:04:28.696 8922-8922/com.example.multimodulecomposemodeltest I/System.out: User Profile Composable
2022-04-07 16:04:28.698 8922-8922/com.example.multimodulecomposemodeltest I/System.out: Wrapped User Profile Composable
2022-04-07 16:06:15.791 9474-9474/com.example.multimodulecomposemodeltest I/System.out: User Profile Composable
2022-04-07 16:06:15.793 9474-9474/com.example.multimodulecomposemodeltest I/System.out: Other Content Composable
결과로 나온 로그를 보면 시나리오에서 의도한대로 Skip이 발생하지 않고 도메인 레이어 모델인 User는 계속 Skip이 되지 않고 Composable이 실행되는 것을 볼 수 있습니다.
이에 대한 해결 법은 이 포스팅에도 잘 나와있습니다.
@Immutable Annotation을 적용해 해당 이슈를 해결하는 방법과 모델을 사용하는 모듈에 Compose Runtime Denependency를 적용해주는 방법이 있습니다.
하지만, 보통 도메인 레이어는 Pure Java/Kotlin Library로 사용하기 때문에 Android Framework 안에서 돌아가는 Compose Runtime Dependency를 적용하기에는 무리가 있어 보입니다.
따라서, @Immutable Annotation을 통해 Recomposition이 발생할 때 Skip을 기대하는 수가 적당해 보입니다.
좋은 글 감사합니다~