Androidで使うFirebase入門

はじめに

先日アプリをリリースしました。

このアプリではほぼFirebaseを使って作りました。そこでいろいろ知見が溜まってきましたので「Androidで使うFirebase入門」を書いてみます。

本書ででてくるコードは全てKotlinで書かれていますが、ご了承ください。

目次

0章 Firebaseの導入
1章 Firebase Auth
  - Firebase Authとは
  - 導入
  - 実装
  - メールアドレス認証
  - 新規ユーザー登録処理
  - ログイン処理
  - ローカライズ問題
2章 Cloud Messaging
  - Cloud Messagingとは
  - 導入
  - 通知チャンネルの作成
  - FirebaseMessagingServiceクラスの作成
  - FirebaseInstanceIdServiceクラスの作成
  - 実装する
3章 Cloud Firestore
  - Cloud Firestoreとは
  - 導入
  - 実装
  - データ登録
  - データ取得
  - データは画面に表示する分だけ取得して出す
4章 Firebase Storage
  - Firebase Storageとは
  - 導入
  - 実装
  - Storageに保存
5章 検証と本番を分ける
6章 最後に

1章 Firebase Auth

Firebase Authとは

Androidアプリを開発する上でほぼほぼ必須なのが「ログイン機能」だと思います。

ですが、この機能を一から実装しようとするとかなり大変(SQLインジェクション攻撃対策などのセキュリティや、入力バリデーション対応など)

Firebase Authではこの辺りを全てライブラリ化されていて簡単に実装することができます。

導入

- Gradleに追加

app/build.gradleに下記を追加します。(Firebaseのバージョンについては最新の物をお使いください。)

dependencies {
   compile 'com.google.firebase:firebase-auth:15.1.0'
}

実装

本書ではメールアドレスとパスワードで認証する方法を掲載します。

他のSNS系サービスからログイン処理を実装することも可能です。

以前QiitaにTwiiterでログイン処理を作成する手順を書きましたのでよろしければそちらもどうぞ

メールアドレス認証

まずはFirebaseのコンソール画面を開いて左のタブから「Authentication」を押します。

そのあと、上のタブで「ログイン方法」→ 「メールアドレス/パスワード」→ 「有効にする」の操作をしてください。

これでメールアドレス認証ができるようになりました。

新規ユーザー登録処理

新規ユーザー登録処理は createUserWithEmailAndPasswordメソッドで認証することができます。

mAuth = FirebaseAuth.getInstance()
mAuth.createUserWithEmailAndPassword(email.toString(), password.toString())
        .addOnCompleteListener {
            if (it.isSuccessfull) {
                // 成功処理
            } else {
                // 失敗処理
            }
        }

email.toString() や、password.toString() とかかれている箇所にEditViewとかから取得してきたテキストを引数にいれてください。

これで成功した場合、Firebase コンソールに認証したユーザーのメールアドレスをみることができるようになります。

パスワードはコンソール画面からも見えないようになっています。(たぶんFirebase内で暗号化されて保存されてる)

ログイン処理

ログイン認証は signInWithEmailAndPasswordメソッドを使用します。

mAuth = FirebaseAuth.getInstance()
mAuth.signInWithEmailAndPassword(email.toString(), password.toString())
        .addOnCompleteListener {
            if (it.isSuccessfull) {
                // 成功処理
            } else {
                // 失敗処理
            }
        }

email.toString() や、password.toString() とかかれている箇所にEditViewとかから取得してきたテキストを引数にいれてください。

ローカライズ問題

Firebase Authの辛いところですが、エラー文言がローカライズされていません。

例えばユーザーがメールアドレスや、パスワードを打ち間違えた時にFirebaseからはこんな文言で返ってきます。

com.google.firebase.auth.FirebaseAuthInvalidCredentialsException: The email address is badly formatted.

文言はit(Exceprion型)で返却されます。その文言は英語でローカライズされておらずそのまま表示してもユーザーにわかりづらいという欠点があります。

自分は暫定対応として英語の文言を日本語に自分で変換して表示し直しています。

var description = getString(R.string.error_occur)
if (it.exception.toString().contains("another account")) {
    description = "すでにこのメールアドレスは使用されています"
}

android.support.v7.app.AlertDialog.Builder(this)
                    .setTitle(R.string.error)
                    .setMessage(description)
                    .show()

この方法だと、Firebase側で文言の変更があった場合すぐ動かなくなります。

この方法はやりたくないので固定文言を出すとかして対処するしかないです。今後のアップデートに期待です。

割愛

