[Android] LifecycleScope의 종류

image.png

1. Activity의 lifecycleScope

  • Activity나 Fragment의 전체 생명주기를 따름
  • Lifecycle.State.DESTROYED가 되면 자동으로 코루틴 취소

따라서 아래 코드는 “3datastore=PATTERN” 로그만 찍힌다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
override fun onDestroy() {
DEBUG.d("leejs - onDestroy")

// 실행 안됨: Destroy를 호출하기 전에 실행
lifecycleScope.launch {
DEBUG.d("leejs - 1datastore=${viewModel.getLockMethod()}")
}

super.onDestroy()

// 실행 안 됨: Destroy된 상태기 때문.
lifecycleScope.launch {
DEBUG.d("leejs - 2datastore=${viewModel.getLockMethod()}")
}
// 실행됨: lifecycle과 관련 없는 코루틴에서 실행.
CoroutineScope(Dispatchers.Main).launch {
DEBUG.d("leejs - 3datastore=${viewModel.getLockMethod()}")
}
}

// 결과 //
// leejs - onDestroy
// leejs - 3datastore=PATTERN

2. Fragment의 viewLifecycleOwner.lifecycleScope

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을 따르며, 앱 프로세스가 종료될 때 취소된다.
  • 앱 전체의 포그라운드/백그라운드 상태를 감지할 수 있다.
  • 앱 레벨 데이터 동기화에 사용한다.

사용처

  • Application을 상속받는 클래스 또는 위젯 관련 로직에서 사용한다.

[Android] lifecycleScope vs viewLifecycleOwner.lifecycleScope

Lifecycle

Fragment의 Lifecycle

Fragment는 Activity의 lifecycle과 다르게 Fragment의 lifecycle과 Fragment의 View의 lifecycle, 두 개가 있다.

lifecycleScope vs viewLifecycleOwner.lifecycleScope

1
2
3
4
5
6
7
8
9
10
11
12
13
class TestActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

lifecycleScope.launch { // Activity가 destroy될 때까지 살아있다.
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.inputEventSharedFlow.collect { event ->
// Activity가 STARTED 이상일 때만 실행된다.
}
}
}
}
}

생명주기:

1
2
3
4
5
onCreate() ──► lifecycleScope 시작

├─ coroutine 1 (collect)

onDestroy() ──► lifecycleScope 자동 취소

생명주기 (백스택 시나리오):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
class MyFragment : Fragment() {
override fun onViewCreated(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

repeatOnLifecycleviewLifecycleOwner.repeatOnLifecycle도 동작 차이가 있다.

Fragment에서 사용했을 때 viewLifecycleOwner.lifecycleScope에서 launch하더라도 repeatOnLifecyclethis.repeatOnLifecycle이기 때문에 Fragment의 View 라이프사이클이 아닌 Fragment 라이프사이클을 따르기 때문에 메모리 누수 가능성이 있다.

따라서 아래와 같이 사용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
class MyFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect {
binding.textView.text = "..."
}
}
}
}
}

[Android] Android 15 대응 - EdgeToEdge: 4. 디스플레이 컷아웃

컷아웃

⚠️ 픽셀 기기에서는 컷아웃이 제대로 넘어오지 않는 것 같음. 카메라가 있는데도 컷아웃이 0으로 내려옴.

Android 9 이상을 실행하는 기기에서는 일관성, 앱 호환성을 보장하기 위해 다음과 같은 컷아웃 동작을 보장해야 한다.

  • 단일 가장자리에 컷아웃을 최대 1개 포함할 수 있다.
  • 기기에 컷아웃이 3개 이상 있을 수 없다.
  • 기기 양 쪽의 긴 가장자리(세로 모드 시 좌우)에는 컷아웃이 있을 수 없다.
  • 특수 플래그를 설정하지 않은 세로 방향에서는 상태 표시줄이 적어도 컷아웃 높이까지 확장되어야 한다.
  • 기본적으로 전체 화면 또는 가로 방향에서는 전체 컷아웃 영역이 레터박스 처리되어야 한다.

