코루틴은 경량 스레드처럼 사용할 수 있는 단위이다.
하나의 프로세스 내부에 여러 개의 스레드가 존재할 수 있고,
하나의 스레드 내부에 여러 개의 코루틴이 존재할 수 있다.
코루틴은 비동기 작업을 효율적으로 처리할 수 있도록 해주며 이점은 다음과 같다.
- 실행중인 스레드를 Block하지 않으므로 스레드 내에서 여러 개의 많은 코루틴을 실행할 수 있다.
-> 만일 스레드1에 코루틴1, 코루틴2가 존재할 때 코루틴1이 특정 이유(스레드2에 특정 값 요청)로
정지(Suspend)되어도 스레드1은 중지되지 않고 코루틴2가 작업을 한다. - 특정 범위 내에서 작업을 하므로 메모리 누수가 방지된다.
- 코루틴에서의 작업은 완료되거나 특정한 사유에 자동으로 취소가 된다.
이제 코루틴을 안드로이드에서 어떻게 사용하는지 정리해보겠다.
구현할 기능
1. 이미지를 통해 url을 가져와서 ImageView에 나타낸다.
2. 랜덤한 숫자를 가져와서 TextView에 나타낸다.
3. 위에서 아래로 스크롤 할 경우 새로고침으로 ImageView와 TextView를 다른 것으로 바꿔준다.
이 때, 1과 2의 기능을 코루틴을 사용해서 비동기로 처리해 볼 것이다.
1의 경우 url을 통해서 이미지를 가져오는 네트워크 작업이므로,
메인 스레드(UI 스레드)에서 작업할 수 없다.
UI 스레드는 Block될 경우 ANR(Application Not Responding)이 발생할 수 있다.
2의 경우는 메인 스레드에서 작업할 수 있지만 코루틴의 이해를 위해 다른 스레드에서
작업하도록 할 것이다.
의존성 추가
app 수준의 build.gradle에 다음과 같은 의존성을 추가해준다.
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
인터넷 권한 허용
Manifest에 인터넷 권한을 허용해준다.
<uses-permission android:name="android.permission.INTERNET" />
소스 코드
레이아웃 소스 코드
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<ImageView
android:id="@+id/image_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="20dp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="21sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/image_view"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
레이아웃 파일에는 ImageView와 TextView, 그리고 이 전체를 감싸는 SwipeRefrshLayout이 있다.
SwipeRefreshLayout을 사용할 경우, app 수준의 build.gradle의 다음과 같은 의존성을 추가해준다.
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
MainActivity 소스 코드
package com.example.coroutineexampleproject
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.coroutineexampleproject.databinding.ActivityMainBinding
import kotlinx.coroutines.*
import java.net.URL
/**
* Coroutine 예제 프로젝트.
* 기능 :
* 1. URL을 통해 이미지를 다운로드 받는다.
* 2. 랜덤 숫자를 텍스트뷰에 나타낸다.
* 3. 화면을 아래로 당기면 1, 2의 기능을 수행한다.
*
* 구현 :
* lifeCycleScope : 액티비티의 생명주기에 따른 범위를 의미한다. 해당 범위에 벗어나면 코루틴이 중지된다.
*
* - URL 클래스를 통해서 이미지의 url에 연결한 후 stream을 생성한다.
*
* - getBitmap() 메서드를 통해서 URL에 나와있는 이미지를 BitmapFactory를 통해 decode해서 bitmap의 형태로 만든다.
* suspendCancellableCoroutine : resume()을 통해 콜백 기능을 제공하며 코루틴이 중지 가능하도록 해준다.
* 또한 이미지를 다운받는 것은 Dispatchers.IO를 통해 메인 스레드가 아닌 다른 스레드에서 백그라운드로 네트워크 작업을 하도록 한다.
* 만일 메인 스레드에서 네트워크 작업을 하면, UI 스레드가 멈추므로 ANR(Application Not Responding)이 발생한다.
*
* - setImage() 메서드를 통해서 받아온 비트맵을 이미지뷰에 나타낸다.
* Dispatchers.Main을 통해 메인 스레드(UI 스레드)에서 뷰를 업데이트 하도록 해준다.
*
* - getRandomNumber() 메서드를 통해서 랜덤한 숫자를 얻는다.
* CPU와 관련된 연산처리 작업이므로 Dispatchers.Default를 통해 작업한다.
*
* - setText() 메서드를 통해서 받아온 랜덤한 숫자를 텍스트뷰에 나타낸다.
* Dispatchers.Main을 통해 메인 스레드(UI 스레드)에서 뷰를 업데이트 하도록 해준다.
*
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val imageURL = "https://picsum.photos/1024/1024"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setView()
binding.swipeRefreshLayout.setOnRefreshListener {
setView()
binding.swipeRefreshLayout.isRefreshing = false
}
}
private fun setView() {
lifecycleScope.launch {
getBitmap()?.let {
setImage(it)
}
setText(getRandomNumber())
}
}
private suspend fun getBitmap(): Bitmap? = suspendCancellableCoroutine { continuation ->
CoroutineScope(Dispatchers.IO).launch {
val inputStream = URL(imageURL).openStream()
try {
val bitmap = BitmapFactory.decodeStream(inputStream)
continuation.resume(bitmap) {}
Log.d("GET IMAGE", "COMPLETE")
} catch (e: Exception) {
} finally {
inputStream.close()
}
}
}
private suspend fun setImage(bitmap: Bitmap) =
withContext(Dispatchers.Main) {
binding.imageView.setImageBitmap(bitmap)
Log.d("SET IMAGE", "COMPLETE")
}
private suspend fun getRandomNumber(): String = suspendCancellableCoroutine { continuation ->
CoroutineScope(Dispatchers.Default).launch {
val randomNum: Long = (Math.random() * 900000000000 + 10000000000).toLong()
continuation.resume(randomNum.toString()) {}
Log.d("GET TEXT", "COMPLETE")
}
}
private suspend fun setText(str: String) =
withContext(Dispatchers.Main) {
binding.textView.text = str
Log.d("SET TEXT", "COMPLETE")
}
}
각 메서드의 기본 기능은 코드 상단 주석으로 설명해 놓았다.
코루틴은 위에서 설명했듯, 코루틴의 실행 범위를 설정해 줄 수 있다.
lifeCycleScope는 생명주기에 따른 코루틴의 범위를 지정해줄 수 있게 해준다.
즉, Activity나 Fragment가 onDestroy()가 호출되면 코루틴도 사라지므로 메모리 누수 위험을 덜어준다.
(GlobalScope에 생명주기를 넣은 버전이라고 생각하면 된다.)
(AAC ViewModel을 위한 viewModelScope도 존재한다.)
또한 코루틴은 withContext({Dispatcher}) { /* ... */ }와
CoroutineScope({Dispatcher}).launch { /* ... */ }으로 설정해 줄 수 있다.
여기에 설정할 Dispatcher가 바로 코루틴이 어디에서 작업을 해야 하는지를 설정해 주는 곳이다.
- Dispatchers.Main : 메인 스레드에서 작업을 할 경우에 설정하는 Dispatcher. 주로 UI 업데이트를 담당한다.
메인 스레드는 UI 스레드로 Block되면 안되므로 빠른 작업을 하게될 때 주로 설정하게 된다. - Dispatchers.IO : 파일 / 네트워크 작업을 하게 될 경우에 설정하는 Dispatcher.
room DB / Retrofit2를 사용할 때 주로 설정하게 된다. - Dispatchers.Default : CPU를 사용하는 연산작업 (정렬, JSON 파싱 등)을 하게 될 경우에 설정하는 Dispatcher.
주로 IO를 사용하고 필요에 따라 Default도 사용하게 된다. (Main은 스레드 Block의 위험성이 있으므로 거의 사용되지 않는다.)
위에서 설명한 lifeCycleScope는 기본적으로 Dispatchers.Main이 적용되어 있다.
suspend fun fetchDocs() { // Dispatchers.Main
val result = get("https://developer.android.com") // Dispatchers.IO for `get`
show(result) // Dispatchers.Main
}
suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }
안드로이드 코루틴 공식 문서에 나와있는 예시 코드이다.
해당 코드를 보면 Dispatcher의 개념을 잘 확인할 수 있다.
또한 공식 문서에서는 async(), await()에 대한 기능의 설명도 제공한다.
만일 작업1, 작업2 가 존재할 때, 작업1과 작업2의 완료된 후 결합물이 필요할 때 위의 기능을 사용하게 된다.
suspend fun fetchTwoDocs() =
coroutineScope {
val deferredOne = async { fetchDoc(1) }
val deferredTwo = async { fetchDoc(2) }
deferredOne.await()
deferredTwo.await()
}
suspend fun fetchTwoDocs() = // called on any Dispatcher (any thread, possibly Main)
coroutineScope {
val deferreds = listOf( // fetch two docs at the same time
async { fetchDoc(1) }, // async returns a result for the first doc
async { fetchDoc(2) } // async returns a result for the second doc
)
deferreds.awaitAll() // use awaitAll to wait for both network requests
}
공식 문서에는 위의 예시 두개로 나와있다.
즉, 코루틴을 실행할 때 launch, async가 존재하게 되는데
launch는 반환값이 필요 없을 때,
async는 반환값이 필요할 때 사용하면 된다.
공식 문서의 추가적인 권장사항으로는, Dispatcher를 하드코딩하지 말라고 되어있다.
// DO inject Dispatchers
class NewsRepository(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}
// DO NOT hardcode Dispatchers
class NewsRepository {
// DO NOT use Dispatchers.Default directly, inject it instead
suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}
이렇게 Repository에 Dispatcher에 대한 의존성을 추가해 줄 경우 기본 스레드에서 호출이 되어도
무슨 Dispatcher를 사용해야 하는지에 대한 걱정을 할 필요가 없게된다.
코루틴은 안드로이드 개발에 있어서 쉽게 비동기 처리를 할 수 있게 해주는 도구이다.
러닝커브가 높은 편이 아니고 쉽게 사용할 수 있고 다양한 기능을 제공해준다.
하지만 그만큼 잘못 사용할 확률이 높으므로 잘 이해하고 사용해야 한다.
사용한 프로젝트의 코드들은 위에서 확인이 가능하다.