256
213

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

バックエンドがFirebaseだけでiOSアプリは作れるのか?

Last updated at Posted at 2018-01-03
1 / 2

Firebaseのイベントでクックパッドの某サービス様が、**「うちはエンジニアはiOSエンジニアだけで、APIも3本くらいです」**とおっしゃっており、「これが時代か」と感動して、いつか触ろうと思っていて、年始で時間もあるし調べて考察。

2018-01-04 追記

コメント、Twitterで返信いただき誠にありがとうございます!懸念部分はfirebaseの既存の仕組み+GAE/GCPである程度解決できそうです。また記事書きますー!

よくあるチャットアプリを例にする

ログインしてチャットができるアプリを作ってみる

必要な画面

  • ログイン画面
    • ログイン
  • チャットルーム一覧画面
    • チャット一覧表示
    • 最新の更新ルームを取得して、自動更新
  • チャット詳細画面
    • チャット一覧表示
    • チャットが来たら更新
    • チャット送信

もし普通にサーバ立ててやるなら

API

  • [POST] /login
  • [POST] /logout
  • [GET] /chatroom
  • [GET] /chat/{targetUserId}
  • [POST] /chat/{targetUserId}

必要なエンドポイントはこの辺ですかね。

インフラ

  • サーバ用意
    • ミドルウェアセットアップ(PHP,nginx,mysqlとか)
    • セキュリティ周りの設定
  • アプリケーション実装
  • 監視の設定
  • デプロイ用の設定・準備
  • プッシュ通知関連の設定

Sakuraで適当なCentOSのインスタンス借りて始めるとこんなとこ。
「チャットが送られた」とか更新を検知するなら、バックエンドからプッシュを送るかwebsocketなりで検知するしかない。これらはちょっと面倒ではある。

iOS + Firebaseで実装

winter.gif

特に何も考えずに2時間くらいでできた。

ログイン画面


//
//  LoginViewController.swift

import UIKit
import FirebaseAuth
import FBSDKLoginKit
import FirebaseDatabase

class LoginViewController: UIViewController {

    @IBOutlet weak var loginBtn : FBSDKLoginButton!
    var ref:DatabaseReference!

    override func viewDidLoad() {
        super.viewDidLoad()
        loginBtn.readPermissions = ["public_profile", "email", "user_friends"]
        loginBtn.delegate = self
        ref = Database.database().reference()
    }

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

extension LoginViewController:FBSDKLoginButtonDelegate{
    
    func loginButtonDidLogOut(_ loginButton: FBSDKLoginButton!) {
    }
    
    func loginButton(_ loginButton: FBSDKLoginButton!, didCompleteWith result: FBSDKLoginManagerLoginResult!, error: Error!) {
        
        if (error != nil) {
            print("Error \(error)")
        } else if result.isCancelled {
            print("Cancelled")
        } else {
            print("Login Succeeded")
            let credential = FacebookAuthProvider
                .credential(withAccessToken: FBSDKAccessToken.current().tokenString)
            Auth.auth().signIn(with: credential) { (user, error) in
                if let error = error {
                    print(error)
                    return
                }
                self.postUser()
            }
        }
    }
    
    func postUser(){
        guard let user = Auth.auth().currentUser else{
            assert(true, "post user with nil")
            return
        }
        
        let facebookId = FBSDKAccessToken.current().userID
        let userRef = ref.child("users")

        userRef
            .queryOrdered(byChild: "facebookId")
            .queryEqual(toValue: facebookId)
            .observeSingleEvent(of: DataEventType.value) { (snapshot) in
                if snapshot.exists() {
                    print("Exist user")
                }else{
                    let postUser = ["facebookId": FBSDKAccessToken.current().userID,
                                    "updated_at": Date().toStr(),
                                   "name": user.displayName]
                    let postUserRef = userRef.childByAutoId()
                    postUserRef.setValue(postUser)
                }
                
                self.dismiss(animated: true, completion: nil)
            }
    }
}

FacebookログインからのFirebaseAuthを使ってユーザー登録、ログイン管理。
これをやるとFirebaseにアカウントが登録されて、アプリ内にもキャッシュされる。

observeSingleEvent

というのは変更を値を一度だけ取得するときに使う。

スクリーンショット 2018-01-03 13.07.25.png

FirebaseAuthは様々な認証方式が用意されているので、方式ごとに登録される情報が異なるようだ。

スクリーンショット 2018-01-03 13.11.19.png

メールでの会員登録だと、送信用のテンプレートをwebコンソールから編集できるようだ。
よくできている。

Realtime Databaseへのユーザー情報の登録

スクリーンショット 2018-01-03 13.14.40.png

上記のソースで登録するとこんな感じで保存される。facebookIdをクエリにしてユーザーの存在チェックをする。keyを任意で発行されているものにしているが、ここはもっとクレバーなアイデアがあったはず。NoSQLな感じなので、あんま階層深くしちゃうと、クライアント側で探索したり、構造の変化に柔軟な実装が難しそうだなぁと思った。

チャットルーム一覧画面


//
//  ChatTableViewController.swift

import UIKit
import FirebaseAuth
import FBSDKLoginKit
import FirebaseDatabase
import SDWebImage

class ChatTargetUserCell:UITableViewCell{
    @IBOutlet weak var nameLabel:UILabel!
    @IBOutlet weak var iconImageView:UIImageView!
    
