onSaveInstanceState의 신비한 호출 시점

송동엽·2024년 5월 22일
1

파베 크래시리스틱에 불현듯 등장한 크래시...

Fatal Exception: java.lang.IllegalStateException
Can not perform this action after onSaveInstanceState

onSaveInstanceState() 호출 이후에 프래그먼트 트랜잭션을 커밋해버렸단다. 대체 onSaveInstanceState()는 언제 호출되는 거길래??

onSaveInstanceState()

onSaveInstanceState()는 액티비티가 가지는 콜백 중 하나로, 화면 재생성에 대비하여 액티비티의 상태를 Bundle로 저장하는 함수이다.

여기서 말하는 상태란 액티비티 객체가 들고 있는 변수 뿐만 아니라, 액티비티에 보이는 뷰들, 심지어 프래그먼트들까지도 포함한다. 말 그대로 어느 시점에 액티비티가 가지는 상태.

상태 저장? 그거 꼭 해야해?

왜 상태를 저장한다는 개념이 필요한가? 유저가 액티비티의 재생성을 인지하지 못하게 하기 위함이다. 액티비티 재생성은 구성 변경이나 메모리 부족으로 인한 process kill 시 발생하며, 이 때 아예 새로운 액티비티 객체가 생성된다. 그런데 유저는 액티비티라는 게 새로 생겼는지 어쨌는지 알 필요도 없고, 모르는 게 훨씬 자연스럽다. 기껏해야 화면 방향을 돌렸을 뿐인데 드라마틱한 변화가 일어나면 어색하게 느낄 것이다.

상태 저장, 복구는 언제 하는 게 좋을까?

상태 저장이 왜 필요한진 알았고, 그럼 상태 저장과 복구는 언제 하는 게 좋을까?

상태 복구는 당연하게도 액티비티가 처음 시작되는 onCreate에서다. onCreate는 savedInstanceState라는 이름의 Bundle 객체를 인자로 받는다. 바로 여기에 선대 액티비티가 남긴 유물이 들어있는 것이다.

protected void onCreate (Bundle savedInstanceState)

그럼 상태 저장은 언제 해야할까? 액티비티가 죽기 직전 가장 최신 상태를 보존하는 것이 좋겠다. 앞서 이야기한 액티비티가 죽는 상황을 떠올려보자.

  • 구성 변경
    액티비티가 어느 상태에 있었든 간에 onDestroy()가 호출되고 파괴된다. RESUMED 상태에 있었다면 onPause() -> onStop() -> onDestroy()가 불리고, STARTED 상태에 있었다면 onStop() -> onDestroy()가 불리는 거다.

  • 메모리 부족 시, 시스템에 의한 process kill
    아무리 메모리가 필요하다고 해도 시스템이 사리분별없이 유저가 쓰고 있는 프로세스를 죽이진 않는다. 당장은 필요없는 프로세스부터 죽인다.

    프로세스는 유저가 얼마나 잘 인식하는지에 따라 4가지 계층으로 구분된다. 가장 최하위 계층을 cached process라고 한다. (1) 유저에게 보이는(foreground에 있는) 액티비티도 없고, (2) 실행 중인 서비스도 없으며, (3) onReceive()를 실행 중인 브로드캐스트리시버도 없는 프로세스를 말한다. 즉, 액티비티만 놓고 본다면 액티비티가 백그라운드로 들어가는 순간 process kill 고위험군에 들어가는 것이다.

종합해 볼 때, 액티비티가 백그라운드에 들어가는 순간이 가장 최근의 상태를 저장하면서도 액티비티 재생성에 대비할 최적의 타이밍 같다. 그럼 그 순간이 lifecycle 콜백으로는 언제냐? onStop()이다!

그런데 onStop()이 원래 하던 일도 모자라 상태 저장까지 한다면 너무 많은 일을 하는 게 아닐까? 다행히 안드로이드에서 오로지 상태 저장 용도로만 쓰기 위한 콜백을 onStop() 직후에 호출해준다. 그 콜백이 바로 onSaveInstanceState()! 우리는 저장하고 싶은 상태가 있다면 onSaveInstanceState()의 인자로 받는 Bundle outState에 담아두면 된다.

protected void onSaveInstanceState (Bundle outState)

