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

dwjeong·2023년 11월 29일
0

안드로이드

목록 보기
26/28

🔎 State holder와 UI state


  • 이해해야 할 것
  1. UI 레이어에 존재하는 UI 상태 타입을 이해
  2. UI 레이어의 해당 UI 상태에서 동작하는 로직 타입을 이해
  3. ViewModel 또는 간단한 클래스와 같은 상태 홀더의 적절한 구현을 선택하는 방법

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

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

📚 UI state

UI state는 UI를 설명하는 속성. UI 상태에는 두 가지 유형이 있음.

  • 화면 UI 상태 (Screen UI state)는 화면에 표시해야하는 것. 예를 들어 NewsUiState 클래스에는 UI를 렌더링하는 데 필요한 뉴스 기사와 기타 정보가 포함될 수 있음.
    이 상태는 앱 데이터를 포함하기 때문에 일반적으로 계층 구조의 다른 레이어와 연결됨.

  • UI 요소 상태(UI element state)는 UI 요소가 렌더링되는 방식에 영향을 미치는 UI 요소 고유의 속성을 나타냄. UI요소는 표시되거나 숨겨질 수 있으며 특정 글꼴, 글꼴 크기 또는 색상을 가질 수 있음. Android 뷰에서 뷰는 본질적으로 상태 저장형이므로 이 상태 자체를 관리하고 해당 상태를 수정하거나 쿼리하는 메서드를 노출함.(예: TextView 클래스의 get 및 set 메서드)

📚 로직

애플리케이션 데이터 및 사용자 이벤트로 인해 UI 상태가 시간에 따라 변경되므로 UI 상태는 정적 속성이 아님.
로직은 UI 상태의 어떤 부분이 변경되었는지, 왜 변경되었는지, 언제 변경해야 하는지 등 변경의 세부 사항을 결정함.

  • 비즈니스 로직은 앱 데이터에 대한 제품 요구사항을 구현하는 것. 예를 들어 사용자가 버튼을 탭하면 뉴스 리더 앱의 기사를 북마크에 추가할 수 있음.
    파일이나 데이터베이스에 책갈피를 저장하는 이 논리는 일반적으로 도메인이나 데이터 계층에 배치됨.
    state holder는 일반적으로 노출된 메서드를 호출하여 이 로직을 해당 레이어에 위임함.

  • UI 로직은 UI 상태를 화면에 표시하는 방법과 관련이 있음.
    예를 들어 사용자가 카테고리를 선택할 때 올바른 검색 창 힌트를 얻거나 리스트의 특정 항목으로 스크롤하거나 사용자가 버튼을 클릭할 때 특정화면으로 이동하는 네비게이션 로직을 얻음.




📖 안드로이드 생명주기와 UI state 및 로직 유형

UI 계층은 두 부분으로 구성됨. 하나는 UI 수명 주기에 종속되고 다른 하나는 독립적임.
이러한 분리는 각 부분에 사용 가능한 데이터 소스를 결정하므로 다양한 유형의 UI state 및 로직이 필요함.

  • UI 수명주기 독립적
    UI 계층의 이 부분은 앱의 데이터 생성 계층(데이터 또는 도메인 계층)을 처리하며 비즈니스 논리에 의해 정의됨.
    UI의 수명 주기, 구성 변경 및 액티비티 재생성은 UI 상태 생성 파이프라인이 활성화되어 있는지 여부에 영향을 미칠 수 있지만 생성된 데이터의 유효성에는 영향을 미치지 않음.

  • UI 수명 주기 종속
    UI 계층의 이 부분은 UI 로직을 처리하며 수명 주기 또는 구성 변경에 직접적인 영향을 받음. 이러한 변경 사항은 내부에서 읽은 데이터 소스의 유효성에 직접적인 영향을 미치며, 결과적으로 수명 주기가 활성화된 경우에만 상태가 변경될 수 있음.
    예) 런타임 권한 및 지역화된 문자열과 같은 구성 종속 리소스 가져오기가 포함됨.


UI 수명주기 독립적UI 수명주기 종속
Business logicUI logic
Screen UI state-



📚 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 로직에 의존한다는 것을 의미함.