따라서, 다음과 같은 컷아웃 유형을 지원한다.

  • 상단 중앙: 상단 가장자리 중앙의 컷아웃
  • 상단 비중앙: 컷아웃이 모서리에 위치하거나 중앙에서 약간 벗어날 수 있다.
  • 하단: 하단의 컷아웃
  • 이중: 상단의 컷아웃 1개, 하단의 컷아웃 1개

콘텐츠가 컷아웃 영역과 겹치지 않게 하려면 콘텐츠가 스테이터스 바 및 네비게이션 바와 겹치지 않게 하려면 컷아웃 영역에서 Inset을 부여하여 처리하면 해결이 가능하다.

컷아웃 영역으로 렌더링하는 경우 WindowInsets#getDisplayCutout() 함수를 사용하여 각 컷아웃의 Safe Inset Area와 Safe Area가 포함된 DisplayCutout 객체를 탐색할 수 있다. 따라서 이러한 API를 사용해 콘텐츠가 컷아웃과 겹치는지 여부를 판단하여 위치를 조정할 수 있다.

아래 세 가지 옵션은 Android 15(API 35)에서 deprecated된 View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION를 사용할 경우 유효한 옵션이다.

Android 15(API 35)부터는 WindowInsets#getDisplayCutout() 함수를 사용하면 된다.

컷아웃 영역은 카메라와 같은 하드웨어가 디스플레이를 가리는 경우 생긴다.
관련 예로, 폰에서 카메라 영역(컷아웃 영역) 때문에 상단 인셋을 적용했는데, 이 코드를 컷아웃 영역이 없는 태블릿에서 실행하니 컷아웃 영역이 없는데도 상단 인셋이 적용되어 버린다.
따라서 아래와 같이 컷아웃을 사용해 패딩을 설정하면 컷아웃에 따른 인셋을 설정할 수 있게 된다.

1
2
3
4
5
6
ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, insets ->
insets.displayCutout?.let {
view.setPadding(0, it.safeInsetTop, 0, 0)
}
insets
}

[Android] Android 15 대응 - EdgeToEdge: 3. WindowInsetsController

WindowInsetsController

