Jetpack Compose 에서 절대로 해서는 안되는 20가지 실수 (1~5)

이지훈·2023년 10월 30일
10
post-custom-banner

서두

https://pl-coding.com/jetpack-compose-mistakes

이 글은 위의 Philip Lackner 님의 글을 번역 및 정리한 글 입니다.

20가지를 하나의 글에 모두 적을 경우 그 양이 너무 많아지는 관계로 분할하여 작성하도록 하겠습니다.

중간 중간에 등장하는 이러한 인용문은 정리하면서 해당 방법에 대해 덧붙힐만한 내용들을 추가한 것 입니다.

1. Composable 함수에서 Compose가 아닌 코드를 호출 하는 것

Calling Non-Compose code in Composable functions

// BAD
@Composable
fun BookingList() {
    val scope = rememberCoroutineScope()
    var bookings by remember {
        mutableStateOf<List<Booking>>(emptyList())
    }
    
    scope.launch { 
        bookings = loadBookings()
    }
    
    LazyColumn {
        items(bookings) {
            // ... 
        }
    }
}

다음과 같이 Composable 함수내에서 Compose 가 아닌 코드를 호출하는 코드가 있습니다.
이 BookingList라는 Composable 함수가 재구성 될 때마다, 새로운 코루틴이 시작되고 Booking 목록을 로드하는 긴 네트워크 요청이 시작하게 될 것입니다. 이것은 끔찍합니다.

// GOOD
@Composable
fun BookingList() {
    var bookings by remember {
        mutableStateOf<List<Booking>>(emptyList())
    }
    
    LaunchedEffect(true) {
        bookings = loadBookings()
    }
    
    LazyColumn {
        items(bookings) {
            //...
        }
    }
}

LaunchedEffect 와 DisposableEffect 와 같은 Jetpack Compose의 Effect Handler를 사용하세요.

LaunchedEffect는 주어진 key에 의존하여 그 key가 변경될 때만 내부의 로직을 실행합니다. 위의 코드에서 key는 true 로 고정된 값이 때문에 BookingList Composable 함수가 재구성 되었을 때 loadBookings() 함수가 다시 호출되는 것을 막을 수 있습니다.

2. MutableList 를 State 로 사용하는 것

Using MutableList as a State

// BAD
@Composable
fun NamesList() {
    val names by remember {
        mutableStateOf(mutableListOf<String>())
    }

    LazyColumn {
        item {
            Button(onClick = { names.add("Hans") }) {
                Text(text = "Add name")
            }
        }
        items(names) { name ->
            Text(name)
        }
    }
}

Compose의 State가 어떻게 동작하는지 잘 못 이해할 경우 많은 버그와 의도되지 않은 동작을 이끌 수 있습니다.

위 예시에서 button을 클릭하고 나서 name 이 names 리스트에 추가될 것입니다. 하지만 실제 리스트는 재구성 되지 않습니다. Compose 가 MutableList 와 같은 mutable data type 일 경우, 변화를 감지하지 못하기 때문입니다.

// GOOD
@Composable
fun NamesList() {
    var names by remember {
        mutableStateOf(listOf<String>())
    }

    LazyColumn {
        item {
            Button(onClick = { 
                names = names + "Hans"
            }) {
                Text(text = "Add name")
            }
        }
        items(names) { name ->
            Text(name)
        }
    }
}

그 대신에, State 를 immutable list 로 만드세요. State 가 변하고, 새로운 값으로 대체 될 때마다, Compose 는 변화를 감지하고 해당 State를 사용하는 Composable 들은 재구성 될 것입니다. immutable list 를 mutable list 와 동일한 방식으로 조작할 수 있습니다. 단, 변경될 때마다 리스트의 새 인스턴스가 생성됩니다.

번외로 Compose Codelab에서 소개된 SnapshotStateList 를 제공하는 mutableStateListOf 를 사용하여 위의 문제를 해결할 수 있을 것입니다.
자세한 내용은 아래 링크에서 확인할 수 있습니다.
https://developer.android.com/codelabs/jetpack-compose-state?hl=ko#10
https://nanamare.tistory.com/242
https://developer.android.com/codelabs/jetpack-compose-state?hl=ko#10

