본문 바로가기
Android/Architecture

[Android] 앱 아키텍처 가이드 (2) - UI Layer

by 태크민 2023. 4. 29.

UI Layer

  • UI Layer의 역할은 화면에 앱 데이터를 표시하는 것
  • 따라서, 사용자 상호작용(ex. 버튼 누르기) 또는 외부 입력(ex. 네트워크 응답)으로 인해 데이터가 변할 때마다 변경사항을 반영하도록 UI가 업데이트 되어야 함
  • 사실상 UI Layer는 Data Layer에서 가져온 앱 데이터를 시각적으로 보여주는 것에 관심이 있다.

 

UI Layer의 구성 요소 

UI = UIelements + State holders

 

UIelements

: UI 요소가 갖는 본질적인 상태

  • 예시 
    • TextView의 font, font size, font color
    • android: font
    • android: fontSize

State holders

: 화면의 UI요소에 데이터를 보여주기 위해 필요한 앱 데이터

  • 예시
    • NewsUiState

 

  1. UI elements
    화면에 데이터를 렌더링하는 역할, (ex. View 또는 Jetpack Compose)
  2. State holders
    - 데이터를 보유하고 이를 UI에 표시하며 비즈니스 로직을 처리하는 역할 (ex. ViewModel)
    - UI 상태를 생성하며, 생성 작업에 필요한 로직을 포함하는 클래스

 

 

UI Layer 아키텍처

일반적으로 UI란 데이터를 표시하는 activity, fragment와 같은 UI 요소를 가리킨다. 

 (반대로, Data Layer의 역할은 앱 데이터를 보유 및 관리하며 앱 데이터에 액세스할 권한을 제공하는 역할을 한다.)

 

UI 계층에서 하는 일

  1. 앱 데이터를 화면에 보여주기 쉬운 모양으로 변환하는 작업 실행(앱 데이터 -> UI State로 변환)
  2. UI 요소에 UI State를 연결하는 작업 실행
  3. 사용자의 이벤트를 받고 이벤트에 응답하기 위해 UI State를 업데이트 하는 작업 실행
    (Data Layer의 역할은 앱 데이터를 보유하고 관리하며 앱 데이터에 액세스할 권한을 제공하는 것이다.)

 

UI State

  • 앱 데이터로부터 화면에 보여주기 쉬운 모양으로 추출되고 변환된 데이터를 의미
  • 사용자에게 표시되는 데이터
  • UI 상태가 변경되면 변경사항사항이 즉시 UI에 반영되어야 한다.

UI = UI Elements + UI State

뉴스 앱에서의 UI State 예시

- NewsUiState와 NewsItemUiState가 존재

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가 지켜야 하는 원칙

  •  불변성(Immutability)
    • 변하지 않는 특성
    • 외부에서 setXXX()함수를 호출하여 UI State를 변경할 수 없다는 의미(=read only, 읽기 전용)
    • Activity, Fragment 클래스에서 UI State를 변경해서는 안된다.
  • 불변성 원칙의 장점
    • Activity, Fragment의 수명 주기와 상관 없이 UI State를 변하지 않게 유지할 수 있다.
    • Activity, Fragment가 UI State를 읽고(쓰지는 않음) 그 값을 UI 요소에 반영하는 역할에만 집중할 수 있게 해준다.
  • data class로 선언
    • 불변성 원칙을 지키기 위해 보통 kotlin의 data class를 활용하여 UI State를 만든다.
    • data class 파라미터는 val로 선언

UI State 클래스 이름 짓기에 대한 컨벤션

- 공식 문서에서는 XXUiState로 짓기를 권장하고 있다.

 

 

State holder 클래스

이름 그대로 UI State를 잡아두고 저장하고 관리하는 클래스

  • State Holder 클래스의 관심사
    • UI State 생산하기
    • UI State를 사용해야 하는 로직 정의하기
  • ViewModel 클래스
    • Android Jetpack 라이브러리가 제공하는 대표적인 State Holder 클래스
  • 뉴스 앱에서의 StateHolder 클래스 예시
    • NewsViewModel 클래스가 존재할 것
    • NewsItemUiState 클래스를 생산하고, 관련된 로직이 정의되어 있을 것

 

 

