Android

액티비티 (2) : 액티비티의 수명 주기

까망사과 2022. 4. 8. 16:00

액티비티의 전체 수명주기 동안 여러 상태를 거친다.

상태가 바뀔 때마다 일련의 콜백 메서드를 사용할 수 있다.

수명주기 콜백을 잘 구현해야 앱의 크래시나 리소스 낭비 등을 방지할 수 있다.

 

Activity 클래스가 제공하는 6가지 핵심 콜백은 다음과 같다.

 

onCreate() 액티비티 상태: 생성됨

시스템이 액티비티를 생성할 때 한 번만 호출되는 콜백.
수명주기 동안 한 번만 발생하는 앱 시작 로직(뷰 초기화 / UI 레이아웃 정의 등)을 구현해야 한다.

onCreate()savedInstanceState라는 파라미터를 받는데 이는 액티비티의 이전 상태가 저장된 Bundle 객체이다.
처음 생성된 액티비티의 경우 savedInstanceState의 값은 null이다.

onCreate()가 완료되면 액티비티는 시작됨 상태로 진입하고 onStart()가 호출된다.

 

 

onStart() 액티비티 상태: 시작됨

이 콜백이 호출되면 액티비티가 사용자에게 보이기 시작한다.
이는 액티비티가 포그라운드로 나와 사용자와 상호작용하기를 준비(UI 관리 코드 초기화 등)하는 단계이다.

onStart()는 굉장히 빠르게 완료되며 액티비티는 시작됨 상태에 머무르지 않는다.
이 메서드가 완료되면 액티비티는 재개됨 상태로 진입하고 onResume()가 호출된다.

 

 

onResume() 액티비티 상태: 재개됨

재개됨 상태에 진입하면 액티비티가 포그라운드로 나와 사용자와 상호작용한다.
액티비티는 앱이 포커스를 잃을 때까지 재개됨 상태를 유지된다.
포커스를 방해하는 이벤트가 발생하면 일시중지됨 상태로 진입하고 onPause()가 호출된다.
액티비티가 일시중지됨 상태에서 재개됨 상태로 돌아오면 onResume()이 다시 호출된다.

 

 

onPause() 액티비티 상태: 일시중지됨

사용자가 액티비티를 벗어나는 첫 번째 신호로 이 메서드를 호출한다.
이는 액티비티가 포그라운드에 있지 않다는 것이지 반드시 소멸한다는 것을 의미하지는 않는다.
(멀티 윈도우 모드일 경우 일시중지됨 상태이더라도 사용자에게 보일 수 있기 때문)

onPause()에서는 액티비티가 일시중지됨 상태일 때 계속하지 않을(잠시 후 재개할) 작업을 일시중지하거나 수정해야 한다.
일시중지됨 상태로 전환되는 몇 가지 원인은 다음과 같다.

  • 앱 실행을 방해하는 이벤트가 발생한다.
  • 안드로이드 7.0(API 레벨 24) 이상 버전에서는 멀티 윈도우 모드를 지원한다.
    1개의 앱만 포커스를 가질 수 있기 때문에 다른 앱은 시스템이 일시중지시킨다.
  • 새로운 반투명 액티비티(예를 들면 다이얼로그)가 열린다.
    액티비티가 부분적으로 보이지만 포커스를 가지지 않으면 일시중지됨 상태를 유지한다.

이 메서드에서 불필요한 리소스를 해제할 수도 있다.
하지만 위에서 설명한 것처럼 멀티 윈도우 모드에서는 액티비티가 일시중지됨 상태이어도 완전히 보일 수 있다.
그러므로 UI 관련 리소스나 작업을 해제 및 조정할 때는 onStop()을 사용하는 것이 좋다.

onPause()onStart()처럼 매우 빠르게 완료되는 콜백이다.
따라서 데이터를 저장하거나 네트워크 호출 또는 데이터베이스 트랜잭션 작업을 수행하기에는 시간이 부족할 수 있다.
부하가 큰 종료 작업은 onStop()에서 수행해야 한다.

