UI의 역할은 화면에 어플리케이션 데이터를 표시하고 사용자 상호작용의 기본 지점으로서의 역할을 수행하는 것.

사실상 UI는 데이터 레이어에서 가져온 어플리케이션의 상태를 시각적으로 나타낸다.

하지만 일반적으로 데이터 레이어에서 가져오는 어플리케이션 데이트는 표시해야 하는 정보와 다른 형식

-> UI용으로 데이터의 일부만 필요

-> 사용자에게 관련성 있는 정보를 표시하기 위해 서로 다른 두 데이터 소스를 병합

UI 레이어는 어플리케이션 데이터 변경사항을 UI가 표시할 수 있는 형식으로 변환한 후에 표시하는 파이프라인이다.

 

UI Layer Architecture

UI라는 용어는 사용하는 API(View or Jetpack Compose)와 관계없이 데이터를 표시하는 Activity or Fragment와 같은 UI 요소를 가리킨다. 

Data Layer의 역할은 앱 데이터를 보유하고 관리하며 앱 데이터에 엑세스할 권한을 제공하는 것이므로 UI 레이어에서 다음 단계를 실행한다.

1. 앱 데이터를 사용하고 UI에서 쉽게 렌더링할 수 있는 데이터로 변환한다.

2. UI 렌더링 가능 데이터를 사용하고 사용자에게 표시할 UI 요소를 변환한다.

3. 이렇게 조합된 UI 요소의 사용자 입력 이벤트를 사용하고 입력 이벤트의 결과를 필요에 따라 UI 데이터에 반영한다.

4. 1~3단계를 필요에 따라 반복

 

UI State Definition

간단히 말해 UI에는 각 기사의 일부 MetaData와 함께 기사 목록이 표시된다. 

즉, 사용자가 보는 화면이 UI라면 UI State는 앱에서 사용자가 봐야 한다고 지정하는 항목이다.

UI State 변경 -> UI 즉시 반영

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

불변성

위 예에서 UI State 정의는 변경할 수 없다. 불변성의 주요 이점은 변경 불가능한 객체가 순간의 어플리케이션 상태를 보장한다는 점이다.

이로 인해 UI는 상태를 읽고 이에 따라 UI 요소를 업데이트하는 한 가지 역할에 집중할 수 있다. 따라서 UI 자체가 데이터의 유일한 소스인 경우를 제외하고 UI에서 UI State를 직접 수정해서는 안된다. 이 원칙을 위반하면 동일한 정보가 여러 정보 소스에서 비롯되어 데이터 불일치와 미세한 버그가  발생한다.

 

상기 가이드의 이름 지정 규칙은 다음과 같다.

기능 + UIState

예를 들어 뉴스를 표시하는 화면의 상태는 NewsUiState, 뉴스 항목 목록에 있는 뉴스 항목의 상태는 NesItemUiState일 수 있다.

 

단방향 데이터 흐름으로 UI State 관리

앞서 상기 UI State가 UI Rendering에 필요한 세부정보가 포함된 변경 불가능한 Snapshot임을 확인했다.

하지만 앱 데이터의 동적 특성에 따라 상태는 시간이 지나면서 변경될 수 있으며 이는 앱을 채우는 데 사용되는 기본 데이터를 수정하는 사용자 상호작용이나 기타 이벤트로인해 발생하기도 한다.

단방향 데이터 흐름(Unidirectional Data Flow)

 

상태 홀더

UI State를 생성하는 역할을 담당하고 생성 작업에 필요한 로직을 포함하는 클래스를 상태 홀더(State Holder)라고 한다.

전체 화면이나 탐색 대상의 경우 일반적인 구현은 ViewModel의 인스턴스이지만 어플리케이션의 요구사항에 따라 간단한 클래스로도 충분하다. 

 

UI 와 State 생성자 간의 상호 종속을 모델링하는 방법은 다양하다. 하지만 UI와 ViewModel클래스 사이의 상호작용은 대체로 이벤트 입력과 입력의 후속 상태인 출력으로 간두될 수 있으며 관계는 상기 다이어그램과 같다.

상태가 아래로 향하고 이벤트는 위로 향하는 패턴을 단방향 데이터 흐름(UDF)이라고 한다. 이 패턴이 앱 아키텍처에 미치는 영향은 다음과 같다.

1. ViewModel이 UI에 사용될 상태를 보유하고 노출한다. UI State는 ViewModel에 의해 변환된 어플리케이션 데이터이다.

2. UI가 ViewModel에 사용자 이벤트를 알린다. 

3. ViewModel이 사용자 작업을 처리하고 State(상태)를 업데이트한다.

4. 업데이트된 상태가 렌더링할 UI에 다시 제공된다.

5. 상태 변경을 야기하는 모든 이벤트 위의 작업이 반복된다.

 

상기 다이어그램을 보자. 사용자의 기사 북마크 요청은 상태 변경을 야기할 수 있는 이벤트의 예이다. 상태 생성자의 경우 UI State의 모든 필드를 채우고 UI가 완전히 렌더링되는데 필요한 이벤트를 처리하기 위해 모든 필수 로직을 정의하는 역할은 ViewModel이 담당한다.

