Android/Compose

Compose 기초 (Compose이해, Composable function, Recomposition)

네모메모 2024. 5. 5. 17:42
반응형

 

 

 

 

 

 

 

 

 

 

 

이전편에 이어 다시보는

Compose 란?

  • Jetpack Compose는 Android를 위한 현대적인 선언형 UI 도구 키트
  • 사용자 인터페이스의 상태와 레이아웃을 설명하는 구조화된 데이터나 코드를 사용하여 UI를 정의 
  • [동작] 처음부터 화면 전체를 개념적으로 생성한 후 -> 특정 시점에선 필요한 변경사항(UI의 어떤 부분을 다시 그려야 하는지)만 지능적으로 선택하여 화면을 재구성(=Recomposition)한다.
  • [동작 구현] 구성 가능한 함수(=Composable 함수)를 '단순히 호출'하는 형태로 UI 렌더링 및 재구성
      ex) `@Composable TextView()`를 호출

 


 Composable function (= 구성 가능한 함수)

  개인적으로 '구성 가능한 함수'라는 번역은 매번 부르기 어려워  'Composable function' 또는 'Composable 함수' 라고 하겠습니다 

 

  • Compose가 UI 렌더링 및 재구성하기 위해 사용하는 함수
  • 모든 구성 가능한 함수에는 @Composable 주석으로 주석을 지정해야 한다. 
    (이 주석이 이 함수가 데이터를 UI로 변환하기 위한 함수라는 것을 Compose 컴파일러에 알려주는 역할을 함)

  • ex) 데이터를 전달받고 이를 사용하여 화면에 텍스트 위젯을 렌더링하는 간단한 구성 가능한 함수

  • Composable 함수는 매개변수를 통해 데이터를 받으며, 이를 통해 앱 로직이 UI를 형성할 수 있다.
    위 예에서 위젯은 사용자의 이름을 부르면서 환영할 수 있도록 String을 받습니다.
  • Composable 함수는 다른 Composable 함수를 호출하여 UI 계층 구조를 내보낸다.
    위 예에서 함수는 UI에 텍스트를 표시합니다. 이를 위해 실제로 텍스트 UI 요소를 생성하는 Text() 구성 가능한 함수를 호출합니다. 
  • UI를 내보내는 Composable 함수는 (UI 위젯을 구성하는 대신 원하는 화면 상태를 설명하므로) 아무것도 리턴할 필요가 없다.
  • Composable 함수는 빠르고 Idempotent(=멱등원)이며 Side Effects(=부작용)이 없어야 한다.
    • Idempotent하고 Side Effects가 없어야 하는 이유? 
      Recomposition(=재구성) 시 변경 없는 함수를 건너뛰고 재구성될 수 있으므로 Composable 함수 실행의 부작용에 의존해서는 안된다.
    • Idempotent이란? 
      : 함수를 여러번 호출하더라도 항상 동일한 방식으로 작동하며 연산 결과는 같아야 하는 특성
      ㄴ>
      이를 위해서 동작 과정에서 전역 변수 또는 random() 호출과 같은 다른 값을 사용하지 않습니다.
    • Side Effects(=부수효과, 부작용)이란?  
      : 함수는 속성 또는 전역 변수 수정등 외부의 변수를 함수 안에서 조작하는 것
    • 일반적으로 모든 'Composable 함수'는 Recomposition에서 설명한 이유로 인해 위 2가지 속성을 지키며 작성해야 한다.

 

 

 Composable function 특징

1.  Composable 함수는 순서와 관계없이 실행가능하다.

  • 일반적으로는 '코드 상에서의 호출 순서'와 '컴포저블의 그리기 순서'가 일치함 
    Ex1) 탭 레이아웃에 세 개의 화면을 그리는 코드
      ㄴ> 별도의 설정이 없다면 순서대로 호출한 순서대로 StartScreen(), MiddleScreen(), EndScreen()이 그려진다.
