오늘은 평소에 관심있었던 Android Jetpack Compose UI에 대해 Naver Deview에서 발표한 내용을 정리해보려고 해요. 남상혁
개발자님께서 발표하셨고 많이 개발자분들이 관심있어 하는 library인 만큼 저도 궁금한 내용이 많았습니다.
이 글에서는 발표 내용에만 집중했습니다. 따라서 코드에 대해 따로 설명하지 않는점 양해 부탁드립니다.
발표는 다음과 같이 진행했습니다.
당시 ABC Studio에서는 일본 배달앱인 Demae-can
을 renewal 하게 됩니다. 이때 Demae-can
은 React-Native로 되어 있었습니다. 그럼 이런 생각이 들 수 있습니다.
cross platform이 아니라 왜 Native로 변경했을까?
1번째로는 비교적 적은 Spec과 화면으로 구성되어 있었다고 합니다.
구체적으로는 Fragment 단위로 14개의 화면으로 구성되어 있었기에, 이정도면 Trouble Shooting 하면서 적용할 수 있지 않을까? 라는 생각이 컸다고 합니다.
2번째로는 팀 모두가 새로운 기술에 거부감이 없어서 가능했다고 하네요. 그래서 과감한 시도를 했다고 합니다.
3번째로는 안드로이드 main developer께서 이미 XML을 최소화하고 View의 onDraw를 이용하여 UI를 그리는 자체 framework을 사용하고 계셨기 때문에 Jetpack compose 개념 이해에 많은 도움을 받을 수 있었다고 합니다.
이제 남상혁 개발자님께서 중요하게 생각했던 Jetpack Compose의 개념입니다.
기존 native의 UI는 canvas에 View를 setting할 때 page1, 2, 3 중 어떤 걸 표시할지 명령하게 됩니다. 이때 명령은 page1 fragment를 스택에서 제거하고 page3 fragment를 표시하라! 와 같은 명령을 말합니다. 그리고 그 안의 component도 어떤 값이 들어갈지 명령하게 됩니다.
이때 Jetpack Compose
의 경우 앞서 말한 page1, 2, 3가 이미 전부 정의되어 있습니다. 단지 특정 상태에 따라 어떤 page가 보여질지 달라질 뿐입니다.
안드로이드 공식 홈페이지의 그림3
즉 보여질 widget은 이미 정의되어 있고 그중 어떤 구성요소가 보여져야 할지 state에 따라 표시되게 됩니다.
이때 기존 Native UI의 구성은
으로 되어있습니다.
선언형 Jetpack Compose의 구성은
으로 되어있습니다.
단순하게 있는 그대로 받아들이면, compose에서는 state를 적용한 결과가 곧 UI가 됩니다.
부가 설명
- 처음에는 비슷한 속도로 build 되었지만, Beta Version Up이후 Build 속도는 눈에 띄게 빨라졌습니다.
이부분은 Jetbrain에서 Jetpack Compose에 참여하면서 부터 가능했던 최적화의 결과로 생각하신다고 하네요
@Composable
fun MyView() {
val context = LocalContext.current
val webViewClient = WebViewClient()
AndroidView(
factory = {
WebView(context).apply {
this.webViewClient = webViewClient
this.loadUrl("file://android_asset/Test.html")
}
}
)
}
@Composable
fun MyView() {
LazyColumn(modifier = modifier.wrapContentSize(()) {
items(list) { item ->
ItemComponent(data = item)
}
}
}
/**
* 문자열 부분이나 padding, size 등 UI에 반영되는 부분을 수정하면 즉시 emulator에 반영됩니다.
*/
@Composable
fun MyView() {
var state by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
if (!state) {
SampleText("Hello")
} else {
Image(
painter = painterResource(R.drawable.compse),
contentDescription = "Compose Image",
modifier = Modifier.padding(20.dp).size(60.dp).align()
)
}
Button(
onClick = { state = !state }
modifier = Modifier.padding(20.dp).fillMaxWidth()
) {
SampleText("Click")
}
}
}
Tree를 전부 탐색하는 것이 아니라 State가 변경된 지점만 탐색합니다. 아래 코드에서는 showError라는 state가 변경되면 LoginError composable만 재구성되게 됩니다.
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput()
}
@Composable
fun LoginInput() { /*...*/ }
안드로이드 공식 홈페이지의 그림3
override fun onResume() {
lifecycleStateModel.notifyOnActivityLifeCycleUpdated(
ActivityLifeCycle.RESUME
this@MainActivity
)
super.onResume()
}
override fun onPause() {
lifecycleStateModel.notifyOnActivityLifeCycleUpdated(
ActivityLifeCycle.PAUSE
this@MainActivity
)
super.onPause()
}
var sharedVar = 0
@Composable
fun Widget1 () {
Text(text = "sharedVar = $sharedVar")
}
@Composable
fun Widget2 () {
Text(text = "sharedVar = $sharedVar")
}
모든 페이지가 State로 연결되어 있고 눈으로 확인할 수 없기 때문에...
명령형은 내가 명령을 내리기 때문에 내가 하지 않은 일은 발생하지 않지만 선언형은 구성을 잘못해놓으면 내가 하지 않은 일도 State 관리 오류로 발생 가능
하지만!
반대로 실수의 여지가 적을 수도 있습니다.
data(state)에 의존하기 때문에 처음 구성을 잘해놓으면 논리적으로 오류가 발생할 일이 없음
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
}
위 코드에서 showError가 바뀌지 않는 이상 LoginError는 발생하지 않음
Spec의 범위가 크지 않다면, 한 번쯤 적용해보는 것을 추천
Recomposition은 해당 State 값을 사용하는 Composable만..
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// this will recompose when [header] changes, but not when [names] changes
Text(header, style = MaterialTheme.typography.h5)
Divider()
// LazyColumn is the Compose version of a RecyclerView.
// The lambda passed to items() is similar to a RecyclerView.ViewHolder.
LazyColumn {
items(names) { name ->
// When an item's [name] updates, the adapter for that item
// will recompose. This will not recompose when [header] changes
NamePickerItem(name, onNameClicked)
}
}
}
}
Text는 header, items는 names라는 state를 참조하고 있는데, 여기서 header state가 변경되면, NamePicker라는 전체 composable이 재구성되기 때문에 전체가 다 갱신될 것이라고 생각이 드는데, 실제로는 Text만 갱신됩니다. 이건 7번째 장점에서 이야기 했던 Tree를 전부 탐색하는 것이 아니라 State가 변경된 지점만 탐색
로 인해 그렇습니다. Log를 찍어보면 LazyColumn쪽에는 아무런 Log가 찍히지 않는다.
data class TestModel(var num: Int)
@Composable
fun OnClickTest() {
val model by remember { mutabelStateOf(TestModel(0) }
Column (modifier = Modifier.fillMaxWidth()) {
//Button( onClick = { model.num++} 이렇게 코드를 작성하는 것이 아니라 직접 model의 값을 변경해야 함.
)
Button( onClick = { model = TestModel(model.num.plus(1)) },
), {
Text("Click")
}
Text("data ${model.num}")
}
}
@Composable
fun MyView() {
refreshModel() // Recomposition마다 호출
Text("model")
LaunchedEffect(Unit) { // initialComposition에만 호출
refreshModel()
}
}
@Composable
fun OnClickTest() {
var num = 0 // 이렇게 사용하면 변경 안됨
// 위 코드는 Recomposition시 변수가 재생성됩니다.
var num by remember = { mutableStateOf(0) }
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { num ++ } // remember로 위임해야 변경됨
) {
Text("Click")
}
Text("num $num")
}
}
Composable 내부에서 변수를 생성할 경우 remember를 꼭 사용! Recomposition 과정에서 객체가 다시 재생성됩니다!
remember를 사용하여 재생성을 방지하거나 상위에서 주입받아 사용해도 됩니다!
data class TestData(var num: MutableLiveData<Int> = mutalbleLiveData(0))
@Composable
fun OnClickTest() {
val data by remember { mutableStateOf(TestData()) }
val num = data.num.observeAsState()
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { data.num.value = num.value.plus(1) }
) {
Text("Click")
}
Text("num $num")
}
}
@Parcelable
data class TestData(var num: Int): Parcelable
var var1 = 0
@Composable
fun Root() {
var data by remember { mutalbleStateOf(TestData(0)) }
Column(modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { data = TestData(data.num + 1); var1 ++}
) {
Text("Click")
}
Text("data = ${data.num}, var1 = $var1")
}
}
JetPack Compose는 State가 변경됨에 따라 UI가 변경되는 것을 알 수 있습니다.
state를 함부로 control하지 못하게 하기 위해 View는 명시적으로 Action object를 StateModel에 보내게 됩니다.
Delegator는 State가 문분별하게 변경되는 것을 방지하기 위해서 설정한 안정장치로 생각하시면 됩니다.
즉 View는 StateModel에 Action을 적용해서 나온 State에 대한 결과물
이상으로 Naver ABC Studio의 JetPack Compose 적용 후기 발표 정리를 마치겠습니다.
잘못된 내용이나 조언이 있으시면 부담없이 댓글로 남겨주세요. 바로 수정하겠습니다!