[Android] Suspend function called within the viewModelScope is not executed

Minji Jeong·2023년 6월 18일
0

Troubleshooting

목록 보기
18/20
post-thumbnail

🟡 Test case

  • 다이얼로그 프래그먼트에서 특정 버튼을 클릭하면 데이터스토어를 통해 로컬에 값이 저장되어야 함
  • 프래그먼트에서 바로 데이터스토어에 접근하지 않고, 뷰모델을 통해 해당 로직을 실행하도록 작성했음
class ViewModel : ViewModel() {
	
	fun setSecondValue(value: Int) {
		viewModelScope.launch {
			dataStore.setSecondValue(value)
		}
	}
}
class dataStore(private val context: Context) {
		
	 suspend fun setSecondValue(value: Int) {
		context.dataStore.edit { preferences ->
           preferences[currentValueKey] = value
       }
	}
}
  • 다이얼로그에서 버튼 클릭 시 실행되는 function
private fun setValues() {

     val nextValue = (initValue.toInt() + 1).toString()
	 viewModel.setFirstValue(initValue)
     viewModel.setSecondValue(nextValue)

	 goToSecondActivity()
}
  • 하지만 뷰모델의 setSecondValue까지만 실행되고, suspend function인 dataStore.setCurrentValue(value)는 실행되지 않음
private suspend fun setValues() {

    val job = CoroutineScope(Dispatchers.Main).launch {
         val nextValue = (initValue.toInt() + 1).toString()
		 viewModel.setFirstValue(initValue)
		 viewModel.setSecondValue(nextValue)
    }

   job.join()

   goToSecondActivity()
}

private fun goToSecondActivity() {
	val intent = Intent(this, SecondActivity::class.java)
	startActivity(intent)
	this.dismiss()
}
  • 해당 부분을 job으로 만들어서 제어하려 해도 안됨

🔴 Root Cause

원인 파악을 위해 로그를 찍어보기로 했다. 일단 뷰모델이 소멸될 때 onCleared()가 호출되기 때문에, onCleared()에 로그를 찍어서 언제 뷰모델이 소멸되는지 확인하기로 했다.

class ViewModel : ViewModel() {
	
	fun setSecondValue(value: Int) {
		viewModelScope.launch {
			Log.e("Jinnie", "ViewModel $value")
			dataStore.setSecondValue(value)
		}
	}

	override fun onCleared() {
       super.onCleared()
       Log.e("Jinnie", "onCleared()")
  }
}
class dataStore(private val context: Context) {
		
	 suspend fun setSecondValue(value: Int) {
		context.dataStore.edit { preferences ->
			Log.e("Jinnie", "DataStore $value")
            preferences[currentValueKey] = value
        }
	 }
}
private fun setValues() {
    val nextValue = (initValue.toInt() + 1).toString()
	viewModel.setFirstValue(initValue)
    viewModel.setSecondValue(nextValue)

    goToSecondActivity()
}

private fun goToSecondActivity() {
	val intent = Intent(this, SecondActivity::class.java)
	startActivity(intent)
	this.dismiss()
}

override fun onDestroy() {
    super.onDestroy()
    Log.e("Jinnie", "onDestroy()")
}

Result

---> 다이얼로그 프래그먼트에서 버튼 클릭 시 goToSecondActivity 실행
2023-06-06 13:51:31.639  7914-7914  Jinnie                  com.example.mlkitproject             E  ViewModel 0
---> dataStore의 setCurrentValue가 실행되기 전에 뷰모델이 소멸됨
2023-06-06 13:51:31.994  7914-7914  Jinnie                  com.example.mlkitproject             E  onCleared
---> 프래그먼트 소멸
2023-06-06 13:51:31.995  7914-7914  Jinnie                  com.example.mlkitproject             E  onDestroy()

