Kotlin 확장 프로그램 라이브러리 빌드

Android KTX는 흔히 사용되는 Android 프레임워크 API, Android Jetpack 라이브러리 등을 위한 확장 프로그램 집합입니다. 이러한 확장 프로그램을 사용하면 확장 함수와 속성, 람다, 이름이 지정된 매개변수와 기본 매개변수, 코루틴과 같은 언어 기능을 활용하여 Kotlin 코드에서 자바 프로그래밍 언어 기반 API를 보다 간결하고 자연스럽게 호출할 수 있습니다.

KTX 라이브러리란 무엇인가요?

KTX는 Kotlin 확장 프로그램을 의미하며 그 자체로 Kotlin 언어의 특수 기술이나 언어 기능이 아니라 원래 자바 프로그래밍 언어로 만든 API의 기능을 확장하는 Google의 Kotlin 라이브러리에 채택된 이름일 뿐입니다.

Kotlin 확장 프로그램의 장점은 누구나 자체 API에 사용할 고유 라이브러리를 빌드하거나 프로젝트에서 사용하는 타사 라이브러리를 위한 라이브러리를 빌드할 수 있다는 것입니다.

이 Codelab에서는 Kotlin 언어 기능을 활용하는 간단한 확장 프로그램을 추가하는 몇 가지 예를 살펴보겠습니다. 또한 콜백 기반 API의 비동기 호출을 정지 함수와 Flow(코루틴 기반의 비동기 스트림)로 변환하는 방법도 살펴보겠습니다.

빌드할 항목

이 Codelab에서는 사용자의 현재 위치를 가져오고 표시하는 간단한 애플리케이션과 관련한 작업을 하겠습니다. 이 앱에는 아래의 기능이 있습니다.

  • 위치 정보 제공자에서 알 수 있는 최근 위치를 가져옵니다.
  • 앱 실행 중에 실시간으로 업데이트되는 사용자 위치를 받도록 등록됩니다.
  • 화면에 위치를 표시하고 위치가 제공되지 않는 경우 오류 상태를 처리합니다.

학습할 내용

  • 기존 클래스 위에 Kotlin 확장 프로그램을 추가하는 방법
  • 단일 결과를 반환하는 비동기 호출을 코루틴 정지 함수로 변환하는 방법
  • Flow를 사용하여, 값을 여러 번 내보낼 수 있는 소스에서 데이터를 가져오는 방법

필요한 항목

  • Android 스튜디오의 최신 버전(3.6 이상 권장)
  • Android Emulator 또는 USB를 통해 연결된 기기
  • Android 개발 및 Kotlin 언어에 관한 기본 지식
  • 코루틴과 정지 함수에 관한 기본적인 이해

코드 다운로드

다음 링크를 클릭하면 이 Codelab의 모든 코드를 다운로드할 수 있습니다.

소스 코드 다운로드

또는 다음 명령어를 사용하여 명령줄에서 GitHub 저장소를 클론할 수 있습니다.

$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git

이 Codelab의 코드는 ktx-library-codelab 디렉터리에 있습니다.

프로젝트 디렉터리에는 이 Codelab의 각 단계에서 도달하고자 하는 최종 상태가 포함된 몇몇 step-NN 폴더가 참조용으로 들어 있습니다.

모든 코딩 작업은 work 디렉터리에서 진행합니다.

처음으로 앱 실행

Android 스튜디오에서 루트 폴더(ktx-library-codelab)를 열고, 아래에 나와 있는 것처럼 드롭다운에서 work-app 실행 구성을 선택합니다.

79c2a2d2f9bbb388.png

Run 35a622f38049c660.png 버튼을 눌러 앱을 테스트합니다.

58b6a81af969abf0.png

아직 이 앱은 어떠한 흥미로운 동작도 보이지 않습니다. 그리고 데이터 표시에 필요한 몇몇 부분이 누락되었습니다. 누락된 기능은 이후 단계에서 추가할 것입니다.

보다 쉽게 권한을 확인하는 방법

58b6a81af969abf0.png

앱이 실행되긴 하지만 현재 위치를 가져올 수 없다는 오류만 표시됩니다.

사용자에게 런타임 위치 정보 액세스 권한을 요청하기 위한 코드가 누락되었기 때문입니다.

