[정리] Compose 가이드 문서 ~ UI Architecture

[정리] Compose 가이드 문서 ~ UI Architecture

May 6, 2024. | By: pluulove


Lifecycle

Recomposition : 상태 변경에 따라 변경되었을 수 있는 컴포저블을 다시 실행한 다음 변경 사항을 반영하도록 컴포저블을 업데이트하는 것

  • Composition은 초기 Composition으로만 생성, Recomposition을 통해서만 업데이트 가능
  • State<T>를 통해서 Recomposition이 발생
  • Recomposition 중에 이전 Composable과 다른 내용으로 호출하는 경우, Composable의 호출 여부를 판단하여 입력이 변경되지 않은 경우 Recomposition을 Skip한다.

생명주기

  • Composition 시작
  • 0회 이상 Recomposition
  • Composition 종료

Smart Recompositions

@Composable
fun LoginScreen(showError: Boolean) {
   if (showError) {
      LoginError()
   }
   LoginInput()
}

@Composable
fun LoginInput() { /* ... */ }

@Composable
fun LoginError() { /* ... */ }

// Column
@Composable
fun MoviesScreenWithKey(movies: List<Movie>) {
   Column {
      for (movie in movies) {
         key(movie.id) { // 고유 ID를 Key로 지정
            MovieOverview(movie)
         }
      }
   }
}

// LazyColumn
@Composable
fun MoviesScreenLazy(movies: List<Movie>) {
   LazyColumn {
      items(movies, key = { movie -> movie.id }) { movie ->
         MovieOverview(movie)
      }
   }
}

입력이 변경되지 않은 경우 건너뛰기

Recomposition시 Skip이 불가능한 케이스

  • 함수의 리턴이 Unit 타입인 경우
  • 함수에 @NonRestartableComposable/@NonSkippableComposable Annotation이 정의되어 있는 경우
  • 파라미터가 non-stable 타입인 경우

위 요구사항을 완하하는 Strong Skipping 모드가 존재 (experimental)

Stable으로 판단 되는 케이스

  • @Stable Annotation을 사용하여 명시적으로 Stable 정의
    • Compose Compiler에게 Stable하다고 강제 정의시 사용
  • 기본 원시 타입 : Boolean, Int, Long, Float, Char emd
  • Strings
  • 모든 함수 유형 (Lambdas)

불변 타입에 대해서만 Stable하다고 판단

Stable하며 Mutable한 타입 : Compose의 MutableState 유형

  • 값이 MutableState에 보관되어 있으며, value 속성이 변경되면 Compose에 알림이 전송
  • 상태 객체는 전체적으로 안정된 것으로 간주

모든 입력이 안정적이고 변경되지 않은 경우, Composable의 Recomposition을 건너뜁니다. 비교는 equals 함수를 사용

Side-effect

Side-effect : Composable 함수의 범위 밖에서 발생하는 앱 상태의 변경

  • Composable의 수명 주기 및 속성 (예측할 수 없는 Recomposition, 다른 순서로 Composable Recomposition 실행, 삭제할 수 있는 Recomposition)으로 인해 Composable은 Side-effect이 없어야 한다.
  • 필요한 경우
    • 예) Snackbar 표시, 화면 이동 등의 일회성 이벤트

LaunchedEffect

  • Composable에서 suspend 함수를 실행할 수 있음

  • LaunchedEffect가 Composition을 종료하면 Coroutine이 취소
  • 다른 Key로 Recomposition되면 기존 Coroutine은 취소 후 새로운 Coroutine에서 suspend 함수가 실행
@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    snackbarHostState: SnackbarHostState
) {
   // UI 상태에 오류가 있는 경우 Snackbar 표시
   if (state.hasError) {
      // snackbarHostState가 변경되면 Launch가 재시작 
      LaunchedEffect(snackbarHostState) {
         snackbarHostState.showSnackbar(
            message = "Error message",
            actionLabel = "Retry message"
         )
      }
   }
   ...
}

rememberCoroutineScope

  • CoroutineScope를 사용해 Coroutine 함수를 실행할 수 있도록 함
  • 하나 이상의 Coroutine을 제어할 때 사용
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
   // MoviesScreen의 생명주기에 바인딩된 CoroutineScope 생성
   val scope = rememberCoroutineScope()

   Scaffold(
      snackbarHost = {
         SnackbarHost(hostState = snackbarHostState)
      }
   ) { contentPadding ->
      Column(Modifier.padding(contentPadding)) {
         Button(
            onClick = {
               // 이벤트 핸들러에서 새 Coroutine을 생성하여 SnackBar 표시
               scope.launch {
                  snackbarHostState.showSnackbar("Something happened!")
               }
            }
         ) {
            Text("Press me")
         }
      }
   }
}

