1. Обзор
Цели
В этом практическом занятии вы создадите приложение для iOS на Swift, использующее Firestore для рекомендаций ресторанов. Вы узнаете, как:
- Чтение и запись данных в Firestore из iOS-приложения.
- Отслеживайте изменения в данных Firestore в режиме реального времени.
- Используйте правила аутентификации и безопасности Firebase для защиты данных Firestore.
- Пишите сложные запросы к Firestore.
Предварительные требования
Перед началом выполнения этого практического задания убедитесь, что у вас установлено следующее:
- Версия Xcode 14.0 (или выше)
- CocoaPods 1.12.0 (или выше)
2. Получите пример проекта
Скачать код
Для начала клонируйте пример проекта и выполните команду pod update в каталоге проекта:
git clone https://github.com/firebase/friendlyeats-ios cd friendlyeats-ios pod update
Откройте FriendlyEats.xcworkspace в Xcode и запустите его (Cmd+R). Приложение должно скомпилироваться корректно, но сразу же вылететь при запуске, поскольку отсутствует файл GoogleService-Info.plist . Мы исправим это на следующем шаге.
3. Настройка Firebase
Создайте проект Firebase.
- Войдите в консоль Firebase, используя свою учетную запись Google.
- Нажмите кнопку, чтобы создать новый проект, а затем введите название проекта (например,
FriendlyEats). - Нажмите «Продолжить» .
- Если появится запрос, ознакомьтесь с условиями использования Firebase и примите их, после чего нажмите «Продолжить» .
- (Необязательно) Включите помощь ИИ в консоли Firebase (в Firebase она называется "Gemini").
- Для этого практического занятия вам не понадобится Google Analytics, поэтому отключите эту опцию.
- Нажмите «Создать проект» , дождитесь завершения подготовки проекта, а затем нажмите «Продолжить» .
Подключите ваше приложение к Firebase.
Создайте iOS-приложение в своем новом проекте Firebase.
Загрузите файл GoogleService-Info.plist вашего проекта из консоли Firebase и перетащите его в корневую папку проекта Xcode. Запустите проект еще раз, чтобы убедиться, что приложение правильно настроено и больше не вылетает при запуске. После входа в систему вы должны увидеть пустой экран, как в примере ниже. Если вы не можете войти в систему, убедитесь, что вы включили метод входа по электронной почте/паролю в консоли Firebase в разделе «Аутентификация».

4. Запись данных в Firestore
В этом разделе мы запишем некоторые данные в Firestore, чтобы заполнить пользовательский интерфейс приложения. Это можно сделать вручную через консоль Firebase , но мы сделаем это в самом приложении, чтобы продемонстрировать базовую запись в Firestore.
Основной объект модели в нашем приложении — это ресторан. Данные Firestore разделены на документы, коллекции и подколлекции. Мы будем хранить каждый ресторан как документ в коллекции верхнего уровня под названием restaurants . Если вы хотите узнать больше о модели данных Firestore, прочитайте о документах и коллекциях в документации .
Прежде чем добавлять данные в Firestore, нам необходимо получить ссылку на коллекцию ресторанов. Добавьте следующий код во внутренний цикл for в методе RestaurantsTableViewController.didTapPopulateButton(_:) .
let collection = Firestore.firestore().collection("restaurants")
Теперь, когда у нас есть ссылка на коллекцию, мы можем записать некоторые данные. Добавьте следующий код сразу после последней добавленной нами строки:
let collection = Firestore.firestore().collection("restaurants")
// ====== ADD THIS ======
let restaurant = Restaurant(
name: name,
category: category,
city: city,
price: price,
ratingCount: 0,
averageRating: 0
)
collection.addDocument(data: restaurant.dictionary)
Приведённый выше код добавляет новый документ в коллекцию ресторанов. Данные документа берутся из словаря, который мы получаем из структуры Restaurant.
Мы почти у цели — прежде чем мы сможем записывать документы в Firestore, нам нужно открыть правила безопасности Firestore и описать, какие части нашей базы данных должны быть доступны для записи каким пользователям. Пока что мы разрешим чтение и запись во всю базу данных только авторизованным пользователям. Это немного слишком либерально для приложения, предназначенного для использования в продакшене, но в процессе разработки приложения нам нужно что-то достаточно мягкое, чтобы не сталкиваться постоянно с проблемами аутентификации во время экспериментов. В конце этого практического занятия мы поговорим о том, как усилить правила безопасности и ограничить возможность непреднамеренного чтения и записи.
На вкладке «Правила» в консоли Firebase добавьте следующие правила, а затем нажмите «Опубликовать» .
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /restaurants/{any}/ratings/{rating} {
// Users can only write ratings with their user ID
allow read;
allow write: if request.auth != null
&& request.auth.uid == request.resource.data.userId;
}
match /restaurants/{any} {
// Only authenticated users can read or write data
allow read, write: if request.auth != null;
}
}
}
Правила безопасности мы подробно обсудим позже, но если вы спешите, ознакомьтесь с документацией по правилам безопасности .
Запустите приложение и войдите в систему. Затем нажмите кнопку « Заполнить » в верхнем левом углу, которая создаст пакет документов для ресторана, хотя в приложении это пока не отображается.
Далее перейдите на вкладку «Данные Firestore» в консоли Firebase. Теперь вы должны увидеть новые записи в коллекции «Рестораны»:

Поздравляем, вы только что записали данные в Firestore из iOS-приложения! В следующем разделе вы узнаете, как получить данные из Firestore и отобразить их в приложении.
5. Отображение данных из Firestore
В этом разделе вы узнаете, как получать данные из Firestore и отображать их в приложении. Два ключевых шага — это создание запроса и добавление слушателя снимков. Этот слушатель будет получать уведомления обо всех существующих данных, соответствующих запросу, и получать обновления в режиме реального времени.
Для начала составим запрос, который будет отображать стандартный, нефильтрованный список ресторанов. Взгляните на реализацию метода RestaurantsTableViewController.baseQuery() :
return Firestore.firestore().collection("restaurants").limit(to: 50)
Этот запрос извлекает до 50 ресторанов из коллекции верхнего уровня с именем "restaurants". Теперь, когда у нас есть запрос, нам нужно добавить обработчик снимков, чтобы загружать данные из Firestore в наше приложение. Добавьте следующий код в метод RestaurantsTableViewController.observeQuery() сразу после вызова stopObserving() .
listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
guard let snapshot = snapshot else {
print("Error fetching snapshot results: \(error!)")
return
}
let models = snapshot.documents.map { (document) -> Restaurant in
if let model = Restaurant(dictionary: document.data()) {
return model
} else {
// Don't use fatalError here in a real app.
fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
}
}
self.restaurants = models
self.documents = snapshot.documents
if self.documents.count > 0 {
self.tableView.backgroundView = nil
} else {
self.tableView.backgroundView = self.backgroundView
}
self.tableView.reloadData()
}
Приведённый выше код загружает коллекцию из Firestore и сохраняет её в локальном массиве. Вызов addSnapshotListener(_:) добавляет в запрос обработчик снимков, который будет обновлять контроллер представления каждый раз, когда данные на сервере изменяются. Мы получаем обновления автоматически и нам не нужно вручную отправлять изменения. Помните, что этот обработчик снимков может быть вызван в любое время в результате изменения на стороне сервера, поэтому важно, чтобы наше приложение могло обрабатывать изменения.
После сопоставления наших словарей со структурами (см. Restaurant.swift ) отображение данных сводится к простому присвоению нескольких свойств представления. Добавьте следующие строки в метод RestaurantTableViewCell.populate(restaurant:) в файле RestaurantsTableViewController.swift .
nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)
Этот метод populate вызывается из tableView(_:cellForRowAtIndexPath:) источника данных табличного представления, который отвечает за сопоставление ранее собранной коллекции типов значений с отдельными ячейками табличного представления.
Запустите приложение еще раз и убедитесь, что рестораны, которые мы видели ранее в консоли, теперь отображаются на симуляторе или устройстве. Если вы успешно выполнили этот раздел, ваше приложение теперь считывает и записывает данные в Cloud Firestore!