어라 그런데 액티비티를 죽이는 건 onDestroy() 호출을 동반한다. 그렇다면 onDestroy() 직전에 상태를 저장하는 게 가장 최신 상태를 보존하는 것 아닌가?

예측하건데, onStop() 직후가 더 여유로운 상황이기 때문이지 않을까 싶다. 시스템에서 process kill을 하는 건 급메모리가 필요한 상황이다. 급해죽겠는데 상태 저장까지 해주고 있을 겨를이 없을 것이다. 액티비티가 백그라운드에 들어가는 상황은 당장 메모리가 필요한 상황보다는 여유로울 것이므로, 그 시점에 상태를 저장하는 것 같다.

로그를 찍어보자

테스트 앱에서 로그를 찍어 확인해보자. MainActivity와 DetailActivity가 있는 단순한 앱이다. MainActivity의 버튼을 클릭하면 DetailActivity가 올라온다. 두 액티비티 모두 onPause(), onStop(), onSaveInstanceState()에서 로그를 찍도록 해두었다.

MainActivity.kt

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.btnToDetail.setOnClickListener {
            startActivity(Intent(this, DetailActivity::class.java))
        }
    }

    override fun onPause() {
        super.onPause()
        Log.d("asdf", "MainActivity: onPause()")
    }

    override fun onStop() {
        super.onStop()
        Log.d("asdf", "MainActivity: onStop()")
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Log.d("asdf", "MainActivity: onSaveInstanceState()")
    }
}

DetailActivity.kt

class DetailActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDetailBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)
    }

    override fun onPause() {
        super.onPause()
        Log.d("asdf", "DetailActivity: onPause()")
    }

    override fun onStop() {
        super.onStop()
        Log.d("asdf", "DetailActivity: onStop()")
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Log.d("asdf", "DetailActivity: onSaveInstanceState()")
    }
}

홈버튼을 눌러 MainActivity를 백그라운드로 보내보자.

2024-05-21 17:11:01.240 29949-29949 asdf                    com.example.onsaveinstancestatetest  D  MainActivity: onPause()
2024-05-21 17:11:01.696 29949-29949 asdf                    com.example.onsaveinstancestatetest  D  MainActivity: onStop()
2024-05-21 17:11:01.698 29949-29949 asdf                    com.example.onsaveinstancestatetest  D  MainActivity: onSaveInstanceState()

onPause() -> onStop() -> onSaveInstanceState() 순으로 호출된 것을 확인할 수 있다.

MainActivity에서 버튼을 클릭해 DetailActivity를 띄워보자.

2024-05-21 17:18:10.502 32322-32322 asdf                    com.example.onsaveinstancestatetest  D  MainActivity: onPause()
2024-05-21 17:18:11.035 32322-32322 asdf                    com.example.onsaveinstancestatetest  D  MainActivity: onStop()
2024-05-21 17:18:11.036 32322-32322 asdf                    com.example.onsaveinstancestatetest  D  MainActivity: onSaveInstanceState()

마찬가지의 결과다. MainActivity 위에 DetailActivity가 올라왔기 때문에 MainActivity에서 onStop()이 불렸고, 이어서 onSaveInstanceState()가 불렸다.

반전 1) onStop()이 불렸다고 해서 onSaveInstanceState()가 항상 불리는 건 아니다!

여기까지 생각했다면 놓칠 수 있는 포인트. 액티비티의 onStop()이 불린다고 해서 onSaveInstanceState()가 항상 불리는 건 아니다!

DetailActivity가 올라온 상태에서 백버튼을 눌러보자.

2024-05-21 17:23:27.652 32322-32322 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onPause()
2024-05-21 17:23:28.055 32322-32322 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onStop()

분명히 onStop()이 불렸는데, onSaveInstanceState()는 불리지 않았다! 왜일까?

상태를 보존할 필요가 없기 때문이다. onSaveInstanceState()는 액티비티의 재생성에 대비하여 현재 상태를 저장한다고 했다. 그러나 유저가 백버튼을 눌러 액티비티를 종료하는 건 재생성이 아닌 찐 종료이다. 유저가 직접 종료한 액티비티는 유저의 관심사 밖이며, 상태를 보존할 필요가 전혀 없다.

DetailActivity가 올라온 상태에서 홈버튼을 누르면 앞서 본 것과 마찬가지로 onSaveInstanceState()가 잘 호출되는 걸 볼 수 있다.