단방향 데이터 플로우로 상태 관리(UDF)

단방향 데이터 플로우(UDF)란?

-> UI State는 아래로 흐르고, evenet는 위로 흐르는 플로우

앱 아키텍처에서 UDF의 작동 방식을 보여주는 다이어그램

UI가 data를 소유하고 관리하고 생성하고 가공하는 등의 역할을 하게되면 복잡해지고 코드의 결합성이 높아져 테스트 및 유지보수에 안좋은 영향을 끼칠 수 있다.

따라서, UI 상태가 매우 단순하지 않는 이상 UI의 역할은 오직 UI 상태를 사용 및 표시해야 한다.

 

 

UDF가 앱 아키텍처에 미치는 영향

  • ViewModel이 UI에 사용될 상태를 보유하고 노출한다. UI상태는 ViewModel에 의해 변환된 애플리케이션 데이터이다.
  • UI가 ViewModel에 사용자 이벤트를 알린다.
  • ViewModel이 사용자 작업을 처리하고 상태를 업데이트한다.
  • 업데이트된 상태가 UI에 다시 적용된다.
  • 상태 변경을 야기하는 모든 이벤트에 위의 작업이 반복된다.

 

UDF 아키텍처 설계 총정리

  • ViewModel은 앱 데이터를 UI State 형태로 변환하는 역할을 하며, 해당 UI State를 보유하고 있다.
  • UI State는 앱 데이터가 UI에 노출하기 쉬운 형태로 추출된 형태이다.
  • UI는 유저의 이벤트를 받으면 ViewModel에게 알린다.
  • ViewModel은 유저의 이벤트에 응답하기 위해 UI State를 업데이트 한다.
  • UI 요소는 업데이트된 UI State를 UI 요소에 반영한다.

 

 

UI 계층과 Data 계층간의 소통

ViewModel은 Repository 또는 Usecase Class와 함께 사용되어 데이터를 가져와 UI상태로 변환하는 동시에 상태 변경을 야기할 수 있는 이벤트를 통합한다.

 

UI Layer < - > Data Layer

  1. UI 요소: 북마크 클릭 이벤트를 ViewModel에게 알린다.
  2. ViewModel: UI State를 업데이트 한다.
    • Data 계층에게 앱 데이터 변경 필요를 알린다.
    • Data 계층에 의해 외부(서버)에서 앱 데이터가 업데이트 된다. (북마크 체크 여부가 true로 변경)
    • 업데이트된 앱 데이터를 ViewModel에게 전달한다.
  3. ViewModel: 업데이트된 앱 데이터를 새로운 UI State로 생산한다.
  4. ViewModel: 새로운 UI State를 UI 요소에게 전달한다.
  5. UI요소: 전달받은 UI State를 UI 요소에 연결하여 보여준다.

북마크 클릭 이벤트에 대한 UDF 다이어그램

 

로직의 유형(UI, Business)

  • 비즈니스 로직
    • 앱 데이터에 대한 제품 요구사항에 대한 구현이다.
    • 앞서 언급했던 뉴스 기사 북마크는 앱에 value(데이터)를 제공하므로 비즈니스 로직의 예라고 할 수 있다.
    • 비즈니스 로직은 일반적으로 Domain Layer 또는 Data Layer에서 처리한다. (UI Layer에서는 처리하면 안된다.)

 

  • UI 로직
    • 화면에 상태 변경사항을 표시하는 방법이다.
    • 예를들어 Android Resources를 사용하여 화면에 표시할 올바른 텍스트를 가져오거나, 사용자가 버튼을 클릭할 때 특정 화면으로 이동하거나, 토스트 메시지 또는 스낵바를 사용하여 화면에 사용자 메시지를 표시한다.
    • 특히, Context 같은 UI 유형의 경우 UI 로직은 ViewModel이 아닌 UI에 있어야한다.

 

  • UI State와 Logic의 관계
    • UI State는 고정된 속성값이 아니다
    • 앱 데이터의 변경이나 사용자 이벤트에 의해 바뀔 수 있는 속성값이다
    • 비즈니스 로직에 의해 앱 데이터가 변경된다
    • UI로직에 의해 화면에 보이는 것들이 변경된다
    • 결론
      : Logic에 의해 UI State가 변경된다(=새로운 UI State가 생산된다)

 

 

