UI 이벤트

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

ViewModel은 일반적으로 특정 사용자 이벤트의 비즈니스 로직을 처리합니다(예: 사용자가 일부 데이터를 새로고침하는 버튼을 클릭하는 경우). ViewModel은 보통 UI에서 호출할 수 있는 함수를 노출하여 이러한 로직을 처리합니다. 사용자 이벤트에는 UI에서 직접 처리할 수 있는 UI 동작 로직이 있을 수도 있습니다(예: 다른 화면으로 이동하거나 Snackbar 표시).

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

  • 비즈니스 로직은 결제 또는 사용자 환경설정 저장과 같은 상태 변경과 관련하여 필요한 조치를 말합니다. 도메인과 데이터 레이어는 일반적으로 이 로직을 처리합니다. 이 가이드에서는 아키텍처 구성요소 ViewModel 클래스가 비즈니스 로직을 처리하는 클래스의 추천 솔루션으로 사용됩니다.
  • UI 동작 로직 또는 UI 로직은 탐색 로직 또는 사용자에게 메시지를 표시하는 방법과 같이 상태 변경사항을 표시하는 방법을 나타냅니다. 이 로직은 UI에서 처리합니다.

UI 이벤트 결정 트리

다음 다이어그램은 특정 이벤트 사용 사례를 처리하는 최상의 방법을 찾기 위한 결정 트리를 보여줍니다. 이 가이드의 나머지 부분에서는 이러한 접근 방식을 자세히 설명합니다.

이벤트가 ViewModel에서 시작된 경우 UI 상태를 업데이트합니다. 이벤트가 UI에서 시작되었고 비즈니스 로직이 필요한 경우 비즈니스 로직을 ViewModel에 위임합니다. 이벤트가 UI에서 시작되었고 UI 동작 로직이 필요한 경우 UI에서 직접 UI 요소 상태를 수정합니다.
그림 1. 이벤트 처리를 위한 결정 트리

사용자 이벤트 처리

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

다음 예제에서는 다양한 버튼을 사용하여 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()
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
          // The expand details event is processed by the UI that
          // modifies this composable's internal state.
          onClick = { expanded = !expanded }
        ) {
          val expandText = if (expanded) "Collapse" else "Expand"
          Text("$expandText details")
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the UI's business logic.
        Button(onClick = { viewModel.refreshNews() }) {
            Text("Refresh data")
        }
    }
}

RecyclerView의 사용자 이벤트

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

예를 들어 NewsActivity의 모든 뉴스 항목에 북마크 버튼이 포함되어 있다고 가정해 보겠습니다. ViewModel은 북마크된 뉴스 항목의 ID를 알아야 합니다. 사용자가 뉴스 항목을 북마크에 추가하면 RecyclerView 어댑터는 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 어댑터가 NewsItemUiState 객체 목록과 같이 필요한 데이터만 사용할 수 있습니다. 어댑터가 전체 ViewModel에 액세스할 수 없으므로 ViewModel에 의해 노출된 기능을 악용할 가능성이 낮습니다. 활동 클래스에서만 ViewModel을 사용하도록 허용하는 경우 책임이 분리됩니다. 이렇게 하면 뷰 또는 RecyclerView 어댑터와 같은 UI별 객체가 ViewModel과 직접 상호작용하지 않습니다.

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

이 가이드에서 사용자 이벤트를 처리하는 ViewModel 함수는 처리하는 작업에 따라 동사를 포함해 이름이 지정됩니다(예: addBookmark(id) 또는 logIn(username, password)).

ViewModel 이벤트 처리

ViewModel에서 발생하는 UI 작업(ViewModel 이벤트)은 항상 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.
                    }
                    ...
                }
            }
        }
    }
}

Compose

class LoginViewModel : ViewModel() {
    var uiState by mutableStateOf(LoginUiState())
        private set
    /* ... */
}

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel(),
    onUserLogIn: () -> Unit
) {
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)

    // Whenever the uiState changes, check if the user is logged in.
    LaunchedEffect(viewModel.uiState)  {
        if (viewModel.uiState.isUserLoggedIn) {
            currentOnUserLogIn()
        }
    }

    // Rest of the UI for the login screen.
}

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

UI에서 특정 ViewModel 이벤트를 소비하면 다른 UI 상태가 업데이트될 수 있습니다. 예를 들어, 화면에 임시 메시지를 표시하여 사용자에게 무언가가 발생했음을 알리는 경우, 메시지가 화면에 표시되었을 때 UI가 다른 상태 업데이트를 트리거하도록 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
)

비즈니스 로직이 사용자에게 임시 메시지를 새로 표시해야 하는 경우 ViewModel은 다음과 같이 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)
        }
    }
}

Compose

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

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

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

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.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()
                    }
                    ...
                }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

메시지가 일시적이더라도 UI 상태는 모든 시점에 화면에 표시되는 내용을 충실하게 표현합니다. 사용자 메시지는 표시되거나 표시되지 않습니다.