@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

 

  • Compose에는 일부 UI 요소가 다른 UI 요소보다 우선순위가 높다는 것을 인식하고 그 요소를 먼저 그리는 옵션이 존재
    Modifier.drawWithContent() 메서드 : 특정 UI 요소를 먼저 그리는 옵션
      ㄴ> Ex1-1) Ex1에서  MiddleScreen을 먼저 그리도록 수정한 코드
@Composable
fun ButtonRow() {
    MyFancyNavigation {
        MiddleScreen() // MiddleScreen을 먼저 그림
        StartScreen()
        EndScreen()
    }
}

@Composable
fun MiddleScreen() {
    Box(
        modifier = Modifier
            .drawWithContent {
                // MiddleScreen을 먼저 그리고 나머지를 그림
                drawContent()
            }
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Middle Screen")
    }
}

@Composable
fun StartScreen() {
    Box(
        modifier = Modifier
            .background(Color.Green)
            .padding(16.dp)
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Start Screen")
    }
}

@Composable
fun EndScreen() {
    Box(
        modifier = Modifier
            .background(Color.Blue)
            .padding(16.dp)
            .fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "End Screen")
    }
}

    ㄴ> MiddleScreen을 먼저 그리고, StartScreen과 EndScreen을 순서대로 그린다.

 

 

 

 

 


2.  Composable 함수는 동시에 실행할 수 있음

  • Compose가 멀티스레딩을 활용하여 UI를 효율적으로 처리할 수 있는 내부적인 동작 메커니즘을 제공한다
더보기

Compose는 Composable 함수를 동시에 실행하여 Recomposition을 최적화할 수 있다. 이를 통해 Compose는 다중 코어를 활용하고 화면에 없는 Composable함수를 낮은 우선순위로 실행할 수 있습니다.

이 최적화는 구성 가능한 함수가 백그라운드 스레드 풀 내에서 실행될 수 있음을 의미합니다. 구성 가능한 함수가 ViewModel에서 함수를 호출하면 Compose는 동시에 여러 스레드에서 이 함수를 호출할 수 있습니다.

애플리케이션이 올바르게 작동하도록 하려면 모든 구성 가능한 함수에 부작용이 없어야 합니다. 대신 UI 스레드에서 항상 실행되는 onClick과 같은 콜백에서 부작용을 트리거합니다.

 

  • 개발자가 직접적으로 Compose에서 스레드를 다루는 것은 일반적으로 권장되지 않는다!! => 대신에 Compose는 백그라운드에서 비동기 작업을 처리하고 UI 업데이트는 메인(UI) 스레드에서 처리하는 등의 내부적인 최적화를 수행한다.

 

<<결론적으로>>

  • Composable 함수 내에서 비동기 작업이 필요한 경우 해당 작업을 다른 곳에서 처리하고, UI 업데이트를 위한 상태나 이벤트를 Composable 함수에 전달하는 것이 좋은 설계
    • = 비동기 작업은 ViewModel 또는 Repository와 같은 다른 구성 요소에서 코루틴 같은 API를 사용해 처리하고,
      그 결과를 Composable 함수에 전달하여 UI를 업데이트하는 것이 좋은 방법

 

ex) 목록과 개수를 표시하는 컴포저블 코드

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}
 

ㄴ> 이 코드는 부작용이 없으며 입력 목록을 UI로 변환한다. (작은 목록을 표시할 때 유용한 코드)

 

Bad Ex) 함수가 로컬 변수에 쓰는 경우 이 코드는 스레드로부터 안전하지 않거나 적절하지 않다.

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}
 

ㄴ>  myList 매개변수에 20개의 아이템을 전달한다고 가정해 보자.
그러면 아이템을 출력하는 동안 items 변수가 총 20번 증가할 것. 그러나 실제로 Compose는 UI를 다시 그릴 때마다 함수가 다시 호출되므로, 이 코드에서는 각 UI 재구성마다 items 변수가 20번씩 더해져서 매번 UI를 다시 그릴 때마다 Count에 표시되는 값은 계속해서 증가할 것이다.

