상태 홀더 및 UI 상태

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

UI 레이어 가이드에서는 UI 레이어의 UI 상태를 생성하고 관리하는 수단으로 단방향 데이터 흐름(UDF)을 설명합니다.

데이터는 데이터 레이어에서 UI로 단방향으로 흐릅니다.
그림 1: 단방향 데이터 흐름

UDF 관리를 상태 홀더라고 하는 특수 클래스에 위임할 때의 이점도 강조합니다. 상태 홀더는 ViewModel 또는 일반 클래스를 통해 구현할 수 있습니다. 이 문서에서는 상태 홀더, 그리고 UI 레이어에서 상태 홀더의 역할을 자세히 살펴봅니다.

이 문서를 살펴보고 나면 UI 레이어에서 애플리케이션 상태를 관리하는 방법, 즉 UI 상태 생성 파이프라인을 이해할 수 있습니다. 다음 사항을 이해하고 파악할 수 있습니다.

  • UI 레이어에 있는 UI 상태 유형 이해
  • UI 레이어의 이러한 UI 상태에서 작동하는 로직 유형 이해
  • 상태 홀더의 적절한 구현(예: ViewModel 또는 간단한 클래스)을 선택하는 방법 파악

UI 상태 생성 파이프라인의 요소

UI 상태와 이를 생성하는 로직이 UI 레이어를 정의합니다.

UI 상태

UI 상태는 UI를 설명하는 속성입니다. UI 상태에는 두 가지 유형이 있습니다.

  • 화면 UI 상태: 화면에 표시해야 하는 항목입니다. 예를 들어 NewsUiState 클래스에는 UI를 렌더링하는 데 필요한 뉴스 기사와 기타 정보가 포함될 수 있습니다. 이 상태는 앱 데이터를 포함하므로 대개 계층 구조의 다른 레이어에 연결됩니다.
  • UI 요소 상태: 렌더링 방식에 영향을 주는 UI 요소에 고유한 속성을 나타냅니다. UI 요소는 표시하거나 숨길 수 있으며 특정 글꼴이나 글꼴 크기, 글꼴 색상을 적용할 수 있습니다. Android 뷰에서 뷰는 기본적으로 스테이트풀(Stateful)이므로 이 상태 자체를 관리하여 상태를 수정하거나 쿼리하는 메서드를 노출합니다. 텍스트에 관한 TextView 클래스의 getset 메서드를 예로 들 수 있습니다. Jetpack Compose에서 상태는 컴포저블의 외부에 있으며 컴포저블 아주 가까이에서 호출 구성 가능한 함수나 상태 홀더로 끌어올릴 수도 있습니다. Scaffold 컴포저블의 ScaffoldState를 예로 들 수 있습니다.

로직

UI 상태는 정적 속성이 아닙니다. 시간이 지남에 따라 애플리케이션 데이터와 사용자 이벤트로 인해 UI 상태가 변경되기 때문입니다. 로직은 변경된 UI 상태 부분, 변경 이유, 변경해야 하는 시점 등 구체적인 변경사항을 결정합니다.

UI 상태를 생성하는 로직
그림 2: UI 상태 생산자인 로직

로직은 비즈니스 로직 또는 UI 로직일 수 있습니다.

  • 비즈니스 로직은 앱 데이터에 대한 제품 요구사항의 구현입니다. 예를 들어 사용자가 버튼을 탭할 때 뉴스 리더 앱에서 기사를 북마크에 추가합니다. 북마크를 파일이나 데이터베이스에 저장하는 이 로직은 일반적으로 도메인 또는 데이터 레이어에 배치됩니다. 상태 홀더는 일반적으로 노출되는 메서드를 호출하여 이 로직을 이러한 레이어에 위임합니다.
  • UI 로직은 화면에 UI 상태를 표시하는 방법과 관련이 있습니다. 사용자가 카테고리를 선택했을 때 올바른 검색창 힌트를 가져오는 것, 목록의 특정 항목으로 스크롤하는 것, 또는 사용자가 버튼을 클릭할 때 특정 화면으로의 탐색 로직을 예로 들 수 있습니다.

