/** * 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도 함께 세팅해야 한다. 이건 로드할 이미지의 크기를 결정하는 데에 필수적이기 때문이다.