    func bind(user:User){
        self.nameLabel.text = user.name
        self.iconImageView.sd_setImage(with: user.iconURL, completed: nil)
    }
}

class ChatTableViewController: UITableViewController {

    var ref:DatabaseReference!
    var users = [User]()
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if let me =  Auth.auth().currentUser{
            self.title = me.displayName
        }else{
            let loginvc = UIStoryboard(name: "Login", bundle: nil).instantiateViewController(withIdentifier: "login") as! LoginViewController
            self.present(loginvc, animated: true, completion: nil)
        }
        
        self.observe()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
    
    func observe(){
        ref = Database.database().reference()
        
        ref.child("users").observe(DataEventType.value) { (snapshot) in
            self.users = [User]()
            for item in snapshot.children{
                if let snap = item as? DataSnapshot{
                    let user = User(snapshot: snap)
                    self.users.append(user)
                }
            }
            self.users.sort(by: { (pre, next) -> Bool in
                pre.updateAt > next.updateAt
            })
            self.tableView.reloadData()
        }
    }

    @IBAction func tapLogout(){
        let firebaseAuth = Auth.auth()
        do {
            try firebaseAuth.signOut()
            FBSDKLoginManager().logOut()
 
            let loginvc = UIStoryboard(name: "Login", bundle: nil).instantiateViewController(withIdentifier: "login") as! LoginViewController
            self.present(loginvc, animated: true, completion: nil)
        } catch let signOutError as NSError {
            print ("Error signing out: %@", signOutError)
        }
    }
}

// MARK: - Table view data source

extension ChatTableViewController{
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return users.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! ChatTargetUserCell
        let user = self.users[indexPath.row]
        cell.bind(user: user)
        return cell
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let vc = ChatViewController.create(user: users[indexPath.row])
        self.navigationController?.pushViewController(vc, animated: true)
    }
}

値の更新を検知する、この辺がキモ。


ref.child("users").observe(DataEventType.value) { (snapshot) in
   ...
}

observeSingleEventとはことなりobserveは常に変更を検知する。
スクリーンショット 2018-01-03 13.24.11.png

このDataEventTypeの種別に寄って、子要素や値全体の変更をどう検知するかを設定できる。

let user = User(snapshot: snap)

取得できたデータはDataSnapshotクラスで取得できる。この中にKeyValueの形式で値が入っているので、適宜entityなどにマッピングする。


import Foundation
import FirebaseDatabase

struct User {
    let faceboookId:String
    let name:String
    let updateAt:Date
    
    var iconURL:URL?{
        get{
            return URL(string: "https://graph.facebook.com/\(self.faceboookId)/picture")
        }
    }
    
    init(snapshot:DataSnapshot) {
        self.faceboookId = snapshot.childSnapshot(forPath: "facebookId").value as! String
        self.name        = snapshot.childSnapshot(forPath: "name").value as! String
        let dateStr      = snapshot.childSnapshot(forPath: "updated_at").value as! String
        self.updateAt = dateStr.toDate()
    }
}

Dataは別にStringにしなくてもいい説もある。書込み可能な構造は


NSString
NSNumber
NSDictionary
NSArray

です。

チャット詳細画面


//
//  ChatViewController.swift

import UIKit
import FirebaseAuth
import FBSDKLoginKit
import JSQMessagesViewController
import FirebaseDatabase
import SDWebImage

class ChatViewController: JSQMessagesViewController {

    var messages = [JSQMessage]()
    var targetUser:User!
    var ref:DatabaseReference!
    var roomKey:String!
    
    fileprivate var incomingBubble: JSQMessagesBubbleImage!
    fileprivate var outgoingBubble: JSQMessagesBubbleImage!
    fileprivate var incomingAvatar: JSQMessagesAvatarImage!
    fileprivate var outgoingAvatar: JSQMessagesAvatarImage!