📖 State holder와 책임

state holder의 책임은 앱이 읽을 수 있도록 상태를 저장하는 것.

로직이 필요한 경우 중개자 역할을 하며 필요한 로직을 호스팅하는 데이터 소스에 대한 적절한 액세스를 제공함.

⭐ 이점

  • 단순한 UI: UI는 상태를 바인딩만 하면 됨.
  • 유지관리성: state holder에 정의된 로직은 UI 자체를 변경하지 않고도 반복할 수 있음.
  • 테스트 가능성: UI와 해당 상태 생성 로직을 독립적으로 테스트할 수 있음.
  • 가독성: 코드를 읽는 사람은 UI 표시 코드와 UI 상태 생성 코드 간의 차이점을 명확하게 볼 수 있음.

크기나 범위에 관계없이 모든 UI 요소는 해당 state holder와 1:1 관계를 갖고 있음.

또한 state holder는 UI 상태 변경을 초래할 수 있는 모든 사용자 작업을 수락하고 처리할 수 있어야 하며 그에 따른 상태 변경을 생성해야함.

📝 참고: state holder가 꼭 필요한 것은 아니며 간단한 UI는 표시 코드와 함께 로직을 인라인으로 호스팅할 수 있음.


📚 state holder의 유형

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

  • 비즈니스 로직 state holder
  • UI 로직 state holder

📝 참고:
UI 로직 state holder가 데이터 또는 도메인 계층의 정보에 의존하는 경우 비즈니스 로직 state holder에서 해당 정보를 전달해야함.
이는 비즈니스 로직 state holder가 UI 수명 주기와 독립적이므로 UI 로직 state holder보다 수명이 길기 때문.



📖 비즈니스 로직과 state holder

비즈니스 로직 holder state는 사용자 이벤트를 처리하고 데이터 또는 도메인 레이어의 데이터를 화면 UI 상태로 변환함.

Android 수명주기 및 앱 구성 변경을 고려할 때 최적의 사용자 경험을 제공하기 위해 비즈니스 로직을 활용하는 holder state는 다음 속성을 가져야함.

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

⭐ 참고: ViewModel 인스턴스는 위에 설명된 기능들과, 특히 살아남은 액티비티 재생성을 지원하기 때문에 비즈니스 로직 state holder는 일반적으로 ViewModel 인스턴스로 구현됨.



  • 예시 (Now in Android 앱)

비즈니스 로직 state holder 역할을 하는 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를 생성AuthorViewModel은 AuthorsRepository 및 NewsRepository에서 데이터를 읽고 해당 데이터를 사용하여 AuthorScreenUiState를 생성함. 또한 사용자가 AuthorsRepository에 위임하여 작성자를 팔로우하거나 팔로우 취소하려는 경우 비즈니스 로직을 적용함.
데이터 레이어에 액세스할 수 있음AuthorsRepository 및 NewsRepository의 인스턴스가 생성자에 전달되어 Author를 따르는 비즈니스 로직을 구현할 수 있음.
Activity 재생성 시 유지ViewModel로 구현되므로 빠른 Activity 재생성에도 유지됨. 프로세스 종료의 경우 SavedStateHandle 객체를 읽어 데이터 레이어에서 UI 상태를 복원하는 데 필요한 최소한의 정보를 제공할 수 있음
장기 지속 상태 보유ViewModel의 범위는 네비게이션 그래프로 지정되므로 author destination이 네비게이션 그래프에서 제거되지 않는 한 uiState StateFlow의 UI 상태는 메모리에 유지됨. StateFlow를 사용하면 UI 상태 collector가 있는 경우에만 상태가 생성되므로 상태를 생성하는 비즈니스 로직을 늦게(lazy) 적용할 수 있다는 이점도 추가됨.
UI에 고유하며 재사용할 수 없음AuthorViewModel은 author navigation destination에만 적용 가능하며 다른 곳에서는 재사용할 수 없음. navigation destination 전체에서 재사용되는 비즈니스 로직이 있는 경우 해당 로직은 데이터 또는 도메인 레이어 범위 컴포넌트에 캡슐화되어야 함.



