대외활동/SOPT

SOPT 34th 안드로이드파트 미미나 - Well Made Component

chattymin 2024. 4. 8. 00:02
728x90
반응형

안녕하세요. SOPT 34기 안드로이드 파트 OB 박동민입니다.

 

이번 안드로이드 파트에서는 Android의 최신 기술인 Compose를 도입하였습니다.

그렇다보니 XML에 익숙한 많은 분들이 헷갈려하시고, 방향을 잡지 못하시는 것 같아 조금이나마 도움을 드리고자 미미나를 하게 되었습니다.

 

 

오늘 공유하고자 하는 내용은 Component를 "잘" 만드는 방법입니다.

 

 

목차

  • Compose UI의 구조
  • Composable을 재사용해보자
  • State Hoisting이란 뭐고 왜 적용해야 할까?
  • Slot API이란 뭐고 왜 적용해야 할까?

 

Compose UI의 구조

출처 : https://developer.android.com/develop/ui/compose/phases?hl=ko

Compose 공식문서에서 위와 같이 Compose의 Layout은 Node로 구성되어있다고 설명되어있다.

즉, Compose는 Tree구조를 가지고 있다. 

 

이때 각 컴포넌트들이 자신만의 정보를 각각 가지고 있다고 가정해보자.

그렇다면 부모 트리에서 특정 컴포넌트의 정보를 가져오려면 어떻게 해야할까?

 

타고타고타고 x 100 내려가서 정보를 가져와야 하는데 애초에 가져올 수 있을까?

Composable 함수의 특성상 class처럼 내부 변수에 참조하는 것이 불가능하다.

 

그렇기 때문에 자신이 아닌 다른 컴포넌트의 값을 직접적으로 가져오고 사용하는 것이 불가능하다.

이러한 부분을 해결하기 위해서 State Hoisting이라는 개념이 나오고, 슬롯 기반 레이아웃인 Slot API가 나오게 되었다.

 

위 두 개념을 설명하기 전, Compose에서 가장 중요한 원칙을 하나 짚고 넘어가자.

 

Composable을 재활용 해보자

내가 xml을 처음 짰을 때 충격을 받았던 부분이다.

xml에서는 같은 기능을 하고, 똑같이 생겼더라도 그냥 복붙으로 쓰는 사람들을 많이 보았다.

그렇게 되면 약간이라도 수정이 되는 순간 붙여넣기한 모든 부분을 수정해야한다.

그래서 너무 비효율 적이라는 생각이 들었고, 이를 해결하기 위해 세미나 기간에는 include를 활용해보기도 하고, 지난 33기 앱잼때 Custom View를 만들었다.

include에서는 내부 값에 접근하는 것이 너무 힘들었고, Custom View는 정말 쓸데없이 복잡하다 라는 느낌을 받을 수 있었다.

 

 

이러한 감정을 느낀 것에는 내가 Compose를 먼저 공부했다는 배경이 깔려있다.

Compose에서는 모든 컴포넌트를 재활용 하는 것이 기본이다.

 

 

같은 UI를 가지고, 같은 기능을 가진다면 해당하는 부분을 Composable 함수로 만들고, 해당 Composable함수를 호출 하는 것으로 재활용 할 수 있다. 아니. 해야한다. 물론 복붙으로도 화면을 구현할 수 있다. 그렇지만 이 글을 읽은 후에는 그러지 않았으면 좋겠다.

 

아래는 정말 간단한 예시다

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
    	Column{
            Greeting("SOPT Android!")
            Greeting("34기 여러분들!")
        }        
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}

 

Compose에서 프로젝트를 만들면 제일 먼저 보이는 코드를 약간 수정해봤다.

이렇게 Greeting이라는 Composable 컴포넌트를 두번 호출한 것 처럼 여러번 재활용이 가능하다.

그러면 재활용을 하기위한 컴포넌트를 "잘" 만들기 위한 기술을 소개해보겠다.

 

 

State Hoisting

State : 상태 

Hoisting : 끌어 올린다

 

즉 영어 뜻풀이 그대로 상태를 끌어올리는 것이다.

사실 이 개념은 Java Script에도 존재한다.

 

여기서 상태에 대해서 잠깐 짚고 넘어가겠다.

Compose에서는 모든 Value를 상태로 표현한다고 생각하면 편하다.

TextField가 가지고 있는 text value도 상태, 값을 입력했을때 진행되어야 하는 행동도 상태 라고 생각하면 된다.

물론 정확한 설명은 절대 아니고, 단순한 이해를 돕기 위한 예시이니 추후에 공부하기를 적극 권장한다.

 

다시 돌아와서 상태를 끌어 올린다는 표현을 다시 생각해보자.

Compose의 UI구조는 Tree 구조다.

즉, 하위 Node에 있는 State를 상위 Node로 끌어올리는 작업을 의미한다.

 

이렇게 하면 뭐가 좋을까?

위에 말했던 것 처럼 하위 노드의 정보를 가지러 많은 노드를 내려가지 않아도 된다.

즉 데이터 후처리가 편해져서 유기적인 데이터 사용이 가능해진다.

또한 이후에 추가적인 설명을 하겠지만, 해당 컴포저블 함수의 다양한 활용이 가능해지고 유지보수에 편리해진다.

즉, 각 기능의 분리가 더욱 명확해진다.

 

