iOS
Swift
Firebase
FirebaseStorage
CloudFirestore

Pringで始める簡単Firebase App開発 for iOS(初心者向け)

Pringで始める簡単Firebase App開発 for iOS

まだFirebaseで開発をしたことがない、開発者向けの記事です。アンドロイド開発者やWeb開発者もすぐに始められるように、なるべく細やかに作業を解説します。

準備

開発環境

  • macOS High Sierra
  • Xcode 9.4
  • Swift 4
  • iOS 11

Xcodeプロジェクトの作成からFirebaseの準備

新しいXcodeプロジェクトを作ろう

まずはXcodeを起動して、新しいプロジェクトを作りましょう。
スクリーンショット 2018-07-10 11.07.27.png

ここでは、Master-Detail Appを選択しましょう。

スクリーンショット 2018-07-10 11.09.42.png

Product Nameには、任意でプロジェクトの名前を入れてください。Firebase Sampleなどで構いません。
Organization IdentifierからBundle Identifierが生成されるため非常に重要です。お持ちのドメインなどがあればそれを使いましょう。

スクリーンショット 2018-07-10 11.15.38.png

このようになればOKです。せっかくここまでは出来ましたが一度Xcodeのプロジェクトを閉じて下さい。

CocoapodsでFirebaseのSDKをインストールしよう

ターミナルを使って、プロジェクトのディレクトリまで移動してください。
プロジェクトのディレクトリで次のコマンドを実行します。

pod init

Podfileが生成されて入れば成功です。

スクリーンショット 2018-07-10 11.21.54.png

もしエラーが出た人は、Cocoapodsをインストールしましょう。

https://cocoapods.org/

Podfileを次のように編集しましょう。

platform :ios, '11.0'

target 'Firebase Sample' do
  use_frameworks!

  pod 'Pring'

end

Podfileを保存して、次のコマンドを実行します。

pod install

次のように出れば成功です。

スクリーンショット 2018-07-10 11.28.20.png

プロジェクトのディレクトリもファイルが増えていると思います。
Firebase Sample.xcworkspaceというWorkspaceが出来ているのでこちらを開きましょう。

スクリーンショット 2018-07-10 11.30.09.png

次のようにプロジェクトにPodsが追加されているのを確認して下さい。
スクリーンショット 2018-07-10 11.32.26.png

これでFirebase SDKのインストール作業は終了です。

Firebaseの準備をしよう

FirebaseのSDKをインストールしましたが、これでFirebaseが使える訳ではありません。Firebaseにアクセスしてデータベースの設定をしましょう。

https://console.firebase.google.com/?pli=1

Firebaseに行って新しいプロジェクトを追加しましょう。

スクリーンショット 2018-07-10 11.49.33.png

ここからは、既に素晴らしい記事が上がっていたのでこちらをご参考ください。

Swiftで始めるFirebase入門

この作業を終えるとこんな感じにGoogleService-Info.plistが追加されているはずです。
スクリーンショット 2018-07-10 11.54.43.png

開発を始めよう

Xcodeで作成したプロジェクトの初期設定ではMain.storyboardを使ってViewControllerを起動するようになっていますが、今回はある理由から最初のViewControllerの起動はAppDelegateで行うことにします。

まず、プロジェクトのDeployment InfoMain Interfaceを空にします。

スクリーンショット 2018-07-10 12.19.01.png

次にAppDelegateを次のように編集します。

import UIKit
import Firebase

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate {

    var window: UIWindow?


    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

        FirebaseApp.configure()

        let storyboard: UIStoryboard = UIStoryboard(name: "Main", bundle: nil)
        let splitViewController: UISplitViewController = storyboard.instantiateInitialViewController() as! UISplitViewController
        let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController
        navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem
        splitViewController.delegate = self

        self.window = UIWindow(frame: UIScreen.main.bounds)
        self.window?.rootViewController = splitViewController
        self.window?.makeKeyAndVisible()

        return true
    }


    // MARK: - Split view

    func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
        guard let secondaryAsNavController = secondaryViewController as? UINavigationController else { return false }
        guard let topAsDetailController = secondaryAsNavController.topViewController as? DetailViewController else { return false }
        if topAsDetailController.detailItem == nil {
            // Return true to indicate that we have handled the collapse by doing nothing; the secondary controller will be discarded.
            return true
        }
        return false
    }

}

