Kotlin 拡張機能ライブラリの作成

Android KTX は、よく使用される Android フレームワーク API、Android Jetpack ライブラリなどの拡張機能セットです。こうした拡張機能は、拡張関数、プロパティ、ラムダ、名前付きパラメータ、デフォルト パラメータ、コルーチンなどの言語機能を利用して、Kotlin コードから Java プログラミング言語ベースの API を簡潔かつ慣用的に呼び出せるようにするために作成されました。

KTX ライブラリとは

KTX は Kotlin 拡張機能を表し、Kotlin 言語自体の特別な技術や言語機能ではありません。これは、元々 Java プログラミング言語で作成された API の機能を拡張する、Google の Kotlin ライブラリに採用された名前にすぎません。

Kotlin 拡張機能の利点は、誰でも独自の API 用に、またはプロジェクトで使用するサードパーティ ライブラリ用に、独自のライブラリを作成できることです。

この Codelab では、Kotlin 言語機能を活用した簡単な拡張機能を追加する例を紹介します。また、コールバック ベースの API での非同期呼び出しを、suspend 関数と Flow(コルーチン ベースの非同期ストリーム)に変換する方法についても説明します。

作業内容

この Codelab では、ユーザーの現在位置を取得して表示する簡単なアプリを作成します。作成するアプリの機能は次のとおりです。

  • 直近の位置情報を位置情報プロバイダから取得する。
  • アプリの実行中に、ユーザーの位置情報のライブ アップデートに登録する。
  • 位置情報を画面に表示し、位置情報を利用できない場合はエラー状態を処理する。

学習内容

  • 既存のクラスに Kotlin 拡張機能を追加する方法
  • 1 つの結果を返す非同期呼び出しをコルーチンの suspend 関数に変換する方法
  • Flow を使用して、値を何度も出力できるソースからデータを取得する方法

必要なもの

  • 最新バージョンの Android Studio(3.6 以降を推奨)
  • Android Emulator または USB 経由で接続されたデバイス
  • Android 開発と Kotlin 言語に関する基本的な知識
  • コルーチンと suspend 関数に関する基礎知識

コードをダウンロードする

次のリンクをクリックして、この Codelab のコードをすべてダウンロードします。

ソースコードをダウンロード

または次のコマンドを使用して、コマンドラインから GitHub リポジトリのクローンを作成します。

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

この Codelab のコードは ktx-library-codelab ディレクトリにあります。

プロジェクト ディレクトリには、複数の step-NN フォルダがあります。このフォルダには、参照用に、この Codelab の各ステップの望ましい最終状態が含まれています。

コーディング作業はすべて、work ディレクトリで行います。

アプリを初めて実行する

Android Studio でルートフォルダ(ktx-library-codelab)を開き、次のようにプルダウンで work-app 実行構成を選択します。

79c2a2d2f9bbb388.png

実行ボタン 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 を使用している。このクラスは下位互換性をサポートするメソッドだけを集めたクラスです。
  • Java プログラミング言語ではフレームワーク クラスにメソッドを追加できないため、このメソッドは常に 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
}

ここで何が起きているのか、整理してみましょう。

  • fun が最も外側のスコープにある(class 内ではない)ということは、ファイル内での最上位の関数として定義されていることを意味します。
  • Activity.hasPermission は、Activity タイプのレシーバーで hasPermission という拡張関数を定義します。
  • 権限を String 引数として取り、権限が付与されたかどうかを示す Boolean を返します。

では、「X タイプのレシーバー」とは正確には何でしょうか。

これは、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 メソッドを使用しています。

myktxlibraryLocationUtils.kt クラスを開きます。このファイルには、Location クラスの拡張機能が含まれています。Location.format 拡張関数を完成させて書式設定された String を返し、ActivityUtils.ktActivity.showLocation を変更して拡張機能を使用します。

問題が発生した場合は、step-03 フォルダ内のコードを確認してください。最終的な結果は次のようになります。

b8ef64975551f2a.png

