[Android Fundamentals] Activity - 3. Lifecycle
사용자가 앱을 사용하는 동안 activity는 생명주기의 여러 단계를 지난다. Activity에는 생명주기가 바뀔 때마다 실행되는 콜백이 정의되어 있어, 생명주기별로 실행할 작업을 정의할 수 있다.
생명주기 콜백 함수에서는 사용자가 activity에서 들어올 때와 나갈 때 실행할 코드를 작성할 수 있다. 예를 들어 비디오 스트리밍 앱을 만들고 있다면, 사용자가 다른 앱으로 이동했을 때에는 비디오 재생을 중지할 수 있다. 사용자가 앱으로 돌아오면 다시 재생을 시작할 수 있다.
생명주기에 맞는 작업을 실행하면 더 빠르고 견고한 앱을 만들 수 있다. 대략 아래와 같은 경우를 방지할 수 있다.
- 사용자가 전화를 받거나 다른 앱으로 이동할 때 앱이 crash되는 경우
- 사용자가 앱을 사용하고 있지 않을 때 시스템 자원을 소모하는 경우
- 사용자가 잠시 다른 앱을 사용하다 돌아왔을 때 사용자의 작업 상황이 초기화되는 경우
- 화면 방향이 바뀔 때 앱이 crash되거나 사용자의 작업 상황이 초기화되는 경우
Activity의 생명주기
Activity 클래스에는 다음과 같은 생명주기 콜백이 정의되어 있다. Activity의 state가 바뀔 때마다 상황에 맞는 콜백이 실행된다.
안드로이드 시스템은 사용자가 activity를 떠날 때 activity를 해체하는 함수를 호출한다. Activity를 완전히 해체하는 경우도 있지만, 잠시 다른 앱을 실행하는 경우에는 나중에 재사용하기 위해 일부만 해체하는 경우도 있다. 위 그림에서 onStop() → onRestart() → onStart() 경로가 그런 경우이다.
몇 가지 예외를 제외하면 백그라운드에서 activity를 실행하는 것은 금지되어 있다. 나중에 다시 알아볼 것이다.
Activity의 state에 따라 시스템이 activity(와 프로세스)를 종료할 가능성이 달라진다. 사용자와 상호작용하고 있는 activity는 웬만해선 종료되지 않는다. 반면 사용자에게 완전히 보이지 않는 activity는 종료될 가능성이 상대적으로 높다.
물론 위 그림에 있는 콜백을 정의할 필요는 없다. 필요할 때 필요한 콜백만 정의하자.
생명주기 콜백
Activity의 생명주기 콜백의 의미를 이해하고, 구현 예시를 알아보자.
onCreate()
시스템이 activity 객체를 만들 때 호출하는 콜백이다. Activity에서 한 번만 실행하면 되는 setup 로직을 작성해야 한다. 이 콜백은 무조건 정의해야 한다.
예를 들어 ``onCreate()``에서 데이터를 리스트에 bind하고, activity와 ``ViewModel``을 연결하고, activity에서 전역으로 사용할 매개변수를 만들 수 있다. 매개변수로 주어지는 ``savedInstanceState: Bundle?``을 통해 activity의 이전 state를 복원할 수 있다. Activity의 이전 상태가 없다면(즉 activity가 처음 실행됐다면) ``savedInstanceState``는 null이다.
Activity의 생명주기에 연결된 다른 lifecycle-aware 컴포넌트가 있다면 그 컴포넌트도 ``ON_CREATE`` 이벤트를 받는다. ``@OnLifecycleEvent`` 어노테이션이 붙은 함수도 호출된다.
블린더 앱의 ``onCreate()``는 대략 이렇게 생겼다. Activity의 window 속성을 설정하고, ``setContent``를 통해 activity에서 보여줄 UI를 정의하고 있다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
// Splash screen will dismiss as soon as the app draws its first frame
installSplashScreen()
setContent {
// ...
}
}
추가로 activity의 state를 저장/복원하는 콜백도 주어진다.
// This callback is called only when there is a saved instance previously saved using
// onSaveInstanceState(). Some state is restored in onCreate(). Other state can optionally
// be restored here, possibly usable after onStart() has completed.
// The savedInstanceState Bundle is same as the one used in onCreate().
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
textView.text = savedInstanceState?.getString(TEXT_VIEW_KEY)
}
// Invoked when the activity might be temporarily destroyed; save the instance state here.
override fun onSaveInstanceState(outState: Bundle?) {
outState?.run {
putString(GAME_STATE_KEY, gameState)
putString(TEXT_VIEW_KEY, textView.text.toString())
}
// Call superclass to save any view hierarchy.
super.onSaveInstanceState(outState)
}
``onRestoreInstanceState()``에서는 ``onCreate``에서 복원하지 않은 몇몇 상태를 복원할 수 있다. 이 콜백은 ``onStart``가 끝난 후 ``onResume``가 실행되기 전에 호출된다.
``onSaveInstanceState``에서는 activity의 몇몇 상태를 bundle에 저장할 수 있다. 이 콜백은 ``onPause``가 끝난 후 ``onStop``이 실행되기 전에 호출된다.
``onCreate()``가 끝나면 activity는 started 상태가 되어 ``onStart()``와 ``onResume()``을 바로 실행한다.
onStart()
Activity가 start 상태에 들어온 후 실행된다. 이 함수를 실행한 후에는 activity가 사용자에게 보이게 되고, 사용자와 상호작용할 준비를 한다. 이 콜백에서는 UI를 초기화하는 코드를 실행할 수 있다. (물론 onCreate에서 실행해도 된다)
Activity가 start 상태로 들어오면 activity와 연결된 lifecycle-aware 컴포넌트도 ``ON_START`` 이벤트를 받는다.
``onStart()``가 끝나면 activity는 resumed 상태가 되고 ``onResume()`` 콜백이 바로 실행된다.
onResume()
Activity가 resumed 상태가 되면 ``onResume()`` 콜백이 실행되고, 콜백이 끝난 후에는 사용자와 상호작용할 수 있게 된다. 사용자와의 상호작용이 종료되기 전까지 activity는 resumed 상태에 머무른다. 상호작용이 종료되는 경우에는 전화를 받는 경우, 다른 activity로 이동하는 경우, 기기 화면이 꺼지는 등의 경우가 있다.
이 함수에서 컴포넌트가 사용자에게 보일 때 실행해야 하는 기능을 활성화할 수 있다. 카메라 프리뷰를 보여준다던가.
위에서 언급한 상호작용을 종료하는 이벤트가 발생하면, activity는 paused 상태로 들어가고 ``onPause()`` 콜백이 실행된다.
Activity가 paused에서 resumed로 돌아오면, ``onResume()``이 다시 실행된다. ``onResume()``에서 리소스를 초기화하고, ``onPause()``에서 리소스를 해제하면 된다.
Activity와 연결된 컴포넌트에서도 ``ON_RESUME`` 이벤트를 받아 처리할 수 있다.
class CameraComponent : LifecycleObserver {
...
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun initializeCamera() {
if (camera == null) {
getCamera()
}
}
...
}
하지만 paused 상태에서도 activity가 사용자에게 보일 수 있다. Paused 상태는 사용자와 상호작용이 중지됐음을 의미하는데, 상호작용만 하지 않을 뿐 사용자에게 보이는 경우도 많기 때문이다. 다른 activity에 의해 일부만 가려졌다던가. 따라서 위 코드는 ``ON_RESUME``이 아닌 ``ON_START``에서 실행하는 것이 적절할 수도 있다.
하지만 paused 상태에서 카메라를 계속 보여주면, resumed 상태의 다른 activity에서 카메라에 접근하지 못할 수도 있다. UX가 저하될 수도 있으므로 잘 판단할 것.
그래서 multi-window 상황에서는 리소스를 어떤 state에서 가져올 지가 매우 중요하다. 가져온 리소스는 잊지 말고 적절히 해제하자. onStart에서 가져왔다면 onStop에서 해제하고, onResume에서 가져왔다면 onPause에서 해제해야 한다.
onPause()
사용자가 activity를 완전히 또는 일시적으로 떠날 때 실행되는 첫 번째 콜백이다. Activity가 포그라운드 상태에서 벗어나 사용자와 상호작용할 수 없지만, 멀티 윈도우 환경에서는 여전히 사용자에게 보일 수 있다.
Paused 상태에 들어오는 이유는 대략 이렇다.
- 실행중인 앱을 인터럽트하는 이벤트가 발생했을 때. 가장 일반적인 경우다.
- 멀티 윈도우 모드에서 다른 앱이 사용자와 상호작용할 때 (동시에 하나의 앱만 사용자와 상호작용할 수 있으므로)
- Dialog 등 반투명한 activity에 의해 가려질 때.
이 함수에서 시스템 자원을 해제할 수도 있다. Paused 상태에서 사용하지 않는 자원이 있다면 ``onPause()``에서 해제하자.
하지만 pause된 activity도 사용자에게 계속 보일 수 있으므로 상황에 맞게 ``onPause()`` 또는 ``onStop()``에서 리소스를 해제하자.
class CameraComponent : LifecycleObserver {
...
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun releaseCamera() {
camera?.release()
camera = null
}
...
}
onResume 문단에서 말했듯이 ``onResume()``과 ``onPause()``는 매우 자주 실행될 수 있다. 따라서 조금이라도 복잡한 작업은 다른 함수에서 실행해야 한다. 애초에 ``onPause()``에 할당된 시간도 얼마 안 된다. 무거운 작업은 ``onStop()``에서 실행하자.
Activity는 ``onPause()``가 종료된 후 paused 상태에서 머물러 있다가, 상황에 따라 resume되거나 stop될 수 있다.
onStop()
Activity가 사용자에게 전혀 보이지 않게 되면 activity는 stopped 상태가 되고, ``onStop()`` 콜백이 실행된다. Activity가 종료될 떄에도 ``onStop()``이 실행될 수 있다.
이 함수에서 사용자에게 보이지 않을 때 사용하지 않는 시스템 자원을 해제하자. 예를 들어 activity에서 지도에서 사용자 GPS에 맞게 UI를 업데이트한다면, ``onStop()``에서 GPS 데이터 수신을 중지할 수 있다.
앱 종료 로직 중 CPU-intensive 부분을 ``onStop()``에서 실행할 수도 있다.
Activity가 stopped 상태가 되면 ``Activity`` 객체는 일단 메모리에 보관된다. 메모리에 보관된 상태에서도 모든 state와 멤버 변수가 보존되지만, window manager에는 등록되어 있지 않다. 사용자에게 보이지 않고 있기 때문.
Stopped된 activity는 메모리가 부족할 때 종료될 수 있다. Activity가 종료된 후에도 ``View`` 객체들의 state는 시스템이 ``Bundle``에 보관한다. 나중에 사용자가 activity에 돌아올 때 해당 정보를 복원하여 사용할 수 있다.
Stopped 상태의 activity는 다시 사용자에게 보이게 될 수도 있고, 완전히 종료될 수도 있다. 각각 ``onRestart()``와 ``onDestroy()``가 실행된다.
onDestroy()
``onDestroy()``는 activity가 destroy되기 직전에 실행된다. Activity가 destroy되는 경우는 다음과 같다.
- Activity가 사용자 또는 ``finish()`` 함수에 의해 종료되는 경우
- Configuration change 때문에 시스템이 일시적으로 activity를 destroy하는 경우. 이 경우에는 시스템이 새 activity 객체를 바로 만든다(따라서 ``onCreate()``도 실행된다).
첫 번째 경우에는 ``ViewModel.onCleared()`` 함수가 실행된다. 이 함수에서 viewmodel이 사용하는 리소스를 해제할 수 있다. ``onDestroy()``가 실행된 후에는 해당 activity를 실행하던 프로세스가 언제든지 kill될 수 있다.
두 번째 경우에서는 activity의 정보를 백업/복원할 필요가 있다. 그러나 ``onDestroy()``에서 두번째 경우에 따로 대응하기보다는 ``ViewModel`` 객체를 사용하는 것이 좋다. ``ViewModel``은 두 번째 경우에서의 destroy에 전혀 영향을 받지 않고, 새로 만들어진 activity 객체에서 즉시 접근할 수 있기 때문이다.
두 경우는 ``isFinishing()`` 함수를 통해 구분할 수 있다. 반환값이 true이면 첫 번째, false이면 두 번째 경우이다.
Activity 상태와 메모리
시스템은 메모리를 확보해야 할 때 몇몇 프로세스를 종료한다. 특정 프로세스를 종료할 가능성은 그 프로세스의 state에 크게 영향받고, activity를 실행하는 프로세스의 state는 해당 activity의 state에 의해 결정된다. 프로세스의 상태에 따라 종료될 가능성을 정리해 보면 다음과 같다.
Kill될 가능성 | 프로세스 상태 | Activity의 마지막 상태 |
매우 낮음 | 포그라운드 (포커스 있음) | Resumed |
낮음 | 사용자에게 보임 (포커스 없음) | Started/Paused |
높음 | 백그라운드 (사용자에게 안 보임) | Stopped |
매우 높음 | Empty | Destroyed |
안드로이드 시스템은 activity를 직접 종료하지는 않고, activity가 실행되고 있는 프로세스를 종료하여 activity를 포함한 프로세스의 모든 메모리를 날려버린다.
사용자가 작업 관리자 등을 통해 앱을 직접 종료할 수도 있다.
UI state를 백업/복원하는 방법
사용자는 화면 회전 등의 configuration change 이후에도 UI의 모든 상태가 보존되어 있길 기대한다. 그러나 안드로이드 시스템은 configuration change가 발생했을 때 activity를 destroy한 후 다시 create하므로 activity 인스턴스의 모든 state가 초기화된다.
마찬가지로, 사용자는 잠시 다른 앱을 사용하다가 돌아왔을 때에도 모든 UI 상태가 보존되어 있길 기대한다. 하지만 위에서 봤듯이 백그라운드 activity는 kill될 가능성이 높다. 따라서 activity가 kill된 후에도 상태를 보존할 수 있어야 한다.
UI 상태를 보존하는 방법은 ``ViewModel``, ``onSaveInstanceState()``, 로컬 저장소의 3가지가 있다. 이 글에서는 ``onSaveInstance()`` 관련 함수를 살펴본다. UI가 비교적 가벼운 경우라면 ``onSaveInstance()``를 사용할 수 있다. 하지만 ``onSaveInstance()``는 (de)serialization 비용이 들기 때문에, 대부분의 경우에는 ``ViewModel``을 함께 사용하는 것이 효과적이다.
Instance state
사용자가 뒤로 가기 버튼을 누르거나 ``finish()`` 함수에 의해 종료되는 경우는 activity를 그대로 종료하는 것이 맞다. 따라서 이 경우에는 어떠한 작업도 수행할 필요가 없다.
하지만 activity가 configuration change나 메모리 부족에 의해 종료된다면, 사용자 입장에서는 UI가 계속 보이는 것이 맞다. 메모리가 부족하든 말든 알 바 아니기 때문이다. 따라서 이 경우에는 시스템이 나중에 activity 인스턴스를 다시 만들 가능성이 있다.
이 때 시스템은 종료되기 직전의 UI 상태(instance state)를 참고하여 데이터를 복원한다. Instance state는 key-value 쌍의 집합으로, ``Bundle`` 객체에 저장된다.
``EditText`` 같은 기본 view는 자체적으로 instance state를 저장하는 로직이 구현되어 있다. 따라서 기본 view는 activity가 destroy된 후 다시 만들어질 때에도 자동으로 UI 상태를 복원할 수 있다. 단 view에 ``android:id``가 선언되어 있어야 한다.
하지만 커스텀 뷰나 activity 로컬 변수 같은 것들은 개발자가 직접 백업해야 한다. ``Bundle`` 객체는 serialization 기반으로 동작하는 특성상 대량의 데이터를 저장하기에는 적합하지 않다. 메모리 사용량이 너무 많아지기 때문. 이런 경우에는 로컬 DB나 ``ViewModel``에 저장하는 것이 좋다.
가벼운 정보는 ``onSaveInstanceState()``에 저장
Activity가 stop될 때 ``onSaveInstanceState()`` 함수가 호출된다. 정확히는 ``onPause()`` - ``onSaveInstanceState()`` - ``onStop()`` 순서로 실행된다. 기본적으로는 view hierarchy의 정보를 저장하도록 구현되어 있다. 위에서 말한 ``EditText``의 텍스트 등이 해당한다.
더 많은 정보를 저장하고 싶다면, ``onSaveInstanceState()``를 오버라이드하여 key-value 쌍을 저장할 수 있다. View hierarchy의 정보를 저장하고 싶다면 ``super.onSaveInstanceState()``를 반드시 호출하자.
override fun onSaveInstanceState(outState: Bundle?) {
// Save the user's current game state.
outState?.run {
putInt(STATE_SCORE, currentScore)
putInt(STATE_LEVEL, currentLevel)
}
// Always call the superclass so it can save the view hierarchy state.
super.onSaveInstanceState(outState)
}
companion object {
val STATE_SCORE = "playerScore"
val STATE_LEVEL = "playerLevel"
}
단, ``onSaveInstanceState()``는 activity가 destroy되는 1번 경우에서는 호출되지 않는다.
``onSaveInstanceState()``는 activity가 포그라운드일 때 실행된다. 백그라운드로 들어가기 직전에 실행되는 것. 따라서 포그라운드에서 데이터를 저장하고 싶다면 이 함수에서 저장하는 것이 좋다.
Instance state에서 데이터 복원
Activity가 destroy된 후 다시 생성될 때, 이전에 저장한 ``Bundle``로부터 데이터를 복원할 수 있다. ``onCreate()``와 ``onRestoreInstanceState()``에 매개변수로 주어지는 ``Bundle``을 활용하면 된다.
두 함수에는 동일한 ``Bundle`` 객체가 주어진다. 이 값이 null이라면 activity를 완전히 새로 만드는 경우라고 생각하면 된다.
``onCreate()``에서 데이터를 복원하는 방법은 다음과 같다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) // Always call the superclass first
// Check whether we're recreating a previously destroyed instance.
if (savedInstanceState != null) {
with(savedInstanceState) {
// Restore value of members from saved state.
currentScore = getInt(STATE_SCORE)
currentLevel = getInt(STATE_LEVEL)
}
} else {
// Probably initialize members with default values for a new instance.
}
// ...
}
``onStart()`` 이후에 실행되는 ``onRestoreInstanceState()``를 구현할 수도 있다. 이 함수는 저장된 instance state가 있을 때에만 실행되므로, ``Bundle``이 null인지 확인하지 않아도 된다. 다만 매개변수가 ``Bundle?`` 타입으로 주어지므로 Kotlin에서는 형식적으로 null을 체크해야 한다.
``super.onRestoreInstanceState()``는 반드시 호출할 것!
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
// Always call the superclass so it can restore the view hierarchy.
super.onRestoreInstanceState(savedInstanceState)
// Restore state members from saved instance.
savedInstanceState?.run {
currentScore = getInt(STATE_SCORE)
currentLevel = getInt(STATE_LEVEL)
}
}
Activity 간 이동
앱을 사용하다 보면 여러 activity를 거쳐갈 수 있다. Activity 사이를 이동할 때 주의해야 할 점을 알아보자.
Activity 시작
Activity는 ``startActivity()`` 또는 ``startActivityForResult()``로 시작할 수 있다. 두 함수의 공통점은 ``Intent``를 통해 작업을 명령한다는 점이고, 차이점은 실행한 activity의 결과값을 받지 않는/받는다는 점이다.
``Intent`` 객체에 activity 경로를 명시하거나(명시적 intent), 실행하고 싶은 작업을 지정하여(암묵적 intent) activity를 실행할 수 있다. 명시적 intent를 사용하면 해당 activity가 실행되고, 암묵적 intent를 사용하면 해당 작업을 실행할 수 있는 activity 중 하나를 사용자가 선택한다.
startActivity()
Activity의 실행 결과를 받을 필요가 없을 때 사용한다. 실행할 activity를 지정하는 명시적 intent를 사용할 수 있다.
val intent = Intent(this, SignInActivity::class.java)
startActivity(intent)
Activity를 직접 지정하는 대신 activity가 수행할 작업을 지정할 수도 있다. 이 경우에는 시스템이 모든 앱을 대상으로 해당 작업을 수행할 수 있는 activity를 찾는다. 즉, 작업의 종류를 명시하는 것만으로도 작업을 수행할 수 있는 것.
예를 들어, 이메일 작성 activity를 찾는 코드는 다음과 같다.
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_EMAIL, recipientArray) // 작업에 사용할 데이터
}
startActivity(intent)
사실 이 코드는 이메일 작성이 아닌 뭔가 보내는 작업을 찾고 있다. ``Intent.ACTION_SEND``를 사용했기 때문. 하지만 이메일 작성 activity가 intent filter에 ``Intent.ACTION_SEND``를 지정했다면 검색 결과에 이메일 앱이 포함되고, 해당 앱을 통해 이메일을 작성할 수 있다.
``Intent.EXTRA_EMAIL``은 작업을 수행하는 데 사용될 데이터이다. Intent에서는 extra라는 이름으로 부른다.
startActivityForResult(Intent, Int)
실행한 activity로부터 뭔가 결과값을 받고 싶을 때도 있다. 연락처 activity를 실행하고, 사용자가 해당 activity에서 고른 연락처 정보를 반환받고 싶을 수도 있다. 이런 경우에는 ``startActivityForResult(Intent, Int)``를 실행하자.
Int 매개변수는 요청 id를 의미한다. 같은 activity에 요청이 여러 개 보내졌을 때 응답을 반환받기 위해 사용된다. 시스템에 전역으로 공유되지는 않고, 하나의 앱 안에서만 겹치지 않으면 된다.
결과값은 ``onActivityResult(int, int, Intent)`` 함수로 반환된다.
protected open fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent!
): Unit
자식 activity에서 ``setResult(int)``를 통해 resultCode를 지정할 수 있다. 안드로이드 기본 결과값인 ``RESULT_OK``나 ``RESULT_CANCELED`` 또는 ``RESULT_FIRST_USER``보다 큰 커스텀 결과값을 반환할 수 있다. ``Intent`` 객체를 통해 데이터를 전달할 수도 있다.
``startActivityForResult``를 통해 activity를 실행하고 결과값을 받는 예제는 다음과 같다.
class MyActivity : Activity() {
// ...
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
// When the user center presses, let them pick a contact.
startActivityForResult(
Intent(Intent.ACTION_PICK, Uri.parse("content://contacts")),
PICK_CONTACT_REQUEST
)
return true
}
return false
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
when (requestCode) {
PICK_CONTACT_REQUEST ->
if (resultCode == RESULT_OK) {
// A contact was picked. Display it to the user.
startActivity(Intent(Intent.ACTION_VIEW, intent?.data))
}
}
}
companion object {
internal val PICK_CONTACT_REQUEST = 0
}
}
Lifecycle 영향
Activity가 다른 activity를 start하면, 두 activity 모두 생명주기가 바뀐다. 부모 activity는 최소한 paused 상태로 들어가고, 자식 activity는 create되어 resumed까지 갈 수 있다. 두 activity에서 어떤 데이터를 공유하는 경우에는, 부모 activity는 자식 activity가 create될 때까지 완전히 stop되지 않는다는 점을 기억하자. 부모의 stop과 자식의 create 과정이 겹쳐진다고 보면 된다.
정확히는 다음과 같다.
- 부모 activity의 ``onPause()``가 실행된다.
- 자식 activity의 ``onCreate()``, ``onStart()``, ``onResume()``이 연속적으로 실행된다. 따라서 자식 activity는 포커스를 갖게 된다.
- 부모 activity가 화면에서 완전히 보이지 않게 된다면, ``onStop()``이 실행된다.
참고자료