これまでの連載でFirebaseのリアルタイムデータベースの基本的な使い方と、Google I/O 2016で発表された新しいFirebaseの機能の中からいくつかをピックアップしてご紹介しました。
今回は連載の締めくくりとして、この連載の初回 でご紹介したわいわいチャット というリアルタイムチャットアプリケーションのソースコードをすべて公開し、コードを解説しながら実践的なテクニックをご紹介したいと思います。
「わいわいチャット」アプリの説明
まずこれから説明するアプリについておさらいしておきます。
わいわいチャットはシンプルなリアルタイムチャットアプリケーションです。起動するとTwitterアカウントによる認証画面が表示され、認証が完了するとチャット画面に遷移します。チャット画面では複数人によるリアルタイムなチャットが可能です。JPEGの画像をアップロードすることもできます。
図1 わいわいチャット 認証画面
図2 わいわいチャット Twitterアカウントによる認証
図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にアプリを追加
このとき、パッケージ名はus.shiroyama.android.firebaserealtimechat.debug
とする必要があります。
完了するとgoogle-services.json
がダウンロードされるので、先ほどcloneしたプロジェクトの./app
直下にコピーしてください。
Twitter認証の有効化
わいわいチャットではTwitter認証を利用します。FirebaseのWebコンソールから「Auth」を選択し、「 ログイン方法」タブから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アプリケーションの作成
入力が完了したら下にスクロールし、利用規約をよく読んでから「Yes, I agree」にチェックを入れ「Create your Twitter applicaiton」を選択してください。
図7 同意して作成
作成が完了したら「Keys and Access Tokens」タブに切り替えて、APIキーとシークレットをコピーしてください。
図8 APIキーとシークレットをコピー
APIキーとシークレットを、先ほど設定途中のまま置いておいたFirebaseのWebコンソールのTwitter認証の設定にコピーして保存します。
保存が完了したらWebコンソールでTwitter認証が次のように有効になっていることを確認してください。
図9 Twitter認証の有効化完了
ビルドして動作確認
FirebaseのWebコンソールから「プロジェクトの設定」を選択し「プロジェクト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で動作確認
何かエラーが起きた場合は、以下に挙げる点等を確認してみてください。
google-services.json
を正しく./app
以下にコピーできているか
settings.properties
の中身を正しく記述できているか
https://apps.twitter.com/ のCallback URLの設定はFirebaseのWebコンソールと合っているか
FirebaseのWebコンソールでTwitter認証が正しく有効になっているか
ソースコードの解説
それではソースコードの中から重要な部分を抜粋して解説したいと思います。いま一度stable
ブランチに切り替えられていることを確認してください。
$ git checkout stable
ログイン処理
本アプリケーションのエントリポイントはLoginActivity
です。実際のログイン処理はLoginHelper
のonCreate()
メソッド内に書かれています。次のコードをご覧ください。
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のリアルタイムデータベースではデータの読み出しにValueEventListener
とChildEventListener
の2種類のリスナが利用できるのですが、本アプリでは両方のリスナを併用しています。その使い分けのテクニックを紹介したいと思います。
ValueEventListenerの使いどころ
ValueEventListener
はあるパス以下のデータを一気に取得するためのリスナです。本アプリでは、以下の2ヵ所でValueEventListener
を利用しています。
初回アクセス時
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 削除の確認ダイアログ
これらの機能を実装する際に利用したテクニックをご紹介したいと思います。
タイムスタンプの付与
メッセージの送信は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.TIMESTAMP
のHashMap
を渡してタイムスタンプのフィールドを更新しています。
こうすることで、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で動作確認
アプリを再起動して新しい背景色でチャット画面が表示されれば成功です。
取得できた値は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に興味を持つきっかけとなることを願ってやみません。