rememberUpdatedState

  • 값이 변경되더라도 재시작하지 않아야 하는 값을 참조
  • 파라미터 중 주요 매개변수 중 하나가 변경되면 LaunchedEffect는 다시 시작됨
  • Effect에서 값이 변경되더라도 다시 시작되지 않도록 캡처하고 싶은 경우에 사용
  • 비용이 많이 들거나 다시 만들고 다시 시작할 수 없도록 하고 싶은 작업에 유용
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
   // Recomposition된 최신 onTimeout 함수를 참조
   val currentOnTimeout by rememberUpdatedState(onTimeout)

   // LandingScreen의 수명 주기와 일치하는 효과를 만듦
   // LandingScreen이 Recomposition되면 delay는 다시 시작되지 않아야 함
   LaunchedEffect(true) {
      delay(SplashWaitTimeMillis)
      currentOnTimeout()
   }
   ...
}

DisposableEffect

  • DisposableEffect의 Key 변경 또는 Composable이 Composition을 떠나고 정리해야 하는 경우에 사용
  • 예) Lifecycle 이벤트를 기반으로 LifecycleObserver를 사용하여 분석 이벤트를 전송 가능. DisposableEffect를 사용하여 이벤트 수신을 대기, 필요에 따라 관찰자를 등록하고 등록 취소 가능
@Composable
fun HomeScreen(
   lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
   onStart: () -> Unit,
   onStop: () -> Unit
) {
   // 현재 Lambda를 안전하게 업데이트
   val currentOnStart by rememberUpdatedState(onStart)
   val currentOnStop by rememberUpdatedState(onStop)

   // LifecycleOwner가 변경되면 Effect를 제거 및 초기화
   DisposableEffect(lifecycleOwner) {
      // remember callback을 트리거하는 Observer 생성
      val observer = LifecycleEventObserver { _, event ->
         if (event == Lifecycle.Event.ON_START) {
            currentOnStart()
         } else if (event == Lifecycle.Event.ON_STOP) {
            currentOnStop()
         }
      }
      lifecycleOwner.lifecycle.addObserver(observer)
     
      // Effect가 Composition에서 사라지면 Observer를 제거
      onDispose {
         lifecycleOwner.lifecycle.removeObserver(observer)
      }
   }
   ...
}

SideEffect

  • compose로 관리되지 않는 객체와 Composition 상태를 공유할 때 사용
  • Recomposition이 동작할 때마다 Effect가 실행
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
   val analytics: FirebaseAnalytics = remember {
      FirebaseAnalytics()
   }

   // Composition 성공할 때마다 FirebaseAnalytics 업데이트
   SideEffect {
      analytics.setUserProperty("userType", user.userType)
   }
   return analytics
}

produceState

  • Compose가 아닌 상태를 Compose 상태로 변환
  • Coroutine을 만드는 경우에도 suspend 하지않는 데이터 소스를 관찰하는 데 사용할 수 있다. 구독을 삭제하려면 awaitDispose 함수를 사용
  • 내부적으로 remember { mutableStateOf(initialValue) }를 사용하는 result 변수를 보유하며 LaunchedEffect에서 producer 블록을 트리거
// 네트워크에서 이미지를 로드하는 예시
// 다른 Composable에서 사용할 수 있는 State를 반환
@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {

    // 초기값으로 Result.Loading을 가진 State<T>를 생성
    // `url` 또는 `imageRepository`가 변경되면 실행 중인 produce가 취소
    // 새 입력값으로 다시 시작
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // suspend 함수 호출
        val image = imageRepository.load(url)

        // 상태 업데이트로 Recomposition이 트리거됨
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf

  • Composable에 대한 입력이 Recomposition해야 하는 것보다 더 자주 변경되는 경우에 사용
    • 예) 스크롤 위치와 같이 항목이 자주 변경되지만 Composable은 특정 기준점을 넘어야만 반응해야 할 때 발생
    • 필요한 만큼만 업데이트되는 관찰 가능한 새로운 Compose 상태 객체를 생성
  • 비용이 많이 발생하므로 결과가 변경되지 않은 경우 불필요한 리컴포지션을 방지하기 위해서만 사용해야 함
@Composable
// 파라미터가 변경되면 MessageList가 Recomposition 됨
// derivedStateOf는 이 Recomposition에 영향을 주지 않음
fun MessageList(messages: List<Message>) {
   Box {
      val listState = rememberLazyListState()

      LazyColumn(state = listState) {
         // ...
      }

      // firstVisibleItemIndex가 0보다 큰 경우에 버튼을 표시
      // remember + derivedStateOf를 사용하여 불필요한 Composition을 최소화
      val showButton by remember {
         derivedStateOf {
            listState.firstVisibleItemIndex > 0
         }
      }

      AnimatedVisibility(visible = showButton) {
         ScrollToTopButton()
      }
   }
}
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // 잘못된 사용
val fullNameCorrect = "$firstName $lastName" // 올바른 사용

snapshotFlow

  • Compose의 상태를 Cold Flow로 변환
  • snapshotFlow 블록에서 읽은 State 객체 중 하나가 변경되면 이전에 방출된 값과 같지 않은 경우 새 값을 방출
    • Flow.distinctUntilChanged의 동작과 유사

Phases

