본문으로 바로가기

[Compose] Desktop application 개발

category 개발이야기/Kotlin 2022. 2. 18. 15:31
반응형

Photo by unsplash

Compose는 android에서 사용되는 선언형 UI Framework입니다. 이는 Kotlin으로만 작성되는데, Compose multiplatform을 이용하면 Desktop application의 UI를 compose를 사용하여 구현할 수 있습니다. [1]

Android Jetpack Compose의 개념을 도입하여 구현하기 때문에 Android, Desktop, Web app에 동일한 코드를 공유할 수 있다는 장점이 있습니다.

게다가 JVM 위에서 실행되기 때문에 Mac, Window, Linux에서 모두 동작이 가능합니다. Compose 하나로 desktop의 UI를 개발할 수 있다는 어마어마한 장점을 갖기에 모바일 개발자가 부담 없이 Desktop app이라는 영역에 한발 다가갈 수 있습니다.

이전에 kotlin으로 서버를 구성할 수 있도록 도와주는 ktor에 대해서 포스팅한 적이 있습니다. 이때 HTTP API server를 ktor로 구성하고, android에서 ktor client를 구현하여 서로 통신하는 예제를 만들었습니다. 이번에는 그때 만든 HTTP API server (Restful api server)는 그대로 사용하고, client를 android가 아닌 desktop application로 구현해 보도록 하겠습니다.

서버 명세 및 최종 결과물

http의 GET api 호출 시 사진의 "제목, 장소, 이미지 주소"을 제공하는 서버를 구성합니다. [2] 서버의 구성 방법은 references에 명시한 ktor 서버 관련 글을 읽고 오셔도 되지만, 여기서 서버의 구축은 중요하지 않습니다. 그냥 아래와 같은 기능을 제공하는 서버의 명세가 있다고 가정합니다.

  • Method: GET
  • Request url: http://192.168.0.6/picture?id=0
  • Result json(sample):
{
    "title":"falls",
    "location":"No address!!",
    "imageUrl":"http://192.168.0.6/nature/nature1.jpg"
}

기존 ktor client의 예제에서는 Android에서 아래와 같은 화면을 표기했습니다. [3]

이번에는 이 화면을 Desktop에서 아래와 같은 client application을 작성해 보도록 하겠습니다.

Project의 생성

Desktop application은 Intellij에서 프로젝트를 생성할 수 있습니다. Ultimate나 CE 버전 모두 가능하므로 무료 버전인 CE버전을 다운받아서 사용 가능합니다.

1. 프로젝트를 생성합니다.

2. 왼쪽에서 Kotlin을 선택하고 상단에 application의 Name, Location을 넣습니다.

이때 Project TemplateCompose Desktop Application을 선택합니다.

Proejct JDK는 11 이상을 설치해야 합니다. 제가 가지고 있는 버전은 최신 버전 17 이므로 해당 버전을 선택했습니다.

"Next"를 눌러 다음으로 넘어갑니다.

3. JVM 버전 선택

여기서는 Target JVM version을 명시하면 됩니다. JDK는 17을 사용하지만 여기서는 target을 아직 16까지밖에 지원하지 않습니다. 따라서 16을 선택하겠습니다.

여기서 추후 자동 생성되는 gradle 파일에서 JDK 8이나, JDK 17을 사용하도록 Target을 수동으로 변경할 수는 있으나, 변경 후 compile을 하면 지원하지 않는 버전이라는 설명과 함께 compile error가 납니다.

"Finish"를 누르면 프로젝트가 생성됩니다.

이제 프로젝트가 생성되며, 가장 먼저 봐야 할 건 build.gradle.kts입니다.

build.gradle.kts의 구성

Android에서 보던 것과 매우 유사합니다. 따라서 생소하기보다는 친근감이 먼저 드네요.

최상단의 plugins는 kotlin 버전과 compose 버전을 최신으로 변경해 줍니다. 프로젝트 생성 시 담긴 값은 kotlin 1.5.31, compose 1.0.0으로 되어 있었습니다만, 최신 버전으로 변경했습니다.

compose 버전은 안드로이드와 다릅니다. 따라서 따로 최신 버전을 확인한 후 사용해야 합니다. 현재(2022.02.18) android의 compose 최신(안정화??) 버전은 1.1.0-rc3이나, jetbrains의 compose 최신 버전은 1.0.1입니다. [4]

결정된 jvm target은 16이며, main 함수가 있는 위치는 MainKt로 지정되어 있습니다.

Main.kt

class 내부에는 아래와 같이 구현되어 있습니다.

간단하게 "Hello, Desktop"이라는 Button을 만들고, 클릭 시 "Hello, World!"로 변경하도록 되어 있습니다. android와 똑같네요.

android에서 처럼 preview를 보려면 아래와 같이 plugin을 추가해야 합니다.

"File -> Settings"로 진입합니다.

"Compose Multiplatform Support"를 install 합니다. 저는 이미 추가했기 때문에 바로 보이지만 처음 한다면 상단의 "Marketplace" tab에서 검색하여 install 할 수 있습니다.

main.kt 파일의 @Preview 함수의 왼쪽에 preview 아이콘이 생깁니다. 클릭하면 안드로이드처럼 오른쪽에서 미리보기로 화면을 볼 수 있습니다. 

다만, 저는 아래와 같이 에러가 뜨면서 미리보기에 실패했습니다. URL Encoder를 사용하는 부분은 없기에 plugin bug로 보입니다. 하지만 mac에서는 또 정상적으로 보이기 때문에 intellij plugin의 오류로 확신하고 일단 넘어가겠습니다.. (window에서만 문제가 있는것 같네요..)

(어차피 미리보기도 compile 하는 만큼 시간이 걸리고 느립니다...)

main() 함수를 실행하면 아래와 같은 application이 desktop에서 딱 뜨게 됩니다.

사실 여기까지 프로젝트 생성에 성공했다면 이미 desktop applciation 만들기는 끝난 것과 다름없습니다. 해당 버튼을 지우고 본인이 원하는 UI로 채워 넣으면 되기 때문입니다. 하지만 약간씩은, 그리고 조금씩은 android와 차이가 있으니, 위에서 언급한 예제를 android 버전과 비교해 가면서 차이점을 보도록 하겠습니다.

Desktop Http client application의 생성

Android에서 http api를 사용하고 위해서 ktor-client를 사용했습니다. 여기서도 동일하게 사용할 예정이므로 필요한 library를 build.gradle.kts에 추가합니다.

http의 응답으로 json이 내려옵니다. 따라서 이를 class 형태로 mapping 시켜 받기 위해서 kotlinx.serialization을 사용하도록 plugins에 추가했습니다. plugins에 추가해 주고, 필요한 library인 ktor-client관련 코드를 import 해 줍니다.

dependency 추가가 끝나면  network access시 사용할 http client를 아래와 같이 ktor-client를 이용하여 만듭니다.

//KtorHttpClient.kt
class KtorHttpClient {   
    val client: HttpClient

    private val json = Json {
        ignoreUnknownKeys = true // 모델에 없고, json에 있는경우 해당 key 무시
        prettyPrint = true
        isLenient = true // "" 따옴표 잘못된건 무시하고 처리
        encodeDefaults = true //null 인 값도 json에 포함 시킨다.
    }

    init {
        client = HttpClient(CIO) {
            // Header 또는 기본값
            defaultRequest {
                header("Accept", "application/json")
                header("Content-type", "application/json")
            }

            // Json serializer
            install(JsonFeature) {
                serializer = KotlinxSerializer(json)
            }

            // Timeout
            install(HttpTimeout) {
                requestTimeoutMillis = 15_000L
                connectTimeoutMillis = 15_000L
                socketTimeoutMillis = 15_000L
            }

            //Logging
            install(Logging) {
                logger = Logger.DEFAULT
                level = LogLevel.ALL
            }
        }
    }
}

