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

dwjeong·2023년 11월 28일
0

안드로이드

목록 보기
24/28

🔎 UI layer

  • UI의 역할
    애플리케이션 데이터를 화면에 표시하고 사용자 상호작용의 주요 지점 역할을 하는 것.
    사용자 상호 작용 (예: 버튼 누르기)이나 외부 입력(예: 네트워크 응답)으로 인해 데이터가 변경될 때마다 UI는 이러한 변경사항을 반영하도록 업데이트되어야 함.

    사실상 UI는 데이터 계층에서 검색된 애플리케이션 상태를 시각적으로 표현한 것.

데이터 영역에서 얻는 애플리케이션 데이터는 일반적으로 표시해야 하는 정보와 형식이 다름.
(예: UI에서는 데이터의 일부만 필요할 수도 있고, 사용자와 관련된 정보를 제공하기 위해 두 개의 서로 다른 데이터 소스를 병합해야 할 수도 있음.)

👉 UI 계층은 애플리케이션 데이터 변경 사항을 UI가 표시할 수 있는 형식으로 변환 후 표시하는 파이프라인임.




📖 UI 상태 정의

앱이 사용자에게 제공하는 정보가 UI 상태임.
즉, 사용자가 보는 항목이 UI라면, UI 상태는 앱에서 사용자가 봐야 한다고 지정하는 항목.
UI는 UI 상태를 시각적으로 표현한 것. UI 상태에 대한 모든 변경 사항은 UI에 즉시 반영됨.

뉴스 앱의 요구 사항을 충족하기 위해 UI를 완전히 렌더링하는 데 필요한 정보는 다음과 같이 정의된 NewUiState 데이터 클래스에 캡슐화될 수 있음.

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 상태 정의는 변경할 수 없음.
👉 이점: 불변 객체가 순간적으로 애플리케이션 상태에 관한 보장을 제공한다는 것. UI가 단일 역할(상태를 읽고 그에 따라 UI 요소를 업데이트)에 집중할 수 있게 됨.

UI 자체가 해당 데이터의 유일한 소스가 아닌 이상 UI에서 UI 상태를 직접 수정해서는 안됨.
이 원칙을 위반하면 동일한 정보에 대한 여러 소스가 생성되어 데이터 불일치와 같은 미묘한 버그가 발생.

⭐ 요점: 데이터 소스 또는 소유자만이 노출된 데이터 업데이트에 대한 책임을 져야 함.




📖 단방향 데이터 흐름(UDF)으로 상태 관리

📚 state holder

UI 상태를 생성하는 역할을 담당하고 생성 작업에 필요한 로직을 포함하는 클래스를 state holder라고 함. state holder의 크기는 하단 앱 바와 같은 단일 위젯부터 전체 화면이나 navigation 목적지에 이르기까지 관리 대상 UI 요소의 범위에 따라 다양함.

일반적인 구현은 ViewModel의 인스턴스이지만 애플리케이션의 요구 사항에 따라 간단한 클래스로도 충분할 수 있음. 예를 들어 뉴스 앱은 NewsViewModel 클래스를 state holder로 사용하여 해당 섹션에 표시되는 화면의 UI 상태를 생성함.


💡 요점: ViewModel 타입은 데이터 레이어에 액세스할 수 있는 화면 수준 UI 상태 관리에 권장되는 구현. 구성 변경이 자동으로 유지됨. ViewModel 클래스는 앱의 이벤트에 적용할 로직을 정의하고 결과적으로 업데이트된 상태를 생성함.


UI와 상태 생성자 사이의 상호 종속성을 모델링하는 방법에는 여러 가지가 있지만 UI와 해당 ViewModel 클래스 간의 상호 작용은 크게 이벤트 입력과 그에 따른 상태 출력으로 이해될 수 있음.


상태가 아래로 흐르고 이벤트가 위로 흐르는 패턴을 단방향 데이터 흐름(UDF)라고 함.

  • UDF 패턴의 의미
  1. ViewModel은 UI에서 사용할 상태를 보유하고 노출함.
  2. UI 상태는 ViewModel에 의해 변환된 애플리케이션 데이터.
  3. UI는 ViewModel에 사용자 이벤트를 알림.
  4. ViewModel은 사용자 작업을 처리하고 상태를 업데이트함.
  5. 업데이트 된 상태는 렌더링을 위해 UI로 피드백됨.
  6. 상태 변화를 일으키는 모든 이벤트에 대해 위의 내용이 반복됨.

