본문으로 바로가기
반응형

포스팅 한 글중에 코루틴과 코틀린에 대한 글은 다수 있으나, 이를 android에서도 적용하기 위한 방법, mvvm와 coroutine의 활용방법에 대한 내용들이 산발적으로 포스팅 되어 있어 정리하는 포스팅을 진행합니다.

기본적인 내용은 기존 포스팅에 존재하는 내용으로 이번 내용에는 어떻게 활용 할 것인가에 대한 내용을 중점적으로 정리하여 기술 할 예정입니다.

따라서 android를 떠나서, mvvm과 coroutine의 기본적인 내용에 대해서는 이해 했다는 가정하여 작성된 글이며 대신 관련된 내용이 중점적으로 기술된 포스팅의 링크를 같이 제공 합니다.

 

Android에서 Coroutine 사용을 위한 준비

coroutine builder들을 coroutine scope 안에서 생성이 가능합니다.
다만 대부분의 코루틴 예제에서 coroutine scope을 만들기 위해서 사용하는 runBlocking {...}은 android에서 사용하기에 적합하지 않습니다.
말 그대로 runBlocking은 해당 블록의 동작이 완료될 때까지 이를 호출하는 thread가 block 되기 때문입니다.
물론 Thread.sleep() 처럼 블록 된다는 의미가 아니지만 (ANR을 일으키지는 않지만) 해당 코드를 만나는 순간 다른 작업을 할 수 없게 됩니다.
protected void onCreate() {
    ...
    runBlocking {
        launch
        {
            delay(1000)
            Log.d(123)
            delay(1000)
        }
    }
    Log.d(456)
    ...
}

위 코드처럼 456은 123이 찍힌 1초 이후에나 찍히게 됩니다.

runBlocking이 수행되는 2초동안 대기하면서 아무 동작을 못하다가 456이 찍힙니다.

이는 coroutine의 Structured concurrency 정책에 의한 동작이므로 이런 형태로는 android에서 사용하기엔 적합하지 않습니다.

따라서 Android에서 coroutine을 효율적으로 적용하고 사용하기 위해서는 Coroutine scope을 implement하는 BaseActivity를 만들어 App내의 activity들이 이 BaseActivity를 상속받아 사용하도록 하게 합니다.

open class BaseActivity : AppCompatActivity(), CoroutineScope {         // Coroutine 사용을 위한 멤버 변수 초기화 - 기본 Dispacher는 Main이다. 
    protected lateinit var job: Job     override
    val coroutineContext: CoroutineContext get() = job + Dispatchers.Main
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        job = SupervisorJob()      
        super.onCreate(savedInstanceState)
    }

    override fun onDestroy() {
        job.cancel()
        super.onDestroy()
    }
    ...
}

위와같이 CoroutineScope를 상속받습니다.

이때 coroutineContext 변수를 override 해야 합니다.

이때 사용할 job은 SupervisorJob()으로 onCreate()에서 생성하여 넣어주고 onDestory()에서 job을 cancel()해 줍니다.

 


 

따라서 이 BaseActivity를 상속받은 모든 Activity들은 기본적으로 coroutine scope을 가지게 됩니다.

이렇게 되면 launchasync등의 기본적인 coroutine builder들을 아무 제약없이 activity 내에서 호출할 수 있습니다.

 

job은 activity의 생성주기에 따라 생성되고, 취소되기 때문에 activity 내부에서 사용된 모든 coroutine builder들 역시 activity 생성주기에 맞춰지는 장점을 누릴 수 있습니다.

만약 activity에서 AsyncTask를 사용할 경우 이는 activity의 생명주기와 상관없이 동작하기 때문에 activity가 destory되더라도 background에서 AsyncTask가 동작하는 상황이 발생합니다.

AsyncTask 대신 launch나 async를 사용한다면 이런 현상에 대한 예외처리를 따로 할 필요가 없어집니다.

만약 Activity의 생명주기와 상관없는 작업을 해야 한다면 Process 생명주기와 맞춰진 GlobalScope를 이용하면 됩니다.

처음 나왔던 예제코드를 다시 수정해 보겠습니다.

class MainActivity : BaseActivity() { //BaseActivity가 CoroutineScope을 implementation 한 상태 
    ...
    protected void onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...
        launch {
            delay(1000)
            Log.d(123)
            delay(1000)
        }
    }
    Log.d (456)
    ...
}

이제 위 로그는 456 -> 123 순으로 출력됩니다. 물론 456이 찍힌 1초 이후에 123이 출력되겠죠?

의도했던 대로 launch 블록의 내용은 비동기 처럼 동작하게 됩니다.

 

여기서 중요한 점은 BaseActivity를 상속받은 Activity들은 onCreate()나 onDeatory()를 override시 반드시 super를 불러줘야 합니다.

Java에서의 사용

안타깝게도 오래된 project의 경우 kotlin 코드보다 java 코드가 더 많습니다.
따라서 기존 프로젝트에 이미 BaseActivity.java가 존재 한다면 아래와 같이 수정 합니다.
public class BaseActivity extends AppCompatActivity implements CoroutineScope {
    ...
// coroutine job - activity마다 생성한다.
    protected Job mCoroutineJob;
    ...
    @Override protected void onCreate(Bundle savedInstanceState) {
        mCoroutineJob = SupervisorKt.SupervisorJob(null);
        super.onCreate(savedInstanceState);
        ...
    }
    ...
    @Override protected void onDestroy() {
        ...
        // Coroutine 종료    
        if (mCoroutineJob != null) {
            mCoroutineJob.cancel(null);
        }
        super.onDestroy();
    }
    ...
    /**      * Coroutine context 생성.      */
    @NotNull
    @Override
    public CoroutineContext getCoroutineContext() {
        return mCoroutineJob.plus(Dispatchers.getMain());
    }

이 BaseActivity를 Kotlin으로된 activity에서 상속받아 사용한다면 문제가 없지만, 자바로 된 activity에서 필요한 경우 아래와 같이 호출 해야 coroutine이 사용 가능합니다.

먼저 자바에서 사용하려면 launch가 정의된 class부터 확인해야 합니다.

kolinx.coroutines.Builder.common.kt 파일을 보면 아래와 같습니다.

@file:JvmMultifileClass
@file:JvmName("BuildersKt")
@file:OptIn(ExperimentalContracts::class)
package kotlinx.coroutines ...
// --------------- launch ---------------
...
public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy) LazyStandaloneCoroutine(newContext, block) else StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}
...