ここで一度ビルドしてみましょう。

真っ白なMasterViewControllerが表示されれば成功です。
スクリーンショット 2018-07-10 12.40.38.png

Note
今回XcodeからMain.storyboardを起動させなかった理由は、Firebaseの初期化のタイミングにあります。FirebaseApp.configure()application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Boolで記述しますが、Main.storyboardはそれより前にロードされるためMainViewControllerでFirebaseを利用できなくなってしまいます。

Firebaseに保存しよう

まずデータを保存するモデルを記述しましょう。
まずは、プロジェクトに新しくSwift Fileを追加します。
スクリーンショット 2018-07-10 13.04.28.png

今回はItemというモデルを作ってみましょう。

スクリーンショット 2018-07-10 13.10.27.png

そしてItemを次のように編集して下さい。
PringはCloud Firestoreのドキュメントをオブジェクトとして扱うことができるライブラリで、以下のようにItemを記述することができます。

import Pring

@objcMembers
class Item: Object {

    dynamic var name: String?

}

それでは早速ItemをFirebaseに保存してみましょう。
MasterViewControllerfunc insertNewObject(_ sender: Any)を次のように編集して下さい。

    @objc
    func insertNewObject(_ sender: Any) {
        let item: Item = Item()
        item.name = "hoge"
        item.save()
    }

では、Xcodeでビルドしてみましょう。

右上の「+」ボタンを押してみましょう。
スクリーンショット 2018-07-10 13.14.43.png

ではFirebaseのコンソールにアクセスして、CloudFirestoreの中に次のように出ていれば成功です。

もし出ていなければルールを確認して下さい。今はサンプルコード実行のため非常にゆるいセキュリティルールを適用していますが、これではプロダクトとして出せないので気をつけましょう。

スクリーンショット 2018-07-10 13.17.38.png

Note
つい先日、Firebaseのセキュリティの緩さがエンジニア界隈で話題になりました。
この件に関しては、こちらを参考にして下さい。「Firebaseの脆弱性で1億件超漏洩」の真相とは?

MasterViewControllerとFirebaseを繋げよう

FirebaseにItemを保存することができました。次にItemをMasterViewControllerのTableViewに表示させてみましょう。

まず、MasterViewControllerにもPringをimportします。

import UIKit
import Pring // PringをImport

次にDataSourceを準備しましょう。

var dataSource: DataSource<Item>?

viewDidLoadの中で次を追記して下さい。ここでは深く説明しませんが、これで繋がります。

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        navigationItem.leftBarButtonItem = editButtonItem

        let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(insertNewObject(_:)))
        navigationItem.rightBarButtonItem = addButton
        if let split = splitViewController {
            let controllers = split.viewControllers
            detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController
        }

        // DataSourceを準備
        self.dataSource = Item.query.dataSource()
            .on({ [weak self] (snapshot, changes) in
                guard let tableView: UITableView = self?.tableView else { return }
                switch changes {
                case .initial:
                    tableView.reloadData()
                case .update(let deletions, let insertions, let modifications):
                    tableView.beginUpdates()
                    tableView.insertRows(at: insertions.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                    tableView.deleteRows(at: deletions.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                    tableView.reloadRows(at: modifications.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                    tableView.endUpdates()
                case .error(let error):
                    print(error)
                }
            }).listen()

    }

TableViewのDelegateを次のように書き直して作業は完了です。

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.dataSource?.count ?? 0
    }
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        let item: Item = self.dataSource![indexPath.row]
        cell.textLabel!.text = item.name
        return cell
    }
    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            self.dataSource!.removeDocument(at: indexPath.row)
        } else if editingStyle == .insert {
            // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view.
        }
    }

MasterViewControllerを載せると次のようになります。
早速ビルドしてみましょう。

import UIKit
import Pring

class MasterViewController: UITableViewController {

    var detailViewController: DetailViewController? = nil

    var dataSource: DataSource<Item>?

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        navigationItem.leftBarButtonItem = editButtonItem

        let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(insertNewObject(_:)))
        navigationItem.rightBarButtonItem = addButton
        if let split = splitViewController {
            let controllers = split.viewControllers
            detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController
        }

        self.dataSource = Item.query.dataSource()
            .on({ [weak self] (snapshot, changes) in
                guard let tableView: UITableView = self?.tableView else { return }
                switch changes {
                case .initial:
                    tableView.reloadData()
                case .update(let deletions, let insertions, let modifications):
                    tableView.beginUpdates()
                    tableView.insertRows(at: insertions.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                    tableView.deleteRows(at: deletions.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                    tableView.reloadRows(at: modifications.map { IndexPath(row: $0, section: 0) }, with: .automatic)
                    tableView.endUpdates()
                case .error(let error):
                    print(error)
                }
            }).listen()

    }

    override func viewWillAppear(_ animated: Bool) {
        clearsSelectionOnViewWillAppear = splitViewController!.isCollapsed
        super.viewWillAppear(animated)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    @objc
    func insertNewObject(_ sender: Any) {
        let item: Item = Item()
        item.name = "hoge"
        item.save()
    }

    // MARK: - Segues

//        ここでは一旦コメントアウト
//    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
//        if segue.identifier == "showDetail" {
//            if let indexPath = tableView.indexPathForSelectedRow {
//                let object = objects[indexPath.row] as! NSDate
//                let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController
//                controller.detailItem = object
//                controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
//                controller.navigationItem.leftItemsSupplementBackButton = true
//            }
//        }
//    }

    // MARK: - Table View

    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.dataSource?.count ?? 0
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        let item: Item = self.dataSource![indexPath.row]
        cell.textLabel!.text = item.name
        return cell
    }

    override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        // Return false if you do not want the specified item to be editable.
        return true
    }

    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            self.dataSource!.removeDocument(at: indexPath.row)
        } else if editingStyle == .insert {
            // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view.
        }
    }


}

次のようにHogeが出ましたか?そうすれば成功です。

スクリーンショット 2018-07-10 13.50.58.png

Firebaseのリアルタイム性を体験しよう

さてさてここで、Firebaseのリアルタイム感を体験してみましょう。
右上の「+」ボタンを押してみましょう。Hogeが増えましたね?

Firebaseの分断耐性を体験しよう

次にMacのWiFiをOffにしてオフライン環境を作りましょう。そして同じく右上の「+」ボタンを押してみましょう。
シミュレーターのHogeが増えることが確認できたらFirebaseのコンソールにアクセスしてデータがまだ増えてないことを確認して下さい。(別の端末でみないとWiFi切れてますよ。)
データが増えてないことを確認したらWiFiを復活させてみましょう。WiFiを復活させて少し待つとコンソールにデータが反映されたことが確認できるはずです。

データの削除もしてみましょう。

左上の「Edit」ボタンを押すことでデータの削除をすることができます。削除した後にコンソールからデータが消えることを確認しましょう。

スクリーンショット 2018-07-10 14.05.55.png

データを更新する

保存したデータを更新するために、まずDetailViewControllerを修正して行きます。まずdetailItemItemに変更して下さい。

    var detailItem: Item? {
        didSet {
            // Update the view.
            configureView()
        }
    }
    func configureView() {
        // Update the user interface for the detail item.
        if let item = detailItem {
            if let label = detailDescriptionLabel {
                label.text = item.name
            }
        }
    }

次にMasterViewControllerも修正しましょう。コメントアウトしてたところを次の用に変更します。

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "showDetail" {
            if let indexPath = tableView.indexPathForSelectedRow {
                let object: Item = self.dataSource![indexPath.row]
                let controller = (segue.destination as! UINavigationController).topViewController as! DetailViewController
                controller.detailItem = object
                controller.navigationItem.leftBarButtonItem = splitViewController?.displayModeButtonItem
                controller.navigationItem.leftItemsSupplementBackButton = true
            }
        }
    }

実行して見ましょう。次のようにhogeが表示されれば成功です。

スクリーンショット 2018-07-13 10.50.42.png

次にStoryboardを修正して、TextFieldButtonを追加しましょう。これをDetailViewControllerをIBOutlet, IBActionで繋いで下さい。
スクリーンショット 2018-07-13 10.53.54.png

最終的にDetailViewControllerは次のようになります。

import UIKit
import Pring

class DetailViewController: UIViewController {

    @IBOutlet weak var detailDescriptionLabel: UILabel!
    @IBOutlet weak var textField: UITextField!
    @IBAction func updateAction(_ sender: Any) {
        self.detailItem?.name = self.textField.text
        self.detailItem?.update()
    }

    func configureView() {
        // Update the user interface for the detail item.
        if let item = detailItem {
            if let label = detailDescriptionLabel {
                label.text = item.name
            }
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        configureView()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    var disposer: Disposer<Item>?

    var detailItem: Item? {
        didSet {
            // Update the view.
            configureView()
            self.disposer = detailItem?.listen { [weak self] (item, error) in
                if let error: Error = error {
                    print(error)
                    return
                }
                self?.configureView()
            }
        }
    }

    deinit {
        self.disposer?.dispose()
    }
}

では実行してみましょう。適当な文字を入れて更新ボタンを押して下さい。

TextLabelに入力した文字が表示されれば成功です。

スクリーンショット 2018-07-13 11.14.21.png

ここで注目して頂きたいのが、listenの機能です。次のようにObjectに対してlistenとするとリアルタイムでデータベースを監視することが可能です。非常に簡単ですね。

self.disposer = detailItem?.listen { [weak self] (item, error) in
                if let error: Error = error {
                    print(error)
                    return
                }
                self?.configureView()
            }

画像をアップロードする

サービスを作るためには、メディアをアップロードする必要があります。Pringでの写真アップロードの方法をご説明します。
Itemを次のように修正して下さい。PringではFileという型を使って画像をアップロードすることが可能です。Fileを配列にすることで複数画像をアップロードすることも可能ですが、今回は一枚だけアップロードします。

import Pring

@objcMembers
class Item: Object {

    dynamic var name: String?
    dynamic var image: File?
    // 複数画像をアップロードしたい場合は配列にもOK
    dynamic var images: [File] = []

}

Storyboardをさらに修正してDetailViewControllerにImageViewを追加してIBOutletでつなぎましょう。

スクリーンショット 2018-07-13 11.16.15.png

UIImagePickerControllerで画像を選択するようにするので、次のようにDetailViewControllerを修正して下さい。

viewDidLoadでImageViewをタップするとUIImagePickerを呼び出せるようにします。

    override func viewDidLoad() {
        super.viewDidLoad()
        configureView()

        let tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapImageView))
        self.imageView.addGestureRecognizer(tapGestureRecognizer)
        self.imageView.isUserInteractionEnabled = true
    }

    @objc func didTapImageView() {
        let imagePickerController: UIImagePickerController = UIImagePickerController()
        imagePickerController.sourceType = .photoLibrary
        imagePickerController.delegate = self
        self.present(imagePickerController, animated: true, completion: nil)
    }

次に選択した画像をアップロードします。画像のアップロードはitemにFileをセットしてupdateを実行するだけです。

