Android Compose
업데이트:
카테고리: Android
/태그: Compose, Saver, Side Effect, snapshotFlow, State Hosting, UI Test, 재구성
recomposition(재구성)
View - Tool Window - layout Inspector 화면에 보이는 레이아웃이 얼마나 recompose 되었는지 확인가능하다.
컴포져블을 다시 호출할 때 함수 내부의 변수도 호출하여 내부의 선언된 함수가 제대로 변경되지 않을 수 있음
@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}
@Composable
fun LoginInput() { /* ... */ }
위의 예제와 같이 조건부에 따라 LoginError 가 있을 수 도 있고, 없을 수도 있다.
그래서 이때 Recomposition이 발생하여 Composable에 들어있는 변수가 다시 초기화된다.
그래서 Compose 내부에서는 아래와 같이 remember 와 mutableStateOf 를 같이 사용해야된다.
remember는 recomposition이 일어나도 변경되지 않고 유지된다.
val expanded = remember { mutableStateOf(false) }
remeber는 구성변경, Lazy에서 스크롤에서 벗어나고 다시 돌아올 때 내부의 상태값이 소실된다.
그래서 Acivity가 소실되고 다시 생성되도 이전에 값을 유지하고 싶으면 rememberSaveable 을 사용한다.
ViewModel
android kotlin과 다르게 compose는 선언형으로 구성되어 있어 MainActivity에서 모든 컴포넌트를 관리하는 것과 다르게 한 컴포넌트가 하나의 함수로 구성되어 있다.
그렇기 때문에 viewModel를 사용하기 위해서는 각 함수마다 의존성을 추가해주어야 한다.
- 
    의존성 추가 implementation "androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}"
- 
    함수의 인자로 viewModel 추가 @Composable fun WellnessScreen( modifier: Modifier = Modifier, viewModel: WellnessViewModel = viewModel(), ) { Column{ StatefulCounter() Spacer(modifier = modifier.height(16.dp)) WellnessTaskList(list = viewModel.tasks, onCloseTask ={task->viewModel.remove(task)}) } }
MutableList 타입은 리스트 내부도 var로 값이 변경되면 반영되지 않는다.
내부의 값도 바로 반영되고 싶으면 MutableState 타입으로 지정하여 값을 변경 시켜준다.
State
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count = 0
       Text("You've had $count glasses.")
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}
변수를 var로 지정하면 아무리 Button을 클릭해도 숫자가 올라가지 않는다.
var count by remember { mutableStateOf(0) }
val count: MutableState<Int> = remember **{** *mutableStateOf*(0) **}**
변수를 mutableState로 지정하면 정상적으로 반영된다.
by 를 사용하여 var로 지정해도 되고, = 를 사용하여 val 로 할당해도 된다.
단, val 형태는 .value 로 값을 접근해야된다.
Side Effect
- 
    LaunchedEffect composable 함수 내부에서 상태변경에 따른 비동기 작업을 실행하기 위한 side Effect Coroutine LifeCycle 과 Compose LifeCycle이 함께 묶여있다. @Composable fun MyScreen( state: UiState<List<Movie>>, snackbarHostState: SnackbarHostState ) { if (state.hasError) { // 에러가 true일 때만 실행 // hasError가 변경되면 이전의 coroutine이 취소되고 재시작 LaunchedEffect(snackbarHostState) { snackbarHostState.showSnackbar( message = "Error message", actionLabel = "Retry message" ) } } Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { contentPadding -> // ... } }LaunchedEffect블록 내부에 작성하는 이유는 error 여부가 나도, 메세지가 아직 준비되지 않았다면, if 문으로 처리한 경우 메세지가 정상적으로 나오지 않을수도 있지만,LaunchedEffect()내부의 상태를 주시하고 있다가 변경이 발생하면 블록 내부의 로직을 실행한다.@Composable fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val currentOnTimeout by rememberUpdatedState(onTimeout) // 처음 로딩될 때 로고를 2초동안 보여주고 끝남 LaunchedEffect(Unit) { delay(SplashWaitTime) currentOnTimeout() } Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null) } }LaunchedEffect의 값을 한번만 처리하고 싶으면 true,Unit을 인자로 넘겨준다.Suspend 함수나 시간적으로 delay가 되는 함수를 넘겨준다면 remeberUpdatedState를 이용하여 람다식을 기억해야 한다.
- 
    rememberCoroutineScope composable 함수에서 비동기 작업을 실행할 때 사용 
- 
    DisposableEffect 키가 변경되거나 Composable이 Compose에서 종료되었을 때 남은 동작을 정리하기 위한 것, 함수가 종료되었을 때 메모리 낭비를 하지 않기 위한 사이드 이펙트 fun rememberMapViewWithLifecycle(): MapView { val context =LocalContext.current return remember{ MapView(context).apply{ id= R.id.map onCreate(Bundle()) } } }어떤 lifeCycle과도 연결되어 있지 않음, mapView만 기억하고, onCreate()를 호출하는 로직임 제대로 lifeCycle과 연결시켜주기 위해서는 하나하나 마다 일일히 mapView의 상태를 연결시켜줘야한다. private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle()) Lifecycle.Event.ON_START -> mapView.onStart() Lifecycle.Event.ON_RESUME -> mapView.onResume() Lifecycle.Event.ON_PAUSE -> mapView.onPause() Lifecycle.Event.ON_STOP -> mapView.onStop() Lifecycle.Event.ON_DESTROY -> mapView.onDestroy() else -> throw IllegalStateException() } }@Composable fun rememberMapViewWithLifecycle(): MapView { val context = LocalContext.current val mapView = remember { MapView(context).apply { id = R.id.map } } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(key1 = lifecycle, key2 = mapView) { val lifecycleObeserver = getMapLifecycleObserver(mapView) lifecycle.addObserver(lifecycleObeserver) onDispose { lifecycle.removeObserver(lifecycleObeserver) } } return mapView }LaunchedEffect와 하는 동작은 비슷하나, 재시작이나 종료되었을 때 작동할 onDispose구문이 필요하다.
