Kotlin Coroutine

비동기 프로그래밍에 권장되는 코루틴은 멀티태스킹을 지원하고 단순히 스레드로 작업하는 것보다 레벨이 다른 추상화를 제공한다. 상태를 저장해 중단했다가 재개할 수 있다는 주요 기능이 핵심이다. 따라서 코루틴을 실행되거나 실행되지 않을 수 있다.

  • Job → 생명 주기가 있는 취소 가능한 작업 단위(launch()로 만든 작업 단위)
  • CoroutineScopelaunch()async()와 같이 새 코루틴을 생성하는 데 사용되는 코드는 CoroutineScope의 확장이다. 모든 코루틴은 범위 내에서 실행해야 하고, CoroutineScope는 하나의 이상의 관련 코루틴을 관리한다.
    • GlobalScope →앱이 실행되는 한 내부의 코루틴이 실행되도록 허용한다.
  • Dispatcher → 코루틴 실행에 사용할 자원 스레드를 관리한다. 개발자가 새 스레드를 사용할 시기와 위치를 파악하지 않아도 되기 때문에 새 스레드를 초기화하는 데 드는 성능 비용이 발생하지 않도록 한다.
    • Main
    • Default
    • IO
    • Unconfined
  • 코루틴 코드 블록은 **suspend 키워드**로 표시된다. 일시정지되거나 재개될 수 있음을 나타내기 위해서다. 함수가 이미 suspend 함수를 호출한 경우에도 자체적으로 표시해야 한다.
  • runBlocking → 새 코루틴을 시작하고 완료될 때까지 현재 스레드를 차단한다. 주로 기본 함수와 테스트에서 blocking 코드와 non-blocking 코드 사이를 연결하는 데 사용된다. 일반적인 android 코드에서는 주로 사용하지 않는다.
  • Deferred → 나중에 객체에 값이 반환된다고 보장한다. 그러니까 반환 값의 자리표시자 역할을 한다.

예제로 알아보자.

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        // Create a new coroutine to move the execution off the UI thread
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}
  • viewModelScope → ViewModel KTX 확장 프로그램에 포함되어 사전 정의된 CoroutineScope. ViewModel 범위에서 실행된다. ViewModel이 소멸되는 경우 viewModelScope가 자동으로 취소되고, 따라서 실행 중인 모든 코루틴도 취소된다.
  • launch → 코루틴을 만들고 함수 본문의 실행을 해당하는 dispatcher에 전달한다.
  • Dispatchers.IO → 코루틴을 I/O 작업용 스레드에서 실행한다.

다음 스레드 활용 코드.

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       Thread {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               Thread.sleep(50)
           }
       }.start()
   }
}

동일한 코드를 코루틴을 활용한 코드.

import kotlinx.coroutines.*

fun main() {
   val states = arrayOf("Starting", "Doing Task 1", "Doing Task 2", "Ending")
   repeat(3) {
       GlobalScope.launch {
           println("${Thread.currentThread()} has started")
           for (i in states) {
               println("${Thread.currentThread()} - $i")
               delay(5000)
           }
       }
   }
}

Retrofit과 moshi 라이브러리

private val retrofit = Retrofit.Builder()
    .addConverterFactory(ScalarsConverterFactory.create())
    .baseUrl(BASE_URL)
    .build()
  • addConverterFactory → 웹 서비스에서 얻은 데이터로 해야 할 일을 Retrofit에 알린다.
  • ScalarsConverter → 문자열 및 기타 primitive 유형을 지원한다.
  • baseUrl →웹 서비스의 기본 URI를 추가한다.
  • build → Retrofit 객체를 생성한다.

cf) Json 문자열을 Kotlin 객체로 변환할 때 Json 파서인 Moshi 라이브러리를 활용한다. converter가 따로 있고, 아래와 같이 활용한다.

private val moshi = Moshi.Builder()
    .add(KotlinJsonAdapterFactory())
    .build()

