
Firebaseは、アプリを素早く開発しデプロイできるようにするための「Backend as a Service(BaaS)」プラットホームです。Firebaseは多くの機能を提供しています。リアルタイムデータベース、ユーザー認証(Eメールとパスワード、Facebook、Twitter、GitHub、Googleアカウントを使用できる)、クラウドメッセージング、ストレージ、ホスティング、リモートコンフィギュレーション、Test Lab、クラッシュレポート、通知、アプリのインデックス付け、ダイナミックリンク、招待、AdWordsとAdMobなどが含まれています。
この記事ではシンプルなToDoアプリを作成しながら、Firebaseへのデータの保存/呼び出し、ユーザーの認証、読み書きの権限設定、サーバー側のデータバリデーションの方法を説明します。
はじめに
最初にリポジトリをダウンロードします。記事で使うプロジェクトも含まれています。次にFirebaseを開き、アカウントが無ければアカウントを作ります。
ダウンロードしたプロジェクトを実行するとログイン画面が表示されます。
ログイン画面にはRegister(新規登録)ボタンがあり、タップするとサインアップ画面になります。この画面にはサインアップ用のフォームと、ログイン画面に戻るボタンがあります。
プロジェクトにFirebaseライブラリーを加えるにはCocoaPodsを使います。コンピューターにCocoaPodsをインストールしてください。
ターミナルを開き、cd Path/To/ToDo\ Appコマンド(\記号は、フォルダー名における空白スペースのエスケープシーケンスです)で、ダウンロードしたプロジェクトのルートへアクセスします。
以下のコマンドでプロファイルを作ります。
pod init
次にプロファイルを開きます。
open -a Xcode Podfile
以下のように、ファイルコンテンツを変更します。
platform :ios, '10.0'
target 'ToDo App' do
use_frameworks!
pod 'Firebase/Auth'
pod 'Firebase/Database'
end
Firebaseはサブスペックから成り立っています。上ではユーザー認証に使う/Authサブスペックと、Firebaseリアルタイムデータベースで必要な/Databaseサブスペックを取り込みました。Firebaseでアプリを作るには/Coreサブスペックが必須ですが、すでに加えたサブスペックが/Coreに依存しているので、プロファイル上で/Coreを含める必要はありません。つまり、pod installコマンドでプロジェクトの依存オブジェクトを取得する際、同時に/Coreライブラリーも入手していることになります。
pod installを実行してプロジェクトの依存オブジェクトを取得します。インストールが完了したら、Xcodeのプロジェクトを閉じてToDo App.xcworkspaceを開いてください。
次にFirebaseコンソールを開きCreate a New Project(新規作成)ボタンをクリックします。プロジェクトの詳細を入力するダイアログが開きます。プロジェクトの名称と国/地域名を入力してください。
入力する国/地域名は、自分の所属組織のある場所です。ここでの選択が、収支レポートの通貨にも反映されます。名称(記事ではToDoApp)と国名の設定ができたらCreate Project(作成)ボタンをクリックします。プロジェクトが作成されてコンソールが開きます。プロジェクトのコンソールのiOS AppのオプションでAdd Firebaseをクリックして表示されたウィンドウに、iOSプロジェクトのデータを記述します。
この時点ではApp Store ID欄は空欄のままにしますが、もしApp Storeに掲載したアプリでFirebaseを使いたいなら、アプリのURLを見てIDを入力します。たとえば、アプリのURLが「https://itunes.apple.com/gb/app/yourapp/id123456789」であれば、App Store IDは「123456789」です。
Add AppボタンをクリックするとGoogleService-Info.plistファイルがダウンロードされます。ダウンロードしたファイルをXcodeプロジェクトのルートに移動して、すべてのターゲットに加えます。
plistファイル(プロパティリスト)には、iOSアプリがFirebaseのサービスとやりとりするのに必要な設定があります。設定にはFirebaseプロジェクトのURLやAPIのキーなどが含まれています。以前のバージョンのFirebaseでは、すべて自分でコードを書く必要がありましたが、現在は必要なデータをまとめたファイル1つで済むように簡略化されました。
もしも公開リポジトリ上にコードを置いてバージョン管理や編集をしているなら、GoogleService-Info.plistファイルへのアクセスは許可しないようにします。制限を超えて使用すると動かなくなりますし、有料プランで使うならば他人が勝手に触ってコストがかさんでしまうのを避けたいからです。
FirebaseのコンソールでContinueを押し、プロジェクトのセットアップを進めます。
すでに上のステップは済ませているのでContinueをクリックします。
最後のダイアログに表示された指示に従い、Firebaseの初期化コードをAppDelegateクラスに加えます。これで、アプリが起動したらFirebaseに接続します。
次にAppDelegate.swiftで、以下のようにインポートします。
import Firebase
続いてapplication(_:didFinishLaunchingWithOptions:)の、returnの前に、以下のコードを加えます。
FIRApp.configure()
FirebaseのコンソールでFinishをクリックし、プロジェクトのセットアップを終えます。
セキュリティとルール
Firebaseサーバーにデータを保存したり取得したりする前に、認証とルールを作成してアクセスを制限するとともに、保存前にユーザーから入力された値をチェックできるようにします。
ユーザー認証
Firebase APIでは、Eメールとパスワード、Facebook、Twitter、GitHub、Googleのアカウント、匿名認証によるユーザー認証が使えます。今回のアプリではEメールとパスワードによる認証を使います。
Eメールとパスワードによる認証を使うにはFirebaseのコンソール左のパネルからAuthenticationを選択し、Sign-In Methodタブに移動します。Email/Passwordの認証プロバイダを有効にしたら、Saveをクリックします。
Xcodeに戻り、次のようにLoginViewController.swiftを変更してください。
import UIKit
import FirebaseAuth
class LoginViewController: UIViewController {
@IBOutlet weak var emailField: UITextField!
@IBOutlet weak var passwordField: UITextField!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let _ = FIRAuth.auth()?.currentUser {
self.signIn()
}
}
@IBAction func didTapSignIn(_ sender: UIButton) {
let email = emailField.text
let password = passwordField.text
FIRAuth.auth()?.signIn(withEmail: email!, password: password!, completion: { (user, error) in
guard let _ = user else {
if let error = error {
if let errCode = FIRAuthErrorCode(rawValue: error._code) {
switch errCode {
case .errorCodeUserNotFound:
self.showAlert("User account not found. Try registering")
case .errorCodeWrongPassword:
self.showAlert("Incorrect username/password combination")
default:
self.showAlert("Error: \(error.localizedDescription)")
}
}
return
}
assertionFailure("user and error are nil")
}
self.signIn()
})
}
@IBAction func didRequestPasswordReset(_ sender: UIButton) {
let prompt = UIAlertController(title: "To Do App", message: "Email:", preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default) { (action) in
let userInput = prompt.textFields![0].text
if (userInput!.isEmpty) {
return
}
FIRAuth.auth()?.sendPasswordReset(withEmail: userInput!, completion: { (error) in
if let error = error {
if let errCode = FIRAuthErrorCode(rawValue: error._code) {
switch errCode {
case .errorCodeUserNotFound:
DispatchQueue.main.async {
self.showAlert("User account not found. Try registering")
}
default:
DispatchQueue.main.async {
self.showAlert("Error: \(error.localizedDescription)")
}
}
}
return
} else {
DispatchQueue.main.async {
self.showAlert("You'll receive an email shortly to reset your password.")
}
}
})
}
prompt.addTextField(configurationHandler: nil)
prompt.addAction(okAction)
present(prompt, animated: true, completion: nil)
}
func showAlert(_ message: String) {
let alertController = UIAlertController(title: "To Do App", message: message, preferredStyle: UIAlertControllerStyle.alert)
alertController.addAction(UIAlertAction(title: "Dismiss", style: UIAlertActionStyle.default,handler: nil))
self.present(alertController, animated: true, completion: nil)
}
func signIn() {
performSegue(withIdentifier: "SignInFromLogin", sender: nil)
}
}
上のコードでは、最初にviewDidAppear()でサインしているか確認します。もし、サインインしているユーザがいればFIRAuth.auth()?.currentUserで認証済みユーザーにします。FIRUserオブジェクトがユーザーに該当します。もしサインインしていたらsignIn()メソッドを実行し、storyboardで作成しておいたsegueを実行します。このsegueで、今回のToDoアプリで項目をリスト表示する画面ItemsTableViewControllerへ遷移します。
ユーザーの入力は認証のためdidTapSignIn()メソッドでFirebaseに送られます。そしてFIRAuth.auth()?.signIn(withEmail: password: completion:)で、Eメールとパスワードの組み合わせによってユーザーはサインインできます。各認証プロバイダ(例:Google、Facebook、Twitter、GitHubほか)によってメソッドは異なります。
ユーザーが無事に認証されたらsignIn()が呼ばれ、失敗した場合はエラーメッセージが表示されます。認証に失敗したらサーバーからNSErrorオブジェクトが返ります。このオブジェクトにはエラーコードが入っているので、エラーの原因を把握してユーザーに適切なメッセージを表示します。上のコードでは、存在しないアカウントが入力されたときとパスワードが間違っていたときでは異なるメッセージを表示するようにしました。そのほかのエラーはerror.localizedDescriptionで詳細を表示します。しかし、実際のアプリでユーザーにはこのようなメッセージは見せません。error.localizedDescriptionで取得するエラー詳細は開発者のデバッグ用なので、ユーザー向けにはもっと一般的な表現のエラーメッセージを表示します。
Forgot Password(パスワード紛失)ボタンが押されたときにはdidRequestPasswordReset()が呼び出され、ユーザーがパスワードリセットのために必要なEメールアドレスを入力するアラートボックスを作成します。ここがFirebaseのすごいところで、パスワードのリセット機能もすでに用意されているのです。わざわざコードを書く必要はありません。
FirebaseのパネルのAuthentication > Email templatesで、メールアドレスの確認や、パスワードリセットの際にユーザーに送信するEメールの文面を変更できます。
注意して欲しいのは、上のコードではDispatchQueue.main.asyncによってメインスレッドでアラートが呼ばれます。コールバック関数を扱うバックグラウンドのスレッドではありません。アプリのUIを更新するコードはすべてメインキューで実行されます。
パスワードリセット機能をテストするなら、FirebaseのコンソールのAuthentication > Users > Add Userから、ユーザーを作成します。そして作成したユーザー用のEメールとパスワードを入力してください(パスワードリセットのEメールを受け取れる本物のアドレスを使用します)。
Xcodeに戻り、プロジェクトを実行してください。メールアドレスを入力してパスワードリセットのEメールを送れるはずです。
ログイン機能が完成したら、サインアップ(新規登録)機能を作ります。SignUpViewControllerを以下のように変更します。
import UIKit
import FirebaseAuth
class SignUpViewController: UIViewController {
@IBOutlet weak var emailField: UITextField!
@IBOutlet weak var passwordField: UITextField!
@IBAction func didTapSignUp(_ sender: UIButton) {
let email = emailField.text
let password = passwordField.text
FIRAuth.auth()?.createUser(withEmail: email!, password: password!, completion: { (user, error) in
if let error = error {
if let errCode = FIRAuthErrorCode(rawValue: error._code) {
switch errCode {
case .errorCodeInvalidEmail:
self.showAlert("Enter a valid email.")
case .errorCodeEmailAlreadyInUse:
self.showAlert("Email already in use.")
default:
self.showAlert("Error: \(error.localizedDescription)")
}
}
return
}
self.signIn()
})
}
@IBAction func didTapBackToLogin(_ sender: UIButton) {
self.dismiss(animated: true, completion: {})
}
func showAlert(_ message: String) {
let alertController = UIAlertController(title: "To Do App", message: message, preferredStyle: UIAlertControllerStyle.alert)
alertController.addAction(UIAlertAction(title: "Dismiss", style: UIAlertActionStyle.default,handler: nil))
self.present(alertController, animated: true, completion: nil)
}
func signIn() {
performSegue(withIdentifier: "SignInFromSignUp", sender: nil)
}
}
上のコードでは、ユーザーがCreate AccountボタンをタップするとdidTapSignUp()が呼び出されます。ユーザーの入力を受け取り、新規アカウントを作成するためFIRAuth.auth()?.createUser(withEmail: password: completion:)をコールします。もしサインアップに失敗したらエラーメッセージが表示されますが、成功すればsignIn()が呼ばれてItemsTableViewControllerへ遷移します。このsegueはすでに初期プロジェクトのstoryboardで作成されています。
アプリを実行します。アカウントの新規作成が、Firebaseのコンソールで確認できます。すでにログインしていてログイン画面を表示できない場合、Simulator > Reset Content and Settingsでミュレーターをリセットすれば大丈夫です。
Firebaseの初期値では、設定可能なパスワードの長さは最短で6文字に制限されています。このルールは、コンソールから追加できます。次の項でルールの作成方法を解説しますが、詳細はこちらを参照してください。
ユーザー権限とデータのバリデーション
Firebaseのリアルタイムデータベースは式によるルール表記が可能です。JavaScriptに似た構文で、データの構造、インデックスの方法、いつデータを読み書きするかを簡単に定義できます。認証サービスと組み合わせれば、データにアクセスできる人を定義できるので、ユーザーの個人情報を不正なアクセスから守れます。
例のユーザー認証はすでに作成しましたが、ルールを追加設定すればセキュリティはさらに強固になります。
初期値のFirebaseのルールは下の通りで、コンソールのDatabase > Rulesで確認できます。
{
"rules": {
".read": "auth != null",
".write": "auth != null"
}
}
上の初期設定のセキュリティルールでは、データベース上のすべてのデータの読み書きができてしまいます。初期設定のルールを次のように変更しPublishをクリックします。
{
"rules": {
"users": {
"$uid": {
".read": "auth != null && auth.uid == $uid",
".write": "auth != null && auth.uid == $uid",
"items": {
"$item_id": {
"title": {
".validate": "newData.isString() && newData.val().length > 0"
}
}
}
}
}
}
}
読み書きの許可の要件としてauth != null && auth.uid == $uidをセットしました。このルールなら、データの読み書きは認証済みユーザーであることが必要で、なおかつ自分のデータにしかアクセスできません。
FirebaseはデータをJSON形式で保管します。今回のデータベースでは、各ユーザーごとにTo-Do項目の入った配列itemsがあります。各itemにはtitleがあります。上のコードでは、タイトル無しの項目は保存できないようにするため、いくつかのバリデーションを加えました。
データの保存
ユーザー認証と権限付けができたので、次はどのようにFirebaseにデータが保存され、読み出されるのかを説明します。
始めにItem.swiftという新規ファイルを作成し、以下のように変更してください。これがItemのモデルクラスです。各Itemはtitleと、FIRDatabaseReferenceオブジェクトを入れるrefがあります。FIRDatabaseReferenceはFirebaseデータベースの特定の場所を表し、そこへの読み書きに使われます。
import Foundation
import FirebaseDatabase
class Item {
var ref: FIRDatabaseReference?
var title: String?
init (snapshot: FIRDataSnapshot) {
ref = snapshot.ref
let data = snapshot.value as! Dictionary<String, String>
title = data["title"]! as String
}
}
次に、以下のようにItemsTableViewControllerを変更します。
import UIKit
import Firebase
class ItemsTableViewController: UITableViewController {
var user: FIRUser!
var items = [Item]()
var ref: FIRDatabaseReference!
private var databaseHandle: FIRDatabaseHandle!
override func viewDidLoad() {
super.viewDidLoad()
user = FIRAuth.auth()?.currentUser
ref = FIRDatabase.database().reference()
startObservingDatabase()
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let item = items[indexPath.row]
cell.textLabel?.text = item.title
return cell
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
let item = items[indexPath.row]
item.ref?.removeValue()
}
}
@IBAction func didTapSignOut(_ sender: UIBarButtonItem) {
do {
try FIRAuth.auth()?.signOut()
performSegue(withIdentifier: "SignOut", sender: nil)
} catch let error {
assertionFailure("Error signing out: \(error)")
}
}
@IBAction func didTapAddItem(_ sender: UIBarButtonItem) {
let prompt = UIAlertController(title: "To Do App", message: "To Do Item", preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default) { (action) in
let userInput = prompt.textFields![0].text
if (userInput!.isEmpty) {
return
}
self.ref.child("users").child(self.user.uid).child("items").childByAutoId().child("title").setValue(userInput)
}
prompt.addTextField(configurationHandler: nil)
prompt.addAction(okAction)
present(prompt, animated: true, completion: nil);
}
func startObservingDatabase () {
databaseHandle = ref.child("users/\(self.user.uid)/items").observe(.value, with: { (snapshot) in
var newItems = [Item]()
for itemSnapShot in snapshot.children {
let item = Item(snapshot: itemSnapShot as! FIRDataSnapshot)
newItems.append(item)
}
self.items = newItems
self.tableView.reloadData()
})
}
deinit {
ref.child("users/\(self.user.uid)/items").removeObserver(withHandle: databaseHandle)
}
}
上のコードでは、viewDidLoad()で変数のインスタンス化から始めています。ユーザーのログイン時の情報をuserにセットし、FIRDatabaseReferenceオブジェクトをrefにセットしました。FIRDatabase.database().reference()メソッドは、FirebaseデータベースのルートのFIRDatabaseReferenceを取得します。続いてstartObservingDatabase()を実行します。
startObservingDatabase()で、データベースに加えられたどのような変更も検知するリスナーをセットします。Firebaseのデータは、FIRDatabaseを参照して非同期のリスナーを加えると取得できます。このリスナーは初期値取得のために一度、呼び出され、その後はデータに変更があるたびに呼ばれます。イベントリスナーを加えるにはobserveEventType()メソッドを使い、イベントの型とコールバックブロックを指定します。イベントリスナーで監視できるイベントは以下のとおりです。
- FIRDataEventTypeValue:そのパスにあるコンテンツ全体の変更の検知と読み取りをする
- FIRDataEventTypeChildAdded:データを読み出すほか、リストへの項目追加を監視する。リスト変更の監視はFIRDataEventTypeChildChangedとFIRDataEventTypeChildRemovedを一緒に使う
- FIRDataEventTypeChildChanged:リスト項目の変更を監視する。リスト変更の監視はFIRDataEventTypeChildAddedとFIRDataEventTypeChildRemovedを一緒に使う
- FIRDataEventTypeChildRemoved:リスト項目の削除を監視する。リスト変更の監視はFIRDataEventTypeChildAddedとFIRDataEventTypeChildChangedを一緒に使う
- FIRDataEventTypeChildMoved:並び順のあるリスト(ordered list)の項目の順序変更を監視する。項目の順序が変わるFIRDataEventTypeChildChangedイベントの後には必ずこのFIRDataEventTypeChildMovedイベントが伴う(現在のorder-byメソッドによる)
例ではFIRDataEventTypeValueイベントを監視します。FIRDataEventTypeValueイベントを使うのは、イベント発生時点でそのパスの示す場所にあるデータを取得するためです。このメソッドはリスナーを設定した際に一度実行され、そのあとは子要素を含めたデータに変更があるたびに呼ばれます。イベントのコールバックは、子要素を含め、取得したデータが収まったsnapshotに渡されます。もしデータが無ければsnapshotはnilを返します。
覚えておきたいのは、FIRDataEventTypeValueイベントは参照するデータベースのデータが変更されると、子要素の変更も含めて毎回実行されるという点です。snapshotのサイズを抑えるため、変更を監視するリスナーは必要最小限にとどめます。たとえば、データベースのルートにリスナーをセットするのはすすめません。例では、各ユーザーのToDo項目(item)が入っているパス/users/{user id}/itemsにリスナーをセットしました。
リスナーは、FIRDataSnapshotを受け取ります。イベント発生時点におけるデータベースの該当箇所のデータが、オブジェクトのvalueプロパティに入っています。データが存在しなければ値はnilです。
snapshotのデータをもとに、TableViewに表示するコンテンツであるitems配列に入れるItemオブジェクトを作り、TableViewをリロードしてデータの変更を反映します。
通常、TableViewにデータをセットするために使われる関数はnumberOfSectionsInTableView()、tableView(_: numberOfRowsInSection:)、tableView(_: cellForRowAtIndexPath)です。
アプリのナビゲーションバーには追加(Add)ボタンがあり、タップされるとdidTapAddItem()を呼び出し、ユーザーにTableViewに項目を追加するためのアラートボックスを表示します。ユーザーが入力した値は/users/{user id}/items/{item id}/title/に保存します。
Firebaseリアルタイムデータベースにデータを書き込むためのメソッドは4つあります。
- setValue:指定されたパスへデータを書き込むか、データを置き換える(例:users/<user-id>/<username>)
- childByAutoId:データをリストに加える。childByAutoIdを実行するたびに、一意のIDとして使用できるキーが生成される(例:user-posts/<user-id>/<unique-post-id>)
- updateChildValues:全データをまるごと入れ替えることなく、指定したパスの一部のキーの値だけを更新する
- runTransactionBlock:同時の更新処理で不整合が生じる可能性のある複合データを更新する
child()関数は、もし特定ノードのデータが存在すればその参照先を取得し、無ければ新規に作成します。例ではitemを作るたびに一意のIDを自動生成するためにchildByAutoId()を使用します。そしてsetValue()でユーザーの入力値をitemのtitleにセットします。
tableView(_: commitEditingStyle: forRowAtIndexPath:)を使うとTableViewが編集できます。ユーザーは項目をスワイプして消去できます。スワイプされた項目の参照先に対しremoveValue()を実行することで、データベースの該当データを消去できます。項目を消去したあとにTableViewに変更を加える必要は一切ありません。すでにデータベースの../items/パスに加えたどのような変更も監視するリスナーをセットしたことを思い出してください。つまりテーブルは、事前にセットしたコールバックメソッドで更新されます。
ナビゲーションバーのSign OutボタンがタップされるとdidTapSignOut()が呼び出されます。ユーザーをサインアウトして、ログインスクリーンに戻るsegueを実行します。
アプリを実行すると、項目の追加ができます。
追加した項目は、テーブルビューに反映されます。
Firebaseのコンソールを見ると、追加したデータが確認できます。
項目を消去するには、スワイプしてDeleteボタンを表示させます。
これで記事は終わりです。Firebaseのリアルタイムデータベースへのデータ保存と読み出しの方法、ユーザー認証と権限付けの方法を説明してきました。説明したデータ保管方法は、たとえば、NSString、NSNumber、NSArray、NSDictionaryのような単純なデータ型の保管には理想的です。もしもFirebaseに画像やドキュメントのようなファイルを保存したいなら、Firebaseストレージを参照してください。
この記事はFirebaseの簡単な紹介なので、できることのほんの一部しか紹介していません。もっと深く知るにはドキュメントを精読してください。本記事で完成したプロジェクトはここからダウンロードできます。Firebaseが生成したGoogleService-Info.plistファイルをプロジェクトに加えるのを忘れないでください。
注:Firebaseライブラリーを追加したとき、あるいはダウンロードした今回のプロジェクトを実行したときに、警告が出るかもしれません。警告メッセージにはConflicting nullability specifier on return types, 'nullable' conflicts with existing specifier 'nonnull'と表示されます。Swift3対応のFirebaseアップデートでこの小さなバグが発生するようになりましたが、この問題はすでに認識されています。リンク先の掲示板を見ると問題は修正済みとのことですが、次回のアップデートを待たなければならないようです。なおこの警告が表示されても、アプリはちゃんと動きます。
(原文:Creating a Backend for Your iOS App Using Firebase)
[翻訳:西尾健史/編集:Livit]
