코틀린 & Java/컴포즈 Compose

컴포즈(compose) 컴포저블 함수 상태 관리

코딩하는후운 2024. 1. 29. 13:06
반응형

컴포저블 함수 상태 관리

7장에서는 초기화 후에 객체를 ViewModel에 전달하는 방법
객체를 사용해 데이터를 불러오고 저장하는 방법을 알아본다.

상태 유지와 검색

이전에 배운것

  • 컴포즈 앱에서는 상태를 State MutableState의 인스턴스로 나타낸다.
  • 상태가 변경됨에 따라 재구성 동작을 유발한다.
  • 상태를 전달 받아 호출한곳으로 상태를 옮기는 것을 상태 호이스팅이라 부른다.

이러한 상태는 종종 사용하는 부모 컴포저블 중 하나에서 기억되는 경우도 있다.
→ 다른 대안으로 ViewModel 패턴으로 구현하는 방법이 있다.

ViewModel 인스턴스는 데이터 초깃값을 어디서 가져오고, 데이터가 변경되면 무슨일을 하게 되는가?

  • 안드로이드에서는 리포지터리 패턴 제안(책에서)

 

생성

class ViewModelFactory(private val repository: Repository) :
    ViewModelProvider.NewInstanceFactory() {
    override fun  create(modelClass: Class): T =
        if (modelClass.isAssignableFrom(TemperatureViewModel::class.java))
            TemperatureViewModel(repository) as T
        else
            DistancesViewModel(repository) as T
}
  • 뷰모델은 ViewModelProvider.Factory타입의 factory 매개변수를 전달 받는다.
    • 매개변수는 뷰모델 인스턴스를 생성하는데 사용되고 null일 경우 기본 팩토리가 사용된다.
  • ViewModelFactory는 ViewModelProvider.NewInstanceFactory 정적 클래스를 확장하고 create()를 재정의 한다.
  • modelClass는 생성될 ViewModel을 나타낸다.

사용

val factory = ViewModelFactory(Repository(applicationContext))
.
.
.
TemperatureConverter(
    viewModel = viewModel(factory = factory)
)
  • 뷰모델은 생성자를 통해 Repository 인스턴스를 전달 받는다.
  • Repository는 Context객체를 전달 받는다

Context를 this로 하면 뷰모델은 환경설정 변경에도 유지되기때문에, Repository는 더이상 사용할수 없는 액티비티에서 접근하게 된다.
applicationContext를 사용하면 문제 발생하지 않게 보장

 

컴포저블을 반응성 있게 유지

데이터가 ViewModel 내부에서 유지되고 있다면 컴포저블은 반드시 ViewModel과 상호작용해야 한다.

ViewModel 인스턴스와 소통

  • ViewModel에 있는 데이터는 옵저버블이어야 한다.
    • LiveData, MutableLiveData사용
private val _temperature: MutableLiveData = MutableLiveData(
    repository.getString("temperature", "")
)

val temperature: LiveData
    get() = _temperature

fun setTemperature(value: String) {
    _temperature.value = value
    repository.putString("temperature", value)
}
@Composable
fun TemperatureConverter(viewModel: TemperatureViewModel) {
    val currentValue = viewModel.temperature.observeAsState(viewModel.temperature.value ?: "")

		val calc = {
        val temp = viewModel.convert()
        result = if (temp.isNaN())
            ""
        else
            "$temp${
                if (scale.value == R.string.celsius)
                    strFahrenheit
                else strCelsius
            }"
    }
		val enabled by remember(currentValue.value) {
        mutableStateOf(!viewModel.getTemperatureAsFloat().isNaN())
    }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TemperatureTextField(
            temperature = currentValue,
            modifier = Modifier.padding(bottom = 16.dp),
            callback = calc,
            viewModel = viewModel
        )
        
        Button(
            onClick = calc,
            enabled = enabled
        ) {
            Text(text = stringResource(id = R.string.convert))
        }
        if (result.isNotEmpty()) {
            Text(
                text = result,
                style = MaterialTheme.typography.h3
            )
        }
    }
}
  • TemperatureConverter()가 ViewModel을 매개변수로 전달 받는다.

Tip) 가능하다면 미리보기나 테스트를 용이하게 기본 ViewModel을 제공하는것이 좋다고 한다.
리포지토리나 다른 생성자 값을 요구하는 경우에는 기본값 동작하지 않음.

 

ViewModel프로퍼티의 observeAsState()를 호출하면 State 인스턴스를 얻게 된다.
버튼을 누르면 calc코드에서 result에 할당한다.

장기간 동작하는 작업 처리

  • 잠재적으로 장기간 동작하는 작업은 반드시 비동기로 동작하도록 구현해야 한다.
  1. 결과를 옵저버블 프로퍼티로 제공한다.
  2. 코루틴이나 코틀린 플로우를 사용해 결과를 연산
  3. 연산을 끝내면 result 프로퍼티를 갱신

 

부수 효과의 이해

부수효과란 Composable 함수의 범위 밖에서 앱 상태에 대한 변경사항을 뜻합니다.
Composable에는 부수효과가 없어야 하지만, 상태를 변경할 때 필요한 경우가 있습니다.