onPause()가 완료된다고 액티비티가 일시중지됨 상태를 벗어나는 것은 아니다.
재개됨 상태로 돌아가거나 사용자에게 완전히 보이지 않게 될 때까지 일시중지됨 상태에 머무른다.
재개됨 상태로 돌아가면 onResume() 콜백이 다시 호출되고 Activity 객체는 메모리에 저장되어 있다가 onResume()이 호출될 때 다시 호출된다.

액티비티가 완전히 보이지 않게 되면 onStop()이 호출된다.

 

 

onStop() 액티비티 상태: 중단됨

액티비티가 사용자에게 더 이상 보이지 않을 때 호출되는 콜백.
예를 들면 새 액티비티가 화면 전체를 차지할 때 호출된다.
액티비티가 실행을 멈추고 종료되려 할 때에도 호출될 수 있다.

onStop()에서 앱은 보이지 않는 동안 필요하지 않은 리소스를 해제 및 조정해야 한다.
예를 들어 애니메이션을 일시중지하거나 세밀한 위치 업데이트를 대략적인 위치 업데이트로 바꿀 수 있다.
onPause() 대신 onStop()을 사용하면 사용자가 멀티 윈도우 모드로 액티비티를 보고 있을 때 UI 작업이 계속되게 할 수 있다.

CPU 소모가 큰 종료 작업에도 onStop()을 사용할 수 있다.
예를 들어 데이터베이스에 정보를 저장할 시간이 따로 없다면 onStop()에서 할 수 있다.
다음은 노트 초안을 영구 저장소에 저장하는 onStop()을 구현한 예시이다.

override fun onStop() {
    // 상위 클래스의 메서드를 먼저 호출
    super.onStop()

    // 노트의 현재 초안을 저장
    val values = ContentValues().apply {
        put(NotePad.Notes.COLUMN_NAME_NOTE, getCurrentNoteText())
        put(NotePad.Notes.COLUMN_NAME_TITLE, getCurrentNoteTitle())
    }

    // AsyncQueryHandler 등으로 백그라운드에서 업데이트를 수행
    asyncQueryHandler.startUpdate(
            token,     // 관련된 호출을 위한 Int형 토큰
            null,      // 쿠키(사용 안 함)
            uri,       // 노트의 URI
            values,    // 열의 이름과 새 값이 짝지어진 맵
            null,      // SELECT 조건(사용 안 함)
            null       // WHERE 열(사용 안 함) 
    )
}

위 예시는 SQLite를 직접 사용하지만 대신 Room 라이브러리를 사용할 수도 있다.액티비티가 중단됨 상태에 진입하면 Activity 객체는 메모리 안에 저장되었다가 활동이 재개될 때 다시 호출된다.
따라서 EditText에 입력된 텍스트를 따로 저장/복원할 필요가 없다.

액티비티가 중단되면 시스템은 해당 액티비티가 포함된 프로세스를 소멸시킬 수 있다(메모리 확보 등을 위해).
사용자가 이 액티비티로 복귀하면 이를 복원한다.

액티비티는 중단됨 상태에서 재시작하여 사용자와 상호작용하거나 소멸된다.
재시작되면 onRestart()가 호출되고 소멸되면 onDestroy()가 호출된다.

액티비티가 중단된 동안 시스템이 프로세스를 소멸시키더라도 Bundle에 있는 뷰 객체 상태가 그대로 유지되고,
시스템은 레이아웃에 있는 각 뷰 객체의 현재 상태도 기록한다.

 

 

onDestroy() 액티비티 상태: 소멸됨

