UI Event는 UI Layer에서 UI 또는 ViewModel로 처리해야 하는 작업이다. 

가장 일반적인 이벤트 유형은 사용자 이벤트이다. 사용자는 화면 탭하기 또는 동작 생성과 같은 앱과의 상호작용을 통해 사용자 이벤트를 생성한다. 그러면 UI에서 onClick() 리스너와 같은 콜백을 소비하여 이러한 이벤트를 사용한다.

ViewModel은 일반적으로 특정 사용자 이벤트의 비즈니스 로직을 처리한다. ViewModel은 보통 UI에서 호출할 수 있는 함수를 노출하여 이러한 로직을 처리한다. 사용자 이벤트에는 UI에서 직접 처리할 수 있는 UI 동작 로직이 있을 수 있다.

UI Layer 페이지에서 이러한 유형의 로직을 다음과 같이 정의한다.

1. Business Logic은 결제 또는 사용자 환경설정 저장과 같은 State 변경과 관련하여 필요한 조치를 말한다. Domain, Data Layer는 일반적으로 이 로직을 처리한다. 

2. UI behavior logic 이나 UI logic은 탐색 로직과 같이 상태 변경사항을 표시하는 방법 또는 사용자에게 메시지를 표시하는 방법을 나타낸다. 이 로직은 UI에서 처리한다.

 

UI Event Decision Tree

다음 다이어그램은 특정 이벤트 사용 사례를 처리하는 최상의 방법을 찾기 위한 결정트리를 보여준다.

사용자 이벤트 처리

확장 가능한 항목의 상태와 같이 UI 요소의 상태 수정과 관련된 경우 UI에서 사용자 이벤트를 직접 처리할 수 있다. 이벤트가 화면상 데이터의 새로고침 같은 비즈니스 로직을 실행해야 하는 경우 ViewModel로 처리해야 한다.

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        // The expand details event is processed by the UI that
        // modifies a View's internal state.
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the business logic.
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

상기 예제는 다양한 버튼을 사용하여 UI 요소를 확장하는 방법(UI Logic)과 화면상 데이터를 새로고침하는 방법(Business Logic)을 보여준다.

RecyclerView의 사용자 이벤트

RecyclerView 항목 또는 Custom View 와 같이 UI Tree 아래쪽에서 작업이 생성되는 경우에도 ViewModel이 사용자 이벤트를 처리해야 한다.

Ex) NewsActivity의 모든 뉴스 항목에 북마크 버튼이 포함되어 있다고 가정해보자. ViewModel은 북마크된 뉴스 항목의 ID를 알아야 한다. 사용자가 뉴스 항목을 북마크에 추가하면 RecyclerView Adapter는 ViewModel에서 노출된 addBookmark(newsId) 함수를 호출하지 않으며, 여기에는 ViewModel의 종속 항목이 필요하다. 대신 ViewModel은 이벤트 처리를 위한 구현이 포함된 NewsItemUiState라는 상태 객체를 노출한다.

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
)
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // Business logic is passed as a lambda function that the
            // UI calls on click events.
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

이렇게 하면 RecyclerView Adapter가 NewsItemUiState 객체 목록과 같이 필요한 데이터만 사용할 수 있다. Adapter가 전체 ViewModel에 엑세스 할 수 없으므로 ViewModel에 의해 노출된 기능을 악용할 가능성이 낮다. Activity클래스에서만 ViewModel을 사용하도록 허용하는 경우 책임이 분리된다. 이렇게 하면 View or RecyclerView Adapter와 같은 UI 별 객체가 ViewModel과 직접 상호작용하지 않는다.

ViewModel Event 처리

ViewModel 이벤트에서 발생하는 UI 작업(ViewModel Event)은 항상 UI 상태 업데이트로 이어진다. 이는 단방향 데이터 흐름의 원칙을 준수한다. 구성 변경 후에 이벤트를 재현할 수 있으며 UI 작업이 손실되지 않는다. 저장된 상태 모듈을 사용하는 경우 선택적으로 프로세스 중단 후에도 이벤트를 재현 가능하게 만들 수 있다.

UI Action을 UI State에 매핑하는 것이 항상 간단한 프로세스인 것은 아니지만, 로직은 더 간단해진다.