반전 2) onSaveInstanceState()가 onStop()보다 먼저 호출될 수도 있다!

OS 버전에 따라 onStop()보다 먼저 호출되기도 한다. 기준이 되는 버전은 HoneyComb(3.0), Pie(9)이다.

  • HoneyComb 미만
    onPause() 직전
  • HoneyComb 이상, Pie 미만
    onStop() 직전
  • Pie 이상
    onStop() 직후

왜 이 사단이 나있는가. 버전에 따라 process kill 당할 수 있는 기준이 다르기 때문이다.

Activity 공식문서의 Activity Lifecycle 문단을 보면, lifecycle 콜백 표에 Killable이라는 열이 있다. 어떤 콜백이 Killable하다는 말은, 그 콜백이 return한 후라면 언제든지 액티비티를 호스팅하는 프로세스가 시스템에 의해 종료될 수 있다는 뜻이다.(프로세스가 액티비티 외에 다른 서비스나 브로드캐스트리시버를 실행 중이지 않고 있다는 가정 하의 이야기다.)

MethodKillable
onCreate()No
onRestart()No
onStart()No
onResume()No
onPause()Pre-Build.VERSION_CODES.HONEYCOMB
onStop()Yes
onDestroy()Yes

onPause()가 다소 애매하게 적혀있다. Honeycomb 이전 버전에서는 onPause() 리턴 직후 process kill 고위험군에 들어갔는데, Honeycomb 포함 이후 버전에서는 onStop() 리턴 이후에야 process kill 고위험군에 들어갔다는 뜻이다. 따라서, Honeycomb 이전 버전에서는 상태를 저장하기 위한 최적의 시점이 onPause() 직전이었다.

그럼 Honeycomb 포함 이후 버전에서는 onStop() '직전'이 최적의 시점이겠군...라고 생각했는데, 생각해보니 앞에서 살펴볼 때에는 onStop() '직후'에 불린다고 했다. 이 동작은 Pie 버전부터 달라졌다.

왜 Pie 포함 이후 버전에서는 onStop() 직후에 호출되도록 바뀌었는가? onSaveInstanceState()의 공식문서 설명을 읽어보자.

If called, this method will occur after onStop() for applications targeting platforms starting with Build.VERSION_CODES.P. For applications targeting earlier platform versions this method will occur before onStop() and there are no guarantees about whether it will occur before or after onPause().

명확한 이유를 설명하고 있진 않다. 예측하건데, onStop()에서 발생할 수 있는 상태 변경까지 저장하고 싶었기 때문이 아닐까 싶다.

프래그먼트의 onSaveInstanceState()

여기까지 액티비티의 onSaveInstanceState()에 대해 알아보았다. 미니 액티비티라고 할 수 있는 프래그먼트에도 onSaveInstanceState()가 있다. 액티비티와 어떤 점이 다른지 살펴보자.

@MainThread
public void onSaveInstanceState(@NonNull Bundle outState)

생긴 건 액티비티랑 똑같다. 기능 또한 액티비티에서와 같다. onSaveInstanceState에서 Bundle에 상태를 저장하면, 새로 만들어진 프래그먼트의 onCreate, onCreateView, onViewCreated에 Bundle 인자로 전달된다.

차이점은 호출 시점이다.

Note however: this method may be called at any time before onDestroy. There are many situations where a fragment may be mostly torn down (such as when placed on the back stack with no UI showing), but its state will not be saved until its owning activity actually needs to save its state.

액티비티에서는 onStop() 이후에 불린다고 확실한 시점을 말해줬는데, 프래그먼트에서는 대애충 onDestroy() 이전이라고 얼버무린다. 그 이유는 프래그먼트의 onSaveInstanceState()는 host 액티비티의 onSaveInstanceState()가 불릴 때 불리기 때문이다.

프래그먼트는 액티비티의 생명주기 내에서 독자적인 생명주기를 가진다. 예를 들어 host 액티비티는 여전히 RESUMED이고, 프래그먼트는 백스택으로 들어가 onStop()을 거쳐 CREATED가 될 수 있다. 이 때, 프래그먼트에서 onStop()이 불렸지만 onSaveInstanceState()는 불리지 않는다. 액티비티가 백그라운드로 들어가서야 프래그먼트의 onSaveInstanceState()가 불린다. 백스택에 들어가는 것 따위 없이, 프래그먼트와 host 액티비티가 함께 백그라운드로 내려가는 경우에는 onStop() 이후 바로 onSaveInstanceState()가 불린다. 말 그대로 onDestroy() 이전에 호출된다고밖에 말할 수 없는 상황이다.