지금쯤 다들 머릿속에 ?가 20개쯤 있을거 같아 간단한 예시를 들고왔다.

@Composable
fun CountBox() {
    var count by remember {
        mutableIntStateOf(0)
    }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "count : $count"
        )

        Button(onClick = { count++ }) {
            Text(
                text = "Click Here!!"
            )
        }
    }
}

간단히 버튼이 있고, 버튼이 클릭되면 버튼 위에 있는 count가 올라가는 컴포넌트다.

 

아마 Compose를 처음 작성한다면 이렇게 많이 작성할 것이다.

이렇게 하면 잘 동작할 것이다.

하지만, 버튼을 클릭한 후 count 값을 다른 컴포넌트에서 참조해야한다면 어떻게 해야할까?

 

하하 답이 없다.

그렇기 때문에 상태를 위로 끌어올리는 State Hoisting을 사용한다.

한번 적용해보자.

 

먼저, count라는 값을 외부에서 참조할 수 있도록 끌어올려준다.

@Composable
fun CountBox(count: MutableIntState) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "count : ${count.value}"
        )

        Button(onClick = { count.value++ }) {
            Text(
                text = "Click Here!!"
            )
        }
    }
}

 

이렇게 한다면 외부에서 값을 참조할 수 있을 것이다.

하지만, 이렇게 한다면 이 컴포넌트는 값을 1씩 증가시키기만 할 수 있다.

나중에 유지보수를 하다보니 +1이 아닌 *2를 해야할 수 도있다.

 

그렇기 때문에 값에 변화를 주는 onClick부분도 외부로 빼줄 수 있다.

@Composable
fun CountBox(count: Int, onClick: () -> Unit) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "count : $count"
        )

        Button(onClick = onClick) {
            Text(
                text = "Click Here!!"
            )
        }
    }
}

이렇게 된다면 해당 컴포넌트는 활용성이 높아졌다.

이전에는 +1만 할줄 아는 컴포넌트였지만, 이제는 +2도, /2, *2 까지 전부 가능하다.

 

이렇게 내부 값을 변화시키는 코드마저 밖으로 빼준다면 코드의 활용도가 더 높아진다.

 

 

 

값을 빼다보면 또다른 장점이 뭐가 있을까?

확실한 관심사 분리가 가능해진다.

 

컴포넌트를 만들고, 이를 호출하는 부분에서 모든 연산, 값저장 등 모든 기능을 하게 코드를 작성했다고 생각해보자.

그렇다면 컴포넌트는 순수하게 UI만 담당하고, 모든 기능은 그 외부에서 담당하게 된다.

 

즉, 추후 UI 수정이 필요하다면 컴포넌트를, 비즈니스 로직 수정이 필요하다면 최상위 Composable 함수를 확인하면 된다.

또한 모든 상태를 최상위 Composable 함수에서 가지고 있기 때문에 해당 상태를 활용한 다른 컴포넌트도 자유롭게 만들 수 있다.

 

이렇듯 내부 값의 손쉬운 공유와, 관심사 분리, 손쉬운 유지보수를 위해서 State Hoisting을 사용한다.

 

 

Slot API

Slot API란 슬롯 기반 레이아웃을 만드는 기술이다.

슬롯 기반 레이아웃은 개발자가 원하는 대로 채울 수 있도록 UI에 빈 공간을 남겨 둡니다. 슬롯 기반 레이아웃을 사용하면 보다 유연한 레이아웃을 만들 수 있습니다.
- 공식문서-

빈 공간을 만들어 두고, 사용자가 원해는대로 채울 수 있도록 하는 방법이다.

 

이게 무슨 소린지 모르겠다면 기존 Composable 함수 내부를 까보자.

@Composable
inline fun Row(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
) {
    val measurePolicy = rowMeasurePolicy(horizontalArrangement, verticalAlignment)
    Layout(
        content = { RowScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

이것은 Compose의 Row 함수 내부다.

 

이렇게 내부에 content를 넣고, 값을 받아오는 그런 방법이다.

그냥 Composable 함수를 받아오는거 아냐? 싶을 수도 있다.

 

하지만, 여기서 핵심은 RowScope. 이다.

확실한 예시를 들어보자

 

@Composable
fun ButtonFun(
    count: Int,
    onClick: () -> Unit,
    content: @Composable RowScope.() -> Unit = {}
) {
    Row(
        verticalAlignment = Alignment.CenterVertically
    ) {
        Button(onClick = { onClick() }) {
            Text(text = count.toString())
        }
        content()
    }
}

@Composable
fun ButtonFun2(
    count: Int,
    onClick: () -> Unit,
    content: @Composable () -> Unit = {}
) {
    Row(
        verticalAlignment = Alignment.CenterVertically
    ) {
        Button(onClick = { onClick() }) {
            Text(text = count.toString())
        }
        content()
    }
}

이렇게 두가지 컴포넌트가 있다.

 

이 둘의 차이는 단 하나. RowScope.의 존재 유무다.

코드에서 호출해 보았다.

RowScope가 없는 경우에는 RowScope가 제공하는 함수들을 사용하지 못해서 빨간색으로 오류가 나고있는 모습이다.

 

이렇듯 Row혹은 Colum의 성질을 활용하고, 사용자가 원하는 컴포넌트를 넣는 유연한 UI를 작성하고 싶다면 Slot API는 선택이 아닌 필수다.

728x90
반응형