Android 수명 주기와 UI 상태 및 로직 유형

UI 레이어는 두 부분으로 구성됩니다. 하나는 UI 수명 주기에 종속되고 다른 하나는 UI 수명 주기와 무관합니다. 이렇게 분리하면 각 부분에 사용할 수 있는 데이터 소스가 결정되므로 다른 유형의 UI 상태와 로직이 필요합니다.

  • UI 수명 주기와 무관: UI 레이어의 이 부분은 앱의 데이터 생성 레이어(데이터 또는 도메인 레이어)를 처리하고 비즈니스 로직으로 정의됩니다. UI의 수명 주기, 구성 변경, Activity 재생성은 UI 상태 생성 파이프라인의 활성화 여부에 영향을 줄 수 있지만 생성된 데이터의 유효성에는 영향을 미치지 않습니다.
  • UI 수명 주기에 종속: UI 레이어의 이 부분은 UI 로직을 처리하며 수명 주기나 구성 변경사항의 직접적인 영향을 받습니다. 이러한 변경사항은 내부에서 읽은 데이터 소스의 유효성에 직접 영향을 미치므로 상태는 수명 주기가 활성 상태일 때만 변경될 수 있습니다. 런타임 권한과 구성 종속 리소스(예: 현지화된 문자열) 가져오기를 예로 들 수 있습니다.

위 내용을 아래 표와 같이 요약할 수 있습니다.

UI 수명 주기와 무관 UI 수명 주기에 종속
비즈니스 로직 UI 로직
화면 UI 상태

UI 상태 생성 파이프라인

UI 상태 생성 파이프라인은 UI 상태를 생성하기 위해 실행하는 단계를 나타냅니다. 이러한 단계는 이전에 정의된 로직 유형을 적용하는 것으로 구성되며 UI의 요구사항에 완전히 종속됩니다. 일부 UI는 파이프라인의 UI 수명 주기와 무관한 부분과 UI 수명 주기에 종속된 부분 모두에서 또는 둘 중 하나에서 이점을 얻을 수 있고 아무런 이득을 얻지 못할 수도 있습니다.

즉, UI 레이어 파이프라인의 다음과 같은 순열은 유효합니다.

  • UI 자체에서 생성 및 관리하는 UI 상태. 예를 들어 간단하고 재사용 가능한 기본 카운터는 다음과 같습니다.

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • UI 로직 → UI. 예를 들어 사용자가 목록 상단으로 이동할 수 있는 버튼을 표시하거나 숨깁니다.

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • 비즈니스 로직 → UI. 화면에 현재 사용자의 사진을 표시하는 UI 요소입니다.

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • 비즈니스 로직 → UI 로직 → UI. 특정 UI 상태에 관해 화면에 올바른 정보를 표시하기 위해 스크롤하는 UI 요소입니다.

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

두 가지 종류의 로직이 모두 UI 상태 생성 파이프라인에 적용되는 경우 비즈니스 로직이 항상 UI 로직보다 먼저 적용되어야 합니다. UI 로직 다음에 비즈니스 로직을 적용하려고 하면 비즈니스 로직이 UI 로직에 종속된다는 것을 의미합니다. 다음 섹션에서는 다양한 로직 유형과 상태 홀더를 자세히 살펴보며 이것이 문제가 되는 이유를 설명합니다.

데이터 생성 레이어에서 UI로의 데이터 흐름
그림 3: UI 레이어의 로직 적용

상태 홀더 및 책임

상태 홀더의 책임은 앱이 읽을 수 있도록 상태를 저장하는 것입니다. 로직이 필요한 경우 상태 홀더는 중개자 역할을 하며 필요한 로직을 호스팅하는 데이터 소스에 대한 액세스 권한을 제공합니다. 이러한 방식으로 상태 홀더는 로직을 적절한 데이터 소스에 위임합니다.