액티비티가 소멸되기 전에 호출되는 콜백으로, 다음의 경우에 호출된다.

  • 사용자가 액티비티를 닫거나 액티비티에서 finish()가 호출되는 경우
  • 구성 변경(화면 회전, 멀티 윈도우 모드 전환 등)으로 인해 시스템이 액티비티를 일시적으로 소멸시키는 경우
  • 액티비티에 소멸되는 이유를 확인하는 로직을 구현하는 대신 ViewModel 객체를 사용해야 한다.
    그렇게 하면 해당 액티비티와 관련된 뷰의 데이터를 포함할 수 있다.

액티비티가 구성 변경으로 인해 재생성된다면 ViewModel은 보존되었다가 다음 액티비티 인스턴스에 전달될 것이다.
액티비티가 재생성되지 않는다면 ViewModelonCleared()로 액티비티가 소멸되기 전에 데이터를 정리해야 한다.
isFinishing()로 이 두 시나리오를 구별할 수 있다.

액티비티가 종료된다면 onDestroy()는 액티비티가 마지막으로 수신하는 콜백이다.
구성 변경으로 인해 onDestroy()가 호출되면 시스템은 액티비티의 새 인스턴스를 생성하고 새 구성으로 새 인스턴스의 onCreate()를 호출한다.

onDestroy()는 이전 콜백에서 해제되지 못한 모든 리소스를 해제해야 한다.

 


액티비티 상태와 메모리에서 제거

시스템은 RAM 공간을 확보해야 할 때 프로세스를 종료한다.
시스템이 프로세스를 정리할 가능성은 당시 프로세스의 상태에 따라 달라진다.

그리고 프로세스 상태는 프로세스에서 실행되는 액티비티의 상태에 따라 달라진다.

다음 표는 프로세스 상태, 액티비티 상태, 그리고 시스템이 프로세스를 종료할 가능성 사이의 상관관계를 나타낸 것이다.

종료 가능성 프로세스 상태 액티비티 상태
최소 포그라운드
(포커스를 가지고 있거나 가질 예정)
생성됨
시작됨
재개됨
중간 백그라운드
(포커스를 잃음)
일시중지됨
최대 백그라운드
(보이지 않음)
중단됨
비어 있음 소멸됨

 

시스템은 메모리를 확보하기 위해 직접 액티비티를 종료하지 않는다.

대신 액티비티가 실행되는 프로세스를 종료하여 액티비티뿐만 아니라 프로세스에서 실행 중이던 다른 작업을 소멸시킨다.

 

사용자도 설정에서 애플리케이션 관리자로 앱을 종료하는 방법으로 프로세스를 종료할 수 있다.

 


UI 상태 저장 및 복구

사용자는 구성 변경이 발생하더라도 액티비티의 UI 상태가 유지되기를 기대한다. 하지만 시스템은 그런 구성 변경이 발생하면 기본적으로 액티비티를 소멸시켜 액티비티 인스턴스에 저장된 모든 UI 상태를 지운다. 또한 사용자는 다른 앱으로 전환했다가 다시 돌아왔을 때도 UI 상태가 유지되기를 기대한다. 하지만 시스템은 사용자가 액티비티에서 떠나서 액티비티가 중단된 동안 앱의 프로세스를 종료할 수 있다.

 

액티비티가 시스템 제약으로 의해 소멸될 경우 ViewModel, onSaveInstanceState(), 로컬 저장소를 조합하여 UI 상태를 보존해야 한다. UI 데이터가 간단하고 가벼운 경우(원시 타입이나 간단한 객체 등) onSaveInstanceState()만 사용하여 UI 상태를 구성 변경 및 시스템에 의한 프로세스 종료로부터 보존할 수 있다. 하지만 onSaveInstanceState()로 인해 직렬화 및 역직렬화 비용이 발생하기 때문에 대부분의 경우 ViewModelonSaveInstanceState()를 모두 사용해야 한다.

 

인스턴스 상태

