2
4

More than 1 year has passed since last update.

【Android】suspendじゃないコールバックの中で、callbackFlowを活用してsuspendな関数を呼び出せるようにする方法

Posted at

注意書き

これは、完全に自分への忘備録です。

アプリを開発していて、非同期関数を使いたいのに使えない状況に遭遇したので、そのときのことをまとめました。

荒削りな内容になっていますが、あらかじめご了承ください。

suspendじゃない関数の中で、suspend関数を使うにはどうしたら良いのだろう?

現在開発中のプロジェクトでは、Firebaseを使っています。
その中で、あるコレクションに存在するドキュメントにいかなる変更が発生した場合に、アプリ内でその情報を取得したい状況に陥りました。

具体的にいうと、そのドキュメントには、DocumentReference型のフィールドが存在しています。

// membersドキュメント
{
    "name": "Taro Tanaka", (文字列)
    "groupRef": "users/user-id-1/groups/group-id-1", (DocumentReference),
    "createdAt": 2022-01-03 23:55:43 UTC+9, (タイムスタンプ) 
}

toObject()を使ってデシリアライズを行うときに、そのDocumentReference型の値を別に取得して、それらをまとめた状態で呼び出し元に返したいというものでした。

data class Member(
    val name: String = "",
    val group: Group? = null,
    val createdAt: Date = Date(),
)

以上を踏まえて、CollectionReferenceaddSnapshotListener()を呼び出す際に渡すコールバックの中で、DocumentReferenceフィールドの値を使って新たにドキュメントを取得したい場合はどうすれば良いのでしょうか?

val collectionReference = firestore.collection("users")
    .document(userId)
    .collection("members")
collectionReference.addSnapshotListener() { querySnapshot, error ->
    // この中で、querySnapshotから取得したDocumentReference型の値を使って
    // 他のドキュメントを取得したい
}

リスナーはEventListener型

addSnapshotListener()に渡すコールバックは、EventListener<QuerySnapshot>型の値です。ラムダで渡せてしまうため、てっきり次のような型の関数を渡すかと思ってしまうでしょう。

fun listener(querySnapshot: QuerySnapshot?, error: FirebaseFirestoreException?): Unit {}

しかし、本当に渡さないといけないのはEventListener<QuerySnapshot>インターフェースを継承したクラスです。つまり、単にSAM変換が行われているに過ぎないので、次のような無名クラスでも問題なく渡すことができます。

val listener = object : EventListener<QuerySnapshot> {}

callbackFlowを使って非同期データストリームを構築する

問題は、コールバック内で非同期関数であるsuspend関数を使いたいということです。

非同期関数を呼び出すためには、スコープが必要です。
そしてそのスコープを取得するためにlaunchを呼び出したいのでFlowを構築します。

Flowを構築する方法としてはいくつかあるのですが、今回はcallbackFlowを使用して構築していこうと思います。

例えば、membersコレクションのスナップショットの変更を検知したい場合は、次のようにcallbackFlowを使ってメソッドを実装します。

fun membersEventFlow(
    dispatcher: CoroutineDispatcher,
): Flow<List<Member>> = callbackFlow {
    // 具体的な処理を行なっていく
}

callbackFlowで取得したスコープを使ってlaunchを呼び出して、非同期関数を呼び出すためにコルーチンを作成しよう

それでは、実際にcallbackFlowを使ってメソッドを定義していこうと思います。

具体的には次のような内容になっています。

callbackFlow {
    val listener = object : EventListener<QuerySnapshot> {
        override fun onEvent(value: QuerySnapshot?, error: FirebaseFirestoreException?) {
            val memberDocuments = value!!.documents.map { documentSnapshot -> documentSnapshot
                .toObject<MemberDocument>()?
                .copy(
                    id = documentSnapshot.id,
                )
            }
            this@callbackFlow.launch {
                withContext(dispatcher) {
                    val members = memberDocuments.map { memberDocument ->
                        // 取得したgroupRefを使ってドキュメントを取得する
                        // このgetRoup()はsuspend関数
                        val groupDocument = service.getGroup(member!!.groupRef!!)
                        val group = Group(...)
                        val member: Member = Member(
                            ...,
                            group = group,
                        )
                    }
                    trySendBlocking(member)
                }
            }
        }
    }
    collectionReference = firestore
        .collection("users")
        .document(userId)
        .collection("members")
    val registration = collectionReference.addSnapshotListener(listener)
    
    awaitClose {
        registration.remove()
    }
}