→ 다이얼로그 프래그먼트에서 버튼을 클릭하면 setValues가 호출되고, setValues의 내부 코드가 순차적으로 실행되면서 goToSecondActivity를 통해 다른 액티비티로 이동

  • viewModel.setSecondValue(nextValue)는 정상적으로 실행되지만, viewModelScope내의 dataStore.setSecondValue(value)가 실행되기 전에 뷰모델이 소멸됨
  • 따라서 뷰모델이 소멸됨에 따라 viewModelScope가 취소되니 자식 코루틴도 취소될 수밖에 없고, 그에 따라 해당 로직이 실행되지 않은 것
  • 프래그먼트에서 job을 사용해 제어해줘도 제어되는 것은 viewModel.setValue(nextValue)지 데이터스토어의 suspend function이 아니므로 소용이 없던 것

다만, setValues에서 viewModel.setSecondValue(nextValue)만 실행할 경우는 문제 없음. 여러 작업들을 순차적으로 실행하는 경우 나중에 실행되는 작업은 뷰모델 인스턴스 소멸에 영향을 받아 이러한 케이스가 발생하는 듯 하다...🤔

작업의 수를 줄이지 않고, 기존 코드를 고수하면서 뷰모델 내에서 실행되는 스코프가 뷰모델의 lifecycle에 영향을 받지 않으려면 어떻게 해야 할까?

✅ Resolution

위에서 던진 질문에 대한 답변은, viewModelScope가 아닌 CoroutineScope를 사용하되, 인자로 SupervisorJob을 전달하는 것이다.

private val supervisorScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.IO) }

일단 viewModelScope의 원형을 살펴보면 다음과 같다. ViewModelScope는 SupervisorJob을 사용해 스코프를 생성한다.

SupervisorJob

SupervisorJob 내의 자식 코루틴들은 서로에게 영향을 받지 않는다. 따라서 여러개의 자식 코루틴들 중 하나가 취소되어도 다른 코루틴에 영향을 주지 않으며, SupervisorJob에도 영향을 미치지 않는다.

A failure or cancellation of a child does not cause the supervisor job to fail and does not affect its other children, so a supervisor can implement a custom policy for handling failures of its children

따라서 viewModelScope의 자식 코루틴들 중 하나가 취소되어도 다른 자식 코루틴과 부모 코루틴엔 영향이 가지 않는다. 하지만 나의 경우엔 뷰모델이 소멸되면서 부모 스코프인 viewModelScope도 취소되었기 때문에 내부의 자식 코루틴들도 모두 취소되었던 것이다. 따라서 이 문제를 해결하기 위해선 뷰모델의 라이프사이클에 영향을 받지 않는 독립적인 CoroutineScope를 사용해야 했다.

일단 CoroutineScope의 원형을 살펴보면, 파라미터로 context를 받는 것을 알 수 있다. 다만 context가 Job을 포함하지 않을 경우, 일반 Job이 생성된다. 그리고 해당 스코프에서 하나의 자식 코루틴이라도 실패하거나 취소될 경우, SupervisorJob과 다르게 다른 자식 코루틴들이 모두 취소된다.

If the given context does not contain a Job element, then a default Job() is created. This way, failure of any child coroutine in this scope or cancellation of the scope itself cancels all the scope's children, just like inside coroutineScope block.

CoroutineScope가 리턴하는 일반 Job의 원형도 살펴봤다. 자식 코루틴들이 서로에게 영향을 주지 않는 독립적인 작업을 만들고자 한다면 SupervisorJob을 사용하라고 명시되어 있다.

Creates a job object in an active state. A failure of any child of this job immediately causes this job to fail, too, and cancels the rest of its children.
To handle children failure independently of each other use SupervisorJob.

따라서 아래처럼 별도의 스코프를 정의해주니, 문제가 해결되었다.

class CountViewModel : ViewModel() {

    private val dataStoreScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.IO) }

    fun setSecondValue(value: Int) {
        dataStoreScope.launch {
            dataStore.setSecondValue(value)
        }
    }

}

📚 References

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope.html
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-supervisor-job.html

profile
Mobile Software Engineer

0개의 댓글