Multi Module Jetpack Compose Recomposition Skippable Issue

이태훈·2022년 4월 7일
1

안녕하세요. 이번 포스팅에서는 멀티 모듈에서 컴포즈를 사용할 때 주의해야할 사항에 대해서 알아보겠습니다.

https://qiita.com/takahirom/items/6907e810d3661e19cfcf
이 포스팅을 보고 작성하게 되었습니다.

요즘 클린 아키텍처를 구성하는 멀티 모듈 프로젝트가 많이 늘었습니다. 더하여, 컴포즈를 도입하는 프로젝트도 많이 늘어났죠.

클린 아키텍처를 구성하면 도메인 레이어에서 모델을 구성하여 프레젠터 레이어에서 해당 모델을 사용하는 경우가 많습니다.

하지만, 프레젠터 레이어에서 컴포즈를 사용할 경우에 숙지해두어야 할 주의사항을 모르고 사용하기 마련입니다.

간단히 말하면, 컴포즈에서 Recomposition이 일어날 때 도메인 레이어에서 사용하는 모델이 파라미터로 들어갈 경우 해당 Composable의 스킵이 일어나지 않게 됩니다.

이러한 경우를 재현하기 위해 다음과 같이 프로젝트를 구성해보았습니다.

Domain Layer

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()
}

Data Layer

@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()
}

Presentation Layer

@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이 실행돼야 할 것입니다.

결과를 보겠습니다.

결과 로그

첫 Composition

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

User 값 변경

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

Other State 값 변경

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을 기대하는 수가 적당해 보입니다.

References


https://qiita.com/takahirom/items/6907e810d3661e19cfcf

profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

1개의 댓글

comment-user-thumbnail
2023년 9월 4일

좋은 글 감사합니다~

답글 달기