즉, 예상결과는 UI를 다시 그릴 때마다 Count에 표시되는 값이 계속해서 증가할 것입니다.

이 때문에 이와 같은 쓰기는 Compose에서 지원되지 않습니다. 이러한 쓰기를 금지함으로써 프레임워크가 구성 가능한 람다를 실행하도록 스레드를 변경할 수 있습니다.

 

 

 

3.  Composable 함수는 매우 자주 실행될 수 있음

  • 경우에 따라 Composable함수는
    (빈번한 케이스로는) UI 애니메이션의 모든 프레임에서 실행될 수 있으며,
    (비용이 큰 케이스로는) 기기 저장소에서 읽기와 같이 비용이 많이 드는 작업을 Composable함수가 실행하면 이로 인해 UI 버벅거림이 발생할 수 있다.
  • Ex case) 위젯이 기기 설정을 읽으려고 하면 잠재적으로 이 설정을 초당 수백 번 읽을 수 있으며 이는 앱 성능에 치명적인 영향을 줄 수 있다.
  • Composable 함수에 데이터가 필요하다면 데이터 타입의 매개변수를 정의해야 하고, 비용이 많이 드는 작업을 구성 외부의 다른 스레드로 이동하고 mutableStateOf 또는 LiveData를 사용하여 Compose에 데이터를 전달할 수 있습니다.
    ex) 비용이 많이 드는 작업을 구성 외부의 다른 스레드로 이동하고 ViewModel 안에 선언된 private한 MutableLiveData를 사용하여 Compose에 데이터를 전달하는 예제  

import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

// 데이터를 관리하는 ViewModel 클래스 정의
class MyViewModel : ViewModel() {
    // Compose에 전달할 데이터를 관리하기 위한 MutableLiveData
    private val _data = MutableLiveData<String>()
    val data: LiveData<String> = _data

    init {
        // 예시로 초기 데이터 설정
        _data.value = "Initial data"
    }

    // 비용이 많이 드는 작업을 수행하는 함수
    fun fetchData() {
        viewModelScope.launch(Dispatchers.IO) {
            // 비용이 많이 드는 작업을 수행하고 결과를 LiveData에 업데이트
            val result = "Expensive operation result"
            _data.postValue(result)
        }
    }
}

// Composable 함수에 데이터를 전달하여 사용하는 예제
@Composable
fun ExampleComposable(data: String) {
    Text(text = "Data from ViewModel: $data")
}

// ViewModel을 사용하는 예제
@Composable
fun ExampleScreen(viewModel: MyViewModel) {
    val data by viewModel.data.observeAsState()

    // Composable 함수에 데이터 전달
    ExampleComposable(data ?: "")

    // 버튼을 클릭하여 비용이 많이 드는 작업 실행
    Button(onClick = { viewModel.fetchData() }) {
        Text("Fetch Data")
    }
}

ㄴ> ViewModel 클래스 내부에 MutableLiveData를 사용하여 데이터를 관리한다.
ㄴ> fetchData() 함수는 비용이 많이 드는 작업을 수행하고, 작업이 완료되면 LiveData를 업데이트한다.

ㄴ> ExampleScreen Composable 함수는 ViewModel의 데이터를 observe하여 UI에 표시하고, 버튼을 클릭하여 비용이 많이 드는 작업을 실행합니다. 이를 통해 Composable 함수에 데이터를 전달하고, 비용이 많이 드는 작업을 다른 스레드에서 실행하여 UI를 업데이트할 수 있다.

 

 

 

 

 

 


Recomposition을 설명하기에 앞서,,

위젯을 변경하려면 ??

  • 명령형 UI 모델에선 
    • 위젯의 속성을 변경하는 setter 함수를 호출하여 내부 상태를 변경합니다
  • 선언형 UI 모델인 Compose에선
    •  새로운 데이터를 사용하여 Composable 함수를 다시 호출 -> 이렇게 하면 함수가 재구성(be recomposed)되며, 필요한 경우 함수에서 내보낸 위젯이 새 데이터로 다시 그려지는데  Compose 프레임워크에 의해 변경된 구성요소만 지능적으로 재구성할 수 있다.
  • ex)  버튼을 표시하는 Composable 함수