MainActivity.kt를 열고, 주석 처리된 다음 코드를 찾습니다.

//  val permissionApproved = ActivityCompat.checkSelfPermission(
//      this,
//      Manifest.permission.ACCESS_FINE_LOCATION
//  ) == PackageManager.PERMISSION_GRANTED
//  if (!permissionApproved) {
//      requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
//  }

코드의 주석 처리를 삭제하고 앱을 실행하면 앱은 권한을 요청하고 위치 표시 작업을 진행합니다. 하지만 이 코드는 몇 가지 이유로 읽기가 어렵습니다.

  • 이 코드는 ActivityCompat 유틸리티 클래스의 정적 메서드 checkSelfPermission을 사용합니다. 이 메서드는 이전 버전과의 호환성을 위해 메서드를 보유하는 용도로만 존재합니다.
  • 이 메서드는 항상 Activity 인스턴스를 첫 번째 매개변수로 취합니다. 이는 자바 프로그래밍 언어에서 메서드를 프레임워크 클래스에 추가할 수 없기 때문입니다.
  • 권한이 PERMISSION_GRANTED인지 확인하는 작업이 항상 진행 중입니다. 그래야 권한이 부여된 경우에는 부울 true를 직접 가져오고 그러지 않은 경우에는 false를 가져오기가 더 좋기 때문입니다.

위에 나와 있는 상세 코드를 다음과 같은 짧은 항목으로 변환할 수 있습니다.

if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
    // request permission
}

코드 단축에는 Activity에서 확장 함수를 사용할 것입니다. 프로젝트에서 myktxlibrary라는 다른 모듈을 찾습니다. 이 모듈에서 ActivityUtils.kt를 열고 다음 함수를 추가합니다.

fun Activity.hasPermission(permission: String): Boolean {
    return ActivityCompat.checkSelfPermission(
        this,
        permission
    ) == PackageManager.PERMISSION_GRANTED
}

이제 여기 항목들의 의미를 자세히 살펴보겠습니다.

  • funclass 내부가 아닌 가장 바깥쪽 범위에 있다는 것은 파일에서 최상위 함수를 정의한다는 의미입니다.
  • Activity.hasPermissionActivity 유형의 수신자에서 hasPermission이라는 이름의 확장 함수를 정의합니다.
  • 이 메서드는 권한을 String 인수로 취하고, 권한이 부여되었는지 나타내는 Boolean을 반환합니다.

그렇다면 'X 유형의 수신자'가 정확히 무엇인가요?

이 수신자는 Kotlin 확장 함수 문서를 읽을 때 자주 등장합니다. 그 의미는 Kotlin 확장 함수가 항상 Activity(이 경우에서) 또는 서브클래스의 인스턴스에서 호출되며, 함수 본문 내에서 키워드 this를 사용하여 그 인스턴스를 참조할 수 있다는 것입니다(또한 완전히 생략할 수 있다는 암시적 의미일 수도 있음).

변경할 수 없거나 그러지 않으면 변경하고 싶지 않은 클래스 위에 새로운 기능을 추가하는 것은 실제로 확장 함수의 중요한 점입니다.

MainActivity.kt에서 확장 함수를 어떻게 호출하는지 살펴보겠습니다. 확장 함수를 열고 권한 코드를 다음으로 변경합니다.

if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
   requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
}

이제 앱을 실행하면 화면에 위치가 표시됩니다.

c040ceb7a6bfb27b.png

위치 텍스트 형식 지정을 위한 도우미

하지만 위치 텍스트가 그다지 멋져 보이지 않습니다. 현재 위치 텍스트에는 UI에 표시되는 용도로 만들어지지 않은 기본 Location.toString 메서드가 사용되고 있습니다.

myktxlibrary에서 LocationUtils.kt 클래스를 엽니다. 이 파일에는 Location 클래스를 위한 확장 프로그램이 포함되어 있습니다. 형식이 지정된 String을 반환하도록 Location.format 확장 함수를 작성한 다음, 확장 프로그램을 사용하도록 ActivityUtils.kt에서 Activity.showLocation을 수정합니다.