Ex) 사고 과정이 UI를 특정 화면으로 이동하는 방법을 결정하는 데서 끝나서는 안된다. UI 상태에서 사용자가 흐름을 나타내는 방법을 좀 더 생각해봐야 한다. 즉, UI에서 실행해야 하는 작업이 아니라 이런 작업이 UI State에 어떤 영향을 미치는지 생각해보라.

data class LoginUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)


class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
    /* ... */
}

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

예를 들어 사용자가 로그인 화면에 로그인한 후 홈 화면으로 이동하는 경우를 생각해보면 이 상황은 UI State에서 위와 같이 모델링할 수 있다. 이 UI는 isUserLoggedIn State 변경에 반응하고 필요에 따라 올바른 대상으로 이동한다.

 

이벤트를 소비하면 상태 업데이트가 트리거될 수 있다.

UI에서 특정 ViewModel 이벤트를 소비하면 다른 UI 상태가 업데이트될 수 있다. 

Ex) 화면에 임시 메시지를 표시하여 사용자에게 무언가가 발생했음을 알리는 경우, 메시지가 화면에 표시되었을 때 UI가 다른 상태 업데이트를 trigger하도록 ViewModel에 알려야 한다. 사용자가 메시지를 소비했을 때(이벤트를 닫거나 시간이 초과됨) 발생하는 이벤트는 '사용자 입력'으로 다루어야 할 수 있으므로 ViewModel에 이를 알아야 한다. 이 상황에서는 다음과 같이 UI 상태를 모델링할 수 있다.

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

Business Logic이 사용자에게 임시 메시지를 새로 표시해야 하는 경우 ViewModel은 다음과 같이 UI State를 업데이트한다.

class LatestNewsViewModel(/* ... */) : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                _uiState.update { currentUiState ->
                    currentUiState.copy(userMessage = "No Internet connection")
                }
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null)
        }
    }
}

ViewModel은 UI가 화면에 메시지를 표시하는 방식을 알 필요가 없다. 사용자에게 표시해야 하는 사용자 메시지가 있다는 사실만 알면 된다. 임시 메시지가 표시되면 UI가 ViewModel에 이를 알려야 하며 그러면 userMessage 속성을 삭제하기 위해 또 다른 UI 상태 업데이트가 발생한다.

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

기타 사용 사례

UI State 업데이트로 UI Event 사용 사례를 해결할 수 없다고 생각되면 앱의 데이터 흐름 방식을 다시 고려해야 할 수 있다. 다음 원칙을 고려하라.

1. 각 클래스에서 각자의 역할만을 수행해야 한다. UI는 탐색 호출, 클릭 이벤트, 권한 요청 가져오기와 같은 화면별 동작 로직을 담당한다. ViewModel은 비즈니스 로직을 포함하며 계층 구조의 하위 레이어에서 얻은 결과를 UI State로 변환한다.

2. 이벤트가 발생하는 위치를 생각하라. 상기 결정 트리를 따르고 각 클래스가 담당하는 역할을 처리하게 한다. 예를 들어 이벤트가 UI에서 발생하고 그 결과 탐색 이벤트가 발생하면 이 이벤트는 UI에서 처리되어야 한다. 일부 로직이 ViewModel에 위임될 수 있지만, 이벤트 처리는 ViewModel에 완전히 위임될 수 없다.

3. 소비자가 여러 명이고 이벤트가 여러 번 소비될 것이 우려된다면 앱 아키텍처를 다시 고려해야 할 수 있다.

동시 실행 소비자가 여럿인 경우 정확히 한 번 제공되는 계약을 보장하기가 매우 어려워지므로 복잡성과 미묘한 동작의 양이 폭발적으로 증가한다. 이 문제가 발생하면 UI Tree의 위쪽으로 문제를 푸시하라. 계층 구조의 상위로 범위가 지정된 다른 항목이 필요할 수 있다.

4. 상태를 소비해야 하는 경우를 생각해 보라. 어떤 상황에서는 앱이 백그라운드에 있다면 계속 소비하지 않는 것이 좋다.Ex) Toast

이 경우 UI가 포그라운드에 있을 때 상태를 소비하는 것이 좋다.

+ Recent posts