Compose의 주요 단계

  • Composition : 어떤 UI를 보여줄지. Composable 함수를 실행하고 UI에 대한 설명 정의
  • Layout : UI를 배치할 위치를 정의. measure/place라는 2단계로 구성. 각 노드에 대해 자신과 모든 하위 element를 2D 좌표로 측정하고 배치
  • Draw : 렌더링 방법. 디바이스의 Canvas에 그려짐

이전 결과를 재사용할 수 있으면 Composable 함수 실행을 건너뛴다. Compose UI는 꼭 필요한 경우가 아니라면 전체 트리를 다시 배치하거나 다시 그리는 작업을 하지 않는다.

Composition

  • Compose Runtime은 Composable 함수를 실행하고 UI를 나타내는 트리 구조를 출력
  • 다음 단계에 필요한 모든 정보가 포함된 레이아웃 노드로 구성

Layout

  • Composition 단계에서 생성된 UI 트리를 입력으로 사용
  • 레이아웃 노드 모음에는 2D 공간에서 각 노드의 크기와 위치 결정에 필요한 모든 정보가 포함

트리 탐색

  1. 하위 element 측정 : 노드는 하위 element가 있는 경우 하위 element를 측정
  2. 자체 크기 결정 : 측정값에 따라 자체 크기를 결정
  3. 하위 element 배치 : 각 하위 노드는 노드 자체 위치를 배치

레이아웃 노드 정보

  • 할당된 너비와 높이
  • 그려야 할 x, y 좌표

https://developer.android.com/static/develop/ui/compose/images/layout.mp4

Draw

트리가 위에서 아래로 다시 탐색되고 각 노드가 차례로 화면에 그려진다

https://developer.android.com/static/develop/ui/compose/images/drawing.mp4

상태 읽기

// MutableState 프로퍼티를 직접 접근
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
   text = "Hello",
   modifier = Modifier.padding(paddingState.value)
)

// MutableState Delegate 사용
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
   text = "Hello",
   modifier = Modifier.padding(padding)
)

단계별 상태 읽기

1단계) Composition

  • 상태 값이 변경되면 Recomposer가 해당 상태 값을 읽는 모든 Composable 함수의 재실행을 예약.
  • 입력이 변경안되면 런타임에 Composable의 함수의 일부 혹은 모두 Skip 가능

2단계) Layout

  • 측정(measurement)과 배치(placement)라는 단계로 구성
  • 측정 단계
    • Layout에 전달된 람다, LayoutModifier 인터페이스의 MeasureScope.measure 메소드 등을 실행
  • 배치 단계
    • layout 함수의 배치 블록, Modifier.offset { … }의 람다 블록 등을 실행

3단계) Draw

  • Draw 코드 중 상태 읽기는 Draw 단계에 영향을 줌 (Canvas(), Modifier.drawBehind, Modifier.drawWithContent 등)
  • 상태 값이 변경되면 Compose UI는 Draw 단계만 실행

Managing state

composables에서의 상태

Composables 함수에서는 remember API를 사용해 객체를 메모리에 저장할 수 있다. remember에 의해 계산된 값은 초기 Composition에 저장되며, 저장된 값은 Recomposition 중에 반환된다. remember API는 mutable/immutable 객체를 저장하는 데 사용할 수 있다.

mutableStateOfMutableState를 생성

  • value가 변경되면 value를 읽는 Composable 함수의 Recomposition이 예약된다
interface MutableState<T> : State<T> {
   override var value: T
}

API 사용법

val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }
  • remember : configuration 변경 시 상태가 유지되지 않음

  • rememberSaveable : configuration 변경 시 상태가 유지됨

지원되는 기타 State 유형

MutableState외에도 관찰 가능한 다른 타입도 지원.

State 객체를 읽어오는 과정에서 자동으로 Recomposition된다

Stateful/Stateless Composable

  • Stateful Composable : remember를 사용하여 객체 상태를 저장하는 Composable
  • Stateless Composable : 상태를 가지지 않는 Composable

상태 호이스팅

Compose에서 상태 호이스팅은 Composable을 스테이트리스(Stateless)로 만들기 위해 상태를 Composable의 호출자로 옮기는 패턴

특징

  • 단일 정보 소스: 상태관련 소스가 하나만 존재
  • 캡슐화됨: 스테이트풀(Stateful) Composable만 상태를 수정 가능
  • 공유 가능함: 호이스팅한 상태를 여러 Composable과 공유 가능.
  • 가로채기 가능함: 스테이트리스(Stateless) Composable의 호출자는 상태를 변경하기 전에 이벤트를 어떻게 처리할지 결정 가능
  • 분리됨: 스테이트리스(Stateless) Composable의 상태는 어디에나 저장 가능
    • 예) name를 ViewModel로 이동 가능

단방향 데이터 흐름 (unidirectional data flow) : 상태는 내려가고 이벤트가 올라가는 패턴

상태를 끌어올릴 때 상태의 이동 위치를 쉽게 파악할 수 있는 규칙

  1. 상태는 적어도 그 상태를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 element로 올려야 한다(읽기).
  2. 상태는 최소한 변경될 수 있는 가장 높은 수준으로 올려야 한다(쓰기).
  3. 동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 올려야 한다.

Compose에서 상태 복원