정상적인 앱 동작에 의해 액티비티가 소멸되는 시나리오가 몇 가지 있다. 예를 들면 사용자가 뒤로 가기 버튼을 누르거나 액티비티가 finish()를 호출하여 스스로 소멸되는 것이다. 사용자가 뒤로 가기 버튼을 누르거나 액티비티가 스스로 소멸되면 Activity 인스턴스에 대한 시스템과 사용자의 개념은 영구적으로 사라진다. 이러한 시나리오에서 사용자의 기대와 시스템의 동작이 일치하므로 따로 작업을 할 필요가 없다.

 

하지만 시스템 제약에 의하여 액티비티가 소멸되면 실제 Activity 인스턴스는 사라져도 존재했다는 정보는 시스템에 남아 있다.

사용자가 액티비티로 돌아가려고 시도하면 시스템은 액티비티의 소멸 당시 상태가 저장된 데이터 세트를 사용하여 새 인스턴스를 생성한다.

 

시스템이 이전 상태를 복구하기 위해 사용하는 저장된 데이터를 인스턴스 상태라 하며 이는 Bundle 객체에 저장된 키-값 쌍의 모임이다. 기본적으로 시스템은 인스턴스 상태를 사용하여 액티비티 레이아웃의 각 뷰 객체 정보를 저장한다. 그러므로 만약 액티비티 인스턴스가 소멸되었다가 재생성된다면 레이아웃의 상태는 별도의 작업 없이 이전 상태로 복구된다. 하지만 사용자의 진행 상황을 추적하는 멤버 변수처럼 복구하려 했던 상태보다 액티비티가 더 많은 상태 정보를 포함하고 있을 수 있다.

 

시스템이 액티비티의 뷰의 상태를 복구하기 위해 각 뷰에 android:id 속성으로 고유한 ID를 지정해야 한다.

 

Bundle 객체는 메인 스레드에서 직렬화 과정이 필요하며 시스템 프로세스 메모리를 소모하기 때문에 소량의 데이터를 보존하는 데만 적합하다. 더 많은 양의 데이터를 보존하려면 영구적 로컬 저장소, onSaveInstanceState(), ViewModel을 사용해야 한다.

 

onSaveInstanceState()를 사용하여 간단하고 가벼운 UI 상태 저장

액티비티가 중지되기 시작하면 시스템은 onSaveInstanceState()를 호출하여 액티비티가 상태 정보를 인스턴스 상태 번들에 저장할 수 있도록 한다. 이 메서드의 기본 구현은 액티비티의 뷰 계층 구조의 상태에 대한 정보(ex: EditText의 텍스트, ListView의 스크롤 위치)를 저장한다.

 

액티비티의 상태 정보를 추가로 저장하려면 onSaveInstanceState()를 재정의하고 액티비티가 의도치 않게 소멸하는 이벤트가 발생할 때 저장되는 키-값 쌍을 Bundle에 추가해야 한다. onSaveInstanceState()를 재정의할 때 뷰 계층의 상태를 저장하는 기본 구현 내용이 필요하다면 상위 클래스의 메서드를 호출해야 한다.

 

예시

override fun onSaveInstanceState(outState: Bundle?) {
    // 사용자의 현재 게임 상태를 저장
    outState?.run {
        putInt(STATE_SCORE, currentScore)
        putInt(STATE_LEVEL, currentLevel)
    }

    // View 계층 상태를 저장하려면 상위 클래스의 메서드를 호출해야 한다.
    super.onSaveInstanceState(outState)
}

companion object {
    val STATE_SCORE = "playerScore"
    val STATE_LEVEL = "playerLevel"
}

 

사용자가 액티비티를 직접 닫거나 finish()가 호출되면 onSaveInstanceState()는 호출되지 않는다.

 

사용자 설정이나 데이터베이스의 데이터와 같은 영구적 데이터를 저장하려면 액티비티가 포그라운드에 있을 때 그 기회를 잡아야 한다. 그런 기회가 없다면 그러한 데이터는 onStop()에서 저장해야 한다.

 

저장된 인스턴스 상태를 사용하여 UI 상태 복구

