[Android] JetpackCompose 상태 관리

코랑·2023년 5월 9일
0

android

목록 보기
14/16

개요

상태 및 구성

Compose는 선언적이어서 업데이트 하는 유일한 방법은 새 인수로 동일한 컴포저블을 호출하는것임.
State가 업데이트 될때마다 Recompose가 실행됨.

  • 컴포지션: 컴포즈가 컴포저블을 실행할 때 빌드한 UI에 관한 설명(컴포저블의 트리 구조)
  • 초기 컴포지션: 처음 생성된 컴포지션
  • 리컴포지션: 데이터가 변경될때 컴포지션을 업데이트 하기 위해 컴포저블을 다시 실행하는 것.
    - 리컴포지션 때 똑같은거는 다시 빌드 안하는데 이걸 식별하는데에 도와주는게 key값, 데이터의 고유한 값을 컴포즈 api의 key 값에 매핑해주면 스마트 리컴포지션에 도움이 된다.

컴포저블의 상태

remember: 상태를 메모리에 객체를 저장할 수 있음. 초기 컴포지션때 저장된 값을 리컴포지션 때 유지되게해줌.

remember 삭제는 언제되냐?
remember를 호출한 컴포저블이 컴포지션에서 삭제되면 삭제됨.
mutableState 타입으로 생성하는 이유?
컴포즈 내부에서는 상태를 State<T>형태로 저장하고있는데 이 상태는 immutable임.
그래서 mutable한 state 형태로 컴포저블의 상태를 선언함.

interface MutableState<T> : State<T> {
    override var value: T
}

지원되는 기타 상태 유형

MutableState의 내부 

interface MutableState<T> : State<T> {
    override var value: T
}

by 키워드 사용을 위해서는 아래 두가지 Import구문을 추가해야함.

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

전체 컴포저블 컴포넌트로 보면 현재 컴포즈가 없어지면 당연히 상태 유지가 안됨..

이때 사용되는게 rememberSaveable Bundle에 저장됨.

MutableState는 상태 변경으로 State를 변경하기 위해 사용하는거고

mutableState 타입으로 생성해야되냐? 아님!
프로젝트에서 필요에 의해 Flow나 Rx등을 사용할 때 Jetpack과 호환해서 사용할 수 있는거? 당연히 제공함미다.(링크 클릭해서 보면 내부 구조랑 사용예시, 설명을 볼수있어요~)

- Flow: collectAsState(), collectAsStateWithLifecycle()

- LiveData: observaAsState()

- RxJava2: subscribeAsState()

핵심은?
Compose는 State 객체를 읽어오는 과정에서 자동으로 리컴포저블 되고,
관찰가능한 다양한 형태(rx, live data, flow)의 데이터를 컴포즈에서 다룰 수 있는 state 형태로 변환해서 사용한다는게 핵심임미다~
그렇기 때문에 의도하는대로 State로 변환되는지 확인 하고 사용해야함

  • remember로 상태를 기억하고 recompose할 때마다 이전 값을 넘겨주면? stateful
  • 상위 객체로 부터 받은 값을 단순 표출하고 recompose할때 유지할 상태가 없다면? stateless => 상태 호이스팅

상태 호이스팅

호이스팅 = 끌어올리다?
=> Stateless를 유지하기 위해서 상태를 끌어올려 컴포저블 호출자를 상위로 옮기는 패턴.

호이스팅된 상태의 속성(끌어올려진 상태의 속성)

  • 단일 정보 소스: 상태를 복제하는 대신 옮겼기 때문에 정보 소스가 하나만 있습니다. 버그 방지
  • 캡슐화됨: 스테이트풀(Stateful) 컴포저블만 상태를 수정할 수 있습니다. 철저히 내부적 속성입니다.
  • 공유 가능함: 호이스팅한 상태를 여러 컴포저블과 공유할 수 있습니다. 다른 컴포저블에서 name을 읽으려는 경우 호이스팅을 통해 그렇게 할 수 있습니다.
  • 가로채기 가능함: 스테이트리스(Stateless) 컴포저블의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있습니다.
  • 분리됨: 스테이트리스(Stateless) ExpandingCard의 상태는 어디에나 저장할 수 있습니다. 예를 들어 이제는 name을 ViewModel로 옮길 수 있습니다

예시

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}
// 상태 저장박식과 분리되고, 테스트가 용이해짐.
// ex> HelloScreen을 수정하거나 교체 시, HelloContent의 구현방식을 변경할 필요 없음.
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

상태 이동 위치를 쉽게 파악할 수 있는 세 가지 규칙

1. 가장낮은 공통 상위 요소로 끌어올리기(읽기)

2. 최소한 변경될 수 있는 가장 높은 수준으로 끌어올리기(쓰기)

3. 동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 끌어올려야 함

Compose에서 상태 복원