rememberSaveable API : 저장된 인스턴스 상태 메커니즘을 사용하여 Recomposition/Activity 및 프로세스 재생성 전반에 걸쳐 상태를 유지

상태 저장 지원 타입

  • Bundle에 저장되는 타입
  • 직렬화가 가능한 타입 @Parcelize annotation
  • MapSaver : Bundle에 저장할 수 있는 값으로 변환하는 커스텀 규칙을 정의 가능
data class City(val name: String, val country: String)

val CitySaver = run {
   val nameKey = "Name"
   val countryKey = "Country"
   mapSaver(
      save = { mapOf(nameKey to it.name, countryKey to it.country) },
      restore = { City(it[nameKey] as String, it[countryKey] as String) }
   )
}

@Composable
fun CityScreen() {
   var selectedCity = rememberSaveable(stateSaver = CitySaver) {
      mutableStateOf(City("Madrid", "Spain"))
   }
}
  • ListSaver : MapSaver 처럼 별도의 Key대신 Index로 사용 가능
data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
   save = { listOf(it.name, it.country) },
   restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
   var selectedCity = rememberSaveable(stateSaver = CitySaver) {
      mutableStateOf(City("Madrid", "Spain"))
   }
}

Compose에서 상태 홀더

추적할 상태의 양이 늘어나거나 Composable 함수에서 실행할 로직이 발생하는 경우 로직과 상태 책임을 다른 클래스, 즉 상태 홀더에 위임을 추천

상태 홀더 : Composable의 로직과 상태를 관리합니다.

키 변경시 remember 계산 재처리

remember API에 고유 키값을 전달함으로써, 키 변경시 Lambda 블럭을 재실행할 수 있다.

@Composable
inline fun <T : Any?> remember(
   key1: Any?,
   crossinline calculation: @DisallowComposableCalls () -> T
): T

rememberSavable API에도 동일한 기능이 존재한다. remember의 keys 정의와 같은 목적으로 inputs 파라미터를 전달받을 수 있다. inputs 값이 변경되면 캐시 데이터도 무효화된다.

@Composable
fun <T : Any> rememberSaveable(
   vararg inputs: Any?,
   saver: Saver<T, Any> = autoSaver(),
   key: String? = null,
   init: () -> T
): T

상태를 호이스팅할 대상 위치

Android 가이드 문서상의 기본 내용

UI 상태는 UI 상태를 읽고 쓰는 모든 컴포저블의 가장 낮은 공통 상위 element로 호이스팅해야 합니다. 상태는 상태가 소비되는 위치에서 가장 가까운 곳에 유지해야 합니다. 상태 소유자로부터 소비자에게 변경 불가능한 상태 및 이벤트를 노출하여 상태를 수정합니다.

UI 상태

UI를 설명하는 속성

  • 화면 UI 상태 : 화면에 표시해야 하는 내용. 앱 데이터를 포함하므로 다른 레이어와 연결
  • UI element 상태 : 렌더링되는 방식에 영향을 주는 UI element의 속성

로직

  • 비즈니스 로직 : 앱 데이터에 대한 제품 요구 사항의 구현
    • 예) 북마크를 파일이나 데이터베이스에 저장하는 로직은 도메인/데이터 레이어에 배치됩니다. 상태 홀더는 노출된 메서드를 호출하여 이 로직을 해당 레이어에 위임한다.
  • UI 로직 : 화면에 UI 상태를 표시하는 방법

UI 로직

상태를 읽거나 써야 하는 경우, UI의 생명 주기에 따라 UI 상태 범위를 지정해야 함

  • State Owner로서의 Composables
    • 상태와 로직이 간단하다면 Composable에 UI 로직과 UI element 상태를 사용하는 것을 추천
  • 상태 호이스팅 불필요한 경우
    • 상태를 제어해야 하는 다른 Composable이 없는 경우 상태를 컴포저블 내부에 유지
@Composable
fun ChatBubble(
   message: Message
) {
   // UI element 상태
   var showDetails by rememberSaveable { mutableStateOf(false) } 

   ClickableText(
      text = AnnotatedString(message.content),
      onClick = { showDetails = !showDetails } // 간단한 UI 로직
   )
   ...
}
  • Composable 내부에서 상태 호이스팅
    • UI element 상태를 다른 Composable과 공유하고 여러 위치에서 상태에 UI 로직을 적용해야 하는 경우에 유용

  • State Owner로서의 상태 홀더 클래스
    • Composable에 UI element 하나 또는 여러 개의 상태 필드가 사용되는 복잡한 UI 로직이 포함되어 있다면 일반 상태 홀더 클래스와 같은 상태 홀더로 그 책임을 위임하면 좋다
    • Composable의 로직을 격리된 상태에서 더 쉽게 테스트할 수 있고 복잡성이 줄어든다.
    • Composable이 UI element를 방출하고 상태 홀더가 UI 로직과 UI element의 상태를 포함
    • 예) LazyColumn 또는 LazyRow의 UI 복잡성을 제어하기 위한 LazyListState
      • 스크롤 위치를 수정하는 메소드를 노출