⭐ 참고: destination 수준 UI에서는 ViewModel만 사용해야 함. 검색창이나 칩 그룹과 같이 UI의 재사용이 가능한 부분에서 사용하면 안됨. 이러한 경우는 일반 클래스가 더 적합함.

❗ 주의: ViewModel 인스턴스를 다른 composable 함수로 전달하지 말 것.
이렇게 하면 composable 함수가 ViewModel 유형과 결합되므로 재사용성이 떨어지고 테스트 및 미리보기가 더 어려워짐. 또한 ViewModel 인스턴스를 관리하는 명확한 SSOT(단일 소스)도 없음.

ViewModel을 전달하면 여러 composable이 ViewModel 함수를 호출하고 상태를 수정할 수 있으므로 버그를 디버깅하기 더 어려워짐. UDF 모범 사례를 따르고 필요한 상태만 전달할 것.



📚 비즈니스 로직 state holder로서의 ViewModel

Android 개발에서 ViewModel의 이점은 비즈니스 로직에 대한 액세스를 제공하고 화면에 표시할 애플리케이션 데이터를 준비하는 데 적합하다는 것.

  • 이점
  1. ViewModel에 의해 트리거된 작업은 구성 변경 후에도 유지됨.

  2. Navigation과의 통합: Navigation은 화면이 백 스택에 있는 동안 ViewModel을 캐시함. 목적지로 돌아올 때 이전에 로드한 데이터를 즉시 사용할 수 있도록 하는 것이 중요.

  3. ViewModel은 대상이 백 스택에서 제거될 때 지워지므로 상태가 자동으로 정리됨.

  4. Hilt와 같은 다른 Jetpack 라이브러리와 통합됨.


📝 참고: ViewModel의 이점이 사용 사례에 적용되지 않거나 다른 방식으로 작업을 수행하는 경우 ViewModel의 책임을 일반 상태 홀더 클래스로 이동할 수 있음.



📖 UI 로직과 state holder

UI 로직은 UI 자체가 제공하는 데이터에 대해 작동하는 로직. 이는 UI 요소의 상태일 수도 있고 권한 API나 리소스와 같은 UI 데이터 소스일 수도 있음.
UI 로직을 활용하는 state holder에는 일반적으로 다음과 같은 속성이 있음.

  • UI 상태를 생성하고 UI 요소 상태를 관리함.

  • 액티비티 재생성에서 살아남지 못함
    UI 로직에서 호스팅되는 state holder는 UI 자체의 데이터 소스에 의존하는 경우가 많으며 구성 변경 시 이 정보를 유지하려고 하면 메모리 누수가 발생하는 경우가 많음.
    state holder가 구성 변경 전반에 걸쳐 데이터를 유지해야 하는 경우 Activity 재생성 시 유지되기에 더 적합한 다른 구성요소에 위임해야함.
    (예를 들어 Jetpack Compose에서 remembered 함수로 만든 컴포저블 UI 요소 상태는 Activity 재생성 전반에 걸쳐 상태를 유지하기 위해 rememberSaveable에 위임되는 경우가 많음. 이러한 함수의 예로는 rememberScaffoldState()와 rememberLazyListState()가 있음.)

  • UI 범위 데이터 소스 참조가 있음
    UI 로직 state holder가 UI와 동일한 수명 주기를 가지므로 수명 주기 API 및 리소스와 같은 데이터 소스를 안전하게 참조하고 읽을 수 있음.

  • 여러 UI에서 재사용 가능
    동일한 UI 로직 state holder의 다양한 인스턴스가 앱의 여러 부분에서 재사용될 수 있음. 예를 들어 칩 그룹의 사용자 입력 이벤트를 관리하는 state holder를 필터 칩의 검색 페이지와 이메일 수신자의 'to' 필드에 사용할 수 있음.



일반적으로 UI 로직 state holder는 일반 클래스로 구현됨. 이는 UI 자체가 UI 로직 state holder 생성을 담당하고 UI 로직 state holder의 수명 주기가 UI 자체의 수명 주기와 동일하기 때문. 예를 들어 Jetpack Compose에서 state holder는 컴포지션의 일부이며 컴포지션의 수명 주기를 따름.