ここでは割愛させていただきますが、パスワードをリセットするときにメールを送信するとか、パスワード確認メールを送信するとかの機能を実装することも可能です。

公式ドキュメントを貼っておきますのでよろしければどうぞ

2章 Cloud Messaging

Cloud Messagingとは

ざっくりいうと、アプリからPush通知を送るサービスです。

これはアプリ開発者として必須サービスだと思っています。なぜかというとGCM(Google Cloud Messaging)が廃止されこのサービスではFCM(Firebase Cloud Messaging)を使うことが必須になります。

導入

- Gradleに追加
app/build.gradleに下記を追加します。(Firebaseのバージョンについては最新の物をお使いください。)

dependencies {
     compile 'com.google.firebase:firebase-messaging:12.0.1'
}

- 2つのクラスを作成する

Push通知を使う上で下記のふたつのクラスを作成しなくてもいいですが、個人的には必須だと考えているのでぜひ作りましょう。(必須だと思う理由は下記に書いております。)

・FirebaseMessagingServiceクラスを継承したサービス

このクラスはサーバーからデータを受け取った時にいろいろ処理をしてから通知を出すときに、その処理をここに書きます。処理をなにも書かなくてもPushは届きますが、アプリがバックグランド状態にあるときだけしかPush通知が来ないし、端末をバイブしたりもしないのでこのクラスをカスタマイズすることを強くおすすめします。

・FirebaseInstanceIdServiceクラスを継承したサービス

このクラスは各ユーザーの端末のデバイストークンを取得するクラスです。
このクラスを実装しなくてもPush通知を送ることができますが、「特定のユーザーにだけPush通知を送る」などの実装をするときには確実に必要なのでこれも実装することを強くおすすめします。

実装する

通知チャンネルの作成

まずはチャンネルを作成しましょう。
Android 8.0から通知チャンネルという機能が追加され、Android8以上はかならず1つ以上実装する必要があります。
通知チャンネルは1つもない場合はPush通知を受信してもnotificationがでないので注意が必要です。

アプリ起動時に必ず一回はその処理を通る必要があるのでApplicationクラスを継承したものを使うとか、あまりよくないかもしれませんがBaseActivity的なクラスに通知チャンネルを作成する処理を書きましょう。
実際の実装は下記になります。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            // 通知チャンネルを作成する
            val channelId = getString(R.string.default_notification_channel_id)
            val channelName = getString(R.string.default_notification_channel_name)
            val notificationManager = getSystemService(NotificationManager::class.java)
            notificationManager?.createNotificationChannel(NotificationChannel(channelId,
                    channelName, NotificationManager.IMPORTANCE_LOW))
}

R.stringとかは適宜読みかえて実装してください。

FirebaseMessagingServiceクラスを継承したクラスを作成する

継承したクラスを作成し、AndroidManifest.xmlに登録しましょう。
下記のコードでoverrideしたonMessageReceivedで受信します。

class GroupSnapMessagingService: FirebaseMessagingService() {

    /**
     * メッセージを受信したら呼び出される
     */
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)

        Log.d("Push", "Push通知を受信しました from=" + remoteMessage.data)
    }
}

上で作成した、サービスクラスをマニフェストに登録しましょう。

    <service
            android:name="service.GroupSnapMessagingService"
            android:permission="true">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
    </service>

これでPush通知を受信するクラスの登録ができました。

そこからは、実際にnotificationを出す処理を書いていきます。

class GroupSnapMessagingService: FirebaseMessagingService() {

    /**
     * メッセージを受信したら呼び出される
     */
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)

        Log.d("Push", "Push通知を受信しました from=" + remoteMessage.data)

        showNotification(remoteMessage)
    }

    /**
     * notificationを出す
     */
    private fun showNotification(remoteMessage: RemoteMessage){

        val dataBody = remoteMessage.notification?.body
        val channelId = getString(R.string.default_notification_channel_id)
        val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId)
        notificationBuilder.setSmallIcon(R.drawable.ic_launcher)
        notificationBuilder.setContentTitle(getString(R.string.app_name))
        notificationBuilder.setContentText(dataBody)
        notificationBuilder.setAutoCancel(true)
        notificationBuilder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE or Notification.DEFAULT_LIGHTS)

        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        // Since android Oreo notification channel is needed.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channelName = getString(R.string.default_notification_channel_name)
            notificationManager.createNotificationChannel(NotificationChannel(channelId,
                    channelName, NotificationManager.IMPORTANCE_LOW))
        }

        notificationManager.notify(createNotificationId(), notificationBuilder.build())
    }

    /**
     * 通知IDを作成する
     */
    private fun createNotificationId() : Int{
        val now = Date()
        return Integer.parseInt(SimpleDateFormat("ddHHmmss", Locale.US).format(now))
    }
}