이는 android에서 사용했던 client 코드와 동일합니다. 다 동일한 kotlin을 사용하기 때문에 서로 코드를 가져와 쓸 수 있습니다. (이 코드 역시 android에서 사용했던 예제에서 class째 복사해서 가져왔습니다.) 상세한 설명과 Gson, Jackson을 사용하는 등의 추가적인 방법은 기존 ktor client 포스팅에서 확인할 수 있습니다. [2]

추가적으로 GET api 요청 시 전달받는 json과 mapping 되는 data class를 아래와 같이 추가합니다. 이 역시 이전 android 예제에서 사용하던 class 그대로 가져왔습니다 [2]

//Picture.kt
@Serializable
data class Picture(
    val title: String,
    val location: String,
    val imageUrl: String
)

화면을 구성하기 위한 composable function을 만듭니다.

@Composable
fun RestFulTestScreen(data: Picture, 
                      imageBitmap: ImageBitmap?,
                      onLoadClick: () -> Unit) {
    Box(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.align(Alignment.Center).padding(20.dp)) {
            Image(
                bitmap = imageBitmap ?: ImageBitmap(300, 300),
                contentDescription = "",
                modifier = Modifier.size(300.dp).border(1.dp, Color.Gray)
                                   .align(Alignment.CenterHorizontally),
                contentScale = ContentScale.Crop
            )
            Divider(Modifier.padding(10.dp))
            Text("Title: ${data.title}")
            Text("Location: ${data.location}")

            Divider(Modifier.padding(10.dp))
            Button(modifier = Modifier.align(Alignment.CenterHorizontally),
                onClick = onLoadClick) {
                Text("Load Data")
            }
        }
    }
}

이 코드는 아까 위에서 봤던 아래 그림을 구성하는 코드입니다.

Image를 보여주고 하단에 title과 location 정보를 보여 줍니다. 그 아래 버튼을 하나 위치시켜 버튼을 클릭하면 서버에 데이터를 요청하여 받아옵니다. 기본적으로 android와 동일한 composable 함수이지만 이미지 로딩 부분에서 차이가 있습니다. 기존 android에서 썼던 코드와 비교해 보면 아래와 같습니다.

