スマホアプリ開発を加速する、Firebaseを使ってみよう

第10回[最終回] Firebaseの実践的なテクニックを使いこなそう

これまでの連載でFirebaseのリアルタイムデータベースの基本的な使い方と、Google I/O 2016で発表された新しいFirebaseの機能の中からいくつかをピックアップしてご紹介しました。

今回は連載の締めくくりとして、この連載の初回でご紹介したわいわいチャットというリアルタイムチャットアプリケーションのソースコードをすべて公開し、コードを解説しながら実践的なテクニックをご紹介したいと思います。

「わいわいチャット」アプリの説明

まずこれから説明するアプリについておさらいしておきます。

わいわいチャットはシンプルなリアルタイムチャットアプリケーションです。起動するとTwitterアカウントによる認証画面が表示され、認証が完了するとチャット画面に遷移します。チャット画面では複数人によるリアルタイムなチャットが可能です。JPEGの画像をアップロードすることもできます。

図1 わいわいチャット 認証画面
図1 わいわいチャット 認証画面
図2 わいわいチャット Twitterアカウントによる認証
図2 わいわいチャット Twitterアカウントによる認証
図3 わいわいチャット チャット画面
図3 わいわいチャット チャット画面

プロジェクトの準備

ソースコードはGitHubに公開しました。

FirebaseRealTimeChat : Sample real-time chat application using Firebase
URL:https://github.com/srym/FirebaseRealTimeChat

Apache 2.0ライセンスの範囲内で、商用・非商用問わず自由にご利用いただけます。

プロジェクトのclone

さっそくアプリをビルドしていきましょう。プロジェクトを任意のディレクトリにcloneしてください。

$ git clone git@github.com:srym/FirebaseRealTimeChat.git

本記事連載時点のソースコードはstableというブランチ名で残しておきますので、以後stableブランチに切り替えて作業します。

$ git checkout stable

Firebaseプロジェクトにアプリを追加

以前の連載記事を参考に、FirebaseのWebコンソールに新しくプロジェクトを作成し、Androidアプリを追加してください。

図4 Firebaseにアプリを追加
図4 Firebaseにアプリを追加

このとき、パッケージ名はus.shiroyama.android.firebaserealtimechat.debugとする必要があります。

完了するとgoogle-services.jsonがダウンロードされるので、先ほどcloneしたプロジェクトの./app直下にコピーしてください。

Twitter認証の有効化

わいわいチャットではTwitter認証を利用します。FirebaseのWebコンソールから「Auth」を選択し、⁠ログイン方法」タブからTwitterログインを有効にします。

図5 Twitter認証を有効化
図5 Twitter認証を有効化

APIキーとAPIシークレットはこの後のステップで取得するのでいったん空欄のままにしておき、⁠コールバックURL」をクリップボードにコピーしてください。本設定画面は次のステップでAPIキーとAPIシークレットを取得するまでそのまま開いておきます。

Twitterアプリケーションの作成

Twitter認証で利用するAPIキーとAPIシークレットを取得するためにTwitterアプリケーションを新規作成します。https://apps.twitter.com/にアクセスし「Create an application」に必要事項をすべて入力してください。

「Name」は全Twitterアプリケーションで一意である必要があるため、重複しない名前を申請してください。⁠Callback URL」に先ほどクリップボードにコピーしたURLをペーストしてください。⁠Website」は一旦Callback URLと同じで構いません。

図6 Twitterアプリケーションの作成
図6 Twitterアプリケーションの作成

入力が完了したら下にスクロールし、利用規約をよく読んでから「Yes, I agree」にチェックを入れ「Create your Twitter applicaiton」を選択してください。

図7 同意して作成
図7 同意して作成

作成が完了したら「Keys and Access Tokens」タブに切り替えて、APIキーとシークレットをコピーしてください。

図8 APIキーとシークレットをコピー
図8 APIキーとシークレットをコピー

APIキーとシークレットを、先ほど設定途中のまま置いておいたFirebaseのWebコンソールのTwitter認証の設定にコピーして保存します。

保存が完了したらWebコンソールでTwitter認証が次のように有効になっていることを確認してください。