@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}
 

버튼이 클릭될 때마다 호출자는 clicks 값을 업데이트하고, Compose는 Text 함수를 사용해 람다를 다시 호출하여 새 값을 표시한다.
이 프로세스를 Recomposition(=재구성)이라고 하며, 값에 종속되지 않은 다른 함수는 Recomposition되지 않습니다.

 

 

 


 

이렇게 전체 UI 트리를 재구성하는 작업은 컴퓨팅 성능 및 배터리 수명을 사용한다는 측면에서 컴퓨팅 비용이 많이 들 수 있는데
Compose는 이 지능적 Recomposition(=재구성)을 통해 이 문제를 해결한다. 👇


 

Recomposition (=재구성)

  • 입력이 변경될 때 구성 가능한 함수를 다시 호출하는 프로세스
  • 함수의 입력이 변경될 때 발생합니다.
  • Compose는 새 입력을 기반으로 재구성할 때 변경되었을 수 있는 함수 또는 람다만 호출하고 나머지는 건너뜁니다. 매개변수가 변경되지 않은 함수 또는 람다를 모두 건너뜀으로써 Compose의 재구성이 효율적으로 이루어질 수 있습니다.
  • Recomposition(=재구성)을 건너뛸 수 있으므로 Composable 함수 실행의 Side Effects(=부작용)에 의존해서는 안 됩니다. 그렇게 하면 사용자가 앱에서 이상하고 예측할 수 없는 동작을 경험할 수 있습니다.
    Side Effects(=부작용)은 앱의 나머지 부분에 표시되는 변경사항입니다.



 

[ 위험한 Side Effects(=부수효과, 부작용)의 대표적인 예 ]

  • 공유 객체의 속성에 쓰기
  • ViewModel에서 식별 가능한 요소 업데이트
  • 공유 환경설정 업데이트

 

  • 비용이 많이 드는 작업을 실행해야 하는 경우, 백그라운드 코루틴에서 작업을 실행하고 값 결과를 Composable함수에 매개변수로 전달한다.
     Ex) 컴포저블을 생성하여 SharedPreferences의 값을 업데이트해야 하지만, Composable은 공유 환경설정 자체에서 읽거나 쓰지 않아야 합니다. 때문에 아래 코드는 백그라운드 코루틴의 ViewModel로 읽기 및 쓰기를 이동합니다.
    구현부
@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

 

 

호출부

@Composable
fun MyScreen(viewModel: MyViewModel) {
    val context = LocalContext.current

    var isChecked by remember { mutableStateOf(false) }

    // SharedPreferences의 값을 읽어와 초기 상태 설정
    LaunchedEffect(key1 = true) {
        isChecked = viewModel.readSharedPreferences()
    }

    // SharedPreferences 값 변경 시 호출되는 콜백
    val onValueChanged: (Boolean) -> Unit = { newValue ->
        isChecked = newValue
        // ViewModel을 통해 SharedPreferences에 쓰기 작업 수행
        viewModel.writeSharedPreferences(newValue)
    }

    // SharedPrefsToggle 컴포저블 호출
    SharedPrefsToggle(
        text = "Toggle Text",
        value = isChecked,
        onValueChanged = onValueChanged
    )
}

 

 

 


 

Recomposition (=재구성) 특징

1.  Recomposition은 가능한 한 많이 건너뜀

  • UI의 일부가 잘못된 경우, Compose는 업데이트해야 하는 부분만 재구성하기 위해 최선을 다한다.
    즉, UI 트리에서 위 또는 아래에 있는 컴포저블을 실행하지 않고 단일 버튼의 컴포저블을 다시 실행하는 것을 건너뛸 수 있다.
    ㄴ> 예를 들어) 부모 컴포저블이나 상태가 변경되었을 때 자식 컴포저블의 재구성이 필요하지 않은 경우, Compose는 해당 자식 컴포저블의 재구성을 스킵한다.
  • ex) 목록을 렌더링할 때 재구성이 일부 요소를 건너뛸 수 있는 방법을 보여주는 예