문제가 발생하는 경우 step-03 폴더의 코드를 입력할 수 있습니다. 최종 결과의 모습은 다음과 같습니다.

b8ef64975551f2a.png

Google Play 서비스의 통합 위치 정보 제공자

현재 작업 중인 앱 프로젝트는 Google Play 서비스의 통합 위치 정보 제공자를 사용하여 위치 데이터를 가져옵니다. API 자체는 매우 간단합니다. 하지만 사실상 사용자 위치를 가져오는 작업이 즉각적으로 이루어지지는 않기 때문에 라이브러리 호출은 모두 비동기여야 합니다. 이로 인해 코드가 콜백으로 복잡해집니다.

사용자 위치를 가져오는 작업은 두 부분으로 나뉩니다. 이 단계에서는 알려진 최근 위치(제공되는 경우)를 가져오는 방법을 중점적으로 살펴보겠습니다. 그다음 단계에서는 앱 실행 시의 주기적 위치 업데이트를 살펴보겠습니다.

알려진 최근 위치 가져오기

Activity.onCreate에서 라이브러리로의 진입점이 될 FusedLocationProviderClient를 초기화합니다.

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)
   fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
}

그런 다음 Activity.onStart에서 getLastKnownLocation()을 호출합니다. 그 모습은 현재 다음과 같습니다.

private fun getLastKnownLocation() {
   fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
       showLocation(R.id.textView, lastLocation)
   }.addOnFailureListener { e ->
       findAndSetText(R.id.textView, "Unable to get location.")
       e.printStackTrace()
   }
}

위에서 알 수 있듯이 lastLocation은 성공 또는 실패로 완료될 수 있는 비동기 호출입니다. 그러한 각 결과와 관련해, 위치를 UI로 설정하거나 오류 메시지를 표시하는 콜백 함수를 등록해야 합니다.

현재 이 코드는 콜백으로 인한 복잡함이 그리 심하지 않아 보이지만, 실제 프로젝트에서는 위치를 처리하거나 데이터베이스에 저장하거나 서버에 업로드하고자 할 수 있습니다. 이러한 작업도 대부분 비동기이며, 콜백에 콜백을 추가할 경우 코드를 신속히 읽을 수 없게 됩니다. 그 모습은 다음과 같을 수 있습니다.

private fun getLastKnownLocation() {
   fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
       getLastLocationFromDB().addOnSuccessListener {
           if (it != location) {
               saveLocationToDb(location).addOnSuccessListener {
                   showLocation(R.id.textView, lastLocation)
               }
           }
       }.addOnFailureListener { e ->
           findAndSetText(R.id.textView, "Unable to read location from DB.")
           e.printStackTrace()
       }
   }.addOnFailureListener { e ->
       findAndSetText(R.id.textView, "Unable to get location.")
       e.printStackTrace()
   }
}

더 심각한 문제는 위 코드에 작업과 메모리 누수 문제가 있다는 것입니다. 포함되어 있는 Activity가 완료될 때 리스너가 삭제되지 않기 때문입니다.

코루틴을 사용해 이 문제를 해결하는 보다 좋은 방법을 찾아볼 것입니다. 그 방법을 통해, 호출 스레드에서 차단 호출을 전혀 진행하지 않고도 일반적인 하향식 명령 코드 블록과 같은 모습의 비동기 코드를 작성할 수 있게 됩니다. 게다가 코루틴이 취소 가능해져 코루틴이 범위를 벗어날 때마다 정리 작업도 가능합니다.

다음 단계에서는 기존 Callback API를 UI에 연결된 코루틴 범위에서 호출할 수 있는 정지 함수로 변환하는 확장 함수를 추가할 것입니다. 이상적인 결과는 다음과 같습니다.

private fun getLastKnownLocation() {
    try {
        val lastLocation = fusedLocationClient.awaitLastLocation();
        // process lastLocation here if needed
        showLocation(R.id.textView, lastLocation)
    } (e: Exception) {
        // we can do regular exception handling here or let it throw outside the function
    }
}

suspendCancellableCoroutine을 사용하여 정지 함수 만들기

LocationUtils.kt를 열고 FusedLocationProviderClient에서 새 확장 함수를 정의합니다.

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
   suspendCancellableCoroutine { continuation ->
    TODO("Return results from the lastLocation call here")
}