// Android composable function
@ExperimentalCoilApi
@Composable
fun RestFulTestScreen(data: Picture) {    
    Box(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.align(Alignment.Center).padding(20.dp)) {
            Image(
                painter = rememberImagePainter(data.imageUrl),
            ...
            
            

// Desktop composable function
@Composable
fun RestFulTestScreen(data: Picture, imageBitmap: ImageBitmap?, onLoadClick: () -> Unit) {
    Box(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.align(Alignment.Center).padding(20.dp)) {
            Image(
                bitmap = imageBitmap ?: ImageBitmap(300, 300),
            ...

Android에서는 서버로 전달받은 image url을 바로 coil로 던져주고 이미지를 로딩시킵니다. 따라서 Image 함수의 painter에 coil에서 제공하는 remeberImagePainter를 넣는 것만으로 이미지가 비동기로 로딩됩니다.

이는 coil이 compose를 공식적으로 지원하기 때문인데, 문제는 coil이 android에서만 compose를 지원하기 때문에 desktop application에서는 쓸 수가 없습니다. (gradle에서 coil compose의 dependency 추가 시 gradle sync에서부터 실패가 납니다.)

따라서 고전적인 방법을 사용하여 이미지를 로딩하거나, 다른 방법을 사용해야 합니다. 여기서는 ktor-client를 이용하여 image의 byte를 받아오고 compose에서 사용 가능한 bitmap으로 변환시켜 사용하는 방법을 사용했습니다.

안드로이드였다면 url, title, location 정보 모두를 담고 있는 picture 객체 하나만 param으로 받아왔다면, desktop composable function에서는 로딩된 image bitmap을 받는 param을 추가해야 합니다 

> fun RestFultestScreen(data: Picture, imageBitmap: ImageBitmap?,...)

Data Loader

Android에서는 MVVM pattern을 이용하여 구조를 잡았기 때문에 ViewModel과 repository가 있었습니다. 하지만 Desktop에서는 해당 pattern을 사용하지 않기 때문에 이들의 역할을 대신할 수 있는 DataLoader.kt class를 하나 추가합니다.

class DataLoader {
    companion object {
        const val BASE_URL = "http://192.168.0.6"
    }

    private val ktorHttpClient = KtorHttpClient()

   ...
   
    // ktor를 이용하여 image url을 가지고 이미지를 받아 bitmap 객체로 변환한다.
    private suspend fun loadPicture(url: String): ImageBitmap {
        val image = ktorHttpClient.client.get<ByteArray>(url)
        println("loadPicture()- url:$url | image.size:${image.size}")
        return Image.makeFromEncoded(image).toComposeImageBitmap()
    }

    // ktor로 서버에서 image url, title, location정보를 받아온다.
    @Throws
    private suspend fun getPictureByGet(id: Int) =
        ktorHttpClient.client.get<Picture>(BASE_URL + "/picture") {
            parameter("id", id)
        }
}

loadPicture() 함수는 image Url을 이용하여 해당 위치의 이미지를 다운로드하고 composabe에서 사용 가능한 ImageBitmap 객체로 반환합니다. (coil의 역할을 대신하는 함수입니다.)

getPictureByGet() 함수는 ktor client를 이용하여 사진의 정보를 요청하고 응답받은 json을 Picture 객체에 담아 반환합니다.

최종 코드는 아래와 같습니다.

class DataLoader {
    companion object {
        const val BASE_URL = "http://222.106.75.252"
    }

    private val ktorHttpClient = KtorHttpClient()

    //coroutien scope 생성
    private val job = SupervisorJob()
    private val coroutineScope = CoroutineScope(job + Dispatchers.Main)

    // 기본 사진 정보
    var pictureData by mutableStateOf(
        Picture(
            "Desktop Restful app",
            "somewhere...",
            "https://developer.android.com/images/brand/Android_Robot.png"
        )
    )

    // 사진의 image bitmap
    var loadedImage by mutableStateOf<ImageBitmap?>(null)

    // 사진 정보를 요청한다.
    fun loadDataFromServer(id: Int) {
        coroutineScope.launch {
            val resultData = withContext(Dispatchers.IO) {
                getPictureByGet(id)
            }
            println("loadDataFromServer() - resultData:$resultData")
            pictureData = resultData

            val image = withContext(Dispatchers.IO) {
                loadPicture(resultData.imageUrl)
            }
            loadedImage = image
        }
    }

    // ktor를 이용하여 image url을 가지고 이미지를 받아 bitmap 객체로 변환한다.
    private suspend fun loadPicture(url: String): ImageBitmap {
        val image = ktorHttpClient.client.get<ByteArray>(url)
        println("loadPicture()- url:$url | image.size:${image.size}")
        return Image.makeFromEncoded(image).toComposeImageBitmap()
    }

    // ktor로 서버에서 image url, title, location정보를 받아온다.
    @Throws
    private suspend fun getPictureByGet(id: Int) =
        ktorHttpClient.client.get<Picture>(BASE_URL + "/picture") {
            parameter("id", id)
        }
}

여기서는 ViewModel이나 Activity 같은 lifecycle을 갖지 않기 때문에 변수로 선언한 coroutineScope은 process와 생명주기가 같아집니다. 즉. application이 닫혀야만 해당 scope의 동작들이 cancel() 됩니다. 물론 필요하다면job.cancel()을 해주는 함수를 추가하고, 외부에서 호출해 주어야 합니다.

마지막으로 main.kt 함수를 아래와 같이 수정해 줍니다.

@Composable
@Preview
fun App() {    
    val dataLoader =  remember { DataLoader() }

    MaterialTheme {
        RestFulTestScreen(dataLoader.pictureData, dataLoader.loadedImage) {
            dataLoader.loadDataFromServer(0)
        }
    }
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication,
        title = "Load Picture - DeskTop Application",
        state = rememberWindowState(width = 500.dp, height = 800.dp)
    ) {
        App()
    }
}

DataLoader()를 remember로 만들어 recompose에 의해 반복적인 생성을 막고, RestFulTestScreendataLoader에 정의된 State 변수들을 넘겨줍니다.

Android와의 차이점.

이로서 안드로이드와 동일한 역할을 하는 Desktop application을 만들 수 있습니다. 위에서 언급되었던 것과 추가적인 차이점은 아래와 같습니다.

  • Lifecycle이 없다. Activity, Fragment, ViewModel 등, lifecycle과 맞물려 돌아가도록 하는 동작을 구성하는 것들이 많으나, 여기서는 그런 경계가 없습니다. 여기서 만든 coroutineScope은 process의 생명주기와 같으므로 필요에 따라 명시적으로 cancel()를 호출하여 작업을 중지시켜야 합니다.
  • Android만의 전용 library들이 있다. 단편적으로 coil을 예로 들었지만, 이런 것들은 Desktop에서는 사용할 수 없습니다. 
  • 새창 띄우기, 화면 크기 조정하기 등의 Android와의 다른 동작이 추가적으로 존재한다. 만약 버튼 클릭 시 또 다른 기능을 하는 새창을 띄워야 한다면 어떻게 해야 할까요? 안드로이드에서는 존재하지 않는 시나리오입니다. 따라서 이런 부분들은 추가적인 학습이 필요합니다. [6]

coil은 지원도 안 하면서?? import 되는 library들은 죄다 androidx.compose..입니다. 즉 기본은 Jetpack compose로 하지만, OS dependency가 존재할 수밖에 없습니다. [6] 

이와 더불어 Desktop에서는 Composable이 내부적으로 Swing을 사용하여 화면을 그리기 때문에 이에 swing과 호환성 있게 사용할 수도 있습니다. [6]

Main.kt 파일의 import

정말 배포할만한 Desktop application을 만든다면 많은 시간과 노력이 필요하겠지만 간단한 프로젝트를 진행할 때는 무리가 없을 것으로 판단됩니다. 특히나, android composable 코드를 그대로 가져올 수 있다는 건 너무나 큰 장점으로 작용할 것으로 보입니다.

사실 크게 본다면 Kotlin 기반으로 이 모든것들을 동작시킨다는점에서 새로운 기능, 새로운 platform에 대한 진입 장벽이 낮추아지는게 가장 큰 장점이라고 생각됩니다. (Kotlin과 Jetpact Compose를 multiplatfrom에서 지원하기 위하여 JetBrains에서 많은 노력들을 하고 있네요.)

iOS에서도 kotlin과 compose를 지원할 가능성이 있다는 얘기들도 들립니다.[7] 그렇다면 안드로이드 개발자에게는 손안대고 코푸는격이 될것같습니다. 앞으로 jetBrains의 생태계 구축 및 활성화를 기대해 봅니다.

References

[1] https://www.jetbrains.com/ko-kr/lp/compose-mpp/

[2] 2022.01.24 - [개발이야기/Spring & Ktor Framework] - [Ktor] HTTP API (Restful api) Server 생성 #2

[3] 2022.01.24 - [개발이야기/Spring & Ktor Framework] - [Ktor] HTTP API(Restful api) - Client for Android #3

[4] https://github.com/JetBrains/compose-jb/releases

[5] https://github.com/JetBrains/compose-jb/tree/master/tutorials/Getting_Started

[6] https://github.com/JetBrains/compose-jb/tree/master/tutorials/Window_API_new

[7] https://medium.com/@gz_k/one-kotlin-team-for-all-your-mobile-apps-318db0a212f4

반응형