본문 바로가기
Development/Android

[Android] Android Compose + MVVM 맛보기!

by du.it.ddu 2020. 11. 18.
반응형

Google에서 Android의 UI를 개발하기 위한 더욱 강한 방법을 제시하고 있다.

바로 Android Compose이다. 

developer.android.com/jetpack/compose?hl=ko

 

Android 개발자  |  Android Developers

강력한 성능 Android 플랫폼 API에 직접 액세스하고 머티리얼 디자인, 어두운 테마, 애니메이션 등을 기본적으로 지원하는 멋진 앱을 만들 수 있습니다.

developer.android.com

위 링크에서 간략한 소개를 보면 도움이 될 것이다. Codelab은 물론 샘플앱도 제시하고 있으니 꼭꼭! 두번 보자.

다만, 이 글을 쓰는 시점을 기준으로 아직 알파 버전이다. 실무에서의 사용은 그다지 권하진 않는다.


- Android Compose?

developer.android.com/jetpack/compose/mental-model?hl=ko

 

Compose 이해  |  Android 개발자  |  Android Developers

Jetpack Compose는 Android를 위한 현대적인 선언형 UI 도구 키트입니다. Compose는 프런트엔드 뷰를 명령형으로 변형하지 않고도 앱 UI를 렌더링할 수 있게 하는 선언형 API를 제공하여 앱 UI를 더 쉽게 작

developer.android.com

Android Compose는 선언형 UI 프레임워크다.

선언형 UI에 대해선 React나 Flutter, SwiftUI로 개발해본 경험이 있다면 느낌이 올 것이다.

기존의 Android에서 UI를 개발하는 과정을 간략히 떠올려보자.

activity_main.xml 이란 파일에 xml코드로 레이아웃을 구현하고 MainActivity.java 혹은 MainActivity.kt에서 id값 혹은 바인딩을 통해 VIew에 접근하여 텍스트 같은 속성을 정의하거나 OnClick 이벤트 등을 정의했을 것이다.

반면, Compose는 Composable들을 조합해서 Kotlin 코드로 UI를 구현한다. 귀퉁이가 동그란 버튼을 만들기 위해 drawable xml을 만들고 background에 지정해주는 이런 행위들을 모두 Kotlin 코드로 구현할 수 있다.

Kotlin 코드로 구현하기 때문에 CustomView를 만들기도, 재사용하기도 쉬우며 레이아웃을 확인하기 위해 xml을 들여다보고 다시 코드를 보고 할 필요 없이, 정말 코드만 보면 된다.

역시 글보단 코드와 결과로 보는게 빠르니, 구현해보자.


- 사전 준비

기존 Android Studio가 아닌 Canary Build를 다운받아 설치해야 한다.

developer.android.com/studio/preview?gclid=Cj0KCQiAqdP9BRDVARIsAGSZ8An_eF1ZkCn4ORP48E00zOJZ_OMvq0O3n_DZJDu6Q93dZ846eef2FCkaAo6UEALw_wcB&gclsrc=aw.ds

 

Android Studio Preview  |  Android 개발자  |  Android Developers

developer.android.com

위 링크에서 각자 OS에 맞는 Android Studio Canary Build를 다운받아 설치하자.

그리고 폴더 내부에 있는 실행파일을 실행하자.


- 프로젝트 생성

새 프로젝트를 생성하면, "Empty Compose Acitivty" 라는 항목이 보일 것이고 우측 귀퉁이에 노란 띠가 하나 보일 것이다. 이것으로 생성하자.


- 차이점을 확인해보자.

plugins {
    ...
}

android {
    ... 
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
        kotlinCompilerVersion '1.4.10'
    }
}

dependencies {
    ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.ui:ui-tooling:$compose_version"
    ...
}

기본적으로 평소의 Android Studio로 생성한 프로젝트와 build.gradle의 내용물이 차이가 좀 난다.

buildFeatures에 compose가 true로 되어있고, compose 관련된 라이브러리들이 implementation 되어 있다.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AndroidComposeTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Greeting("Android")
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    AndroidComposeTheme {
        Greeting("Android")
    }
}

MainActivity는 위와 유사한 형태로 작성되어 있을 것이다.

기존엔 setContentView에 레이아웃의 아이디값을 넘겨주었는데, setContent 이후의 블럭에 Composable들을 조합하고 있다.

여기서 AndroidComposeTheme은 프로젝트의 이름에 따라 다른데, App의 Theme을 정의하여 일관성 있는 앱을 구현하도록 하기 위함이다. 그리고 이 Theme 아래에 Composable들을 조합하여 UI를 구성하면 Theme을 활용할 수 있다.

디폴트로 생성된 Greeting, DefaultPreview를 통해 Composable들을 생성하는 방법을 참고할 수 있다.

기본적으로 @Composable 어노테이션을 달아주고 함수를 정의하면 Composable을 만들 수 있고, Composable은 또 다른 Composable을 가질 수 있다.


- Theme

패키지를 살펴보면 위와 같은 패키지 구성으로 되어 있을 것이다. ui 하위에 Color, Shape, Theme, Type이 위에서 말했던 App의 Theme을 정의하기 위한 파일들이다.

Type는 Typography를 정의한다. button, h1, h2, ... 등 사전에 정의되어 있는 TextStyle들에 대해 본인이 원하는 텍스트 크기, 폰트를 정의할 수 있다.

Color는 앱에서 사용할 Color들을 정의할 수 있다. 

Shape는 앱에서 기본적으로 사용할 Shape들을 정의한다. small, medium, large의 Shape에 대해 RoundedCornerShape을 사용할 것인지 등이다.

Theme은 위에서 정의한 것들을 조합하여 App의 Theme을 만든다. 코드를 보자.

private val DarkColorPalette = darkColors(
    primary = purple200,
    primaryVariant = purple700,
    secondary = teal200
)

private val LightColorPalette = lightColors(
    primary = purple500,
    primaryVariant = purple700,
    secondary = teal200

    /* Other default colors to override
    background = Color.White,
    surface = Color.White,
    onPrimary = Color.White,
    onSecondary = Color.Black,
    onBackground = Color.Black,
    onSurface = Color.Black,
    */
)

@Composable
fun AndroidComposeTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

    MaterialTheme(
        colors = colors,
        typography = typography,
        shapes = shapes,
        content = content
    )
}

라이트모드와 다크모드 지원을 위한 방법도 제시하고 있다.

위의 AndroidComposeTheme은 Composable이며, 앞서 커스텀하게 정의한 color, shape, typography과 content 블럭 내부에 선언된 Composable들을 사용하여 MaterialTheme에 전달하여 UI를 구현한다.


- 구현하기 전에.

주절주절 말이 많아 지루했을 것 같다. 사실 작성하는 나도..

자 이제 Compose와 MVVM 패턴을 활용하여 간단하게 앱을 구현 해 보자.

간단하게 RecyclerView에 어떤 모델의 Title을 뿌려주는 목록 정도를 상상하고 Compose를 활용해 구현 할 것이다.


- Model Code

data class MyModel(
    val id: String = UUID.randomUUID().toString(),
    val title: String = ""
)

Compose에 집중하기 위해 심플하게 가도록 하자.


- ViewModel Code

class MainViewModel(
    private val repository: MyRepository
) : ViewModel() {
    private val _myModels: MutableLiveData<List<MyModel>> = MutableLiveData()
    val myModels: LiveData<List<MyModel>> = _myModels

    val onItemClickEvent: MutableLiveData<MyModel> = MutableLiveData()

    fun loadMyModels() {
        repository.getModels().let {
            _myModels.postValue(it)
        }
    }

    fun onItemClick(position: Int) {
        _myModels.value?.getOrNull(position)?.let {
            onItemClickEvent.postValue(it)
        }
    }
}

class MainViewModelFactory(val repository: MyRepository): ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
            return MainViewModel(repository) as T
        }

        throw IllegalArgumentException("Unknown ViewModel class :: ${modelClass::class.java.simpleName}")
    }
}

interface MyRepository {
    fun getModels(): List<MyModel>
}

class FakeMyRepositoryImpl: MyRepository {
    override fun getModels(): List<MyModel> = listOf(
        MyModel(title = "Fake title 1"),
        MyModel(title = "Fake title 2"),
        MyModel(title = "Fake title 3"),
        MyModel(title = "Fake title 4"),
        MyModel(title = "Fake title 5"),
        MyModel(title = "Fake title 6"),
        MyModel(title = "Fake title 7"),
        MyModel(title = "Fake title 8"),
        MyModel(title = "Fake title 9"),
        MyModel(title = "Fake title 10")
    )
}

MainViewModel은 loadMyModels가 호출되면 repository로부터 data를 획득하여 LiveData에 postValue 한다.

MyRepository는 interface로 구현하고, FakeMyRepositoryImpl을 구현하여 임의 데이터를 반환하게 하였다.

MainViewModel 생성 시 Factory의 인자로 FakeMyRepositoryImpl을 전달하면 될 것이다.

또한 ItemClick 이벤트 처리도 추가하였는데, MutableLiveData로 이벤트를 처리하는 것은 좋지 않다.

LiveData를 활용하여 이벤트를 처리하는 것은 SingleLiveEvent에 대해 검색하거나 아래 링크를 참고하는 것을 권장한다.

proandroiddev.com/singleliveevent-to-help-you-work-with-livedata-and-events-5ac519989c70

 

SingleLiveEvent to help you work with LiveData and events

Have you had to deal with a Dialog or a SnackBar that after been triggered/shown or dismissed and followed by a device rotation is…

proandroiddev.com


- MainActivity Code

plugins {
    ...
}

android {
    ...
}

dependencies {
    ...
    implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
}