private val retrofit = Retrofit.Builder()
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .baseUrl(BASE_URL)
    .build()

싱글톤 패턴과 Retrofit

객체의 인스턴스가 하나만 생성되도록 보장한다. 이 객체의 전역 액세스 포인트도 하나를 가진다. 객체 선언의 초기화는 스레드로부터 안전하고 첫 액세스할 때 한다.

다음은 싱글톤 객체 선언과 액세스 예시 코드. 객체 선언에는 항상 object 키워드가 붙는다.

// Object declaration
object DataProviderManager {
    fun registerDataProvider(provider: DataProvider) {
        // ...
    }

    val allDataProviders: Collection<DataProvider>
        get() = // ...
}

// To refer to the object, use its name directly.
DataProviderManager.registerDataProvider(...)

Retrofit 객체에서 create() 함수를 호출하는 데는 리소스가 많이 든다. 또한 Retrofit API 인스턴스는 하나만 필요하다. 따라고 싱글톤 객체 선언을 해서 서비스를 노출시키자.

object MarsApi {
    val retrofitService : MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }
}

lazy를 활용해 초기화하면 최초 사용 시에 초기화된다. 실제로 객체가 필요할 때까지 불필요한 계산이 실행되거나 컴퓨팅 리소스가 사용되지 않도록 하기 위해 객체 생성을 의도적으로 지연하는 것이다.

BindingAdapter

view의 맞춤 속성을 위해 맞춤 setter를 만드는 데 사용되는 주석처리된 메서드다. 이게 무슨 소리인지는 다음 예시를 통해서 이해해보자.

xml에서 android:text="Sample Text"로 속성을 설정하면, android 시스템은 setText(String: text) 메서드에서 결정되는 text 속성과 같은 이름의 setter를 찾는다. 그러니까 setText(String: text)는 android에서 제공하는 일부 view의 setter 메서드라는 거다.

Binding Adapter 또한 이와 유사한 동작을 맞춤 설정할 수 있다! 쉽게 말해 url을 view에 붙여준다는 거다.

코드를 보며 되짚어보자.

<androidx.recyclerview.widget.RecyclerView
            android:id="@+id/photos_grid"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:clipToPadding="false"
            android:padding="6dp"
            app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:listData="@{viewModel.photos}"
            app:spanCount="2"
            tools:itemCount="16"
            tools:listitem="@layout/grid_view_item" />

app:imageUrl속성을 구현해 ImageView로 설정하는 메서드를 만들어야 한다.

@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    imgUrl?.let {
        val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
        imgView.load(imgUri) // coil 라이브러리 활용
    }
}
  • @BindingAdpater → 이 주석은 속성 이름을 매개변수로 사용한다. 이 코드를 해석하면 view에 imageUrl 속성이 있을 경우 bindingAdapter에게 binding을 실행시킨다는 거다.
  • bindImage 메서드의 첫 번째 매개변수는 타겟이 되는 view, 두 번째 매개변수는 속성에 설정되는 값이다.
  • buildUpon.scheme("https") → HTTPS 스키마 사용

cf) let은 ?.와 함께 null safety 연산을 실행하는 데 사용된다. null이 아닌 경우에만 실행되고, 메서드 하나 이상을 호출하는 데 쓴다.

cf) BindingAdapter는 class 안에서 쓰지 않는다!!!

