2022년 4월 27일 수요일

[안드로이드] Compose 3 - 상태(State)

 

목차

  1. Compose 시작
  2. Compose의 생명주기
  3. Compose에서의 상태(State)
  4. 렌더링 수행 과정
  5. 부수효과(Side-Effect)
  6. 시맨틱(Semantics)
  7. 아키텍쳐 레이어
  8. 로컬 범위지정(Scope) 데이터

Compose에서의 상태(State)

Composable함수에서의 상태

  • 동일한 Composable 함수를 다른 파라미터로 호출해야만 UI가 업데이트(리컴포지션)됨.
    • 즉, 함수에 전달되는 파라미터는 UI가 무엇을 렌더링해야하는지를 전달하므로 UI 상태를 의미
  • 일반적으로, 파라미터를 State 타입으로 감싸서 remember() Composable 함수를 이용해 값을 저장하는 방식으로 사용함
@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        // name값을 상태로 저장
        var name by remember { mutableStateOf("") }
        if (name.isNotEmpty()) {
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.h5
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

State

  • Composable 함수의 수행시에 값을 읽기위해 Compose에서 제공하는 타입.

  • Compose는 내부적으로, State타입이 가진 값의 변화가 발생하면 RecomposeScope를 이용하여 리컴포지션을 요청

  • State는 변경불가능한(immutable) 값을 저장하는 타입으로, 변경가능한(mutable) 값을 저장하려면 하위타입인 MutableState 를 이용

    • MutableState는 이전값과 다른값이 저장되는 경우에만 리컴포지션을 요청하고, 이전값과 동일한 값이 저장되는 경우에는 리컴포지션을 스킵
  • Composable 함수 내에서 MutableState를 선언하는 3가지 방법

// by 위임키워드를 사용하려면 import 필요
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

val mutableState = remember { mutableStateOf(defaultValue) }
var value by remember { mutableStateOf(defaultValue) }
val (value, setValue) = remember { mutableStateOf(defaultValue) }
  • Compose에서는 다른 Observable 타입을 State로 변환하는 확장함수를 지원

remember

  • 함수 파라미터는 함수가 수행되는 동안에만 값을 유지하고 사용가능.
  • Compose에서는 메모리에 단일 객체를 저장하는 remember Composable 함수를 제공
    • 가변(mutable), 불변(immutable) 모두 저장가능
  • 최초 컴포지션 수행시에 값을 저장하고, 리컴포지션이 수행될 때마다 저장된 값을 반환.
  • 단, 구성변경(Configuration Change)시에는 상태를 저장하지 않음.
    • rememberSaveable 을 사용해야 내부적으로 Bundle 객체에 저장하는 로직 수행

State Hoisting

Stateful vs Stateless

  • Stateful : Composable함수 내부에서 remember로 상태를 저장하고 관리
    • 함수사용의 입장에서는 UI상태를 알아서 관리하므로 외부에서 관리할 필요가 없으나, 재사용성이 떨어지고 테스트가 어려움
  • Stateless : Composable함수 내부에서 어떤 상태도 관리하지 않음.
    • 외부에서 상태관리를 할 필요가 있지만, 재사용하기에 용이하고 테스트가 쉬움

State Hoisting

  • State의 관리를 외부(함수 호출한 곳)에서 하도록 구성하여, Stateless한 Composable을 만드는 디자인패턴
  • Composable이 state관리를 하면 안되므로, 내부에서 발생한 액션(이벤트)처리도 밖으로 전달해야 함
// 위의 HelloContent() 함수를 State Hoisting 패턴으로 변경한 예시
// HelloScreen() 함수에서 상태를 관리하는(Stateful) 역할을 담당.
@Composable
fun HelloScreen() {
    // name값을 상태로 저장
    var name by remember { mutableStateOf("") }
    
    HelloContent(
        name = name,
        onNameChange = { name = it }
    )
}

// Stateless Composable 함수. 외부에서 값(name)을 받아서 렌더링하고, 액션/이벤트(onNameChange)를 밖으로 전달
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}


이 패턴을 왜 사용하는가?

  • 앱의 데이터/상태를 관리하는 부분과 화면에 렌더링하는 UI로직을 분리할 수 있다.
    • 데이터와 UI가 각각의 변경에 독립적
  • stateless한 Composable함수로 변경하여 재사용성, 테스트가능성을 높일 수 있다.

Compose에서의 상태 복원

  • Activity나 프로세스가 재생성될 때, 상태를 유지하기 위해 rememberSaveable Composable 함수를 사용
  • rememberSaveable은 내부적으로 Bundle을 이용하므로, Bundle에 저장할 수 없는 객체라면 변경해주어야 함

Parcelize

  • 저장할 클래스에 @Parcelize어노테이션을 명시하여 Parcelable을 지원
// City 클래스를 Parcelable 지원하도록 변경
@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

Saver

  • Parcelable을 지원할 수 없는 객체인 경우, Saver를 이용하여 Bundle에 저장/복원 로직을 직접 지정하도록 Compose에서 지원함

  • mapSaver : 객체의 저장/복원로직으로 Map을 사용한 Saver를 반환하는 함수

    • save : 객체의 값을 Map에 저장한 후에 Map을 반환하도록 로직 구성
    • restore : 데이터 복원시에 save에서 반환한 Map을 받아서 다시 객체로 구성
data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    // stateSaver 파라미터를 이용하여 상태 저장/복원에 사용할 Saver를 지정
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}
  • ListSaver : 객체의 저장/복원로직으로 List를 사용한 Saver를 반환하는 함수
    • save : 객체의 값을 List에 저장한 후에 List를 반환하도록 로직 구성
    • restore : 데이터 복원시에 save에서 반환한 List를 받아서 다시 객체로 구성
data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

2022년 4월 15일 금요일

[안드로이드] Compose 2 - 생명주기

 

목차

  1. Compose 시작
  2. Compose의 생명주기
  3. Compose에서의 상태(State)
  4. 렌더링 수행 과정
  5. 부수효과(Side-Effect)
  6. 시맨틱(Semantics)
  7. 아키텍쳐 레이어
  8. 로컬 범위지정(Scope) 데이터

Compose 생명주기

개요



  1. 최초 1회 컴포지션
  2. (변경사항이 있는 경우) 리컴포지션 (0~n번)
  3. 컴포지션 해제/종료

컴포지션(Composition)

  • Composable 함수를 트리 구조로 변환하는 작업. 이렇게 변환된 트리구조는 내부적으로 인스턴스화 되며, 이 인스턴스로 Compose 프레임워크가 화면에 렌더링을 수행
  • 컴포지션은 Composable 함수가 처음 호출될 때 수행
  • 컴포지션은 Composable 함수가 호출되는 위치마다 각각 수행(여러개의 인스턴스가 생성됨)
    • Compose 컴파일러가 Composable 함수가 호출되는 위치(Call Site)와 호출순서를 Key로 하여, 컴포지션 인스턴스를 관리

컴포지션 예시

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

// 최초 호출시 Composition 수행됨
MyComposable()


리컴포지션(Recomposition)

  • 컴포지션으로 생성된 트리 인스턴스를 변경하여 트리 인스턴스를 다시 구성하는 작업.
    • 변경이 필요한 경우, 기존 인스턴스를 재사용하지 않고 새로운 인스턴스를 생성
    • 트리의 모든 인스턴스를 재생성하지 않고, 변경이 있는 인스턴스만 재생성
  • 리컴포지션의 수행 시점
    • 인자가 있는 Composable 함수에서, 인자가 변경되어 함수가 다시 호출되었을 때
    • 함수 내부에서 State객체를 사용하는 경우, State객체의 값이 변경되었을 때

리컴포지션 예시 - 유지되는 인스턴스와 변경된 인스턴스

@Composable
fun MyComposable(text: String) {
    Column {
        Text("Hello")
        Text(text)
    }
}

// MyComposable()을 파라미터 바꿔서 두번 호출
val list = listOf("Kotlin", "Android")
for (text in list) {
    MyComposable(text)
}



스마트 리컴포지션 (Smart Recomposition)

  • 스마트 리컴포지션 : Composable함수가 호출될 때마다 무조건 리컴포지션을 수행하면 인스턴스가 재생성되어 비효율적이므로, 변경이 필요한 경우에만 리컴포지션을 수행하는 것
  • 리컴포지션을 건너뛰는 경우 (모든 조건을 만족해야 함)
    • 호출 위치/순서가 변경되지 않음.
    • 파라미터가 Stable 타입
    • 파라미터의 값이 변경되지 않음. (equals() 값이 동일)

Stable 타입

  • 다음 특성을 만족하는 데이터타입으로, Compose 컴파일러는 @Stable 어노테이션을 붙은 클래스/인터페이스를 stable한 타입으로 인식
    • equals() 메서드로 두 인스턴스의 동일성이 항상 보장됨
    • public 프로퍼티가 변경되면, 컴포지션이 해당 변경사항을 알림받을 수 있어야 함.
    • 모든 public 프로퍼티가 stable 타입
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}
  • @Stable 어노테이션 없어도 stable 타입으로 인식되는 경우
    • 모든 primitive 타입 : Boolean, Int, Long, Float, Char 등..
    • 문자열 (String)
    • 모든 함수 유형(람다)

Composable의 별도 값을 식별자로 사용하기 - key() 함수

  • Compose 컴파일러는 Composable 함수의 호출순서가 달라지면 리컴포지션을 수행함
@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        // movies 리스트의 정렬을 바꾼다면, 기존의 호출순서와 달라지므로 MovieOverview()가 리컴포지션을 수행
        for (movie in movies) {
            MovieOverview(movie)
        }
    }
}



  • Compose 컴파일러가 호출순서 대신에 별도의 식별자를 이용하여 컴포지션을 관리하도록, key() Composable 함수를 지원. 식별자로 사용할 값을 key() 함수의 첫번째 인자로 전달한다.
