[Kotlin] Coroutine Exception Handling
Coroutine Exception
Coroutine Builder들은 Exception을 어떻게 handling 하느냐에 따라서 두가지 타입으로 나뉜다
Exception propagation
propagating exceptions automatically (예외 전파) : launch, actor
exposing them to users (예외 노출) : async, produce
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
val job = GlobalScope.launch { // root coroutine with launch
println("Throwing exception from launch")
throw IndexOutOfBoundsException() // Will be printed to the console by Thread.defaultUncaughtExceptionHandler
}
job.join()
println("Joined failed job")
val deferred = GlobalScope.async { // root coroutine with async
println("Throwing exception from async")
throw ArithmeticException() // Nothing is printed, relying on user to call await
}
try {
deferred.await()
println("Unreached")
} catch (e: ArithmeticException) {
println("Caught ArithmeticException")
}
}
1
2
3
4
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException
launch의 경우 exception이 발생하면 바로 exception이 발생한다 (전파)
하지만 async의 경우 exception이 발생하더라도 실제로 exception이 발생되는 부분은 await()을 만날 때 이다 (노출)
CoroutineExceptionHandler
CoroutineExceptionHandler는 포착되지 않은 예외, 즉 처리되지 않은 예외에 대해서만 호출된다. Coroutine은 취소를 제외한 다른 예외가 발생하면 부모의 coroutine까지 모두 취소시킨다.
1
2
3
4
5
6
7
8
9
10
11
val handler = CoroutineExceptionHandler { _, exception ->
println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) { // root coroutine, running in GlobalScope
throw AssertionError()
}
val deferred = GlobalScope.async(handler) { // also root, but async instead of launch
throw ArithmeticException() // Nothing will be printed, relying on user to call deferred.await()
}
joinAll(job, deferred)
1
CoroutineExceptionHandler got java.lang.AssertionError
실제로 ViewModel에서 사용해보기에 앞서 viewModelScope에 대해서 한번 알아보자
ViewModelScope
ViewModel에서 onCleared()를 호출할 때 직접 coroutine context를 명시적으로 취소하지 않아도 자동적으로 onCleared()가 호출될 때 coroutine 작업을 취소한다.
viewModelScope를 사용하지 않았을 경우
1
2
3
4
5
6
7
8
9
10
11
12
13
class TestViewModel : ViewModel() {
private val job = SupervisorJob()
private val uiScope = CoroutineScope(job + Dispatchers.Main)
...
fun doSomething() = uiScope.launch {
...
}
override fun onCleared() {
super.onCleared()
job.cancel()
}
}
이런식으로 onCleared에서 직접 job을 cancel 시켜줘야 한다
하지만 viewModelScope를 사용한다면
1
2
3
4
5
class TestViewModel : ViewModel() {
fun doSomething() = viewModelScope.launch {
...
}
}
상당히 간단해진다
viewModelScope의 내부 코드이다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private const val JOB_KEY = "androidx.lifecycle.ViewModelCoroutineScope.JOB_KEY"
public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context
override fun close() {
coroutineContext.cancel()
}
}
- CoroutineScope을 저장하면getTag(JOB_KEY)로 꺼내서 사용하고 있다.
- 만약 생성된CoroutineScope이 없다면s etTagIfAbsent()에 CloseableCoroutineScope 인스턴스를 인자로 넘겨, ViewModel 필드에 있는 HashMap에 코루틴 객체를 저장후 사용한다
setTagIfAbsent
함수내부를 보면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Nullable
private final Map<String, Object> mBagOfTags = new HashMap<>();
<T> T setTagIfAbsent(String key, T newValue) {
T previous;
synchronized (mBagOfTags) {
previous = (T) mBagOfTags.get(key);
if (previous == null) {
mBagOfTags.put(key, newValue);
}
}
T result = previous == null ? newValue : previous;
if (mCleared) {
closeWithRuntimeException(result);
}
return result;
}
- ViewModel의 mBagOfTags 라는 HashMap 에 중복되는 key가없다면 인자로 받아온 key,newValue를 mBagOfTags에 put 하고 난 후 매개변수로 받은 newValue를 return 해준다
- 실제로 이 mBagOfTags는 viewModel이 clear될때 사용되는데
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@MainThread
final void clear() {
mCleared = true;
if (mBagOfTags != null) {
synchronized (mBagOfTags) {
for (Object value : mBagOfTags.values()) {
closeWithRuntimeException(value);
}
}
}
onCleared();
}
private static void closeWithRuntimeException(Object obj) {
if (obj instanceof Closeable) {
try {
((Closeable) obj).close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
- clear가 호출되면 mBagOfTag의 값들을 모두 close처리한다
정리해보면
- viewModelScope에서 mBagOfTags에 coroutineScope를 put한다
- LifeCycle이 Destory되면 clear 함수가 호출되면서 mBagOfTags에 저장되어있는 coroutineScope를 cancel시킨다
- onCleared가 호출된다
ViewModelScope with CoroutineExceptionHandler
viewModel에서 사용할 Handler를 만들어보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* baseCoroutineExceptionHandler
* @param methodName 호출한 함수 이름
* @param isShowToast 에러 발생시 Toast 보여줄지 여부, 기본값 true
* @param afterFunc 에러 발생시 후처리 함수
*/
fun baseCoroutineExceptionHandler(
methodName: String,
isShowToast: Boolean = true,
afterFunc: (() -> Unit)? = null
): CoroutineExceptionHandler =
CoroutineExceptionHandler { _, exception ->
val type = when (exception) {
is TestException -> "TestException"
else -> "Unexpected Exception"
}
println("[Error] $methodName : $type : ${exception.message}")
if (isShowToast) {
viewModelScope.launch { _errorToast.emit(exception.message) }
}
afterFunc?.invoke()
}
실제 사용은
1
2
3
4
5
6
7
8
private fun doSomething() {
viewModelScope.launch(
ioDispatcher +
baseCoroutineExceptionHandler(::doSomething.name, false) { abc() }
) {
...
}
}
- 로깅을 위해서 함수이름을 넘기고, toast사용 여부와 에러가 발생했을 때 함수를 넣어주면 끝!