この記事の内容
kotlin coroutineを使用して、Firestoreのデータ追加とデータ読み取り、コールバックでのデータ読み取りをします。
前提
FirebaseAuthでのログイン・ログアウトが実装されている前提です。
Firestoreの構成
Firestoreの構成は以下の階層構造になっています。
users - collection
┣ {auth.user1.uid} - document
┃ ┗ spot_list - collection
┃ ┣ spotデータ1 - document
┃ ┗ spotデータ2 - document
┗ {auth.user2.uid} - document
┗ spot_list - collection
┗ spotデータ3 - document
Firestoreは、先頭にCollectionというDocumentを複数格納するコンテナがあります。
Documentは、SubCollectionもしくは単純な文字列とか数値とかを格納できます。
記事にするFirestoreの構成は、先頭を「users」というCollectionにしました。このusersという名前は、Firestoreを設計する人が自由に決めて良い名前です。
usersには、FirebaseAuthのuser.uidをドキュメントIDにもつDocument(以降ユーザドキュメントと呼びます)が複数入ります。
ユーザドキュメントには、「spot_list」というSubCollectionを格納します。
spot_listには、spotデータを格納するドキュメントを複数格納する、こんな構成にします。
実装
dependencies
依存関係を追加します。
// Firebase
implementation(platform("com.google.firebase:firebase-bom:32.2.0"))
implementation("com.google.firebase:firebase-analytics-ktx")
implementation("com.google.firebase:firebase-auth-ktx")
+ implementation("com.google.firebase:firebase-firestore-ktx")
DI
Firestoreのインスタンスを取得するProviderを追加します。
Firestoreのインスタンスは、「Firebase.firestore」で取得できます。
@Module
@InstallIn(SingletonComponent::class)
object FirebaseModule {
@Provides
fun provideAuth(): FirebaseAuth = Firebase.auth.apply { setLanguageCode("ja") }
+ @Provides
+ fun provideFirestore(): FirebaseFirestore = Firebase.firestore
インフラ層
Spotデータの追加、Spotデータ一覧の取得、Spotデータの変更コールバック
の3つを実装します。
Firestoreでは、CollectionやDocumentのReference(参照)に対して、スナップショットを要求するという感じで実装していきます。
spot_listコレクションのリファレンス取得
private val spotListRef: CollectionReference?
get() = auth.currentUser?.let { user ->
firestore
.collection(COLLECTION_NAME_USERS)
.document(user.uid)
.collection(COLLECTION_NAME_SPOT_LIST)
}
Firebase.authでログイン中のユーザがいれば、そのユーザのspot_listコレクションに対するReferenceを返却する
という実装です。
このCollectionReferenceに対して、spotドキュメントを追加する、spotドキュメントの一覧を取得する、spot_listコレクションの変更を監視するという実装を行っていきます。
Spotデータの追加(=spotドキュメントを追加する)
override fun add(spot: Spot): Flow<SpotFuture<Spot>> {
val spotListRef = this.spotListRef ?: return flowOf(SpotFuture.Error(Throwable("user unauthorized")))
return flow<SpotFuture<Spot>> {
val docRef = spotListRef
.add(FirestoreSpot.fromSpot(spot))
.addOnFailureListener {
throw it
}
.await()
Timber.d("<SpotRepository>add ${docRef.id}")
emit(SpotFuture.Success(spot.copy(id = docRef.id)))
}.onStart {
emit(SpotFuture.Proceeding)
}.catch { cause ->
emit(SpotFuture.Error(cause))
}.flowOn(coroutineContext)
}
FirestoreSpotがSpotデータです。
spotListRefに対して、add(FirestoreSpot)して、データの追加を行っています。
addで追加することで、新しいドキュメントIDが自動発番されます。
Spotデータ一覧の取得(=spotドキュメントの一覧を取得する)
override fun getAll(): Flow<SpotFuture<List<Spot>>> {
val spotListRef = this.spotListRef ?: return flowOf(SpotFuture.Error(Throwable("user unauthorized")))
return spotListRef
.snapshots() // ①
.map { querySnapshot: QuerySnapshot ->
// ②
val firestoreSpotList = querySnapshot.documents.map { documentSnapshot ->
documentSnapshot.toObject<FirestoreSpot>()
}
// ③
@Suppress("USELESS_CAST")
SpotFuture.Success(
firestoreSpotList.mapNotNull {
it?.toDomain()
}
) as SpotFuture<List<Spot>>
}
.catch { cause ->
emit(SpotFuture.Error(cause))
}
.flowOn(coroutineContext)
}
①spotListRefのsnapshotを取得します。
②snapshotからドキュメント一覧を取得します。
③結果を返却します。
Spotデータの変更コールバック(=spot_listコレクションの変更を監視する)
override val spotListFlow: Flow<List<SpotChange>>
get() = callbackFlow {
val listener: EventListener<QuerySnapshot> = EventListener<QuerySnapshot> { snapshotQuery, error ->
if (error != null) {
Timber.e("FirestoreSnapshotListener(Spot) ${error.localizedMessage}")
} else {
try {
snapshotQuery?.documentChanges?.mapNotNull { documentChange ->
val firestoreSpot = documentChange.document.toObject<FirestoreSpot>()
val spot = firestoreSpot.toDomain() ?: return@mapNotNull null
val type = when (documentChange.type) {
DocumentChange.Type.ADDED -> SpotChange.Type.ADDED
DocumentChange.Type.MODIFIED -> SpotChange.Type.MODIFIED
DocumentChange.Type.REMOVED -> SpotChange.Type.REMOVED
}
SpotChange(type, spot)
}?.let { trySend(it) }
} catch (error: Exception) {
Timber.e("FirestoreSnapshotListener(Spot) ${error.localizedMessage}")
}
}
}
val spotListRef = this@SpotRepositoryImpl.spotListRef ?: return@callbackFlow
val registrationListener = spotListRef.addSnapshotListener(listener)
awaitClose { registrationListener.remove() }
}
一番下の行と下から2行目の行で、監視リスナーの登録と削除を行っています。
監視リスナーには、変更時点のスナップショットが通知されます。
スナップショットから、変更のあったドキュメント(snapshotQuery?.documentChanges)を取得して、変更をコールバックFlowで通知しています。
Usecase
データを追加する
データ追加を一回だけ呼び出すようにしています。
class SpotAddCase @Inject constructor(
private val spotRepository: SpotRepository,
) {
suspend fun add(): SpotFuture<Spot> {
return spotRepository.add(
Spot.newSpot(
name = "name",
location = LatLng(35.6809591, 139.7673068),
address = "address",
tel = "tel",
url = "url",
imageUrl = "imageUrl",
memo = listOf("memo"),
)
).first()
}
}
データを取得する
初期データとして、一回だけデータの取得を呼び出しています。
class SpotGetCase @Inject constructor(
private val spotRepository: SpotRepository,
) {
suspend fun getInitialData(): SpotFuture<List<Spot>> {
return spotRepository.getAll().first()
}
}
変更を監視する
変更の監視Flowを返す関数と、その結果をViewModelが保持している一覧とマージする関数の2つを実装しました。
class SpotWatchCase @Inject constructor(
private val spotRepository: SpotRepository,
) {
fun addListener(): Flow<List<SpotChange>> {
return spotRepository.spotListFlow
}
fun merge(currentList: List<Spot>, changeList: List<SpotChange>): List<Spot> {
val list = currentList.toMutableList()
val modList = changeList.filter { it.type == SpotChange.Type.ADDED || it.type == SpotChange.Type.MODIFIED }
modList.forEach { spotChange ->
val index = list.indexOfFirst { it.id == spotChange.spot.id }
if (index < 0) {
list.add(spotChange.spot)
} else {
list[index] = spotChange.spot
}
}
val removeList = changeList.filter { it.type == SpotChange.Type.REMOVED }
removeList.forEach { spotChange ->
val index = list.indexOfFirst { it.id == spotChange.spot.id }
if (index >= 0) {
list.removeAt(index)
}
}
return list
}
}
ViewModel
まぁ...呼んでいるだけですね。
@HiltViewModel
class SpotListViewModel @Inject constructor(
private val spotGetCase: SpotGetCase,
private val spotWatchCase: SpotWatchCase,
private val spotAddCase: SpotAddCase,
) : ViewModel() {
private val _spotListFlow: MutableStateFlow<List<Spot>> = MutableStateFlow(emptyList())
val spotListFlow = _spotListFlow.asStateFlow()
fun refreshSpotList() {
viewModelScope.launch {
val spotListFuture = spotGetCase.getInitialData()
if (spotListFuture is SpotFuture.Success) {
_spotListFlow.value = spotListFuture.value
} else {
// TODO エラー処理
}
spotWatchCase.addListener().collect { spotChanges ->
_spotListFlow.value = spotWatchCase.merge(_spotListFlow.value, spotChanges)
}
}
}
fun add() {
viewModelScope.launch {
spotAddCase.add()
}
}
}
Screen
addをタップすると、Spotデータが追加され、その結果をコールバックで受け取り、LazyColumnの要素として表示しています。
@Composable
fun SpotListScreen(
viewModel: SpotListViewModel = hiltViewModel(),
) {
val spotListState = viewModel.spotListFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.refreshSpotList()
}
Column {
Button(onClick = { viewModel.add() }) {
Text("add")
}
LazyColumn {
items(spotListState.value) {
Text(it.id ?: "")
}
}
}
}
いざ実行!
全部のソースはこちらです。
FirebaseAuthの実装とかいろいろ入ってしまっていてすみません...