- 
    produceState 
ComposeState가 아닌 값 State 로 반환시킨다.
비동기적으로 값을 가져오며, 상태가 변경되면 ui에 반영한다.
@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
		// uiState 초기값 지정
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // 도시의 상세정보의 결과에 따라 초기값이 달라짐
				val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }
		
		// 도시 상세정보가 들어있다면, 지도를 랜더링하고 아니면 로딩화면을 보여준다.
    when {
        (uiState.cityDetails != null) -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}
- 
    derivedStateOf !https://developer.android.com/static/codelabs/jetpack-compose-advanced-state-side-effects/img/2c112d73f48335e0.gif 스크롤이 특정위치에 도달하면 하단에 상단으로 바로가는 버튼이 보여진다. 처음 아이템이 지났는지 확인해야 한다. → LazyColumn-LazyListStateval showButton = listState.firstIndex > 0와 같은식으로 하면 firstIndex가 바뀔 때 마다 recompose가 발생하게 된다.derivedStateOf를 사용하여 Compose의 상태를 다른 곳으로 전달을 원할 때만 요청한다?
위와같이 계산로직으로 진행하면 값이 변경되는 매순간마다 호출해야되지만, 후자의 경우는 recomposition 마다 실행되지는 않으므로, 함수의 호출횟수를 최소한으로 줄일 수 있다.
조건부에 따라 함수 상태를 변경해야되는 경우에는 derivedStateOf 사용을 고려해볼 수 있다.
State Hosting
- State는 내려보내고, event는 올려보낸다 → 실제 데이터를 사용하는 함수에서는 Stateless 하게 된다.
- 재사용성이 높고, 테스트가 편해진다.
- State는 공유될 수도, 가로챌수도 있다.
- 단일 소스에서 오기(SSOT)때문에 신뢰성 ⬆️
@Composable
fun Cart(viewModel: CartViewModel = viewModel()) {
	// ViewModel에서 데이터를 총괄하여 관리함
	val cartItems by viewModel.cartItems
	
	LazyColumn {
		items(cartItems) { item ->
			// 각각의 데이터를 하나의 아이템에 뿌려준다.
			CartItem(
				quantitiy = item.quantitiy,
				incrementQuantity = {
					viewModel.incrementQuantity(item)
				},
				decrementQuantity = {
					viewModel.decrementQuantitiy(item)
				},
			)
		}
	}
}
자식에 있는 State를 부모 Composable(viewModel)이 호출하여 저장하는 방식이다.
- Scaffold - scaffoldState
- navHost - navController
위의 두 사례도 StateHosting 이다.
compose 파일 내부에서 class를 만들어서 state holder를 제작하기 보다는 viewModel를 통해서 만드는 것이 좋다.
Saver
rememberSaveable 에서 상태의 직렬화 및 역직렬화를 담당하는 인터페이스
- save : 저장 가능한 상태로 변환
- restore : 원본 값을 복원 가능한 값으로 변환
Compose에서는 listSaver, mapSaver 를 지원한다.
remember라는 상태저장타입이 있는데, Saver까지 추가로 설정해서 rememberSaveable 를 사용하는 것일까
remember 은 Compose 함수 내부에서 상태를 보존하기에, 구성변경과 같은 상황에서는 상태가 제대로 보존되지 않을 수 있음
remember 는 해당 함수의 LifeCycle를 따르고 rememberSaveable 은 하나의 Compose(Fragment) LifeCycle를 따른다.
기존
@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) { ... }
Hint와 입력 로직을 Composable함수에서 인자로 받아서 사용하고 있었음
변경
class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)
    val isHint: Boolean
        get() = text == hint
    companion object {
        val saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1]
                )
            }
        )
    }
}
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    // 인자로 들어가는 값은 초기의 값
    rememberSaveable(hint, saver = EditableUserInputState.saver) {
        EditableUserInputState(hint, hint)
    }