Google Play 開発者サービスの Fused Location Provider

今回取り組んでいるアプリ プロジェクトでは、Google Play 開発者サービスの Fused Location Provider を使用して位置情報を取得します。API 自体はごく単純ですが、ユーザーの位置情報の取得は即時的なオペレーションではないため、ライブラリに対する呼び出しはすべて非同期で行う必要があり、コールバックによりコードが複雑になります。

ユーザーの位置情報の取得には、2 つの部分があります。このステップでは、直近の位置情報(利用可能な場合)を取得することに焦点を当てます。次のステップでは、アプリ実行中の定期的な位置情報の更新について見ていきます。

直近の位置情報を取得する

Activity.onCreate で、ライブラリへのエントリ ポイントとなる FusedLocationProviderClient を初期化しています。

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

その後 Activity.onStartgetLastKnownLocation() を呼び出します。これは現在のところ次のようになっています。

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 が終了してもリスナーは削除されないため、メモリリークとオペレーションの問題が生じます。

これを解決するために、コルーチンを使用する方法について見ていきます。呼び出し元スレッドでブロッキング呼び出しを行わない、トップダウンの通常の命令型コードブロックのような非同期コードを記述できます。さらに、コルーチンはキャンセル可能でもあるため、スコープ外となった場合はいつでもクリーンアップできます。

次のステップでは、拡張関数を追加し、既存のコールバック API を UI に関連付けられたコルーチン スコープから呼び出せる suspend 関数に変換します。最終的な結果は次のようになります。

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 を使用して suspend 関数を作成する

LocationUtils.kt を開き、FusedLocationProviderClient で新しい拡張関数を定義します。

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

実装部分に進む前に、この関数署名について整理してみましょう。

  • 拡張関数とレシーバー タイプについては、この Codelab の前半(fun FusedLocationProviderClient.awaitLastLocation)で説明しました。
  • suspend は suspend 関数で、コルーチンの範囲内か別の suspend 関数からのみ呼び出せる特別な関数です。
  • この関数の呼び出し結果は、API から位置情報の結果を同期的に取得する場合と同様に、Location タイプになります。

結果の生成には suspendCancellableCoroutine を使用します。これは、コルーチン ライブラリから suspend 関数を作成するための低レベルのビルディング ブロックです。

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
    }
}

これは、コールバックを呼び出す関数が先に終了してしまうので、後からコールバックが行われても結果を返すところがないためです。このような場合には、コードブロックに提供されている suspendCancellableCoroutinecontinuation を使用すれば、中断された関数に continuation.resume を使用して後から結果を返すことができます。continuation.resumeWithException(e) を使用してエラーケースを処理し、例外をコールサイトに適切に伝達します。

通常は、事後のある時点で結果か例外を返すようにして、コルーチンが結果を待っていつまでも中断したままにならないようにしてください。

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

これで完了です。直近の位置情報を扱う API で、suspend 関数を使用してアプリのコルーチンから結果を得ることのできるバージョンができました。

suspend 関数を呼び出す

MainActivitygetLastKnownLocation 関数を変更して、コルーチンを使用して直近の位置情報を取得するバージョンを呼び出します。

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)
    }
}

上述のように、suspend 関数はコルーチン内で実行されるように常に他の suspend 関数から呼び出される必要があります。つまり、getLastKnownLocation 関数自体に suspend 修飾子を追加する必要があります。そうしないと IDE 上でエラーが発生します。

例外処理には通常の try-catch ブロックを使用できます。このコードは失敗のコールバック内から移動できます。Location API からの例外も、通常の命令型プログラムと同様に正しく伝達されるようになったためです。

コルーチンを開始するには、通常はコルーチン スコープが必要となる CoroutineScope.launch を使用します。Android KTX ライブラリには、ActivityFragmentViewModel などの一般的なライフサイクル オブジェクト用の事前定義スコープがいくつか用意されています。

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() 関数について説明します。現在のコードでは、Fused Location Provider にリスナーを登録し、ユーザーのデバイスが実際に移動するたびに、最新の位置情報を定期的に取得します。

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 コードから削除した機能を複製するために、ここでしなければならないことがいくつかあります。ここで使用する callbackFlow()Flow を返すビルダー関数で、コールバック ベースの API からデータを出力する場合に適しています。