/**
 * Display a list of names the user can click with a header
 */
@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.bodyLarge)
        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)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

ㄴ> "NamePicker" 컴포저블은 "header"와 "names"라는 두 가지 입력을 받는데 "header"는 변경될 수 있지만, "names"는 변경되지 않다.
=> 그 결과 "header"(Mutable)가 변경될 때마다 "NamePicker"가 다시 그려지지만, "names"(Immutable)가 변경될 때마다는 그려지지 않습니다. 이것이 재구성이 일부 요소를 건너뛸 수 있는 방법을 보여주는 예입니다.

 

ㄴ> 또한 "NamePickerItem"은 "NamePicker"의 각 이름에 대한 개별적인 아이템을 표시합니다. 이 아이템은 클릭 가능하며, 클릭하면 해당 이름을 처리하는 함수가 호출됩니다. "NamePickerItem"은 각 이름이 변경될 때마다 재구성됩니다.

 


 

다시다시 말하지만, Composable 함수 또는 람다를 실행하는 작업에는 SideEffects부작용이 없어야 합니다.
부작용을 실행해야 할 때는 콜백에서 부작용을 트리거해야 합니다.

 


2.  Recomposition 낙관적임

  • Compose는 매개변수가 다시 변경되기 전에 Recomposition을 완료할 것으로 낙관적인 예상을 한다.
  • (위 낙관적인 예상을 뒤엎어) Recomposition이 완료되기 전에 매개변수가 변경되면,
    'Recomposition을 취소(Recomposition cancellation)'하고 새 매개변수를 사용하여 재구성을 다시 시작할 수 있다.
  • 표시되는 UI에 종속되는 부작용이 있다면 구성이 취소된 경우에도 부작용이 적용됩니다. 이로 인해 일관되지 않은 앱 상태가 발생할 수 있다.
      => 낙관적 Recomposition을 처리할 수 있도록 모든 Composable 함수 및 람다가 멱등원이고 부작용이 없는지 확인해야 한다.

 


 

Recomposition cancellation(=재구성 취소) 란?

  • UI가 Recomposition(=재구성)되기 시작한 후에 이를 중단하는 과정
  • Recomposition은 Compose에서 UI를 업데이트하는 과정을 나타내며, 상태가 변경되면 Compose는 UI를 다시 그리기 위해 Recomposition을 시작합니다. 그러나 Recomposition이 시작된 후에도 변경 사항이 계속되면 UI를 다시 그리는 과정이 중단될 수 있습니다. 이것이 "Recomposition cancellation (재구성 취소)"입니다.
  • [재구성이 취소되는 경우]
  1. 재구성을 취소하는 조건이 충족될 때:
    예를 들어, 사용자가 화면을 벗어난 경우나 화면 간 전환 중에 재구성이 불필요한 경우가 있을 수 있습니다. 이러한 경우에는 취소 조건이 충족되면 재구성이 중단됩니다.
  2. 재구성의 시작 이후에 변경 사항이 취소되는 경우:
    예를 들어, 상태가 변경된 후에 다시 동일한 상태로 변경되는 경우, Compose는 재구성을 시작했지만 변경 사항이 취소되면 재구성이 취소됩니다.
  • 재구성 취소는 UI 업데이트의 효율성을 향상시키고 성능을 최적화하는 데 사용된다.
    변경 사항이 더 이상 필요하지 않을 때 재구성을 취소함으로써 불필요한 UI 업데이트를 방지하여 앱의 반응성을 향상시킬 수 있다.
 
 
 
 

 

 

 

 

 

 


출처


https://developer.android.com/develop/ui/compose/mental-model

 

Compose 이해  |  Jetpack Compose  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Compose 이해 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Jetpack Compose는 Android를 위한 현대적인 선언

developer.android.com

 

반응형