async도 하단에 같이 정의 되어 있지만 생략하고 가장 많이 사용하게 될 launch만 확인해 봅니다.

JvmName이 "BuildersKt"로 되어 있으므로 Java에서 접근시 해당 이름으로 접근해야 합니다.

또한 launch의 param들은 기본값이 설정되어 있지만 @JvmOverloads Annotation이 붙어있지 않기 때문에 안타깝게도 Java에 호출시 param을 일일히 전부 넣어줘야 합니다

 

따라서 자바에서는 아래와 같은 형태로 호출합니다.

BuildersKt.launch(this, mCoroutineJob, CoroutineStart.DEFAULT, (coroutineScope, continuation) -> {
    Log.d(TAG, "coroutine test " + Thread.currentThread().getName());
    return Unit.INSTANCE;
});
BuildersKt.launch(this, mCoroutineJob.plus(Dispatchers.getIO()),         CoroutineStart.DEFAULT, (coroutineScope, continuation) -> {
    Log.d(TAG, "coroutine test " + Thread.currentThread().getName());
    return Unit.INSTANCE;
});

두번째 launch 코드는 dispatcher를 이용하여 thread를  Dispatchers.IO를 사용했습니다.

사실 예제처럼 간단한 코드를 호출하는데는 문제가 없지만 내부에서 delay() 함수만 호출하려고 해도 쉽지 않습니다.

BuildersKt.launch(this, mCoroutineJob, CoroutineStart.DEFAULT, (coroutineScope, continuation) -> {
    kotlinx.coroutines.DelayKt.delay(1000L, continuation);
    Log.d(TAG, "coroutine test1 " + Thread.currentThread().getName());
    return Unit.INSTANCE; 
});
 

만약 코드 block 내부에서 delay 함수만 부르려고 해도 아래와 같은  Exception이 떨어집니다.

  Caused by: java.lang.ClassCastException: kotlin.coroutines.jvm.internal.CompletedContinuation cannot be cast to kotlinx.coroutines.DispatchedContinuation

  at kotlinx.coroutines.CoroutineDispatcher.releaseInterceptedContinuation(CoroutineDispatcher.kt:103)

  at kotlin.coroutines.jvm.internal.ContinuationImpl.releaseIntercepted(ContinuationImpl.kt:118)

  at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:39)

  at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:55)

람다로 넘어오는 Continuation 객체의 type과 delay 함수에 param으로 넣어야 하는 객체 타입이 달라 casting을 못하는 이유인데 해결하기는 쉽지 않습니다.

Kotlin에서 썼다면 넣어주지 않는 인자값인데 java라서 조건에 맞춰 해당 인자를 찾아서 넣어주는것도 일반적이지 않습니다.

미루어 짐작하건데, java에서 suspend function을 호출하기 위해서 stack overflow에서도 여러 꼼수(?)를 제공하지만 이를 위해서 추가해야 하는 코드들이 훨씬더 비효율적입니다.

 

따라서 java 파일 내부에서 coroutineBuilder를 사용하는건 개인적으로 지양합니다.

비동기 작업이 필요하다면 RxJava를 사용하든지, 아니면 kotlin 파일의 helper를 아래와 같이 생성하여 사용하여 대체 방안으로 사용하는게 더 낫습니다.

class MainActivityHelper(override val coroutineContext: CoroutineContext) : CoroutineScope {
    fun launchSomething() {
        launch { 
            delay(100)
            Log.d("delay 100") 
        }
    }
    ...
}

Main activity에서 호출시

class MainActivity : BaseActivity() {
    private MainActivityHelper mHelper;
    ...
    protected void onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
    ...
    mHelper = new MainActivityHelper(getCoroutineContext());
    mHelper.xxxx()
    ...
}

MainActivity에서 위 Helper를 만들때 coroutineContext를 넘겨주기 때문에, 여기서 생성되는 모든 Coroutine Builder역시 해당 Activity의 생명주기에 종속됩니다.

 

기본 CoroutineContext의 Dispatcher - Main vs Default

위 예제에서는 기본 coroutineContext를 생성할때 Main thread를 사용하도록 설정했습니다.
실제로 background thread 작업이 많아 Dispatchers.DefaultDispatchers.IO를 많이 사용 한다면야 해당 Dispatcher를 기본값으로 해도 상관은 없습니다만, 최근 coroutine을 지원하는 추세를 보면 각 library들이 내부적으로는 비동기 처리를 하고 실제 값은 호출한 coroutineContext를 사용하여 반환해 주도록 되어 있습니다.
(Room이 그렇게 되어 있더군요.)
 
사실 withContext로 Coroutine의 context switch는 매우 쉽기 때문에 어떤 Dispathcer를 기본으로 하든 상관은 없습니다.
 
Room에서의 coroutine에 대한 내용은 다음 포스팅에서 다룹니다.

 

반응형