DataBinding, ViewModel, BindingAdapter, RecyclerView를 함께 사용하기

  1. RecyclerView와 list 객체인 MarsPhoto를 인수로 사용하는 bindRecyclerView() 메서드를 추가하자.

    // BindingAdapters.kt
    
    @BindingAdapter("listData")
    fun bindRecyclerView(recyclerView: RecyclerView,
                        data: List<MarsPhoto>?) {
    
    }
  2. 어댑터를 할당하고, 출력할 사진 list 데이터가 포함된 adapter.submitList()를 호출한다.

    // BindingAdapters.kt
    
    @BindingAdapter("listData")
    fun bindRecyclerView(recyclerView: RecyclerView,
                        data: List<MarsPhoto>?) {
       val adapter = recyclerView.adapter as PhotoGridAdapter
       adapter.submitList(data)
    
    }
  3. xml의 RecyclerView에 listData 속성을 추가해 dataBinding을 활용하여 ViewModel의 데이터를 설정한다.

    // fragment_overview.xml
    
    <androidx.recyclerview.widget.RecyclerView
                ...
                app:listData="@{viewModel.photos}"
                ... />
  4. RecyclerView 어댑터를 PhotoGridAdapter 객체로 초기화한다.

    // OverviewFragment.kt
    
    override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            val binding = FragmentOverviewBinding.inflate(inflater)
            ...
            binding.photosGrid.adapter = PhotoGridAdapter()
    
            return binding.root
        }

xml Design 탭에서 RecyclerView에 sampleData 넣어 미리보기

<androidx.recyclerview.widget.RecyclerView
            ...
            tools:itemCount="16"
            tools:listitem="@layout/grid_view_item" />

RecyclerView 콘텐츠를 padding 영역 안에 그리기

아래 속성을 false로 하면 스크롤 뷰가 패딩 영역 안에 그려진다.

<androidx.recyclerview.widget.RecyclerView
            ...
            android:clipToPadding="false"
            ...  />

ViewModel에 상태 추가하기

인터넷을 활용해 통신을 해 데이터를 받을 때, 비행기 모드와 같은 경우에는 오류가 날 수 있다. 이러한 오류를 처리하려면 ViewModel에 웹 요청 상태를 나타내는 속성을 만들어 해결해야 한다.

  • loading → 데이터를 기다리는 중
  • success → 웹 서비스에서 데이터를 성공적으로 검색
  • failure → 네트워크 오류 or 연결 오류

위 세 가지 상태를 나타내기 위해 enum 클래스를 활용한다. kotlin에서 enum은 상수 집합을 보유할 수 있는 데이터 유형이다. 쉽게 예시로 알아보자.

// 정의
enum class Direction {
    NORTH, SOUTH, WEST, EAST
}
// 사용
var direction = Direction.NORTH

다음은 실습 예제다.

class OverviewViewModel : ViewModel() {

    enum class MarsApiStatus { LOADING, ERROR, DONE }

    ...
    private fun getMarsPhotos() {
        viewModelScope.launch { // 통신 시작
            _status.value = MarsApiStatus.LOADING
            try { // 통신 성공
                _photos.value = MarsApi.retrofitService.getPhotos()
                _status.value = MarsApiStatus.DONE
            } catch (e: Exception) { // 통신 에러
                _status.value = MarsApiStatus.ERROR
                _photos.value = listOf() // RecyclerView 삭제
            }
        }

    }
}

위 코드를 xml에 적용하려면 다음과 같이 해보자.

// BindingAdapter.kt

@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
               status: OverviewViewModel.MarsApiStatus?) {
    when (status) {
        OverviewViewModel.MarsApiStatus.LOADING -> {
            statusImageView.visibility = View.VISIBLE
            statusImageView.setImageResource(R.drawable.loading_animation)
        }
        OverviewViewModel.MarsApiStatus.ERROR -> {
            statusImageView.visibility = View.VISIBLE
            statusImageView.setImageResource(R.drawable.ic_connection_error)
        }
        OverviewViewModel.MarsApiStatus.DONE -> {
            statusImageView.visibility = View.GONE
        }
    }
}
// fragment_overview.xml

<ImageView
            android:id="@+id/status_image"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:marsApiStatus="@{viewModel.status}" />

ViewModel의 통신 상태에 따라 ImageView가 보일 수도, 안 보일 수도 있게 된다!

0개의 댓글