    class func create(user:User)->ChatViewController{
        let vc = UIStoryboard(name: "Chat", bundle: nil).instantiateViewController(withIdentifier: "chat") as! ChatViewController
        vc.targetUser = user
        return vc
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let facebookId = FBSDKAccessToken.current().userID!
        self.senderId = facebookId
        self.senderDisplayName = Auth.auth().currentUser?.displayName
        self.ref = Database.database().reference()

        self.title = targetUser.name
        
        let bubbleFactory = JSQMessagesBubbleImageFactory()
        self.incomingBubble = bubbleFactory?.incomingMessagesBubbleImage(with: UIColor.jsq_messageBubbleLightGray())
        self.outgoingBubble = bubbleFactory?.outgoingMessagesBubbleImage(with: UIColor.jsq_messageBubbleBlue())
        
        SDWebImageDownloader.shared().downloadImage(with: targetUser.iconURL, options: [], progress: nil) { (image, data, err, res) in
            self.incomingAvatar = JSQMessagesAvatarImageFactory.avatarImage(with: image, diameter: 64)
        }
        let url = URL(string: "https://graph.facebook.com/\(facebookId)/picture")
        SDWebImageDownloader.shared().downloadImage(with: url, options: [], progress: nil) { (image, data, err, res) in
            self.outgoingAvatar = JSQMessagesAvatarImageFactory.avatarImage(with: image, diameter: 64)
        }

        createTalkRoomIfNeeded()
    }

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

extension ChatViewController{
    
    func createTalkRoomIfNeeded(){
        let facebookId = FBSDKAccessToken.current().userID!
        let roomRef = ref.child("rooms")
        let userIds:[String] = [targetUser.faceboookId, facebookId].sorted()
        roomRef
            .observeSingleEvent(of: DataEventType.value) { (snapshot) in
                if snapshot.exists(){
                    for item in snapshot.children{
                        if let roomSnap = (item as? DataSnapshot),
                            let room = (roomSnap.value as? [String]),
                            room == userIds{
                            print("exist room")
                            self.roomKey = roomSnap.key
                            self.observe()
                            return
                        }
                    }
                }
                print("create room")
                let newRoomRef = roomRef.childByAutoId()
                newRoomRef.setValue(userIds)
                self.roomKey = newRoomRef.key
                self.observe()
        }
    }
    
    func updateUserDate(){
        let userRef = ref.child("users")
        
        userRef
            .queryOrdered(byChild: "facebookId")
            .queryEqual(toValue: targetUser.faceboookId)
            .queryLimited(toFirst: 1)
            .observeSingleEvent(of: DataEventType.value) { (snapshot) in
                
                if let key = (snapshot.children.allObjects[0] as? DataSnapshot)?.key{
                    let myuserRef = userRef.child(key)
                    myuserRef.updateChildValues(["updated_at": Date().toStr()])
                }
        }
    }
    
    func observe(){
        print(self.roomKey)
        
        let chatRef = ref.child("chats")
        chatRef
            .queryOrdered(byChild: "roomId")
            .queryEqual(toValue: self.roomKey)
            .observe(DataEventType.value) { (snapshot) in
                self.messages = [JSQMessage]()
                for item in snapshot.children{
                    if let chatSnap = item as? DataSnapshot{
                        let senderId = chatSnap.childSnapshot(forPath: "senderId").value as? String
                        let text = chatSnap.childSnapshot(forPath: "text").value as? String
                        if senderId == self.senderId{
                            let message = JSQMessage(senderId: senderId, displayName: self.senderDisplayName, text: text)
                            self.messages.append(message!)
                        }else{
                            let message = JSQMessage(senderId: senderId, displayName: self.targetUser.name, text: text)
                            self.messages.append(message!)
                        }
                    }
                }
                self.collectionView.reloadData()
        }
    }
}

// MARK: JSQMessagesViewController

extension ChatViewController{
    
    override func didPressSend(_ button: UIButton!, withMessageText text: String!, senderId: String!, senderDisplayName: String!, date: Date!) {
        let facebookId = FBSDKAccessToken.current().userID!
        let chatRef = ref.child("chats").childByAutoId()
        let newMessage = ["roomId":roomKey ,"senderId": facebookId, "text": text]
        chatRef.setValue(newMessage)
        
        self.updateUserDate()
    }
    