Activity 혹은 프로세스가 다시 생성된 후 `rememberSaveable`을 사용하여 UI 상태를 복원함.

Bundle에는 int, double, long, String 부터 FloatArray, StringArrayList, Serializable, Parcelable까지 저장가능

  • Pacelize: 아래와 같은 지원하지 않는 데이터 타입은 어노테이션을 붙여서 사용가능하게 만들어준다.
@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}
  • mapSaver
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
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의 상태 홀더

간단한 호이스팅은 컴포저블함수 자체에서 관리가 가능함.

하지만 추적할 양이 늘어나거나 컴포저블 함수에서 실행할 로직이 발생하면 상태 책임을 다른 클래스(=> 상태 홀더)에 위임하는것이 좋음.

상태 홀더 = 호이스팅한 상태 객체

키가 변경될 경우 Computed remember 다시 트리거

remember는 calculation 람다 매개 변수를 취함.
remember를 사용하여 초기화 하거나 계산하는데 비용이 많이 드는 객체 또는 작업의 결과를 컴포지션 중에 저장할 수 있음. 그러나 안하는게 좋다~
remember API는 key 또는 keys 매개변수도 취합니다. 이러한 키 중 하나라도 변경될 경우 다음번에 함수가 재구성될 때 remember는 캐시를 무효화하고 계산 람다 블록을 다시 실행함 그래서 이 키를 잘 활용하면 된다~

@Composable
fun BackgroundBanner(
   @DrawableRes avatarRes: Int,
   modifier: Modifier = Modifier,
   res: Resources = LocalContext.current.resources
) {
   val brush = remember(key1 = avatarRes) {
       ShaderBrush(
           BitmapShader(
               ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
               Shader.TileMode.REPEAT,
               Shader.TileMode.REPEAT
           )
       )
   }

   Box(
       modifier = modifier.background(brush)
   ) {
       // ...
   }
}

remember의 키값을 windowSizeClass로 받고있음.

@Composable
fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { ... }

Compose 클래스가 equals 함수를 구현해서 키가 변경된 것을 확인하고 저장된 값을 무효화 함(= 리컴포저블을 안함).
derivesStateOf와의 차이점 => 상태를 압축

val isEnabled = remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > 0 }
}
val output = remember(input) { expensiveCalculation(input) }

rememberSaveable은 이 키로 input매개변수를 받음. input이 변경되면 캐시가 무 효화하고 함수가 재구성 될 때 계산 람다 블록을 재실행함.

// rememberSaveable는 typedQuery가 변경될 때까지 userTypedQuery를 저장
var userTypedQuery by
  rememberSaveable(inputs = typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
      TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
  }

부수 효과(Side Effect)

컴포저블 함수 범위 밖에서 발생하는 앱 상태에 관한 변경사항
컴포저블 수명주기 및 속성으로 인해 컴포저블에는 부수 효과가 없는것이 좋은데,
필요한 경우도 있음. 상태에 따른 스낵바 같은 단발성 이벤트
아래 API 들은 이 필요한 경우에 사용하는 것들임.

Effect: UI를 내보내지 않으며 컴포지션이 완료될 때 부수효과를 실행하는 컴포저블 함수. 예측 가능한 방식으로 실행됨

  • LaunchedEffect: 컴포저블의 범위에서 정지 함수 실행
@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(scaffoldState.snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError` is true
            // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}
  • rememberCoroutineScope: 컴포지션 인식 범위를 확보하여 컴포저블 외부에서 코루틴 실행
@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        scaffoldState.snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}
  • rememberUpdatedState: 값이 변경되는 경우 다시 시작되지 않아야하는 효과에서 값 참조
@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}
  • DisposableEffect: 종라거 필요한 효과
@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

- SideEffect: Compose 상태를 비 Compose 코드에 게시

@Composable
fun rememberAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        /* ... */
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}
  • produceState: 비Compose상태를 Compose 상태로 변환
@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}
  • derivedStateOf: 하나 이상의 상태 객체를 다른 상태로 변환
@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}
  • snapshotFlow: Compose의 상태를 Flow로 변환
val listState = rememberLazyListState()

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

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

Effect 다시 시작

LaunchedEffect, produceState, DisposableEffect와 같은 Compose의 일부 효과에서 실행 중인 Effect를 취소하는 데 사용되는 가변적인 수의 인수를 취하고 새 키로 새 Effect를 시작
Effect에 사용되는 변수는 Effect 컴포저블의 매개변수로 추가하거나 rememberUpdatedState를 사용해야함.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

State를 호이스팅 할 위치

UI 로직

  • State 소유자로서의 컴포저블
  • State 호이스팅불필요
  • 컴포저블 내부에서 호이스팅
  • 상태 소유자로서의 일반 상태 홀더 클래스

비즈니스 로직

  • 상태 소유자로서의 ViewModel
  • 화면 UI 상태
  • UI 요소 상태

0개의 댓글