Crash Course on the Android UI Layer (안드로이드 UI Layer 에 대한 단기 속성 과정정도로 볼 수 있을 것 같아요)
(전 Google Android DevRel 로 믿고 보는 아티클이에요. 다만 저의 번역 실력을 믿으시면 안됩니다. 중간중간 주관적인 생각도 들어갈 예정이에요.)
해당 블로그의 글은 https://developer.android.com/topic/architecture/ui-layer 에 대한 정리하는 글 입니다.
우리는 UI Layer 와 관련된 많은 Entity 들을 살펴보고, 각각의 부분에 대해 이해하고, 모범 사례에 대해 이야기해볼 예정이에요.
해당 시리즈의 마지막에서는 우리는 UI layer 에서 발생하는 일반적인 것들과, UI Layer 안에서 로직과 상태를 처리할 때 어떤식으로 처리할 수 있는지도 이해할 수 있어요. 이와 관련있는 다양한 API 들의 어떤식으로 사용하는지 설명하고 또 확실하지 않은 상태에서도 도움을 줄 수 있는 의사 결정 트리를 제공할 예정이에요.
파트 1 에서는 UI 와 UI 상태에 대해 알아보고, 파트 2 에서는 State holders(값을 저장하는 방식들) 과 안드로이드에서의 상태 저장, 상태 호이스팅과 같은 UI Layer 와 관련있는 다른 주제들에 대해 알아볼 예정이에요.
비슷한 내용으로 저자가 droidcon 에서 발표했던 영상도 존재하네요.(관심 있는 분들은 살펴보시길 추천드려요)
The UI Layer in the grand scheme of things (큰 틀에서의 UI Layer)
일반적인 어플리케이션 아키텍쳐에는 UI Layer 뿐만 아니라 다른 레이어도 존재합니다. UI Layer 와 함께 Data Layer 도 존재하고, 가끔식 Domain Layer 도 존재해요. Android Architecture documentation 을 토대로 보면, 역할은 아래와 같아요.
UI Layer : 화면에 Data 를 보여줘요.
Data Layer : 어플리케이션의 데이터를 노출하고 앱의 비즈니스 로직의 대부분을 포함하고 있어요.
Domain Layer : 선택 가능한 Layer 로 비즈니스 로직을 재사용가능하게, 복잡성을 간단하게 하는 것을 목적으로 가지고 있어요. 그이상 그이하도 아니에요.
참고 : 비즈니스 로직이란 앱에 가치를 주는 것을 의미해요. 가령 제품 요구조건(데이터를 fetch 하거나, 저장하고 수정하는 것들)에 따라 구현이 달라질 수 있어요.
Entities in the UI layer (UI Layer 에서의 Entities)
UI Layer 는 책임이 명확하게 정의된 3가지 Entity 로 구분됩니다. 이러한 구분은 관심사를 분리하고, 테스트를 쉽게 가능하도록 그리고 재사용성을 향상 시켜요.
- UI 또는 UI elements(요소들) 은 화면에 데이터를 그려내요
- UI State(UI 상태) 는 화면에 그려낼 데이터를 설명해요. 만약 UI 가 유저에게 표시되는 것을 의미한다면(대표한다면), UI State 는 유저에게 보여질 수 있도록 앱이 지정하는 것을 의미해요.
- 옵셔널하게 State holder 를 활용해서 로직의 일부를 관리하거나, UI State 를 유지하고, UI 에 노출해서 UI 로직을 단순하게 만들 수도 있어요. State holder 는 UI 와 관련있는 로직 복잡성이 늘어나거나 유지보수하기 어려워질 때 사용할 수 있어요.
Unidirectional data flow (단반향 데이터 흐름)
앱은 보통 정적인 데이터만 보여주지 않아요. 유저들 어플리케이션 상태를 잠재적으로 변경시킬 수 있는 작업을 지속적으로 실행하며 앱과 상호작용해요. 유저 이벤트들은 일반적으로 state holder 에 의해 처리되어요. 그리고 처리 된 이후에는 UI state 에 결과를 반영해요. 이러한 경우 UI state 는 정적이지 않아요. State holder 는 UI state 를 Flow 로 노출해야해요. UI 에 즉시 결과를 배출해서 반영하기 위함이에요. 이러한 컨셉은 Unidirectional Data Flow 문서에 언급되어 있어요. (단방향 데이터 흐름 뿐만 아니라 SSOT 에 대해서도 설명하기 때문에 꼭 읽어보길 추천해요)
THE UI
UI Layer 문서의 가이드와 지금 보고 있는 포스트는 View system(XML) 과 Jetpack Compose 모두 적용 됩니다. UI Layer 내에서의 UI 의 역할은 UI 툴킷(Compose, SwiftUI) 과 무관하게 유지(적용)되어요.
UI layer 를 고려할 때, 많은 개발자들은 UI Layer 를 단지 어플리케이션 데이터를 보여주는 UI 트리의 한 부분정도의 화면으로 생각하는 경향이 있어요. 그리고 일반적으로 개발자들은 state holder 를 구현하기 위해 androidx.ViewModel 를 사용해요.
그러나 다양한 유형의 데이터를 다루기 위해 PaymentsRepository, UserRepository 를 만들고 사용하는 것 처럼, 필요한 부분의 UI 계층 구조나 UI 트리의 한 부분에 UI Layer Entity 들을 사용해서 유연하게 만들 수도 있어요 UI 복잡성은 결정 세부사항에 따라 달라져요.
State holder 섹션에서 살펴보겠지만, UI 트리 이내의 어떠한 곳에서든 State holder 를 사용해서 UI 를 단순화할 수 도 있어요. 실제로 특정 상황에서는 이러한 방식을 권장해요.
The UI state (UI 상태)
UI state 는 화면에 그려지는 정보들을 표현하고 있어요. 이번 장에서는 모델링, 생성, 그리고 UI state 를 관찰하는 방법에 대해 알아볼 예정이에요.
Types* of UI state (UI 상태의 여러 유형들)
특별한 처리가 필요한 하위 타입의 화면 Ui state 가 있을 때, 보통 Data Layer 로 부터 어플리케이션 상태로 전달 됩니다. 화면에 표시되는 대부분의 정보와 사용자가 대부분 관심있어 하는 정보가 일치하기 때문이에요.
이후에 추가될 기능을 살짝 보면, UI 상태가 상태 변경이 발생해도 캐시 되어서 유지되어야해요.
How to produce UI state (UI state 생성 하는 방법)
UI state 를 생성하는 것은 특정 인풋을 처리하고 state holder 로 부터의 결과를 의미합니다. 인풋이 될 수 있는 것들은 1) 이벤트, 2) 로컬 상태 변경 3) 외부 상태 변경 등이 있습니다.
각각의 경우에 따라 어떤 API 를 사용해야할까요 ?
- UI state 는 관찰가능한 data holder 클래스로 노출되어야해요 (예를 들어 StateFlow, Compose 의 State<T> 또는 LiveData 가 있어요) 이러한 타입들은 화면에 보여지는 UI state 를 항상 가지고 있도록 보장해요.
- 입력들은 다양한 형태를 가질 수 있고, 보통은 데이터를 스트림 형태나 one-shot API(일회성 이벤트 처리) 가 있습니다.
예시를 보도록 하죠.
Producing UI state with local source of state change (로컬 상태 변경 소스로 UI 상태 생성하기)
유저가 두개의 주사위를 굴릴 수 있는 화면이 있다고 가정해볼게요. 게다가 주사위 값을 보여줄 때 유저가 주사위를 여러번 돌린 값들을 추적하고 싶어요. UI state 는 아래와 같아 보일 수 있어요.
data class DiceRollUiState(
val firstDiceValue: Int? = null
val secondDiceValue: Int? = null
val numberOfRolls: Int = 0
)
주사위를 굴리는 비즈니스 로직은 아래와 같이 Random API 를 사용해서 one-shot 으로 구현되었어요.
firstDiceValue = Random.nextInt(1..6),
secondDiceValue = Random.nextInt(1..6),
numberOfRolls = currentUiState.numberOfRolls + 1
그러면 해당 UI state 를 state holder 안에 어떤식으로 유지(보관)할까요? 관찰 가능한 데이터 홀더 클래스를 생성하면 됩니다! 예를 들어 여기서는 MutableStateFlow API 를 활용해서 달성합니다. Random API 의존성을 직접 사용하는 것을 회피하기 위해서는 RandomProvider interface 를 사용할 예정이에요. 이를 통해 재사용 가능하며, 테스트 용이하게 만들 수 있습니다.
class DiceRollStateHolder(
private val randomProvider: RandomProvider
) {
private val _uiState = MutableStateFlow(DiceRollUiState())
val uiState: StateFlow<DiceRollUiState> = _uiState.asStateFlow()
fun rollDice() {
_uiState.update { currentState ->
currentState.copy(
firstDiceValue = randomProvider.nextInt(1..6),
secondDiceValue = randomProvider.nextInt(1..6),
numberOfRolls = currentState.numberOfRolls + 1
)
}
}
}
해당 UI state 를 생성하는 비즈니스 로직은 로컬에 존재해요. 그리고 관찰 가능한 state holder 의 변경 가능한 버전이 노출되어 UI state 를 직접 접근해 수정하거나, SSOT(단일 진실 원칙) 을 위반 하지 않기 위해서는 StateFlow.uiState 의 형태로 읽기 가능하도록 만들어서 노출해야 합니다. asStateFlow 연산자를 활용해서 쉽게 변경할 수 있어요.
참고 : MutableStateFlow 대신에 Compose 의 State<T> 심지어 LiveDat 를 활용해서 UI state 를 모델링할 수도 있어요. Compose State<T> 를 사용하는 패턴과 모범사례들은 UI state production 에서 확인할 수 있어요
Producing UI state with external source of state change (외부 소스 상태 변경으로 UI state 생성하기)
어플리케이션 데이터에는 스트림의 형태로 다른 Layer 에서 전달 될 수 있어요. 이런 데이터들을 UI state 에 맞게 조정하려면, 관찰 가능한 데이터 홀더 형태로 변환하는 과정을 거쳐야해요. 다음 예제에는 화면에 유저의 이름이 출력되는 코드가 포함되어 있어요.
class DiceRollViewModel(
userRepository: UserRepository
) : ViewModel() {
val userUiState: StateFlow<String> =
userRepository.userStream.map { user -> user.name }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ""
)
}
State holder 는 UserRepository 와 같은 Data layer 로 부터 의존성을 가지는 인스턴스를 가져오는 전달 받습니다. 그리고 위의 예제에서는 유저 이름이라는 특정한 정보를 추출해서 userStream: Flow 로 매핑시킵니다. map 연산자로부터 flow 가 반환되고 stateIn 연산자를 사용해 Flow 를 관찰 가능한 데이터 홀더 타입인 StateFlow 로 반환시킵니다.
stateIn 메서드는 다수의 데이터 스트림으로 부터 전달되는 흐름을 합치거나(Combine) 다른 계층으로 부터 전달되는 Flow 를 처리할 때 사용됩니다. 해당 메서드에는 아래와 같은 정의가 포함되어 있습니다.
- scope : StateFlow 의 결과에 대한 생명주기를 정의 합니다. (위에서는 viewModelScope 를 사용해서 뷰모델이 살아있는 동안 유지 됩니다.)
- started : 공유(sharing = 구독정도로 바라보면 좋을 것 같아요)를 시작하거나 정지하는 전략을 결정해요. 위 코드 조각에서는 UserRepository 에서 전달되는 업스트림 Flow 들로 부터 WhileSubscribed(5_000) 를 적용했어요. collector, observers 가 없는 상태(UI 가 유저에게 보여지지 않는 상태)에서 5초 이상 지속되면 업스트림 Flow(Data Layer 에서 전달되는 Flow 들) 을 취소하고, 디바이스의 자원을 절약할 수 있도록 도와줘요.
- initialValue : StateFlow 의 초기값을 설정할 수 있어요. 앞서 언급했듯이 관찰 가능한 state holder 타입은 UI 가 화면에 그려내는 UI state 를 가질 수 있도록 항상 보장해줘야하고 해당 프로퍼티가 이를 해결하는데 중요한 역할을 해요.
Summary of producing UI state (UI state 생성 요약하기)
각 상황에서 어떤 인풋 타입과 어떤 API 를 사용해야할지 요약해볼게요.
- 만약 지속적으로 값을 가져오는 스트림이 아닌 one-shot API 혹은 로컬에서 이루어지는 비즈니스 로직을 작성한다면 MutableStateFlow 와 Compose MutableStae<T> 를 사용해서 state holder 에 상태를 저장하고 StateFlow 와 Compose State<T> 와 같은식으로 노출할 수 있습니다.
- 소스 타입이 외부로 부터 Flow 와 같이 스트림 타입으로 전달되는 경우 단순히 StateFlow 로 노출하면 됩니다.
- 만약 로컬과 외부 스트림 두가지에 대해서 적어도 각각 하나 이상 존재한다면 이를 combine 하고 StateFlow 로 UI state 를 노출하면 됩니다.
해당 주제에 대해서 좀더 정보를 얻고 싶다면, State holders and state production talk 혹은 UI Layer State production 문서를 참고할 것을 추천드려요
How to model UI state (UI state 를 모델링 하는 방법)
UI state 는 특정 시점의 UI 를 시각적으로 표현한 것이라 할 수 있어요. 이전에 DiceRollUiState 를 정의한 데이터 클래스를 UI state 로 위에서 정의했었어요 다시 한번 볼까요 ?
data class DiceRollUiState(
val firstDiceValue: Int? = null
val secondDiceValue: Int? = null
val numberOfRolls: Int = 0
)
UI state 에 존재하는 필드(각각의 프로퍼티)들은 불변(val)해요 따라서 동시성이나 타이밍 이슈를 보장해요. 일반적으로 UI state 필드들은 생성하고, 복사할 때 용이하게 사용할 수 있도록 기본값을 가지고 있어요. 하지만 모든 UI state 가 위에서 이야기한 것 처럼 간단하지는 않아요.
좀더 복잡한 상태를 고려해보죠, 로그인 한 유저들만 주사위를 굴릴 수 있는 시나리오를 생각해볼게요. 유저가 화면에 도달했을 때 유저 상태를 체크해야해요. 가능한 상태들은 아래와 같아요.
sealed interface DiceRollUiState {
data object Loading : DiceRollUiState
data class DiceRoll(
val username: String,
val numberOfRolls: Int,
val firstDiceValue: Int? = null,
val secondDiceValue: Int? = null,
) : DiceRollUiState
data object LogUserIn : DiceRollUiState
}
UI state 는 로딩 상태가 될 수도 있고 또는 로그인이 필요한 상태일 수도 있고, 유저의 이름과 함께 주사위를 굴리는 상태일 수도 있어요.
이를 표현하기 위해서는 data class, sealed interface/class 또는 두개를 합쳐서 표현할 수 있어요.
- 화면이 여러가지 독립적인 상태를 가질 수 있는 경우에는 sealed interface/class 를 사용하는 것이 좋아요.
- 내부 데이터들이 변경될 수 있는 경우 데이터 클래스를 사용하면 용이해요. 로딩 상태, 에러 메세지 등이 동시에 보여질 수 있는 오프라인 모드를 고려해서 개발할 때 특히 편리해요.
How to model complex UI state (복잡한 UI state 모델링 하는 방법)
복잡한 화면을 처리할 때는 UI 의 불일치가 발생하지 않도록 신경써야 해요. 연습해볼겸 Jetnews 의 홈 화면 모델링을 살펴볼게요(컴포즈 샘플 앱이에요)
화면에 보여지는 주요한 컨텐츠는 피드 아티클과 아티클의 세부 사항을 읽어 볼 수 있는 섹션으로 구성되어 있어요. 전체 상태를 모델링 하기 전에 아래와 같이 간단히 정의해볼 수 있어요.
private data class HomeViewModelState(
val postsFeed: PostsFeed? = null,
val selectedPostId: String? = null,
val isArticleOpen: Boolean = false,
val favorites: Set<String> = emptySet(),
val isLoading: Boolean = false,
val errorMessages: List<ErrorMessage> = emptyList(),
val searchInput: String = "",
)
그러나 문제가 있는 것 같아요. 발견 했나요 ? 기본값에 문제가 있어서 UI 에 불일치가 발생할 수 있을 것으로 보여요. 가령 postFeed 가 없어도 selectedPostId 가 존재하는 역설적인 상황이 존재할 수 있어요. 실제로는 발생할 수 없는 일이에요. 해당 문제를 해결하기 위해서는 보다 강력한(엄격한) 상태를 도입해야해요. 비즈니스 요구사항에 따라 게시물을 표시하거나, 하나도 표시하지 않을 수도 있어요. 이때 최상위 상태에 sealred interface 를 사용해볼 수 있어요.
sealed interface HomeUiState {
val isLoading: Boolean
val errorMessages: List<ErrorMessage>
val searchInput: String
data class NoPosts(
override val isLoading: Boolean = false,
override val errorMessages: List<ErrorMessage> = emptyList(),
override val searchInput: String = ""
) : HomeUiState
data class HasPosts(
val postsFeed: PostsFeed,
val selectedPost: Post,
val isArticleOpen: Boolean,
val favorites: Set<String>,
override val isLoading: Boolean = false,
override val errorMessages: List<ErrorMessage> = emptyList(),
override val searchInput: String = ""
) : HomeUiState
}
이제 UI 는 포스트가 있는 경우 또는 없는 경우중 하나만 표현해요. (* 주관적인 생각 여기서 하나의 상태만을 가진다는 것은 굉장히 중요해요. HomeUiState 에서 포스트가 있으면서 없는 경우가 동시에 존재하지 않는 다는 것을 의미해요.) 포스트가 있는 경우에는 selectedPost 가 not null 이기 때문에 존재하지 않는 경우는 없어요. 문제가 해결되었네요. UI state 의 초기 값에 HasPosts, NoPosts 상태가 표시되지 않도록 private 로 처리해주는 것이 좋아요. 궁극적으로는 아래와 같이 현재 상태를 HomeUiState 로 매핑해줘야해요
private data class HomeViewModelState(...) {
fun toUiState(): HomeUiState =
if (postsFeed == null) {
HomeUiState.NoPosts(...)
} else {
HomeUiState.HasPosts(...)
}
}
Exposing single vs multiple UI state streams (하나 혹은 여러개의 UI state 스트림 노출하기)
State holder 를 하나 혹은 다수의 데이터 스트림으로 노출해야하는지 논의가 종종 있곤 합니다.
필드가 서로 의존적이라면 합쳐서 각각 하나의 스트림을 노출하는 것이 좋다고 지금까지 조언해 왔습니다. 반면에 서로 독립적이고 위에서 이야기 한 것처럼 UI 불일치가 발생하지 않은 경우에는 여러개의 스트림을 노출하는 것도 허용 됩니다.
몇몇은 완벽하게 UI 와 상관없이 독립적이라면, 각각의 상태를 관리하는 state holder 가 존재하는 것이 좋다고 주장할 수도있습니다. 물론 동의 합니다. 하지만 여러 상태를 만들고 상위 함수에 여러 상태를 노출해도 괜찮은 경우에만 사용하는 것이 좋습니다.(상위 함수에 여러 상태가 전달 되면 유지보수가 어렵다는 것을 이야기하고 있습니다)
How to comsume UI state (UI state 를 처리(소비)하는 방법)
이상적으로 UI state 는 생명주기를 고려하여 소비되어야 해요. 그 말은 UI 가 화면에 표시될 때만을 해당 됩니다. 안드로이드 생명주기에서는 STARTED ~ STOPPED 상태 일 때를 의미해요. 이 작업을 쉽게 처리할 수 있는 다양한 API 들이 제공되어요.
Android View 에서는 repeatOnLifecycle 과 flowWithLifecycle 을 사용해볼 수 있어요. (androidx.lifecycle.lifecycle-runtime-ktx 패키지 아래 존재해요) repeatOnLifecycle 을 사용해보죠. (onEach + launchIn + flowWithLifecycle 조합이면 intent 도 줄여낼 수 있겠네요.)
class SomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// New UI state! Update the UI
}
}
}
}
}
repeatOnLifecycle 은 코루틴 블록을 내부에서는 UI state 를 수집하고 있어요. repeatOnLifecycle 은 설정한 Lifecycle.State.STARTED 가 되면 자동으로 코루틴 블록을 실행하고 STOPPED 상태를 벗어나면 실행중인 코루틴 블록을 취소해요.
Compose 에서는 내부적으로 repeatOnLifecycle 을 실행하는 collectAsStateWithLifecycle API 를 사용할 수 있어요. (androidx.lifecycle.lifecycle-runtime-compose 패키지 아래 존재해요) 해당 API 는 설정한 생명주기 상태에 도달하면 수집을 시작해요. 그리고 Compose 에서의 최신 값은 State<T> 형식으로 제공되어요. 아래는 새로운 원소가 배출될 때마다 Composable 함수에 재구성이 발생해요
@Composable
fun SomeScreen(
modifier: Modifier = Modifier,
viewModel: SomeViewModel = viewModel()
) {
val uiState: SomeUiState by viewModel.uiState
.collectAsStateWithLifecycle()
// Emit UI given uiState. SomeScreen will recompose
// whenever `viewModel.uiState` emits a new value.
}
생명주기 관리에 따른 수집에 대해 좀더 궁금하다면 아래 Collect flows on android talk 영상을 보거나, repeatOnLifecycle, collectAsStateWithLifecycle 게시글을 읽는 것을 추천해요. 이러한 API 가 어떤식으로 만들어졌는지 궁금하다면 repeatOnLifecycle API design story blog.post 를 읽어보세요.
이제 UI Layer 단기 속성 과정 파트 1을 모두 읽었어요. UI Layer 에서 일반적으로 사용되는 다양한 Entity 들을 이해할 수 있어요. 그리고 UI 와 UI state 가 를 효율적으로 다룰 수 있는 방법에 대해 알아봤어요.
파트2 에서는 state holders 를 다루거나 UI Layer 와 관련있는 주제들(상태 호이스팅, 상태를 저장하는 방법) 등에 대해 알아볼 예정이에요.
다음 시간에 와요.
만약 해당 내용을 비디오 형식으로 보고싶다면 2023 년 런던 드로이드콘에서 발표한 영상이 있다는 것을 기억하세요
그럼 20000
'Android > 번역' 카테고리의 다른 글
[번역] Effective android - Using Jetpack Compose with MVVM (0) | 2023.12.29 |
---|---|
[번역] Crash Course on the Android UI Layer Part 2 (2) | 2023.12.29 |
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 3 (0) | 2021.06.07 |
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 2 (0) | 2021.06.06 |
LiveData, Coroutine, Flow 를 이용한 반응형 UI - Part 1 (1) | 2021.05.25 |