Composable의 책임을 늘리면 상태 홀더의 필요성이 증가

비즈니스 로직

State Owner로서의 ViewModel

  • ViewModel에서 UI 상태를 호이스팅하면 상태가 Composition 외부로 이동된다.
  • ViewModel은 Composition의 일부로 저장되지 않음
  • ViewModel은 프레임워크에 의해 제공되며, ViewModelStoreOwner(Activity, Fragment, Navigation Graph)로 범위 지정
  • ViewModel이 정보 소스이자 UI 상태의 가장 낮은 공통 상위 element가 됨

Screen UI 상태

  • Screen UI 상태는 Screen 수준 상태 홀더(여기서는 ViewModel)에서 호이스팅됨을 의미
  • Composable은 ViewModel에서 호이스팅된 Screen UI 상태를 소비
  • Screen Composable에 ViewModel 인스턴스를 주입하여 비즈니스 로직 접근을 허용
class ConversationViewModel(
   channelId: String,
   messagesRepository: MessagesRepository
) : ViewModel() {

   val messages = messagesRepository
      .getLatestMessages(channelId)
      .stateIn(
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5_000),
         initialValue = emptyList()
      )

   // Business logic
   fun sendMessage(message: Message) { /* ... */ }
}
@Composable
private fun ConversationScreen(
   conversationViewModel: ConversationViewModel = viewModel()
) {

   val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

   ConversationScreen(
      messages = messages,
      onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
   )
}

@Composable
private fun ConversationScreen(
   messages: List<Message>,
   onSendMessage: (Message) -> Unit
) {
   MessagesList(messages, onSendMessage)
   /* ... */
}

Property drilling

  • 여러 중첩된 하위 컴포넌트를 통과하여 데이터를 데이터가 읽힌 위치로 전달하는 것
  • 예) 최상위 수준에서 Screen 수준 상태 홀더를 주입하고 상태와 이벤트를 하위 Composable에 전달하는 경우. 이로 인해 추가로 Composable 함수 signature의 오버로드가 발생 가능
    • 이벤트를 개별 Lambda 파라미터로 노출하면 함수 서명이 오버로드될 수 있지만, Composable 함수 책임의 가시성이 극대화
  • Wrapper 클래스보다 Property drilling을 사용하여 한곳에서 상태 및 이벤트를 캡슐화하는 것을 권장

UI element 상태

  • UI element 상태를 읽거나 써야 하는 비즈니스 로직이 있다면 상태를 Screen 수준 상태 홀더로 호이스팅

주의 사항

  • 일부 Compose UI element 상태의 경우 ViewModel로 호이스팅하려면 고려사항이 필요함
  • 예) Compose UI element의 일부 상태 홀더는 상태를 수정하는 메서드를 노출, 그 중 일부는 애니메이션을 트리거하는 suspend 함수는 Composition으로 범위가 지정되지 않은 CoroutineScope에서 호출하는 경우 예외를 발생 가능
  • 해결법 : ViewModel에 Composition의 CoroutineContext를 전달해서 사용

https://developer.android.com/develop/ui/compose/state-hoisting#caveat

UI 상태 저장

상태는 호이스팅된 위치와 필요한 로직에 따라 각각의 API를 사용하여 UI 상태를 저장/복원 가능

상태 손실이 발생할 수 있는 이벤트

  • Configuration 변경
  • 시스템에 의한 프로세스 종료

UI 로직

  • 상태가 UI에서 호이스팅되는 경우 rememberSaveable 를 사용하여 상태를 유지 가능
  • rememberSaveable는 UI element 상태를 Bundle에 저장
  • 예) rememberLazyListState
  • Activity#onSaveInstanceState에서 사용하는 Bundle이 사용되므로 크기의 제한이 있음
  • 상태 복원 확인 : StateRestorationTester

비즈니스 로직

Event UI 로직 비즈니스 로직 (ViewModel)
Configuration 변경 rememberSaveable 자동으로 대응 됨
시스템에 의한 프로세스 종료 rememberSaveable SavedStateHandle

Architecture

Compose

  • UI를 그린 후에는 UI 상태로만 업데이트가 가능

단방향 데이터 흐름 (UDF, Unidirectional data flow)

상태가 아래로 흐르고 이벤트가 위로 흐르는 디자인 패턴

  • 이벤트 : 이벤트를 생성하여 위쪽으로 전달하거나(예: ViewModel로 전달), 앱의 다른 레이어에서 이벤트를 전달
  • 상태 업데이트 : 이벤트 핸들러가 상태를 변경 가능
  • 상태 표시 : 상태 홀더가 상태를 전달하고 UI가 이를 표시

해당 패턴의 이점

  • 테스트 가능성
  • 상태 캡슐화 : 상태는 한 곳에서만 업데이트 가능
  • UI 일관성 : 모든 상태 업데이트는 관찰 가능한 상태 홀더(StateFlow, LiveData)를 사용하여 UI에 반영

Composable 파라미터 정의

  • 얼마나 재사용 가능하거나 유연한가?
  • 상태 매개변수가 Composable의 성능에 어떤 영향을 미치는가?