구현 부분으로 넘어가기 전에 이 함수 서명의 내용을 살펴보겠습니다.

  • 이 Codelab의 이전 부분을 통해 확장 함수와 수신자 유형에 대해 이미 알고 계실 겁니다(fun FusedLocationProviderClient.awaitLastLocation).
  • suspend는 확장 함수가 정지 함수가 된다는 의미입니다. 정지 함수는 코루틴 내에서나 다른 suspend 함수에서만 호출 가능한 특수한 유형의 함수입니다.
  • 마치 API에서 위치 결과를 가져오는 동기 방식처럼, 이 함수를 호출하는 결과 유형은 Location입니다.

결과를 빌드하기 위해 suspendCancellableCoroutine을 사용할 것입니다. 이는 코루틴 라이브러리에서 정지 함수를 만들기 위한 하위 수준 구성 요소입니다.

suspendCancellableCoroutine은 매개변수로 전달된 코드 블록을 실행한 다음, 결과를 기다리는 동안 코루틴 실행을 정지합니다.

이전 lastLocation 호출에서 본 것처럼, 성공 및 실패 콜백을 함수 본문에 추가해 보겠습니다. 아쉽게도 아래의 주석에서 볼 수 있듯이 결과 반환 작업을 하고 싶지만 콜백 본문에서는 할 수가 없습니다.

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
   suspendCancellableCoroutine { continuation ->
    lastLocation.addOnSuccessListener { location ->
        // this is not allowed here:
        // return location
    }.addOnFailureListener { e ->
        // this will not work as intended:
        // throw e
    }
}

주변 함수가 종료되고 오래 지나 콜백이 발생하고 결과를 반환할 곳이 없기 때문입니다. 이러한 경우를 위해 코드 블록에 제공된 continuation과 함께 suspendCancellableCoroutine이 제공됩니다. 이 메서드를 사용해 나중에 continuation.resume으로 결과를 다시 정지 함수에 제공할 수 있습니다. continuation.resumeWithException(e)을 사용하여 오류 사례를 처리하여 호출 사이트에 예외를 적절히 전파할 수 있습니다.

일반적으로는 결과를 기다리는 동안 코루틴이 영구적으로 정지 상태에 놓이지 않도록 언젠가는 결과 또는 예외를 반환하게 된다는 점을 항상 염두에 두어야 합니다.

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
   suspendCancellableCoroutine<Location> { continuation ->
       lastLocation.addOnSuccessListener { location ->
           continuation.resume(location)
       }.addOnFailureListener { e ->
           continuation.resumeWithException(e)
       }
   }

이제 완료됐습니다. 알려진 최근 Location API의 정지 버전(앱의 코루틴에서 사용 가능)을 방금 노출했습니다.

정지 함수 호출

알려진 최근 위치 호출의 새 코루틴 버전을 호출하기 위해 MainActivity에서 getLastKnownLocation 함수를 수정해 보겠습니다.

private suspend fun getLastKnownLocation() {
    try {
        val lastLocation = fusedLocationClient.awaitLastLocation()
        showLocation(R.id.textView, lastLocation)
    } catch (e: Exception) {
        findAndSetText(R.id.textView, "Unable to get location.")
        Log.d(TAG, "Unable to get location", e)
    }
}

앞서 언급했듯이 정지 함수가 코루틴 내에서 실행되도록 하려면 정지 함수를 항상 다른 정지 함수에서 호출해야 합니다. 즉, getLastKnownLocation 함수 자체에 정지 수정자를 추가해야 합니다. 그러지 않으면 IDE에서 오류가 발생합니다.

예외 처리에는 일반적인 try-catch 블록을 사용할 수 있습니다. 이 코드를 실패 콜백 내부에서 옮길 수 있습니다. 이제 일반적인 명령 프로그램에서처럼 Location API에서 발생하는 예외가 올바르게 전파되기 때문입니다.

코루틴을 시작하려면 대개는 코루틴 범위가 필요한 CoroutineScope.launch를 사용합니다. 다행히 Android KTX 라이브러리에는 Activity, Fragment, ViewModel과 같은 일반 수명 주기 객체의 범위가 몇몇 사전 정의되어 있습니다.

