이동식 저장소

Android Architecture Layers - 3. UI 본문

Primary/Android

Android Architecture Layers - 3. UI

해스끼 2022. 6. 23. 15:31

다음 글을 요약했습니다. 원문을 직접 읽어보길 권합니다.

 

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

UI 레이어 UI의 역할은 화면에 애플리케이션 데이터를 표시하고 사용자 상호작용의 기본 지점으로서의 역할을 수행하는 것 입니다. 사용자 상호작용(예: 버튼 누르기) 또는 외부 입력(예: 네트워

developer.android.com


UI는 데이터를 사용자에게 보여주고, 사용자와의 상호작용을 담당한다. UI는 데이터가 변경되거나 사용자가 상호작용을 시도할 때마다 즉시 그 사항을 반영해야 한다. 

 

내가 항상 하는 말이 있다.

UI는 데이터의 표현이다!!

 

그렇다고 해서 UI를 무시해서 안 된다. 사용자가 보는 것은 백엔드가 아니라 UI이고, 잘 정제된 데이터가 있더라도 제대로 보여주지 않으면 무슨 소용이 있겠는가?

 

Case study

뉴스 데이터를 가져와서 보여주는 앱이 있다고 가정하자. 앱의 기능은 다음과 같다.

  • 기사를 읽을 수 있다.
  • 주제별로 기사를 분류하여 보여준다.
  • (로그인한 유저에 한하여) 기사를 북마크할 수 있다.
  • 유료 기능을 제공할 수도 있다.

이제부터 이 뉴스 앱을 기반으로 UI 레이어의 구조를 설명할 것이다.

UI 레이어 아키텍쳐

UI 레이어가 하는 일은 다음과 같다.

  • 데이터를 받아 UI가 보여주기 쉬운 형태로 변환한다.
  • UI가 보여주기 쉬운 데이터를 받아 실제로 그린다.
  • 사용자의 입력을 처리하고, 필요하다면 입력으로 인한 효과를 UI에 보여준다.
  • 위의 과정을 계속 반복한다.

이제부터 UI 레이어에서 위의 과정을 어떻게 구현할 수 있는지 설명한다. 자세히 말하면 다음과 같다.

  • UI state를 정의하는 방법
  • Unidirectional Data Flow(UDF)를 활용하여 UI state를 관리하는 방법
  • UDF 원칙에 따라 UI state를 제공하는 방법
  • UI state를 그래픽 요소로 그려내는 방법

UI state 정의

UI state란 UI가 보여주고자 하는 데이터를 의미한다. 예를 들어 홈 화면에서 보여줄 뉴스 데이터 등이 있다. 하부 레이어에서의 데이터와 구분되는 점은, UI state는 UI가 실제로 보여줄 데이터만 담고 있다는 점이다.

 

위에서 본 뉴스 앱의 UI state를 대략 다음과 같이 정의할 수 있다.

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,
    ...
)

변하지 않는 것

위에서 정의한 ``NewsUiState``는 모든 변수가 불변하는 형태로 선언되었다. UI state는 불변해야 한다!! 적어도 UI 스스로 state를 변경할 수 없어야 한다. UI는 그래픽 요소를 그리는 일만으로도 힘에 부치기 때문이다.

 

UI 그 자체가 데이터의 source일 수도 있겠지만, Separation of Concern 원칙에 따라 데이터는 하부 레이어에서 처리하는 편이 좋다. 데이터가 바뀌었다면, state의 값을 변경하지 말고 아예 새로운 state를 만들어서 노출하는 편이 좋다. 

 

State가 불변한다고 보장하면 어떤 일이 생길까? 항상 최신 데이터를 보여줘야 하는 UI 입장에서는 데이터가 자신도 모르는 사이 바뀔까 불안해하지 않아도 된다. 기술적으로 말하자면 UI를 다시 그려야 하는지 확인하지 않아도 된다. 새로운 state가 주어졌을 때에만 다시 그리면 되기 때문이다.

 

그러니까 상자는 그대로인데 내용물이 바뀌지 않았는지 걱정하지 않아도 된다. 상자가 바뀔 때에만 내용물이 바뀐다고 보장되기 때문이다.

이름 짓는 방법

UI state 클래스는 보통 다음과 같이 이름붙인다.

 

기능 + UiState

 

예를 들면 ``NewsUiState``는 뉴스 화면 전체의 state이고, ``NewsItemUiState``는 뉴스 하나의 state를 나타낸다.

Unidirectional Data Flow

직역하면 단방향 데이터 흐름으로, 데이터가 한 방향으로만 전달되어야 한다는 뜻이다. 그렇다면 그 한 방향은 분명히 생산자에서 시작하여 소비자에게 도달하는 흐름일 것이다. Data 레이어를 생산자로, UI 레이어를 소비자로 보면 딱 맞다.

State holder

UI는 그래픽을 그리는 것만으로도 할 일이 차고 넘친다. 따라서 UI를 대신하여 state 객체를 관리할 state holder가 필요하다.

 

State holder는 하위 레이어로부터 데이터를 가져오고, 필요한 경우 약간의 로직을 적용하여 state를 완성한다. UI 이벤트가 발생했을 때 하부 레이어와 소통하는 역할도 맡는다.

 

안드로이드에서는 주로 ``ViewModel``을 state holder로 사용한다. 위의 뉴스 앱 예시에서는 ``NewsViewModel``이 ``NewsUistate``를 hold할 수 있다.

 