분리와 재사용을 위해 각 Composable은 가능한 한 최소한의 정보만 담아야 한다

Compose의 이벤트

앱에 대한 모든 입력은 이벤트로 표시되어야 한다. 이벤트는 UI의 상태를 변경하므로 ViewModel이 이를 처리하고 UI 상태를 업데이트해야 한다.

상태 및 이벤트 핸들러 람다에 불변 값을 전달하는 것을 권장

  • 재사용성이 향상
  • UI가 상태 값을 직접 변경하지 않도록 보장
  • 상태가 다른 스레드에서 변경되지 않도록 하므로 동시성 문제 방지
  • 코드 복잡성을 감소

ViewModel, 상태, 이벤트

  • ViewModel, mutableStateOf를 사용하여 앱에 단방향 데이터 흐름을 도입 가능
    • UI의 상태는 StateFlow 또는 LiveData와 같은 관찰 가능한 상태 홀더를 통해 노출
    • ViewModel은 UI 또는 앱의 다른 레이어에서 발생하는 이벤트를 처리하고, 이벤트에 따라 상태 홀더를 업데이트

Architectural layering

Compose의 주요 레이어

Runtime

  • remember, mutableStateOf, @Composable 어노테이션 및 SideEffect와 같은 Compose 런타임의 기본을 제공
  • UI가 아닌 Compose의 트리 관리 기능

UI

  • UI 관련 여러 모듈로 구성
  • LayoutNode, Modifier, 입력 핸들러, 사용자 정의 레이아웃, 그리기 등 UI 툴킷의 기본을 구현

Foundation

  • Compose UI에 Row, Column, LazyColumn, 특정 제스처 인식 등과 같이 디자인 시스템에 구애받지 않는 구성 요소를 제공

Material

  • Compose UI에 Material Design 시스템의 구현을 제공
  • 테마 설정 시스템, 스타일이 적용된 컴포넌트, Ripple 표시, Icon 제공

디자인 원칙

Control

  • 상위 수준 Component는 더 많은 기능을 제공하는 경향. 직접 제어할 수 있는 범위는 제한
  • ‘드롭다운’을 통해 더 낮은 수준의 Component를 사용
  • 예) animateColorAsState (상위 수준) -> Animatable (하위 수준)

Customization

  • 작은 Component에서 상위 수준의 Component를 조합하면 쉽게 자체 Component 만들 수 있다
// Gradient Button
@Composable
fun GradientButton(
   // …
   background: List<Color>,
   modifier: Modifier = Modifier,
   content: @Composable RowScope.() -> Unit
) {
   Row(
      // …
      modifier = modifier
         .clickable(onClick = {})
         .background(
            Brush.horizontalGradient(background)
         )
   ) {
      CompositionLocalProvider(/* … */) { // material LocalContentAlpha 적용
         ProvideTextStyle(MaterialTheme.typography.button) {
            content()
         }
      }
   }
}
// Material 개념이 없는 기본 Button
@Composable
fun BespokeButton(
   // …
   backgroundColor: Color,
   modifier: Modifier = Modifier,
   content: @Composable RowScope.() -> Unit
) {
   Row(
      // …
      modifier = modifier
         .clickable(onClick = {})
         .background(backgroundColor)
   ) {
      // Material components를 미사용
      content()
   }
}

정확한 추상화 선택

  • 재사용 가능한 계층화된 컴포넌트를 구축한다는 Compose의 철학은 항상 낮은 수준의 빌딩 블록에 도달해서는 안 된다는 것을 의미
  • 상위 컴포넌트는 더 많은 기능을 제공, 접근성 지원과 같은 기능을 제공
    • 사용자 정의 컴포넌트에 제스처 지원을 추가하려는 경우
      • 하위 수준 : Modifier.pointerInput
      • 상위 수준 : Modifier.draggable, Modifier.scrollable, Modifier.swipeable

CompositionLocal

CompositionLocal은 Composition을 통해 암시적으로 데이터를 전달하기 위한 도구

CompositionLocal 소개

Compose에서 데이터는 UI 트리를 통해 각 Composable 함수에 파라미터로 전달 -> Composable의 종속성이 명확해짐 -> 색상/Type 스타일과 같이 매우 빈번하고 널리 사용되는 데이터의 경우 번거로움

  • UI 트리의 특정 노드에 값이 제공. Composable 함수에 CompositionLocal을 파라미터로 선언하지 않고도 Composable 하위 컴포넌트에서 사용 가능
  • Composition : Composition 가능한 함수의 호출 그래프에 대한 기록
  • UI 트리 또는 UI 계층구조 : Composition 프로세스에 의해 생성, 업데이트 및 유지되는 LayoutNode의 트리

MaterialTheme도 CompositionLocal을 사용

  • colors, typography, shapes 인스턴스를 제공
  • Composition의 모든 하위 부분에서 접근 가능 (LocalColors, LocalShapes, LocalTypography)
// MaterialTheme 구조의 일부 Composable
@Composable
fun SomeTextLabel(labelText: String) {
   Text(
      text = labelText,
      // primary :  MaterialTheme의 LocalColors CompositionLocal에서 가져옴
      color = MaterialTheme.colors.primary
   )
}