this@callbackFlowを使ってProducerScopeを取得して、launchメソッドを呼び出しています。
これにより、コルーチンを作成することができたので、内部で非同期関数を呼び出すことができるようになっています。

getGroup()の詳細については割愛させていただきますが、このメソッドこそが今回の主役である非同期関数となっています。この記事は、まさにこのようなメソッドをコールバックで呼び出すための方法を解説した記事になっています。

trySendBlocking()を呼び出すことで、collect()を呼び出した側に、ここで生成した値を送信しています。

登録したリスナーを解除するには?

お気づきの方もいらっしゃると思いますが、callbackFlowの最後の箇所で、awaitClose()を呼び出しています。
実は、このawaitClose()の呼び出しがないとcallbackFlowに渡したラムダは即終了してしまいます。そのため、ラムダの終了を防ぐために、このFlowがクローズするまで処理をブロックするためにこのメソッドを呼び出しています。

そして、awaitClose()に渡したラムダの中でFlowの終了時の処理を記述することができます。

このラムダ内でListenerRegistrationに対してremove()を呼び出すことで、リスナーの解除を同時に行なっています。

 val registration = collectionReference.addSnapshotListener(listener)
    
awaitClose {
    registration.remove()
}

trySendBlocking()によって送信されたデータを受け取るにはどうしたら良いの?

callbackFlowのラムダの中で、trySendBlocking()を呼び出して受信側に生成されたデータを送信しています。

Androidアプリを開発している方なら、おそらくViewModelを使ってUI Stateの内容をStateFlowを使って実装していることが多いのではないでしょうか?

そのため、今回はViewModel側でデータを受信する方法を解説します。

前提として、先ほどのメソッドはリポジトリに実装されていることとします。
そして、そのリポジトリはHiltによってDIされているものとします。詳細については割愛させていただきます。

以上を踏まえたコードは次のようになります。

@HiltViewModel
class MembersViewModel @Inject constructor(
    private val memberRepository: MemberRepository,
) : ViewModel()
    ...
    val membersEvent = memberRepository
            .membersEventFlow(dispatcher = Dispatchers.IO)
            .stateIn(
                viewModelScope,
                SharingStarted.Eagerly,
                emptyList()
            )
    ...
}

stateIn()を呼び出すことで、FlowをStateFlowに変換しています。

StateFlowの詳細については、こちらの記事をぜひご一読ください。

stateIn()を呼び出してmemberEvent変数を定義した段階では、まだ送られてきたデータの取得を行うことはできません。

実際にデータを受信するためには、このStateFlowに対してcollect()、またはcollectAsState()を呼び出してあげる必要があります。

例えば、コンポーザブルでデータを取得する場合は次のようになります。

@Composable
fun MembersRoute(
    viewModel: MemberListViewModel = hiltViewModel(),
) {
    val membersEvent = viewModel.membersEvent.collectAsState()

    LaunchedEffect(key1 = membersEvent.value) {
        // membersEvent.valueにはtrySendBlocking()によって
        // 送信された値が格納されているので、実際にはそれぞれのユースケースに合わせて処理を行う。
    }
    ...
}

collectAsState()を呼び出すことで、Stateが返されます。このStateのvalueプロパティには、trySendBlocking()によって送信された値が格納されていますので、コンポーザブルで表示したりすることができるようになります。

LaunchedEffect()使用時の注意点

LaunchedEffect()key1に指定する値としてStateそのものを渡すのではなく、Stateインスタンス.valueのようにvalueプロパティの内容を渡すことに注目してください。

Stateを渡しただけでは、LaunchedEffect()で値の変更を検知できないため、データの送信は成功しているものの一向にコンポーザブルで表示するデータが更新されないといったバグを生み出しかねません。

そのため、もしデータが更新されないなと思ったら、key1に渡した値を確認してみることをオススメします。

まとめ

冒頭にも述べたように、この記事は完全に自分のための忘備録となっています。
データの更新と同時にアプリ側でそれを検知して、表示する内容を更新したい場合にはスナップショットの変更の監視が欠かせません。

そのためにはコールバックを使わないといけないのですが、今回のような状況によってはコールバックだと幾分か不便なケースが存在してしまいます。

そういった場合のために、KotlinにはFlowなどの機能が実装されていますので、ぜひ今回の記事を参考にしていただきながら、もっとエレガントな実装方法を編み出していただけると幸いです。

参考にした記事

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4