    override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAt indexPath: IndexPath!) -> JSQMessageBubbleImageDataSource! {
        if self.messages[indexPath.item].senderId == senderId {
            return self.outgoingBubble
        }
        return self.incomingBubble
    }
    
    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = super.collectionView(collectionView, cellForItemAt: indexPath) as! JSQMessagesCollectionViewCell
        if self.messages[indexPath.item].senderId == senderId {
            cell.textView.textColor = UIColor.white
        }else{
            cell.textView.textColor = UIColor.darkGray
        }
        return cell
    }
    
    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return messages.count
    }
    
    override func collectionView(_ collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAt indexPath: IndexPath!) -> JSQMessageAvatarImageDataSource! {
        if self.messages[indexPath.item].senderId == senderId {
            return self.outgoingAvatar
        }else{
            return self.incomingAvatar
        }
    }
    
    override func collectionView(_ collectionView: JSQMessagesCollectionView!, messageDataForItemAt indexPath: IndexPath!) -> JSQMessageData! {
        return messages[indexPath.row]
    }
}

すでにdeprecatedいなっているライブラリを惜しげもなく使う。ここでもobserveを惜しげもなく使い、チャットの更新を検知して自動でUIを更新する。プッシュを受けてどうこう・websocketでどうこうするより、非同期な双方向データ通信が明示的に実装できる印象。

まとめ

懸念

1 クライアント依存なNoSQLによるデータ管理

データが構造が各クライアント依存なので、**webなりiosなりandroidなりで、どれかで例えば要素を一つずれて保存してしまった場合に、親子関係が壊れる。**もちろんvalidationやunittestで回避することも可能だろうが、それを結局クライアントごとに実装が必要で、それならAPIで実装されていたほうがメンテナブルじゃないか?直接アプリから共通のvalidationなしに直接DBを触れるのは便利だが、怖い部分もあるなと感じた。

2 データ管理の方法

どうセキュアに、ロール分けて用意すればいいのだろうか?もちろん管理画面をwebで実装して、そこにアクセスできるユーザーのロールを定義して、CS対応などすればできるはできる。ただFirebase Realtime Database上ではログインすれば全てのデータが表示されているので、これはどう管理するのがベストなんだろうか?IAMの役割と権限を見た感じ、権限の設定は可能なようだが、データごとにロールで分けたりが難しそうだ。この辺はサービス化した後の運用フローに懸念がある。

3 エラーの検知

Firebase Realtime Databaseに関するエラーがクラッシュするだけで、内容がわからないし、例外を履くわけでもないので**エラー箇所がわからない。**ビルドの設定が悪かったのかもしれないし、Firebaseの使い方を間違えている可能性もあるが、もうちょっとわかりやすいエラーが欲しい・・・

4 リソース監視アラート

従量課金だし、Paas的には料金を検知するアラートを自前で設定したい。[Firebase]運用面における導入のポイント(利用料金、監視、セキュリティ)などを参考にすると、制限超過する前にはメールが来るようだが、少額でも飛ぶようにしてほしいし、止める仕組みもほしい。
これは厳密にはfirebaseにはないが、GCP連携をすると利用可能のようだ。

5 オフラインの管理

これまで多くのアプリは「オフラインのため利用できません」みたいなトースト出して、画面をロックするような処理がおおかったが、オフラインでの挙動が可能になる。メディア系のアプリなら便利かもしれないが、ガッツリユーザーのイベント起因で、データ更新が置きまくるようなアプリだと監理が大変そうだ。
さらにここにCloud Functionsをつけて、データ更新をフックして何かするような処理を入れてたらカオス。 もし本番導入するなら一分機能を除いて、更新はさせないようにしたい。

6 dev/stg/prodを分ける

ただ分けるだけならFirebaseのコンソール上で、分ければいいけど、

  • 定期的に一部データを本番からstgに流す
  • stgは定期的に洗替する
  • devを個人ごとに用意する

とかやり始めると、どうするのがベストなんだろうか?パッと触ってみた感じ泥臭くなりそうだ。

7 画面とリソースの紐付け

1画面1APIが美しいとされているけど、NoSQLに直アクセスするとそうも行かないだろうし、複数のキーの値を取得して、マージするシーンも出てくるはず。Rxなにがしで両方の変更をさらに監視すれば行けそうだが、そもそもそんなことしないで、一方の更新を受けて、もう片方も更新するようにCloud Functionsで対応しておくべきなんだろうか?

まとめ

懸念はもっとあるけど、本当に便利なのは間違いない。データの更新・アカウントの登録などをフックしてイベントベースで、プッシュ通知やデータ更新のような処理をできるのは本当に強力だし、その多くをfirebaseまかせにできるのはすごい。
バックエンドがFirebaseだけでiOSアプリは作れるのかという問に対してはもろもろの懸念はあるが**「できる」**と言っても過言ではないし、今後もっと強力になることを考えると、今のうちにナレッジを貯めておくのはいいことだと思う。

既存サービスをこれにリプレイスするのは超大変だと思うので、新規サービスで「ユーザー同士のインタラクション」が重視されるようなものは親和性がいいと思う。

256
213
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
256
213

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?