관리 메뉴

나만을 위한 블로그

[Android] 앱 아키텍처 - UI 이벤트 본문

Android

[Android] 앱 아키텍처 - UI 이벤트

참깨빵위에참깨빵 2024. 5. 14. 02:47
728x90
반응형

https://developer.android.com/topic/architecture/ui-layer/events?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fandroid-architecture&hl=ko#article-https://developer.android.com/topic/architecture/ui-layer/events&hl=ko

 

UI 이벤트  |  Android 개발자  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. UI 이벤트 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. UI 이벤트는 UI 레이어에서 UI 또는 ViewModel로

developer.android.com

 

UI 이벤트는 UI 레이어에서 UI 또는 뷰모델로 처리해야 하는 작업이다. 가장 일반적인 이벤트 유형은 사용자 이벤트다.

유저는 화면 탭, 동작 생성 같은 앱과의 상호작용을 통해 사용자 이벤트를 만든다. 그러면 UI에서 onClick 리스너 같은 콜백을 통해 이런 이벤트를 사용한다.

 

뷰모델은 일반적으로 특정 사용자 이벤트의 비즈니스 로직을 처리한다. 뷰모델은 보통 UI에서 호출할 수 있는 함수를 노출해서 이런 로직을 처리한다. 유저 이벤트에는 UI에서 직접 처리할 수 있는 UI 동작 로직이 있을 수 있다.

비즈니스 로직은 다른 모바일 플랫폼 또는 폼팩터의 같은 앱에 동일하게 유지되는 반면 UI 동작 로직은 케이스에 따라 다를 수 있는 구현 세부정보다. UI 레이어에선 이런 유형의 로직을 아래와 같이 정의한다.

 

  • 비즈니스 로직 : 결제 or 환경설정 저장 같은 상태 변경과 관련해 필요한 조치를 말한다. 도메인, 데이터 레이어가 일반적으로 이 로직을 처리한다.
  • UI 동작 로직 or UI 로직 : 탐색 로직 or 유저에게 메시지를 표시하는 방법 같이 상태 변경사항을 표시하는 방법을 나타낸다. 이 로직은 UI에서 처리한다.

 

UI 이벤트 결정 트리

 

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

 

 

사용자 이벤트 처리

 

확장 가능한 항목의 상태 같이 UI 요소의 상태 수정과 관련된 경우 UI에서 사용자 이벤트를 직접 처리할 수 있다.

이벤트가 화면상 데이터의 새로고침 같은 비즈니스 로직을 실행해야 하는 경우 뷰모델로 처리해야 한다.

아래 예에선 여러 버튼을 써서 UI 요소를 확장하는 방법(UI 로직)과 화면상 데이터를 새로고침하는 방법(비즈니스 로직)을 보여준다.

 

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 트리 아래쪽에서 작업이 생성되는 경우에도 뷰모델이 사용자 이벤트를 처리해야 한다.

NewsActivity의 모든 뉴스 항목에 북마크 버튼이 포함돼 있다고 가정한다. 뷰모델은 북마크된 뉴스 항목의 ID를 알아야 한다. 유저가 뉴스 항목을 북마크에 추가하면 리사이클러뷰 어댑터는 뷰모델에서 노출된 addBookmark(newsId)를 호출하지 않으며 여기엔 뷰모델의 종속 항목이 필요하다. 대신 뷰모델은 이벤트 처리를 위한 구현이 포함된 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)
            }
        )
    }
}

 

이렇게 하면 리사이클러뷰 어댑터가 NewsItemUiState 객체 목록 같이 필요한 데이터만 쓸 수 있다. 어댑터가 전체 뷰모델에 접근할 수 없으므로 뷰모델에 의해 노출된 기능을 악용할 가능성이 낮다.

액티비티에서만 뷰모델을 사용하도록 허용하는 경우 책임이 분리된다. 이렇게 하면 뷰 또는 리사이클러뷰 어댑터 같은 UI 별 객체가 뷰모델과 직접 상호작용하지 않는다.

 

또 다른 일반적인 패턴은 리사이클러뷰 어댑터가 사용자 작업을 위한 콜백 인터페이스를 갖는 것이다. 이 경우 액티비티 / 프래그먼트가 바인딩을 처리하고 콜백 인터페이스에서 직접 뷰모델 함수를 호출할 수 있다.

 

사용자 이벤트 함수의 이름 지정 규칙

 

