Compose for Desktop 코드 분석

이번 포스팅에선 이전에 작성한 Counter 앱의 코드를 분석해보며 Compose 에 대해 공부해보도록 하겠습니다.

@Composable
fun MainWindow(state: MainWindowState)

Compose 를 사용하려면 데이터를 UI 요소로 변환하는 Composable 함수를 정의해야합니다. Composable 함수는 @Composable 어노테이션을 지정하여 정의할 수 있습니다. Composable 함수는 매우 간단한 LifeCycle 을 가지고 있습니다. 초기 컴포지션과 리컴포지션 두가지입니다.

Compose는 초기 컴포지션 시 처음으로 컴포저블을 실행할 때 컴포지션에서 UI를 기술하기 위해 호출하는 컴포저블을 추적합니다. 그런 다음 앱 상태가 변경되면 Compose는 리컴포지션을 예약합니다. 리컴포지션은 Compose가 상태 변경사항에 따라 변경될 수 있는 컴포저블을 다시 실행한 다음 변경사항을 반영하도록 컴포지션을 업데이트하는 것입니다.

컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트될 수 있습니다. 컴포지션을 수정하는 유일한 방법은 리컴포지션을 통하는 것입니다.

새로운 용어들이 나와서 혼란스럽습니다. 우선 예제를 보도록 하겠습니다.

@Composable
fun BadCounter() {
    println("BadCounter: Start")
    var count = 0
    println("BadCounter: count initialize : $count")
    Button(
        onClick = {
            println("BadCounter: count onClick (count: $count)")
            count++
            println("BadCounter: count update (count: $count)")
        }
    ) {
        Text("$count")
    }
}

/* output:
 * BadCounter: Start
 * BadCounter: count initialize : 0
 * BadCounter: count onClick (count: 0)
 * BadCounter: count update (count: 1)
 * BadCounter: count onClick (count: 1)
 * BadCounter: count update (count: 2)
 * BadCounter: count onClick (count: 2)
 * BadCounter: count update (count: 3)
**/

해당 코드는 컴포지션의 이해를 돕기 위해 잘못 작성된 Counter 의 예제입니다.
초기 컴포지션 시 Start 가 호출되고 0으로 초기화가 됩니다. 버튼을 클릭해보면 콘솔에 찍히는 count는 변하지만 UI가 변경되지 않는것을 알 수 있습니다.

이는 Composable 함수가 리컴포지션을 수행하지 못했기 때문입니다. 일반적으로 리컴포지션은 State 객체가 변경이 되면 트리거가 됩니다. 이 예제에서 사용된 count 변수는 일반 Int 형 변수이기 때문에 앞서 말했듯이 컴포지션을 수정하는 유일한 방법인 리컴포지션이 수행되지 않아 UI 가 업데이트 되지 않았습니다. 이제 리컴포지션이 수행될 수 있도록 코드를 수정해보겠습니다.

@Composable
fun BadCounter() {
    println("BadCounter: Start")
    var count by mutableStateOf(0)
    println("BadCounter: count initialize : $count")
    Button(
        onClick = {
            println("BadCounter: count onClick (count: $count)")
            count++
            println("BadCounter: count update (count: $count)")
        }
    ) {
        Text("$count")
    }
}
/*
 * BadCounter: Start
 * BadCounter: count initialize : 0
 * BadCounter: count onClick (count: 0)
 * BadCounter: count update (count: 1)
 * BadCounter: Start
 * BadCounter: count initialize : 0
 * BadCounter: count onClick (count: 0)
 * BadCounter: count update (count: 1)
 * BadCounter: Start
 * BadCounter: count initialize : 0
**/ 

State 객체는 mutableStateOf 함수를 이용하여 정의할 수 있습니다. 저는 by 델리게이트를 사용했지만 State는 세가지 표현식으로 사용할 수 있습니다.


  • val state = mutableStateOf(default)
  • var value by mutableStateOf(default)
  • val (value, setValue) = mutableStateOf(default)

첫번째는 state.value 를 변경을 해서 리컴포지션을 일으킬 수 있습니다. Flutter의 GetX에서 .obs 객체과 비슷하네요.
두번째는 value 값을 바꾸면 적용됩니다. var 로 선언 한게 포인트네요.
세번째는 리액트의 hook 과 비슷합니다. value 값과 그 value를 바꾸는 setValue 함수를 리턴합니다.


