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
実行構成を選択します。
実行ボタン を押して、アプリをテストします。
このアプリはまだ何もしません。データを表示できる部分がないからです。足りない部分は後続のステップで追加します。
権限を簡単にチェックする方法
アプリを実行しても、現在位置を取得できないというエラーが表示されるだけです。
これは、実行時の位置情報の利用許可をユーザーにリクエストするコードがないためです。
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)
}
これで、アプリを実行すると画面に位置情報が表示されるようになりました。
位置情報テキストを書式設定するためのヘルパー
この位置情報テキストは、あまり良くありません。UI で表示するようには設計されていないデフォルトの Location.toString
メソッドを使用しています。
myktxlibrary
で LocationUtils.kt
クラスを開きます。このファイルには、Location
クラスの拡張機能が含まれています。Location.format
拡張関数を完成させて書式設定された String
を返し、ActivityUtils.kt
の Activity.showLocation
を変更して拡張機能を使用します。
問題が発生した場合は、step-03
フォルダ内のコードを確認してください。最終的な結果は次のようになります。
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.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
が終了してもリスナーは削除されないため、メモリリークとオペレーションの問題が生じます。
これを解決するために、コルーチンを使用する方法について見ていきます。呼び出し元スレッドでブロッキング呼び出しを行わない、トップダウンの通常の命令型コードブロックのような非同期コードを記述できます。さらに、コルーチンはキャンセル可能でもあるため、スコープ外となった場合はいつでもクリーンアップできます。
次のステップでは、拡張関数を追加し、既存のコールバック 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
}
}
これは、コールバックを呼び出す関数が先に終了してしまうので、後からコールバックが行われても結果を返すところがないためです。このような場合には、コードブロックに提供されている suspendCancellableCoroutine
と continuation
を使用すれば、中断された関数に 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 関数を呼び出す
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)
}
}
上述のように、suspend 関数はコルーチン内で実行されるように常に他の suspend 関数から呼び出される必要があります。つまり、getLastKnownLocation
関数自体に suspend 修飾子を追加する必要があります。そうしないと 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()
関数について説明します。現在のコードでは、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
に結果の出力が始まる、という意味なので無視できます。
失敗の場合は、Flow
を Exception
ですぐに閉じます。
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 を収集する
MainActivity
の startUpdatingLocation
関数を変更して 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
を相互運用する方法、データ ストリームを使用してアプリを設計する方法について説明しています。