Всем привет! С вами Анна Жаркова, ведущий мобильный разработчик компании Usetech. Продолжаем вам рассказывать про интересные технологии мобильной разработки и об их эффективном применении в приложениях на практике. Сегодня поговорим про то, как с помощью Firebase (без помощи бэкенд-разработчика), а именно облачных хранилищ Firebase Realtime Database/Firestore и Cloud Storage, создать свой собственный бэкенд для мобильного приложения. В качестве примера напишем приложение-аналог известного сервиса с картинками, фотографиями и постами. UI у нас уже готов, подробнее можно посмотреть в этой статье.
Облачные хранилища дают широкий спектр возможностей при работе с ними. Это и бэкап данных, и удобство доступа к ним с другого устройства, и инструментарий для разработки на его основе простого бэкенда для приложения или сервиса.
Начнем с Firebase Realtime Database и Firestore. Это NoSQL (schemeless) хранилища, поддерживающие данные любой структуры. Обе технологии поддерживают обновление запросов в режиме реального времени, работу в оффлайн (при появлении сети данные автоматически синхронизируется с remote-версией), просто и удобно масштабируются.
Внутри Realtime Database данные хранятся в виде Json, Firestore оперирует коллекциями документов (как MongoDB).
Если сравнивать эти 2 хранилища между собой, то Firestore представляет собой расширенную и улучшенную версию функционала Realtime Database:
- больше возможностей записи и транзакций
- сортировка и фильтрация в едином запросе (меньше запросов, меньше нагрузка)
- запрос конкретного документа или коллекции (снижает трафик)
- возможность настроить правила безопасности на конкретный сегмент без каскада.
В целом, для небольшого приложения или сервиса, где нет особой разницы, можно выбрать любое из этих хранилищ. Мы остановим свой выбор на Firestore.
Итак, UI у нас есть, займемся нашей бизнес-логикой. Для работы нам понадобятся:
- Firebase Authentication для авторизации и регистрации нашего пользователя.
- Storage для хранения изображений.
- Firestore для хранения наших данных и отработку запросов.
Начнем с подключения и настройки. Для этого заходим на https://console.firebase.google.com, нажимаем на Add project и проходим по мастеру создания проекта.
В консоли уже созданного проекта выбираем таргет (Add an app to get started) и переходим к мастеру подключения. На этапе 2 скачиваем конфигурационный файл и кладем туда, куда предлагает инструкция:
На этапе 3 копируем код в build.gradle.kts:
Добавляем нужные нам библиотеки:
implementation 'com.google.firebase:firebase-firestore'
implementation 'com.google.firebase:firebase-auth-ktx'
implementation 'com.google.firebase:firebase-storage-ktx'
Приступим к авторизации с помощью Firebase Authentication. Все операции мы будем проводить через инстанс FirebaseAuth. В нашем решении используется синглтон-хелпер для поддержки работы с Auth.
private val auth: FirebaseAuth by lazy { Firebase.auth }
Через auth мы также можем получить доступ к авторизованному юзеру:
var currentUser: FirebaseUser? = null
get() = auth.currentUser
//Проверяем факт авторизации по юзеру
var isAuthorized: Boolean = false
get() = auth.currentUser != null
Нам потребуется реализовать как авторизацию, так и регистрацию. Начнем с проверки:
fun check(hasAuth: (Boolean) -> Unit) {
auth.addAuthStateListener(object:FirebaseAuth.AuthStateListener{
override fun onAuthStateChanged(fauth: FirebaseAuth) {
hasAuth(fauth.currentUser != null)
}
})
}
Firebase Authentication поддерживает авторизацию пользователя по email и паролю:
suspend fun signInWithEmail(email: String, password: String): Result<FirebaseUser?> {
try {
val response = auth.signInWithEmailAndPassword(email, password).await()
return Result.Success(auth.currentUser)
} catch (e: Exception) {
return Result.Error(e)
}
}
Для выхода просто вызываем signOut:
fun logout() {
auth.signOut()
}
Для регистрации пользователя нам необходимо сначала создать его:
suspend fun createUser(email: String, password: String):Result<AuthResult?> {
return try{
val data = auth
.createUserWithEmailAndPassword(email,password)
.await()
return Result.Success(data)
}catch (e : Exception){
return Result.Error(e)
}
}
Затем с этими же данными проводим авторизацию, изменение профиля пользователя для добавления нужных полей (например, имени):
suspend fun changeProfile(currentUser: FirebaseUser, name: String ) {
val request = userProfileChangeRequest {
displayName = name
}
currentUser.updateProfile(request).await()
}
Для работы с Firestore нам нужно обращаться через инстанс FirebaseFirestore.getInstance(). При необходимости мы можем включить поддержку оффлайн-режима через настройки:
val db = FirebaseFirestore.getInstance()
val settings = firestoreSettings {
isPersistenceEnabled = true
}
db.firestoreSettings = settings
Или изменить размер кэша:
val settings = FirebaseFirestoreSettings.Builder()
.setCacheSizeBytes(FirebaseFirestoreSettings.CACHE_SIZE_UNLIMITED)
.build()
db.firestoreSettings = settings
Переходим к работе с лентой постов. Подготовим модельку данных для поста. Для Firestore необходимо пометить Exclude те поля, которые мы отправлять не будем:
class PostItem{
var uuid: String = UUID.randomUUID().toString()
var imageLink: String = ""
var postText: String = ""
var date:String = ""
var userId: String = ""
var userName: String = ""
var likeItems: ArrayList<LikeItem> = arrayListOf()
var editedTime: String? = null
var editor: String? = null
var timeStamp: Date? = null
@Exclude
@com.google.firebase.firestore.Exclude
var hasLike: Boolean = false
}
Обратите внимание, что у нас нет вложенных объектов. Firestore не обрабатывает вложенные коллекции автоматически. Если вы хотите использовать такое поле в модель, например, для вывода на UI, исключите его с помощью Exclude.
Чтобы отслеживать посты в режиме реального времени, создаем подписку на события. Указываем ту коллекцию, с которой будем работать. Если коллекции еще нет, она будет создана:
private var listener: ListenerRegistration? = null
fun startListenToPosts(result: (List<PostItem>) -> Unit) {
val collection = FirebaseFirestore.getInstance().collection("posts")
listener = collection.orderBy("timeStamp", Query.Direction.DESCENDING)
.addSnapshotListener(MetadataChanges.INCLUDE) { data, firebaseFirestoreExceptioor ->
if (data != null) {
//Десериализуем в нашу модель данных
val posts = data.toObjects(PostItem::class.java)
//Вот тут проверяем лайки
result(checkLiked(posts))
}
}
}
Включим в снепшот коллекции метаданные. Если данные к нам придут, десериализуем в нашу модель данных. Первую порцию данных мы получим сразу после подключения. Если посты изменятся или удалятся, листенер получит автоматически изменения.
Для отписки удаляем листенер:
fun stopListening() {
listener?.remove()
listener = null
}
Чтобы добавить новый документ в коллекцию, просто запрашиваем ссылку на него в коллекции по UUID объекта. Если объекта не было, он создастся автоматически, либо мы получим существующий объект:
//Создание
suspend fun createPost(postItem: PostItem) {
val currentUser = FirebaseAuthHelper.instance.currentUser
postItem.userId = currentUser?.uid.orEmpty()
postItem.userName = currentUser?.displayName.orEmpty()
val collection = FirebaseFirestore.getInstance().collection("posts")
val document = collection.document(postItem.uuid)
document.set(postItem).await()
}
//Редактирование
suspend fun editPost(postItem: PostItem) {
postItem.editor = FirebaseAuthHelper.instance.currentUser?.uid.toString()
val collection = FirebaseFirestore.getInstance().collection("posts")
val document = collection.document(postItem.uuid)
document.set(postItem).await()
}
Меняем в модели данных нужные поля и применяем к документу.
Чтобы удалить документ, также получаем его по id из коллекции и вызываем команду delete:
suspend fun deletePost(id: String) {
val collection = FirebaseFirestore.getInstance().collection("posts")
val document = collection.document(id)
document.delete().await()
}
Пост может содержать изображение, т.е. ссылку на него. Картинки мы будем грузить в Cloud Storage как массив байтов:
val storage = Firebase.storage
suspend fun uploadImage(bytes: ByteArray): Uri? {
//Ссылка на объект
val reference = storage.reference.child("image-${Date()}.jpg")
var uploadTask = reference.putBytes(bytes)
// Ожидаем загрузку
uploadTask.await()
//Обрабатываем конечный url
return reference.downloadUrl.await()
}
Полученный url добавляем к посту и отправляем на синхронизацию.
Каждый пост связан с внутренней коллекцией комментариев. Но чтобы их получить, нам нужна отдельная подписка именно на эту коллекцию: FirebaseFirestore.getInstance().collection("posts").document(postId)
.collection("comments"). Автоматически для поста мы такие данные не получим.
fun startListenToComments(postId: String, result: (List<CommentItem>) -> Unit) {
val collection = FirebaseFirestore.getInstance().collection("posts").document(postId)
.collection("comments")
commentListener = collection.orderBy("date", Query.Direction.DESCENDING)
.addSnapshotListener(MetadataChanges.INCLUDE) { data, firebaseFirestoreExceptioor ->
if (data != null) {
val comments = data.toObjects(CommentItem::class.java)
result(comments)
}
}
}
fun stopCommentListening() {
commentListener?.remove()
commentListener = null
}
Создание комментария абсолютно аналогично созданию поста. Запрашиваем элемент в коллекции по его id и меняем поля:
suspend fun sendComment(commentItem: CommentItem) {
val currentUser = FirebaseAuthHelper.instance.currentUser
commentItem.userId = currentUser?.uid.orEmpty()
commentItem.userName = currentUser?.displayName.orEmpty()
val collection =
FirebaseFirestore.getInstance().collection("posts").document(commentItem.postId)
.collection("comments")
val document = collection.document(commentItem.uuid)
document.set(commentItem).await()
}
Помимо создания комментариев, у нас есть поддержка лайков наших постов (как положено). Мы храним в массиве id тех юзеров, кто его лайкнул. Но т.к лайк можно отменить, или несколько юзеров могут делать это одновременно, то тут нам потребуется использовать транзакции:
fun changeLike(postItem: PostItem, onCompleted: (Result<Boolean>) -> Unit) {
val currentUser = FirebaseAuthHelper.instance.currentUser
val document = FirebaseFirestore.getInstance().collection("posts").document(postItem.uuid)
if (currentUser != null) {
// Подготавливаем здесь контент
FirebaseFirestore.getInstance().runTransaction { transition ->
//Обновляем поля
transition.update(document, "likeItems", newLikes)
newLikes
}.addOnSuccessListener { result ->
onCompleted(Result.Success(true))
}.addOnFailureListener { e ->
onCompleted(Result.Error(e))
}
}
}
Так как транзакции считаются тяжелой операцией, как пакетные, то рекомендуется подготовить данные заранее, т.е. до блока runTransaction.
И последнее, но не по значению, это безопасность наших данных. Для этого настроим специальные правила безопасности нашего хранилища.
Для того, чтобы их прописать, переходим на вкладку Rules.
По умолчанию у любого пользователя полный доступ к данным и их изменению:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write
}
}
}
Изменим их, чтобы доступ был только у авторизованных пользователей. После внесения новых правил, нужно будет нажать Develop&Test и подождать некоторое время:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth.uid != null;
}
match /users/{user} {
allow read, write: if (signedIn() == true);
}
match /posts/{post} {
allow read, write: if (signedIn() == true);
}
match /comments/{comment} {
allow read, write: if (signedIn() == true);
}
}
}
Теперь дадим доступ на редактирование только автору. При этом не забудем, что лайкать и комментировать могут все авторизованные юзеры:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth.uid != null;
}
match /users/{user} {
allow read, write: if (signedIn() == true);
}
match /posts/{post} {
allow read: if (signedIn() == true);
allow write: if (signedIn() == true);
allow update: if ((request.auth != null && request.auth.uid == request.resource.data.userId) ||
request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['likeItems', "comments"]));
}
match /posts/{post}/comments/{comment} {
allow read, write: if (signedIn() == true);
}
match /comments/{comment} {
allow read, write: if (signedIn() == true);
}
}
}
Готово, мы великолепны. Теперь наше приложение с лентой постов функционирует, как нужно.
Ссылка на исходники:
https://github.com/anioutkazharkova/android-firestore_realtime/
Полезные статьи:
https://firebase.google.com/docs/firestore
https://firebase.google.com/docs/auth
zmeytee
Но можно же использовать map в Firestore, а в коде это будет объект?
anioutka Автор
С учетом того, что мы отслеживаем получение данных в реальном времени, использование nested map не совсем то, чего мы хотим.