@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        // movies 리스트의 정렬을 바꾼다면, 기존의 호출순서와 달라지므로 MovieOverview()가 리컴포지션을 수행
        for (movie in movies) {
            // movie 객체의 식별자(id)를 인자로 넘겨준다.
            key (movie.id) {
                MovieOverview(movie)
            }
        }
    }
}



2022년 4월 3일 일요일

[안드로이드] Compose 1 - 시작

목차

  1. Compose 시작
  2. Compose의 생명주기
  3. Compose에서의 상태(State)
  4. 렌더링 수행 과정
  5. 부수효과(Side-Effect)
  6. 시맨틱(Semantics)
  7. 아키텍쳐 레이어
  8. 로컬 범위지정(Scope) 데이터

Jetpack Compose 시작

Jetpack Compose?

  • 안드로이드의 View 시스템을 대체하기 위한 새로운 패러다임의 UI Kit로 구글에서 2019년
  • 선언형(Declarative) API 구조를 제공

명령형 UI vs 선언형 UI

명령형 UI

  • 뷰에게 직접 내부의 상태를 변경하도록 명령하는 구조.
    • ex) 텍스트를 변경 (setText()), 자식 뷰를 추가 (addChild()), 이미지 변경 (setImageBitmap()) 등..
  • 무엇(What) 을 그리는지 보다는 어떻게(How) 그리는지에 더 중점을 가지고 있음.

선언형 UI

  • 기존 명령형 구조의 단점들을 해결하기 위해서 새롭게 도입된 패러다임
  • 제공되는 데이터를 가지고 뷰가 무엇을 그리면 될지를 선언하는 구조
  • 어떻게(How) 그릴지는 프레임워크에게 위임해버리고, 무엇(What) 을 그리는지에 중점을 가지고 있음.

왜 Compose가 도입되었을까?

기존의 View UI 시스템의 문제점

  • 기존의 View 시스템은 명령형(Imperative) API 구조
  • 이 구조는 소프트웨어 유지보수를 어렵게 하는 문제가 있음.
    • 데이터를 여러 위치에서 렌더링하는 경우에 뷰 갱신 누락 등의 실수할 여지
    • 여러 위치에서 UI업데이트를 수행하는 경우에 예상하지 못한 충돌 발생 여지
  • xml을 이용하여 정적 UI를 구성해야하므로 UI코드가 산재되어 있음
    • 동적 컨텐츠를 표시하려면 xml선언된 뷰를 객체화하여 사용해야하는 번거로움이 있음.
    • DataBinding으로 번거로움을 어느정도 해결했지만, xml 자체의 가독성이 안좋아짐

선언형 UI 프레임워크를 사용하면서 오는 장점들

  • 기존 View 시스템 대비 작성해야 하는 코드가 현저히 줄어들게 됨
    • 작성하는 코드가 적을수록 버그 발생확률이 감소
  • 주어진 데이터로 화면에 무엇을 보여줄지만 신경쓰면 되므로, 직관적인 코드구조를 유지할 수 있음.

그 외에 Compose 고유의 장점들

  • UI를 kotlin으로만 작성
    • 관리포인트 일원화
    • 코드가독성
    • 언어레벨의 유연함을 활용할 수 있음. - 분기, 반복 등
  • 기존의 View UI 시스템과 상호호환 가능

첫번째 Compose 화면 구성하기

  • Compose 프레임워크는 Composable 함수를 호출하여 화면 렌더링을 수행
  • Composable 함수 : Compose 에서 화면을 구성하는 단위로, 화면이 어떻게 구성될지를 표현한 함수

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            firstComposable("World")
        }
    }
}

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


Composable 함수 작성의 기본 원칙

  • 함수에 @Composable 어노테이션을 지정하여 Composable 함수임을 명시
  • Composable 함수 내에서 다른 Composable 함수를 호출하여 UI 계층 구조를 구성
  • Compose 프레임워크는 Text()LazyComlumn()Button() 등, 화면에 표시하기 위한 컴포넌트들을 제공
    • View 시스템에서 제공하는 위젯(뷰)들처럼 제공됨
    • 이 컴포넌트들도 결국은 Composable 함수
    • 이 컴포넌트들의 종류와 사용법을 익혀야 함
  • 일반함수처럼 Composable 함수도 인자(데이터)를 받을 수 있어서, 받은 데이터를 이용하여 화면을 렌더링하도록 함수를 작성. 반면에 Composale 함수는 반환값이 필요없음
  • Composable 함수는 동일한 인자로 호출할 경우에 동일한 동작(렌더링)을 수행되어야 하고, 글로벌 변수나 random()함수 등의 외부요인으로 인해 동작이 변경되어서는 안됨!