다음 코드를 Activity.onStart에 추가합니다.

override fun onStart() {
   super.onStart()
   if (!hasPermission(ACCESS_FINE_LOCATION)) {
       requestPermissions(arrayOf(ACCESS_FINE_LOCATION), 0)
   }

   lifecycleScope.launch {
       try {
           getLastKnownLocation()
       } catch (e: Exception) {
           findAndSetText(R.id.textView, "Unable to get location.")
           Log.d(TAG, "Unable to get location", e)
       }
   }
   startUpdatingLocation()
}

위치 결과를 여러 번 내보내는 함수에 Flow를 도입하는 다음 단계로 진행하기 전에, 앱을 실행해 앱 실행 여부를 확인할 수 있어야 합니다.

이제 startUpdatingLocation() 함수를 중점적으로 살펴보겠습니다. 사용자 기기가 실제로 움직일 때마다 주기적인 위치 업데이트를 받기 위해, 현재 코드에서 리스너를 통합 위치 정보 제공자에 등록합니다.

Flow 기반 API를 사용하여 하려는 작업을 나타내기 위해 우선 MainActivity의 일부분을 살펴보겠습니다. 이 요소는 나중에 이 섹션에서 삭제되고 대신 새 확장 함수의 구현 세부정보로 옮길 것입니다.

현재 코드에는 업데이트 수신 대기를 시작했는지 추적하기 위한 변수가 있습니다.

var listeningToUpdates = false

또한 기본 콜백 클래스의 서브클래스와 함께 위치 업데이트 콜백 함수 구현도 있습니다.

private val locationCallback: LocationCallback = object : LocationCallback() {
   override fun onLocationResult(locationResult: LocationResult?) {
       if (locationResult != null) {
           showLocation(R.id.textView, locationResult.lastLocation)
       }
   }
}

또한 비동기 호출이므로 콜백과 함께 최초로 등록된 리스너(사용자가 필수 권한을 부여하지 않으면 실패할 수 있음)가 있습니다.

private fun startUpdatingLocation() {
   fusedLocationClient.requestLocationUpdates(
       createLocationRequest(),
       locationCallback,
       Looper.getMainLooper()
   ).addOnSuccessListener { listeningToUpdates = true }
   .addOnFailureListener { e ->
       findAndSetText(R.id.textView, "Unable to get location.")
       e.printStackTrace()
   }
}

마지막으로, 화면이 더 이상 활성 상태가 아닌 경우 다음을 지웁니다.

override fun onStop() {
   super.onStop()
   if (listeningToUpdates) {
       stopUpdatingLocation()
   }
}

private fun stopUpdatingLocation() {
   fusedLocationClient.removeLocationUpdates(locationCallback)
}

계속해서 MainActivity에서 이러한 코드 스니펫을 모두 삭제하고, 나중에 Flow를 수집하는 데 사용할 빈 startUpdatingLocation() 함수만 남겨 두어도 됩니다.

callbackFlow: 콜백 기반 API를 위한 Flow 빌더

LocationUtils.kt를 다시 열고 FusedLocationProviderClient에서 다른 확장 함수를 정의합니다.

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    TODO("Register a location listener")
    TODO("Emit updates on location changes")
    TODO("Clean up listener when finished")
}

MainActivity 코드에서 방금 삭제한 기능을 복제하려면 여기에 몇 가지 작업을 해야 합니다. Flow를 반환하는 빌더 함수인 callbackFlow()를 사용할 것입니다. 이 함수는 콜백 기반 API에서 데이터를 내보내는 데 적합합니다.

callbackFlow()에 전달된 블록이 ProducerScope를 사용하여 수신자로 정의됩니다.

noinline block: suspend ProducerScope<T>.() -> Unit

ProducerScopecallbackFlow의 구현 세부정보(예: 생성된 Flow를 지원하는 Channel이 있다는 점)를 캡슐화합니다. 세부적으로 들어가지 않고, Channels는 내부적으로 일부 Flow 빌더와 연산자에 사용됩니다. 따라서 빌더/연산자를 직접 작성하지 않는 한 하위 수준의 세부정보를 걱정할 필요가 없습니다.