여기에는 다음과 같은 이점이 있습니다.

  • 간단한 UI: UI가 상태를 바인딩합니다.
  • 유지관리: 상태 홀더에 정의된 로직을 UI 자체를 변경하지 않고도 반복할 수 있습니다.
  • 테스트 가능성: UI 및 상태 생성 로직을 독립적으로 테스트할 수 있습니다.
  • 가독성: 코드 리더가 UI 표시 코드와 UI 상태 생성 코드 간의 차이점을 명확하게 알아볼 수 있습니다.

크기나 범위와 관계없이 모든 UI 요소는 상응하는 상태 홀더와 1:1 관계를 갖습니다. 또한 상태 홀더는 UI 상태 변경을 야기할 수 있는 모든 사용자 작업을 수락하고 처리할 수 있어야 하고 후속 상태 변경을 생성해야 합니다.

상태 홀더 유형

UI 상태 및 로직의 종류와 마찬가지로 UI 레이어에는 UI 수명 주기와의 관계에 따라 정의되는 두 가지 유형의 상태 홀더가 있습니다.

  • 비즈니스 로직 상태 홀더
  • UI 로직 상태 홀더

다음 섹션에서는 상태 홀더 유형을 자세히 살펴봅니다. 먼저 비즈니스 로직 상태 홀더를 살펴봅니다.

비즈니스 로직 및 상태 홀더

비즈니스 로직 상태 홀더는 사용자 이벤트를 처리하고 데이터 또는 도메인 레이어에서 화면 UI 상태로 데이터를 변환합니다. Android 수명 주기와 앱 구성 변경사항을 고려할 때 최적의 사용자 환경을 제공하려면 비즈니스 로직을 활용하는 상태 홀더에 다음 속성이 있어야 합니다.

속성 세부정보
UI 상태 생성 비즈니스 로직 상태 홀더는 UI의 UI 상태를 생성해야 합니다. 이 UI 상태는 종종 사용자 이벤트를 처리하고 도메인 및 데이터 레이어에서 데이터를 읽은 결과입니다.
활동 재생성을 통해 유지됨 비즈니스 로직 상태 홀더는 Activity 재생성 전반에 걸쳐 상태 및 상태 처리 파이프라인을 유지하여 원활한 사용자 환경을 제공할 수 있도록 합니다. 상태 홀더를 유지할 수 없어 다시 만드는 경우(일반적으로 프로세스 중단 후) 상태 홀더는 일관된 사용자 환경을 보장하기 위해 마지막 상태를 쉽게 재생성할 수 있어야 합니다.
장기 지속 상태 보유 비즈니스 로직 상태 홀더는 종종 탐색 대상의 상태를 관리하는 데 사용됩니다. 따라서 탐색 그래프에서 삭제될 때까지 탐색 변경 후에도 상태를 유지하는 경우가 많습니다.
UI에 고유하며 재사용할 수 없음 비즈니스 로직 상태 홀더는 일반적으로 특정 앱 기능(예: TaskEditViewModel 또는 TaskListViewModel)의 상태를 생성하므로 이 앱 기능에만 적용됩니다. 동일한 상태 홀더가 다양한 폼 팩터에서 이러한 앱 기능을 지원할 수 있습니다. 예를 들어 모바일, TV, 태블릿 버전의 앱은 동일한 비즈니스 로직 상태 홀더를 재사용할 수 있습니다.

예를 들어 'Now in Android' 앱의 작성자 탐색 대상을 살펴보겠습니다.

Now in Android 앱을 보면 주요 앱 기능을 나타내는 탐색 대상이 고유한 자체 비즈니스 로직 상태 홀더가 있어야 함을 알 수 있습니다.
그림 4: Now in Android 앱

비즈니스 로직 상태 홀더 역할을 하는 AuthorViewModel은 다음 사례에서 UI 상태를 생성합니다.

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = …

    // Business logic
    fun followAuthor(followed: Boolean) {
      …
    }
}

AuthorViewModel에는 앞에서 설명한 속성이 있습니다.