6. Сортировка и фильтрация данных
В настоящее время наше приложение отображает список ресторанов, но у пользователя нет возможности фильтровать их в соответствии со своими потребностями. В этом разделе вы воспользуетесь расширенными запросами Firestore, чтобы включить фильтрацию.
Вот пример простого запроса для получения списка всех ресторанов, где подают димсам:
let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")
Как следует из названия, метод whereField(_:isEqualTo:) заставит наш запрос загрузить только те элементы коллекции, поля которых соответствуют установленным нами ограничениям. В данном случае он загрузит только рестораны, category которых — "Dim Sum" .
В этом приложении пользователь может объединять несколько фильтров для создания конкретных запросов, например, «Пицца в Сан-Франциско» или «Морепродукты в Лос-Анджелесе, отсортированные по популярности».
Откройте файл RestaurantsTableViewController.swift и добавьте следующий блок кода в середину query(withCategory:city:price:sortBy:) :
if let category = category, !category.isEmpty {
filtered = filtered.whereField("category", isEqualTo: category)
}
if let city = city, !city.isEmpty {
filtered = filtered.whereField("city", isEqualTo: city)
}
if let price = price {
filtered = filtered.whereField("price", isEqualTo: price)
}
if let sortBy = sortBy, !sortBy.isEmpty {
filtered = filtered.order(by: sortBy)
}
Приведённый выше фрагмент кода добавляет несколько условий whereField и order для построения единого составного запроса на основе пользовательского ввода. Теперь наш запрос будет возвращать только те рестораны, которые соответствуют требованиям пользователя.
Запустите свой проект и убедитесь, что вы можете фильтровать по цене, городу и категории (убедитесь, что вы точно вводите названия категории и города). Во время тестирования в журналах могут появиться ошибки, похожие на эти:
Error fetching snapshot results: Error Domain=io.grpc Code=9
"The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..."
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}
Это связано с тем, что Firestore требует наличия индексов для большинства составных запросов. Требование наличия индексов для запросов обеспечивает высокую скорость работы Firestore при масштабировании. Открытие ссылки из сообщения об ошибке автоматически откроет интерфейс создания индексов в консоли Firebase с правильно заполненными параметрами. Чтобы узнать больше об индексах в Firestore, посетите документацию .
7. Запись данных в транзакции
В этом разделе мы добавим возможность для пользователей оставлять отзывы о ресторанах. До сих пор все наши операции записи были атомарными и относительно простыми. Если какая-либо из них выдавалась с ошибкой, мы, скорее всего, просто предлагали пользователю повторить попытку или повторяли её автоматически.
Для добавления рейтинга ресторану необходимо скоординировать несколько операций чтения и записи. Сначала нужно отправить сам отзыв, а затем обновить количество оценок и средний рейтинг ресторана. Если одна из этих операций не удается, а другая нет, мы оказываемся в несогласованном состоянии, когда данные в одной части нашей базы данных не совпадают с данными в другой.
К счастью, Firestore предоставляет функциональность транзакций, которая позволяет нам выполнять множество операций чтения и записи в рамках одной атомарной операции, обеспечивая согласованность наших данных.
Добавьте следующий код ниже всех объявлений let в RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:) .
let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in
// Read data from Firestore inside the transaction, so we don't accidentally
// update using stale client data. Error if we're unable to read here.
let restaurantSnapshot: DocumentSnapshot
do {
try restaurantSnapshot = transaction.getDocument(reference)
} catch let error as NSError {
errorPointer?.pointee = error
return nil
}
// Error if the restaurant data in Firestore has somehow changed or is malformed.
guard let data = restaurantSnapshot.data(),
let restaurant = Restaurant(dictionary: data) else {
let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
])
errorPointer?.pointee = error
return nil
}
// Update the restaurant's rating and rating count and post the new review at the
// same time.
let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
/ Float(restaurant.ratingCount + 1)
transaction.setData(review.dictionary, forDocument: newReviewReference)
transaction.updateData([
"numRatings": restaurant.ratingCount + 1,
"avgRating": newAverage
], forDocument: reference)
return nil
}) { (object, error) in
if let error = error {
print(error)
} else {
// Pop the review controller on success
if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
self.navigationController?.popViewController(animated: true)
}
}
}
Внутри блока обновления все операции, выполняемые с использованием объекта транзакции, будут рассматриваться Firestore как единое атомарное обновление. Если обновление на сервере завершится неудачей, Firestore автоматически повторит попытку несколько раз. Это означает, что наша ошибка, скорее всего, представляет собой повторяющуюся ошибку, например, если устройство полностью отключено или пользователь не авторизован для записи в путь, куда он пытается записать данные.
8. Правила безопасности
Пользователи нашего приложения не должны иметь возможность читать и записывать все данные в нашей базе данных. Например, каждый должен иметь возможность видеть рейтинги ресторана, но оставлять отзывы должен только авторизованный пользователь. Недостаточно просто написать качественный код на стороне клиента, необходимо определить модель безопасности данных на стороне бэкенда, чтобы обеспечить полную защиту. В этом разделе мы узнаем, как использовать правила безопасности Firebase для защиты наших данных.
Для начала давайте подробнее рассмотрим правила безопасности, которые мы написали в начале практического занятия. Откройте консоль Firebase и перейдите в раздел «База данных» > «Правила» на вкладке Firestore .
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /restaurants/{any}/ratings/{rating} {
// Users can only write ratings with their user ID
allow read;
allow write: if request.auth != null
&& request.auth.uid == request.resource.data.userId;
}
match /restaurants/{any} {
// Only authenticated users can read or write data
allow read, write: if request.auth != null;
}
}
}
Переменная request в правилах является глобальной переменной, доступной во всех правилах, а добавленное нами условие гарантирует, что запрос будет аутентифицирован, прежде чем разрешить пользователям что-либо делать. Это предотвращает использование API Firestore неаутентифицированными пользователями для внесения несанкционированных изменений в ваши данные. Это хорошее начало, но мы можем использовать правила Firestore для гораздо более мощных задач.
Мы хотели бы ограничить возможность написания отзывов таким образом, чтобы идентификатор пользователя, оставившего отзыв, совпадал с идентификатором авторизованного пользователя. Это гарантирует, что пользователи не смогут выдавать себя друг за друга и оставлять мошеннические отзывы.
Первое условие соответствия соответствует подколлекции с именем ratings любого документа, принадлежащего коллекции restaurants . Условие allow write предотвращает отправку любого отзыва, если идентификатор пользователя, которому принадлежит отзыв, не совпадает с идентификатором пользователя. Второе условие соответствия позволяет любому авторизованному пользователю читать и записывать информацию о ресторанах в базу данных.
Это отлично подходит для наших отзывов, поскольку мы использовали правила безопасности, чтобы явно указать на неявную гарантию, которую мы заложили в наше приложение ранее, — что пользователи могут писать только свои собственные отзывы. Если бы мы добавили функцию редактирования или удаления отзывов, этот же набор правил также предотвратил бы изменение или удаление отзывов других пользователей. Но правила Firestore можно использовать и более детально, чтобы ограничить запись в отдельные поля внутри документов, а не во весь документ целиком. Мы можем использовать это, чтобы разрешить пользователям обновлять только рейтинг, средний рейтинг и количество оценок для ресторана, исключая возможность изменения названия или местоположения ресторана злоумышленником.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /restaurants/{restaurant} {
match /ratings/{rating} {
allow read: if request.auth != null;
allow write: if request.auth != null
&& request.auth.uid == request.resource.data.userId;
}
allow read: if request.auth != null;
allow create: if request.auth != null;
allow update: if request.auth != null
&& request.resource.data.name == resource.data.name
&& request.resource.data.city == resource.data.city
&& request.resource.data.price == resource.data.price
&& request.resource.data.category == resource.data.category;
}
}
}
Здесь мы разделили права на запись на создание и обновление, чтобы более точно определить, какие операции должны быть разрешены. Любой пользователь может записывать данные о ресторанах в базу данных, сохраняя функциональность кнопки «Заполнить», которую мы создали в начале практического занятия, но после записи название, местоположение, цена и категория ресторана изменить нельзя. В частности, последнее правило требует, чтобы любая операция обновления данных о ресторане сохраняла те же название, город, цену и категорию, что и уже существующие поля в базе данных.
Чтобы узнать больше о возможностях использования правил безопасности, ознакомьтесь с документацией .
9. Заключение
В этом практическом занятии вы изучили базовые и расширенные операции чтения и записи с использованием Firestore, а также способы защиты доступа к данным с помощью правил безопасности. Полное решение вы найдете в ветке codelab-complete .
Чтобы узнать больше о Firestore, посетите следующие ресурсы: