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


Комментарии (3)


  1. zmeytee
    00.00.0000 00:00

    Обратите внимание, что у нас нет вложенных объектов. Firestore не обрабатывает вложенные коллекции автоматически. 

    Но можно же использовать map в Firestore, а в коде это будет объект?


    1. anioutka Автор
      00.00.0000 00:00
      +1

      С учетом того, что мы отслеживаем получение данных в реальном времени, использование nested map не совсем то, чего мы хотим.


  1. kszorin
    00.00.0000 00:00
    +1

    Давно искал такой guide. Спасибо!