[Naver Deview] Naver Android Jetpack Compose 적용 후기 발표 정리

박준규·2022년 4월 8일
7

Naver Deview

목록 보기
1/1

오늘은 평소에 관심있었던 Android Jetpack Compose UI에 대해 Naver Deview에서 발표한 내용을 정리해보려고 해요. 남상혁 개발자님께서 발표하셨고 많이 개발자분들이 관심있어 하는 library인 만큼 저도 궁금한 내용이 많았습니다.

이 글에서는 발표 내용에만 집중했습니다. 따라서 코드에 대해 따로 설명하지 않는점 양해 부탁드립니다.

영상 보러가기

발표는 다음과 같이 진행했습니다.

  1. 적용 배경
  2. 중요 개념
  3. 장점
  4. 단점
  5. 주의할점
  6. State에 맞게 추가된 점

1️⃣ 적용 배경

당시 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 개념 이해에 많은 도움을 받을 수 있었다고 합니다.

2️⃣ 중요 개념

이제 남상혁 개발자님께서 중요하게 생각했던 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의 구성은

  1. View의 형태를 XML로 미리 구성
  2. XML안에 View를 불러와서 (findVieById or Binding)
  3. View를 어떻게 만들지 명령

으로 되어있습니다.

선언형 Jetpack Compose의 구성은

  1. 그리고 싶은 View 자체를 선언
  2. State에 맞게 View를 선택적으로 그림

으로 되어있습니다.

단순하게 있는 그대로 받아들이면, compose에서는 state를 적용한 결과가 곧 UI가 됩니다.

3️⃣ 장점

1. Build 속도

  • Gradle 제외한 Code 수정 후의 Rebuild 속도 : 27sec → 3sec

    부가 설명
    - 처음에는 비슷한 속도로 build 되었지만, Beta Version Up이후 Build 속도는 눈에 띄게 빨라졌습니다.
    이부분은 Jetbrain에서 Jetpack Compose에 참여하면서 부터 가능했던 최적화의 결과로 생각하신다고 하네요

2. APK File Size

  • App DownLoad 크기 감소 : 53.2MB → 3.64MB
    이 비교는 기존의 React Native와의 비교이며, 최적화 이후의 결과입니다. 그래도.. App Bundle을 사용하지 않았는데도 불구하고 이정도 size면.. 감탄이 나네요..

3. Native 기능 및 Context 사용

  • Native를 사용, Context 사용 둘 모두에 불편함이 없었다고 합니다. 사용하기 간편하다!
    @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")
                }
            }
        )
    }

4. XML을 벗어난 UI 개발

  • XML을 사용하지 않고 Kotlin Code 만으로 대부분의 UI 개발 가능합니다. 즉 None XML Layout
  • 이로 인해 resource와 main 폴더 파일을 왔다갔다 하면서 개발하는 번거로움을 줄여줄 수 있었습니다.
  • 남상혁 개발자님 개인적으로는 Kotlin코드만 보면 되기 때문에 코드 생산성도 매우 좋아졌다고 이야기했습니다.

5. RecyclerView

  • List를 만들기 위해 복잡한 RecyclerView 및 adapter를 더이상 만들지 사용하지 않아도 됩니다.
  • Column, Row 개념으로 접근해도 list를 구성할 수 있었습니다. 이로 인해 생산성에 도움이 되었다고 합니다.
@Composable
fun MyView() {
	LazyColumn(modifier = modifier.wrapContentSize(()) {
    	items(list) { item ->
        	ItemComponent(data = item)
       	}
    }
}

6. Live Edit of literals

  • 처음에는 emulator에 지원을 안했었지만 지금은 emulator에 지원이 됩니다. 따라서 literals를 변경할 때마다 실시간으로 emulator에 반영이 된다고 합니다.
    /**
    * 문자열 부분이나 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")
            }
        }
    }

7. 가볍다.

  • Tree를 전부 탐색하는 것이 아니라 State가 변경된 지점만 탐색합니다. 아래 코드에서는 showError라는 state가 변경되면 LoginError composable만 재구성되게 됩니다.

    @Composable
    fun LoginScreen(showError: Boolean) {
        if (showError) {
            LoginError()
        }
        LoginInput()
    }
    
    @Composable
    fun LoginInput() { /*...*/ }

안드로이드 공식 홈페이지의 그림3

4️⃣ 단점

1. LifeCycle 대응

  • App이 pause, resume될 때 동작을 추가하고 싶은데...
  • Composable 안에서 Activity lifeCycle을 Trigger 할 수 없다..
  • Activity에서 Trigger에서 Composable로 전개해야 한다...
    override fun onResume() {
    	lifecycleStateModel.notifyOnActivityLifeCycleUpdated(
        	ActivityLifeCycle.RESUME
            this@MainActivity
        )
        super.onResume()
    }
    
    override fun onPause() {
    	lifecycleStateModel.notifyOnActivityLifeCycleUpdated(
        	ActivityLifeCycle.PAUSE
            this@MainActivity
        )
        super.onPause()
    }

2. Composable간 공용 변수 사용

  • widget 각각이 fun(독립적)이기 때문에 같은 화면 안에서도 같은 변수를 사용할 수 없다.
  • DI or 전역변수 or 변수를 계속 넘겨다녀야 한다.
    var sharedVar = 0
    
    @Composable
    fun Widget1 () {
    	Text(text = "sharedVar = $sharedVar")
    }
    
    @Composable
    fun Widget2 () {
    	Text(text = "sharedVar = $sharedVar")
    }

3. State 관리

  • 전역에서 유지해야할 State, 페이지 안에서만 쓸 State 따로 구분해야 합니다.
  • 전역에서 공유되어야 하는 Root()의 경우 최상단을 유지하고 parameter로 주입 또는 전역에서 관리해야 합니다.
  • Widget끼리 공유되는 State의 경우에는 Widget 상위에서 생성해서 사용해야 합니다. 예를 들어서 Page2 하위의 Widget들의 공유되는 State들은 Page2가 제거되었다가 다시 보여질 때 초기화될 수 있습니다.
    - 따라서 remember, rememberSaveable 적절히 활용해야 합니다.
  • State관리가 제대로 되지 않으면, 화면이 갱신되지 않거나 원하는 값을 얻을 수 없을지도 모릅니다.

4. FireBase Tracking

  • Activity Base가 아니기 때문에 화면별 Tracking이 쉽지 않습니다.
  • 화면 진입 시점을 체크해서 Logging을 추가해주어야 했습니다.

5. 선호되는 Archtecture의 부재

  • MVP?, MVVM? Clean Archtecture?
  • Jetpack은 이렇게 쓰는게 좋더라 하는 자료들이 아직 별로 없습니다.
  • 그렇기 때문에 프로젝트 구성에 대해 아직 많은 고민을 하고 있다고 합니다.

6. 실수할 여지가 많다.

  • 모든 페이지가 State로 연결되어 있고 눈으로 확인할 수 없기 때문에...

  • 명령형은 내가 명령을 내리기 때문에 내가 하지 않은 일은 발생하지 않지만 선언형은 구성을 잘못해놓으면 내가 하지 않은 일도 State 관리 오류로 발생 가능

    하지만!

  • 반대로 실수의 여지가 적을 수도 있습니다.

  • data(state)에 의존하기 때문에 처음 구성을 잘해놓으면 논리적으로 오류가 발생할 일이 없음

    @Composable
    fun LoginScreen(showError: Boolean) {
        if (showError) {
            LoginError()
        }
    }

    위 코드에서 showError가 바뀌지 않는 이상 LoginError는 발생하지 않음

결론

  • Spec의 범위가 크지 않다면, 한 번쯤 적용해보는 것을 추천

5️⃣ 주의해야 할 점

1. State를 변경했는데, 재구성이 안됨..1🤔

  • 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가 찍히지 않는다.

2. State를 변경했는데, 재구성이 안됨..2 😢

  • Model 쓸때 조심할 것
  • State인 model 자체가 변경되어야 recomposition이 일어나게 됨
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}")
    }
}

3. Composable 함수가 너무 자주 불려요...🥲

  • Composable은 여러번 호출될 수 있기 때문에 Composable 안에서 다른 함수를 호출할 때는 주의!
  • 따라서 LaunchedEffect를 사용
@Composable
fun MyView() {
	refreshModel() // Recomposition마다 호출
    
    Text("model")
    
    LaunchedEffect(Unit) { // initialComposition에만 호출
    	refreshModel()
    } 
}

4. 변수 생성시에도 주의 1!

  • Composable 내부에서 변수를 생성할 경우, 꼭 state를 활용!
  • State가 아닌 값은 변수가 바뀌어도 Recomposition 하지 않음
@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")
    }
}

5. 변수 생성시에도 주의 2!

  • Composable 내부에서 변수를 생성할 경우 remember를 꼭 사용! Recomposition 과정에서 변수가 다시 재생성됩니다!

6. 객체 생성 주의!

  • 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")
    }
}

7. Parcelable

  • Parcelable, Serializer한 Object를 사용해서 State를 구성
  • Object Reference가 바뀌어도 내부 값의 변경을 체크해서 Recomposition합니다. 즉 내부 값이 바뀌지 않으면 Recomposition되지 않습니다.
@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")
    }
}

6️⃣ State에 맞게 추가한 개념

JetPack Compose는 State가 변경됨에 따라 UI가 변경되는 것을 알 수 있습니다.

MVSM → Model - View - StateModel 제안

UI=f(state)UI = f(state)
  • StateModel의 State의 변화에 따라 UI가 Seamless하게 변환!

state를 함부로 control하지 못하게 하기 위해 View는 명시적으로 Action object를 StateModel에 보내게 됩니다.

  • Action : View에서 StateModel로 Action을 넘겨 StateModel에서 로직을 수행
    → State 변경
    → View의 자동갱신 By State
  • Delegator : Action을 전달할 때 다음 동작을 정의 (연속적으로 정의하기 위해서입니다.)

Delegator는 State가 문분별하게 변경되는 것을 방지하기 위해서 설정한 안정장치로 생각하시면 됩니다.

View(StateModel(State))=StateView(StateModel(State)) = State

즉 View는 StateModel에 Action을 적용해서 나온 State에 대한 결과물

이상으로 Naver ABC Studio의 JetPack Compose 적용 후기 발표 정리를 마치겠습니다.

잘못된 내용이나 조언이 있으시면 부담없이 댓글로 남겨주세요. 바로 수정하겠습니다!

profile
'개발'은 '예술'이고 '서비스'는 '작품'이다

0개의 댓글