CompositionLocal 인스턴스는 Composition의 일부로 범위에 따라 다른 값을 제공 가능

  • 인스턴스는 해당 Composition의 부모가 제공하는 가장 가까운 값을 사용

새로운 CompositionLocal 값 사용

@Composable
fun CompositionLocalExample() {
   MaterialTheme { // MaterialTheme는 ContentAlpha.high를 기본값으로 설정
      Column {
         // Uses MaterialTheme's provided alpha
         CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            // Medium value provided for LocalContentAlpha
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
               // This Text uses the disabled alpha now
            }
         }
      }
   }
}

나만의 CompositionLocal 만들기

중간 계층이 파라미터의 존재를 인식하면 Composable의 유용성이 제한될 수 있으므로, 중간 계층이 파라미터의 존재를 인식하지 않아야 할 때 CompositionLocal을 사용할 수 있다

  • 예) Android permissions, Media picker composable

CompositionLocal 단점

  • 암시적 종속성으로 컴포저블의 동작을 추론하기 어렵다. 모든 CompositionLocal의 값이 만족되는지 확인해야 한다.
  • 어느 부분에서든 변할 수 있기 때문에 명확한 제공 위치를 확인하기 까다롭다. IDE 혹은 Compose layout inspector를 통해 정보를 제공할 수 있다.

CompositionLocal 사용 여부

사용에 적합한 조건

  • 명시적인 기본값을 제공해야 한다. 기본값을 제공하지 않으면 테스트를 만들거나 컴포저블을 미리 볼 때 문제가 발생
  • 트리 범위 또는 하위 계층 범위로 생각되지 않는 개념에는 CompositionLocal을 사용 금지
    • CompositionLocal은 모든 하위 클래스에서 잠재적으로 사용할 수 있을 때 의미가 있다.

잘못된 사례 : ViewModel을 가지는 CompositionLocal 생성

CompositionLocal 만들기

생성 API

  1. compositionLocalOf : Recomposition 중에 제공된 값을 변경하면 현재 값을 읽는 콘텐츠만 무효화
  2. staticCompositionLocalOf : Compose에서 추적하지 않으며, 값을 변경하면 Composition에서 현재 값을 읽은 위치만 Recomposition되는 것이 아니라 CompositionLocal이 제공된 콘텐츠 Lambda 전체가 Recomposition

CompositionLocal에 제공된 값이 변경될 가능성이 거의 없거나 변경되지 않을 경우, staticCompositionLocalOf를 사용하여 성능상의 이점 확보 가능

// LocalElevations.kt file
data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// 기본값으로 CompositionLocal 객체를 정의
// 이 인스턴스는 앱의 모든 Composable에서 접근 가능
val LocalElevations = compositionLocalOf { Elevations() }

CompositionLocal에 값 제공

CompositionLocalProvider composable은 주어진 계층 구조에 CompositionLocal 인스턴스에 값을 바인딩. CompositionLocal 키를 값에 연결하는 provides infix 함수를 사용하면 됨

val elevations = /** 인스턴스 생성 */
// elevations을 LocalElevations의 값으로 바인딩
CompositionLocalProvider(LocalElevations provides elevations) {
   // LocalElevations.current에 접근시, elevations 인스턴스가 사용됨
}

CompositionLocal 사용

CompositionLocal.current는 해당 CompositionLocal에 값을 제공하는 가장 가까운 CompositionLocalProvider가 제공하는 값을 반환

@Composable
fun SomeComposable() {
   Card(elevation = LocalElevations.current.card) {
      // Content
   }
}

고려할 대안

CompositionLocal 대신 다른 대안

  • 명시적 파라미터 전달
  • 제어의 역전 : 하위 element가 일부 로직을 실행하는 종속 항목을 가져오는 대신 상위 element가 실행
@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
   // ...
   ReusableLoadDataButton(
      onLoadClick = {
         myViewModel.loadData()
      }
   )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
   Button(onClick = onLoadClick) {
      ...
   }
}

Arugment를 통한 Navigate

  • 기본 Navigation의 딥링크에 인수를 추가하는 방법과 유사하게 경로에 argument placeholder를 추가
  • 모든 argument는 기본 문자열로 분석
  • composable()의 arguments 파라미터는 NamedNavArgument에 정의가 필요. navArgument() 메소드를 사용하여 정확한 타입을 지정 가능
  • composable() 함수의 Lambda의 NavBackStackEntry에서 argument를 꺼내서 사용
NavHost(startDestination = "profile/{userId}") {
   ...
   composable(
      "profile/{userId}",
      arguments = listOf(navArgument("userId") { type = NavType.StringType })
   ) { backStackEntry ->
      Profile(navController, backStackEntry.arguments?.getString("userId"))
   }
}

탐색시 복잡한 데이터 대신 고유 식별자/ID 등 최소한의 정보만 argument로 전달할 것을 강력히 권장

navController.navigate("profile/user1234")