로그를 찍어보자

로그를 찍어 확인해보자. DetailActivity에 FragmentContainerView를 두고 FirstFragment를 넣어줬다. 하단의 버튼을 누르면 addToBackStack()과 함께 SecondFragment로 replace하도록 하였다.

DetailActivity.kt

class DetailActivity : AppCompatActivity() {

    private lateinit var binding: ActivityDetailBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.btnReplace.setOnClickListener {
            supportFragmentManager.commit {
                setReorderingAllowed(true)
                addToBackStack("")
                replace<SecondFragment>(R.id.fcv_container)
            }
        }
    }

    override fun onPause() {
        super.onPause()
        Log.d("asdf", "DetailActivity: onPause()")
    }

    override fun onStop() {
        super.onStop()
        Log.d("asdf", "DetailActivity: onStop()")
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Log.d("asdf", "DetailActivity: onSaveInstanceState()")
    }
}

FirstFragment.kt

class FirstFragment : Fragment() {

    private var _binding: FragmentFirstBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        _binding = FragmentFirstBinding.inflate(inflater, container, false)
        return binding.root

    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    override fun onPause() {
        super.onPause()
        Log.d("asdf", "FirstFragment: onPause()")
    }

    override fun onStop() {
        super.onStop()
        Log.d("asdf", "FirstFragment: onStop()")
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d("asdf", "FirstFragment: onDestroy()")
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        Log.d("asdf", "FirstFragment: onSaveInstanceState()")
    }
}

버튼을 눌러 FirstFragment를 백스택으로 보내보자.

2024-05-22 11:08:31.871 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  FirstFragment: onPause()
2024-05-22 11:08:31.871 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  FirstFragment: onStop()

FirstFragment의 onStop()이 불렸지만, onSaveInstanceState()는 불리지 않았다. 이 상태에서 홈버튼을 눌러보자.

2024-05-22 11:08:41.505 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  SecondFragment: onPause()
2024-05-22 11:08:41.505 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onPause()
2024-05-22 11:08:41.533 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  SecondFragment: onStop()
2024-05-22 11:08:41.534 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onStop()
2024-05-22 11:08:41.535 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  FirstFragment: onSaveInstanceState()
2024-05-22 11:08:41.536 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  SecondFragment: onSaveInstanceState()
2024-05-22 11:08:41.537 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onSaveInstanceState()

DetailActivity가 백그라운드로 내려가며, onStop()이후 DetailActivity()의 onSaveInstanceState()가 불린다. 그리고 그보다 한 발 앞서, DetailActivity()가 호스팅하고 있는 FirstFragment와 SecondFragment의 onSaveInstanceState()가 불린다.

// 첫 번째 뒤로가기
2024-05-22 11:12:34.306 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  SecondFragment: onPause()
2024-05-22 11:12:34.306 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  SecondFragment: onStop()
2024-05-22 11:12:34.314 15826-15826 asdf                    com.example.onsaveinstancestatetest  D  SecondFragment: onDestroy()
// 두 번째 뒤로가기
2024-05-22 11:20:28.800 20370-20370 asdf                    com.example.onsaveinstancestatetest  D  FirstFragment: onPause()
2024-05-22 11:20:28.800 20370-20370 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onPause()
2024-05-22 11:20:29.181 20370-20370 asdf                    com.example.onsaveinstancestatetest  D  FirstFragment: onStop()
2024-05-22 11:20:29.181 20370-20370 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onStop()
2024-05-22 11:20:29.184 20370-20370 asdf                    com.example.onsaveinstancestatetest  D  FirstFragment: onDestroy()
2024-05-22 11:20:29.184 20370-20370 asdf                    com.example.onsaveinstancestatetest  D  DetailActivity: onDestroy()

뒤로가기를 누르면 SecondFragment가 destroy되고, 한 번 더 누르면 FirstFragment와 DetailActivity가 destroy된다.