図9 Twitter認証の有効化完了
図9 Twitter認証の有効化完了

ビルドして動作確認

FirebaseのWebコンソールから「プロジェクトの設定」を選択し「プロジェクトID」をクリップボードにコピーしてください。

図10 プロジェクトIDの確認
図10 プロジェクトIDの確認

次に先ほどcloneしたレポジトリを開き、プロジェクト直下のsettings.propertiesというファイルを任意のテキストエディタで開き、TwitterのAPIキーとシークレット、それから先ほど確認したプロジェクトIDをそれぞれ設定してください。

firebase_project_id=your-project-id
twitter_key=your-twitter-api-key
twitter_secret=your-twitter-api-secret

以上で準備が整いました。Android Studioでcloneしたプロジェクトを開いてビルドし、エミュレータや実機でアプリが動作することを確認してみてください。

図11 Android Studioで動作確認
図11 Android Studioで動作確認

何かエラーが起きた場合は、以下に挙げる点等を確認してみてください。

  1. google-services.jsonを正しく./app以下にコピーできているか
  2. settings.propertiesの中身を正しく記述できているか
  3. https://apps.twitter.com/のCallback URLの設定はFirebaseのWebコンソールと合っているか
  4. FirebaseのWebコンソールでTwitter認証が正しく有効になっているか

ソースコードの解説

それではソースコードの中から重要な部分を抜粋して解説したいと思います。いま一度stableブランチに切り替えられていることを確認してください。

$ git checkout stable

ログイン処理

本アプリケーションのエントリポイントはLoginActivityです。実際のログイン処理はLoginHelperonCreate()メソッド内に書かれています。次のコードをご覧ください。

LoginHelper.java
// LoginHelper.java

twitterLoginButton.setCallback(new Callback<TwitterSession>() {
    @Override
    public void success(Result<TwitterSession> result) {
        TwitterSession session = result.data;
        TwitterAuthToken token = session.getAuthToken();
        AuthCredential credential = TwitterAuthProvider.getCredential(token.token, token.secret);

        firebaseAuth.signInWithCredential(credential)
                .addOnSuccessListener(authResult -> {
                    FirebaseUser firebaseUser = authResult.getUser();
                    String uid = firebaseUser.getUid();

                    UserInfo twitterUser = firebaseUser.getProviderData().get(1);
                    String name = twitterUser.getDisplayName();
                    String thumbnail = twitterUser.getPhotoUrl() != null ? twitterUser.getPhotoUrl().toString() : null;

                    // 中略

                    User user = new User(name, thumbnail);
                    databaseReference
                            .child(User.PATH)
                            .child(uid)
                            .setValue(user)
                            .addOnSuccessListener(userCreation -> {})
                            .addOnFailureListener(error -> {});
                })
                .addOnFailureListener(e -> {});
    }

    @Override
    public void failure(TwitterException exception) { }
});

Twitterの認証処理はTwitter社が公式に提供するFabric SDKのTwitterLoginButtonを利用しています。

TwitterLoginButtonを使ったログインが成功するとsuccess(Result<TwitterSession> result)に処理が渡り、その認証結果を使ってTwitterAuthProvider.getCredential(token.token, token.secret)でFirebaseAuthで利用するAuthCredentialを取り出しています。

次にfirebaseAuth.signInWithCredential(credential)で、先ほど取得したAuthCredentialを使ってFirebaseAuthでログインしています。

無事ログインできると、あとはauthResult経由でFirebase内で一意なユーザ識別子であるuidを取り出したり、サムネイルアイコンやログイン名を取り出しています。

最終的にUserエンティティに必要な情報を詰めてリアルタイムデータベースに格納しています。格納した情報はチャットのユーザ情報として利用します。

FirebaseAuthに関しては第5回の連載でパスワード認証について解説しており、連載第7回で新しいFirebaseでの変更点について説明しています。認証をどのプロバイダ(今回の例ではTwitter)でするかという違いこそありますが、一度ログインしてしまえば、その後の作法はパスワード認証もTwitter認証もほとんど違いがありませんので参考にしてみてください。

メッセージ受信時のテクニック

