[Android] Android 15 대응 - EdgeToEdge: 1. Inset 적용

TL; DR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import android.view.View
import android.view.Window
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat

/**
* EdgeToEdge(API 30부터 지원) 적용에 따른 인셋을 적용한다.
*/
fun Window.setupWindowInsets() {
WindowCompat.getInsetsController(this, this.decorView).apply {
isAppearanceLightStatusBars = true
isAppearanceLightNavigationBars = true
}

ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}

/**
* 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
}

view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}

EdgeToEdge 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MainActivity : ComponentActivitiy() {

private fun setupWindowInsets() {
ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}

override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // 반드시 onCreate보다 먼저 호출해야 한다.
super.onCreate(savedInstanceState)
setupWindowInsets()
...
}
...
}

setDecorFitsSystemWindows

WindowInsetsCompat에 따라 decorView가 루트 수준 콘텐츠 뷰에 맞출 것인지 여부를 설정한다.

false로 설정하면 프레임워크는 콘텐츠 뷰를 인셋에 맞추지 않고 WindowInsetsCompat을 통해 콘텐츠 뷰에 전달한다.

API 35 미만에서는 시스템 바까지 확장된 풀스크린(edgeToEdge)이 decorFitsSystemWindows 옵션을 통해 선택적이었다.

decorFitsSystemWindows는 내부적으로 API 16-29에서 setSystemUiVisibility를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static class Api16Impl {

private Api16Impl() {
// This class is not instantiable.
}

static void setDecorFitsSystemWindows(
@NonNull Window window,
final boolean decorFitsSystemWindows
) {
final int 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();
final int sysUiVis = decorView.getSystemUiVisibility();
decorView.setSystemUiVisibility(decorFitsSystemWindows
? sysUiVis & ~decorFitsFlags
: sysUiVis | decorFitsFlags);
}
}

WindowCompat.setDecorFitsSystemWindows(window, boolean)은 API에 맞는 동작으로 하도록 구성되어, API 30 이상과 API 16-29가 분기처리되어 있다.

1
2
3
4
5
6
7
8
public static void setDecorFitsSystemWindows(@NonNull Window window,
final boolean decorFitsSystemWindows) {
if (Build.VERSION.SDK_INT >= 30) {
Api30Impl.setDecorFitsSystemWindows(window, decorFitsSystemWindows);
} else {
Api16Impl.setDecorFitsSystemWindows(window, decorFitsSystemWindows);
}
}

그런데, API 35에서 이를 아우를 수 있는, 즉 decorFitsSystemWindows나 setSystemUiVisibility를 개발자가 사용하지 않고 enableEdgeToEdge() 함수 호출만으로 풀스크린을 구현할 수 있게 되었다.

문제는 그것 뿐만이 아니라 풀스크린이 기본 기기 설정이 된 것이다. enableEdgeToEdge()를 설정하지 않아도 targetSDK가 35인 앱은 풀스크린으로 실행된다. 따라서 그 기본 설정을 위해 enableEdgeToEdge()를 호출해줘야 하는 것이다.

enableEdgeToEdge()를 사용하기 이전에는 decorFitsSystemWindows를 사용하여 시스템 표시줄 뒤에 앱을 배치하였다.

1
2
3
4
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
}
1
2
3
<RelativeLayout
...
android:fitsSystemWindows="true">

그런데 enableEdgeToEdge()의 내부 코드를 보면 내부적으로 모두 setDecorFitsSystemWindows(window, false)를 호출하고 있음을 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
val view = window.decorView
val statusBarIsDark = statusBarStyle.detectDarkMode(view.resources)
val navigationBarIsDark = navigationBarStyle.detectDarkMode(view.resources)
val impl = Impl ?: if (Build.VERSION.SDK_INT >= 29) {
EdgeToEdgeApi29()
} else if (Build.VERSION.SDK_INT >= 26) {
EdgeToEdgeApi26()
} else if (Build.VERSION.SDK_INT >= 23) {
EdgeToEdgeApi23()
} else if (Build.VERSION.SDK_INT >= 21) {
EdgeToEdgeApi21()
} else {
EdgeToEdgeBase()
}.also { Impl = it }
impl.setUp(
statusBarStyle, navigationBarStyle, window, view, statusBarIsDark, navigationBarIsDark
)
}

@RequiresApi(21)
private class EdgeToEdgeApi21 : EdgeToEdgeImpl {
@Suppress("DEPRECATION")
@DoNotInline
override fun setUp(...) {
WindowCompat.setDecorFitsSystemWindows(window, false)
...
}
}

@RequiresApi(23)
private class EdgeToEdgeApi23 : EdgeToEdgeImpl {
@DoNotInline
override fun setUp(...) {
WindowCompat.setDecorFitsSystemWindows(window, false)
...
}
}

@RequiresApi(26)
private class EdgeToEdgeApi26 : EdgeToEdgeImpl {
@DoNotInline
override fun setUp(...) {
WindowCompat.setDecorFitsSystemWindows(window, false)
...
}
}

@RequiresApi(29)
private class EdgeToEdgeApi29 : EdgeToEdgeImpl {
@DoNotInline
override fun setUp(...) {
WindowCompat.setDecorFitsSystemWindows(window, false)
...
}
}

링크 Scrim은 연극 용어이다. 앞쪽에 빛을 비추면 불투명하고, 뒤쪽에 빛을 비추면 투명하거나 반투명하게 보이는 theater drop을 의미한다.

키보드가 나타났을 때의 인셋 대응 - adjustResize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 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
}

view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}

[Android/Flow] LiveData가 아닌 Flow를 사용해야 하는 이유

예전에는,

  • LiveData는 생명주기에 종속적이었고, 메인 스레드에서 실행되었기 때문에 Repository에서 사용하기엔 알맞지 않았다.
  • 반면 Flow는 생명주기를 인식할 수 없었으나 데이터 스트림을 관리하는 객체로서 확장 함수가 잘 구성되어있었고, 코틀린 코루틴 라이브러리의 일부이기 때문에 Repository에서 사용하더라도 클린 아키텍처에 반하지 않았다.
    • Flow는 coroutine 라이브러리에 포함되어있기 때문에 Repository에서 사용 시 의존성을 추가하지 않아도 된다.
      1
      2
      import kotlinx.coroutines.flow.MutableSharedFlow
      import kotlinx.coroutines.flow.asSharedFlow
    또한,

    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 요청이 없어도 바로 값을 내보내고, 데이터가 업데이트 될 떄마다 데이터를 발행한다.

[Android] 쿠키에 관하여

쿠키

여기서는 앱에 내려주는 쿠키형식에 관하여 이야기한다.

  • [MDN] Set-Cookie

1. 세션 쿠키

1
Set-Cookie: sessionId=38afes7a8

가장 간단한 형재는 key=value 형태로 되어있다.

Expires 속성 설명에 나와있듯이,

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 2015 07:28:00 GMT

지속 쿠키는 Expires 값이 HTTP Date 형태로 지정된 값이다. 이는 GMT(그리니치 평균시)를 따르며 런던을 기점으로 하는 시각이고, 로컬 시각이 아니다.

⚠️ 한국의 경우 GMT+09:00으로 표현하나, Expires 속성값 마지막에 “GMT” 대신 “GMT+09:00”를 쓰더라도 “+09:00”는 무시되기 때문에 날짜는 항상 “GMT” 시간을 나타내는 점에 주의해야 한다.

이렇게 Expires 속성값이 지정된 쿠키는 지정된 만료 일자까지 살아있는다.

3. 안드로이드 웹뷰에도 세션 쿠키와 지속 쿠키가 나뉘어져서 보관되는가?

결론만 말하면, 그렇다.

이는 CookieManager의 removeSessionCookies()와 removeAllCookies() 메소드로 확인할 수 있다.

  1. 우선 expires 속성값이 내려오는 “test” 쿠키가 있다고 하자.
    1
    Set-Cookie: test=1; Expires=Thu, 20 Mar 2025 11:13:47 GMT+09:00; Domain=.megastudy.net; Path=/;
  2. 이를 앱에서 받아(받는 방법은 아래의 [okhttp3.CookieJar] 항목을 참조) cookieManager를 이용해 setCookie한다.
  3. removeSessionCookies()와 removeAllCookies()를 각각 수행하고 쿠키가 삭제되는지 확인한다.

그 결과는 다음과 같다.

expires 속성 없음 expires 속성 있음
removeSessionCookies 삭제됨 삭제안됨
removeAllCookies 삭제됨 삭제됨

여기서 removeSessionCookie로 삭제되지 않는 값, 즉 expires 속성이 있는 쿠키값은 지속 쿠키로 간주할 수 있겠다.

CookieManager의 getCookie를 통해서는 쿠키의 이름과 값만 내려온다. 따라서 CookieManager를 통해 해당 쿠키의 만료일자 또는 만료여부를 판단할 수 있는 deprecated되지 않은 메소드는 없다.(CookieManager#removeExpiredCookie()는 deprecated되었다.) 그러나 앱 내부적으로 쿠키DB에 저장은 되고 있음을 inspector의 [Application] 탭 > 사이드바의 [Storage] > [Cookies] > [해당 도메인]으로 접근하여 expires 값이 세팅되어있음을 확인할 수 있다.

okhttp3.CookieJar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val cookieManager = CookieManager.getInstance()

private val myCookieJar = object : CookieJar {
override fun loadForRequest(url: HttpUrl): MutableList<Cookie> {
val cookies = mutableListOf<Cookie>()

// 여기서 앱에서 가지고 있는 쿠키를 웹으로 보낼 수 있다.

return cookies
}

override fun saveFromResponse(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가 호출된다.

이를 적용하는 코드는 다음과 같다.

1
2
3
4
5
6
private val clientBuilder = OkHttpClient.Builder().apply {
connectTimeout(Const.TIMEOUT, TimeUnit.SECONDS)
writeTimeout(Const.TIMEOUT, TimeUnit.SECONDS)
readTimeout(Const.TIMEOUT, TimeUnit.SECONDS)
}
.cookieJar(myCookieJar)

[Web/Attribute] Expires

Expires 속성을 가진 지속 쿠키를 웹으로부터 받아온 경우(CookieJar의 saveFromResponse로 확인) 이 만료 기간이 잘 작동하는지 확인하고자 하였다.

우선 아래와 같이 test 라는 이름의 쿠키를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
val time = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US)
time.timeZone = TimeZone.getTimeZone("GMT")
val expiryDate = time.format(
Date(System.currentTimeMillis() + 60 * 1000)
)
cookieManager.setCookie(
url.toString(),
"test=1; Expires=$expiryDate;Domain=.megastudy.net; Path=/;"
);

// "test=1; Expires=Thu, 20 Mar 2025 11:13:47 GMT+09:00;Domain=.megastudy.net; Path=/;"

Expires 속성값은 HTTP Date 형식으로 되어, GMT(그리니치 표준시)를 따른다. 이를 위해 타임존을 설정하면 로컬(한국)의 시간보다 9시간차이가 나는 값을 얻을 수 있다. 즉 아래 두 값은 같은 시각을 나타낸다고 보면 된다.

  1. Thu, 20 Mar 2025 15:00:00 GMT+09:00
  2. Thu, 20 Mar 2025 06:00:00 GMT

그리고 Expires 속성값은 1번을 넣어도 2번으로 인식하는 게 아니기 때문에 반드시 로컬 시간을 보정해주어야 하며 이를 위해 timeZone을 TimeZone.getTimeZone("GMT")로 설정한다.

chrome inspector

그리고 해당 “test” 쿠키는 GMT 값에 따라 만료일이 경과하면 자동으로 사라지는 것을 inspector를 통해 확인할 수 있다.

[Android] LifecycleOwnerWrapper 분석

앱이 포그라운드에서 백그라운드로 전환됐을 때 해당 Activity가 onPause에서 onStop으로 전환된다. 이때 ViewModel의 LiveData가 변경을 감지하고자 등록된 (Activity 또는 Fragment로부터 받은) lifecycleOwner는 활성화(active) 상태에서 비활성화(inActive) 상태로 전환된다.

이때 onStop(inActive 상태) 시에도 활성화 상태를 유지하기 위해 구현된 LifecycleOwnerWrapper 상세는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class LifecycleOwnerWrapper(
sourceOwner: LifecycleOwner
): LifecycleOwner, LifecycleEventObserver {

override val lifecycle = LifecycleRegistry(this)

init {
sourceOwner.lifecycle.addObserver(this)

when (val state = sourceOwner.lifecycle.currentState) {
Lifecycle.State.DESTROYED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
Lifecycle.State.CREATED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
Lifecycle.State.STARTED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)
Lifecycle.State.RESUMED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
else -> {
// do nothing, the observer will catch up
}
}
}

override fun onStateChanged(
source: LifecycleOwner,
event: Lifecycle.Event
) {
if (event != Lifecycle.Event.ON_PAUSE
&& event != Lifecycle.Event.ON_STOP) {
lifecycle.handleLifecycleEvent(event)
}
}
}

  1. “onStop(inactive 상태) 시에도 활성화 상태를 유지하”는 lifecycle을 새롭게 만들기 위해 LifecycleOwner를 상속받아 lifecycle 변수를 새로 정의한다.

    1
    2
    3
    4
    class LifecycleOwnerWrapper(sourceOwner: LifecycleOwner): LifecycleOwner, LifecycleEventObserver {
    override val lifecycle = LifecycleRegistry(this)
    ...
    }
  2. 그 lifecycle이 생성되었을 때 현재 state를 init을 통해 등록한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    init {
    ...
    when (val state = sourceOwner.lifecycle.currentState) {
    Lifecycle.State.DESTROYED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    Lifecycle.State.CREATED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
    Lifecycle.State.STARTED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)
    Lifecycle.State.RESUMED -> lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
    else -> {}
    }
    }

    여기서 handleLifecycleEvent는 아래 구현부와 같이 파라미터로 받은 state를 currentState로 등록하는 역할을 한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    /**
    * 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()
    }
    }
  3. 새로운 lifecycle에 대한 동작을 구현하기 위해 LifecycleEventObserver를 상속받도록 하여 onStateChanged를 구현할 수 있도록 한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class LifecycleOwnerWrapper(
    sourceOwner: LifecycleOwner
    ): LifecycleOwner, LifecycleEventObserver {
    ...
    override fun onStateChanged(
    source: LifecycleOwner,
    event: Lifecycle.Event
    ) {}
    }
    • 여기서 LifecycleEventObserver를 상속받지 않고 LifecycleOwnerWrapper를 사용하는 Activity 또는 Fragment 내의 observer를 등록하는 부분에서 LifecycleEventObserver를 구현해도 된다.
  4. onStateChanged에는 “onStop(inactive 상태) 시에도 활성화 상태를 유지하”기 위해 onStop일 경우 정상적으로 handleLifecycleEvent를 호출하지 않도록 한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    override fun onStateChanged(
    source: LifecycleOwner,
    event: Lifecycle.Event
    ) {
    if (event != Lifecycle.Event.ON_PAUSE
    && event != Lifecycle.Event.ON_STOP) {
    lifecycle.handleLifecycleEvent(event)
    }
    }

    그럼으로써 이 lifecycle에서는 다음과 같은 커스텀 lifecycle이 적용된다.

    정상 lifecycle 활성화 여부 커스텀 lifecycle에서의 state 활성화 여부
    onCreate x ON_CREATE x
    onStart o ON_START o
    onResume o ON_RESUME o
    onPause o* ON_RESUME o
    onStop x ON_RESUME o
    onDestroy x ON_DESTROY x
    *링크에 따르면, Fragment에서는 onPause에서도 active하다고 함.
  5. 마지막으로 구현된 새로운 lifecycle을 적용하기 위해 sourceOwner에 LifecycleEventObserver를 등록(addObserver)한다.

    1
    sourceOwner.lifecycle.addObserver(this)

[Coil/번역] 2.3.0

📌 implementation 'io.coil-kt:coil-compose:2.3.0’

인트로

SubcomposeAsyncImage을 다음과 같이 사용했을 때 LazyColumn 동작에서 버벅이는 이슈가 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SubcomposeAsyncImage(
model = imageUrl,
imageLoader = CoilImageLoader.getImageLoader(context = context),
error = {
Image(
modifier = Modifier.size(50.dp),
painter = painterResource(id = R.drawable.none),
contentDescription = ""
)
},
contentDescription = "",
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.zIndex(1f),
contentScale = ContentScale.Crop
)
1
2
3
4
5
6
7
8
9
10
11
12
13
object CoilImageLoader {
@Composable
fun getImageLoader(context: Context): ImageLoader {
val imageLoader = remember {
ImageLoader.Builder(context)
.memoryCache { MemoryCache.Builder(context).maxSizePercent(0.3).build() }
.crossfade(true)
.allowHardware(false)
.build()
}
return imageLoader
}
}

테스트 해보니, Coil에서는 url에 빈값이 들어올 경우 error로 넘어간다.(Glide는 빈값일 경우 네트워크를 통하지 않고 바로 placeholder를 그대로 보여준다) 그래서 스크롤 할 때마다 로딩이미지도 깜빡거리면서 다시 그려지는 모션을 보이게 되었다.

여기서 문제는 Subcomposition이었다.

1
2
3
4
5
6
7
8
9
10
11
12
AsyncImage(
model = memberImage,
contentDescription = stringResource(id = R.string.description_profile_image),
imageLoader = CoilImageLoader.getImageLoader(context = context),
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.zIndex(1f),
placeholder = painterResource(id = R.drawable.none_profile_02),
error = painterResource(id = R.drawable.none_profile_02),
contentScale = ContentScale.Crop,
)

평범하게 AsyncImage를 사용하여 어느정도 예상대로의 움직임을 보이도록 성공했다.

📌 이하는 Coil 공식 문서를 번역한 것입니다.

AsyncImage 컴포저블을 사용하는 예시는 다음과 같다.

1
2
3
4
AsyncImage(
model = "https://example.com/iamge.jpg",
contentDescription = null
)

model은 ImageRequest.data 값이나 ImageRequest 그 자체를 넘길 수 있다.

AsyncImage

AsyncImage는 비동기적으로 image를 요청하여 결과값을 렌더링하는 컴포저블이다. 이는 Image 컴포저블의 표준 매개변수를 동일하게 지원하고, 부가적으로 placeholder, error, fallback painters와 onLoading, onSuccess, onError 콜백을 추가 지원한다.

1
2
3
4
5
6
7
8
9
10
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://example.com/image.jpg")
.crossfade(true)
.build(),
placeholder = painterResource(R.drawable.placeholder),
contentDescription = stringResource(R.string.description),
contentScale = ContentScale.Crop,
modifier = Modifier.clip(CircleShape)
)

SubcomposeAsyncImage

SubcomposeAsyncImage는 AsyncImage의 변형이다. 이것은 Painter를 사용하는 대신 AsyncImagePainter의 상태를 API 슬롯(아래 예제에서 loading 와 같은 것)에 제공하는 subcomposition을 사용하는 데에 쓰인다. 그 예제는 다음과 같다.

1
2
3
4
5
6
7
SubcomposeAsyncImage(
model = "https://example.com/image.jpg",
loading = {
CircularProgressIndicator() // subcomposition?
},
contentDescription = straintResource(R.string.description)
)

거기에 더해, 현재 상태에 따라 렌더링하는 항목이 달라지도록 복잡한 로직을 만들 수도 있다.

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