UDF를 구체적으로 설명하면 다음과 같다.

  • ViewModel은 UI가 보여줄 state를 노출한다(expose). UI state는 하위 레이어에서 받은 데이터를 ViewModel이 가공한 것이다.
  • UI는 버튼 클릭 등 사용자 이벤트가 발생한 사실을 ViewModel에게 알려준다.
  • ViewModel은 이벤트를 처리하고, state를 업데이트한다. 이때 state의 값을 수정하는 게 아니라, 아예 새로운 state 객체를 만든다.
  • 새로 만들어진 state가 UI에 반영된다.
  • 위의 과정이 계속 반복된다.

로직?

지금까지 말했던 로직은 전부 비즈니스 로직이었다. 사실 비즈니스 로직은 UI state가 바뀔 때 무엇을 할 지 정의하는 로직이다. 예를 들어 사용자가 좋아요 버튼을 클릭했을 때 서버에 좋아요 요청을 보내는 코드 등이 비즈니스 로직이다.

 

UI 로직은 바뀐 state를 어떻게 보여줄지 정의하는 로직이다. 버튼을 클릭했을 때 실제로 버튼이 눌린 것처럼 그림자 효과를 준다던가, 닫기 버튼을 클릭했을 때 실제로 화면을 닫는 등 UI 로직은 보여주는 내용을 바꾼다.

 

간단한 UI 로직은 UI에 하드코딩해도 괜찮지만, 복잡한 로직은 별도의 객체로 떼어놓는 편이 좋다. Compose를 아는 분들을 위해 예시 코드를 첨부한다.

// Plain class that manages App's UI logic and UI elements' state
class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    private val resources: Resources,
    /* ... */
) {
    val bottomBarTabs = /* State */

    // Logic to decide when to show the bottom bar
    val shouldShowBottomBar: Boolean
        get() = /* ... */

    // Navigation logic, which is a type of UI logic
    fun navigateToBottomBarRoute(route: String) { /* ... */ }

    // Show snackbar using Resources
    fun showSnackbar(message: String) { /* ... */ }
}

@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    /* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
    MyAppState(scaffoldState, navController, resources, /* ... */)
}

UI 로직을 ViewModel에 선언하지 않는 이유는, ViewModel을 최대한 안드로이드로부터 독립적으로 만들기 위함이다. 물론 비즈니스 로직은 ViewModel에 선언해야 한다.

비즈니스 로직과 UI 로직을 구분하자.

UDF의 장점

일단 단순하다. 데이터가 하나의 흐름을 통해서만 전달되므로, 데이터와 상관 없는 외부의 간섭을 막을 수 있다. 또, State holder가 UI와 분리되어 있으므로 holder를 테스트하기 쉬워진다

 

아니, 뭐 거창하게 말팔 필요 없이 한번만 이렇게 구현해 보면 답 나온다. 그냥 좋다..

State 노출하기 (expose)

UI state를 잘 정의했다면, 이제 UI가 state에 접근할 방법을 만들어야 한다. 위에서 말했다시피 앱을 사용하다 보면 계속해서 새로운 state가 만들어진다. State를 지속적으로 전달할 수 있는 방법이 필요하다는 뜻이다.

 

안드로이드에서는 Kotlin의 ``StateFlow``를 사용할 수 있다. 예전에는 ``LiveData``도 많이 사용했지만, 이제는 Kotlin의 시대이므로 ``StateFlow``를 사용하는 편이 좋다. ``StateFlow``는 ``Flow``처럼 지속적으로 값을 전달할 수 있으면서, 동시에 가장 최근에 전달된 값을 저장하고 있다. 최신값에 접근할 수 있다는 점은 큰 장점이다. 

 

ViewModel에 ``StateFlow``를 선언하면 딱 좋다. Compose에서는 ``mutableStateOf()``을 이용하여 다음과 같이 선언할 수도 있다.

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

   var uiState by mutableStateOf(NewsUiState())
        private set

    private var fetchJob: Job? = null

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

UDF에서 비즈니스 로직을 적용하는 전형적인 예시이다.

기타 참고사항

UI에서 보여주는 데이터가 서로 독립적이라면, 각각을 별개의 stream으로 노출해도 된다. 이렇게 하면 데이터를 불러올 때 먼저 로드된 데이터부터 보여주는 식의 최적화도 가능하다.

UI state 사용하기 (consume)

이제 state를 실제로 사용할 시간이다. ``LiveData``를 사용했다면 ``observe()`` 메서드를 통해, Kotlin flow를 사용했다면 ``collect()`` 메서드를 통해 state를 받을 수 있다. 

 

UI state를 참조할 때 알아두면 좋은 팁을 적어 보겠다.

로딩 중임을 보여주기

State 객체 안에 Boolean 값을 하나 선언하면 된다. 참 쉽죠?

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

이 값에 따라 로딩창을 보여주면 된다.

에러 보여주기

로딩 상태를 보여줄 때랑 비슷하게 하면 된다. 에러의 존재 유무와 메시지 등을 담는 필드를 state 객체에 선언하자.

 

'Primary > Android' 카테고리의 다른 글

Hilt Test Principles  (0) 2022.06.30
Android Architecture Layers - 4. UI events  (0) 2022.06.27
Android Architecture Layers - 2. Domain  (0) 2022.06.18
Android Architecture Layers - 1. Data  (0) 2022.06.15
Android Architecture Layers - 0. Overview  (1) 2022.06.07
Comments