반전 2-1) 프래그먼트의 onSaveInstanceState()도 onStop()보다 먼저 불릴 수 있다!

참고로, Fragment의 onSaveInstanceState() 또한 Pie부터 호출 순서가 바뀌었다. 백스택에 들어가지 않은 프래그먼트가 액티비티와 함께 백그라운드로 내려가면 onStop() -> onSaveInstanceState() 순으로 호출된다고 하였다. 그러나 이건 Pie부터의 동작이고, Pie 이전에는 onSaveInstanceState() -> onStop() 순으로 호출되었다. 액티비티의 onSaveInstanceState() 호출 순서 변화와 같은 양상이다.

뒤에서 이야기하겠지만, onSaveInstanceState() 이후에 트랜잭션 커밋을 시도하면 예외가 발생한다. Pie 이전에는 onStop()에서 트랜잭션 커밋 시 예외가 발생하였지만, Pie부터는 onStop()에서 트랜잭션 커밋을 해도 아무 문제가 없도록 변경된 것이다.

크래시 해결

다시 크래시로 돌아오자.

Fatal Exception: java.lang.IllegalStateException
Can not perform this action after onSaveInstanceState

프래그먼트에서 DialogFragment를 show한 직후, 프래그먼트가 백그라운드로 내려가면 크래시가 발생하였다. 이미 host 프래그먼트에서 onSaveInstanceState()가 불렸는데, 비동기로 동작 중이던 코루틴에서 DialogFragment를 show하는 트랜잭션 커밋을 했기 때문에 예외가 발생한 것이다. onSaveInstanceState()가 리턴된 시점에서 액티비티가 호스팅하고 있는 프래그먼트들의 상태가 모두 저장된 건데, 그 이후에 트랜잭션을 커밋하려 하면 그 상태 변경은 저장할 방법이 없다. 따라서 에라모르겠다 하고 예외를 던지는 것이다. 자세한 내용은 잘 설명된 다른 글을 참고...

따라서 트랜잭션 커밋 시 lifecycle state를 확인하여 STARTED 이상일 때에만 커밋하도록 하였다.

        viewLifecycleOwner.lifecycleScope.launch {
            delay(1000L)
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // 프래그먼트 트랜잭션 커밋
                this@launch.cancel()
            }
        }

minSdk가 24였기 때문에 Honeycomb 같은 고대 버전을 고려할 필요는 없었다. onSaveInstanceState()가 ON_STOP 이벤트 이후 불린다는 것이 확실하므로, lifecycle state가 STARTED 이상일 때에 트랜잭션 커밋을 하도록 하면 안전했다.

그리고 repeatOnLifecycle을 이용하면, 1초 딜레이 동안 host 프래그먼트가 백그라운드로 내려갔다 하더라도, 다시 포어그라운드로 돌아왔을 때 트랜잭션 커밋을 재개하는 것이 가능했다! 다만 중복으로 커밋하는 것을 방지하기 위해 커밋 이후 코루틴을 cancel하였다.

참고한 자료

https://developer.android.com/reference/android/app/Activity?_gl=1*kgb422*_up*MQ..*_ga*MTk3MDE4OTUxNy4xNzE2MzM2OTY5*_ga_6HH9YJMN9M*MTcxNjMzNjk2OS4xLjAuMTcxNjMzNjk2OS4wLjAuMA..#onSaveInstanceState(android.os.Bundle)

https://developer.android.com/reference/android/app/Activity?_gl=1*kgb422*_up*MQ..*_ga*MTk3MDE4OTUxNy4xNzE2MzM2OTY5*_ga_6HH9YJMN9M*MTcxNjMzNjk2OS4xLjAuMTcxNjMzNjk2OS4wLjAuMA..#activity-lifecycle

https://developer.android.com/reference/androidx/fragment/app/Fragment?_gl=1*jyltmc*_up*MQ..*_ga*MTkxNDE2NzA4OC4xNzE2MzQxNTg1*_ga_6HH9YJMN9M*MTcxNjM0MTU4NC4xLjAuMTcxNjM0MTU4NC4wLjAuMA..#onSaveInstanceState(android.os.Bundle)

https://developer.android.com/guide/fragments/lifecycle#fragment_and_view_created_2

https://www.androiddesignpatterns.com/2013/08/fragment-transaction-commit-state-loss.html

0개의 댓글