Recomposition
: 상태 변경에 따라 변경되었을 수 있는 컴포저블을 다시 실행한 다음 변경 사항을 반영하도록 컴포저블을 업데이트하는 것
State<T>
를 통해서 Recomposition이 발생생명주기
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput()
}
@Composable
fun LoginInput() { /* ... */ }
@Composable
fun LoginError() { /* ... */ }
// Column
@Composable
fun MoviesScreenWithKey(movies: List<Movie>) {
Column {
for (movie in movies) {
key(movie.id) { // 고유 ID를 Key로 지정
MovieOverview(movie)
}
}
}
}
// LazyColumn
@Composable
fun MoviesScreenLazy(movies: List<Movie>) {
LazyColumn {
items(movies, key = { movie -> movie.id }) { movie ->
MovieOverview(movie)
}
}
}
Recomposition시 Skip이 불가능한 케이스
Unit
타입인 경우@NonRestartableComposable
/@NonSkippableComposable
Annotation이 정의되어 있는 경우non-stable
타입인 경우위 요구사항을 완하하는 Strong Skipping 모드가 존재 (experimental)
Stable으로 판단 되는 케이스
불변 타입에 대해서만 Stable하다고 판단
Stable하며 Mutable한 타입 : Compose의 MutableState 유형
모든 입력이 안정적이고 변경되지 않은 경우, Composable의 Recomposition을 건너뜁니다. 비교는
equals
함수를 사용
Side-effect : Composable 함수의 범위 밖에서 발생하는 앱 상태의 변경
Composable에서 suspend 함수를 실행할 수 있음
@Composable
fun MyScreen(
state: UiState<List<Movie>>,
snackbarHostState: SnackbarHostState
) {
// UI 상태에 오류가 있는 경우 Snackbar 표시
if (state.hasError) {
// snackbarHostState가 변경되면 Launch가 재시작
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
message = "Error message",
actionLabel = "Retry message"
)
}
}
...
}
@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {
// MoviesScreen의 생명주기에 바인딩된 CoroutineScope 생성
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { contentPadding ->
Column(Modifier.padding(contentPadding)) {
Button(
onClick = {
// 이벤트 핸들러에서 새 Coroutine을 생성하여 SnackBar 표시
scope.launch {
snackbarHostState.showSnackbar("Something happened!")
}
}
) {
Text("Press me")
}
}
}
}
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
// Recomposition된 최신 onTimeout 함수를 참조
val currentOnTimeout by rememberUpdatedState(onTimeout)
// LandingScreen의 수명 주기와 일치하는 효과를 만듦
// LandingScreen이 Recomposition되면 delay는 다시 시작되지 않아야 함
LaunchedEffect(true) {
delay(SplashWaitTimeMillis)
currentOnTimeout()
}
...
}
DisposableEffect
를 사용하여 이벤트 수신을 대기, 필요에 따라 관찰자를 등록하고 등록 취소 가능@Composable
fun HomeScreen(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
onStart: () -> Unit,
onStop: () -> Unit
) {
// 현재 Lambda를 안전하게 업데이트
val currentOnStart by rememberUpdatedState(onStart)
val currentOnStop by rememberUpdatedState(onStop)
// LifecycleOwner가 변경되면 Effect를 제거 및 초기화
DisposableEffect(lifecycleOwner) {
// remember callback을 트리거하는 Observer 생성
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
currentOnStart()
} else if (event == Lifecycle.Event.ON_STOP) {
currentOnStop()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
// Effect가 Composition에서 사라지면 Observer를 제거
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
...
}
@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
val analytics: FirebaseAnalytics = remember {
FirebaseAnalytics()
}
// Composition 성공할 때마다 FirebaseAnalytics 업데이트
SideEffect {
analytics.setUserProperty("userType", user.userType)
}
return analytics
}
awaitDispose
함수를 사용remember { mutableStateOf(initialValue) }
를 사용하는 result
변수를 보유하며 LaunchedEffect
에서 producer
블록을 트리거
// 네트워크에서 이미지를 로드하는 예시
// 다른 Composable에서 사용할 수 있는 State를 반환
@Composable
fun loadNetworkImage(
url: String,
imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
// 초기값으로 Result.Loading을 가진 State<T>를 생성
// `url` 또는 `imageRepository`가 변경되면 실행 중인 produce가 취소
// 새 입력값으로 다시 시작
return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
// suspend 함수 호출
val image = imageRepository.load(url)
// 상태 업데이트로 Recomposition이 트리거됨
value = if (image == null) {
Result.Error
} else {
Result.Success(image)
}
}
}
비용이 많이 발생
하므로 결과가 변경되지 않은 경우 불필요한 리컴포지션을 방지하기 위해서만 사용해야 함@Composable
// 파라미터가 변경되면 MessageList가 Recomposition 됨
// derivedStateOf는 이 Recomposition에 영향을 주지 않음
fun MessageList(messages: List<Message>) {
Box {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
// firstVisibleItemIndex가 0보다 큰 경우에 버튼을 표시
// remember + derivedStateOf를 사용하여 불필요한 Composition을 최소화
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
}
}
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // 잘못된 사용
val fullNameCorrect = "$firstName $lastName" // 올바른 사용
Compose의 주요 단계
이전 결과를 재사용할 수 있으면 Composable 함수 실행을 건너뛴다. Compose UI는 꼭 필요한 경우가 아니라면 전체 트리를 다시 배치하거나 다시 그리는 작업을 하지 않는다.
트리 탐색
레이아웃 노드 정보
https://developer.android.com/static/develop/ui/compose/images/layout.mp4
트리가 위에서 아래로 다시 탐색되고 각 노드가 차례로 화면에 그려진다
https://developer.android.com/static/develop/ui/compose/images/drawing.mp4
// MutableState 프로퍼티를 직접 접근
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(paddingState.value)
)
// MutableState Delegate 사용
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
text = "Hello",
modifier = Modifier.padding(padding)
)
1단계) Composition
2단계) Layout
MeasureScope.measure
메소드 등을 실행layout
함수의 배치 블록, Modifier.offset { … }
의 람다 블록 등을 실행3단계) Draw
Canvas()
, Modifier.drawBehind
, Modifier.drawWithContent
등)Composables 함수에서는 remember API를 사용해 객체를 메모리에 저장할 수 있다. remember에 의해 계산된 값은 초기 Composition에 저장되며, 저장된 값은 Recomposition 중에 반환된다. remember API는 mutable/immutable 객체를 저장하는 데 사용할 수 있다.
mutableStateOf
는 MutableState
를 생성
value
를 읽는 Composable 함수의 Recomposition이 예약된다interface MutableState<T> : State<T> {
override var value: T
}
API 사용법
val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }
remember : configuration 변경 시 상태가 유지되지 않음
rememberSaveable : configuration 변경 시 상태가 유지됨
MutableState
State 객체를 읽어오는 과정에서 자동으로 Recomposition된다
Compose에서 상태 호이스팅은 Composable을 스테이트리스(Stateless)로 만들기 위해 상태를 Composable의 호출자로 옮기는 패턴
특징
단방향 데이터 흐름 (unidirectional data flow) : 상태는 내려가고 이벤트가 올라가는 패턴
상태를 끌어올릴 때 상태의 이동 위치를 쉽게 파악할 수 있는 규칙
- 상태는 적어도 그 상태를 사용하는 모든 컴포저블의 가장 낮은 공통 상위 element로 올려야 한다(읽기).
- 상태는 최소한 변경될 수 있는 가장 높은 수준으로 올려야 한다(쓰기).
- 동일한 이벤트에 대한 응답으로 두 상태가 변경되는 경우 두 상태를 함께 올려야 한다.
rememberSaveable
API : 저장된 인스턴스 상태 메커니즘을 사용하여 Recomposition/Activity 및 프로세스 재생성 전반에 걸쳐 상태를 유지
상태 저장 지원 타입
Bundle
에 저장할 수 있는 값으로 변환하는 커스텀 규칙을 정의 가능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() {
var selectedCity = rememberSaveable(stateSaver = CitySaver) {
mutableStateOf(City("Madrid", "Spain"))
}
}
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"))
}
}
추적할 상태의 양이 늘어나거나 Composable 함수에서 실행할 로직이 발생하는 경우 로직과 상태 책임을 다른 클래스, 즉 상태 홀더에 위임을 추천
상태 홀더 : Composable의 로직과 상태를 관리합니다.
remember API에 고유 키값을 전달함으로써, 키 변경시 Lambda 블럭을 재실행할 수 있다.
@Composable
inline fun <T : Any?> remember(
key1: Any?,
crossinline calculation: @DisallowComposableCalls () -> T
): T
rememberSavable API에도 동일한 기능이 존재한다. remember의 keys 정의와 같은 목적으로 inputs 파라미터를 전달받을 수 있다. inputs 값이 변경되면 캐시 데이터도 무효화된다.
@Composable
fun <T : Any> rememberSaveable(
vararg inputs: Any?,
saver: Saver<T, Any> = autoSaver(),
key: String? = null,
init: () -> T
): T
Android 가이드 문서상의 기본 내용
UI 상태는 UI 상태를 읽고 쓰는 모든 컴포저블의 가장 낮은 공통 상위 element로 호이스팅해야 합니다. 상태는 상태가 소비되는 위치에서 가장 가까운 곳에 유지해야 합니다. 상태 소유자로부터 소비자에게 변경 불가능한 상태 및 이벤트를 노출하여 상태를 수정합니다.
UI를 설명하는 속성
상태를 읽거나 써야 하는 경우, UI의 생명 주기에 따라 UI 상태 범위를 지정해야 함
@Composable
fun ChatBubble(
message: Message
) {
// UI element 상태
var showDetails by rememberSaveable { mutableStateOf(false) }
ClickableText(
text = AnnotatedString(message.content),
onClick = { showDetails = !showDetails } // 간단한 UI 로직
)
...
}
Composable의 책임을 늘리면 상태 홀더의 필요성이 증가
State Owner로서의 ViewModel
ViewModel
에서 UI 상태를 호이스팅하면 상태가 Composition 외부로 이동된다.가장 낮은 공통 상위 element
가 됨Screen UI 상태
ViewModel
)에서 호이스팅됨을 의미ViewModel
에서 호이스팅된 Screen UI 상태를 소비ViewModel
인스턴스를 주입하여 비즈니스 로직 접근을 허용class ConversationViewModel(
channelId: String,
messagesRepository: MessagesRepository
) : ViewModel() {
val messages = messagesRepository
.getLatestMessages(channelId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// Business logic
fun sendMessage(message: Message) { /* ... */ }
}
@Composable
private fun ConversationScreen(
conversationViewModel: ConversationViewModel = viewModel()
) {
val messages by conversationViewModel.messages.collectAsStateWithLifecycle()
ConversationScreen(
messages = messages,
onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
)
}
@Composable
private fun ConversationScreen(
messages: List<Message>,
onSendMessage: (Message) -> Unit
) {
MessagesList(messages, onSendMessage)
/* ... */
}
Property drilling
UI element 상태
주의 사항
ViewModel
로 호이스팅하려면 고려사항이 필요함CoroutineScope
에서 호출하는 경우 예외를 발생 가능
https://developer.android.com/develop/ui/compose/state-hoisting#caveat
상태는 호이스팅된 위치와 필요한 로직에 따라 각각의 API를 사용하여 UI 상태를 저장/복원 가능
상태 손실이 발생할 수 있는 이벤트
UI 로직
비즈니스 로직
ViewModel에서 호스팅된 경우, 기존 API를 활용
Event | UI 로직 | 비즈니스 로직 (ViewModel) |
---|---|---|
Configuration 변경 | rememberSaveable | 자동으로 대응 됨 |
시스템에 의한 프로세스 종료 | rememberSaveable | SavedStateHandle |
Compose
상태가 아래로 흐르고 이벤트가 위로 흐르는 디자인 패턴
해당 패턴의 이점
Composable 파라미터 정의
분리와 재사용을 위해 각 Composable은 가능한 한 최소한의 정보만 담아야 한다
앱에 대한 모든 입력은 이벤트로 표시되어야 한다. 이벤트는 UI의 상태를 변경하므로 ViewModel이 이를 처리하고 UI 상태를 업데이트해야 한다.
상태 및 이벤트 핸들러 람다에 불변 값을 전달하는 것을 권장
ViewModel, 상태, 이벤트
Runtime
UI
Foundation
Material
Control
Customization
// Gradient Button
@Composable
fun GradientButton(
// …
background: List<Color>,
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
) {
Row(
// …
modifier = modifier
.clickable(onClick = {})
.background(
Brush.horizontalGradient(background)
)
) {
CompositionLocalProvider(/* … */) { // material LocalContentAlpha 적용
ProvideTextStyle(MaterialTheme.typography.button) {
content()
}
}
}
}
// Material 개념이 없는 기본 Button
@Composable
fun BespokeButton(
// …
backgroundColor: Color,
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
) {
Row(
// …
modifier = modifier
.clickable(onClick = {})
.background(backgroundColor)
) {
// Material components를 미사용
content()
}
}
정확한 추상화 선택
CompositionLocal은 Composition을 통해 암시적으로 데이터를 전달하기 위한 도구
Compose에서 데이터는 UI 트리를 통해 각 Composable 함수에 파라미터로 전달 -> Composable의 종속성이 명확해짐 -> 색상/Type 스타일과 같이 매우 빈번하고 널리 사용되는 데이터의 경우 번거로움
MaterialTheme도 CompositionLocal을 사용
// MaterialTheme 구조의 일부 Composable
@Composable
fun SomeTextLabel(labelText: String) {
Text(
text = labelText,
// primary : MaterialTheme의 LocalColors CompositionLocal에서 가져옴
color = MaterialTheme.colors.primary
)
}
CompositionLocal 인스턴스는 Composition의 일부로 범위에 따라 다른 값을 제공 가능
새로운 CompositionLocal 값 사용
@Composable
fun CompositionLocalExample() {
MaterialTheme { // MaterialTheme는 ContentAlpha.high를 기본값으로 설정
Column {
// Uses MaterialTheme's provided alpha
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
// Medium value provided for LocalContentAlpha
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
// This Text uses the disabled alpha now
}
}
}
}
}
중간 계층이 파라미터의 존재를 인식하면 Composable의 유용성이 제한될 수 있으므로, 중간 계층이 파라미터의 존재를 인식하지 않아야 할 때 CompositionLocal을 사용할 수 있다
CompositionLocal 단점
사용에 적합한 조건
잘못된 사례 : ViewModel을 가지는 CompositionLocal 생성
생성 API
CompositionLocal에 제공된 값이 변경될 가능성이 거의 없거나 변경되지 않을 경우, staticCompositionLocalOf를 사용하여 성능상의 이점 확보 가능
// LocalElevations.kt file
data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)
// 기본값으로 CompositionLocal 객체를 정의
// 이 인스턴스는 앱의 모든 Composable에서 접근 가능
val LocalElevations = compositionLocalOf { Elevations() }
CompositionLocalProvider composable은 주어진 계층 구조에 CompositionLocal 인스턴스에 값을 바인딩. CompositionLocal 키를 값에 연결하는 provides infix 함수를 사용하면 됨
val elevations = /** 인스턴스 생성 */
// elevations을 LocalElevations의 값으로 바인딩
CompositionLocalProvider(LocalElevations provides elevations) {
// LocalElevations.current에 접근시, elevations 인스턴스가 사용됨
}
CompositionLocal.current는 해당 CompositionLocal에 값을 제공하는 가장 가까운 CompositionLocalProvider가 제공하는 값을 반환
@Composable
fun SomeComposable() {
Card(elevation = LocalElevations.current.card) {
// Content
}
}
CompositionLocal 대신 다른 대안
@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
// ...
ReusableLoadDataButton(
onLoadClick = {
myViewModel.loadData()
}
)
}
@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
Button(onClick = onLoadClick) {
...
}
}
NavHost(startDestination = "profile/{userId}") {
...
composable(
"profile/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("userId"))
}
}
탐색시 복잡한 데이터 대신 고유 식별자/ID 등 최소한의 정보만 argument로 전달
할 것을 강력히 권장
navController.navigate("profile/user1234")
탐색 후 목적지에 도착하면 ViewModel의 SavedStateHandle을 사용하여 arugment를 사용할 수 있다
class UserViewModel(
savedStateHandle: SavedStateHandle,
...
) : ViewModel() {
private val userId: String = checkNotNull(savedStateHandle["userId"])
...
}
composable(
"profile?userId={userId}",
arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("userId"))
}
composable() 함수의 deepLinks 파라미터에 navDeepLink() 메소서드를 사용하여 정의 가능
val uri = "https://www.example.com"
composable(
"profile?id={id}",
deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
Profile(navController, backStackEntry.arguments?.getString("id"))
}
기본적으로 외부에 노출하지 않으므로 manifest.xml에 정의 필요.
<activity …>
<intent-filter>
...
<data android:scheme="https" android:host="www.example.com" />
</intent-filter>
</activity>
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Graph의 시작 위치로 popUp하여 사용자가 항목을 선택할 때
// 스택에 목적지가 많이 쌓이는 것을 방지
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// 동일한 항목을 다시 선택할 때
// 동일한 대상의 복사본을 여러 개 만들지 않도록 정의
launchSingleTop = true
// 이전에 선택한 항목을 다시 선택할 때 상태 복원
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
composable(Screen.Profile.route) { Profile(navController) }
composable(Screen.FriendsList.route) { FriendsList(navController) }
}
}
기본 타입 안정성을 보장하지않으므로, 타입 안정함을 보장되도록 Navigation을 정의 가능
https://www.youtube.com/watch?v=goFpG25uoc8
Compose와 Navigation component를 작성시 2가지의 선택이 가능
모든 화면이 Composable인 경우에만 가능
Navigate
// In compose
@Composable
fun MyScreen(onNavigate: (Int) -> Unit) {
Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}
// In fragment
override fun onCreateView( /* ... */ ) {
setContent {
MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
}
}
Navigation 코드를 Composable destination에서 분리하여 NavHost Composable과 별도로 각 Composable을 개별적으로 테스트 가능
// In Profile composable
@Composable
fun Profile(
userId: String,
navigateToFriendProfile: (friendUserId: String) -> Unit
) {
…
}
// In NavHost
composable(
"profile?userId={userId}",
arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
Profile(backStackEntry.arguments?.getString("userId")) { friendUserId ->
navController.navigate("profile?userId=$friendUserId")
}
}
dependencies {
// ...
androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
// ...
}
navigation testing 라이브러리에서 TestNavHostController를 사용하여 테스트
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: TestNavHostController
@Before
fun setupAppNavHost() {
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
AppNavHost(navController = navController)
}
}
// Unit test
@Test
fun appNavHost_verifyStartDestination() {
composeTestRule
.onNodeWithContentDescription("Start Screen")
.assertIsDisplayed()
}
}
목적지를 확인, 예상 경로와 현재 경로를 비교하는 등의 테스트가 가능
navController를 사용하여 현재 문자열 경로를 예상 경로와 비교 가능
@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
composeTestRule.onNodeWithContentDescription("All Profiles")
.performScrollTo()
.performClick()
val route = navController.currentBackStackEntry?.destination?.route
assertEquals(route, "profiles")
}
comments powered by Disqus
Subscribe to this blog via RSS.
LazyColumn/Row에서 동일한 Key를 사용하면 크래시가 발생하는 이유
Posted on 30 Nov 2024