여기서 사용자 이벤트를 처리하는 뷰모델 함수는 처리하는 작업에 따라 동사를 포함해 이름이 지정된다.(addBookmark, login)

 

뷰모델 이벤트 처리

 

뷰모델에서 발생하는 UI 작업(뷰모델 이벤트)은 항상 UI 상태 업데이트로 이어진다. 이는 단방향 데이터 흐름의 원칙을 준수한다. 따라서 구성 변경 후에 이벤트를 재현할 수 있으며 UI 작업이 손실되지 않는다.

뷰모델의 저장된 상태 모듈을 사용하는 경우 선택적으로 프로세스 종료 후에도 이벤트를 재현 가능하게 만들 수 있다.

UI 작업을 UI 상태에 매핑하는 절차가 늘 간단하진 않지만 그렇게 하면 로직은 더 간단해진다. 예를 들어 사고 과정이 UI를 특정 화면으로 이동하는 방법을 결정하는 데서 끝나선 안 된다.

 

UI에서 실행해야 하는 작업이 아니라 이런 작업이 UI 상태에 주는 영향을 생각해 보라.

유저가 로그인 화면에 로그인한 후 홈 화면으로 이동하는 경우를 생각한다. 이 상황은 UI 상태에서 아래와 같이 모델링할 수 있다.

 

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

 

이 UI는 isUserLoggedIn 상태 변경에 반응하고 필요에 따라 올바른 대상으로 이동한다.

 

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에서 특정 뷰모델 이벤트를 소비하면 다른 UI 상태가 업데이트될 수 있다.

화면에 임시 메시지를 표시해서 유저에게 뭔가 발생했음을 알리는 경우, 메시지가 화면에 표시됐을 때 UI가 다른 상태 업데이트를 트리거하도록 뷰모델에 알려야 한다. 유저가 메시지를 소비했을 때(메시지를 닫거나 시간이 초과됨) 발생하는 이벤트는 사용자 입력으로 다뤄야 할 수 있으므로 뷰모델에서 이걸 알아야 한다.

이 상황에선 아래처럼 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
)

 

비즈니스 로직이 유저에게 임시 메시지를 새로 표시해야 하는 경우 뷰모델은 아래와 같이 UI 상태를 업데이트한다.

 

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

 

뷰모델은 UI가 화면에 메시지를 표시하는 방식을 알 필요가 없다. 표시해야 하는 유저 메시지가 있단 것만 알면 된다.

임시 메시지가 표시되면 UI가 뷰모델에 이를 알려야 하며, 그러면 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 상태는 모든 시점에 화면에 표시되는 내용을 충실하게 표현한다. 사용자 메시지는 표시되거나 표시되지 않는다.

 

탐색 이벤트

 

탐색 이벤트는 안드로이드 앱의 일반적인 이벤트 유형이기도 하다. 유저가 버튼을 눌렀기 때문에 UI에서 이벤트가 트리거되는 경우 UI는 탐색 컨트롤러를 호출하거나 적절하게 이벤트를 호출자 컴포저블에 노출해서 이를 처리한다.

 

class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

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

        binding.helpButton.setOnClickListener {
            navController.navigate(...) // Open help screen
        }
    }
}

 

탐색 전에 데이터 입력에 비즈니스 로직 확인이 필요하면 뷰모델은 UI에 상태를 노출해야 한다.

UI는 상태 변경에 반응하고 적절하게 이동한다. 아래는 비슷한 코드다.

 

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.
                    }
                    ...
                }
            }
        }
    }
}

 

위 예에선 현재 대상인 로그인이 백스택에 유지되지 않아서 예상대로 작동한다. 유저가 백버튼을 누르면 로그인 화면으로 돌아갈 수 없다. 이런 상황이 발생하면 추가 로직이 필요하다.

 

대상이 백스택에 유지된 경우 탐색 이벤트

 

뷰모델이 화면 A에서 화면 B로 탐색 이벤트를 생성하는 상태를 설정하고 화면 A가 탐색 백스택에 유지될 때는 자동으로 B를 진행하지 않게 추가 로직이 필요할 수 있다. 이걸 구현하려면 UI가 다른 화면으로 이동하는 걸 고려해야 하는지를 나타내는 추가 상태가 있어야 한다. 일반적으로 이런 상태는 UI에 유지된다. 탐색 로직이 뷰모델이 아닌 UI에 관한 문제기 때문이다.

 