ログインが完了するとアプリはチャット画面に遷移します。チャット画面はChatActivityが担当します。処理自体はMessageHelperに大部分が移譲されています。

第3回等で解説したように、Firebaseのリアルタイムデータベースではデータの読み出しにValueEventListenerChildEventListenerの2種類のリスナが利用できるのですが、本アプリでは両方のリスナを併用しています。その使い分けのテクニックを紹介したいと思います。

ValueEventListenerの使いどころ

ValueEventListenerはあるパス以下のデータを一気に取得するためのリスナです。本アプリでは、以下の2ヵ所でValueEventListenerを利用しています。

  1. 初回アクセス時
  2. Pull To Refresh(引っ張り更新)

MessageHelperの次のコードをご覧ください。

private ValueEventListener singleShotListener = new ValueEventListener() {
    @Override
    public void onDataChange(DataSnapshot dataSnapshot) {
        for (DataSnapshot snapshot : dataSnapshot.getChildren()) {
            // 一気に取得したメッセージをリストに詰める
        }

        /* 中略 */

        // 更新ダイアログを非表示
        swipeRefreshLayout.setRefreshing(false);

        // この部分は後述
        databaseReference.child(Message.PATH).orderByChild(Message.KEY_TIMESTAMP).startAt(lastTimestamp + 1).addChildEventListener(childAddListener);
        databaseReference.child(Message.PATH).orderByChild(Message.KEY_TIMESTAMP).addChildEventListener(childRemoveListener);
    }

    @Override
    public void onCancelled(DatabaseError databaseError) { }
};

前述のように、初回アクセス時やPull to Refresh時にはChildEventListenerで要素の個数分毎回コールバックが発生するよりも一気に取得したほうが効率的です。また、一気に取得するのでデータの取得開始・取得終了のタイミングが明白であり、swipeRefreshLayout.setRefreshing(false)のように更新ステータスの表示/非表示を切り替えるような処理を明快に書くことができます。

本アプリではこのリスナをaddListenerForSingleValueEvent()に渡すことで、シングルショットのリスナとして利用しています。したがって一度データを受信した以後はデータを受信せず、その後のデータの受信はaddChildEventListener(childAddListener)に処理をバトンタッチしています。詳しくは次節で解説します。

ChildEventListenerの使いどころ

ChildEventListenerはあるパスにデータが追加・変更・移動・削除された時に呼び出されるリスナです。ValueEventListenerが初回データを受信して以降は、全メッセージをChildEventListenerで受信しています。MessageHelperの次のコードをご覧ください。

private ChildEventListener childAddListener = new ChildEventListener() {
    @Override
    public void onChildAdded(DataSnapshot dataSnapshot, String s) {
        Message message = getMessageWithId(dataSnapshot);
        messages.add(message);
        chatAdapter.notifyDataSetChanged();
        recyclerView.scrollToPosition(chatAdapter.getItemCount() - 1);
    }

    /* 以下略 */
};

これで、初回起動以降に届くメッセージは逐一リアルタイムにリストに反映されます。ポイントはこのリスナをセットしている次のコードです。

databaseReference.child(Message.PATH).orderByChild(Message.KEY_TIMESTAMP).startAt(lastTimestamp + 1).addChildEventListener(childAddListener);

startAt(lastTimestamp + 1)としている部分に注目してください。ここはValueEventListenerで一気にメッセージを取得した際に、一番最後に受信したメッセージのタイムスタンプを覚えておいて、それ以降に届くメッセージのみを受信すると指定しています。

こうしないと、addChildEventListener(childAddListener)したタイミングで、すでに一括取得したメッセージと同じものを重複取得してしまうのです。意外に陥りがちなミスなので参考にしてみてください。

2つのChildEventListener

もう一点ポイントがあります。MessageHelperでは次のようにChildEventListenerを2つ利用しています。これはなぜなのでしょうか。

databaseReference.child(Message.PATH).orderByChild(Message.KEY_TIMESTAMP).startAt(lastTimestamp + 1).addChildEventListener(childAddListener);
databaseReference.child(Message.PATH).orderByChild(Message.KEY_TIMESTAMP).addChildEventListener(childRemoveListener);

これはメッセージの削除処理と関係しています。

