Compose codelab 마지막 챕터인 사이드이펙트 부분을 리뷰합니다.
3. Consuming a Flow from the ViewModel
위의 앱에서 동작하지 않는 부분들을 채워넣는 과정.
먼저, 앱을 실행하면 flight destination list가 비어있다.
home/CraneHome.kt 에서 CraneHomeContent를 확인하자.
이유는, 위의 리스트가 비어있기때문인데 이를 MainViewModel을 참조하여 채워넣는다.
home/MainViewModel.kt을 열고 suggestedDestinations 를 확인하면 updatePeople이나 toDestinationChanged 함수가 get called 될때 stateFlow가 update될것임을 알 수 있다.
suggestedDestinations data stream 으로 newi item emit될때면 언제나 composable CraneHomeContent의 UI가 업데이트 되기를 원하므로 StateFlow.collectAsState() 함수를 사용하자.
이를 사용하면 StateFlow에서 부터 vlues를 collect하고 State API of Compose를 통해 latest value를 represent한다.
이는 compose 코드를 읽고 새로운 emission에 따라 state를 recompose한다. 즉 Side effect를 통해 recomposing을 시킬 수 있음.
CraneHomeContent composable로 돌아가 suggestedDestinations를 아래와 같이 수정한다.
위와 같이 수정하면 아래와 같이 도착지가 업데이트되는 것을 확인할 수 있다
4. LaunchedEffect and rememberUpdatedState
이 프로젝트에서는 home/LandingScreen이 사용되고 있지 않은데 이를 사용하자. 이를 통해 add a landing screen to the app, 그래서 백그라운드에 우리가 필요한 모든 데이터를 로드하도록할 수 있을 것이다.
참고 : LaunchedEffect: 컴포저블의 범위에서 정지 함수 실행
컴포저블 내에서 안전하게 정지 함수를 호출하려면 LaunchedEffect 컴포저블을 사용하세요. LaunchedEffect가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행됩니다. LaunchedEffect가 컴포지션을 종료하면 코루틴이 취소됩니다. LaunchedEffect가 다른 키로 재구성되면(아래 효과 다시 시작 섹션 참고) 기존 코루틴이 취소되고 새 코루틴에서 새 정지 함수가 실행됩니다.
The landing screen will occupy the whole screen and show the app's logo in the middle of the screen. Ideally, we'd show the screen and—after all the data's been loaded—we'd notify the caller that the landing screen can be dismissed using the onTimeout callback.
랜딩 화면은 전체 화면을 차지하고 화면 중앙에 앱 로고를 표시합니다. 이상적으로는 화면을 표시하고 모든 데이터가 로드된 후 호출자에게 onTimeout 콜백을 사용하여 방문 화면을 닫을 수 있음을 알립니다.
Kotlin coroutines are the recommended way to perform asynchronous operations in Android. An app would usually use coroutines to load things in the background when it starts. Jetpack Compose offers APIs that make using coroutines safe within the UI layer. As this app doesn't communicate with a backend, we'll use the coroutines' delay function to simulate loading things in the background.
Kotlin 코루틴은 Android에서 비동기 작업을 수행하는 데 권장되는 방법입니다. 앱은 일반적으로 시작될 때 백그라운드에서 항목을 로드하기 위해 코루틴을 사용합니다. Jetpack Compose는 UI 계층 내에서 코루틴을 안전하게 사용할 수 있도록 하는 API를 제공합니다. 이 앱은 백엔드와 통신하지 않으므로 코루틴의 지연 기능을 사용하여 백그라운드에서 로드하는 것을 시뮬레이션합니다.
A side-effect in Compose is a change to the state of the app that happens outside the scope of a composable function. For example, opening a new screen when the user taps on a button, or showing a message when the app doesn't have Internet connection.
Compose의 부작용은 구성 가능한 함수 범위 외부에서 발생하는 앱 상태의 변경입니다. 예를 들어 사용자가 버튼을 탭할 때 새 화면을 열거나 앱이 인터넷에 연결되어 있지 않을 때 메시지를 표시합니다.
A side-effect in Compose is a change to the state of the app that happens outside the scope of a composable function. Changing the state to show/hide the landing screen will happen in the onTimeout callback and since before calling onTimeout we need to load things using coroutines, the state change needs to happen in the context of a coroutine!
To call suspend functions safely from inside a composable, use the LaunchedEffect API, which triggers a coroutine-scoped side-effect in Compose.
When LaunchedEffect enters the Composition, it launches a coroutine with the block of code passed as a parameter. The coroutine will be cancelled if LaunchedEffect leaves the composition.
Although the next code is not correct, let's see how to use this API and discuss why the following code is wrong. We'll be calling the LandingScreen composable later in this step.
Compose의 부작용은 구성 가능한 함수 범위 외부에서 발생하는 앱 상태의 변경입니다. 방문 화면을 표시/숨기기 위한 상태 변경은 onTimeout 콜백에서 발생하며 onTimeout을 호출하기 전에 코루틴을 사용하여 로드해야 하므로 코루틴 컨텍스트에서 상태 변경이 발생해야 합니다!
컴포저블 내부에서 suspend function를 안전하게 호출하려면 Compose에서 코루틴 범위의 부작용을 트리거하는 LaunchedEffect API를 사용합니다.
LaunchedEffect가 컴포지션에 들어가면 매개변수로 전달된 코드 블록을 사용하여 코루틴을 시작합니다. LaunchedEffect가 컴포지션을 떠나면 코루틴이 취소됩니다d. 다음 코드는 정확하지 않지만 이 API를 사용하는 방법을 살펴보고 다음 코드가 잘못된 이유에 대해 논의해 보겠습니다. 이 단계의 뒷부분에서 LandingScreen 컴포저블을 호출할 것입니다.
Some side-effect APIs like LaunchedEffect take a variable number of keys as a parameter that are used to restart the effect whenever one of those keys changes. Have you spotted the error? We don't want to restart the effect if onTimeout changes!
To trigger the side-effect only once during the lifecycle of this composable, use a constant as a key, for example LaunchedEffect(true) { ... }. However, we're not protecting against changes to onTimeout now!
LaunchedEffect와 같은 일부 부작용 API는 키 중 하나가 변경될 때마다 효과를 다시 시작하는 데 사용되는 매개변수로 다양한 키를 사용합니다.
오류를 발견하셨습니까? onTimeout이 변경되면 효과를 다시 시작하고 싶지 않습니다!
이 컴포저블의 수명 주기 동안 한 번만 부작용을 트리거하려면 상수를 키로 사용합니다(예: LaunchedEffect(true) { ... }). 그러나 우리는 지금 onTimeout에 대한 변경으로부터 보호하지 않습니다!
f onTimeout changes while the side-effect is in progress, there's no guarantee that the last onTimeout is called when the effect finishes. To guarantee this by capturing and updating to the new value, use the rememberUpdatedState API:
부작용이 진행되는 동안 onTimeout이 변경되면 효과가 완료될 때 마지막 onTimeout이 호출된다는 보장이 없습니다. 새 값을 캡처하고 업데이트하여 이를 보장하려면 RememberUpdatedState API를 사용하십시오.
아래와 같이 변경하면된다.
Showing the landing screen
Now we need to show the landing screen when the app is opened. Open the home/MainActivity.kt file and check out the MainScreen composable that's first called.
In the MainScreen composable, we can simply add an internal state that tracks whether the landing should be shown or not:
이제 앱이 열릴 때 랜딩 화면을 표시해야 합니다. home/MainActivity.kt 파일을 열고 처음 호출된 MainScreen 구성 요소를 확인합니다.
MainScreen 컴포저블에서 랜딩을 표시할지 여부를 추적하는 내부 상태를 간단히 추가할 수 있습니다.
위를 아래와 같이 수정한다
위와 같이 수정하면 LandingScreen의 timeout여부에 따른 compose반영을 MainScreen에서 할 수 있다
실행시 2초간 오프닝 화면이 뜨는것을 확인할 수 있다.
5. rememberCoroutineScope
In this step, we'll make the navigation drawer work. Currently, nothing happens if you try to tap the hamburger menu.
Open the home/CraneHome.kt file and check out the CraneHome composable to see where we need to open the navigation drawer: in the openDrawer callback!
In CraneHome, we have a scaffoldState that contains a DrawerState. DrawerState has methods to open and close the navigation drawer programmatically. However, if you attempt to write scaffoldState.drawerState.open() in the openDrawer callback, you'll get an error! That's because the open function is a suspend function. We're in the realm of coroutines again.
이 단계에서는 navigation drawer가 작동하도록 합니다.
현재 햄버거 메뉴를 눌러도 아무 일도 일어나지 않습니다.
home/CraneHome.kt 파일을 열고 CraneHome 컴포저블을 확인하여 탐색 창을 (openDrawer 콜백에서) 열어야 하는 위치를 확인하십시오.
CraneHome에는 DrawerState를 포함하는 scaffoldState가 있습니다. DrawerState에는 프로그래밍 방식으로 탐색 창을 열고 닫는 메서드가 있습니다. 그러나 openDrawer 콜백에서 scaffoldState.drawerState.open()을 작성하려고 하면 오류가 발생합니다! open 함수가 suspend 함수이기 때문입니다. 우리는 다시 코루틴의 영역에 있습니다.
Apart from APIs to make calling coroutines safe from the UI layer, some Compose APIs are suspend functions. One example of this is the API to open the navigation drawer. Suspend functions, in addition to being able to run asynchronous code, also help represent concepts that happen over time. As opening the drawer requires some time, movement, and potential animations, that's perfectly reflected with the suspend function, which will suspend the execution of the coroutine where it's been called until it finishes and resumes execution.
UI 레이어에서 코루틴 호출을 안전하게 만드는 API 외에도 일부 Compose API는 suspend function입니다.
이에 대한 한 가지 예는 navigation drawer을 여는 API입니다. Suspend function은 비동기 코드를 실행할 수 있을 뿐만 아니라 시간이 지남에 따라 발생하는 개념을 나타내는 데도 도움이 됩니다. 서랍을 여는 데는 약간의 시간, 이동 및 잠재적인 애니메이션이 필요하므로 suspend 기능에 완벽하게 반영됩니다. 이 기능은 완료되고 실행을 재개할 때까지 호출된 코루틴의 실행을 일시 중단합니다.
scaffoldState.drawerState.open() must be called within a coroutine. What can we do? openDrawer is a simple callback function, therefore:
- We cannot simply call suspend functions in it because openDrawer is not executed in the context of a coroutine.
- We cannot use LaunchedEffect as before because we cannot call composables in openDrawer. We're not in the Composition.
scaffoldState.drawerState.open()은 코루틴 내에서 호출되어야 합니다. 우리는 무엇을 할 수 있습니까? openDrawer는 간단한 콜백 함수이므로 다음과 같습니다.
openDrawer가 코루틴 컨텍스트에서 실행되지 않기 때문에 단순히 suspend 함수를 호출할 수 없습니다.
openDrawer에서 컴포저블을 호출할 수 없기 때문에 LaunchedEffect를 이전처럼 사용할 수 없습니다. We're not in the Composition.
We want to be able to launch a coroutine which scope should we use? Ideally, we'd want a CoroutineScope that follows the lifecycle of its call-site. To do this, use the rememberCoroutineScope API. The scope will be automatically cancelled once it leaves the Composition. With that scope, you can start coroutines when you're not in the Composition, for example, in the openDrawer callback.
어떤 범위를 사용해야 하는 코루틴을 시작할 수 있기를 원합니까? 이상적으로는 호출 사이트의 수명 주기를 따르는 CoroutineScope가 필요합니다. 이렇게 하려면 RememberCoroutineScope API를 사용하십시오. 범위는 컴포지션을 떠나면 자동으로 취소됩니다. 해당 범위를 사용하면 구성에 있지 않을 때(예: openDrawer 콜백에서) 코루틴을 시작할 수 있습니다.
위를 아래와 같이 수정한다
메뉴 버튼 클릭시 -> openDrawer가 response 되고 scope.launch가 동작한다
실행화면,
LaunchedEffect vs rememberCoroutineScope
Using LaunchedEffect in this case wasn't possible because we needed to trigger the call to create a coroutine in a regular callback that was outside of the Composition.
Looking back at the landing screen step that used LaunchedEffect, could you use rememberCoroutineScope and call scope.launch { delay(); onTimeout(); } instead of using LaunchedEffect?
이 경우에는 LaunchedEffect를 사용할 수 없었습니다. 왜냐하면 컴포지션 외부에 있는 일반 콜백에서 코루틴을 생성하기 위해 호출을 트리거해야 했기 때문입니다. LaunchedEffect를 사용한 landing screen을 되돌아보면, LaunchedEffect를 사용하는 대신 RememberCoroutineScope를 사용하고 scope.launch { delay(); onTimeout(); }을 호출할 수 있겠는가?
You could've done that, and it would've seemed to work, but it wouldn't be correct.
As explained in the Thinking in Compose documentation, composables can be called by Compose at any moment. LaunchedEffect guarantees that the side-effect will be executed when the call to that composable makes it into the Composition. If you use rememberCoroutineScope and scope.launch in the body of the LandingScreen, the coroutine will be executed every time LandingScreen is called by Compose regardless of whether that call makes it into the Composition or not. Therefore, you'll waste resources and you won't be executing this side-effect in a controlled environment.
그렇게 할 수 있었고 작동하는 것처럼 보였지만 올바르지 않습니다.
Think in Compose 문서에 설명된 대로 컴포저블은 언제라도 Compose에서 호출할 수 있습니다. LaunchedEffect는 해당 컴포저블에 대한 호출이 이를 컴포지션으로 만들 때 side-effect이 실행되도록 보장합니다.
LandingScreen의 본문에서 RememberCoroutineScope 및 scope.launch를 사용하는 경우 해당 호출이 컴포지션에 포함되는지 여부에 관계없이 Compose에서 LandingScreen이 호출될 때마다 매번 코루틴이 실행됩니다. 따라서 (계속 recompose가 일어나) 리소스를 낭비하게 되고 통제된 환경에서 이 부작용을 실행하지 않게 됩니다.
6. Creating a state holder
Have you noticed that if you tap Choose Destination you can edit the field and filter cities based on your search input? You also probably noticed that whenever you modify Choose Destination, the text style changes.
목적지 선택을 탭하면 필드를 편집하고 검색 입력을 기반으로 도시를 필터링할 수 있다는 것을 알고 계셨습니까? 또한 대상 선택을 수정할 때마다 텍스트 스타일이 변경된다는 점을 알아차렸을 것입니다.
Open the base/EditableUserInput.kt file. The CraneEditableUserInput stateful composable takes some parameters such as the hint and a caption which corresponds to the optional text next to the icon. For example, the caption To appears when you search for a destination.
base/EditableUserInput.kt 파일을 엽니다. CraneEditableUserInput stateful composable은 아이콘 옆의 선택적 텍스트에 해당하는 힌트 및 캡션과 같은 일부 매개변수를 사용합니다. 예를 들어 목적지를 검색하면 캡션 To가 나타납니다.
Why?
The logic to update the textState and determine whether what's been displayed corresponds to the hint or not is all in the body of the CraneEditableUserInput composable. This brings some downsides with it:
- The value of the TextField is not hoisted and therefore cannot be controlled from outside, making testing harder.
- The logic of this composable could become more complex and the internal state could be out of sync more easily.
textState를 업데이트하고 표시된 내용이 힌트에 해당하는지 여부를 결정하는 논리는 모두 CraneEditableUserInput composable의 body에 있습니다. 이것은 몇 가지 단점을 가져옵니다.
- TextField의 값은 호이스팅되지 않으므로 외부에서 제어할 수 없으므로 테스트가 더 어렵습니다.
- 이 컴포저블의 논리는 더 복잡해지고 internal state가 더 쉽게 동기화되지 않을 수 있습니다.
By creating a state holder responsible for the internal state of this composable, you can centralize all state changes in one place. With this, it's more difficult for the state to be out of sync, and the related logic is all grouped together in a single class. Furthermore, this state can be easily hoisted up and can be consumed from callers of this composable.
In this case, hoisting the state is a good idea since this is a low-level UI component that might be reused in other parts of the app. Therefore, the more flexible and controllable it is, the better.
이 컴포저블의 내부 상태를 담당하는 a state holder를 생성하면 모든 state 변경 사항을 한 곳에서 중앙 집중화할 수 있습니다. 이렇게 하면 상태가 동기화되지 않고 관련 논리가 모두 단일 클래스로 그룹화되기가 더 어렵습니다(구글 번역 오류인듯) 이렇게 하면 sync가 안맞게 하기가 더 어렵고, 모든 관련 logic들은 단일 클래스로 그룹화 되는 장점이 있습니다. 또한 이 상태는 쉽게 끌어올릴 수 있으며 이 컴포저블의 호출자로부터 소비될 수 있습니다.
이 경우, hoisting the state은 앱의 다른 부분에서 재사용될 수 있는 낮은 수준의 UI 구성 요소이기 때문에 좋은 생각입니다. 따라서 유연하고 제어할 수 있을수록 좋습니다.
Creating the state holder
As CraneEditableUserInput is a reusable component, let's create a regular class as state holder named EditableUserInputState in the same file that looks like the following:
CraneEditableUserInput은 reusable component이므로, 다음과 같은 동일한 파일에 EditableUserInputState라는 state holder로 일반 클래스를 생성해 보겠습니다.
The class should have the following traits:
- text is a mutable state of type String, just as we have in CraneEditableUserInput. It's important to use mutableStateOf so that Compose tracks changes to the value and recomposes when changes happen.
- text is a var, which makes it possible to be directly mutated from outside the class.
- The class takes an initialText as a dependency that is used to initialize text.
- The logic to know if the text is the hint or not is in the isHint property that performs the check on-demand.
If the logic gets more complex in the future, we only need to make changes to one class: EditableUserInputState.
클래스에는 다음과 같은 특성이 있어야 합니다.
-text는 CraneEditableUserInput에서와 같이 String 유형의 mutable state입니다. Compose가 값의 변경 사항을 추적하고 변경 사항이 발생할 때 recomposes하도록 mutableStateOf를 사용하는 것이 중요합니다.
-text는 var로, 클래스 외부에서 직접 변경할 수 있습니다.
-클래스는 텍스트를 초기화하는 데 사용되는 종속성으로 initialText를 사용합니다.
-텍스트가 힌트인지 여부를 확인하는 논리는 주문형 검사를 수행하는 isHint 속성에 있습니다.
앞으로 로직이 더 복잡해지면 EditableUserInputState라는 클래스 하나만 변경하면 됩니다.
Remembering the state holder
State holders always need to be remembered in order to keep them in the Composition and not create a new one every time. It's a good practice to create a method in the same file that does this to remove boilerplate and avoid any mistakes that might occur. In the base/EditableUserInput.kt file, add this code:
State holders는 항상 새 Composition을 생성하지 않고 Composition에 그것들을 유지하기 위해 기억해야 합니다. 상용구를 제거하고 발생할 수 있는 실수를 피하기 위해 이 작업을 수행하는 동일한 파일에 메서드를 만드는 것이 좋습니다. base/EditableUserInput.kt 파일에서 다음 코드를 추가합니다.
If we only remember this state, it won't survive activity recreations. To achieve that, we can use the rememberSaveable API instead which behaves similarly to remember, but the stored value also survives activity and process recreation. Internally, it uses the saved instance state mechanism.
이 state만 기억하면 activity recreations에서 살아남을 수 없습니다. 이를 달성하기 위해 기억과 유사하게 동작하는 RememberSaveable API를 대신 사용할 수 있지만 저장된 값은 활동 및 프로세스 재생성에서도 살아남습니다. 내부적으로 저장된 인스턴스 상태 메커니즘을 사용합니다.
rememberSaveable does all this with no extra work for objects that can be stored inside a Bundle. That's not the case for the EditableUserInputState class that we created in our project. Therefore, we need to tell rememberSaveable how to save and restore an instance of this class using a Saver.
rememberSaveable은 Bundle 내부에 저장할 수 있는 객체에 대한 추가 작업 없이 이 모든 작업을 수행합니다. 우리 프로젝트에서 만든 EditableUserInputState 클래스의 경우는 그렇지 않습니다. 따라서 Saver를 사용하여 이 클래스의 인스턴스를 저장하고 복원하는 방법을 메모리에 저장해야 합니다.
Creating a custom saver
A Saver describes how an object can be converted into something which is Saveable.
Implementations of a Saver need to override two functions:
Saver는 개체를 저장 가능한 것으로 변환하는 방법을 설명합니다. Saver 구현은 두 가지 기능을 재정의해야 합니다.
- save to convert the original value to a saveable one.
- restore to convert the restored value to an instance of the original class.
- save를 사용하여 원래 값을 저장 가능한 값으로 변환합니다.
- the restored value을 원래 클래스의 인스턴스로 변환하려면 복원합니다.
For our case, instead of creating a custom implementation of Saver for the EditableUserInputState class, we can use some of the existing Compose APIs such as listSaver or mapSaver (that stores the values to save in a List or Map) to reduce the amount of code that we need to write.
우리의 경우, EditableUserInputState 클래스에 대한 Saver의 사용자 정의 구현을 만드는 대신 listSaver 또는 mapSaver(List 또는 Map에 저장할 값을 저장함)와 같은 기존 Compose API 중 일부를 사용하여 코드 양을 줄일 수 있습니다. 우리가 작성해야합니다.
It's a good practice to place Saver definitions close to the class they work with. Because it needs to be statically accessed, let's add the Saver for EditableUserInputState in a companion object. In the base/EditableUserInput.kt file, add the implementation of the Saver
Saver 정의는 작업하는 클래스에 가깝게 배치하는 것이 좋습니다. 정적으로 액세스해야 하므로 Companion 개체에 EditableUserInputState용 Saver를 추가해 보겠습니다. base/EditableUserInput.kt 파일에서 Saver 구현을 추가합니다.
In this case, we use a listSaver as an implementation detail to store and restore an instance of EditableUserInputState in the saver.
이 경우에는 listSaver를 구현 세부 정보로 사용하여 Saver에 EditableUserInputState의 인스턴스를 저장하고 복원합니다.
Now, we can use this saver in rememberSaveable (instead of remember) in the rememberEditableUserInputState method we created before:
이제 우리는 이전에 생성한 RememberEditableUserInputState 메서드의 rememberSaveable (remember 대신)에서 이 saver를 사용할 수 있습니다.
위와 같이 rememberSaveable로 교체합니다.
이를 통해 EditableUserInput remember state는 process and activity recreations에서 살아남을 것입니다.
Using the state holder
We're going to use EditableUserInputState instead of text and isHint, but we don't want to just use it as an internal state in CraneEditableUserInput as there's no way for the caller composable to control the state. Instead, we want to hoist EditableUserInputState so that callers can control the state of CraneEditableUserInput. If we hoist the state, then the composable can be used in previews and be tested more easily since you're able to modify its state from the caller.
To do this, we need to change the parameters of the composable function and give it a default value in case it is needed. Because we might want to allow CraneEditableUserInput with empty hints, we add a default argument:
텍스트와 isHint 대신 EditableUserInputState를 사용할 것이지만 caller composable가 state를 제어할 수 있는 방법이 없기 때문에 CraneEditableUserInput에서 internal state로 사용하고 싶지는 않습니다. 대신 caller가 CraneEditableUserInput의 상태를 제어할 수 있도록 EditableUserInputState를 hoist 하려고 합니다. If we hoist the state, 호출자로부터 modify its state를 할 수 있으므로, 컴포저블을 미리보기에서 사용할 수 있고 더 쉽게 테스트할 수 있습니다.
이렇게 하려면 composable function의 매개변수를 변경하고 필요한 경우 기본값을 제공해야 합니다. 빈 힌트로 CraneEditableUserInput을 허용하고 싶을 수 있으므로 기본 인수를 추가합니다.
위를 아래와 같이 교체한다
You've probably noticed that the onInputChanged parameter is not there anymore! Since the state can be hoisted, if callers want to know if the input changed, they can control the state and pass that state into this function.
Next, we need to tweak the function body to use the hoisted state instead of the internal state that was used before. After the refactoring, the function should look like this:
onInputChanged 매개변수가 더 이상 존재하지 않는다는 것을 눈치채셨을 것입니다! state가 hoist될 수 있으므로 호출자가 입력이 변경되었는지 알고 싶다면 상태를 제어하고 해당 상태를 이 함수에 전달할 수 있습니다.
다음으로, 이전에 사용된 내부 상태 대신 호이스트 상태를 사용하도록 함수 본문을 조정해야 합니다. 리팩토링 후 함수는 다음과 같아야 합니다.
위를 아래와 같이 교체한다
State holder callers
Since we changed the API of CraneEditableUserInput, we need to check in all places where it's called to make sure we pass in the appropriate parameters.
The only place in the project that we call this API is in the home/SearchUserInput.kt file. Open it and go to the ToDestinationUserInput composable function; you should see a build error there. As the hint is now part of the state holder, and we want a custom hint for this instance of CraneEditableUserInput in the Composition, we need to remember the state at the ToDestinationUserInput level and pass it into CraneEditableUserInput:
CraneEditableUserInput의 API를 변경했으므로 적절한 매개변수를 전달하기 위해 호출되는 모든 위치를 확인해야 합니다.
프로젝트에서 이 API라고 부르는 유일한 위치는 home/SearchUserInput.kt 파일입니다. 그것을 열고 ToDestinationUserInput composable function으로 이동하십시오. 거기에 빌드 오류가 표시되어야 합니다. 힌트는 이제 상태 홀더의 일부이고 컴포지션에서 이 CraneEditableUserInput 인스턴스에 대한 사용자 지정 힌트를 원하므로 ToDestinationUserInput 수준에서 상태를 기억하고 CraneEditableUserInput에 전달해야 합니다.
snapshotFlow
The code above is missing functionality to notify ToDestinationUserInput's caller when the input changes. Due to how the app is structured, we don't want to hoist the EditableUserInputState any higher up in the hierarchy because we want to couple the other composables such as FlySearchContent with this state. How can we call the onToDestinationChanged lambda from ToDestinationUserInput and still keep this composable reusable?
We can trigger a side-effect using LaunchedEffect every time the input changes and call the onToDestinationChanged lambda:
위의 코드에는 입력이 변경될 때 ToDestinationUserInput의 호출자에게 알리는 기능이 없습니다. 앱이 구조화된 방식으로 인해 우리는 FlySearchContent와 같은 다른 컴포저블을 이 상태와 결합하기를 원하기 때문에 EditableUserInputState를 계층 구조에서 더 높은 곳으로 hoist 하고 싶지 않습니다. ToDestinationUserInput에서 onToDestinationChanged 람다를 호출하고 이 composable reusable를 유지하려면 어떻게 해야 합니까?
입력이 변경될 때마다 LaunchedEffect를 사용하여 부작용을 트리거하고 onToDestinationChanged 람다를 호출할 수 있습니다.
위를 아래와 같이 변경해줍니다
We've already used LaunchedEffect and rememberUpdatedState before, but the code above also uses a new API!
We use the snapshotFlow API to convert Compose State<T> objects into a Flow. When the state read inside snapshotFlow mutates, the Flow will emit the new value to the collector. In our case, we convert the state into a flow to use the power of flow operators. With that, we filter when the text is not the hint, and collect the emitted items to notify the parent that the current destination changed.
There are no visual changes in this step of the codelab, but we've improved the quality of this part of the code. If you run the app now, you should see everything is working as it did previously.
우리는 이미 LaunchedEffect와 RememberUpdatedState를 사용한 적이 있지만 위의 코드도 새로운 API를 사용합니다! 우리는 snapshotFlow API를 사용하여 Compose State<T> 객체를 Flow로 변환합니다. snapshotFlow 내부에서 읽은 상태가 변경되면 Flow는 수집기에 새 값을 내보냅니다. 우리의 경우, the power of flow operators을 사용하기 위해 state를 flow로 변환합니다. 이를 통해 텍스트가 힌트가 아닐 때 필터링하고 방출된 항목을 수집하여 현재 대상이 변경되었음을 부모에게 알립니다. 코드랩의 이 단계에서 시각적인 변경 사항은 없지만 코드의 이 부분의 품질을 개선했습니다. 지금 앱을 실행하면 모든 것이 이전과 같이 작동하는 것을 볼 수 있습니다.
7. DisposableEffect
목적지를 탭하면 세부정보 화면이 열리고 지도에서 도시 위치를 확인할 수 있습니다. 이 코드는 details/DetailsActivity.kt 파일에 있습니다. CityMapView 컴포저블에서 rememberMapViewWithLifecycle 함수를 호출합니다. details/MapViewUtils.kt 파일에 있는 이 함수를 열면 함수가 수명 주기에 연결되지 않은 것을 확인할 수 있습니다. 단순히 MapView를 기억하고 onCreate를 호출합니다.
// details/MapViewUtils.kt file - cod
앱이 잘 실행되지만 MapView가 올바른 수명 주기를 따르지 않으므로 문제가 됩니다. 따라서 앱이 언제 백그라운드로 이동하는지, 뷰가 언제 일시중지되어야 하는지 등을 알 수 없습니다. 이 문제를 해결해 보겠습니다.
참고 지도가 표시되지 않는 경우 설정 단계의 [선택사항] 세부정보 화면에 지도 표시 섹션을 확인하세요. 하지만 이 섹션에서 반드시 화면에 표시된 지도를 볼 필요는 없습니다.
MapView는 컴포저블이 아닌 뷰이므로, 컴포지션의 수명 주기 대신 사용되는 활동의 수명 주기를 따르는 것이 좋습니다. 즉, 수명 주기 이벤트를 수신 대기하고 MapView에서 올바른 메서드를 호출하기 위해 LifecycleEventObserver를 만들어야 합니다. 그런 다음 이 관찰자를 현재 활동의 수명 주기에 추가해야 합니다.
먼저 특정 이벤트에 따라 MapView에서 해당 메서드를 호출하는 LifecycleEventObserver를 반환하는 함수를 만들어 보겠습니다.
이제 이 관찰자를 현재 수명 주기에 추가해야 합니다. 현재 LifecycleOwner를 LocalLifecycleOwner 컴포지션 로컬과 함께 사용해 이 관찰자를 가져올 수 있습니다. 하지만 관찰자를 추가하는 것만으로는 충분하지 않습니다. 삭제할 수 있어야 합니다. 효과가 컴포지션을 종료하는 시기를 알려주는 부작용이 있어야 일부 정리 코드를 수행할 수 있습니다. 필요한 부작용 API는 DisposableEffect입니다.
DisposableEffect는 키가 변경되거나 컴포저블이 컴포지션을 종료하면 정리되어야 하는 부작용을 위한 것입니다. 최종 rememberMapViewWithLifecycle 코드가 정확하게 이 작업을 수행합니다. 프로젝트에 다음 줄을 구현합니다.
관찰자는 현재 lifecycle에 추가되고, 현재 수명 주기가 변경되거나 이 컴포저블이 컴포지션을 종료할 때마다 삭제됩니다. DisposableEffect의 key를 사용하여 lifecycle 또는 mapView가 변경되면 관찰자가 삭제되고 오른쪽 lifecycle에 다시 추가됩니다.
조금 전의 변경사항에 따라 MapView는 항상 현재 LifecycleOwner의 lifecycle을 따르며, 그 동작은 View 환경에서 사용된 것과 똑같습니다.
자유롭게 앱을 실행하고 세부정보 화면을 열어 MapView가 여전히 제대로 렌더링되는지 확인합니다. 이 단계에는 시각적 변경사항이 없습니다.
8. produceState
이 섹션에서는 세부정보 화면이 시작되는 방식을 개선할 것입니다. details/DetailsActivity.kt 파일의 DetailsScreen 컴포저블이 cityDetails를 ViewModel에서 동기식으로 가져오고 결과가 성공적인 경우 DetailsContent를 호출합니다.
하지만 cityDetails는 UI 스레드를 로드하는 데 더 많은 비용이 들 수 있고 코루틴을 사용하여 데이터 로드를 다른 스레드로 옮길 수 있습니다. 이 코드를 개선하여 로드 화면을 추가하고 데이터가 준비되면 DetailsContent를 표시해 보겠습니다.
화면의 상태를 모델링하는 한 가지 방법은 화면에 표시할 데이터, 로드 및 오류 신호와 같은 모든 가능성을 다루는 클래스를 사용하는 것입니다. DetailsActivity.kt 파일에 DetailsUiState 클래스를 추가합니다.
정보가 준비되면 ViewModel에서 업데이트하고 Compose에서 이미 알고 있는 collectAsState() API를 사용해 수집하는 데이터 스트림인 DetailsUiState 유형의 StateFlow를 사용하여, 화면에 표시해야 하는 항목과 ViewModel 레이어의 UiState를 매핑할 수 있습니다.
하지만 이 연습을 위해 대안을 구현해 보겠습니다. uiState 매핑 로직을 Compose 환경으로 옮기려면 produceState API를 사용하면 됩니다.
produceState를 사용하면 Compose가 아닌 상태를 Compose 상태로 변환할 수 있습니다. value 속성을 사용하여 반환된 State에 값을 푸시할 수 있는 컴포지션으로 범위가 지정된 코루틴을 실행합니다. LaunchedEffect와 마찬가지로 produceState 역시 키를 가져와 계산을 취소하고 다시 시작합니다.
이 사용 사례에서는 다음과 같이 produceState를 사용하여 초기 값이 DetailsUiState(isLoading = true)인 uiState 업데이트를 내보낼 수 있습니다.
그런 다음 uiState에 따라 데이터를 표시하거나, 로드 화면을 표시하거나, 오류를 보고합니다. 다음은 DetailsScreen 컴포저블의 전체 코드입니다.
9. derivedStateOf
Crane의 마지막 개선사항은 화면의 첫 번째 요소를 넘긴 후 항공편 목적지 목록을 스크롤할 때마다 맨 위로 스크롤 버튼을 표시하는 것입니다. 버튼을 탭하면 목록의 첫 번째 요소로 이동합니다.
이 코드가 포함된 base/ExploreSection.kt 파일을 엽니다. ExploreSection 컴포저블은 Scaffold의 배경에 표시되는 컴포저블에 해당합니다.
동영상에 표시된 행동을 구현하는 솔루션은 특별한 것이 아닙니다. 하지만 아직 본 적이 없는 새로운 API가 있으며, 이 사용 사례에서 중요합니다. 바로 derivedStateOf API입니다.
derivedStateOf는 다른 State에서 파생된 Compose State를 원하는 경우에 사용됩니다. 이 함수를 사용하면 계산에서 사용되는 상태 중 하나가 변경될 때만 계산이 실행됩니다.
listState를 사용하여 사용자가 첫 번째 항목을 전달했는지 계산하는 것은 listState.firstVisibleItemIndex > 0 여부를 확인하는 것 만큼 간단합니다. 하지만 firstVisibleItemIndex는 mutableStateOf API에 래핑되므로 관찰 가능한 Compose 상태가 됩니다. 또한 버튼을 표시하도록 UI를 재구성하려고 하므로 계산이 Compose 상태여야 합니다.
다음은 단순하고 비효율적인 구현의 예시입니다. 프로젝트에 복사하지 마세요. 올바른 구현이 프로젝트에 복사되고 나중에 화면의 나머지 로직이 적용됩니다.
더욱 효과적이고 효율적인 대안은 listState.firstVisibleItemIndex가 변경될 때만 showButton을 계산하는 derivedStateOf API를 사용하는 것입니다.
ExploreSection 컴포저블의 새 코드는 이미 익숙할 것입니다. rememberCoroutineScope를 활용하여 Button의 onClick 콜백 내에서 listState.scrollToItem 정지 함수를 호출하는 방법을 다시 확인해 보세요. Box를 사용하여 조건부로 ExploreList 상단에 표시되는 Button을 배치합니다.
위를 아래와 같이 수정한다.
앱을 실행하는 경우 스크롤하여 화면의 첫 번째 요소를 전달하면 하단에 버튼이 표시됩니다.
'Android Dev > Compose' 카테고리의 다른 글
Compose - LazyColumn + Counting Method + Firebase + Flow 예제 (0) | 2021.09.04 |
---|---|
Compose Layout trial (0) | 2021.08.31 |
Compose - LazyColumn Refresh ( mutableStateOf / PagingSource) / Get Index 구현 (0) | 2021.08.28 |
JetPack Compose(2) - Navigation (0) | 2021.08.11 |
revision[2022.11.04] JetPack Compose(1) - Using state in Jetpack Compose (0) | 2021.08.09 |