extension DetailViewController: UINavigationControllerDelegate, UIImagePickerControllerDelegate {

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) {
        guard let image: UIImage = info[UIImagePickerControllerOriginalImage] as? UIImage else { return }
        let data: Data = UIImageJPEGRepresentation(image, 0.5)!
        self.detailItem?.image = File(data: data)
        self.uploadTasks = self.detailItem?.update()
        picker.dismiss(animated: true, completion: nil)
    }

}

アップロード中のプログレスはuploadTasksの中に含まれるStorageUploadTaskで取得することができます。

    var uploadTasks: [String: StorageUploadTask]? {
        didSet {
            let uploadTask: StorageUploadTask = uploadTasks![uploadTasks!.keys.first!]!
            uploadTask.observe(.progress) { (snapshot) in
                print(snapshot.progress)
            }
        }
    }

configureViewも少し修正しましょう。

    var donwloadTask: StorageDownloadTask?

    func configureView() {
        // Update the user interface for the detail item.
        if let item = detailItem {
            if let label = detailDescriptionLabel {
                label.text = item.name
            }
            donwloadTask = item.image?.getData(completion: { [weak self](data, error) in
                if let error = error {
                    print(error)
                    return
                }
                self?.imageView.image = UIImage(data: data!)
            })
        }
    }

最終的にDetailViewControllerは次のようになります。

import UIKit
import Firebase
import Pring

class DetailViewController: UIViewController {

    @IBOutlet weak var detailDescriptionLabel: UILabel!
    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var textField: UITextField!
    @IBAction func updateAction(_ sender: Any) {
        self.detailItem?.name = self.textField.text
        self.detailItem?.update()
    }

    var uploadTasks: [String: StorageUploadTask]? {
        didSet {
            let uploadTask: StorageUploadTask = uploadTasks![uploadTasks!.keys.first!]!
            uploadTask.observe(.progress) { (snapshot) in
                print(snapshot.progress)
            }
        }
    }

    var donwloadTask: StorageDownloadTask?

    func configureView() {
        // Update the user interface for the detail item.
        if let item = detailItem {
            if let label = detailDescriptionLabel {
                label.text = item.name
            }
            donwloadTask = item.image?.getData(completion: { [weak self](data, error) in
                if let error = error {
                    print(error)
                    return
                }
                self?.imageView.image = UIImage(data: data!)
            })
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        configureView()

        let tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapImageView))
        self.imageView.addGestureRecognizer(tapGestureRecognizer)
        self.imageView.isUserInteractionEnabled = true
    }

    @objc func didTapImageView() {
        let imagePickerController: UIImagePickerController = UIImagePickerController()
        imagePickerController.sourceType = .photoLibrary
        imagePickerController.delegate = self
        self.present(imagePickerController, animated: true, completion: nil)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    var disposer: Disposer<Item>?

    var detailItem: Item? {
        didSet {
            // Update the view.
            configureView()
            self.disposer = detailItem?.listen { [weak self] (item, error) in
                if let error: Error = error {
                    print(error)
                    return
                }
                self?.configureView()
            }
        }
    }

    deinit {
        self.disposer?.dispose()
    }
}

extension DetailViewController: UINavigationControllerDelegate, UIImagePickerControllerDelegate {

    func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [String : Any]) {
        guard let image: UIImage = info[UIImagePickerControllerOriginalImage] as? UIImage else { return }
        let data: Data = UIImageJPEGRepresentation(image, 0.5)!
        self.detailItem?.image = File(data: data)
        self.uploadTasks = self.detailItem?.update()
        picker.dismiss(animated: true, completion: nil)
    }
}

では実行してみましょう。

ImageViewをタップして画像を選択すれば数秒で画像がアップロードされるはずです。そしてImageViewに画像が表示されれば成功です。

スクリーンショット 2018-07-13 11.54.42.png

また、プログレスがprintで表示されいるので見てみましょう。

スクリーンショット 2018-07-13 11.55.23.png

このようにPringはApp開発に置いて必要になってくる機能を助けてくれる強力な機能を提供しています。

https://github.com/1amageek/Pring