setSystemUiVisibility가 deprecated되어 Android 15(API 35)부터는 WindowInsetsController 인터페이스를 사용해야 한다.

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
/**
* Inset 생성 시 Window를 제어하기 위한 인터페이스
*/
public interface WindowInsetsController {

/**
* 어두운 백그라운드, 밝은 포그라운드 색상을 가진 불투명한 상태표시줄을 만듦
* @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 클래스에는 다음과 같이 분기처리가 되어 있다.

1
2
3
4
5
6
7
8
9
// Window의 Inset을 적용하는 경우 //
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);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}
}
1
2
3
4
5
6
7
8
9
10
11
@RequiresApi(30)
static class Api30Impl {
private Api30Impl() {
// This class is not instantiable.
}
@DoNotInline
static void setDecorFitsSystemWindows(@NonNull Window window,
final boolean decorFitsSystemWindows) {
window.setDecorFitsSystemWindows(decorFitsSystemWindows);
}
}

이와 같이 Android 15(API 35) 이상에서는 Window의 Inset을 부분적으로 적용하는 경우, Window#setDecorFitsSystemWindows를 사용하면 된다.

인셋의 종류는 다음 세 가지이다.

  • System bars insets
  • Display cutout insets
  • System gesture insets

SystemBar 숨김/표시

1
2
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.show(WindowInsets.Type.systemBars())

이를 사용해 시스템바를 표시하거나(show) 숨길(hide) 수 있다.

Window Inset 제어를 통한 FullScreen 구현

  • [AndroidDev] 몰입형 모드를 위한 시스템 표시줄 숨기기

기존에 decorView에 systemUiVisibility 옵션을 주었던 것과는 달리 안드로이드 11(API 30)에 대응하기 위해서는 InsetsController를 이용해야 한다.

Android 11에서 기존 옵션에 매칭되는 사항은 다음과 같다.

  • WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_TOUCH: lean back
  • WindowInsetsController.BEHAVIOR_SHOW_BARS_BY_SWIPE: immersive
  • WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: sticky immersive 최상단에서 쓸어내리거나 최하단에서 쓸어올리면 잠깐 나타나고 뷰에서 영역을 차지하지 않는다.

BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE를 적용한 예시 코드는 다음과 같다.

1
2
3
4
WindowCompat.getInsetsController(this, this.decorView).apply {
hide(WindowInsetsCompat.Type.navigationBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}

[Android] Android 15 대응 - EdgeToEdge: 2. StatusBar 및 NavigationBar

배경색

statusBar의 배경색을 지정할 수 있는 window.statusBarColor는 Android 15(API 35)에서 지원 종료되었다.

If the app targets VANILLA_ICE_CREAM or above, the color will be transparent and cannot be changed. - Window#setStatusBarColor 주석

VANILLA_ICE_CREAM 이상을 타겟팅한다면 색상은 투명이고 이 값을 사용할 수 없다고 가이드되어 있다.

따라서 status bar와 navigation bar 영역의 색상을 바꾸려면 setOnApplyWindowInsetsListener에 넘겨주는 findViewById(android.R.id.content)의 배경색을 바꿔야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
)

view.setBackgroundColor(
ContextCompat.getColor(view.context, R.color.white)
)

insets
}

이는 뷰의 배경색이기 때문에 padding을 설정해야 보이게 된다.

또한 status bar와 navigation bar의 색상을 다르게 하려면 아래 코드만으로는 안되고, 또 다른 조치를 취해줘야할 것으로 보인다.

statusBar는 위 코드와 같이 지정하고, navigationBar는 Activity의 최상위 뷰의 background에 컬러값 옵션과 paddingBottom을 설정함으로써 statusBar와 navigationBar의 색상을 다르게 처리하였다.

1
2
3
4
5
6
7
8
9
10
11
12
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setupWindowInsetsClassroom()
ViewCompat.setOnApplyWindowInsetsListener(window.findViewById(android.R.id.content)) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setBackgroundColor(ContextCompat.getColor(view.context, R.color.common_blue_color))
view.setPadding(0, systemBars.top, 0, 0)
binding.slidingPanelLayout.setPadding(0, 0, 0, systemBars.bottom)
insets
}
...
}

아이콘 색상

Android 15(API 35)에서는 다크모드에서의 흰색과 라이트 모드에서의 회색으로만 설정할 수 있다.

이를 설정하기 위해서는 아래 두 가지 setter를 사용한다.

  • InsetsController#isAppearanceLightStatusBars(boolean)
  • InsetsController#isAppearanceLightNavigationBars(boolean)
1
2
3
4
5
6
7
8
WindowCompat.getInsetsController(this, this.decorView).apply {
// 배경이 밝으니 스테이터스 바의 아이콘 색상을 회색으로 설정한다. //
isAppearanceLightStatusBars = true
// 배경이 밝으니 네비게이션 바의 아이콘 색상을 회색으로 설정한다. //
isAppearanceLightNavigationBars = true
// 3버튼 네비 바의 배경이 완전 투명 //
isNavigationBarContrastEnforced = false
}

InsetsController에 관해서는 아래에서 설명한다.

제스처 네비게이션 바

⚠️ 픽셀 기기에서는 제스처 네비게이션 바의 색상이 제제대로 처리되지 않는 것 같음. 흰배경이 뒤에 있는데도 흰색으로 설정되어있음.

제스처 네비게이션의 바 색상은 흰색↔회색으로 변하는데, 이는 아래 인용과 같이 시스템에서 알아서 변경해준다.

동작 탐색 모드: 시스템은 시스템 표시줄의 콘텐츠가 뒤에 있는 콘텐츠를 기반으로 색상을 변경하는 동적 색상 조정을 적용한다. (시스템 표시줄 색상 변경하기 영상 참고)