https://pl-coding.com/jetpack-compose-mistakes
이 글은 위의 Philip Lackner 님의 글을 번역 및 정리한 글 입니다.
20가지를 하나의 글에 모두 적을 경우 그 양이 너무 많아지는 관계로 분할하여 작성하도록 하겠습니다.
중간 중간에 등장하는 이러한 인용문은 정리하면서 해당 방법에 대해 덧붙힐만한 내용들을 추가한 것 입니다.
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() 함수가 다시 호출되는 것을 막을 수 있습니다.
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
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
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
Using unstable classes from external modules
Compose 에는 stability 와 unstability 라는 개념이 있습니다.
간단하게 말하자면, 해당 클래스가 다음의 3가지 조건들을 만족할 경우, Compose Compiler 에 의해 stable 하다고 간주 됩니다. (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 를 붙혀줄 수 있도록 해주는 것도 좋은 대안일 것 같습니다.
다음 글
맛있는 포스팅이네요.
잘봤습니다~!