액티비티가 이전에 소멸되었다가 재생성될 때 시스템이 액티비티에 전달하는 Bundle 객체에 저장된 인스턴스 상태를 복구할 수 있다. onCreate()onRestoreInstanceState()가 수신하는 Bundle 객체는 동일한 것이며 인스턴스 상태 정보가 포함되어 있다.

 

onCreate()는 시스템이 액티비티의 새 인스턴스를 생성하거나 이전의 것을 재생성할 때 호출되기 때문에 Bundle 객체를 읽어 들이기 전에 null인지 확인해야 한다. null이면 시스템은 이전에 소멸된 액티비티를 복구하지 않고 새 인스턴스를 생성한다.

 

예를 들어 다음 코드 스니펫은 onCreate()에서 몇 가지 상태 데이터를 복구하는 예시이다.

override fun onCreate(savedInstanceState: Bundle?) {
    // 상위 클래스의 메서드를 항상 먼저 호출해야 한다.
    super.onCreate(savedInstanceState)

    // 이전에 소멸된 인스턴스를 재생성하는지 확인
    if (savedInstanceState != null) {
        with(savedInstanceState) {
            // 저장된 상태에서 멤버의 값을 복구
            currentScore = getInt(STATE_SCORE)
            currentLevel = getInt(STATE_LEVEL)
        }
    } else {
        // 새 인스턴스에 대하여 멤버를 기본값으로 초기화
    }
    // ...
}

 

onCreate()에서 상태를 복구하는 대신 onRestoreInstanceState()를 구현할 수 있다. 이는 onStart()가 호출된 뒤에 호출된다.

onRestoreInstanceState()는 복구할 저장된 상태가 존재할 때만 시스템에 의해 호출되므로 수신하는 Bundle 객체가 null인지 확인할 필요가 없다.

override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
    // 항상 상위 클래스의 메서드를 호출하여 View 계층 구조를 복구한다.
    super.onRestoreInstanceState(savedInstanceState)

    // 저장된 인스턴스에서 상태 멤버를 복구
    savedInstanceState?.run {
        currentScore = getInt(STATE_SCORE)
        currentLevel = getInt(STATE_LEVEL)
    }
}

 

onRestoreInstanceState()에서 항상 상위 클래스의 구현을 호출해야 한다.

그렇게 하면 기본 구현에 의해 뷰 계층의 상태가 복구된다.

 


액티비티 간 이동

앱은 수명주기 동안 액티비티를 여러 번 드나들 가능 성이 크다.

예를 들어 사용자는 디바이스의 뒤로 가기 버튼을 누르거나 액티비티가 다른 액티비티를 시작할 수 있다.

 

액티비티에서 다른 액티비티 시작하기

특정 시점에서 액티비티는 다른 액티비티를 시작해야 할 때가 많다.

예를 들어 앱이 현재 화면에서 새 화면으로 이동해야 할 때 그렇다.

 

액티비티가 시작하려는 새 액티비티에서 결과를 돌려받아야 할지 여부에 따라 startActivity() 또는 startActivityforResult()로 새 액티비티를 시작한다. 두 경우 모두 Intent 객체를 전달해야 한다.

 

Intent 객체는 시작하려 하는 액티비티를 특정하거나 수행하고자 하는 액션에 대해 설명한다.

Intent 객체는 시작하려는 액티비티에서 사용할 소량의 데이터도 포함할 수 있다.

 

startActivity()

새로 시작되는 액티비티가 결과를 반환할 필요가 없다면 현재 액티비티는 startActivity()를 호출하여 새 액티비티를 시작할 수 있다.

 

앱 안에서 작업할 때에는 이미 알고 있는 액티비티를 간단하게 시작해야 할 때가 많다.

예를 들어 다음 코드 스니펫은 SignInActivity라는 액티비티를 시작하는 방법을 알려준다.

val intent = Intent(this, SignInActivity::class.java)
startActivity(intent)

 

수행하고자 하는 작업을 본인의 앱에서 실행하지 못할 수도 있다.

