https://pl-coding.com/jetpack-compose-mistakes
이 글은 위의 Philip Lackner 님의 글을 번역 및 정리한 글 입니다.
이어서 작성하도록 하겠습니다.
Consuming Flows with CollectAsState()
// BAD
class CounterViewModel: ViewModel() {
private val _counter = MutableStateFlow(0)
val counter = _counter
.onEach {
saveCounterToDb(it)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
}
@Composable
fun Counter() {
val viewModel = viewModel<CounterViewModel>()
val counter by viewModel.counter.collectAsState()
Text(text = counter.toString())
}
Compose 에선 collectAsState() 라는 함수를 통해 Flow 를 Compose 의 State로 변환할 수 있습니다. 하지만 Android 프로젝트에서는 해당 함수를 피하는 것이 좋습니다.
왜냐하면 CollectAsState() 함수는 Activity 의 생명주기에 대해서 알지 못하기 때문입니다. 생명주기에 대해서 알지 못하기 때문에 Activity 가 background 로 내려갔을 때에도, Flow 가 여전히 실행하고 있을 것 입니다. 유저가 UI에 변화를 보고 있지 않는 상황에서도 말입니다.
(위의 문제는 asStateFlow()로 생성된 StateFlow 에는 영향을 주지 않으며, 오직 stateIn()으로 생성된 StateFlow 에게 영향을 줍니다.)
위의 예제 코드를 실행할 경우 앱이 background에 내려가게 되어도, 여전히 counter Flow 는 실행되며 DB 작업을 수행할 것입니다. 이는 종종 원하지 않는 동작일 수 있습니다.
//GOOD
@Composable
fun Counter() {
val viewModel = viewModel<CounterViewModel>()
val counter by viewModel.counter.collectAsState()
Text(text = counter.toString())
}
CollectAsStateLifecycle() 함수를 사용할 수 있습니다. 해당 함수를 사용하면 Lifecycle 에 대해 알고 있기 때문에 앱이 background 에 내려갈 경우 Flow 가 실행되지 않을 것 입니다.
Compose 에서는 변환 애니메이션(회전, 크기 변화, 이동)을 구현하는 여러 방법이 존재합니다.
transform modifier 들을 직접 사용할 수 있으며, 또는 graphicsLayer modifier 를 사용하는 방법도 존재 합니다. 왜 transform modifier 들을 직접 사용하는게 문제가 될 수 있는지 알아봅시다.
// BAD
@Composable
fun RotatingBox() {
val transition = rememberInfiniteTransition()
val rotationRatio by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(5000)
)
)
Box(modifier = Modifier
.rotate(rotationRatio * 360f)
.size(100.dp)
.background(Color.Red)
)
}
@Composable
fun InfiniteTransition.animateFloat(
initialValue: Float,
targetValue: Float,
animationSpec: InfiniteRepeatableSpec<Float>,
label: String = "FloatAnimation"
): State<Float> =
animateValue(initialValue, targetValue, Float.VectorConverter, animationSpec, label)
rotationRatio 가 변할 때 마다, Box Composable 은 재구성 될 것 입니다. RotationRatio 라는 변경되는 State 를 사용하고 있기 때문입니다.
// GOOD
@Composable
fun RotatingBox() {
val transition = rememberInfiniteTransition()
val rotationRatio by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(5000)
)
)
Box(modifier = Modifier
// This is better
.graphicsLayer {
rotationZ = rotationRatio * 360f
}
.size(100.dp)
.background(Color.Red)
)
}
graphicsLayer modifier 를 사용할 경우 Composable 의 외관(appearance)이 변하지 않는다면 재구성이 발생하지 않습니다. Composable 은 회전 후에도 같은 모습을 하고 있을 것입니다. 따라서 graphicsLayer 를 사용할 수 있으며, 애니메이션 효과는 똑같지만 많은 횟수의 재구성이 발생하지 않을 것입니다.
따라서 clipping(컨텐츠를 잘라내는 것), trasfrom, alpha(투명도) 값 변경이 발생하는 경우 graphicLayer 를 사용하세요.
Creating ViewModels on screen level with hilt
뷰모델은 Android 에서 상태를 저장하고 관리하기 위해 사용되는 전형적인 수단입니다.
여전히 많은 온라인에서의 예시들이 뷰모델을 다음과 같이 초기화 하는 것을 볼 수 있을 것 입니다.
// BAD
@Composable
fun LoginScreen() {
val viewModel: LoginViewModel = hiltViewModel()
val state = viewModel.state
Column {
if (state.isLoading) {
CircularProgressIndicator()
}
Button(onClick = { viewModel.login() }) {
Text(text = "Login")
}
}
}
뷰모델이 Screen 의 생성자에 주입되는 한, Screen 의 프리뷰를 확인할 수 없으며, 고립된 UI Test도 불가능해질 것 입니다. Screen 자체가 고립되어 있지 않기 때문입니다. Screen 을 사용하기 위해선 항상 앱의 나머지 부분들과 ViewModel 을 초기화 하기 위한 Dagger Hilt 에 대한 문맥을 알아야 필요가 있습니다.
// GOOD
sealed interface LoginEvent {
object Login: LoginEvent
// More events...
}
class LoginViewModel: ViewModel() {
var state by mutableStateOf(LoginState())
private set
fun onEvent(event: LoginEvent) {
when(event) {
is LoginEvent.login -> { /* Handle login */ }
// ,,,
}
}
}
Compose 프로젝트의 경우, 위의 예제와 같이 뷰모델을 구성하는 것을 추천합니다.
각각의 뷰모델은 Screen 의 State 객체를 가지고 있으며, onEvent 함수를 노출합니다. onEvent 함수는 사용자가 수행하는 다양한 액션들을 수신할 수 있습니다.
// GOOD
@Composable
fun LoginScreen(
state: LoginState,
onEvent: (LoginEvent) -> Unit
) {
Column {
if (state.isLoading) {
CircularProgressIndicator()
}
Button(onClick = { onEvent(LoginEvent.Login) }) {
Text(text = "Login")
}
// ...
}
}
그리고 나서, ViewModel 객체를 Screen 에 직접 전달하는 것 대신에, state와 onEvent 람다를 전달하세요. 그러고 나면 프리뷰와 UI Test(뷰모델 객체를 가지지 않는) 를 위한 Screen 을 쉽게 초기화할 수 있습니다.
// GOOD
@Composable
fun LoginScreenRoot() {
val viewModel = hiltViewModel<LoginViewModel>()
LoginScreen(
state = viewModel.state,
onEvent = viewModel::onEvent
)
}
이후에, 별도의 Screen 을 래핑하는 Composable 을 만들어줘야 합니다. 해당 Composable 에서 안전하게 ViewModel 을 초기화 할 수 있습니다. 해당 LoginScreenRoot Composable 은 프리뷰와 고립된 UI Test 등이 필요 하지 않기 때문입니다.
Setting expanding sizes in Sub-Composables
Compose의 Modifier 는 강력합니다. 하지만 또한 종종 잘 못 쓰여지고 있습니다. Compose 의 주요한 목표 중 하나는 만든 component 들을 재사용 가능하도록 하는 것입니다. 그러나 종종 아래와 같은 코드를 확인할 수 있습니다.
// BAD
@Composable
fun MyButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier
.clip(RoundedCornerShape(100))
.fillMaxWidth()
) {
Text(text = "Cool button")
}
}
재사용성을 위해선 가장 바깥 쪽에 사이즈를 확장하는 modifier 를 사용하는 것을 피하세요.
위의 예제와 같은 방식에서의 fillMaxWidth() modifier 사용은 각각의 버튼을 항상 부모에 전체 가로 길이를 채우도록 강제하므로, 다른 버튼을 옆에 배치할 수 없습니다.
// GOOD
@Composable
fun MyButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier
.clip(RoundedCornerShape(100))
) {
Text(text = "Cool button")
}
}
MyButton(
onClick = { /*TODO*/ },
modifier = Modifier.fillMaxWidth() // <- better
)
재사용 가능한 Composable 의 최상위 modifier 에 크기를 확장하는 modifier 를 하드 코딩하지 않는 것이 좋습니다. 대신에 유연 가능하도록 이를 외부에서 전달하는 것을 추천합니다.
재사용 가능한 Composable 들의 핵심적인 특징이 크기라면 해당 Composable들의 크기를 고정할 수 있습니다. 그리고 모든 객체는 항상 같게 보일 것 입니다.
modifier 는 이제 각각의 독립적인 버튼에 대해 쉽게 적용할 수 있습니다.
Not using remember for heavy computations
remember 함수는 재구성이 발생할 때 계산된 값을 캐시(저장)할 수 있습니다. 이는 매우 유요합니다. 하지만 많은 사람들이 무거운 연산 작업을 수행 할 때 Remember 를 사용하지 않습니다. 이는 앱 그리고 UI 에 성능 저하를 야기할 수 있습니다.
// BAD
@Composable
fun EncryptedImage(
encryptedBytes: ByteArray,
modifier: Modifier = Modifier
) {
val decryptedBytes = CryptoManager.decrypt(encryptedBytes)
val bitmap = BitmapFactory.decodeByteArray(decryptedBytes, 0, decryptedBytes.size)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = modifier
)
}
위의 예제 코드에서는 재구성이 될 때마다 바이트가 복호화 될 것입니다. 이는 많은 양의 연산을 필요로 합니다.
// GOOD
@Composable
fun EncryptedImage(
encryptedBytes: ByteArray,
modifier: Modifier = Modifier
) {
val bitmap = remember(encryptedBytes) {
val decryptedBytes = CryptoManager.decrypt(encryptedBytes)
BitmapFactory.decodeByteArray(decryptedBytes, 0, decryptedBytes.size)
}
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = modifier
)
}
key 값이 변경될 때, 연산이 다시 수행되도록 remember 를 key 와 함께 사용하는 것이 좋습니다.
이 코드는 단지 설명을 위한 예시일 뿐이며, 실제로 복호화와 같은 작업들은 다른 분리된 클래스에서 발생해야 하며, 뷰모델로 부터 호출되어야 합니다.
이 것은 UI 와 연관된 무거운 연산 작업에도 적용될 수 있습니다.
derivedStateOf 를 사용해도 좋을 것 같습니다.
이에 대한 자세한 내용은 아래 글을 참고해 주세요.
https://proandroiddev.com/deep-dive-into-derivedstateof-and-comparison-with-remember-key-d8469602676
derivedStateOf는 언제 써야 할까?
다음 글
와우 웹 서칭 하다가 이 글을 봐버렸네요.
필립 래크너씨 대단한 사람인 지는 알았는 데 이런 개꿀팁이 있을 줄이야.
거기다가 한국어로 해석된 지훈씨 글까지 ㅎㄷㄷㄷㄷ
다음에 정리해서 블로그에 써야겠습니다.
바로 즐찾~
이 글을 보고 보니 Component에 fillMaxWIdth(), fillMaxSize() 덕지덕지 붙여놨는데 추후에 수정해야겠네요...
하...