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

# 注意書き
これは、完全に自分への忘備録です。

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

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

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

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

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

```json
// 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`型の値を別に取得して、それらをまとめた状態で呼び出し元に返したいというものでした。

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

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

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

# リスナーはEventListener<QuerySnasphot>型

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

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

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

```kotlin
val listener = object : EventListener<QuerySnapshot> {}
```

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

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

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

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

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

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

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

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

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

```kotlin
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()`を呼び出すことで、リスナーの解除を同時に行なっています。

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

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

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

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

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

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

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

```kotlin
@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の詳細については、こちらの記事をぜひご一読ください。

https://qiita.com/takagimeow/items/2271c8a843b8caf92ebe

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

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

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

```kotlin
@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などの機能が実装されていますので、ぜひ今回の記事を参考にしていただきながら、もっとエレガントな実装方法を編み出していただけると幸いです。


# 参考にした記事

https://proandroiddev.com/suspending-firebase-realtime-database-with-kotlin-coroutines-76b4651bc0e8

https://star-zero.medium.com/%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E9%80%9A%E7%9F%A5%E3%82%92flow%E3%81%AB%E5%AF%BE%E5%BF%9C%E3%81%99%E3%82%8B-5ba3760c4264

https://pouhon.net/kotlin-anonymous/3433/

https://qiita.com/kawmra/items/ee4acb7db61f70dec9a8