저는 onClick 함수에서 count++ 를 통해 값을 1씩 더했으므로 리컴포지션이 일어났고, 로그창에 "BadCounter: Start" 호출되는것을 알 수 있습니다. 하지만 실제 실행을 해보면 UI 에 업데이트는 되지 않는데 리컴포지션이 일어날 때마다 count 가 0으로 다시 초기화가 되서 UI 에 업데이트가 되지 않는것처럼 보이는것입니다. 코드를 완성해보겠습니다.

@Composable
fun Counter() {
    println("Counter: Start")
    var count by remember { mutableStateOf(0) }
    println("Counter: count initialize : $count")
    Button(
        onClick = {
            println("Counter: count onClick (count: $count)")
            count++
            println("Counter: count update (count: $count)")
        }
    ) {
        Text("$count")
    }
}
/*
 * Counter: Start
 * Counter: count initialize : 0
 * Counter: count onClick (count: 0)
 * Counter: count update (count: 1)
 * Counter: Start
 * Counter: count initialize : 1
 * Counter: count onClick (count: 1)
 * Counter: count update (count: 2)
 * Counter: Start
 * Counter: count initialize : 2
**/

remember 함수가 등장했습니다. remember 함수를 완전히 이해하려면 내용이 많아 다음에 기회가 있으면 포스팅하겠습니다. 지금은 초기 컴포지션 때 변수를 기억하고 리컴포지션 때 사용 할 수 있게 해준다고 생각하면 됩니다. 로그를 보시면 count 가 업데이트 된 후 리컴포지션이 일어났을 때(Start 호출) 0으로 초기화가 되지 않고 1로 상태가 유지되는것을 알 수 있습니다. @Stable 개념과 Composable 함수를 작성할 때 주의해야할 점은 다음 기회에 포스팅하겠습니다.

fun main() = Window {
    MainWindow(rememberMainWindowState())
}

@Composable
fun rememberMainWindowState() = remember {
    MainWindowState()
}

class MainWindowState {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increase() {
        _count.value++
    }
}

Window 객체는 상태가 매우 복잡해질 수 있습니다. 그 모두를 Composable 함수안에서 관리를 하는것은 매우 어렵습니다. MainWindowState 를 정의하여 윈도우의 상태를 정의하고, MainWindow Composable 함수에서 상태를 받도록 하면 유지보수와 테스트가 용이한 코드를 작성할 수 있습니다.


Compose for Desktop 은 Desktop Java 기반으로 구동되기 때문에 안드로이드에서 사용하는 LiveData 같은 라이브러리를 사용할 수 없습니다. 다행히도 코틀린에 StateFlow와 SharedFlow 가 추가가 되면서 안드로이드와 데스크탑이 동일한 코드를 작성할 수 있게 되었습니다. 이 객체를 State 객체로 변환시키는 법은 .collectAsState() 메서드를 이용하면 됩니다.

State Flow란?

안드로이드의 LiveData 와 매우 흡사한 홀더 클래스입니다. 기본적으로 최신 상태를 전달 받을 수 있습니다 (Hot 성질)
LiveData 와의 차이점은 StateFlow 는 기본값을 가져야만 하며, 안드로이드가 아닌 Kotlin 라이브러리이기 때문에 안드로이드 라이프사이클이 얽혀있을 때 동작이 조금 다릅니다. 이후에 포스팅할 기회가 있다면 작성하겠습니다.


나머지 코드들은 UI의 크기를 설정하거나 정렬방법 설정하기처럼 읽어도 어떤것인지 알 수 있는 코드들이라 넘어가도록 하겟습니다.

개인적으로는 컴파일러가 관여하여 코드가 변환되는 프레임워크들은 학습 난이도가 높다고 생각합니다. 하지만 Compose는 매우매우 단순한 라이프사이클로 다른 선언형 UI 프레임워크들을 접하신 분들이면 쉽게 적응하실 수 있을 것 같습니다. 또 안드로이드의 Jetpack Compose 는 다른 Jetpack 라이브러리들과 매우 뛰어난 호환성을 가지고 있기에 매우 기대가 됩니다.

좋은 웹페이지 즐겨찾기