Remote бэкенд на Firebase для МП без бэкенда

от автора

image

Всем привет! С вами Анна Жаркова, ведущий мобильный разработчик компании 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 у нас есть, займемся нашей бизнес-логикой. Для работы нам понадобятся:

  1. Firebase Authentication для авторизации и регистрации нашего пользователя.
  2. Storage для хранения изображений.
  3. Firestore для хранения наших данных и отработку запросов.

Начнем с подключения и настройки. Для этого заходим на https://console.firebase.google.com, нажимаем на Add project и проходим по мастеру создания проекта.

В консоли уже созданного проекта выбираем таргет (Add an app to get started) и переходим к мастеру подключения. На этапе 2 скачиваем конфигурационный файл и кладем туда, куда предлагает инструкция:
image

На этапе 3 копируем код в build.gradle.kts:
image

Добавляем нужные нам библиотеки:

    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.

image

По умолчанию у любого пользователя полный доступ к данным и их изменению:

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


ссылка на оригинал статьи https://habr.com/ru/company/usetech/blog/719102/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *