해당 블로그의 글은 https://developer.android.com/topic/architecture/ui-layer 에 대한 정리하는 글 입니다.
우리는 UI Layer 와 관련된 많은 Entity 들을 살펴보고, 각각의 부분에 대해 이해하고, 모범 사례에 대해 이야기해볼 예정이에요.
해당 시리즈의 마지막에서는 우리는 UI layer 에서 발생하는 일반적인 것들과, UI Layer 안에서 로직과 상태를 처리할 때 어떤식으로 처리할 수 있는지도 이해할 수 있어요. 이와 관련있는 다양한 API 들의 어떤식으로 사용하는지 설명하고 또 확실하지 않은 상태에서도 도움을 줄 수 있는 의사 결정 트리를 제공할 예정이에요.
파트 1 에서 UI 와 UI state 에 대해 다뤄봤어요. 이를 통해 UI Layer 에 다양한 Entity 가 존재하는 것을 알 수 있었어요. 그리고 UI 와 UI state 를 효율적으로 다루는 법을 알아봤어요.
파트 2 는 state holder 들과 UI Layer 와 관련있는 다른 주제(상태 호이스팅, 안드로이드에서 상태 저장 등)들에 대해 살펴볼꺼에요.
State holders (상태를 유지하는 방법들)
State holders 는 로직을 처리하거나, 또는 UI state 를 노출해서 UI 를 단순하게 만드는 역할을 해요. 이번 섹션에서는 어떤식으로 State holders 를 구현할지, 구현할 때 고려해야할 세부사항들을 살펴보겠습니다.
세부 구현 사항들을 결정하기 위해서는 먼저 안드로이드 앱에서 흔하게 볼 수 있는 로직들을 알아내야합니다.
Types of logic (로직 유형들)
파트 1 에서 비즈니스 로직에는 제품 요구조건을 구현하는데 필요한 어플리케이션 데이터의 생성, 저장, 수정 등의 로직이 포함된다고 설명했었어요. 비즈니스 로직이 UI Layer 에서 처리되는 경우 해당 로직은 화면 레벨에서 관리하는 것을 추천해요. 이부분은 이후에 좀더 다뤄볼게요.
로직에는 다양한 타입들이 존재하고 그중에 UI 로직 이라고 불리는 것이 있어요. UI 로직은 화면에 변경된 상태를 어떤식으로 보여줄지 결정해요. 데이터로는 어떻게 표현할지 비즈니스 로직 통해 결정하고, UI 로직은 어떤식으로 화면에 그려낼지 결정해요. 그래서 UI 로직은 UI 구성(설정) 에 따라 달라질 수 있어요.
예를 들어 일반적인 앱에서 특정 세부 화면을 표현하려면, 앱을 디바이스에서 실행하고 네비게이션(Activity 의 전환)시켜야해요. 하지만 태블릿에서 실행한다면 화면을 이동시키는 것이 아니라 다른 요소 옆에 표현하게 될 수도 있어요.
각각의 UI 로직들은 구성요소의 변경시에 다르게 보여질 수도 있다는 점을 기억해야해요
- 따라서 UI 로직은 구성 요소의 변경을 감지해서 해당 환경에 맞게 다시 실행될 수 있어야해요.
- 비즈니스 로직은 구성 요소 변경에도 계속 유지되어야해요 (가령 인앱 결제 이후 구성 요소가 변경되었다고 중복으로 결제를 하면 안됩니다)
예를 들어 UI 로직은 화면 사이즈에 변경이 발생하면 phone 의 환경처럼 bottom bar 를 보여줄지 태블릿 환경처럼 navigation rail 을 보여줄지 다시 실행되어야해요. 반면에 비즈니스 로직은 특정 주제를 팔로잉 하거나 새로고침할 때 디바이스를 접거나(foldable), 유저가 rotation 했다고 취소하거나 해당 로직을 다시 실행하는 것을 지양해야 합니다. 이러한 방해 요인은 유저에게 좋지 않은 경험을 제공합니다.
Where to handle that logic (로직을 처리하는 위치)
UI Layer 에서 실행되는 비즈니스 로직은 화면 레벨에 가까운 곳에서 처리되어야 합니다. 비즈니스 로직은 대부분 Data layer 로 부터 처리됩니다. 그렇기 때문에 화면 레벨에 위치하면 로직의 범위를 올바르게 지정하기 용이합니다(실수할 확률이 줄어든다). 그리고 저수준의 UI 컴포넌트가 비즈니스 로직과 타이트하게 결합하는 것을 방지할 수 있습니다. (여기서 이야기 하는 저수준은 CheckBox 같이 구체적인 UI 가리킵니다)
비즈니스 로직은 해당 화면 레벨의 state holder 로 부터 처리되어야 합니다. 가령 일반적으로 androidx.ViewModel 을 확장해서 사용하는 경우가 많습니다.
UI 로직이 비교적 간단하면 ViewModel 이 아닌 UI(Activity, Fragment) 에서 직접 관리하는게 좀더 직관적일 수도 있습니다. 그러나 UI 가 점점더 복잡해지면 복잡한 UI 로직을 일반적인(Plain) state holder 클래스에 위임하는 것이 좋습니다. 이 경우는 androidx.ViewModel 을 확장해서 사용하는 방식을 의미하는 것은 아닙니다
다음 섹션에서는 방금 이야기 한 것들에 대해 좀더 자세하게 이야기해보겠습니다. 다양한 형태를 가진 상태와 로직들이 각각 어떤 연관관계가 있는지 볼까요?
위의 이미지는 일반적인 화면에서 일어나는 흐름을 요약한 것으로, Data layer 는 App state 를 다른 계층에 전달 합니다. 뷰모델은 비즈니스 로직을 적용해서 Screen UI state 를 생성합니다. 그리고 UI 가 스스로 UI Component 를 변경하거나 혹은 일반적인 state holder 클래스가 화면과 관련있는 UI 를 관찰하고 있다가 UI 요소를 변경합니다.
Handling business logic - androidX.ViewModel (androidX.ViewModel 을 이용해서 비즈니스 로직 처리하기)
화면 레벨 State holder 로써 androidx.ViewModel 혹은 Architecture Components ViewModel 를 꽤 광범위 하게 이야기했었습니다.
아래 코드에서는 1) 화면에 UI state 를 노출하고 2) 비즈니스 로직 처리 크게 2가지를 우리는 관찰할 수 있습니다.
@HiltViewModel
class InterestsViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
authorsRepository: AuthorsRepository,
topicsRepository: TopicsRepository
) : ViewModel() {
val uiState: StateFlow<InterestsUiState> = ... // 1) ui state
fun followTopic(followedTopicId: String, followed: Boolean) {
viewModelScope.launch {
// 2) business logic
userDataRepository.toggleFollowedTopicId(followedTopicId, followed)
}
}
...
}
근데 생각해볼 부분이 있습니다. 해당 로직에 ViewModel 에 존재해야하는 이유가 무엇일까요 ?
androidX.ViewModel benefits (androidX.ViewModel 의 장점)
가장 큰 장점은 화면 보다 긴 생명 주기를 가지기 때문에, 구성 요소 변경에도 ViewModel 은 살아남는다는 점 입니다. Activity, Fragment, Navigation graph 또는 Navigation 의 목적지에 ViewModel 스코프를 사용할 수 있습니다. 구성 요소 변경이 발생해도, 안드로이드 시스템은 같은 뷰모델 인스턴스를 제공합니다.
androidX.ViewModel 이 구성요소 변경에도 살아남는 다는 점은 화면의 UI state 를 노출하거나, 비즈니스 로직을 처리하기에는 완벽한 장소로 보입니다. 화면의 UI state 는 캐시되기 때문에 구성 요소의 변경이 일어나기 전 후 모두 즉시 이용가능합니다. 비즈니스 로직은 ㅁ뷰모델 스코프를 가진 코루틴(예 : viewModelScope)과 함께 계속 실행될 수 있습니다
또 다른 장점은 다른 제트팩 라이브러리 혹은, 제트팩 네비게이션과 부분적으로 원활하게 통합된다는 점입니다. 가령 네비게이션의 목적지가 백스택에 존재하는 경우에도 동일한 뷰모델 인스턴스를 메모리에 유지하고 있습니다. 이말은 즉 백스택에 존재하는 목적지들을 이동할 때 마다 매번 데이터를 다시 가져오지 않고 바로 데이터를 화면에 이용할 수 있다는 것을 의미합니다.
제트팩 네비게이션은 또한 네비게이션의 목적지가 백스택에 더이상 존재하지 않다면 ViewModel 인스턴스를 자동으로 파괴(destroy)시켜 줍니다. 따라서 이전 화면으로 돌아간다고 해도 화면에 유저의 이전 데이터가 보이는 것이 방지하고 안전하게 사용할 수 있습니다.
또한 Hilt 와도 통합되어 있습니다 @HiltViewModel 어노테이션을 사용하면 Domain 혹은 Data Layer 에 종속성을 가지는 ViewModel 을 수고스러움 없이 획득(obtain)할 수 있습니다.
androidX.ViewModel best practices (androidX.ViewModel 모범 사례들)
뷰모델 스코프는 화면 레벨에서 state holder 구현체로써 적합합니다. 그러나 남용되지 않도록 조심해야합니다. 아래에는 ViewModel 을 사용할 때 기억해야하는 모범 사례가 나와 있습니다.
- 화면 레벨로 사용하는 것이 좋아요. 재사용 가능한 UI 복잡성을 다룰 때에는 뷰모델 사용을 지양해야 합니다. 그 이유는 ViewModel 의 스코프 때문입니다. 같은 UI 요소들은 같은 뷰모델 아래 존재하기 때문에 바람직 하지 않은 경우가 존재합니다.
- 다양한 UI 플랫폼에서 사용할 수 있도록 충분히 일반적인 ViewModel 을 만드는 것을 지향해야해요. 뷰모델은 직접적으로 UI 를 알아서는 안돼요. 뷰모델 내(public 함수 혹은 UI state 등)에서는 UI 의 세부정보를 포함해서는 안되고, 어플리케이션 데이터(UI state 등)만 노출해야해요. 예를 들어 데이터를 로딩중일 때는 뷰모델은 showLoadingSpinner(직접 UI 알지 못해도 Spinner 라는 의미가 간접적으로 전달되고 있어요 플랫폼 마다 다룰 수도 있음을 인지해야해요.) 와 같은 메서드가 아닌 isLoading 과 같은 UI state 를 표현하는 field(프로퍼티) 를 가지고 있는 것이 좋아요.
- 라이프 사이클과 관련 있는 API 를 레퍼런스로 가지고 있는 것을 지양해야해요. 뷰모델은 UI 보다 생명주기가 길기 때문에 Context or Resource 와 같은 객체가 메모리에 유지되어 메모리릭을 야기시킬 수도 있어요.
- 뷰모델을 전달하지 마세요. 위에서 이야기한 것들을 고려해서 최대한 ViewModel 이 화면 레벨과 가까이 붙어 있도록 유지시켜주세요. 그렇지 않으면 저수준 컴포넌트에 필요한 데이터 뿐만 아니라 더 많은 상태 와 로직들에 접근할 수 있도록 실수할 수도 있어요
androidX.ViewModel gotchas (androidX.ViewModel 의 문제점)
뷰모델은 모든점에서 완벽하지 않아요. 뷰모델을 사용하면 기억해야할 것들이 꽤 있고 특히 뷰모델 스코프와 관련된 것들이 있어요.
- 뷰모델이 메모리에 존재하는 동안은 viewModelScope 작성된 모든 로직들은 계속 실행되고 있고 메모리에서 해지되지 않아요. 이부분은 장점도 있으면 작업이 오래 걸린다면 단점도 존재해요. 완료하는데 10초 이상 걸린다면 이때는 WorkManager 같은 다른 대안을 고려해야해요. 백그라운드 작업에 대한 더 많은 정보를 알아보세요
- 뷰모델 스코프로 작성된 코드들은 유닛 테스트시에 추가적인 테스트 환경 작업이 필요해요. MainDispatcher 를 사용할 수 있도록 변경해줘야해요.
주관적인 생각 - 문제점이 그리 많지는 않은 것 같다(?)는 생각이 드네요..!
Using androidX.ViewModel (androidX.ViewModel 사용)
그렇다면 항상 뷰모델을 사용해야한다는 것일까요 ?! 만드는 앱에서 화면 레벨의 state holder 가 필요할 때는 사용하는 것이 좋아요.
만약 구성요소 변경에 관심이 있다면(꼭 그래야 합니다) 또는 제트팩 라이브러리를 사용한다면 ViewModel 을 활용하는 것이 합리적이에요. 그렇지 않다면, 화면 레벨에서 발생하는 복잡한 비즈니스 로직을 다루기 위한 화면 레벨의 일반적은 state holder 만드는 것을 고려해야해요.
Handling UI logic - Plain state holder class (일반적인 state holder 클래스로 UI 로직 다루기 )
UI 가 복잡성이 높아짐에 따라 state holder class 를 고려해야 합니다. 클래스를 만드는 시점은 스스로 혹은 팀과 함께 정하면 됩니다. UI 를 단순화 해야한다고 느낀다면 생각해볼 때 입니다.
앞으로 보여질 코드에서는 state holder 가 당장 필요해보이지는 않습니다. 단지 유저 상호작용으로 변경되는 expanded 라는 boolean 변수만 가지고 있으면 됩니다.
@Composable
fun <T> NiaDropdownMenuButton(items: List<T>, ...) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
NiaOutlinedButton(
onClick = { expanded = true },
...
)
NiaDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
...
)
}
UI 에 더 많은 상태와 관련 로직들이 늘어가면 복잡성이 높아지고 state holder 를 고려해볼 수 있습니다. Compose 라이브러리의 일부 컴포넌트는 이미 state holder 를 가지고 있습니다. 아래 코드에는 Drawer composable 에 사용되는 다양한 상태와 연관된 state holder 입니다.
@Stable
class DrawerState(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true }
) {
internal val swipeableState = SwipeableState(...)
val currentValue: DrawerValue
get() { return swipeableState.currentValue }
val isOpen: Boolean
get() = currentValue == DrawerValue.Open
suspend fun open() = animateTo(DrawerValue.Open, AnimationSpec)
suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec<Float>) {
swipeableState.animateTo(targetValue, anim)
}
}
발견할 수 있는 몇가지 부분들이 있습니다
- Drawer 에 대한 currentValue 와 같은 값을 가지고 있습니다.
- State holder 가 composable 이라는 점입니다. DrawerState 는 SwipebleState 라는 외부 state holder 에 의존하고 있습니다.
- UI 로직 제어에 사용되는 drawer 를 열거나 애니메이션을 위한 특정 값 같은 액션을 포함하고 있습니다.
Compose 가 위와 같은 state holder 들을 제공하는 것 처럼 우리는 비슷한 패턴으로 UI 를 단순화 시킬 수 있습니다. 아래코드는 NiaAppState 와 관련된 코드로 NiaApp 에 사용되는 state holder 입니다.
@Stable
class NiaAppState(
val navController: NavHostController,
val windowSizeClass: WindowSizeClass
) {
val currentDestination: NavDestination?
@Composable get() = navController
.currentBackStackEntryAsState().value?.destination
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
fun navigate(...) { ... }
fun onBackClick() { ... }
}
비슷한 방식으로 currentDestination 이나 bottom bar 의 visibility (shouldShowBottomBar) 를 UI state 로 노출하면서도 화면이동(navigate) 뒤로가기 처리(onBackClick) 도 가지고 있네요.
참고 : 컴포즈에서는 state holder 인 경우 State 로 끝나는 네이밍 컨벤션을 가지고 있어요. 그래서 NiaAppState, DrawerState 와 같은 이름을 가지고 있어요.
Plain state holder classes best practices (일반적인 state holder 클래스 모범 사례들)
위에서 처럼 재사용 가능한 UI 컴포넌트를 만들어야할 때 일반적인 state holder 클래스를 만드는 것을 추천해요. 외부 상태를 받아서 처리하거나 또는 외부에서 제어할 수 있고 UI 재사용성이 향상 됩니다.
일반적인 state holder 클래스들은 라이프 사이클과 연관있는 API 를 레퍼런스로 들고 있어도 괜찮아요.
UI 라이프 사이클에 종속된 인스턴스이기 때문이에요. 구성요소 변경이 발생해도 state holder 는 새롭게 만들어져서 메모리릭이 발생하지 않아요. 따라서 Context 나 Resource 객체를 레퍼런스로 가지고 있어도 괜찮아요. 제트팩 컴포즈에서 state holder 는 Composition 스코프 내에서 유지됩니다.
만약 일반 클래스에 비즈니스 로직이 필요한 경우 해당 클래스 안으로 기능화 시켜도 좋아요. 해당 기능을 사용하는 어떤 사람이든 UI 범위 보다 오래 유지될 것이라 확신할 수 있어요. (UI 에 비즈니스 로직을 분리해서 유연하다 정도의 이야기로 보이네요.)
Handling large ViewModels (큰 사이즈의 뷰모델 다루기)
가령 뷰모델이 여러개의 사이즈가 큰 UI 요소들을 처리하는 비즈니스 로직을 다뤄야한다면, 잠재적으로 크기가 계속 커지고 다루기 어려우면서 이해하기도 쉽지 않을 것이에요. 어떤식으로 ViewModel 을 단순화 시킬 수 있을까요 ?
- Domain Layer 를 이용하세요. 뷰모델이 가진 복잡한 비즈니스 로직을 재사용 가능하도록 UseCase 에 위임할 수 있어요. UseCase 는 각기 다른 Repository 를 통해 상호작용해요. 그러나 해당 방법도 단순히 Repository 로직만 대체된 UseCase 를 많이 사용하는 뷰모델이 될 여지가 있어요.
- UI 다양한 요소에 사용되는 여러개의 state holder 만들 수도 있어요. 뷰모델에서는 state holder 를 호이스팅 해서 이로 인해 발생하는 이점을 누릴 수 있어요. 뷰모델은 기본적으로 구성요소 변경에도 살아남는 상태 호이스팅 메커니즘을 가지고 있어요.
- 혹은 2번 대신 재사용하지는 않지만 복잡성을 관리할 수 있도록 여러개의 뷰모델을 만드는 방식도 고려해볼 수 있어요. (해당 글에서는 AAC ViewModel 을 쓰면 안드로이드 플랫폼 한정이기 때문에 재사용성이 좋지 않다고 보고 있어요) 이러한 접근법은 ViewModel 에 작성된 로직들은 메모리에서 해제되지 않는 다는 것을 기억하고 사용해야해요. 메모리 관리, 사용량 모니터링에 어려움이 있을 수 있어요.
Where to hoist state (상태 호이스팅 위치)
상태를 읽거나 쓰는 최하위 공통 조상에 상태를 위치시키는게 좋습니다.
요약하자면, UI 에서는 1) 상태를 전혀 갖지 않거나, 2) 상태를 가지고 있거나, 3) UI 를 단순화 하기 위해 state holder 를 가지고 있거나, 4) 다른 Composable 호추 함수나 조상에서 상태를 제어 가능하도록 UI 트리 중 더 높은 곳으로 호이스팅 하는 경우, 5) 비즈니스 로직 등의 니즈로 뷰모델로 상태를 호이스팅 하는 경우 등이 존재해요.
만약에 읽기나 쓰기 작업처럼 비즈니스 로직에 값이 필요하다면 해당 값을 스크린 레벨의 state holder 에 호이스팅 하는 것이 좋습니다(뷰모델 같은 클래스를 의미하네요) 그렇지 않다면 UI tree 의 적절한 위치에 배치시키면 됩니다.
일반적인 채팅 앱의 UI 계층을 살펴보고 왜 이런식으로 배치하는지 이야기 나눠보겠습니다.
- 화면의 UI state 는 ViewModel 에 위치하고 있는 것을 볼 수 있어요 (위에서 이야기한 5번 사례네요) 그 이유는 UI state 가 만들어질 때 ViewModel 에서 비즈니스 로직이 관여하기 때문이에요.
- LazyList* 가 존재하는 위치는 MessageList 의 한 부분이 아니라 ConversationScreen 에 위치하고 있어요. 왜냐하면 LazyList 는 UserInput 에 입력된 최근 메세지에 따른 화면의 스크롤 이벤트 등을 알아야 하기 때문이에요.
해당 주제에 대해서 더 알고 싶다면, State hoisting in compose talk by Alejandra Stamato 을 참고해보세요
Saving UI State (UI 상태 저장하기)
해당 블로그 포스트에서는 구성 요소 변경에 따른 데이터를 보존하기 위해 사용되는 androidX.ViewModel API 를 살펴봤어요 그러나 안드로이드에서 좀더 효율적으로 안전하게 저장할 수 있는 대안도 제공해요.
SavedState API 는 구성 요소 변경에도 데이터를 유지시켜주며, 시스템에서 발생하는 프로세스 종료시에도 유지 시켜줍니다. 시스템은 데이터를 Bundle 형태로 parcelized 해서 저장해요. 일반적으로 유저 입력이나 Navigation 에 따라 달라지는 UI state 를 일시적으로 저장하는데 사용됩니다.
궁극적으로 위와 같은 상황 뿐만 아니라, 예기치 않은 앱 종료(사용자가 앱을 종료하거나, LMK 로 제거, OOM 등) 에서 데이터를 유지하기 위해서는 영구 저장소를 고려해야합니다. 디스크 공간에 제한이 있어서 보통 어플리케이션 데이터를 저장하는데 이용됩니다.
해당 주제에 대해 관심이 있다면 Saving UI state on android talk 를 확인해주세요
Conclusion(결론)
UI Layer 단기 코스 글을 읽은 후에는 일반적으로 UI Layer 에서 발생하는 다양한 프로세스에 대해 이해할 수 있어요. 그리고 상태와 로직을 효율적으로 관리하는데 필요한 도구들도 알게되었어요.
다양한 UI 요소에 대해 반응할 수 있도록 안드로이드 앱을 설계하는 방식으로 인해서 복잡하게 로직들이 작성될수 있지만 동시에 앱에 기대한대로 동작할 수 있도록 도구들을 제공하고 이를 통해 유저에게 더 나은 경험을 제공할 수 있어요.
이 글을 재미있게 읽었기를 바랍니다. 댓글을 활용해서 생각이나 질문들을 편안하게 공유해주세요. 고맙습니다.
해당 내용으로 Droidcon London 2023 에서 발표했다는 것을 잊지 마세요~!
그럼 20000-
12월 29일 스타벅스 카페에서 이글이 다른이에게 도움이 되길 희망하며,
'Android > 번역' 카테고리의 다른 글
[번역] The color of Composable functions (1) | 2023.12.30 |
---|---|
[번역] Effective android - Using Jetpack Compose with MVVM (0) | 2023.12.29 |
[번역] Crash Course on the Android UI Layer Part 1 (1) | 2023.12.28 |
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 3 (0) | 2021.06.07 |
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 2 (0) | 2021.06.06 |