viewModel One-off Anti-Pattern

HEETAE HEO·2023년 10월 9일
post-thumbnail

이번 글은 아래의 포스트를 베이스로 작성되었습니다.

ViewModel: One-off event antipatterns

위의 포스트의 내용은 다음과 같습니다.

  • ViewModel에서 발생하는 일회성 이벤트(One-Off Event, 사용자에게 정보 메시지를 표시하거나 애플리케이션의 상태 변경 이때의 상태는 화면 이동 or 로그인과 같은 사용자 동작에 대한 결과 등을 나타냅니다)를 즉시 처리하여 상태 업데이트를 발생시켜야 한다 것과
  • 그 이벤트(상태)를 관찰 가능한 데이터 홀더 유형으로 사용해야한다는 것입니다.

해당 글에서는 상태를 통해 이벤트를 전달해야한다고 하였는데 그 이유로는 다음과 같습니다.
Android 앱에서는 Kotlin의 Channel이나 reactiveStream (SharedFlow)를 사용하여 이벤트를 UI에 알려주는데 위의 2가지가 이벤트의 전달 및 처리를 보장하지 않기 때문입니다.

해당 이슈 노트를 보면 다음과 같이 설명하고 있습니다.
kotlinx.coroutines/issues/2886

Due to the prompt cancellation guarantee changes that landed in Coroutines 1.4, Channels cannot be used as mechanism that guarantees delivery and acknowledges successful processing between producer and consumer in order to guarantee that an item was handled exactly once.

해당 내용은 Coroutine 1.4에서 취소 보장 변경으로 인해 Channel이나 SharedFlow가 생산자(producer)와 소비자(consumer)간의 이벤트 전달과 처리를 완벽하게 보장하지 못한다는 것을 의미하고 있습니다.

포스트에 있는 Case Study를 가져와 보겠습니다.

코드를 보면 MakePaymentViewModel에서 makePayment()라는 메서드를 호출해 결제 요청 결과가 반한되고 private val _navigateToPaymentResultScreen = Channel()에 그 결과를 update 해주고 있습니다.

/* Copyright 2022 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */

class MakePaymentViewModel(...) : ViewModel() {

    val uiState: StateFlow<MakePaymentUiState> = /* ... */

    // ⚠️⚠️ DO NOT DO THIS!! ⚠️⚠️
    // This one-off ViewModel event hasn't been handled nor reduced to state
    // Boolean represents whether or not the payment was successful
    private val _navigateToPaymentResultScreen = Channel<Boolean>()

    // `receiveAsFlow` makes sure only one collector will process each
    // navigation event to avoid multiple back stack entries
    val navigateToPaymentResultScreen = _navigateToPaymentResultScreen.receiveAsFlow()

    // Protecting makePayment from concurrent callers
    // If a payment is in progress, don't trigger it again
    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 }
        }
    }
}

Compose 및 Activity 코드를 보면 다음과 같습니다.

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

위의 코드에서 Compose 코드에서는 LaunchedEffect를 통해 viewModel또는 lifecycle의변화가 감지된다면 내부가 코드가 동작되도록 하였고 내부에서는 라이프사이클 상태가 STARTED 이상일 때 viewModel.navigateToPaymentResult를 수집하여 동작을 수행하도록 하였고 Activity 또한 마찬가지 입니다.

해당 코드로 구현되어있는 결제 화면에서 사용자가 viewModel의 makePayment() 동작을 호출한 뒤 앱을 백그라운드로의 이동 또는 화면전환과 같이 UI에 영향을 주는 액션을 취하게 된다면 channel의 전달되어야하는 이벤트가 전달되지 않을 가능성이 있고 그렇게 된다면 UI 업데이트가 발생하지 않는 문제가 발생하게 된다는 것입니다.

그렇기에 해당 포스트에서 추천하는 방식은 관찰가능한 데이터 홀더 유형으로 관리하여 화면을 업데이트 하는 것을 제안하고 있습니다.

상태는 바로 stateFlow 입니다. 위의 문제를 개선한 코드를 보면 다음과 같습니다.

/* Copyright 2022 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */

data class MakePaymentUiState(
    val paymentInformation: PaymentModel,
    val isLoading: Boolean = false,
    // PaymentResult models the application state of this particular payment attempt,
    // `null` represents the payment hasn't been made yet.
    val paymentResult: PaymentResult? = null
)

class MakePaymentViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow<MakePaymentUiState>(...)
    val uiState: StateFlow<MakePaymentUiState> = _uiState.asStateFlow()

    // Protecting makePayment from concurrent callers
    // If a payment is in progress, don't trigger it again
    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(...)

                // The event of what to do when the payment response comes back
                // is immediately handled here. It causes a UI state update.
                _uiState.update {
                    it.copy(
                        isLoading = false,
                        paymentResult = PaymentResult(it.paymentInfo, isPaymentSuccessful)
                    )
                }
            } catch (ioe: IOException) { ... }
            finally { makePaymentJob = null }
        }
    }
}

다음 코드를 보면 _uiState 변수에 타입은 상단에 선언된 데이터 클래스인 MakePaymentUiState를 mutableState로 담아주고 있습니다. makePayment() 메서드가 실행되면 isLoading의 값을 true로 바꾸고 repository의 결과 값을 받아 온뒤 _uiState값을 update 해주고 있습니다.

위의 코드를 보면 호출하는 즉시 내부에서 값이 update 되었고 새로운 값이 할당될 때 값이 즉시 방출됩니다. 이벤트 스트림을 통해 전달받는 방식이 아닌 가장 최신의 값을 방출하는 방식이기에 업데이트 된 이벤트를 못받는 경우가 없어지는 것입니다.

그렇기에 ui에서는 아래의 코드와 같이 viewModel의 uiState 변수를 참조하고 LaunchedEffect에서 uiState 값 변경을 탐지하고 값의 변경이 있다면 최신 값을 받아와 ui의 업데이트를 수행해주는 것입니다.

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

위의 제안 코드의 처리해줘야 하는 부분

그렇다면 개선된 코드에서는 단점을 없을까? 라는 생각이 들 수 있습니다. 가장 큰 부분은 상태의 초기화 입니다.

예시를 들어보겠습니다. 사용자가 어떠한 동작을 수행하였고 그에 따라 navigation을 통해 화면을 전환하였습니다. 그러던 중 사용자가 뒤로가기를 통해 동작을 수행한 화면으로 돌아간다면 어떠한 상황이 발생할까요? 화면이 재구성되면서 최신 state 값을 받게 되고 이미 어떠한 동작을 수행해 받은 결과 값이 남아있기에 뒤로가자마자 다시 화면 전환이 수행될 것입니다. 즉 화면 시나리오에 따라 state를 초기화 해주는 동작이 들어가야할 수 있다는 것입니다.

다음과 같은 동작을 한번에 수행해주기 위해 몇몇 개발자들은 다음과 같은 인터페이스를 통해 state의 업데이트와 state의 초기화를 동시에 수행해주고 있었습니다. 코드를 보겠습니다.

interface UiEvent<T> { 
 val data: T,
 val onConsumed: () -> Unit 
}

--- viewModel ---

class ExampleViewModel: ViewModel() {

private val _uiEvent = MutableLiveData<UiEvent<String>?>()
val uiEvent = LiveData<UiEvent<String>?> = _uiEvent

fun oneOffEvent(msg: String){
		_uiEvent.value = object : UiEvent<String> {
			override val data = msg
			override val onConsumed = { _uiEvent.value = null }
		}
	}
}

--- compose ---

@Composable 
fun exampleScreen(viewModel: ExampleViewModel){
	val uiEvent by viewModel.uiEvent.observeAsState()

	uiEvent?.let { event -> 
	
		Snackbar(message = event.data)
		event.onConsumed()
  }
}

다음과 같이 일회성 이벤트를 관리하고 ui상의 중복 처리를 방지하는데 사용하는 것입니다.

Channel 개선 방식

포스트를 작성한 개발자의 제안대로 State를 통해서 처리하는 방식 말고도 기존의 문제가 있던 channel을 개선해 문제를 해결할 수 있습니다. 바로 collect를 하는 곳을 withContext(Dispatcher.Main.immediate)로 감싸주는 것 입니다. 그렇게 되면 메인 스레드에서 작업을 수행하도록 하는데 immediate가 추가되었으므로 즉시 실행하도록 합니다. 이를 통해 state update처럼 값의 update가 대기 queue에 할당 되는 것이 아닌 즉시 실행되도록 하여 이벤트의 전달 및 처리의 보장성을 높여주는 것입니다.

이번 글을 통해 One-off Anti-pattern에 대해서 공부를 해보고 어떠한 방법을 통해 안티패턴을 제거할 수 있는지 찾아봤습니다. 감사합니다.

profile
Android 개발 잘하고 싶어요!!!

0개의 댓글