@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }
함수 외부에 class로 입력 text와 hint를 State로 관리
함수에서 입력값을 관리하는 것이 아니라 최상위에서 class로 변수들을 관리함
snapshotFlow
Compose의 상태를 flow의 형태로 내보낸다.
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(currentOnDestinationChanged) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
LaunchedEffect 와 같이 사용하여 관찰자가 변경될 때 Flow를 실행시키는 것으로 활용할 수 있다.
위의 코드는 DestinationChanged 가 변경되면 즉, 새로운 목적지 값이 입력된 경우에 isHint가 아니면 StateInput의 값을 변경 시키는 로직이다.
Navigation
React의 router 설정하는 것과 유사함
- 
    의존성 추가 implementation "androidx.navigation:navigation-compose:{latest_version}"
- 
    NavController 설정 rememberNavController()를 사용해서 구성변경이 일어나도 값이 유지되도록 한다.→ 유저가 이동했던 경로가 포함되어 있는 정보이기에 항상 composable 함수의 최상단에 위치해야함 @Composable fun RallyApp() { RallyTheme { var currentScreen: RallyDestination by remember { mutableStateOf(Overview) } val navController = rememberNavController() Scaffold() {...} } }
- 
    Navigation graph에 NavHost추가Navigation의 중요부분 → NavController,NavGraph,NavHostNavController는 항상 하나의NavHost와 연관되어있음NavHost는NavGraph의 현재 목적지를 보여주고, 컨테이너 역할을 한다.composable 사이로 이동을 할 때 NavHost의 내용은 자동으로 recompose 된다.- 
        router 목록 interface RallyDestination { val icon: ImageVector val route: String val screen: @Composable () -> Unit } object Overview : RallyDestination { override val icon = Icons.Filled.PieChart override val route = "overview" override val screen: @Composable () -> Unit ={OverviewScreen()} } object Accounts : RallyDestination { override val icon = Icons.Filled.AttachMoney override val route = "accounts" override val screen: @Composable () -> Unit ={AccountsScreen()} } object Bills : RallyDestination { override val icon = Icons.Filled.MoneyOff override val route = "bills" override val screen: @Composable () -> Unit ={BillsScreen()} }
 NavHost( navController = navController, // 처음 시작 startDestination = Overview.route, modifier = Modifier.padding(innerPadding) ) { // 이동할 화면 설정 composable(route = Overview.route) { Overview.screen() } composable(route = Accounts.route) { Accounts.screen() } composable(route = Bills.route) { Bills.screen() } }→ 위의 탭한 Icon은 정상적으로 바뀌지만 화면은 바뀌지 않음 왜나면 탭의 클릭이벤트에 지정을 하지 않았기 때문에 RallyTheme { var currentScreen: RallyDestination by remember { mutableStateOf(Overview) } val navController = rememberNavController() Scaffold( topBar = { RallyTabRow( allScreens = rallyTabRowScreens, onTabSelected = { newScreen -> navController.navigate(newScreen.route) }, currentScreen = currentScreen ) } ) { innerPadding -> NavHost( navController = navController, startDestination = Overview.route, modifier = Modifier.padding(innerPadding) ) { composable(route = Overview.route) { Overview.screen() } composable(route = Accounts.route) { Accounts.screen() } composable(route = Bills.route) { Bills.screen() } } } }상단의 탭선택 시 NavController.navigate()로 클릭한 탭의 route를 넘겨주고 아래의NavHost를 통해 맞는 route의 값에 따라서 화면을 보여준다.
- 
        
- 
    
    navigate에 추가적인 옵션을 부여하여 사용하고 싶다면, 확장함수 형태로 제작후 원하는 옵션을 입력하고 navController.navigate대신 작성한 확장함수를 사용한다.// 확장 함수 제작 fun NavHostController.navigateSingleTopTo(route: String) = this.navigate(route) { popUpTo ( this@navigateSingleTopTo.graph.findStartDestination().id ) { saveState = true } launchSingleTop = true restoreState = true }- launchSingleTop: 여러번 같은 탭을 클릭해도, 최상단에 동일한 탭이 존재한다면 스택이 쌓이지 않고 하나만 존재
- popUpTo: 백스택에서 탐색하기 전에 지정한 목적지로 이동, 해당 목적지를 찾을 때 까지 일치하지 않는 대상을 모두 꺼냄- 뒤로가기를 클릭하면, 뒤에 아무리 다른 화면의 이동이 많아도, 내부에 지정된 화면으로만 이동된다.
 
- restoreState: 이전의 저장된 경로를 복원해야 되는 여부
 - 
        탭 UI 적용 var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }이전에는 onTapSelected에서 currentScreen의 값을 변경하지 않아 UI에 반영되지 않음val navController = rememberNavController() val currentBAckStack by navController.currentBackStackEntryAsState() val currentDestination = currentBAckStack?.destination val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: OverviewnavController 에서 현재 화면의 id를 받아와 상단 화면과 비교 후 맞는 화면으로 반환한다. 이러면 이전에는 currentScreen이 반영되지 않아서 상단 탭 UI가 변경되지 않았는데, 화면이 변경됨에 따라 반영을 하여 정상적으로 탭 UI가 변경되는 것을 볼 수 있다. 
 6 . 한가지 경로로 여러가지 데이터 보여주기 “route/{argument}”→ 특정 경로의 아이디에 따라서 보여주는 데이터가 달라짐NavHost() { composable( route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}", arguments = listOf( navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType} ) ) { SingAccountScreen() } }- route- 경로 형식을 작성
- arguments-- route에- {argument}로 들어가는 것에 대한 정보를 작성한다.
- 
        위의 arguments 에 대한 정보는 NavHost 가 아닌, SingleAccount에 변수로 작성해도 된다.object SingleAccount : RallyDestination { ... const val accountTypeArg = "account_type" val arguments = listOf( navArgument(accountTypeArg) { type = NavType.StringType } ) }
 그럼 이렇게 경로로 넘겨준 정보는 어디에서 확인하는가? → NavHost에서 argument에 대한 정보를 같이 넘겨줘 전달해주어야한다. composable( route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}", arguments = SingleAccount.arguments ) { navBackStackEntry -> val accountType = navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg) SingleAccountScreen(accountType) }- 해당 경로에 작성된 composable 블록 내부에서 argument를 가져와 compose 함수에 넘겨주면, route의 경로에 작성되있는 값을 가져올 수 있다.
 
- 
    route 경로 설정 대신 딥링크 설정 manifest에 추가설정<activity> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="rally" android:host="single_account" /> </intent-filter> </activity>data 필드 덕분에 url : rally://single_account로 변경되었다.그리고 더이상 composable에 argument를 작성할 필요가 없다. composable( route = SingleAccount.routeWithArgs, // arguments = SingleAccount.arguments deepLinks = listOf(navDeepLink { uriPattern = " relly://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}" }) )
- 
    Compose Navigation Test Navigation 을 최상위 Compose에서 전달받아 사용했기 때문에 Test할 때 NavController 인스턴스가 필요하지 않다. 의존성 추가 : androidTestImplementation "androidx.navigation:navigation-testing:$rootProject.composeNavigationVersion"테스트 케이스 작성 class NavigationTest { @get:Rule val composeTestRule = createComposeRule() lateinit var navController: NavHostController @Before fun setupRallyNavHost() { // Navigation에 필요한 Controller 설정 composeTestRule.setContent { navController = TestNavHostController(LocalContext.current) // NavController의 navigation을 직접 설정함 navController.navigatorProvider.addNavigator( ComposeNavigator() ) RallyNavHost(navController = navController) } } @Test fun rallyNavHost_verifyOverviewStartDestination() { composeTestRule // 실제로 이동해서 해당 Content를 클릭 .onNodeWithContentDescription("Overview Screen") .assertIsDisplayed() } @Test fun rallyNavHost_clickAllAccount_navigatesToAccounts() { composeTestRule .onNodeWithContentDescription("All Accounts") .performClick() composeTestRule .onNodeWithContentDescription("Accounts Screen") .assertIsDisplayed() } }onNodeWithContentDescription에 인자로 들어간 부분은Modifier.semantics { contentDescription = "" }으로 Modifier의 속성으로 들어가있다.
UI test
class ExampleTest {
	// UI 테스트 실행을 위해 필요한 인스턴스
	@get:Rule
	val rule = createComposeRule()
	
	// 실제 테스트를 실행하는 코드
	@Test
	fun enterFormula_showFormula() {
		// 테스트 실행에 필요한 화면 설정
		rule.setContent { MyCalculator() }
		
		rule.onNodeWithText("1").performClick()
		// 확인
		rule.assert("", "")
	}
}
compose에서 요소들은 Node 로 표현된다.
- onNodeWith… : view의 findViewById 와 같이 해당 요소를 찾는다.
    - onNodeWithText: 해당 글씨가 포함된 요소를 선택한다. 작성된 text 기준으로 찾기에, 원치않는 요소가 선택될 수 있다.
- onNode(): 인자안에 원하는 조건을 설정하여 요소를 선택할 수 있다.
- onNodeWithContentDescription: Compose에는 따로 요소에 ID를 지정할 수 없기때문에 고유한 값을 modifier.semantic { contentDescription = “” } 으로 설정한다.
 
- perform… : 선택된 요소로 어떤 동작을 할지 정의한다.
- assert : 원하는 결과가 도출되었는지 확인
이것을 무한애니메이션 대신, 반복가능한 애니메이션으로 변경한다.
// **이전**
val animatedElevation = animateDpAsState(
    targetValue = currentTargetElevation,
    animationSpec = **tween(durationMillis = 500)**,
    finishedListener = {
        currentTargetElevation = if (currentTargetElevation > 4.dp) {
            1.dp
        } else {
            8.dp
        }
    }
)
// **변경**
val infiniteElevationAnimation = rememberInfiniteTransition()
    val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
        initialValue = 1.dp,
        targetValue = 8.dp,
        typeConverter = Dp.VectorConverter,
        animationSpec = **infiniteRepeatable**(
            animation = tween(500),
            repeatMode = RepeatMode.Reverse
        )
    )
접근성
- 화면에 터치하기 위한 최소 사이즈는 48dp- Icon을 사용하면 일일히 사이즈를 지정해줘야하는데- IconButton을 사용하면 그럴필요 없이 사이즈가 자동으로 점유된다.
 
- 레이블 클릭
    - Modifier.clickable(onClickLabel)에 클릭했을 때의 출력할 내용을 ㅅ설정
- modifier.semantic()안에 contextDescription 이나 label을 집어넣어 음성비서를 쓸 때 적혀질 단어를 작성
 
- Image 나 Icon - contentDescription
    - TalkBack을 작동시, 해당 버튼의 이름
 
- Headings - semantic { heading() }
- 사용자 지정으로 합치기
    - 게시물의 저자의 정보에 대한 부분은, 프로필 / 이름 / 작성일자 로 쪼개져 있기 때문에 일기 매우 불편하다.
- 전체 컨텐츠를 감싸고 있는 Row/Column-Modifier.semantics(mergeDescendants = ture)로 설정
 