데이터를 내보내고 Flow의 상태를 관리하기 위해 ProducerScope에 노출되는 몇 가지 함수를 간단히 사용할 것입니다.

먼저 Location API의 리스너를 생성합니다.

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            for (location in result.locations) {
                offer(location) // emit location into the Flow using ProducerScope.offer
            }
        }
    }

    TODO("Register a location listener")
    TODO("Clean up listener when finished")
}

들어온 위치 데이터를 ProducerScope.offer를 통해 보낼 때는 Flow를 사용합니다.

그다음, 오류 처리에 유의하며 FusedLocationProviderClient에 콜백을 등록합니다.

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            for (location in result.locations) {
                offer(location) // emit location into the Flow using ProducerScope.offer
            }
        }
    }

    requestLocationUpdates(
       createLocationRequest(),
       callback,
       Looper.getMainLooper()
    ).addOnFailureListener { e ->
       close(e) // in case of error, close the Flow
    }

    TODO("Clean up listener when finished")
}

FusedLocationProviderClient.requestLocationUpdateslastLocation과 마찬가지로 비동기 함수이며, 성공적으로 완료되거나 실패할 경우 콜백을 사용해 신호를 보냅니다.

여기서 성공 상태는 무시할 수 있습니다. 언젠가 onLocationResult가 호출되고 결과를 Flow에 내보낸다는 의미이기 때문입니다.

실패할 경우에는 Exception를 사용해 Flow를 즉시 닫습니다.

callbackFlow에 전달된 블록 내부에서 항상 호출해야 하는 마지막 항목은 awaitClose입니다. 이 요소는 Flow가 완료되거나 취소될 때 리소스를 해제하기 위한 정리 코드를 배치할 수 있는 편리한 공간을 제공합니다(Exception을 통해 발생한 것인지의 유무에 상관없음).

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            for (location in result.locations) {
                offer(location) // emit location into the Flow using ProducerScope.offer
            }
        }
    }

    requestLocationUpdates(
       createLocationRequest(),
       callback,
       Looper.getMainLooper()
    ).addOnFailureListener { e ->
       close(e) // in case of exception, close the Flow
    }

    awaitClose {
       removeLocationUpdates(callback) // clean up when Flow collection ends
    }
}

이제 모든 부분(리스너 등록, 업데이트 수신 대기 및 정리)이 완료되었으므로 MainActivity로 돌아가서 실제로 Flow를 사용해 위치를 표시해 보겠습니다.

Flow 수집

Flow 빌더를 호출하고 수집하기 위해 MainActivity에서 startUpdatingLocation 함수를 수정해 보겠습니다. 기본 구현은 다음과 같은 모습입니다.

private fun startUpdatingLocation() {
    lifecycleScope.launch {
        fusedLocationClient.locationFlow()
        .conflate()
        .catch { e ->
            findAndSetText(R.id.textView, "Unable to get location.")
            Log.d(TAG, "Unable to get location", e)
        }
        .collect { location ->
            showLocation(R.id.textView, location)
            Log.d(TAG, location.toString())
        }
    }
}

Flow.collect()Flow의 실제 작업을 시작하는 터미널 연산자입니다. callbackFlow 빌더에서 내보내는 모든 위치 업데이트를 여기서 받게 됩니다. collect는 정지 함수이므로 lifecycleScope를 사용하여 시작하는 코루틴 내에서 실행되어야 합니다.

Flow에서 호출되는 conflate()catch() 중간 연산자도 있음을 알 수 있습니다. 코루틴 라이브러리에는 선언적 방식으로 흐름을 필터링하고 변환할 수 있는 다양한 연산자가 있습니다.

흐름 융합은 업데이트가 수집기에서 처리 가능한 속도보다 더 빨리 내보내질 때에만 그때마다 최신 업데이트를 수신하게 됩니다. 여기서는 UI에 최근 위치만 표시하고자 하기 때문에 흐름 융합은 이 예시에 잘 맞습니다.

catch는 이름에서 알 수 있듯이 업스트림(이 경우에는 locationFlow 빌더)에서 발생한 모든 예외를 처리할 수 있게 해 줍니다. 업스트림은 현재 작업 이전에 적용된 작업이라고 생각하면 됩니다.