UDF로 설계해야 하는 이유

UDF는 다음과 같이 관심사를 분리 시킨다.

  • UI State가 생산되는 곳
  • UI State 변경이 발생하는 곳
  • UI State가 소비되는 곳

상태 변경사항을 관찰하여 정보를 표시하고 변경사항을 ViewModel에 전달하는식으로 동작을 한다.

 

 

UDF 설계의 장점

  • 데이터 일관성: 특정 UI와 연관되어 있는 UI State는 단 하나다.
  • 테스트 용이성: 상태 소스가 분리되므로 UI와 별개로 테스트가 가능하다.

 

 

UI State 생산하기

Observable Data Holder 사용

  • 종류
    • LiveData: Android Jetpack에서 제공 중인 관찰 가능한 데이터 홀더(Google 제공)
    • StateFlow: Kotlin Coroutine에서 제공 중인 관찰 가능한 데이터 홀더(JetBrain에서 제공)
  • 사용해야 하는 이유
    • UI 요소가 UI State가 새로 생성되는 것(=업데이트)을 알아서 직접 감지할 수 있기 때
      (ViewModel에서 데이터를 직접 가져오지 않고도 UI 상태 변경사항을 반영 할 수 있음 )
      -> 최신 버전의 UI 상태를 유지할 수 있음

 

Observable Data Holder 정의 예시 코드

 

잘 알려진 well-known 방법

  • 변경 불가능한 Observable 데이터 홀더를 1개 만든다.
    • UI 요소에 전달할(노출할) 용도
    • Public 접근 제한자 (외부에서 접근가능)
  • 변경 가능한 Observable 데이터 홀더를 1개 만든다.
    • ViewModel 클래스 내부에서 사용할 용도
    • private 접근 제한자 (외부에서 접근 불가능)
class NewsViewModel(...) : ViewModel() {

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

    ...

}

 

 

UI State 생산(업데이트)하는 예시 코드

  • 앱 데이터를 서버로부터 가져와야 하는 경우
    1. 비동기 처리를 위해 viewModelScope를 시작
    2. 앱 데이터를 가져왔으면, 새로운 UI State 생산
      : kotlin의 data class가 제공하는 copy()함수 사용
    3. 생산한 UI State를 변경 가능한(Mutable) Observable 데이터 홀더에 업데이트
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가 적절히 반응할 수 있도록 시도의 성공 또는 실패 결과를 UI 상태에 반영한다.

 

 

생산한 UI State 사용(소비, Consume)하는 방법

UI 요소가 Observable 데이터 홀더 관찰하고 있기

  • 관찰하고 있다가 Observable 데이터 홀더가 새로운 UI State 생산됨을 알려주면,
  • ViewModel이 잡고 있는 변경 불가능한 데이터 홀더의 UI State에 접근해서 가져온 다음
  • UI 요소에 연결해주면 된다.

UI 코드 예시

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

UI에서 UiState 객체의 스트림을 사용하려면 Observable 데이터 유형에 터미널 연산자를 사용한다.

LiveData의 경우 observe() 메소드를 사용하고 Kotlin Flow의 경우 collect()메서드를 사용한다.

 

 

UI State가 업데이트 되는 일반적인 과정

  • 안드로이드 UI 계층 아키텍처에서 UI State는 보통 '원인(input) -> 처리(process) -> 결과(output)'의 과정을 거쳐 업데이트 된다.

UI State 는 화면에 보여질 최종적인 데이터이다. 사용자 이벤트나 서버 호출 등으로 기존 데이터에서 새로운 데이터로 업데이트 되는데, 이 때 비즈니스 로직이나 UI 로직등을 먼저 처리해주고 UI State를 업데이트 시킨 후 UI에 반영해주면 된다.

 

출처

UI 레이어  |  Android 개발자  |  Android Developers

[공식문서] 2021년 모던 앱 아키텍처 > UI 계층 (tistory.com)

[안드로이드] UI 계층 > 2. State holder와 UI State에 대해 (tistory.com)

[안드로이드] UI 계층 > 3. UI State 업데이트하기 (tistory.com