What is Compose
Compose는 Google에서 만든 UI툴킷으로 개발을 간소화 및 가속화가 가능하며,
적은 수의 코드와 다양한 도구 및 Kotlin API를 사용하여 UI 개발이 가능합니다.
안드로이드의 Jetpack Compose
많은 분들은 안드로이드의 Jetpack Compose 이야기만 들어보셨을 것 이라 생각됩니다.
지난 21년 말, GDG Korea Android 에서도 Jetpack Compose를 사용해보도록 4주간 개발 및 질문하는 소규모 캠프(?)를 여는 등
최근 안드로이드 UI 개발의 패러다임은 기존 XML 방식에서 Jetpack Compose로 바뀌어 나가고 있습니다.
저 역시 지난 1월부터 XML 방식의 UI 개발에서 Jetpack Compose로 넘어와 개발하고 있습니다.
멀티플랫폼 지원
사실 Compose는 안드로이드에만 국한된 것이 아닌, 다양한 Multiplatform으로 개발이 가능합니다.
Desktop App으로는 Windows, macOS, Linux를 지원하며 Web 역시 지원하고 있습니다.
Web은 어떨까?
Jetpack Compose를 공부하던 중 Compose for Web으로 한번 개발하면 재밌지 않을까하여 이전에 만들었던 무료 웹 음악플레이어와 유사한 느낌으로 간단한 사이트를 만들어보았습니다.
새로 만드는 김에 API도 기존 PHP에서 스프링부트로 다시 만들었습니다.
프로젝트 구조
정확한 가이드라인이 제시된 도큐멘트가 없어 우선 안드로이드 개발을 할 때와 유사하게 프로젝트 구성을 하였습니다.
기본적으로 페이지는 screens/{name}별 분리를 구상하였으며 공통 UI 컴포넌트는 ui/components 아래에, 특정 화면에 종속되는 화면은 공통 컴포넌트를 상속 또는 새로 만들어 각각의 컴포넌트로 분리하였습니다. 추가적으로 Compose 관련 Extension은 supports/compose에, API 통신은 repository 안에 배치하였습니다.
Screen Tree
화면 구성을 Tree로 보면 다음과 같습니다.
Main
---HomeScreen
------HomeHeader
------HomeSidebar
------HomeMain
---------HomeHotMusic
---------HomeLargeBanner
화면 그리기 - Composable
Compose for Web에서는 Jetpack Compose와 유사하지만 무언가 많이 다릅니다.
기본적으로 Row, Column, Box 등 기존에 알던 개념이 아닌 웹 태그를 그대로 사용합니다.
각 태그는 @Composable 함수로 구현이 되어있어 H1 { }, Div { }, Tbody { } 등 정의돼있는 함수를 선언만 하면 됩니다.
attrs에 style, event 등 정의하며, content에 자식 노드를 담습니다.
Div(attrs = {
style {
color(ColorWhite)
fontSize(0.8.em + 0.8.vw)
}
}) {
Text("당신이 찾고있는")
Br()
Text("당신이 원하는 음악")
}
예시로 입력할 때 마다 onInput 이벤트로 넘어온 입력받은 값을 넘길 때에는 다음과 같습니다.
@Composable
fun SearchField(text: String = "", placeholder: String = "", onInput: (text: String) -> Unit) {
... 생략 ...
Input(
type = InputType.Search,
attrs = {
style {
padding(10.px, 0.px, 10.px, 0.px)
border(0.px)
placeholder(placeholder)
color(ColorWhite)
background("none")
}
onInput {
onInput(it.value) //
}
}
)
... 생략 ...
정의되지 않은 property
다만 생각보다 없는 property들이 많았습니다. 그런 경우 직접 propery 함수로 name, value를 넣으면 적용이 가능합니다.
예시로 Border 관련하여 모든 스타일을 제공하고 있지 않아 Extension을 새로 만들어 사용하였습니다.
// Border.kt
fun StyleBuilder.borderTop(width: CSSNumeric, lineStyle: LineStyle, color: CSSColorValue) {
property("border-top", "$width $lineStyle $color")
}
fun StyleBuilder.borderRight(width: CSSNumeric, lineStyle: LineStyle, color: CSSColorValue) {
property("border-right", "$width $lineStyle $color")
}
fun StyleBuilder.borderBottom(width: CSSNumeric, lineStyle: LineStyle, color: CSSColorValue) {
property("border-bottom", "$width $lineStyle $color")
}
fun StyleBuilder.borderLeft(width: CSSNumeric, lineStyle: LineStyle, color: CSSColorValue) {
property("border-left", "$width $lineStyle $color")
}
// 사용 예시
@Composable
fun HomeSidebar() {
Div(attrs = {
style {
display(DisplayStyle.Flex)
position(Position.Fixed)
flexDirection(FlexDirection.Column)
top(0.px)
width(280.px)
height(100.vh)
backgroundColor(ColorPrimary)
borderRight(1.px, LineStyle.Solid, ColorBorder)
borderBottom(1.px, LineStyle.Solid, ColorBorder)
}
}) {
SidebarLogo(content = {
Text("Jetpack Compose")
})
SidebarList {
SidebarItem("Home", true)
SidebarItem("Chart", false)
SidebarItem("Notice", false)
}
}
}
FontAwesome
웹 개발을 하다보면 FontAwesome을 많이 애용합니다. 컴포즈에서는 어떻게 쓸지 고민하다 따로 함수를 만들어 사용하였습니다.
// FontAwesome.kt
@Composable
fun FontAwesome(
vararg classes: String,
attrs: AttrBuilderContext<HTMLElement>,
content: ContentBuilder<HTMLElement>? = null
) {
I(
attrs = {
classes(*classes)
attrs()
},
content = content
)
}
// 사용 예시
FontAwesome("fas", "fa-search", attrs = {
style {
color(ColorWhiteGray)
}
})
이러쿵 저러쿵 기본 화면 개발 끝
개발 일기가 아닌 전체적인 후기이기에 많은 내용을 스킵하였지만, 뼈대 화면이 완성되었습니다
이제 스프링부트로 만들어둔 API를 연결하여 차트를 보여주면 기본 작업이 완료가 됩니다!
(상세 코드를 원하신다면 포스팅 마지막에 안내된 Github 레포지토리에서 참고 하시면 됩니다🤟)
RESTful API을 위한 험난한 여정
사용이 불가능한 라이브러리
Srping Boot로 각 멜론/지니/벅스 음악 스트리밍 실시간 차트를 파싱한 API를 만들고 이를 Compose Web에서 사용하려 하였습니다.
처음 생각한 방식은 Retrofit2를 사용하여 깔끔한(?) 방식으로 구현하려 하였습니다. 그런데... 어떤 방법을 사용하더라도 import가 불가능하여 적용이 안되더군요. 2시간동안 열심히 구글링을 해보았지만, 결국 실패하였습니다.
XMLHttpRequest를 통한 API 통신, 다만...
그래도 API 통신을 위해 내장된 네트워킹 관련 기능이 있겠지하고 열심히 A, B, C ... 입력해가며 자동완성을 찾아보았습니다. 한 시간동안 찾고 찾다 Wrapping된 XMLHttpRequest를 발견하여 '그래 이거라도 있으면 구현되겠다' 생각하고 사용해보았습니다.
정상적으로 response를 받아옴을 확인하여 HttpRequest 클래스를 만들어 suspendCoroutine 및 T인자를 받아 JSON.parse를 하도록 Wrapping하고 각 HTTP Method에 따라 함수를 만들었습니다.
class HttpRequest {
enum class HttpRequestType {
GET,
POST,
HEAD,
PUT,
DELETE,
CONNECT,
OPTIONS,
PATCH
}
suspend fun <T> requestGet(url: String) = request<T>(HttpRequestType.GET, url)
suspend fun <T> requestPost(url: String) = request<T>(HttpRequestType.POST, url)
suspend fun <T> requestHead(url: String) = request<T>(HttpRequestType.HEAD, url)
suspend fun <T> requestPut(url: String) = request<T>(HttpRequestType.PUT, url)
suspend fun <T> requestDelete(url: String) = request<T>(HttpRequestType.DELETE, url)
suspend fun <T> requestConnect(url: String) = request<T>(HttpRequestType.CONNECT, url)
suspend fun <T> requestOptions(url: String) = request<T>(HttpRequestType.OPTIONS, url)
suspend fun <T> requestPatch(url: String) = request<T>(HttpRequestType.PATCH, url)
suspend fun <T> request(httpRequestType: HttpRequestType, url: String) = suspendCoroutine<T> { continuation ->
XMLHttpRequest().let { request ->
request.open(httpRequestType.name, url, true)
request.send()
request.onloadend = {
JSON.parse<T>(request.responseText).let { response ->
continuation.resume(response)
}
}
}
}
}
ChartRepository를 상속받고 있는 ChartRepositoryImpl에서 해당 HttpRequest를 통해 API 통신을 하도록 하였습니다.
class ChartRepositoryImpl : ChartRepository {
private val httpRequest = HttpRequest()
override suspend fun getMelonChart(): List<Song> = httpRequest.requestGet("http://localhost:8080/chart/melon")
override suspend fun getBugsChart(): List<Song> = httpRequest.requestGet("http://localhost:8080/chart/bugs")
override suspend fun getGenieChart(): List<Song> = httpRequest.requestGet("http://localhost:8080/chart/genie")
}
테스트를 위해 실행해본 결과, 정상적으로 Song 객체로 변환되어 리스트가 뿌려지고 있음을 확인하였습니다.
이제 Compose 화면에서 데이터를 가져와 뿌려주는 작업이 남았습니다.
이상하게 LaunchedEffect를 통해 value를 emit하는 경우 반응을 하지 않아 coroutineScope로 처리하였습니다.
chartRepository의 getMelonChart()를 통해 chart를 받아오면 songList.collectAsState()에 의해 차트 리스트가 갱신될 것이라 생각하였습니다 아니 되어야만 합니다.
@Composable
fun HomeHotMusic(chartRepository: ChartRepository) {
val coroutineScope = rememberCoroutineScope()
val songList = MutableStateFlow<Array<Song>>(emptyArray())
Section(attrs = {
style {
display(DisplayStyle.Flex)
flexDirection(FlexDirection.Column)
paddingTop(50.px)
}
}) {
HomeHotMusicTitle()
HomeHotMusicList {
songList.collectAsState().value.forEach {
HomeHotMusicItem(it)
}
}
}
coroutineScope.launch {
chartRepository.getMelonChart().let { chart ->
songList.value = chart
}
}
}
그런데...
원인을 알 수 없는 오류가 발생하여 이리저리 고쳐보고 나올리 없는 구글링도 계속 해보았지만 소용이 없었습니다.
1시간 정도 이리저리 고치다보니 드디어 원인 아닌 원인을 알아냈습니다.
coroutineScope.launch {
chartRepository.getMelonChart().let { chart ->
console.log(chart) // ok
console.log(chart[0]) // error
console.log(chart[1]) // error
songList.value = chart
}
}
responseText를 JSON.parse로 나온 객체를 console.log를 찍으면 정상적으로 해당 리스트가 출력되지만, 해당 객체의 n번째 인덱스 원소에 접근하는 경우 Exception이 발생합니다.
이후 오랜 시간에 걸쳐 여러 방법을 시도해보았지만 결국 실패하였습니다.
일단 야매로 돌아가게만 하자...
우선 차트 기능이라도 구현을 해야 마음이 풀릴 것 같아 아름답지는 않지만 responseText의 통짜 String을 split하여 title, artist, thumbnail을 가져와 Song 객체를 만들어 넣고, 이를 리스트에 추가하여 반환하도록 구현하였습니다.
suspend fun requestForSongList(url: String) =
suspendCoroutine<List<Song>> { continuation ->
XMLHttpRequest().let { request ->
request.open(HttpRequestType.GET.name, url, true)
request.send()
request.onloadend = {
val songs = ArrayList<Song>(emptyList())
request.responseText.let { response ->
for (i in 1..50) {
val title = response.split("title\":\"")[i].split("\"")[0]
val artist = response.split("artist\":\"")[i].split("\"")[0]
val thumbnail = response.split("thumbnail\":\"")[i].split("\"")[0]
val song = Song(title, artist, thumbnail)
songs.add(song)
}
}
continuation.resume(songs)
}
}
}
두근거리는 마음으로 실행을 해보았고, 그 결과 정상적으로 멜론 실시간 차트가 화면에 표시되었습니다.
추가적으로 발견한 사실은 List 타입을 Array로 바꾸니 n번째 인덱스에는 정상 접근되지만, property를 가져오면 undefined가 나오는 것을 보고 Compose Web은 잠정 중단을 생각하게 되었습니다...
하지만 코틀린으로 만드는 웹 감성에 다시 시도할 것 같네요 흐흐
기존에는 웹 음악플레이어를 Compose로 다시 만들 계획이였지만, 아직은 아쉬운점이 많아 다시 Vue나 React로 개발을 고려해봐야겠네요... 그렇지만 차후 포럼이 활성화되고 기능이 강화/보완된다면 다시 사용할 의지가 매우 높습니다.
결과적으로 느낀점은...
- 기존 Jetpack Compose의 Row/Column/Lazy 이런 개념 없이 순수 태그 제공 -> 그냥 HTML Tag를 Wrapping한 느낌
- document/article이 없기에 구글링을 전혀 할 필요가 없다 (와!)
- 핫 리로드가 없기에 (빌드 - 실행 - 빌드 - 실행)의 반복...
- 무언가 Wrapping이 되다만 느낌... 생각보다 없는 property가 많아 매번 extension을 만들어 사용해야함
- JS Event Wrapping은 생각보다 잘 돼있어서 좋다.
- 놀랍게도 Coroutine Flow로 개발이 가능하다. Dispatchers 변경도 가능하다 (우와!)
아직 Compose for Web은 아쉬운점이 많지만, Desktop 부분에 있어서는 활발한 개발이 이뤄지고 있는 것 같습니다.
차후 web에도 Material UI와 Column/Row/Box 등의 개념이 도입된다면, 정말 통일된 소스코드 하나의 Compose로 모든 플랫폼 UI 개발이 가능하지 않을까 싶습니다.
Compose Music 소스코드 참고 시, 로컬서버 API로 맞추어져 있기에 HomeHotMusic.kt내 songList를 dummyMusicList로 치환해주시면 API 연동 전의 뼈대 화면을 만나보실 수 있습니다!
'개발 > 개발 일기' 카테고리의 다른 글
NamingFox - 변수, 함수 이름 짓는 플러그인 개발기1 (0) | 2023.02.07 |
---|---|
Compose Web - M1 맥 node gradle 오류 (0) | 2022.08.26 |
xcode 10 beta - iOS 12 빌드하기 (Could not locate device support files 해결하기) (1) | 2018.06.20 |
무료 웹 음악 플레이어 Silire 개발일기 - 7 (7) | 2018.05.20 |
무료 웹 음악 플레이어 Silire 개발일기 - 6 (0) | 2018.05.19 |
상상하는 것을 소프트웨어로 구현하는 것을 좋아하는 청년
게시글이 마음에 드시나요? [ 공감❤️ ] 눌러주시면 큰 힘이 됩니다!