Fragment는 백스택에 있을 때 onDestroyView()가 호출되지만 Fragment 자체는 살아있는 상태다. 따라서 Fragment에서 lifecycleScope를 쓰게 되는 경우 메모리 누수가 발생할 수 있다. 이때 사용하는 게 viewLifecycleOwner.lifecycleScope이며, onViewCreated(뷰가 생성완료 되었을 때)에서 launch한다.
1 2 3 4 5 6 7 8
class TestFragment : Fragment() { override onViewCreated(...) { viewLifecycleOwner.lifecycleScope.launch { // Fragment의 lifecycle을 따르는 (Fragment의 onCreate에서 호출하는) lifecycleScope.launch와는 다르게, // Fragment의 View의 lifecycle을 따르기 때문에 "Fragment가 DESTROYED되기 전, 그리고 Fragment의 View가 DESTROYED된 후"의 메모리 누수를 방지할 수 있다. } } }
3. ViewModel의 viewModelScope
ViewModel이 clear될 때만 취소됨
Dispatchers.Main.immediate 사용
1 2 3 4 5 6 7
class TestViewModel : ViewModel() { init { viewModelScope.launch { // 해당 ViewModel의 lifecycle을 따르기 때문에 viewModel이 살아있는 동안에만(Cleared되기 전까지) 여기의 코루틴을 실행하도록 해준다. } } }
4. 앱의 ProcessLifecycleOwner.get().lifecycleScope
Application 레벨의 lifecycle을 따르며, 앱 프로세스가 종료될 때 취소된다.
onCreateView() ──► View 생성 │ onViewCreated() ──► viewLifecycleOwner.lifecycleScope 시작 │ │ │ ├─ coroutine 1 (collect openEvent) │ ├─ coroutine 2 (collect toastEvent) │ └─ coroutine 3 (collect alertEvent) │ [다른 Fragment로 이동] │ onDestroyView() ──► View 파괴 viewLifecycleOwner.lifecycleScope내 모든 코루틴이 자동 취소된다. (Fragment는 메모리(백스택)에 남아있는 상태) [뒤로가기] │ onCreateView() ──► View 다시 생성 │ onViewCreated() ──► 새로운 viewLifecycleOwner.lifecycleScope에서 새로운 코루틴들이 시작한다.
잘못 사용한 예
1 2 3 4 5 6 7 8 9 10
classMyFragment : Fragment() { overridefunonViewCreated(view: View, savedInstanceState: Bundle?) { lifecycleScope.launch { // Fragment lifecycle viewModel.events.collect { // binding.textView 접근 → onDestroyView 후에도 실행됨! binding.textView.text = "..."// 앱 크래시 가능성이 있다. } } } }
1 2 3 4 5 6 7 8 9 10 11 12 13
onCreateView() ──► View 생성 │ ├─ lifecycleScope.launch (Fragment의 lifecycle) │ └─ collect 시작 │ onDestroyView() ──► View 파괴 (binding.textView 사라짐) │ │ (하지만 collect는 계속 실행 중인 상태) │ └─ viewModel.events.emit() 발생 └─ binding.textView.text = "..."// 앱 크래시 가능성이 있다. onDestroy() ──► 여기서야 lifecycleScope 내 코루틴들이 취소된다.
repeatOnLifecycle vs viewLifecycleOwner.repeatOnLifecycle
repeatOnLifecycle과 viewLifecycleOwner.repeatOnLifecycle도 동작 차이가 있다.
Fragment에서 사용했을 때 viewLifecycleOwner.lifecycleScope에서 launch하더라도 repeatOnLifecycle은 this.repeatOnLifecycle이기 때문에 Fragment의 View 라이프사이클이 아닌 Fragment 라이프사이클을 따르기 때문에 메모리 누수 가능성이 있다.
⚠️ 픽셀 기기에서는 컷아웃이 제대로 넘어오지 않는 것 같음. 카메라가 있는데도 컷아웃이 0으로 내려옴.
Android 9 이상을 실행하는 기기에서는 일관성, 앱 호환성을 보장하기 위해 다음과 같은 컷아웃 동작을 보장해야 한다.
단일 가장자리에 컷아웃을 최대 1개 포함할 수 있다.
기기에 컷아웃이 3개 이상 있을 수 없다.
기기 양 쪽의 긴 가장자리(세로 모드 시 좌우)에는 컷아웃이 있을 수 없다.
특수 플래그를 설정하지 않은 세로 방향에서는 상태 표시줄이 적어도 컷아웃 높이까지 확장되어야 한다.
기본적으로 전체 화면 또는 가로 방향에서는 전체 컷아웃 영역이 레터박스 처리되어야 한다.
따라서, 다음과 같은 컷아웃 유형을 지원한다.
상단 중앙: 상단 가장자리 중앙의 컷아웃
상단 비중앙: 컷아웃이 모서리에 위치하거나 중앙에서 약간 벗어날 수 있다.
하단: 하단의 컷아웃
이중: 상단의 컷아웃 1개, 하단의 컷아웃 1개
콘텐츠가 컷아웃 영역과 겹치지 않게 하려면 콘텐츠가 스테이터스 바 및 네비게이션 바와 겹치지 않게 하려면 컷아웃 영역에서 Inset을 부여하여 처리하면 해결이 가능하다.
컷아웃 영역으로 렌더링하는 경우 WindowInsets#getDisplayCutout() 함수를 사용하여 각 컷아웃의 Safe Inset Area와 Safe Area가 포함된 DisplayCutout 객체를 탐색할 수 있다. 따라서 이러한 API를 사용해 콘텐츠가 컷아웃과 겹치는지 여부를 판단하여 위치를 조정할 수 있다.
Android 15(API 35)부터는 WindowInsets#getDisplayCutout() 함수를 사용하면 된다.
컷아웃 영역은 카메라와 같은 하드웨어가 디스플레이를 가리는 경우 생긴다. 관련 예로, 폰에서 카메라 영역(컷아웃 영역) 때문에 상단 인셋을 적용했는데, 이 코드를 컷아웃 영역이 없는 태블릿에서 실행하니 컷아웃 영역이 없는데도 상단 인셋이 적용되어 버린다. 따라서 아래와 같이 컷아웃을 사용해 패딩을 설정하면 컷아웃에 따른 인셋을 설정할 수 있게 된다.
/** * Inset 생성 시 Window를 제어하기 위한 인터페이스 */ publicinterfaceWindowInsetsController{
/** * 어두운 백그라운드, 밝은 포그라운드 색상을 가진 불투명한 상태표시줄을 만듦 * @hide */ int APPEARANCE_OPAQUE_STATUS_BARS = 1; /** * 어두운 백그라운드, 밝은 포그라운드 색상을 가진 불투명한 네비게이션 바를 만듦 * @hide */ int APPEARANCE_OPAQUE_NAVIGATION_BARS = 1 << 1; /** * 상태바 레이아웃이 변경됨 없이 덜 두드러지게 상태표시줄의 아이템을 적용함 * @hide */ int APPEARANCE_LOW_PROFILE_BARS = 1 << 2; /** * 밝은 상태표시줄로 변경하여 상태바 내 아이템들의 시연성을 뚜렷하게 함 */ int APPEARANCE_LIGHT_STATUS_BARS = 1 << 3; /** * 밝은 네비게이션 바로 변경하여 상태바 내 아이템들의 시연성을 뚜렷하게 함 */ int APPEARANCE_LIGHT_NAVIGATION_BARS = 1 << 4;
... }
여기의 플래그를 보면 기존에 제공하던 아래 플래그와 전혀 다른 것을 알 수 있다.
1 2 3 4 5 6 7 8
decorView.systemUiVisibility = // 아래 SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 와 중복? View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN or // 가장 자리 스와이프 시 발동, 다만 앱에서는 인지 못함 View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or // 하단 네비게이션 바 숨기기 View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
여러 SDK 버전을 커버하는 WindowCompat 클래스에는 다음과 같이 분기처리가 되어 있다.
staticclassApi16Impl{ privateApi16Impl(){ // This class is not instantiable. } staticvoidsetDecorFitsSystemWindows(@NonNull Window window, finalboolean decorFitsSystemWindows){ finalint decorFitsFlags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; final View decorView = window.getDecorView(); finalint sysUiVis = decorView.getSystemUiVisibility(); decorView.setSystemUiVisibility(decorFitsSystemWindows ? sysUiVis & ~decorFitsFlags : sysUiVis | decorFitsFlags); } }
1 2 3 4 5 6 7 8 9 10 11
@RequiresApi(30) staticclassApi30Impl{ privateApi30Impl(){ // This class is not instantiable. } @DoNotInline staticvoidsetDecorFitsSystemWindows(@NonNull Window window, finalboolean decorFitsSystemWindows){ window.setDecorFitsSystemWindows(decorFitsSystemWindows); } }
이와 같이 Android 15(API 35) 이상에서는 Window의 Inset을 부분적으로 적용하는 경우, Window#setDecorFitsSystemWindows를 사용하면 된다.
/** * EdgeToEdge(API 30부터 지원) 적용에 따른 인셋을 적용한다. * 추가로, 키보드가 올라올 때 (IME 인셋이 있을 때) 버튼을 이동시킨다. * @param viewForKeyboard 키보드가 올라올 때 이동시킬 View */ fun Window.setupWindowInsetsKeyboard(viewForKeyboard: View) { WindowCompat.getInsetsController(this, this.decorView).apply { isAppearanceLightStatusBars = false isAppearanceLightNavigationBars = true }
ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
// 키보드가 올라올 때 (IME 인셋이 있을 때) 버튼을 이동 viewForKeyboard.translationY = if (imeInsets.bottom > 0) { -(imeInsets.bottom - systemBars.bottom).toFloat() } else { 0f }
/** * EdgeToEdge(API 30부터 지원) 적용에 따른 인셋을 적용한다. * 추가로, 키보드가 올라올 때 (IME 인셋이 있을 때) 버튼을 이동시킨다. * @param viewForKeyboard 키보드가 올라올 때 이동시킬 View */ fun Window.setupWindowInsetsKeyboard(viewForKeyboard: View) { ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
// 키보드가 올라올 때 (IME 인셋이 있을 때) 버튼을 이동 viewForKeyboard.translationY = if (imeInsets.bottom > 0) { -(imeInsets.bottom - systemBars.bottom).toFloat() } else { 0f }
Flow는 Cold Stream 방식이다. 연속해서 계속 들어오는 데이터를 처리할 수 없고, collect 되었을 때만 생성되고 값을 반환한다. 만약 하나의 Flow Builder에 대해 다수의 collector가 있다면 collector 하나마다 하나씩 데이터를 호출하기 대문에 비용이 비싼 DB 접근, 서버 통신 등을 수행한다면 여러 번 리소스 요청을 하게될 수 있다.
→ LiveData는 ViewModel, Flow는 Repository에서 사용하는 상호보완적 관계였다.
그런데 repeatOnLifecycle 블록에서 데이터를 collect함으로써 생명주기를 인식할 수 있는 StateFlow/SharedFlow가 등장했다.
LiveData와 Flow는 ViewModel단에서는 상호대체 가능해졌다.
→ 하지만 Flow가 사용성이 더 높기 때문에(ViewModel과 Repository 두 영역에서 모두 사용할 수 있게 되었기 때문에) LiveData보다는 Flow를 더 선호하게 되었다.
StateFlow
StateFlow는 현재 상태와 새로운 상태 업데이트를 수집기에 내보내는 관찰 가능한 상태 홀더 흐름이다. value 속성을 통해서도 현재 상태 값을 읽을 수 있다. 상태를 업데이트하고 Flow에 전송하려면 MutableStateFlow 클래스의 value 속성에 새 값을 할당한다.
StateFlow는 Hot Flow이다. Flow에서 수집해도 생산자 코드가 트리거되지 않는다.
즉, StateFlow는 Hot 스트림 방식으로, 소비자의 collect 요청이 없어도 바로 값을 내보내고, 데이터가 업데이트 될 떄마다 데이터를 발행한다.
If unspecified, the cookie becomes a session cookie. A session finishes when the client shuts down, after which the session cookie is removed.
Expires 속성값이 지정되지 않으면 세션 쿠키이고, Chrome Inspector의 쿠키 리스트 중 expires 값에 “session”이라고 나타난다.
2. 지속(persistent) 쿠키
1
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 201507:28:00 GMT
지속 쿠키는 Expires 값이 HTTP Date 형태로 지정된 값이다. 이는 GMT(그리니치 평균시)를 따르며 런던을 기점으로 하는 시각이고, 로컬 시각이 아니다.
⚠️ 한국의 경우 GMT+09:00으로 표현하나, Expires 속성값 마지막에 “GMT” 대신 “GMT+09:00”를 쓰더라도 “+09:00”는 무시되기 때문에 날짜는 항상 “GMT” 시간을 나타내는 점에 주의해야 한다.
이렇게 Expires 속성값이 지정된 쿠키는 지정된 만료 일자까지 살아있는다.
3. 안드로이드 웹뷰에도 세션 쿠키와 지속 쿠키가 나뉘어져서 보관되는가?
결론만 말하면, 그렇다.
이는 CookieManager의 removeSessionCookies()와 removeAllCookies() 메소드로 확인할 수 있다.
우선 expires 속성값이 내려오는 “test” 쿠키가 있다고 하자.
1
Set-Cookie: test=1; Expires=Thu, 20 Mar 202511:13:47 GMT+09:00; Domain=.megastudy.net; Path=/;
이를 앱에서 받아(받는 방법은 아래의 [okhttp3.CookieJar] 항목을 참조) cookieManager를 이용해 setCookie한다.
removeSessionCookies()와 removeAllCookies()를 각각 수행하고 쿠키가 삭제되는지 확인한다.
그 결과는 다음과 같다.
expires 속성 없음
expires 속성 있음
removeSessionCookies
삭제됨
삭제안됨
removeAllCookies
삭제됨
삭제됨
여기서 removeSessionCookie로 삭제되지 않는 값, 즉 expires 속성이 있는 쿠키값은 지속 쿠키로 간주할 수 있겠다.
CookieManager의 getCookie를 통해서는 쿠키의 이름과 값만 내려온다. 따라서 CookieManager를 통해 해당 쿠키의 만료일자 또는 만료여부를 판단할 수 있는 deprecated되지 않은 메소드는 없다.(CookieManager#removeExpiredCookie()는 deprecated되었다.) 그러나 앱 내부적으로 쿠키DB에 저장은 되고 있음을 inspector의 [Application] 탭 > 사이드바의 [Storage] > [Cookies] > [해당 도메인]으로 접근하여 expires 값이 세팅되어있음을 확인할 수 있다.
privateval myCookieJar = object : CookieJar { overridefunloadForRequest(url: HttpUrl): MutableList<Cookie> { val cookies = mutableListOf<Cookie>() // 여기서 앱에서 가지고 있는 쿠키를 웹으로 보낼 수 있다. return cookies } overridefunsaveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) { // 여기서 파라미터를 통해 받아온 cookies 값은 웹에서 세팅된 쿠키값이며 // 이를 안드로이드에 setCookie 해주어야 한다. cookies.forEachIndexed { i, cookie -> cookieManager.setCookie(url.toString(), cookie.toString()) } } }
CookieJar은 loadForRequest와 saveFromResponse 두 개의 함수를 구현하도록 되어 있다.
loadForRequest: HTTP 요청을 보내 기 전에 요청에 사용할 쿠키를 반환한다.
saveFromResponse: HTTP 응답으로부터 받은 쿠키를 저장소에 저장한다.
따라서 순서로 따지면 loadForRequest 후에 saveFromReponse가 호출된다.
앱이 포그라운드에서 백그라운드로 전환됐을 때 해당 Activity가 onPause에서 onStop으로 전환된다. 이때 ViewModel의 LiveData가 변경을 감지하고자 등록된 (Activity 또는 Fragment로부터 받은) lifecycleOwner는 활성화(active) 상태에서 비활성화(inActive) 상태로 전환된다.
이때 onStop(inActive 상태) 시에도 활성화 상태를 유지하기 위해 구현된 LifecycleOwnerWrapper 상세는 다음과 같다.
/** * Sets the current state and notifies the observers. */ open fun handleLifecycleEvent(event: Event){ enforceMainThreadIfNeeded("handleLifecycleEvent") moveToState(event.targetState) } private fun moveToState(next: State){ if (state == next) { return } check(!(state == State.INITIALIZED && next == State.DESTROYED)) { "no event down from $state in component ${lifecycleOwner.get()}" } state = next if(handlingEvent || addingObserverCounter != 0){ newEventOccurred = true // we will figure out what to do on upper level. return } handlingEvent = true sync() handlingEvent = false if(state == State.DESTROYED){ observerMap = FastSafeIterableMap() } }
새로운 lifecycle에 대한 동작을 구현하기 위해 LifecycleEventObserver를 상속받도록 하여 onStateChanged를 구현할 수 있도록 한다.
AsyncImage( model = "https://example.com/iamge.jpg", contentDescription = null )
model은 ImageRequest.data 값이나 ImageRequest 그 자체를 넘길 수 있다.
AsyncImage
AsyncImage는 비동기적으로 image를 요청하여 결과값을 렌더링하는 컴포저블이다. 이는 Image 컴포저블의 표준 매개변수를 동일하게 지원하고, 부가적으로 placeholder, error, fallback painters와 onLoading, onSuccess, onError 콜백을 추가 지원한다.
SubcomposeAsyncImage는 AsyncImage의 변형이다. 이것은 Painter를 사용하는 대신 AsyncImagePainter의 상태를 API 슬롯(아래 예제에서 loading 와 같은 것)에 제공하는 subcomposition을 사용하는 데에 쓰인다. 그 예제는 다음과 같다.
거기에 더해, 현재 상태에 따라 렌더링하는 항목이 달라지도록 복잡한 로직을 만들 수도 있다.
1 2 3 4 5 6 7 8 9 10 11 12
SubcomposeAsyncImage( model = "https://example.com/image.jpg", contentDescription = stringResource(R.string.description) ) { val state = painter.state if (state is AsyncImagePainter.State.Loading || state is AsyncImagePainter.State.Error) { CirclularProgressIndicator() } else { SubcomposeAsyncImageContent() } }
Subcomposition은 정규 composition보다 효율이 낮다. 그래서 이 컴포저블은 높은 퍼포먼스(부드러운 스크롤 동작 등)를 요구하는 UI에서는 맞지 않을 수 있다.
If you set a custom size for the ImageRequest using ImageRequest.Builder.size (e.g. size(Size.ORIGINAL)), SubcomposeAsyncImage will not use subcomposition since it doesn’t need to resolve the composable’s constraints.
ImageRequest.Builder.size 를 사용하여 ImageRequest의 맞춤 크기를 설정하면 SubcomposeAsyncImage는 컴포저블의 제약 조건을 해결할 필요가 없으므로 하위 컴포지션을 사용하지 않습니다.
AsyncImagePainter
내부적으로 AsyncImage와 SubcomposeAsyncImage는 model을 로드할 때 AsyncImagePainter를 사용한다. 만약 Painter가 필요한데 AsyncImage를 사용할 수 없다면 rememberAsyncImagePainter를 사용할 수 있을 것이다.
1
val painter = rememberAsyncImagePainter("https://example.com/image.jpg")
단, rememberAsyncImagePainter는 모든 경우에서 예상대로 동작하지 않을 수 있는 하위 수준 API다.
만약 AsyncImagePainter를 렌더링하는 Image 컴포저블에 커스텀 ContentScale를 적용한다면 rememberAsyncImagePainter도 함께 세팅해야 한다. 이건 로드할 이미지의 크기를 결정하는 데에 필수적이기 때문이다.