속성 세부정보
AuthorScreenUiState 생성 AuthorViewModelAuthorsRepositoryNewsRepository에서 데이터를 읽고 AuthorScreenUiState를 생성하는 데 이 데이터를 사용합니다. 또한 사용자가 AuthorsRepository에 위임하여 Author를 팔로우하거나 팔로우 해제하려고 할 때 비즈니스 로직을 적용합니다.
데이터 영역에 대한 액세스 권한 보유 AuthorsRepositoryNewsRepository 인스턴스가 생성자에서 전달되므로 Author를 팔로우하는 비즈니스 로직을 구현할 수 있습니다.
Activity 재생성 시 유지 ViewModel로 구현되므로 빠른 Activity 재생성에도 유지됩니다. 프로세스 중단의 경우 SavedStateHandle 객체를 읽어 데이터 레이어에서 UI 상태를 복원하는 데 필요한 최소한의 정보를 제공할 수 있습니다.
장기 지속 상태 보유 ViewModel의 범위가 탐색 그래프로 지정되므로 작성자 대상이 탐색 그래프에서 삭제되지 않는 한 uiState StateFlow의 UI 상태는 메모리에 유지됩니다. StateFlow를 사용하면 UI 상태의 수집기가 있는 경우에만 상태가 생성되므로 상태를 생성하는 비즈니스 로직의 적용을 지연시킬 수 있다는 이점도 추가됩니다.
UI에 고유함 AuthorViewModel은 작성자 탐색 대상에만 적용되며 다른 곳에서는 재사용할 수 없습니다. 탐색 대상에서 재사용되는 비즈니스 로직이 있는 경우 해당 비즈니스 로직은 데이터 또는 도메인 레이어 범위 구성요소에 캡슐화되어야 합니다.

비즈니스 로직 상태 홀더로서의 ViewModel

Android 개발에서 ViewModel이 가진 이점 덕분에, 비즈니스 로직에 대한 액세스 권한을 제공하고 화면에 표시하기 위한 애플리케이션 데이터를 준비하는 데 ViewModel이 적합합니다. 이점은 다음과 같습니다.

  • ViewModel에 의해 트리거된 작업이 구성 변경에도 그대로 유지됩니다.
  • Navigation과의 통합:
    • 화면이 백 스택에 있는 동안 Navigation이 ViewModel을 캐시합니다. 이는 개발자가 대상으로 돌아갈 때 이전에 로드한 데이터를 즉시 사용할 수 있도록 하는 데 중요합니다. 이 작업은 컴포저블 화면의 수명 주기를 따르는 상태 홀더를 사용할 경우 더 어려워집니다.
    • 또한 대상이 백 스택에서 사라질 때 ViewModel도 삭제되기 때문에, 상태가 자동으로 정리됩니다. 이는 구성 변경으로 인한 새 화면으로의 이동 등 여러 이유로 발생할 수 있는 컴포저블 폐기에 관한 수신 대기와는 다릅니다.
  • Hilt와 같은 다른 Jetpack 라이브러리와의 통합

UI 로직 및 상태 홀더

UI 로직은 UI 자체에서 제공하는 데이터에 작동하는 로직입니다. 이는 UI 요소의 상태 또는 UI 데이터 소스(예: 권한 API나 Resources)에 있을 수 있습니다. UI 로직을 활용하는 상태 홀더에는 일반적으로 다음 속성이 있습니다.

  • UI 상태 생성 및 UI 요소 상태 관리
  • Activity 재생성 시 유지되지 않음: UI 로직에서 호스팅되는 상태 홀더는 종종 UI 자체의 데이터 소스에 종속되므로 구성 변경 시 이 정보를 유지하려고 하면 메모리 누수를 일으키는 경우가 많습니다. 상태 홀더가 구성 변경 시 데이터를 유지하려는 경우 Activity 재생성 시 유지되기에 더 적합한 다른 구성요소에 위임해야 합니다. 예를 들어 Jetpack Compose에서 remembered 함수로 만든 컴포저블 UI 요소 상태는 Activity 재생성 전반에 걸쳐 상태를 유지하기 위해 rememberSaveable에 위임되는 경우가 많습니다. 이러한 함수의 예로는 rememberScaffoldState()rememberLazyListState()가 있습니다.
  • UI 범위 데이터 소스 참조가 있음: UI 로직 상태 홀더가 UI와 동일한 수명 주기를 가지므로 수명 주기 API 및 리소스와 같은 데이터 소스를 안전하게 참조하고 읽을 수 있습니다.
  • 여러 UI에서 재사용 가능: 동일한 UI 로직 상태 홀더의 다양한 인스턴스가 앱의 여러 부분에서 재사용될 수 있습니다. 예를 들어 칩 그룹의 사용자 입력 이벤트를 관리하는 상태 홀더를 필터 칩의 검색 페이지와 이메일 수신자의 'to' 필드에 사용할 수 있습니다.