前節で解説したように、初回起動にはValueEventListenerでメッセージを一括取得し、以後はChildEventListenerで追加分のメッセージのみ受信していますが、一覧からどれかメッセージを削除するような場合は一括取得したメッセージも後から1件1件取得したメッセージも同様に削除でき、同期される必要があります。

もし受信時のようにstartAt(lastTimestamp + 1)と指定してリスナを登録してしまうと、一括取得したメッセージを消すことができないので、削除用のリスナは別途startAt()を指定せずに登録する必要があるのです。

過去のメッセージの取得テクニック

本アプリでは、初回起動時またはPull To Refresh時に最新のメッセージを50件分だけ取得します。残りのメッセージはリストの一番上までスクロールした際に追加取得される作りになっています。

追加取得のロジックはMessageHelper#onCreate内で実装しています。次のコードをご覧ください。

onScrollListener = new ScrollEdgeListener((LinearLayoutManager) recyclerView.getLayoutManager()) {
    @Override
    public void onTop() {
        databaseReference.child(Message.PATH).orderByChild(Message.KEY_TIMESTAMP).endAt(firstTimestamp - 1).limitToLast(LIMIT).addListenerForSingleValueEvent(new ValueEventListener() {
            @Override
            public void onDataChange(DataSnapshot dataSnapshot) {
                // 過去のメッセージを現在のメッセージリストにマージ
            }

            @Override
            public void onCancelled(DatabaseError databaseError) { }
        });
    }
};
recyclerView.addOnScrollListener(onScrollListener);

リストの最上部に到達したことはScrollEdgeListener#onTop()が通知してくれるので、ここで取得したメッセージを現在のメッセージ一覧にマージしています。さらに過去のメッセージがある場合はもう一度一番上までスクロールすると同様に追加取得します。次の部分がミソです。

databaseReference.child(Message.PATH).orderByChild(Message.KEY_TIMESTAMP).endAt(firstTimestamp - 1).limitToLast(LIMIT).addListenerForSingleValueEvent()

過去のメッセージを遡って取得するので、現在持っているメッセージ一覧の中で一番古いものを覚えておき、そのタイムスタンプを-1した時間までのメッセージを取得しています。こうすることで直近のメッセージを順に遡りながら取得することができます。

メッセージ送信時のテクニック

本アプリは単純なテキストメッセージの送信の他、JPEG画像のアップロードにも対応しています。またメッセージや画像は、自分が送信したもののみ長押しで削除の確認を求めるダイアログが表示されます。

図12 削除の確認ダイアログ
図12 削除の確認ダイアログ

これらの機能を実装する際に利用したテクニックをご紹介したいと思います。

タイムスタンプの付与

メッセージの送信はMessageHelper#send()で行っています。送信と言っても事実上Firebaseのリアルタイムデータベースにメッセージを保存しているだけです。

保存メッセージにタイムスタンプを付与するのはよくある処理だと思いますが、その際に端末時間等を利用してしまうと環境によってズレが発生したりするので、Firebaseのリアルタイムデータベースでは保存時にサーバ上のタイムスタンプを利用できる機能を提供してくれています。

まずはメッセージを表現するMessageクラスを見てみましょう。

public class Message {
    /* 中略 */

    private String body;

    @Exclude
    private long timestamp;

    /* 中略 */

    public Message() {
    }

    public long getTimestamp() {
        return timestamp;
    }
}

これまでの連載で、リアルタイムデータベースにオブジェクトをそのまま保存したい場合は、いわゆるシンプルなPOJOエンティティクラスを作成してsetValue(object)すればフィールドに対応する値が保存されることを見てきました。今回はタイムスタンプをFirebaseのサーバ上の値にするので、Messageクラスのtimestampフィールドを@Excludeアノテーションで修飾します。

次に保存するコードをご覧ください。

Message message = new Message(MessageType.NORMAL.ordinal(), loginInfo.getUid(), body);
DatabaseReference newMessage = databaseReference.child(Message.PATH).push();