3. Remember 를 이용하여 State 를 만드는 것

Creating State with Remember

// BAD
@Composable
fun LoginScreen() {
    var emailText by remember {
        mutableStateOf("")
    }

    TextField(
        value = emailText,
        onValueChange = { emailText = it }
    )
}

Compose 에서 State 를 만드는 가장 흔한 방법은 remember 를 사용하는 것 입니다. 그것 자체로는 틀린 방법은 아닙니다. remember 에 의해서 재구성 될 때 마다 State 가 다시 만들어지진 않기 때문입니다.

그러나, 실제 앱에서는 이 것이 역효과를 일으키지 않는지 생각해야 합니다. 왜냐하면 remember 는 구성 변경이나 프로세스 종료가 발생하지 않는 한 모든 재구성에 대한 값을 캐시(저장)하기 때문입니다.

// GOOD

class ViewModel(
    private val savedStateHandle: SavedStateHandle
): ViewModel() {
    val emailText by savedStateHandle.saveable("emailText") {
        mutableStateOf("")
    }
    
    fun onEmailTextChange(value: String) {
        savedStateHandle["emailText"] = value 
    }
}

@Composable
fun AppRoot() {
    val navController = remmeberNavController()
    NavHost(
        navController = navController,
        startDestination = "login"
    ) {
        composable("login") {
            val viewModel = viewModel<LoginViewModel>()
            LoginScreen(
                emailText = viewModel.emailText,
                onEmailTextChange = viewModel::onEmailTextChange
            )
        }
    }
}

@Composable
fun LoginScreen(
    emailText: String,
    onEmailTextChange: (String) -> Unit
) {
    TextField(
        value = emailText,
        onValueChane = onEmailTextChange
    )
}

그 대신에 State 를 뷰모델에 저장하는 것을 추천합니다. 또는 State를 Composable 내에 가지고 싶은 경우에는 구성 변경이 발생해도 살아남을 수 있는 rememberSaveable 을 사용하세요.

뷰모델 내에서는 SavedStateHandle을 사용할 수 있어 프로세스가 종료된 이후에도 State를 복원할 수 있습니다.

NavHost 내에서 간편하게 뷰모델을 초기화 할 수 있으며, State 를 필요로 하는 화면에 State 를 전달해 줄 수 있습니다.

위의 코드는 뷰모델을 사용할 경우의 변경된 LoginScreen Composable 함수 입니다.

savedStateHandle의 saveable() 함수에 대한 자세한 설명은 아래 글을 참고 해주세요,
https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate?hl=ko#savedstate-compose-state
https://developer.android.com/jetpack/compose/state?hl=ko#restore-ui-state

4. LazyColumn 내에서 Key 를 사용하지 않는 것

Not using keys inside a LazyColumn

@Composable
fun NoteList(notes: List<Note>) {
    LazyColumn {
        items(notes) { note ->
            // ,,,
        }
    }
}

LazyColumn 을 통해 구현한 List가 업데이트 될 때, LazyColumn 은 어떤 item 들이 변경되었는지 알 수 없기 때문에, 단지 갱신되고 모든 화면에 보이는 item 을 재구성 합니다.

@Composable
fun NoteList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                note.id
            }
        ) { note ->
            // ,,,
        }
    }
}

그 대신에, LazyColumn 에게 각각의 아이템을 판별할 수 있는 고유한 값을 알려 주기 위한 key 람다를 사용하세요. 예를 들어 각각의 note 의 고유한 id 를 전달 해 줄 수 있습니다.

이 방식을 통해 LazyColumn은 실제로 변경된 item만 재구성하게 됩니다.

bonus: animateItemPlacement() modifier 를 사용하여, list 가 변경 되는 애니메이션을 손쉽게 구현 할 수 있습니다.
https://developer.android.com/jetpack/compose/lists?hl=ko#item-animations

그밖에 다른 Compose 성능 최적화 관련한 방법들은 공식 문서를 참고하는 것을 추천드립니다.
https://developer.android.com/jetpack/compose/graphics/images/optimization?hl=ko

5. 외부 모듈로 부터 Unstable 한 class 를 사용하는 것

Using unstable classes from external modules

Compose 에는 stability 와 unstability 라는 개념이 있습니다.

간단하게 말하자면, 해당 클래스가 다음의 3가지 조건들을 만족할 경우, Compose Compiler 에 의해 stable 하다고 간주 됩니다. (stable 한 타입의 경우 아래의 내용을 준수 해야 한다는 의미 입니다.)

  1. 두 인스턴스에 대한 equals()의 결과는 항상 동일하게 반환됩니다.
  2. 타입의 public 속성(property)이 변경되면 컴포지션에 통지 됩니다.
  3. 모든 public 속성(property) 의 타입들이 stable 합니다.

세가지 조건의 대한 해석이 애매해서 원문을 첨부 하겠습니다.
1. The result of equals() will always return the same result for two instance.
2. When a public property of the type changes, composition will be notified.
3. All public property types are stable.

많은 사람들이 외부 모듈로 부터 클래스를 class 등을 사용하거나, Compose 를 사용하지 않는 라이브러리 들을 사용하는 경우, 이 것들은 기본적으로 unstable 하며, 이는 많은 사람들이 알지 못합니다.

예시)

이 앱은 외부의 non-Compose 모듈을 사용하고 있습니다. 이 예시는 일반적으로 dependency로 추가되는 non-Compose 라이브러리의 상황일 수도 있습니다.

// BAD
// from External Module
data class User(
    val id: String,
    val name: String,
    val isAdmin: Boolean,
    val profilePictureUrl: String
)

한번 가정을 해봅시다. 이 non-Compose한 모듈은 위의 User 모델을 포함하고 있습니다.

@Composable
fun UserProfile(user: User) {
    Column {
        ProfilePicture(user.profilePictureUrl)
        Text(text = user.name)
        
        if (user.isAdmin) {
            Text(text = "ADMIN")
        }
    }
}

User 모델은 이제 Composable 함수 내부에서 사용됩니다. 일반적으로 이 컴포저블은 user 모델이나 그 내부의 필드가 실제로 변경될 경우에만 재구성됩니다.

그러나 해당 User 모델을 외부 non-Compose 모듈에서 온 클래스이므로 기본적으로 Unstable 하다고 판단 되므로, 이 user 인스턴스를 사용하는 모든 컴포저블들은 User 모델의 어떠한 필드의 변경마다 재구성됩니다.

// GOOD
fun User.toComposedUser(): ComposeUser {
    return ComposeUser(id, name, isAdmin, profilePictureUrl)
}

fun ComposeUser.toUser(): User {
    return User(id, name, isAdmin, profilePictureUrl)
}

외부 모듈(라이브러리)의 User 모델을, 당신의 Compose 모듈 내의 User 모델로 매핑하는 mapper 를 만드세요. 당신의 모듈은 Compose 를 사용하고 있기 때문에 위의 언급한 Stable 하다고 판단되는 조건이 만족되어 Stable 하다고 간주될 것입니다.

@Composable
fun UserProfile(user: CompsoseUser) { ... }

그리고 나서 이 Compose User 모델을 Composable 내에서 사용하면 내부의 필드가 실제도 변경될 경우에만 재구성이 될 것 입니다.

하지만 모델의 개수가 너무 많아질 경우 mapper를 하나씩 대응하여 만들어주는 것도 많은 공수가 들 것이기 때문에, https://github.com/skydoves/compose-stable-marker 해당 라이브러리를 이용해서 domain 모델의 stable marker 를 붙혀줄 수 있도록 해주는 것도 좋은 대안일 것 같습니다.

다음 글

Jetpack Compose 에서 절대로 해서는 안되는 20가지 실수 (6~10)

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

3개의 댓글

comment-user-thumbnail
2023년 12월 5일

맛있는 포스팅이네요.
잘봤습니다~!

1개의 답글
comment-user-thumbnail
2024년 1월 28일

굳굳굳굳 잘 읽고 갑니다.
감사합니다 !!

답글 달기