로직의 유형

상기 예처럼 기사 북마크는 앱에 가치를 부여하므로 비즈니스 로직의 예이다. 

-> 비즈니스 로직은 State 변경에 따라 진행해야 할 작업이다. 비즈니스 로직은 일반적으로 도메인 또는 데이터 레이어에 배치되지만 UI Layer에는 배치되지 않는다.

-> UI 동작 로직 또는 UI 로직은 State 변경 사항을 표시하는 방법이다. 

* 특히 Context와 같은 UI 유형의 경우 UI 로직은 ViewModel이 아닌 UI에 있어야 한다. 테스트 가능성을 높이고 문제 구분에 도움이 되도록 UI로직을 다른 클래스에 위임하고자 하며 UI가 점점 복잡해지는 경우 간단한 클래스를 State Holder로 만들 수 있다.

 

UDF(Unidirectional Data Flow)을 사용하는 이유

UDF는 상기 다이어그램과 같이 상태 생성 주기를 모델링한다. 또한 상태 변경이 발생하는 위치, 변환되는 위치, 최종적으로 사용되는 위치를 구분한다. 이렇게 구분하면 UI가 이름에 드러난 의미 그대로 동작할 수 있다.

즉, 상태 변경사항을 관찰하여 표시하고 변경사항을 ViewModel에 전달하여 사용자 인텐트를 전달한다.

UDF 를 사용하면 다음이 가능하다.

1. 데이터 일관성: UI용 정보 소스가 하나이다.

2. 테스트 가능성: 상태 소스가 분리되므로 UI와 별개로 테스트 할 수 있다.

3. 유지 관리성: 상태 변경은 잘 정의된 패턴을 따른다. 즉, 변경은 사용자 이벤트 및 데이터를 가져온 소스 모두의 영향을 받는다.

 

UI 상태 노출

UI 상태를 정의하고 이 상태의 생성을 관리할 방법을 결정한 후에는 생성된 상태를 UI에 표시하는 단계를 진행한다.

UDF를 사용하여 상태 생성을 관리하므로 생성된 상태를 스트림으로 간주할 수 있다.

즉, 시간 경과에 따라 여러 버전의 상태가 생성된다. 따라서 LiveData, StateFlow와 같이 관찰 가능한 데이터 홀더에 UI 상태를 노출해야 한다. 이유는 ViewModel에서 데이터를 직접 가져오지 않고도 UI가 상태 변경사항에 반응할 수 있도록 하기 위해서이다.

이러한 유형은 항상 최신 버전의 UI 상태를 캐시한다는 이점도 있다. 이는 구성 변경 후 빠른 상태 복원에 유용하다.

class NewsViewModel(...) : ViewModel() {

    val uiState: StateFlow<NewsUiState> = …
}

UI에 노출되는 데이터가 비교적 간단할 때는 UI 상태 유형으로 데이터를 래핑하는 것이 좋은 경우가 많다.

내보낸 상태 홀더와 관련 View/UI Component 간의 관계를 전달하기 때문. 또한 UI Component가 더 복잡해질 때 언제나 간편하게 UI State Definition을 추가하여 UI Component를 렌더링하는데 필요한 더 많은 정보를 포함할 수 있다.

 

UIState Stream을 만드는 일반적인 방법은 ViewModel에서 지원되는 변경 가능한 스트림을 변경 불가능한 스트림으로 노출하는 것이다.

Ex) MutableStateFlow<UIState> 를 StateFlow<UIState>로 노출한다.

class NewsViewModel(...) : ViewModel() {

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

    ...

}

이후, ViewModel은 상태를 내부적으로 변경하는 메서드를 노출하여 UI에 사용되도록 업데이트를 게시한다. 예를 들어 비동기 작업을 실행해야 하는 경우 viewModelScope를 사용하여 코루틴을 실행하고 코루틴이 완료되면 변경 가능한 상태를 업데이트 할 수 있다.

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

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

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                 }
            }
        }
    }
}

상기 예에서 NewsViewModel 클래스는 특정 카테고리의 기사를 가져오려고 시도한 후에 결과에 따라 UI가 적절하게 반응할 수 있도록 시try catch결과를 UI 상태에 반영한다. 

 

추가 고려사항

1. UI State 객체는 서로 관련성 있는 State를 처리해야 한다. 이렇게 하면 불일치가 줄어들고 코드를 이해하기 더 쉽다. 뉴스 항목 목록과 북마크 수를 서로 다른 두 스트림에 노출하면 한 스트림이 업데이트 되고 다른 스트림은 업데이트되지 않은 상황이 발생할 수 있다. 단일 스트림을 사용하면 두 요소가 최신 상태로 유지된다. 또한 일부 비즈니스 로직에는 소스 조합이 필요할 수 있다.

Ex) 로그인한 상태인 동시에 프리미엄 뉴스 서비스의 구독자인 사용자에게만 북마크 버튼을 표시해야 한다면 다음과 같이 UI State클래스를 정의하면된다.

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf()
)

val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium

2. UI State: 단일 스트림인지 여러 스트림인지. UI State 노출 대상을 단일 스트림과 여러 스트림 중에서 선택할 때 주요 원칙은 항목 간의 관계이다. 단일 스트림 노출의 가장 큰 장점은 편의성과 데이터 일관성이다. 즉, State 사용자가 언제나 즉시 최신 정보를 확인할 수 있다. 하지만 ViewModel 상태의 스트림이 별개일 때 적합한 경우가 있다.

-> 관련 없는 데이터 유형: UI를 렌더링하는 데 필요한 일부 상태는 서로 완전히 별개일 수 있다. 이때 서로 다른 상태를 번들로 묶는 데 드는 비용이 이점보다 더 클 수 있으며 이는 상태 중 하나가 다른 상태보다 더 자주 업데이트 되는 경우에 특히 그렇다.

-> UIState 비교(diff): UIState 객체에 필드가 많을수록 필드 중 하나를 업데이트하면 스트림이 내보내질 가능성이 크다. 뷰에는 연속적으로 이러우지는 내보내기가 같은지 다른지 파악하는 비교(diff) 메커니즘이 없으므로 내보내기할 때마다 뷰가 업데이트된다. 따라서 Flow API 또는 LiveData의 distinctUntilChaged()와 같은 메서드를 사용한 완화 작업이 필요할 수있다.

 

UI State 사용

UI에서 UIState 객체의 스트림을 사용하려면 사용 중인 관찰 가능한 데이터 유형에 터미널 연산자를 사용한다.

Ex) LiveData -> observe(), Kotlin Flow -> collect()

UI에서 관찰 가능한 데이터 홀더를 사용할 때는 UI의 수명 주기를 고려해야 한다. 수명 주기를 고려해야 하는 이유는 사용자에게 뷰가 표시되지 않을 때 UI가 UI 상태를 관찰해서는 안되기 때문. LiveData를 사용하면 LifecycleOwner가 수명 주기 문제를 암시적으로 처리한다. Flow를 사용할 때는 적절한 코루틴 범위와 repeatOnLifecycle API로 처리하는 것이 가장 좋다.

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

진행 중인 작업 표시

UIState 클래스의 로드 상태를 나타내는 간단한 방법은 Boolean Field를 사용하는 것이다.

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

이 플래그의 값은 UI에 진행률 표시줄이 존재하는지를 나타낸다.

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Bind the visibility of the progressBar to the state
                // of isFetchingArticles.
                viewModel.uiState
                    .map { it.isFetchingArticles }
                    .distinctUntilChanged()
                    .collect { progressBar.isVisible = it }
            }
        }
    }
}

화면에 오류 표시

UI에서의 오류 표시는 진행 중인 작업 표시와 비슷하다. 두 작업은 모두 존재 여부를 나타내는 Boolean 값으로 쉽게 표현되기 때문이다.

하지만 오류에는 사용자에게 다시 전달하는 관련 메시지 또는 실패한 작업을 다시 시도하는 관련 작업이 포함될 수 있다.

따라서 진행중인 작업을 로드하고 있거나 로드하고 있지 않은 동안 오류 컨텍스트에 적절한 메타데이터를 호스팅하는 데이터  클래스를 사용하여 오류 상태를 모델링해야 할 수 있다.

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

Threading and concurrency

ViewModel에서 실행되는 모든 작업은 기본 스레드에서 안전하게 호출된다는 기본 안전성을 갖추어야 한다. 데이터 레이어와 도메인 레이어가 작업을 다른 스레드로 옮기는 역할을 담당하기 때문.

또한 장기 실행 작업의 경우 ViewModel에서 로직을 백그라운드 스레드로 옮기는 역할을 담당한다. Kotlin 코루틴은 동시 실행 작업을 관리하는 좋은 방법이며, JetPack 아키텍처 구성요소는 Kotlin Coroutine을 기본적으로 지원한다.

Navigation

앱 Navigation 변경사항은 주로 이벤트 같은 내보내기에 의해 이루어진다. 예를 들어 SignInViewModel Class가 로그인을 실행하면 UIState는 isSignedin 필드를 true로 설정할 수 있다. 이러한 Trigger 위의 UI State 사용에서 설명한 대로 사용해야 한다.

단, 사용 구현이 탐색 구성요소를 지연해야 한다.

Paging

Paging 라이브러리는 UI에서 PagingData라는 유형과 함께 사용된다. PagingData는 시간 경과에 따라 변경될 수 있는(즉, 변경 불가능한 유형이 아님)항목을 나타내고 포함하므로 변경 불가능한 UI 상태로 표현되엇는 안된다. 대신 ViewModel의 데이터를 독립적으로 자체 스트림에서 노출해야한다.

Animation

부드럽고 원활한 최상위 탐색 전환을 제공하기 위해 다음 화면의 데이터가 로드될 때까지 기다린 후에 애니메이션을 시작하는 것이 좋다.

Android View Framework는 postponeEnterTransition() 및 startPostponedEnterTransition() API를 사용하여 프래그먼트 대상 간의 전환을 지연하는 후크를 제공한다.

+ Recent posts