navigation 목적지 또는 화면의 경우 ViewModel은 repository 또는 use case 클래스와 함께 작동하여 데이터를 가져와 이를 UI 상태로 변환하는 동시에 상태 변형을 일으킬 수 있는 이벤트 효과를 통합함.

  • 기사 항목의 UI

사용자가 북마크를 요청하는 것은 상태 변화를 일으킬 수 있는 이벤트의 예.
상태 생산자로서 UI 상태의 모든 필드를 채우고 UI가 렌더링하는 데 필요한 이벤트를 처리하는 데 필요한 모든 로직을 정의하는 것은 ViewModel의 책임.




📚 로직의 타입

  • 비즈니스 로직: 앱 데이터에 대한 제품 요구사항을 구현하는 것. 예를 들어 기사를 북마크하는 것은 앱에 값을 제공하기 때문에 비즈니스 로직의 한 예. 비즈니스 로직은 일반적으로 도메인이나 데이터 레이어에 배치되지만 UI 레이어에는 배치되지 않음.

  • UI 동작 로직 혹은 UI 로직: 상태 변경을 화면에 표시하는 방법. 예를 들어 android 리소스를 사용하여 화면에 표시할 올바른 텍스트 가져오기, 사용자가 버튼을 클릭할 때 특정 화면으로 이동, 토스트나 스낵바를 이용하여 화면에 사용자 메시지 표시하기 등.


특히 Context와 같은 UI 유형과 관련된 UI 로직은 ViewModel이 아닌 UI에 있어야 함.
UI가 복잡해지고 테스트 가능성과 관심사 분리를 위해 UI 로직을 다른 클래스에 위임하려는 경우 간단한 클래스를 state holder로 만들 수 있음. UI에서 생성된 간단한 클래스는 UI의 수명 주기를 따르기 때문에 Android SDK 종속성을 사용할 수 있음.



📚 UDF를 사용해야하는 이유

UDF는 상태 생성 주기를 모델링함. 또한 상태 변경이 발생하는 위치, 변환되는 위치, 최종적으로 소비되는 위치를 분리함. 이러한 분리를 통해 UI는 이름에서 알 수 있듯이 상태 변경을 관찰하여 정보를 표시하고 해당 변경 사항을 ViewModel에 전달하여 사용자 의도를 전달하는 등의 작업을 정확하게 수행할 수 있음.


  • UDF가 허용하는 것
  1. 데이터 일관성: UI에 대한 단일 정보 소스가 있음.
  2. 테스트 가능성: 상태 소스는 격리되어 있으므로 UI와 독립적으로 테스트할 수 있음.
  3. 유지관리성: 상태 변경은 사용자 이벤트와 해당 이벤트가 가져오는 데이터 소스의 결과로 발생하는 잘 정의된 패턴을 따름.




📖 UI 상태 노출

UDF를 사용하여 상태 생성을 관리하므로 생성된 상태를 스트림으로 간주할 수 있음.
즉, 시간 경과에 따라 여러 버전의 상태가 생성됨.
ViewModel에서 데이터를 직접 가져오지 않고도 UI가 상태 변경사항에 반응할 수 있도록 하기 위해 LiveData 또는 StateFlow와 같이 관찰 가능한 데이터 홀더에 UI 상태를 노출해야함.

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

    val uiState: StateFlow<NewsUiState> =}

UI에 노출되는 데이터가 상대적으로 단순한 경우 데이터를 UI 상태 타입으로 래핑하는 것이 좋음.
내보낸 state holder와 관련 화면/UI 요소 간의 관계를 전달하기 때문.

또한 UI 요소가 더 복잡해질 때 언제나 간편하게 UI 상태 정의를 추가하여 UI 요소를 렌더링하는 데 필요한 더 많은 정보를 포함할 수 있음.

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

예를 들어 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)
                 }
            }
        }
    }
}

⭐ 참고: 위 예의 ViewModel에서 함수를 통해 상태가 변경되는 패턴은 단방향 데이터 흐름에서 가장 흔한 구현 중 하나.


