UI Layer 란
- UI Layer의 역할은 화면에 앱 데이터를 표시하는 것
- 따라서, 사용자 상호작용(ex. 버튼 누르기) 또는 외부 입력(ex. 네트워크 응답)으로 인해 데이터가 변할 때마다 변경사항을 반영하도록 UI가 업데이트 되어야 함
- 사실상 UI Layer는 Data Layer에서 가져온 앱 데이터를 시각적으로 보여주는 것에 관심이 있다.
즉 정리하자면, 데이터가 변경될 때마다 "어! 데이터 바뀌었다!"하고 해당 변경 사항을 업데이트하고 반영하는 것이 UI 레이어의 역할이다. UI 레이어는 버튼을 누른다거나, 네트워크로부터 응답이 도착했다거나 등 상태 변화에 따라 변경 사항을 업데이트한다.
UI Layer 에는 구성 요소 두 가지로 나눠짐
- UI elements : UI 요소가 갖는 본질적인 상태
즉, 실제 화면에 보이는 구성 요소
👉 예: `TextView`, `Button`, `Image`, `LazyColumn`, `Text()`
- State holders : 데이터를 보유하고 이를 UI에 표시하며 비즈니스 로직을 처리하는 역할
UI 상태를 생성하며, 생성 작업에 필요한 로직을 포함하는 클래스
즉, UI 상태를 보관하고, 이를 UI에 전달하는 역할
👉 예: `ViewModel`, `remember { mutableStateOf(...) }` (Compose 기준)
UI 레이어가 어떤 단계를 거쳐 무슨 일을 하나 ?
1. 우선, 앱 데이터를 UI 데이터로 변환한다.
보통 Data 레이어에서 가져오는 데이터는 UI 레이어에서 표시해야 하는 정보랑 다른 형식이다.
예를 들어, student라는 데이터가 있으면 거기서 이름만 필요할 수도 있고
아니면 성적이라는 데이터 소스와 병합하여 보여줘야 할 수도 있고 말이다.
즉, UI가 필요로 하는 데이터를 화면에 보여주기 쉬운 모양으로 변환하는 일을 한다.(앱데이터 -> UI State로 변환)
2. UI 데이터를 UI 요소로 업데이트한다.
번역이 좀 어렵게 되었는데, 데이터를 이용해 뷰를 그린다고 생각하면 된다.
예를 들어 학생 이름을 텍스트뷰에 넣는 것처럼 말이다.
3. 사용자 입력 이벤트를 처리하여 UI를 변경시킨다.
예를 들어 버튼이 클릭되면 토스트 메시지를 띄우는 행위가 여기에 해당한다.
4. 위 행위를 반복
UI State ?
- 앱 데이터로부터 화면에 보여주기 쉬운 모양으로 추출되고 변환된 데이터를 의미
- 사용자에게 표시되는 데이터
- UI 상태가 변경되면 변경사항사항이 즉시 UI에 반영되어야 한다.
UI State가 지켜야 하는 원칙
- 불변성(Immutability)
- 변하지 않는 특성
- 외부에서 setXXX()함수를 호출하여 UI State를 변경할 수 없다는 의미(=read only, 읽기 전용)
- Activity, Fragment 클래스에서 UI State를 변경해서는 안된다.
- 불변성 원칙의 장점
- Activity, Fragment의 수명 주기와 상관 없이 UI State를 변하지 않게 유지할 수 있다.
- Activity, Fragment가 UI State를 읽고(쓰지는 않음) 그 값을 UI 요소에 반영하는 역할에만 집중할 수 있게 해준다.
- data class로 선언
- 불변성 원칙을 지키기 위해 보통 kotlin의 data class를 활용하여 UI State를 만든다.
- data class 파라미터는 val로 선언
++ UI State 클래스 이름 짓기에 대한 컨벤션
공식 문서에서는 XXUiState로 짓기를 권장하고 있다.
단방향 데이터 플로우로 상태 관리(UDF)
플로우 예시
- ViewModel 이 Data Layer 에서 현재의 앱 데이터 가져옴 (여기서는 이미지 / 타이틀 / 작성자 / 작성 시간 / 북마크 체크 여부)
- View 에서 현재의 UI State 를 옵저빙해서 UI 요소에 적용
- View 에서 북마크를 해제했을 때(UI 이벤트 발생) ViewModel 에게 알림 (여기서 알린다는 뜻은 ViewModel 이 View 를 참조하고 있다는 것이 아닌, View 에서 ViewModel 의 메서드를 호출함. 즉, MVVM 패턴에서 “ViewModel 은 View 를 몰라야 한다” 라는 것에 위배되지 않음)
- ViewModel 은 Data Layer 에게 데이터 수정을 요청함
- Data Layer 에서는 data를 업데이트하고, 앱 데이터를 수정함
- ViewModel 에서는 새로운 데이터를 받아옴
- 무한 반복
그렇다면 UDF를 사용하는 이유
- 데이터 일관성 (SSOT: Single Source of Truth)
- 테스트 가능성 : 상태가 분리되어 있어 UI 와 별개로 테스트 가능
- 유지 관리성 : 상태 변화가 일관된 방식으로 처리되어 코드를 쉽게 수정, 이해, 확장할 수 있음
1. 데이터 일관성 (SSOT: Single Source of Truth)
"UI 데이터는 딱 하나의 출처에서만 관리된다!"
- 예전에는 View(Activity/Fragment)에서도 데이터를 관리하고, ViewModel에서도 데이터를 관리해서 어디서 바뀌는지 추적이 어려웠음.
- UDF를 사용하면 UI 상태는 오직 ViewModel 하나만이 관리함.
- View는 그 상태만 보고 UI를 그립니다. 데이터를 여기저기서 따로 관리하지 않으니 데이터 충돌이나 불일치가 줄어듬.
val uiState = viewModel.uiState.collectAsState()
// 화면은 오직 이 uiState만 보고 그림
2. 테스트 가능성
"상태와 UI를 분리했기 때문에, 상태만 따로 테스트할 수 있다!"
- ViewModel은 UI를 몰라요. 그저 데이터를 받아서 UI 상태만 만들 뿐이다.
- 따라서 ViewModel의 로직을 테스트할 때 UI 코드 없이도 테스트가 가능함.
- 예를 들어, "카테고리를 선택하면 뉴스 목록을 불러오는지" 같은 동작을 View 없이 테스트할 수 있다.
@Test
fun `카테고리 선택 시 뉴스 목록이 갱신된다`() {
viewModel.fetchArticles("sports")
assertEquals(viewModel.uiState.value.newsItems.isNotEmpty(), true)
}
3. 유지 보수성 (코드 수정·확장·이해가 쉬움)
"데이터 흐름이 일정하고 예측 가능해서 유지보수가 쉽다!"
- 데이터가 View → ViewModel → Repository → ViewModel → View 이런 식으로 항상 같은 방향으로 흐르니까, 코드 흐름을 추적하기 쉽고, 어디서 문제가 생겼는지도 파악이 쉽다.
- 새로운 기능을 추가하거나 기존 기능을 수정할 때도, 흐름이 일정해서 실수할 확률이 낮아짐.
[사용자 입력] → [이벤트 처리 (ViewModel)] → [데이터 변경] → [UI 상태 변경] → [UI 갱신]
UDF 원칙에 따라 관찰 가능한 데이터 유형으로 UI 상태를 노출하는 방법
근데 어떻게 데이터가 바뀌었다는 것을 감지할까? -> LiveData 또는 StateFlow 같은 관찰 가능한 데이터 홀더 클래스이다.
class NewsViewModel : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState()) // 내부에서만 변경 가능
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow() // 외부에 노출할 땐 읽기 전용
}
- _uiState: ViewModel 내부에서만 수정 가능
- uiState: 외부(UI)에는 읽기 전용으로 제공
- 왜 이렇게?
👉 UI가 상태를 직접 수정하지 못하게 막기 위해서
(UDF 원칙: 상태는 ViewModel이 관리, UI는 보기만 함)
UI 상태는 왜 ViewModel에서 관리하나?
- UI는 ViewModel이 관리하는 상태(State) 를 "관찰"하고,
- 상태가 바뀌면 자동으로 UI가 갱신되게 하기 위함입니다.
- 즉, ViewModel에서 상태를 변경하고, UI는 그걸 관찰(observe) 합니다.
그럼 Backing Property 란 ?
간단히 말해,
- 외부에서 수정 못 하도록 막고,
- 내부에서만 값을 바꿀 수 있게 만든 변수 패턴이다.
private val _uiState = MutableStateFlow(NewsUiState()) // 내부에서만 수정 가능
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow() // 외부에 노출
→ 외부(UI)는 uiState만 볼 수 있어서, UI가 상태를 바꾸는 실수를 방지할 수 있습니다.
실제 사용 예시
fun fetchArticles(category: String) {
fetchJob?.cancel() // 이전 작업 취소
fetchJob = viewModelScope.launch {
try {
val newsItems = repository.newsItemsForCategory(category)
_uiState.update {
it.copy(newsItems = newsItems) // 상태 변경!
}
} catch (ioe: IOException) {
_uiState.update {
it.copy(userMessages = getMessagesFromThrowable(ioe))
}
}
}
}
- viewModelScope.launch를 통해 백그라운드에서 비동기 작업(뉴스 받아오기) 실행
- 데이터를 받아오면 NewsUiState를 업데이트
- _uiState가 바뀌면 uiState를 보고 있는 UI는 자동으로 다시 렌더링됨
관찰 가능한 UI 상태를 소비하는 UI를 구현하는 방법
class NewsActivity : AppCompatActivity() {
private val viewModel: NewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
...
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
// Update UI elements
}
}
}
}
}
관찰 가능한 데이터 홀더를 써보지 않았거나 (특히) Flow를 접해보지 않았다면 위 코드가 이해가 잘 안 갈 수도 있다.
위 코드는 ViewModel에서 노출하는 UI 상태를 소비하는 코드이다.
간단하게 설명하면,
- lifecycleScope.launch
👉 Activity의 생명주기를 따라가는 코루틴을 실행. 화면이 닫히면 자동으로 취소돼서 메모리 누수 X - repeatOnLifecycle(Lifecycle.State.STARTED)
👉 Activity가 STARTED 이상 상태일 때만 코루틴 블록을 실행.
(즉, 사용자가 화면을 보고 있을 때만 상태를 수집하고 UI를 업데이트함) - collect
👉 StateFlow를 계속 관찰하면서 값이 바뀔 때마다 {} 안의 코드를 실행.
(UI 상태가 바뀔 때마다 UI를 새로 그린다는 뜻)
이번 포스에선 코드를 이해하려고 하기보단 개념을 이해하면서 넘어가면 될 것 같다.
'안드로이드 프로그래밍 > Android' 카테고리의 다른 글
[Android] 액티비티(Activity)와 생명주기(Lifecycle) (3) | 2024.09.13 |
---|---|
[Android] 안드로이드 4대 컴포넌트에 대해서 알아보자. (3) | 2024.09.11 |
[안드로이드] Hilt에서 @Binds와 @Provides의 차이 (0) | 2024.09.10 |
[Android] view binding (0) | 2023.09.28 |
[Android] Linearlayout , layout_weight 속성 (0) | 2023.09.26 |