개발자가 앱의 등록 흐름에 있다고 가정한다. 생년월일 확인 화면에서 유저가 날짜를 입력할 때 계속 버튼을 탭하면 뷰모델에서 날짜를 확인한다. 뷰모델은 확인 로직을 데이터 레이어에 위임한다. 날짜가 유효하면 유저는 다음 화면으로 이동한다.

추가 기능으로 사용자가 일부 데이터를 바꾸려는 경우 여러 등록 화면 간에 이동할 수 있다. 따라서 등록 흐름의 모든 대상이 같은 백스택에 유지된다. 이런 요구사항을 고려해서 아래처럼 화면을 구현할 수 있다.

 

// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"

class DobValidationFragment : Fragment() {

    private var validationInProgress: Boolean = false
    private val viewModel: DobValidationViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = // ...
        validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false

        binding.continueButton.setOnClickListener {
            viewModel.validateDob()
            validationInProgress = true
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState
                .flowWithLifecycle(viewLifecycleOwner.lifecycle)
                .collect { uiState ->
                    // Update other parts of the UI ...

                    // If the input is valid and the user wants
                    // to navigate, navigate to the next screen
                    // and reset `validationInProgress` flag
                    if (uiState.isDobValid && validationInProgress) {
                        validationInProgress = false
                        navController.navigate(...) // Navigate to next screen
                    }
                }
        }

        return binding
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
    }
}

 

생년월일 확인은 뷰모델이 담당하는 비즈니스 로직이다. 대부분의 경우 뷰모델은 이 로직을 데이터 레이어에 위임한다.

유저를 다음 화면으로 이동시키는 로직은 UI 로직이다. 이런 요구사항은 UI 구성에 따라 바뀔 수 있기 때문이다.

 

기타 사용 사례

 

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

 

  • 각 클래스에서 각자의 역할만 수행해야 한다 : UI는 탐색 호출, 클릭 이벤트, 권한 요청 가져오기 같은 화면 별 동작 로직을 담당한다. 뷰모델은 비즈니스 로직을 포함하며 계층 구조의 하위 레이어에서 얻은 결과를 UI 상태로 바꾼다
  • 이벤트가 발생하는 위치를 생각하라 : 이 가이드의 시작 부분의 결정 트리를 따르고, 각 클래스가 담당하는 역할만 처리하게 한다. 예를 들어 이벤트가 UI에서 발생하고 그 결과 탐색 이벤트가 발생하면 이 이벤트는 UI에서 처리돼야 한다. 일부 로직이 뷰모델에 위임될 수 있지만 이벤트 처리는 뷰모델에 완전 위임될 수 없다
  • 소비자가 여럿이고 이벤트가 여러 번 소비될 게 우려되면 앱 아키텍처를 다시 고려해야 할 수 있다 : 동시 실행 소비자가 여럿인 경우 정확히 한 번 제공되는 계약을 보장하기가 매우 어려워지므로 복잡성과 미묘한 동작의 양이 폭발적으로 증가한다. 이 문제가 발생하면 UI 트리의 위쪽으로 문제를 푸시해 보라. 계층 구조의 상위로 범위가 지정된 다른 항목이 필요할 수 있다
  • 상태를 소비해야 하는 경우를 생각하라 : 어떤 상황에선 앱이 백그라운드에 있다면 계속 소비하지 않는 게 좋을 수 있다.(토스트 표시 등) 이 경우 UI가 포그라운드에 있을 때 상태를 소비하는 게 좋다

 

일부 앱에선 코틀린 채널 또는 다른 반응형 스트림을 써서 뷰모델 이벤트가 UI에 노출되는 걸 볼 수 있다.

생산자(뷰모델)가 소비자(UI)보다 오래 지속될 때 이런 솔루션은 그런 이벤트의 전송, 처리를 보장하지 않는다. 이로 인해 개발자에게 향후 문제가 발생할 수 있고 대부분의 앱에서 허용되지 않는 사용자 환경이기도 하다. 앱이 일관되지 않은 상태가 되거나 버그가 발생할 수 있고 유저가 중요 정보를 놓칠 수 있다.

 

이런 상황 중 하나가 발생하면 1회성 뷰모델 이벤트가 실제로 UI에 갖는 의미를 다시 생각하라. 즉시 처리하고 UI 상태로 만든다. UI는 특정 시점에 UI를 더 잘 표현하고 더 많은 전송 및 처리 보장을 제공하며 일반적으로 테스트하기 더 용이하고 앱의 나머지 부분과 일관되게 통합된다.

반응형
Comments