일반적으로 UI 로직 상태 홀더는 일반 클래스로 구현됩니다. 이는 UI 자체가 UI 로직 상태 홀더 생성을 담당하고 UI 로직 상태 홀더의 수명 주기가 UI 자체의 수명 주기와 동일하기 때문입니다. 예를 들어 Jetpack Compose에서 상태 홀더는 컴포지션의 일부이며 컴포지션의 수명 주기를 따릅니다.

위의 내용은 Now in Android 샘플의 다음 예로 보여줄 수 있습니다.

Now in Android에서는 일반 클래스 상태 홀더를 사용하여 UI 로직을 관리합니다.
그림 5: Now in Android 샘플 앱

Now in Android 샘플은 기기의 화면 크기에 따라 탐색 시 하단 앱 바 또는 탐색 레일을 표시합니다. 작은 화면에서는 하단 앱 바를, 큰 화면에서는 탐색 레일을 사용합니다.

구성 가능한 NiaApp 함수에서 사용되는 적절한 탐색 UI 요소를 결정하는 로직은 비즈니스 로직에 종속되지 않으므로 NiaAppState라는 일반 클래스 상태 홀더로 관리할 수 있습니다.

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

위 예에서 NiaAppState에 관한 다음 세부정보가 중요합니다.

  • Activity 재생성 시 유지되지 않음: NiaAppState는 Compose 이름 지정 규칙을 따르는 구성 가능한 함수 rememberNiaAppState를 사용하여 만들어 컴포지션에서 remembered됩니다. Activity가 재생성되면 이전 인스턴스가 손실되고, 재생성된 Activity의 새 구성에 적합한 모든 종속 항목이 전달된 상태로 새 인스턴스가 생성됩니다. 이러한 종속 항목은 새로운 것이거나 이전 구성에서 복원된 것일 수 있습니다. 예를 들어 rememberNavController()NiaAppState 생성자에서 사용되며, rememberSaveable에 위임하여 Activity 재생성 전반에 걸쳐 상태를 유지합니다.
  • UI 범위 데이터 소스 참조가 있음: navigationController, Resources, 기타 유사한 수명 주기 범위 유형에 관한 참조가 NiaAppState에 안전하게 보관될 수 있습니다. 동일한 수명 주기 범위를 공유하기 때문입니다.

상태 홀더의 ViewModel과 일반 클래스 중에서 선택

위 섹션에서 ViewModel과 일반 클래스 상태 홀더 중 하나를 선택하는 일은 UI 상태에 적용되는 로직과 로직이 작동하는 데이터 소스에 따라 결정됩니다.

요약하면 아래 다이어그램은 UI 상태 생성 파이프라인에서 상태 홀더의 위치를 보여줍니다.

데이터 생성 레이어에서 UI 레이어로의 데이터 흐름
그림 6: UI 상태 생성 파이프라인의 상태 홀더

궁극적으로 UI 상태는 사용되는 위치와 가장 가까운 상태 홀더에서 배치되고 생성되어야 합니다. 덜 공식적으로 상태는 적절하게 소유되는 동안 최대한 낮게 보관되어야 합니다. 비즈니스 로직에 액세스해야 하며 화면이 이동될 수 있는 한(Activity 재생성 시에도) UI 상태를 유지해야 하는 경우 비즈니스 로직 상태 홀더 구현에 ViewModel을 사용하는 것이 좋습니다. 단기 지속 UI 상태 및 UI 로직의 경우 수명 주기가 UI에만 종속되는 일반 클래스로 충분합니다.