実際の処理は上のコードを参考にいただきたいと思いますが、ざっくり説明します。

val dataBody = remoteMessage.notification?.body

この処理で通知する情報と一緒に飛んで来た情報を取得します。

        val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId)
        notificationBuilder.setSmallIcon(R.drawable.ic_launcher)
        notificationBuilder.setContentTitle(getString(R.string.app_name))
        notificationBuilder.setContentText(dataBody)
        notificationBuilder.setAutoCancel(true)
        notificationBuilder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE or Notification.DEFAULT_LIGHTS)

        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        // Since android Oreo notification channel is needed.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channelName = getString(R.string.default_notification_channel_name)
            notificationManager.createNotificationChannel(NotificationChannel(channelId,
                    channelName, NotificationManager.IMPORTANCE_LOW))
        }

        notificationManager.notify(createNotificationId(), notificationBuilder.build())

あとはnotificationに表示したい情報を設定していきます。
詳しくはリファレンスにお読みいただきたいと思いますが、基本的に上で設定している情報の登録ができれば十分だと思います。

FirebaseInstanceIdServiceクラスを継承したサービス

このクラスはPush通知を送るユーザーをターゲティングしたりするときに必要になります。
自分はこのクラスでデバイストークンをCloud Firestoreに登録しています。

class GroupSnapInstanceIdService : FirebaseInstanceIdService(){

    override fun onTokenRefresh() {
        super.onTokenRefresh()
        val refreshToken = FirebaseInstanceId.getInstance().token
        Log.d("deviceToken", "デバイストークン=$refreshToken")
    }
}

このクラスを作成したら、AndroidManifest.xmlに登録しましょう。

        <service
            android:name="service.GroupSnapInstanceIdService"
            android:permission="true">
            <intent-filter>
                <action android:name="com.google.firebase.INSTANCE_ID_EVENT" />
            </intent-filter>
        </service>

これでデバイストークンが新しくなった瞬間にデバイストークンが取れるようになるので確実にユーザーごとのデバイストークンが取得できるようになります。

自分はデバイストークンが更新されたらその都度Cloud Firestoreに登録しています。

class GroupSnapInstanceIdService : FirebaseInstanceIdService(){

    override fun onTokenRefresh() {
        super.onTokenRefresh()
        val refreshToken = FirebaseInstanceId.getInstance().token
        Log.d("deviceToken", "デバイストークン=$refreshToken")

        if (refreshToken != null){
            setDeviceToken(refreshToken)
        }
    }

    /**
     * デバイストークンを更新する
     */
    private fun setDeviceToken(refreshToken: String){

        val uid = PrefUtil.getSpValStr(applicationContext, PrefUtil.UID, "1")
        if (uid != null && uid == "1"){

            PrefUtil.putSpValStr(applicationContext, PrefUtil.deviceToken, refreshToken)
            val db = FirebaseFirestore.getInstance()
            db.collection("user").document(uid).update("device_token", refreshToken)
        }
    }
}

この記事では書きませんが、Cloud Functionsと組み合わせて特定のユーザーにPush通知を送ることができます。ターゲティングしたPush通知を送りたい場合はこの実装でCloud Functionsを使いこなしましょう!

3章 Cloud Firestore

ここからはデータベースの話になります。

Realtime Database しかなかったときから触ってましたが、いろいろ辛いところがいろいろありました。
ですが、Cloud Firestoreの登場により辛いところがいろいろ改善され素晴らしいものになっています。

Cloud Firestoreとは

ざっくりいうとNoSQLで操作するデータベースのことです。

Realtime Databaseのときにはなかったクエリや、データの型が充実していたり、バッチ処理などにも対応していて素晴らしい物だと思います。

Cloud Firestoreについては素晴らしい記事がたくさんあります。

QiitaでのCloud Firestoreのタグで検索しただけでもたくさん素晴らしい記事が見つかります。
ここでデータをFirestoreに保存するときに考えることは「データ構造」だと思います。

自分はデータ構造をどのように設定したときは下記の記事にかちらっと書いておりますのでぜひご参考ください。

「使った技術」の下あたりから書いております。

あくまでこの記事では「AndroidでFirebase入門」なのでデータ構造などは割愛させていただきます。(そもそもこの辺は自分より他の記事を参考にした方が絶対いい)

導入

dependencies {
     compile 'com.google.firebase:firebase-firestore:12.0.0'
}