이벤트 소비로 상태 업데이트 트리거 가능 섹션에서는 UI 상태를 사용하여 화면에 사용자 메시지를 표시하는 방법을 자세히 설명합니다. 탐색 이벤트는 Android 앱의 일반적인 이벤트 유형이기도 합니다.

사용자가 버튼을 탭했기 때문에 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
        }
    }
}

Compose

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI

    Button(onClick = onHelp) {
        Text("Get help")
    }
}

탐색 전에 데이터 입력에 비즈니스 로직 확인이 필요하면 ViewModel은 UI에 상태를 노출해야 합니다. UI는 상태 변경에 반응하고 적절하게 이동합니다. ViewModel 이벤트 처리 섹션에서는 이 사용 사례를 다룹니다. 다음은 비슷한 코드입니다.

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

Compose

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.login()
        }
    ) {
        Text("Log in")
    }
    // Rest of the UI

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
    LaunchedEffect(viewModel, lifecycle)  {
        // Whenever the uiState changes, check if the user is logged in and
        // call the `onUserLogin` event when `lifecycle` is at least STARTED
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                currentOnUserLogIn()
            }
    }
}

위 예에서는 앱이 예상대로 작동합니다. 현재 대상인 로그인이 백 스택에 유지되지 않기 때문입니다. 사용자가 뒤로 버튼을 누르면 로그인 화면으로 돌아갈 수 없습니다. 그러나 이러한 상황이 발생할 경우 솔루션에는 추가 로직이 필요합니다.

ViewModel이 화면 A에서 화면 B로 탐색 이벤트를 생성하는 상태를 설정하고 화면 A가 탐색 백 스택에 유지될 때는 자동으로 B로 진행되지 않도록 추가 로직이 필요할 수 있습니다. 이를 구현하려면 UI가 다른 화면으로 이동하는 것을 고려해야 하는지를 나타내는 추가 상태가 있어야 합니다. 일반적으로 이러한 상태는 UI에 유지됩니다. 탐색 로직이 ViewModel이 아닌 UI에 관한 문제이기 때문입니다. 이 점을 설명하기 위해 다음 사용 사례를 살펴보겠습니다.

개발자가 앱의 등록 흐름에 있다고 가정해 보겠습니다. 생년월일 확인 화면에서 사용자가 날짜를 입력할 때 사용자가 '계속' 버튼을 탭하면 ViewModel에서 날짜를 확인합니다. ViewModel은 확인 로직을 데이터 레이어에 위임합니다. 날짜가 유효하면 사용자는 다음 화면으로 이동합니다. 추가 기능으로는 사용자가 일부 데이터를 변경하려는 경우 여러 등록 화면 간에 이동할 수 있습니다. 따라서 등록 흐름의 모든 대상이 동일한 백 스택에 유지됩니다. 이러한 요구사항을 고려해 이 화면을 다음과 같이 구현할 수 있습니다.

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

Compose

class DobValidationViewModel(/* ... */) : ViewModel() {
    var uiState by mutableStateOf(DobValidationUiState())
        private set
}

@Composable
fun DobValidationScreen(
    onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
    viewModel: DobValidationViewModel = viewModel()
) {
    // TextField that updates the ViewModel when a date of birth is selected

    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(
        onClick = {
            viewModel.validateInput()
            validationInProgress = true
        }
    ) {
        Text("Continue")
    }
    // Rest of the UI

    /*
     * The following code implements the requirement of advancing automatically
     * to the next screen when a valid date of birth has been introduced
     * and the user wanted to continue with the registration process.
     */

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
        LaunchedEffect(viewModel, lifecycle) {
            // If the date of birth is valid and the validation is in progress,
            // navigate to the next screen when `lifecycle` is at least STARTED,
            // which is the default Lifecycle.State for the `flowWithLifecycle` operator.
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    currentNavigateToNextScreen()
                }
        }
    }
}

생년월일 확인은 ViewModel이 담당하는 비즈니스 로직입니다. 대부분의 경우 ViewModel은 이 로직을 데이터 레이어에 위임합니다. 사용자를 다음 화면으로 이동시키는 로직은 UI 로직입니다. 이러한 요구사항은 UI 구성에 따라 변경될 수 있기 때문입니다. 예를 들어 여러 등록 단계를 동시에 표시하는 경우 태블릿의 다른 화면으로 자동으로 넘어가지 않아야 할 수도 있습니다. 위 코드의 validationInProgress 변수는 이 기능을 구현하며, 생년월일이 유효하고 사용자가 다음 등록 단계로 계속 진행하려고 할 때마다 UI가 자동으로 이동해야 하는지를 처리합니다.

기타 사용 사례

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

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

샘플

다음 Google 샘플은 UI 레이어에서 발생하는 UI 이벤트를 보여줍니다. 이러한 샘플을 참고하여 가이드가 실제로 어떻게 적용되는지 살펴보세요.