callbackFlow() に渡されるブロックは、ProducerScope をレシーバーとして定義されます。

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

ProducerScope は、作成された Flow の背後に Channel があるなど、callbackFlow の実装の詳細をカプセル化します。詳しくは触れませんが、Channels は一部の Flow ビルダーと演算子によって内部的に使用されます。独自のビルダーや演算子を作成している場合を除き、こうした低レベルの詳細を気にする必要はありません。

ここでは、ProducerScope が公開する関数をいくつか使用して、データを出力し、Flow の状態を管理します。

まず、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.requestLocationUpdates は非同期関数で(lastLocation と同様)、正常に終了した場合と失敗した場合のシグナリングにコールバックを使用します。

ここでの成功とは、将来のある時点で onLocationResult が呼び出されたときに Flow に結果の出力が始まる、という意味なので無視できます。

失敗の場合は、FlowException ですぐに閉じます。

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 を収集する

MainActivitystartUpdatingLocation 関数を変更して Flow ビルダーを呼び出し、収集を開始します。単純な実装は次のようになります。

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 は suspend 関数であるため、lifecycleScope を使用して起動するコルーチン内で実行する必要があります。

Flow で呼び出される中間演算子 conflate()catch() もあります。コルーチン ライブラリには、宣言を通じてフローのフィルタリングと変換を行える演算子が多く用意されています。

フローの「conflate」は、コレクタで処理できないほど速く更新が出力された場合に、常に最新の更新のみを受け取ることを意味します。UI には常に最新の位置情報のみを表示したいため、今回のケースに適しています。

catch は、その名のとおり、上流(この場合は locationFlow ビルダー)でスローされたすべての例外を処理できるようにします。ここでの「上流」は、現在のオペレーションより前に適用されるオペレーションと考えてください。

では、上のスニペットの問題点は何でしょうか。アプリをクラッシュさせることなく、アクティビティが(lifecycleScope のために)DESTROYED となった後は適切にクリーンアップされますが、アクティビティが停止するタイミング(明示的でない場合)が考慮されていません。

つまり、UI の更新が不要なときでも更新されるだけでなく、Flow が位置情報のサブスクリプションを維持するため、バッテリーと CPU サイクルが無駄に浪費されることになります。

これを修正する 1 つの方法は、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())
        })
}

asLiveData が Flow の実行に必要なスコープを提供するため、明示的な lifecycleScope.launch は不要です。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 による収集を一旦停止してから再開するはずです。

最初の KTX ライブラリが完成しました

これで完了です。この Codelab で実現したことは、既存の Java ベースの API を対象に Kotlin 拡張機能ライブラリを作成する際にもちいられる内容と同様のものです。

学習内容をおさらいしましょう。

  • Activity の権限をチェックする便利な関数を追加しました。
  • Location オブジェクトにテキスト書式設定の拡張機能を用意しました。
  • 直近の位置情報の取得と、Flow を使用した定期的な位置情報の更新を目的とした、Location API のコルーチン バージョンを公開しました。
  • 必要に応じて、コードをさらにクリーンアップし、テストをいくつか追加して、チームの他の開発者が利用できるように location-ktx ライブラリを配布できるようになりました。

配布用の AAR ファイルを作成するには、:myktxlibrary:bundleReleaseAar タスクを実行します。

Kotlin 拡張機能を利用できる他の API についても、同様の手順を適用できます。

Flow を使用してアプリ アーキテクチャを改良する

前にも述べましたが、この Codelab で行ったように Activity からオペレーションを開始することは、必ずしも最善の方法であるとは限りません。こちらの Codelab では、UI で ViewModels からフローを監視する方法、フローと LiveData を相互運用する方法、データ ストリームを使用してアプリを設計する方法について説明しています。