newMessage
        .setValue(message)
        .addOnSuccessListener(result -> {
            newMessage
                    .updateChildren(new HashMap<String, Object>(1) {{
                        put(Message.KEY_TIMESTAMP, ServerValue.TIMESTAMP);
                    }})
                    .addOnSuccessListener(command -> {})
                    .addOnFailureListener(error -> {});
        })
        .addOnFailureListener(error -> {});

Messageオブジェクトにはtimestampを設定せずそのままsetValue(message)で保存します。次にその成功コールバックの中でupdateChildren()メソッドを呼び出してやり、キーがtimestamp値がServerValue.TIMESTAMPHashMapを渡してタイムスタンプのフィールドを更新しています。

こうすることで、Firebaseのリアルタイムデータベースにメッセージを保存した際にサーバサイドのタイムスタンプを付与してレコードを保存することができます。非常によく使うテクニックなのでぜひ覚えておいてください。

画像アップロードのテクニック

画像のアップロードには第8回で解説したFirebase Storageを利用しています。

アプリのメッセージ入力欄の左側にあるアイコンをクリックすると端末からファイルを選択する画面が現れ、JPEG画像を選択するとStorageHelper#uploadImage()が呼び出されて画像がアップロードされます。

public void uploadImage(String filePath) {
    String fileName = UUID.randomUUID().toString();
    StorageReference target = storageReference
            .child(IMG_DIR)
            .child(fileName);

    try {
        InputStream inputStream = new BufferedInputStream(new FileInputStream(filePath));
        target.putStream(inputStream)
                .addOnSuccessListener(taskSnapshot -> {
                    // アップロード完了を通知
                })
                .addOnFailureListener(e -> Log.e(TAG, e.getMessage(), e));
    } catch (FileNotFoundException e) { }
}

端末ローカルからアップロードする画像パスを取得し、ランダムな文字列でファイル名を指定してStorage上にアップロードしています。

アップロードが完了したらMessageHelper#onImageUploadSuccess()内で、メッセージの種類を画像と指定し、画像のURLも付与した上でMessageとして保存しています。こうすることで、テキストメセージも画像も同一のメッセージ一覧内に表示することができます。

メッセージ削除のテクニック

前述のとおり、メッセージや画像は自分が送信したものに限りロングタップで削除メニューが表示されます。

ロングタップを検出するコードはChatAdapter#onCreateViewHolder()内にあります。

@Override
public ChatViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    ChatViewHolder viewHolder;

    /* 中略 */

    viewHolder.itemView.setOnLongClickListener(view -> {
        int position = viewHolder.getAdapterPosition();
        bus.post(new OnLongClickEvent(position));
        return true;
    });
    return viewHolder;
}

削除を確認するダイアログはMessageDeleteDialogFragmentで表示しており、最終的にはMessageHelper#onItemeleteYes()内で削除処理を行っています。