gradleに上記を追加してSyncします。

実装
データの登録

        val db = FirebaseFirestore.getInstance()
        val data = Hash<String, Any>()
        data["fish"] = "sakana"
        db.collection("group").add(data)
                .addOnSuccessListener{
                    // 保存成功
                 }.addOnFailureListener{
                    // 保存失敗
                 }

データは必ずHash<String, Any>の型で登録する必要があります。
valueの方の型はなんでもいいように見えますが、型はあくまでFirestoreで使える型に限定されています。

Firestoreに保存できない型でデータを保存しようとするとエラーが返ってきて保存できないので注意してください。

データの取得

データの取得も簡単です。

val db = FirebaseFirestore.getInstance()
db.collection("group").document(groupId)
       .get()
       .addOnSuccessListener{
            // 成功したとき
            val title = it["title"] as String
            val description = it["description"] as String
            val type = it["groupType"] as Long ←Number型はLongで受け取る
            // なんかの処理
       }
       .addOnFailureListener{
            // 失敗したとき
       }

ここで注意が必要なのですが、
データを保存したときにIntもしくはLongで保存した場合、FirestoreではNumber型で保存されます。そのデータを取得するときは必ずLong型で受け取ってください。Intで受け取るとクラッシュします。

データは画面に表示する分だけ取得して出す

Firestoreでよく使われる手法で、画面にデータを表示する分だけデータを取得しましょう。という考え方です。

追記:Qiitaにもうしこし詳しく記事を書きました。

例に挙げて説明します。

ユーザーが所属しているグループ一覧を取得したい!ってなったらときにまずはユーザーが所属しているグループID一覧を取得しにいきます。

/user/{userId}/group/

groupの下に所属しているグループID一覧が格納されています。

そこで取得できたgroupIDから画面に表示する分だけ、group情報を取得しにいきます。

/group/{groupId}

なぜこの方法をとるかというと、スマートフォンでは画面に表示できる情報量が限られています。Twitterアプリを見てもせいぜい5〜6個程度です。

表示できるデータが1万件あったときに1万件データをサーバーから返し画面に表示するように頑張ってもせいぜいユーザーがみるのは全体の2割程度しかありません。ですのでユーザーに見えている分だけデータを取得しましょう。これもFirestoreでよく使われる手法だそうです。

ですので、Androidの具体的な実装に当てはめていくと、まずはユーザーが所属しているグループID一覧を取得してArrayList<String>で持ちます。

val db = FirebaseFirestore.getInstance()
val list = ArrayList<String>()
db.collection("user").document(userId)
                .collection("group")
                .get().addOnSuccessListener {

                    dismissProgress()
                    if (it.isEmpty) return@OnSuccessListener

                    val groupIdList = ArrayList<GroupIdModel>()
                    val dataList = it.documents

                    dataList.forEach {
                        list.add(it.id)
                    }

                setGroupList(list)
            }.addOnFailereListener(failureListener)

これでユーザーが所属しているグループID一覧を取得できます。このリストをRecycleViewでList表示、Grid表示、Card表示するためのAdapterクラスに渡します。

AdapterクラスのonBindViewHolderでデータを取得グループ情報を取得しにいきましょう。

override fun onBindViewHolder(viewHolder: ViewHolder, i: Int) {
        val groupIdModel = groupIdList[i]

        val db = FirebaseFirestore.getInstance()
        db.collection("group").document(groupId)
                .get().addOnSuccessListener {

            if (it.exists()){

                val title = it["title"] as String

                if (it["description"] != null){
                    val description = it["description"] as String
                }

                if (it["icon_url"] != null){
                    val iconUrl = it["icon_url"] as String
                }

                viewHolder.layout.setOnClickListener {
                }

            }
        }.addOnFailerListener{
        })
    }

これであれば、onBindViewHolderはCellを表示する分だけ取得してくれます。

要件にGroupに所属しているユーザー一覧、Userが所属しているグループ一覧両方とる場合がある場合でも同じです。

/group/{groupId}/user/{userId}
/user/{userId}/group/{groupId}

というふうに相互参照できるようにデータ構造を設計しましょう。RDBではこの考えはあまりしないと思いますが、NoSQLでは結構主流の方法のようです。

ここで考えなればいけないのが相互参照できるように2回更新処理を投げた時に片方が失敗したらデータ不整合が起こるということになりかねません。

そこでに登場するのがBatch処理です。

あまり難しいものではありませんが同時に更新したいものがあればBatchとして処理一回スタックして1リクエストで一気に更新するというものです。

