일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- r
- Design Pattern
- 싱글톤
- 안드로이드 디자인시스템
- 프로토타입 패턴
- 디자인패턴 #
- android designsystem
- builderPattern
- Singleton
- factory method
- designPattern
- ㅋㅁ
- 빌터패턴
- 추상 팩토리
- 추상팩토리패턴
- Functional Programming
- 팩토리 메소드
- Kotlin
- F
- Observer Pattern
- PrototypePattern
- 디자인패턴
- compose
- ㅓ
- material3
- 코틀린
- 함수형프로그래밍
- El
- 옵저버 패턴
- Abstract Factory
- Today
- Total
오늘도 더 나은 코드를 작성하였습니까?
ViewModel: One-off event antipatterns(학습 및 번역) 본문
ViewModel 이벤트는 UI가 이용하고 있는 ViewModel에서 시작된 작업입니다.
예를 들어 사용자에게 정보 메시지를 표시하거나 애플리케이션 상태가 변경될 때 다른 화면으로 이동합니다.
ViewModel 이벤트에 대한 지침은 두 가지 방식으로 설명됩니다.
1. one-off(일회성) 이벤트가 ViewModel에서 발생할 때마다, ViewModel은 해당 이벤트를 즉시 처리하여 상태 업데이트를 발생시켜야 합니다. ViewModel은 App 상태만 노출해야 합니다. ViewModel에서 State로 축소되지 않은 이벤트를 노출한다는 것은 ViewModel이 해당 이벤트에서 파생된 State에 대한 Data 소스가 아님을 의미합니다.
단방향 데이터 흐름(UDF)은 생산자보다 오래 지속되는 소비자에게만 이벤트를 보내는 이점을 설명합니다.
2. Observable 데이터 홀더 유형을 사용하여 State를 노출해야 합니다.
ui state는 ViewModel에서 UI로 내려가고, 이벤트는 UI에서 ViewModel로 올라갑니다.
앱에서 Kotlin Channel 또는 SharedFlow와 같은 기타 React Stream을 사용하여 ViewModel 이벤트를 UI에 노출한다.
생산자(ViewModel)가 소비자(UI—Compose 또는 Views)보다 오래 지속될때 (ViewModel에서 발생되는 이벤트의 경우)
이러한 API는 해당 이벤트의 전달 및 처리가 반드시 되지 않을 수 있습니다.(collecting이 되도 앱이 백그라운드에 있으면 데이터를 소모하지 않음) 이로 인해 개발자에게 버그와 향후 문제가 발생할 수 있으며 대부분의 앱에서 허용되지 않는 사용자 환경이기도 합니다.
ViewModel 이벤트를 즉시 처리하여 UI 상태를 업데이트해야 합니다.
Channel 또는 SharedFlow와 같은 다른 반응형 솔루션을 사용하여 이벤트를 객체로 노출하려고 해도 이벤트 전달 및 처리가 보장되지 않습니다.
Case Study
다음은 앱의 일반적인 결제 Flow에서 ViewModel을 구현한 예입니다.
다음 코드 스니펫에서 MakePaymentViewModel은 결제 요청 결과가 반환되면 결과 화면으로 이동하도록 UI에 직접 지시합니다.
이 예를 사용하여 이와 같은 일회성 ViewModel 이벤트를 처리하는 것이 문제와 높은 엔지니어링 비용을 초래하는 이유를 살펴보겠습니다.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
class MakePaymentViewModel(...) : ViewModel() {
val uiState: StateFlow<MakePaymentUiState> = /* ... */
// ⚠️⚠️ DO NOT DO THIS!! ⚠️⚠️
// 이 one-off ViewModel 이벤트는 처리되지 않았거나 state로 축소되지 않았습니다.
// Boolean은 결제 성공 여부를 나타냅니다.
private val _navigateToPaymentResultScreen = Channel<Boolean>()
// `receiveAsFlow`는 하나의 컬렉터가 각각 네비게이션 이벤트를 처리하도록 합니다.
// 백스택에 여러개의 detination가 쌓이는걸, 피하기 위해서
val navigateToPaymentResultScreen = _navigateToPaymentResultScreen.receiveAsFlow()
// makePayment 통시호출되는 것을 방지합니다.
// 결제가 진행 중인 경우 다시 트리거하지 않음
private var makePaymentJob: Job? = null
fun makePayment() {
if (makePaymentJob != null) return
makePaymentJob = viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true) } // Show loading spinner
val isPaymentSuccessful = paymentsRepository.makePayment(...)
_navigateToPaymentResultScreen.send(isPaymentSuccessful)
} catch (ioe: IOException) { ... }
finally { makePaymentJob = null }
}
}
}
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
//////////////////////////////////////////////
// Jetpack Compose code
//////////////////////////////////////////////
@Composable
fun MakePaymentScreen(
onPaymentMade: (Boolean) -> Unit,
viewModel: MakePaymentViewModel = viewModel()
) {
val currentOnPaymentMade by rememberUpdatedState(onPaymentMade)
val lifecycle = LocalLifecycleOwner.current.lifecycle
// Check whenever navigateToPaymentResultScreen emits a new value
// to tell the caller composable the payment was made
LaunchedEffect(viewModel, lifecycle) {
lifecycle.repeatOnLifecycle(state = STARTED) {
viewModel.navigateToPaymentResultScreen.collect { isPaymentSuccessful ->
currentOnPaymentMade(isPaymentSuccessful)
}
}
}
// Rest of the UI for the make payment screen.
}
//////////////////////////////////////////////
// Activity / Views code
//////////////////////////////////////////////
class MakePaymentActivity : AppCompatActivity() {
private val viewModel: MakePaymentViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.navigateToPaymentResultScreen.collect { isPaymentSuccessful ->
val intent = Intent(this, PaymentResultActivity::class.java)
intent.putExtra("PAYMENT_RESULT", isPaymentSuccessful)
startActivity(intent)
finish()
}
}
}
}
}
navigateToPaymentResultScreen 구현에는 여러 설계 결함이 있습니다.
안티패턴 #1: 결제 완료 상태가 손실될 수 있음
Channel은 이벤트 전달 및 처리를 보장하지 않습니다.
따라서 이벤트가 손실되어 UI가 일관성 없는 상태가 될 수 있습니다.
이에 대한 예는 ViewModel(생산자)이 이벤트를 보낸 직후에 UI(소비자)가 백그라운드로 이동하고 채널 수집을 중지하는 경우에 발생할 수 있습니다. 이벤트를 수신하는 소비자가 없더라도 이벤트를 내보낼 수 있는 SharedFlow와 같이 관찰 가능한 데이터 홀더 유형이 아닌 다른 API에 대해서도 마찬가지입니다.
이는 ACID 트랜잭션 측면에서 생각해 보면,(Atomicity, Consistency, Isolation, Durability)
UI 레이어에서 모델링된 결제 결과 상태가 지속 가능하거나 원자적이지 않기 때문에 안티패턴입니다.
Repository에 관한 한 결제는 성공했을 수 있지만 적절한 다음 화면으로 이동하지 않았습니다.
참고: 이 안티패턴은 이벤트를 보내고 받을 때 Dispatchers.Main.immediate를 사용하여 완화할 수 있습니다.
그러나 lint 검사에 의해 시행되지 않는 경우 이 솔루션은 개발자가 쉽게 잊어버릴 수 있으므로 오류가 발생하기 쉽습니다.
안티패턴 #2: UI에 조치를 취하라고 지시하기
여러 화면 크기를 지원하는 앱의 경우 ViewModel 이벤트가 주어지면 수행할 UI 작업은 화면 크기에 따라 다를 수 있습니다.
예를 들어 케이스 스터디 앱은 휴대폰에서 실행할 때 결제 결과 화면으로 이동해야 합니다.
그러나 앱이 태블릿에서 실행 중인 경우 작업이 동일한 화면의 다른 부분에 결과를 표시할 수 있습니다.
ViewModel은 앱 상태가 무엇인지 UI에 알려야 하고 UI는 이를 반영하는 방법을 결정해야 합니다.
ViewModel은 UI에 어떤 작업을 수행해야 하는지 알려주지 않아야 합니다.
안티패턴 #3: 일회성 이벤트를 즉시 처리하지 않음
이벤트를 fire and forget(반환값을 돌려주든 상관없이 함수를 호출하는 행위)로 모델링하면 문제가 발생합니다.
ACID 속성을 준수하기가 더 어렵기 때문에 , 결코 데이터 신뢰성과 무결성을 보장할 수 없습니다
이벤트를 처리하지 않는 시간이 길수록 문제가 더 어려워집니다.
ViewModel 이벤트의 경우 가능한 한 빨리 이벤트를 처리하고, 새 UI 상태를 생성합니다.
예시에서 Boolean로 표시되는 이벤트에 대한 객체를 만들고 Channel을 사용하여 노출했습니다.
// Create Channel with the event modeled as a Boolean
val _navigateToPaymentResultScreen = Channel<Boolean>()
// Trigger event
_navigateToPaymentResultScreen.send(isPaymentSuccessful)
이벤트를 트리거하면 정확히 once delivery and handling같은 사항을 보장하는 책임을 지게 됩니다.
특정 이유로 이벤트를 객체로 모델링해야 하는 경우, 수명을 가능한 한 짧게 제한하여 이벤트 객체를 잃지 않도록 해야 합니다.
ViewModel에서 one-off 이벤트 처리는 일반적으로 UI 상태 업데이트와 같은 메서드 호출로 귀결됩니다.
해당 메서드를 호출하면 성공적으로 완료되었는지 또는 예외가 발생했는지 알 수 있으며 정확히 한 번 발생했음을 알 수 있습니다.
Case Study Improvements
이러한 상황 중 하나에 해당하는 경우 one-off ViewModel 이벤트가 실제로 UI에 어떤 의미가 있는지 다시 생각해 보자.
즉시 처리하고 StateFlow 또는 mutableStateOf와 같은 관찰 가능한 데이터 홀더를 사용하여 노출되는 UI 상태로 줄입니다.
UI State는
주어진 시점의 UI를 더 잘 나타내고,
더 많은 전달 및 처리 보장을 제공하며,
일반적으로 테스트하기 더 쉽고,
앱의 나머지 부분과 일관되게 통합됩니다.
ViewModel 이벤트를 state로 줄이는 방법을 찾는 데 어려움을 겪고 있다면,
해당 이벤트가 UI에 실제로 어떤 의미가 있는지 다시 생각해 보세요.
위의 예에서 ViewModel은 UI에 수행할 작업을 알리는 대신 실제 애플리케이션 데이터(이 경우 결제 데이터)를 노출해야 합니다.
다음은 관찰 가능한 데이터 홀더 유형을 사용하여 처리되고 State로 축소된 ViewModel 이벤트를 더 잘 구현한 것이다.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
data class MakePaymentUiState(
val paymentInformation: PaymentModel,
val isLoading: Boolean = false,
// PaymentResult는 특정 payment 시도한 애플리케이션 상태를 모델링한 것이다.
// `null`은 결제가 아직 이루어지지 않았음을 나타냅니다.
val paymentResult: PaymentResult? = null
)
class MakePaymentViewModel(...) : ViewModel() {
private val _uiState = MutableStateFlow<MakePaymentUiState>(...)
val uiState: StateFlow<MakePaymentUiState> = _uiState.asStateFlow()
// 동시 호출자로부터 makePayment 보호
// 결제가 진행 중인 경우 다시 트리거하지 않음
private var makePaymentJob: Job? = null
fun makePayment() {
if (makePaymentJob != null) return
makePaymentJob = viewModelScope.launch {
try {
_uiState.update { it.copy(isLoading = true) }
val isPaymentSuccessful = paymentsRepository.makePayment(...)
// 결제 응답이 돌아왔을 때 처리할 이벤트
// 여기에서 즉시 처리됩니다. UI 상태 업데이트가 발생합니다.
_uiState.update {
it.copy(
isLoading = false,
paymentResult = PaymentResult(it.paymentInfo, isPaymentSuccessful)
)
}
} catch (ioe: IOException) { ... }
finally { makePaymentJob = null }
}
}
}
위의 코드에서
상태 로딩중 & 지불 로직 시작.
_uiState.update { it.copy(isLoading = true) }
val isPaymentSuccessful = paymentsRepository.makePayment(...)
지불 로직에 대한 상태 업데이트.
_uiState.update {
it.copy(
isLoading = false,
paymentResult = PaymentResult(it.paymentInfo, isPaymentSuccessful)
)
}
이렇게 하면, 이벤트가 분실될 위험이 없습니다.
이벤트가 상태로 축소되었으며 ,
MakePaymentUiState의 paymentResult 필드에 결제 결과 애플리케이션 데이터가 반영됩니다.
이를 통해 UI는 paymentResult 변경에 반응하고 그에 따라 행동합니다.
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
//////////////////////////////////////////////
// Jetpack Compose code
//////////////////////////////////////////////
@Composable
fun MakePaymentScreen(
onPaymentMade: (PaymentModel, Boolean) -> Unit,
viewModel: MakePaymentViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
uiState.paymentResult?.let {
val currentOnPaymentMade by rememberUpdatedState(onPaymentMade)
LaunchedEffect(uiState) {
// Tell the caller composable that the payment was made.
// the parent composable will act accordingly.
currentOnPaymentMade(
uiState.paymentResult.paymentModel,
uiState.paymentResult.isPaymentSuccessful
)
}
}
// Rest of the UI for the login screen.
}
//////////////////////////////////////////////
// Activity / Views code
//////////////////////////////////////////////
class MakePaymentActivity : AppCompatActivity() {
private val viewModel: MakePaymentViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.paymentResult != null) {
val intent = Intent(this, PaymentResultActivity::class.java)
intent.putExtra(
"PAYMENT_RESULT",
uiState.paymentResult.isPaymentSuccessful
)
startActivity(intent)
finish()
}
}
}
}
}
}
참고: 사용 사례에서 Activity finish()를 호출하지 않고 백스택에 유지되는 경우, ViewModel은 나중에 호출될 UiState에서 paymentResult를 초기화 함수(즉, 필드를 null로 설정)를 노출해야 합니다. 그래야 다시 백스택에 유지된 Activity에 돌아왔을때 문제가 일어나지 않습니다. 이에 대한 예는 문서의 Consuming events can trigger state updates 섹션에서 찾을 수 있습니다.
이 블로그 게시물이 1) 일회성 ViewModel 이벤트를 즉시 처리하고 상태로 축소하고 2) 관찰 가능한 데이터 홀더 유형을 사용하여 상태를 노출하는 것이 권장되는 이유를 이해하는 데 도움이 되었기를 바랍니다. 우리는 이 접근 방식이 더 많은 전달 및 처리 보장을 제공하고 일반적으로 테스트하기 더 쉽고 앱의 나머지 부분과 일관되게 통합된다고 믿습니다.