우선 LiveData를 활용하기 위해 위의 라이브러리를 추가해주자.

class MainActivity : AppCompatActivity() {
    private val viewModel by lazy {
        ViewModelProvider(this, MainViewModelFactory(FakeMyRepositoryImpl()))
            .get(MainViewModel::class.java)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AndroidComposeTheme {
                val myModels = viewModel.myModels.observeAsState().value ?: emptyList()
                val clickedModel = viewModel.onItemClickEvent.observeAsState().value

                MyModelList(models = myModels, onItemClick = viewModel::onItemClick)

                if (clickedModel != null) {
                    ComposableToast(clickedModel.title)
                }
            }
        }

        viewModel.loadMyModels()
    }
}

MainActivity는 위와 같다.

viewModel의 myModels LiveData를 observeAsState를 활용하여 State로 관찰한다. 그럼 이 State의 value가 변경될 때 Composable들이 변경되게 된다. (문서상에는 다시 그린다고 되어 있는 것 같다.)

마찬가지로 viewModel의 onItemClickEvent의 State를 관찰하여 Click한 모델이 변경된 경우 토스트 메시지를 띄운다.

그리고 viewModel.loadMyModels()를 호출하여 데이터를 요청한다.


- MainActivity Composable

@Composable
fun MyModelList(models: List<MyModel>, onItemClick: (Int) -> Unit = {}) {
    LazyColumnForIndexed(
        items = models,
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) { index, item ->
        MyModelListItem(model = item, onClick = {
            onItemClick.invoke(index)
        })
    }
}

@Composable
fun MyModelListItem(model: MyModel, onClick: () -> Unit = {}) {
    Card(
        shape = RoundedCornerShape(8.dp),
        border = BorderStroke(1.dp, MaterialTheme.colors.primary),
        backgroundColor = Color.White,
        contentColor = MaterialTheme.colors.primary,
        modifier = Modifier
            .clickable(onClick = onClick)
            .fillMaxWidth()
            .wrapContentHeight()
            .padding(8.dp)
    ) {
        Text(
            text = model.title,
            style = MaterialTheme.typography.h3
        )
    }
}

@Composable
fun ComposableToast(message: String) {
    Toast.makeText(ContextAmbient.current, message, Toast.LENGTH_SHORT).show()
}

Composable들은 위와 같이 정의되어 있다.

LazyColumnFor 또는 LazyColumnForIndexed를 활용하면 세로로 스크롤 가능한 RecyclerView와 유사한 형태를 구현할 수 있다. 

Composable들의 width, height, padding, onClick 등의 속성은 모두 Modifier를 통해 정의할 수 있다.

또한 MyModelListItem을 보면 알 수 있듯, shape와 border 속성을 활용하여 테두리가 둥글고 색깔이 있는 형태를 구현할 수 있다.

또한 Composable내에서 Context는 ContextAbient.current를 통해 접근할 수 있다.


- 결과

결과는 위와 같다. 굉장히 심플하게 구현한 것이라 예쁘진 않다. 

이 글의 목적은 Compose에 대한 기본적인 내용을 포스팅하고 기존의 MVVM 패턴과 어떻게 통합할 수 있는지에 대해 간단한 Codelab이니.. 양해바란다.


- 기존의 방식을 떠올려보자.

만약 이것을 기존의 UI 구현방법으로 했다고 상상해보자.

activity_main.xml, item_my_model.xml 같은 것을 만들고, 내부에 RecyclerView를 정의한 다음 Adapter, ViewHolder를 구현할 것이다. 

그리고 CardView를 활용하거나 background.xml 같은것을 만들어 테두리가 둥근 레이아웃을 구현했을 것이다.

탭도 많이 이동해야 하고 xml도 많고 간단한 화면인데도 할게 많다.

반면에 Composable은 모두 코드로 뚝딱뚝딱 만들 수 있기 때문에 흐름대로 이어나갈 수 있고 파일도 많지 않다.

그리고 코드가 길지도 않고 오히려 더 빠른 시간에 구현을 완료할 수 있다.


- 그럼 Compose가 최고인가?

아직은 정식으로 배포되지 않았다.

아직 활발하게 구현되고 있고 글을 쓰는 시점에 1.0.0-alpha07 이 최신이다.

계속 API가 변경되고 있고 어떤것은 Deprecate가 되고 있다.

나는 개인적으로 사이드 프로젝트에 도입하고 있지만, 아직 실무 도입은 시기상조라고 본다.

하지만 미리 다가올 날을 대비해 알아두는 것은 중요하다고 본다.

왜냐하면 React나 Flutter, SwiftUI를 보면 선언형 레이아웃 프레임워크가 효율적인 방법임을 증명해주고 있다고 생각하기 때문이고, 이를 Google에서 인지하고 개발하고 있기 때문이다.

간단한 샘플인지라 큰 도움이 되지는 않았겠지만, 누군가에겐 사소한 도움이 되길 바란다.

반응형