탐색 후 목적지에 도착하면 ViewModel의 SavedStateHandle을 사용하여 arugment를 사용할 수 있다

class UserViewModel(
   savedStateHandle: SavedStateHandle,
   ...
) : ViewModel() {
   private val userId: String = checkNotNull(savedStateHandle["userId"])
   ...
}

Optional Argument

  • 쿼리 파라미터 구문(“?argName={argName}”)을 사용
  • defaultValue 혹은 nullable = true로 선언
composable(
   "profile?userId={userId}",
   arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
   Profile(navController, backStackEntry.arguments?.getString("userId"))
}

composable() 함수의 deepLinks 파라미터에 navDeepLink() 메소서드를 사용하여 정의 가능

val uri = "https://www.example.com"

composable(
   "profile?id={id}",
   deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
   Profile(navController, backStackEntry.arguments?.getString("id"))
}

기본적으로 외부에 노출하지 않으므로 manifest.xml에 정의 필요.

  • PendingIntent로도 사용 가능
<activity >
   <intent-filter>
      ...
      <data android:scheme="https" android:host="www.example.com" />
   </intent-filter>
</activity>

Nested Navigation

bottom nav bar

  • BottomNavigation Composable에서 currentBackStackEntryAsState() 함수를 사용하여 현재 NavBackStackEntry를 통해 현재 NavDestination에 접근 가능
  • NavDestination 계층 구조를 사용하여 현재 목적지/상위 목적지의 경로와 비교하여 각 BottomNavigationItem의 선택된 상태로 가능
val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      items.forEach { screen ->
        BottomNavigationItem(
          icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
          label = { Text(stringResource(screen.resourceId)) },
          selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
          onClick = {
            navController.navigate(screen.route) {
              // Graph의 시작 위치로 popUp하여 사용자가 항목을 선택할 때
              // 스택에 목적지가 많이 쌓이는 것을 방지
              popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
              }
              // 동일한 항목을 다시 선택할 때
              // 동일한 대상의 복사본을 여러 개 만들지 않도록 정의
              launchSingleTop = true
              // 이전에 선택한 항목을 다시 선택할 때 상태 복원
              restoreState = true
            }
          }
        )
      }
    }
  }
) { innerPadding ->
  NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
    composable(Screen.Profile.route) { Profile(navController) }
    composable(Screen.FriendsList.route) { FriendsList(navController) }
  }
}

기본 타입 안정성을 보장하지않으므로, 타입 안정함을 보장되도록 Navigation을 정의 가능

https://www.youtube.com/watch?v=goFpG25uoc8

상호 운용성

Compose와 Navigation component를 작성시 2가지의 선택이 가능

  • Fragment용 Navigation component를 사용하여 Navigation graph를 정의
  • Compose의 NavHost API를 사용하여 Navigation graph 정의.
    • Navigation graph의 모든 화면이 Composable인 경우에만 가능

Navigate

// In compose
@Composable
fun MyScreen(onNavigate: (Int) -> Unit) {
   Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}

// In fragment
override fun onCreateView( /* ... */ ) {
   setContent {
      MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
   }
}

테스트

Navigation 코드를 Composable destination에서 분리하여 NavHost Composable과 별도로 각 Composable을 개별적으로 테스트 가능

  • navController를 Composable에 전달하는 대신 내비게이션 콜백을 파라미터로 전달하여 해결
  • 테스트에 navController 인스턴스가 필요하지 않음
// In Profile composable
@Composable
fun Profile(
   userId: String,
   navigateToFriendProfile: (friendUserId: String) -> Unit
) {
   
}

// In NavHost
composable(
   "profile?userId={userId}",
   arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
   Profile(backStackEntry.arguments?.getString("userId")) { friendUserId ->
      navController.navigate("profile?userId=$friendUserId")
   }
}
dependencies {
  // ...
  androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
  // ...
}

navigation testing 라이브러리에서 TestNavHostController를 사용하여 테스트

class NavigationTest {

   @get:Rule
   val composeTestRule = createComposeRule()
   lateinit var navController: TestNavHostController

   @Before
   fun setupAppNavHost() {
      composeTestRule.setContent {
         navController = TestNavHostController(LocalContext.current)
         navController.navigatorProvider.addNavigator(ComposeNavigator())
         AppNavHost(navController = navController)
      }
   }

   // Unit test
   @Test
   fun appNavHost_verifyStartDestination() {
      composeTestRule
         .onNodeWithContentDescription("Start Screen")
         .assertIsDisplayed()
   }
}

목적지를 확인, 예상 경로와 현재 경로를 비교하는 등의 테스트가 가능

navController를 사용하여 현재 문자열 경로를 예상 경로와 비교 가능

@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
   composeTestRule.onNodeWithContentDescription("All Profiles")
      .performScrollTo()
      .performClick()

   val route = navController.currentBackStackEntry?.destination?.route
   assertEquals(route, "profiles")
}

comments powered by Disqus

Currnte Pages Tags

Android AndroidX Compose

About

Pluu, Android Developer Blog Site

이전 블로그 링크 :네이버 블로그

Using Theme : SOLID SOLID Github

Social Links