📝 참고: 일반 클래스 state holder는 UI 로직이 너무 복잡해서 UI 밖으로 이동할 때 사용됨. 그 외의 경우 UI 로직은 UI에서 인라인으로 구현할 수 있음.



예시) Now in Android 앱

Now in Android 샘플은 기기의 화면 크기에 따라 네비게이션을 위한 하단 앱 바 또는 네비게이션 레일을 표시. 작은 화면에서는 하단 앱 바를 사용하고, 큰 화면에서는 네비게이션 레일을 사용.

NiaApp Composable 함수에 사용되는 적절한 네비게이션 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는 Compose 명명 규칙에 따라 RememberNiaAppState를 Composable 함수로 생성하여 컴포지션에 기억됨. 액티비티가 다시 생성된 후에는 이전 인스턴스가 손실되고 다시 생성된 액티비티의 새 구성에 적합하도록 모든 종속 항목이 전달된 새 인스턴스가 생성됨.
    이러한 종속성은 새로운 것일 수도 있고 이전 구성에서 복원된 것일 수도 있음.
    예를 들어, RememberNavController()는 NiaAppState 생성자에서 사용되며, Activity 재생성 전반에 걸쳐 상태를 보존하기 위해 RememberSaveable에 위임함.

  • UI 범위 데이터 소스에 대한 참조가 있음. NavigationController, Resources 및 기타 유사한 수명 주기 범위 유형에 대한 참조는 동일한 수명 주기 범위를 공유하므로 NiaAppState에 안전하게 보관될 수 있음.


📝 참고: 검색 창이나 칩 그룹과 같은 재사용 가능한 UI 부분에는 일반 state holder 클래스를 사용하는 것이 좋음. 이 경우 ViewModel은 navigation destination의 상태를 관리하고 비즈니스 로직에 액세스하는 데 가장 적합하므로 사용하면 안됨.




📖 state holder로 ViewModel과 일반 클래스 중 선택

ViewModel과 일반 클래스 state holder 중 선택하는 것은 UI 상태에 적용되는 로직과 로직이 동작하는 데이터 소스에 따라 결정됨.

📝 참고: 대부분의 애플리케이션은 일반 클래스 state holder에 배치될 수 있는 UI 로직을 UI 자체에서 인라인으로 수행하도록 선택함. 간단한 경우에는 괜찮지만 다른 상황에서는 로직을 일반 클래스 state holder로 끌어내면 가독성을 높일 수 있음.


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



비즈니스 로직에 액세스해야 하고 화면을 탐색할 수 있는 동안 액티비티 재생 전반에 걸쳐 UI 상태를 유지해야 하는 경우 ViewModel은 비즈니스 로직 state holder 구현을 위한 탁월한 선택.

수명이 짧은 UI 상태 및 UI 로직의 경우 수명 주기가 UI에만 의존하는 일반 클래스로 충분함.



📖 state holder는 복합적

state holder는 종속성의 수명이 동일하거나 더 짧은 한 다른 state holder에 의존할 수 있음.

UI 로직 state holder는 다른 UI 로직 state holder에 종속될 수 있음.
화면 수준 state holder는 UI 로직 state holder에 따라 달라질 수 있음.

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

state holder보다 오래 지속되는 종속성의 예로는 화면 수준 state holder에 따른 UI 로직 state holder가 있음. 이는 수명이 짧은 state holder의 재사용성을 감소시키고 실제로 필요한 것보다 더 많은 로직과 상태에 대한 액세스를 제공함.

수명이 짧은 state holder가 더 높은 범위의 state holder로부터 특정 정보를 필요로 하는 경우, state holder 인스턴스를 전달하는 대신 필요한 정보만 매개변수로 전달할 것. 예를 들어 아래 코드에서 UI 로직 state holder 클래스는 전체 ViewModel 인스턴스를 종속성으로 전달하는 대신 ViewModel에서 매개 변수로 필요한 것만 받음.

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

다음 다이어그램은 UI와 이전 코드의 다양한 state holder간의 종속성을 나타냄.


UI State Production 문서 링크

0개의 댓글