부수 효과를 사용할 때는 부수 효과가 예측 가능한 방식으로 실행될 수 있도록 Effect API를 사용해야합니다.
Effect: UI를 내보내지 않으며 컴포지션이 완료될 때 부수효과를 실행하는 Composable함수

중단함수를 생성하기 위한 또 다른 방법으로는 LaunchedEffect() 컴포저블이 있다.
LaunchedEffect: 컴포저블의 범위에서 코루틴 실행
LaunchedEffect의 매개변수에 넣은 값이 변경되면 기존 코루틴을 종료하고 새 코루틴에서 suspend 함수를 실행합니다.

DisposableEffect: 정리가 필요한 효과

DisposableEffect는 LaunchedEffect와 동작이 동일합니다.
하지만 DinsposableEffect는 Compose leave로 종료할 때 항상onDispose{..} 가 호출됩니다.

@Composable
@Preview
fun LaunchedEffectDemo() {
    var clickCount by rememberSaveable { mutableStateOf(0) }
    var counter by rememberSaveable { mutableStateOf(0) }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Log.d("JHTEST", "### Column start")
        Row {
            Log.d("JHTEST", "### Row start")
            Button(onClick = {
                Log.d("JHTEST", "### Button1 onClick ")
                clickCount += 1
            }) {
                Log.d("JHTEST", "### Button1 contents ")
                Text(
                    text = if (clickCount == 0)
                        stringResource(id = R.string.start)
                    else
                        stringResource(id = R.string.restart)
                )
            }
            Spacer(modifier = Modifier.width(8.dp))
            Button(enabled = clickCount > 0,
                onClick = {
                    Log.d("JHTEST", "### Button2 onClick ")
                    clickCount = 0
                }) {
                Log.d("JHTEST", "### Button2 contents ")
                Text(text = stringResource(id = R.string.stop))
            }
            if (clickCount > 0) {
                Log.d("JHTEST", "### clickCount > 0")
                DisposableEffect(clickCount) {
                    println("init: clickCount is $clickCount")
                    onDispose {
                        println("dispose: clickCount is $clickCount")
                    }
                }
                LaunchedEffect(clickCount) {
                    Log.d("JHTEST", "### LaunchedEffect ")
                    counter = 0
                    while (isActive) {
                        Log.d("JHTEST", "### LaunchedEffect isActive")
                        counter += 1
                        delay(1000)
                        Log.d("JHTEST", "### LaunchedEffect delay end")
                    }
                }
            }
        }
        Text(
            text = "$counter",
            style = MaterialTheme.typography.h3
        )
        Log.d("JHTEST", "### Column End ")
    }
}
  • LaunchedEffect()가 구성단계에 진입하면(if(clickCount > 0)) 매개변수로 전달된 코드를 블룩하면서 코루틴을 실행
  • LaunchedEffect()가 함성 단계를 벗어나면(clickCount ≤ 0) 코루틴은 취소 될 것이다.
  • LaunchedEffect()는 비동기 작업을 손쉽게 시작하거나 재시작 할수 있게 해준다.
    • 관련 코루틴은 자동으로 정리된다.
  • init: 메시지는 clickCount가 변경될 때마다 출력
  • dispose:는 clickCount가 변경되거나 DisposableEffect()가 구성 단계를 벗어날 때 나타날 것이다.

DisposableEffect()는 블록의 최종문단으로 반드시 onDispose { }를 포함해야 한다.

  1. clickCount가 0보다 커지면 Effect API가 실행
  2. $counter가 변하면서 Text부분이 다시 그려지면서 Column부분이 실행된다.
  3. clickCount가 변하게 되면 기존 코루틴을 종료(onDispose가 불림)하고 새 코루틴실행
  4. stop시키면 clickCount가 0이되어 DisposableEffect와 LaunchedEffect쪽이 실행이 되지않아 Composable이 Dispose가 되는것 같다.

counter 세는 부분을 고정으로 해보았습니다.

Text(
    text = "noCounter",
    style = MaterialTheme.typography.h3
)

 

API 콜 해보기 예제

@Composable
fun LaunchedApi(viewModel: ConsultationSettingViewModel) {
    var currentValue by remember {
        mutableStateOf("")
    }
    viewModel.consultationMessage.observe(this@ConsultationSettingActivity) {
        ExLog.d(TAG, "COMPOSE consultationMessage : $it")
        currentValue = it
    }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = {
            ExLog.d(TAG, "COMPOSE Button onClick")
            viewModel.getConsultSetting()
        }) {
            Text(
                text = "API 콜!!!"
            )
        }
        DisposableEffect(key1 = Unit, effect = {
            println("COMPOSE ### init: DisposableEffect")
            onDispose {
                println("COMPOSE ### dispose: DisposableEffect")
            }
        })
        LaunchedEffect(key1 = Unit, block = {
            viewModel.getComposeConsultSetting()
        })
        Text(
            text = "currentValue : $currentValue",
            style = MaterialTheme.typography.h3
        )
    }
}

 

참조 : 젯팩 컴포즈로 개발하는 안드로이드 UI, 사내 스터디

반응형