これで片方が失敗してデータ不整合が起こるということが無くなります。
データ登録に失敗したときは全てのバッチが失敗になります。

例として残しておきますが、自分はこんな感じでバッチ処理を書いています。

        val db = FirebaseFirestore.getInstance()
        val batch = db.batch()

        val groupUsersData = HashMap<String, Any>()
        groupUsersData[uid] = true
        groupUsersData["created_at"] = Date()
        groupUsersData["updated_at"] = Date()
        
        val groupUsers = db.collection("group").document(groupId)
                .collection("user").document(uid)
        batch.set(groupUsers, groupUsersData)

        val userGroupsData = HashMap<String, Any>()
        userGroupsData[groupId] = true
        userGroupsData["created_at"] = Date()
        userGroupsData["updated_at"] = Date()
        val userGroups = db.collection("user").document(uid)
                .collection("group").document(groupId)
        batch.set(userGroups, userGroupsData)

        val updatedTimeData = HashMap<String, Any>()
        updatedTimeData[groupId] = Date()
        val updatedTime = db.collection("updated_at").document(groupId)
        batch.set(updatedTime, updatedTimeData)

        batch.commit().addOnSuccessListener(successListener)
                .addOnFailureListener(failureListener)

更新、削除も可能

更新、削除とくにいうことはなくリファレンス通りにやれば問題ないと思います。

更新のリファレンス

削除のリファレンス

検索

検索処理はありますが、RDBほど強力ではありません。

基本的にリファレンス通りで実装できますし、「できること」が明確にかかれているのでリファレンスをご確認ください。

Elastic Searchで複雑なクエリを実行できるようですが自分はここまでキャッチアップできてないので本書ではかけません、、すいません、、、

4章 Firebase Storage

Firebase Storageとは

ざっくりいうとファイルストレージです。Amazon S3のようなものだと思っていただいて結構です。

どんな時に使うかというと主に写真や、動画の保存とかに使うと思います。
写真や、動画をStorageにアップロードし、生成されたダウンロードURLからPicassoなどライブラリを使ってアプリに画像を表示させるみたいな使い方が望ましいと思います。

導入

いつも通り、gradleに下記を追加

dependencies {
    compile 'com.google.firebase:firebase-storage:11.8.0'
}

実装

storageに保存

・StorageのURLを取得

Firebaseのコンソール画面→Storage→`gs://~~~`の部分をコピーする

        val storage = FirebaseStorage.getInstance()
        val storageRef = storage.getReferenceFromUrl(`コピーしたストレージURL`)
        val dir = storageRef.child(`保存するファイル名`)
        val task = dir.putFile(`保存するファイル`)
        task.addOnSuccessListener{
            // 成功したとき
            val url = it.downloadUrl
        }
        task.addOnFailureListener{
            // 失敗したとき
        }

これで実装できます。

`保存するファイル` とかかれているファイルはUri型で保存します。
Androidで撮影した画像や、ライブラリから選択した画像は実装内でローカルファイルパスを示すUri型で返ってくるのでそれをそのままStorageの実装に渡せば問題ありません。

`保存するファイル名`に関しては注意が必要です。
同じファイル名で保存すると新しくきた画像に上書きされてしまいます。
ですので、写真を複数保存したり場合など、絶対上書きさせたくない場合は一意になるファイル名を指定する必要があります。

自分はFirestoreで生成されるIDと1対1で画像を保存する仕様のためそのID名で画像を保存するようにしました。Firestoreで生成されるIDは一意になることが保証されるので1対1で保存できるようにテーブルを設計する必要があるかと思います。

ちなみにファイル名を`group_item/$imageId` みたいな書き方をするとStorage内でディレクトリで分けてくれるので便利です。

val url = it.downloadUrl

自分は保存が成功したときに生成されるダウンロード URLをFirestoreに保存しています。https://firebasestorage.googleapis.com〜〜〜〜の形式で吐き出されるのでそれをアプリ側でPicassoを使ってアプリに表示しています。

5章 本番環境と検証環境を分ける

検証と本番は[Build Variants]を使って分けましょう。
これはFirebase関係なく、Androidでプロダクトを開発する上でよく使うものだと思います。
Build Variantsの設定に適したディレクトリにgoogle-services.jsonを配置すれば問題ありません。

自分の環境では、[debug:検証環境 relese:本番環境] にしています。

6章 最後に

最後まで読んでいただいた方に少しでも力になれば幸せです。

投げ銭はいりません。それより無料でできる拡散をしてください!! 感想をツイートしていただけることが一番嬉しいです!!