public void onItemDeleteYes(int position) {
    Message message = messages.get(position);
    databaseReference
            .child(Message.PATH)
            .child(message.getMessageId())
            .removeValue()
            .addOnSuccessListener(result -> {
                if (message.isTypeImage()) {
                    bus.post(new ImageDeleteRequestEvent(message.getFileName()));
                }
            })
            .addOnFailureListener(error -> handleError(error, R.string.message_remove_error));

当該positionのメッセージをremoveValue()で削除した後、画像の場合はStorageに削除する処理を要求しています。

画像の削除処理はStorageHelper#deleteImage()で行っています。処理内容は第8回の連載がそのまま参考にできるので適宜ご参照ください。

その他のテクニック

以上で、駆け足ですがメッセージアプリとしての中核機能は解説できました。本節では、それ以外にアプリに組み込んだFirebaseの機能をかいつまんでご紹介したいと思います。

Remote Config

本アプリでは連載第8回で解説したRemote Configを使い、チャット画面の背景色を変更できるようにしています。

具体的には、LoginActivityを表示している時にRemoteConfigHelper#fetch()メソッドがバックグラウンドでサーバ設定の取得を試みます。

第8回の記事を参考にしながら、パラメーターキーをchat_bg_color値を#FFE4E1のような16進数のカラーコードを指定してみてください。

図13 Android Studioで動作確認
図13 Android Studioで動作確認

アプリを再起動して新しい背景色でチャット画面が表示されれば成功です。

取得できた値はChatActivity#onCreate()内のremoteConfigHelper.setBackgroundColor(recyclerView)で背景色として設定されます。サーバで値を未設定の場合やネットワーク接続がない場合は、remote_config_defaults.xmlから初期値が使われます。

セキュリティルールとインデックス

最後の仕上げとして、リアルタイムデータベースに適切なアクセス制御とインデックスの設定を行います。

今回のアプリでは、自分の投稿したメッセージと画像のみ削除できるという仕様ですが、それを担保しているのがアプリケーションコードだけという状況では、万一コードにバグが有った場合に意図せず他人の情報を削除してしまいかねません。そこで、リアルタイムデータベースのセキュリティルールでアクセス制限をかけたいと思います。

また、アプリ内で頻繁に使われるクエリはインデックスを活用することでパフォーマンスを向上することができます。こちらも設定してみましょう。

セキュリティルールとインデックスはどちらもFirebaseのデータベースのWebコンソールの「ルール」タブから設定するので、まずは次の設定例をご覧ください。

{
    "rules": {
        ".read": false,
        ".write": false,
        "users": {
            ".read": "auth != null",
            "$user_id": {
                ".write": "auth != null && auth.uid === $user_id"
            }
        },
        "messages": {
            ".read": "auth != null",
            ".indexOn": ["timestamp"],
            "$message_id": {
                ".write": "(auth != null && auth.uid === newData.child('senderUid').val()) || (auth != null && auth.uid === data.child('senderUid').val())"
            }
        }
    },
}

まずはユーザ一覧に関する設定は次の部分です。

"users": {
    ".read": "auth != null",
    "$user_id": {
        ".write": "auth != null && auth.uid === $user_id"
    }
},

メッセージ一覧で各ユーザのサムネイルを表示する必要があるため、ユーザ情報の閲覧権限は認証済みユーザならだれでも参照可能としています。 ただし、情報の作成や更新・削除は本人以外ができてしまうとまずいので、".write": "auth != null && auth.uid === $user_id"として本人以外の操作を制限しています。

次にメッセージ一覧に関する設定は次の部分です。

"messages": {
    ".read": "auth != null",
    ".indexOn": ["timestamp"],
    "$message_id": {
        ".write": "(auth != null && auth.uid === newData.child('senderUid').val()) || (auth != null && auth.uid === data.child('senderUid').val())"
    }
}

こちらも同様で、閲覧に関しては認証済みユーザには許可する設定にしています。

書き込みと削除に関しては少しだけ複雑です。まず新規書き込みに関する設定は(auth != null && auth.uid === newData.child('senderUid').val())の部分です。これから書き込まれるデータはnewData変数で参照できるので、認証済みかつ新規に書き込むメッセージの送信者が認証済みユーザ本人である場合に書き込みを許可しています。

削除に関する設定は(auth != null && auth.uid === data.child('senderUid').val())の部分です。すでに存在するデータはdata変数で参照できるので、認証済みかつそのデータを書き込んだユーザと削除しようとしているユーザが同一である場合にのみ削除を許可しています。

最後にインデックスの設定です。メッセージ一覧はどれもtimestampでソートを行うため、".indexOn": ["timestamp"]でインデックスを作成して読み出しを高速化しています。

セキュリティルールとインデックスに関しては、本連載の第6回でも扱っていますので、適宜ご参照いただければ幸いです。

これでプロダクションリリースとして公開できる設定が完了しました!おめでとうございます。

まとめ

以上で約半年間に渡ったFirebaseの連載はひとまず完了です。いかがだったでしょうか?

当初はリアルタイムデータベースと認証機能、静的WebサイトホスティングぐらいしかなかったFirebaseですが、⁠Google I/O 2016」で突如メジャーバージョンアップを果たし、その注目度と重要性は以前にも増して高まっていると感じています。

顧客のニーズが目まぐるしく変わり、素早い開発と改善を繰り返していかなければならない昨今ですが、Firebaseはこれからも我々モバイル開発者の大きな助けとなってくれることでしょう。本連載が、みなさまがFirebaseに興味を持つきっかけとなることを願ってやみません。

おすすめ記事

記事・ニュース一覧