2022년 5월 29일 일요일

[안드로이드] Compose 4 - 렌더링 수행 과정

 

목차

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

Compose 렌더링 수행 과정

프레임의 3가지 단계(Phase)

  1. Composition : 표시할 UI를 결정하는 단계. Composable 함수를 실행하고 UI의 설명(descrtiption)을 생성.
  2. Layout : UI의 위치를 측정(measuring)하고 배치(placement)하는 단계. 각 요소들을 2D좌표계로 측정&배치 수행.
  3. Drawing : 렌더링을 수행하는 단계. UI 요소들은 일반적으로 디바이스의 화면인 Canvas에 그려짐.


  • 대부분의 Compose 컴포넌트들은 데이터를 이 단계의 순서(Composition -> Layout -> Drawing)로 한방향으로만 전달해서 하나의 프레임을 구성.
    • BoxWithConstraintsLayzColumn & LazyRow는 위의 흐름을 따르지않는 대표적인 예외 컴포넌트 - 하위요소의 Composition이 상위요소의 Layout 단계에 따라 달라짐
  • Compose 컴포넌트가 화면에 표시될 때마다 위의 단계를 반복적으로 수행하는데, 성능개선을 위해 같은 입력(데이터)으로 인한 프레임구성을 최대한 수행하지 않도록 최적화 되어있음.

상태 읽기 (State reads)

  • 상태(State)는 일반적으로 mutableStateOf() 함수를 이용하여 생성 (Compose에서의 상태 참고)
  • 상태를 읽는 방법은 2가지
    • value필드를 직접 사용
    • 코틀린의 프로퍼티 위임 기법 사용
// value 필드 직접 읽기
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)
// 프로퍼티 위임으로 읽기
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)
  • Compose는 상태를 읽을 때 어떤 동작을 하고있었는지(프레임 구성의 어떤 단계였는지)를 추적하여, 상태가 변하는 경우에 그 단계부터 다시 수행
  • 이 상태는 어떤 단계에 있는가? : 상태를 읽는 시점이 Composable 함수 내에 어떤 스코프인가?

단계별 상태 읽기

Composition 단계에서의 읽기

  • @Composable 함수/람다 내에서 상태를 읽는 경우를 의미
  • 상태값의 변화를 감지하면 Recomposer는 Composable함수의 재실행을 예약
    • Composition -> Layout -> Drawing의 세 단계를 모두 다시 실행할 수도 있음.
    • 컨텐츠가 변경되지 않고, 크기/레이아웃이 변경되지 않았으면 이후 단계는 스킵할 수 있음
@Composable
fun showHello() {
    var padding by remember { mutableStateOf(8.dp) }
    Text(
        text = "Hello",
        // `padding` 상태값은 Composable함수내에서 값을 읽었으므로, Composition 단계의 읽기
        // modifier를 생성할 때 `padding`의 값이 변경되었으면, recomposition을 수행하도록 예약
        modifier = Modifier.padding(padding)
    )
}

Layout 단계에서의 읽기

  • Layout의 두 단계 - 측정(measurement) & 배치(placement)
  • Layout의 단계 내에서 상태를 읽는 경우를 의미
  • 상태값이 변경되면 Compose는 Layout단계를 예약
    • Layout -> Drawing의 단계를 다시 실행할 수도 있음
    • 크기/위치의 변경이 없으면 이후 단계는 스킵할 수 있음
var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // `offsetX` 상태는 배치 단계에서 값을 읽었으므로, Layout 단계의 읽기
        // `offsetX`가 변경되면 layout을 다시 수행
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Drawing 단계에서의 읽기

  • Drawing 람다/함수 내에서 상태를 읽는 경우를 의미
  • 상태값이 변경되면 Drawing 단계를 예약
    • 가장 마지막 단계이므로 다른단계에는 영향을 미치지 않음
var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // `color` 상태는 Canvas람다 내부에서 상태를 읽었으므로, Drawing 단계의 읽기
    // `color`가 변화하면 drawing을 다시 수행
    drawRect(color)
}

최적화

  • 각 단계에서 필요한 상태에 대해서만 읽기를 수행하면 불필요한 렌더링 단계를 줄일 수 있음

예시 - 사용자 스크롤에 따라 offset을 변화하는 경우

  • Composition 단계에서의 상태읽기 - 전체 단계가 다시 수행됨
    • 데이터(값)이 변경되는 경우가 아니라면, Composition부터 수행하는 것은 불필요한 동작
Box {
    val listState = rememberLazyListState()

    Image(
        //최적화 필요한 구현
        Modifier.offset(
            with(LocalDensity.current) {
                //firstVisibleItemScrollOffset 상태값을 Composition 단계에서 읽고 있음.
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState)
}
  • 최적화 - firstVisibleItemScrollOffset 상태를 Layout 단계에서 읽도록 변경
    • 불필요한 Composition 단계를 수행하지 않음
Box {
    val listState = rememberLazyListState()

    Image(
        Modifier.offset {
            //firstVisibleItemScrollOffset 상태값을 Layout 단계에서 읽고 있음. (Modifier.offset의 람다 안)
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState)
}

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