따라서 다른 앱이 제공하는 액티비티를 대신 활용하여 작업을 수행할 수 있다.

이를 위해서는 암시적 인텐트를 사용한다.

수행할 액션이 담긴 인텐트를 실행하면 시스템이 이와 맞는 적절한 액티비티를 다른 앱에서 시작한다.

호환되는 액티비티가 여러 개인 경우 사용자가 어떤 앱을 사용할 것인지 선택할 수 있다.

예를 들어 이메일을 보낼 수 있게 하려면 다음처럼 인텐트를 생성하면 된다.

val intent = Intent(Intent.ACTION_SEND).apply {
    putExtra(Intent.EXTRA_MAIL, recipientArray)
}
startActivity(intent)

 

startActivityForResult()

액티비티가 종료될 때 결과를 반환받고자 할 경우에는 startActivityForResult(Intent, Int)를 호출해야 한다.

Int형 파라미터는 호출을 식별하는 요청 ID이다. 이 ID는 동일한 액티비티에서 여러 번 할 수 있는 액티비티 시작 요청을 식별하기 위한 것이다. 전역 식별자가 아니기 때문에 다른 앱 및 액티비티와 충돌할 위험이 없다.

결과는 onActivityResult(Int, Int, Intent)를 통해 반환된다.

 

하위 액티비티가 존재하면 setResult(Int)를 호출하여 상위 액티비티로 데이터를 반환할 수 있다.

하위 액티비티는 항상 결과 코드를 제공한다. 이는 표준 결과인 RESULT_OK, RESULT_CANCELED이거나 RESULT_FIRST_USER로 시작하는 임의의 사용자 지정 값이 될 수도 있다. 또한 하위 액티비티는 원하는 모든 추가 데이터가 포함된 Intent 객체를 반환할 수도 있다. 상위 액티비티는 원래 제공했던 정수 식별자와 함께 onActivityResult()를 사용하여 정보를 수신한다.

 

하위 액티비티가 실행되지 않으면 이유에 관계없이 상위 액티비티는 RESULT_CANCELED 코드가 포함된 결과를 수신한다.

class MyActivity : Activity() {
    // ...

    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) {
            // 사용자가 가운데 키를 누르면 연락처를 선택한다.
            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) {
                    startActivity(Intent(Intent.ACTION_VIEW, intent?.data))
                }
        }
    }

    companion object {
        internal val PICK_CONTACT_REQUEST = 0
    }
}

 

액티비티 조정

액티비티에서 다른 액티비티를 시작하면 양쪽 모두 수명주기가 전환된다. 첫 번째 액티비티는 작업을 중단하고 일시중지됨/중단됨 상태로 진입한다. 반면에 두 번째 액티비티는 생성됨 상태에 진입한다. 이 액티비티들이 디스크 등에 저장된 데이터를 공유하는 경우 첫 번째 액티비티는 두 번째 액티비티가 생성되기 전까지 완전히 중단된 것이 아니라는 사실을 명심해야 한다. 오히려 두 번째 액티비티 시작 과정은 첫 번째 액티비티의 중단 과정과 겹친다.

 

수명주기 콜백의 순서는 잘 정의되어 있다. 특히 두 액티비티가 같은 프로세스 안에 있고 한쪽이 다른 한쪽을 시작할 때가 그렇다.

액티비티 A가 액티비티 B를 시작한다 가정했을 때 일어나는 동작의 순서는 다음과 같다.

  1. 액티비티 A의 onPause()가 실행된다.
  2. 액티비티 B의 onCreate(), onStart(), onResume()가 차례로 호출된다. (이제 액티비티 B가 사용자 포커스를 가진다)
  3. 액티비티 A는 더 이상 화면에 보이지 않으므로 onStop()가 실행된다.

이렇게 수명주기 콜백의 순서를 예측하면 액티비티의 정보가 다른 액티비티로 전달되는 것을 관리할 수 있다.