📚 추가 고려사항

  1. UI 상태 객체는 서로 관련된 상태를 처리해야함. 이렇게 하면 불일치가 줄어들고 코드를 더 쉽게 이해할 수 있음.

    예를 들어 뉴스 목록과 북마크 수를 서로 다른 두 스트림에 노출하는 경우 하나는 업데이트되고 다른 하나는 업데이트되지 않는 상황이 발생할 수 있음.
    단일 스트림을 사용하면 두 요소가 모두 최신 상태로 유지됨.
    또한 일부 비즈니스 로직에서는 소스를 합쳐야할 필요가 있을 수 있음. 예를 들어 로그인되어 있고 사용자가 프리미엄 뉴스 서비스에 가입한 경우에만 북마크 버튼을 표시해야할 수 있음.

    아래 코드에서 책갈피 버튼의 visibility는 다른 두 속성의 파생 속성.
    비즈니스 로직이 더욱 복잡해짐에 따라 모든 속성을 즉시 사용할 수 있는 단일 UiState 클래스를 갖는 것이 점점 더 중요해지고 있음.
data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf()
)

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

  1. UI 상태 - 단일 스트림 혹은 다중 스트림
    단일 스트림 노출의 가장 큰 장점은 편의성과 데이터 일관성. 상태 소비자는 언제든지 최신 정보를 사용할 수 있음.

    그러나 ViewModel과 별도의 상태 스트림이 적절한 경우가 있음.

    (1) 관련되지 않은 데이터 유형
    UI를 렌더링하는데 필요한 일부 상태는 서로 완전히 독립적일 수 있음. 이와 같은 경우 서로 다른 상태를 하나로 묶는데 드는 비용이 이점보다 클 수 있음.
    (상태 중 하나가 다른 상태보다 더 자주 업데이트 되는 경우는 더욱 비용이 이점보다 커짐.)


    (2) UIState 비교: UIState 객체에 필드가 많을수록 해당 필드 중 하나가 업데이트되어 스트림이 내보내질 가능성이 높아짐. 뷰에는 연속적으로 이루어지는 내보내기가 다른지 동일한지 파악하는 비교(diff) 메커니즘이 없기 때문에 내보내기할 때마다 뷰가 업데이트 됨.

    따라서 Flow API 또는 LiveData의 distinctUntilChanged()와 같은 메서드를 사용한 완화 작업이 필요할 수 있음.




📖 UI 상태 소비

UI에서 UiState 객체의 스트림을 사용하려면 사용 중인 관찰 가능한 데이터 유형에 터미널 연산자를 사용함. 예를 들어 LiveData의 경우 observe() 메서드를 사용하고 Kotlin flow의 경우 collect() 메서드나 이 메서드의 변형을 사용함.

UI에서 관찰 가능한 데이터 홀더를 사용할 때는 UI의 수명 주기를 고려해야함.

👉 수명 주기를 고려해야 하는 이유는 사용자에게 뷰가 표시되지 않을 때 UI가 UI 상태를 관찰해서는 안 되기 때문.
LiveData를 사용하면 LifecycleOwner가 수명 주기 문제를 암시적으로 처리. flow를 사용할 때는 적절한 coroutine scope와 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 필드를 사용하는 것.

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 값으로 표현되기 때문. 그러나 오류에는 사용자에게 다시 전달하기 위한 관련 메시지나 실패한 작업을 재시돌하는 관련 작업이 포함될 수 있음.

오류 메시지는 스낵바와 같은 UI 요소의 형태로 사용자에게 표시될 수 있음.

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

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



📖 스레딩 및 동시성

ViewModel에서 수행되는 모든 작업은 기본적으로 안전해야함.
👉기본 스레드에서 호출해도 안전해야함. 이는 데이터 및 도메인 계층이 작업을 다른 스레드로 이동하는 역할을 담당하기 때문.

ViewModel이 장기 실행 작업을 수행하는 경우 해당 로직을 백그라운드 스레드로 이동하는 역할도 담당.
Kotlin 코루틴은 동시 작업을 관리하는 좋은 방법이며 Jetpack 아키텍처 컴포넌트는 이를 기본적으로 지원.

0개의 댓글