아키텍처 가이드 - UI layer (2)

dwjeong·2023년 11월 29일
0

안드로이드

목록 보기
25/28

🔎 UI 이벤트

UI 이벤트는 UI 또는 ViewModel을 통해 UI 계층에서 처리되어야 하는 작업. 가장 일반적인 유형의 이벤트는 사용자 이벤트. 사용자는 화면을 탭하거나 동작을 생성하는 등 앱과 상호작용하여 사용자 이벤트를 생성함.
UI는 onClick() 리스너와 같은 콜백을 사용하여 이러한 이벤트를 사용함.


⭐️ 핵심 용어

  • UI: 사용자 인터페이스를 처리하는 View 기반 또는 Compose 코드.
  • UI 이벤트: UI 레이어에서 처리되어야 하는 작업.
  • 사용자 이벤트: 사용자가 앱과 상호작용할 때 생성하는 이벤트.

ViewModel은 일반적으로 특정 사용자 이벤트의 비즈니스 로직을 처리.
예를 들어 사용자가 일부 데이터를 새로 고치기 위해 버튼을 클릭하는 경우. 일반적으로 ViewModel은 UI가 호출할 수 있는 함수를 노출하여 이를 처리함.

사용자 이벤트에는 UI에서 직접 처리할 수 있는 UI 동작 로직(예: 다른 화면으로 이동하거나 스낵바 표시)이 있을 수 있음.


비즈니스 로직은 다양한 모바일 플랫폼 또는 폼 팩터의 동일한 앱에 대해 동일하게 유지되지만 UI 동작 로직은 해당 경우에 따라 다를 수 있음.

UI 레이어에서는 이러한 유형의 로직을 다음과 같이 정의

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



📖 UI 이벤트 결정 트리

  • 특정 이벤트 사용 사례를 처리하기 위한 최선의 접근 방식을 찾기 위한 의사 결정 트리

📖 사용자 이벤트 처리

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

  • 다양한 버튼을 사용하여 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()
        }
    }
}

📚 RecyclerView의 사용자 이벤트

RecyclerView 아이템이나 커스텀 뷰에서와 같이 작업이 UI 트리 아래에서 생성되는 경우 ViewModel은 사용자 이벤트를 처리하는 역할을 해야함.

예를 들어 NewsActivity의 모든 뉴스 항목에 북마크 버튼이 포함되어 있다면, ViewModel은 북마크된 뉴스 항목의 ID를 알아야 함. 사용자가 뉴스 항목을 북마크에 추가하면 RecyclerView 어댑터는 ViewModel에 대한 종속성이 필요한 ViewModel에서 노출된 addBookmark(newsId) 함수를 호출하지 않음.

대신 ViewModel은 이벤트 처리를 위한 구현을 포함하는 NewsItemUiState라는 state 객체를 노출함.

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을 RecyclerView 어댑터에 전달하는 것은 좋지 않음. 이럴 경우 어댑터가 ViewModel 클래스와 긴밀하게 연결되기 때문


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



📖 ViewModel 이벤트 처리

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

사용자가 메시지를 소비했을 때 (메시지를 닫거나 시간이 초과됨.) 발생하는 이벤트는 사용자 입력으로 다루어야 할 수 있으므로 ViewModel에서 이를 알아야 함.

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

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에서 이벤트가 트리거되는 경우 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
        }
    }
}

탐색 전에 데이터 입력에 비즈니스 로직 확인이 필요하면 ViewModel은 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.
                    }
                    ...
                }
            }
        }
    }
}

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


📚 대상이 백 스택에 유지된 경우 네비게이션 이벤트

ViewModel이 화면 A에서 화면 B로 네비게이션 이벤트를 생성하는 일부 상태를 설정하고 화면 A가 네비게이션 백 스택에 유지되는 경우 자동으로 B로 계속 진행하지 않도록 추가 로직이 필요할 수 있음.

이를 구현하려면 추가 로직이 필요. UI가 다른 화면으로 이동하는 것을 고려해야 하는지 여부를 나타내는 상태. 일반적으로 네비게이션 로직은 ViewModel이 아닌 UI의 관심사이므로 해당 상태는 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)
    }
}

생년월일 확인은 ViewModel이 담당하는 비즈니스 로직.
대부분의 경우 ViewModel은 해당 로직을 데이터 계층에 위임.
사용자를 다음 화면으로 이동시키는 로직은 UI 로직. 이러한 요구사항은 UI 구성에 따라 변경될 수 있기 때문.

예를 들어 동시에 여러 등록 단계를 표시하는 경우 태블릿에서 다른 화면으로 자동으로 이동하지 않을 수 있음.
위 코드의 validationInProgress 변수는 이 기능을 구현하고 생년월일이 유효하고 사용자가 다음 등록 단계를 계속하기를 원할 때마다 UI가 자동으로 탐색되어야 하는지 여부를 처리.



📖 기타 사용사례

UI 이벤트 사용 사례가 UI 상태 업데이트로 해결될 수 없다고 생각한다면 앱에서 데이터가 흐르는 방식을 다시 고려해야 할 수도 있음.

  • 각 클래스는 자신이 담당한 작업을 수행해야 함. UI는 네비게이션 호출, 클릭 이벤트, 권한 요청 획득과 같은 화면별 동작 로직을 담당. ViewModel은 비즈니스 로직을 포함하고 계층 구조의 하위 레이어 결과를 UI 상태로 변환.

  • 이벤트가 어디서 발생하는지 생각해볼 것. 의사 결정 트리에 따라 각 클래스가 담당하는 작업을 처리하도록 할 것. 이벤트가 UI에서 시작되어 네비게이션 이벤트가 발생하는 경우 해당 이벤트는 UI에서 처리되어야 함. 일부 로직은 ViewModel에 위임될 수 있지만 이벤트 처리는 ViewModel에 완전히 위임될 수 없음.

  • 소비자가 여러명이고 이벤트가 여러번 소비되는것이 걱정된다면 앱 아키텍처를 다시 고려해야 할 수 있음. 동시 소비자가 여러명 있으면 정확히 한번만 제공되는 계약을 보장하기 어려워지므로 복잡하고 미묘한 동작이 폭발적으로 증가함. 이 문제가 발생할 경우 해당 문제를 UI 트리의 위쪽으로 푸시하는 것을 고려해볼 것.

  • 상태를 소비해야 하는 경우를 생각해볼 것. 어떤 상황에서는 앱이 백그라운드에 있다면 계속 소비하지 않는 것이 좋을 수 있음.(예: Toast 표시) 이 경우 UI가 포그라운드에 있을 때 상태를 소비하는 것이 좋음.

0개의 댓글