그렇다면 위 스니펫의 문제는 무엇일까요? 스니펫이 앱을 비정상적으로 종료시키지 않고 활동이 DESTROYED된 후 lifecycleScope 덕분에 적절히 정리되기는 하지만, 활동이 중지된 상황(예: 활동이 표시되지 않는 경우)을 고려하지 않습니다.

즉, 필요하지 않더라도 UI를 업데이트하게 될 뿐 아니라, Flow가 위치 데이터 구독을 활성 상태로 유지해 배터리와 CPU 주기를 낭비하게 됩니다.

이 문제를 해결하는 한 가지 방법은 LiveData KTX 라이브러리의 Flow.asLiveData 확장 프로그램을 사용하여 Flow를 LiveData로 변환하는 것입니다. LiveData는 구독을 관측할 시점과 일시중지할 시점을 인식하고, 필요에 따라 기본 Flow를 다시 시작합니다.

private fun startUpdatingLocation() {
    fusedLocationClient.locationFlow()
        .conflate()
        .catch { e ->
            findAndSetText(R.id.textView, "Unable to get location.")
            Log.d(TAG, "Unable to get location", e)
        }
        .asLiveData()
        .observe(this, Observer { location ->
            showLocation(R.id.textView, location)
            Log.d(TAG, location.toString())
        })
}

명시적인 lifecycleScope.launch는 더 이상 필요하지 않습니다. asLiveData가 Flow를 실행하는 데 필요한 범위를 제공하기 때문입니다. 실제로 observe 호출은 LiveData에서 비롯되고, 코루틴 또는 Flow와 전혀 관계가 없습니다. 이 호출은 LifecycleOwner로 LiveData를 관측하는 표준 방법일 뿐입니다. LiveData가 기본 Flow를 수집해 위치를 관측자로 내보냅니다.

이제 흐름 재생성과 수집이 자동으로 처리되므로 startUpdatingLocation() 메서드를 여러 번 실행할 수 있는 Activity.onStart에서 Activity.onCreate로 옮겨야 합니다.

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   setContentView(R.layout.activity_main)
   fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
   startUpdatingLocation()
}

이제 앱을 실행하고 홈 버튼과 뒤로 버튼을 눌러 앱이 회전에 어떻게 반응하는지 확인할 수 있습니다. 앱이 백그라운드에 있을 때 새 위치가 출력되는지 logcat에서 확인합니다. 구현이 올바르다면 앱이 올바르게 Flow 컬렉션을 일시중지해야 하고, 홈 버튼을 누른 다음 앱으로 돌아가면 Flow 컬렉션을 다시 시작해야 합니다.

첫 KTX 라이브러리를 빌드했습니다.

수고하셨습니다. 이 Codelab에서 한 작업은 기존의 자바 기반 API를 위한 Kotlin 확장 프로그램 라이브러리를 빌드할 때의 일반적인 작업과 매우 유사합니다.

지금까지의 작업을 요약해 보겠습니다.

  • Activity에서 권한을 확인하기 위한 편의 함수를 추가했습니다.
  • Location 객체에 텍스트 형식 지정 확장 프로그램을 제공했습니다.
  • Flow를 사용하여 주기적으로 위치를 업데이트하고 알려진 최근 위치를 가져오기 위해 Location API의 코루틴 버전을 노출했습니다.
  • 원하는 경우 코드를 세부적으로 정리하고 테스트를 추가하고 팀의 다른 개발자가 활용할 수 있도록 location-ktx 라이브러리를 배포할 수 있습니다.

배포용 AAR 파일을 빌드하려면 :myktxlibrary:bundleReleaseAar 작업을 실행하세요.

Kotlin 확장 프로그램을 활용할 수 있는 다른 모든 API에도 비슷한 단계를 진행하면 됩니다.

Flow를 사용하여 애플리케이션 아키텍처 세분화

앞서 언급했지만 이 Codelab에서 한 것처럼 Activity에서 작업을 시작하는 것이 항상 가장 좋은 생각은 아닙니다. 이 Codelab에서는 UI에서 ViewModels로부터 흐름을 관측하는 방법, 흐름이 LiveData와 상호 운영되는 방법, 데이터 스트림 사용을 중심으로 앱을 디자인하는 방법을 배울 수 있습니다.