MBaaS徹底入門――Kii Cloudでスマホアプリ開発

第8回Kii Cloudを用いたチャットアプリケーションの開発 [その4] ―メッセージ送受信機能の実装

前回は、チャットルームの実装について解説しました。第8回となる今回は、チャットアプリケーションのメイン機能であるメッセージの送受信機能を実装していきます。テキストメッセージのやりとりだけでは少し寂しいので、最近流行りのスタンプを送信できる機能も追加で実装したいと思います。

今回使用したソースコードについては、GitHubで公開しています。こちらもあわせてご覧ください。

メッセージ送受信機能の実装

実装に入る前に、第5回で説明したメッセージ送受信機能について簡単に確認してみましょう。新たにスタンプを送信する機能を追加することになったので、第5回で説明したデータフローにスタンプを保存するchat_stampsバケットが追加されています。

メッセージ送受信時のデータフロー
メッセージ送受信時のデータフロー
  • 新規スタンプのアップロード:ユーザは好きな画像を⁠chat_stamps⁠バケットにアップロードすることでスタンプとして利用できます。アップロードされたスタンプはチャットを利用するすべてのユーザが使用することができます。
  • スタンプ一覧の取得:スタンプを送信する場合は、まず、⁠chat_stamps⁠バケットを検索して利用可能なスタンプの一覧を取得します。その後、1件ずつスタンプの画像をダウンロードして画面に表示します。
  • メッセージの送信:メッセージの送信は⁠chat_room⁠バケットにKiiObjectを保存することで実現できます。チャットの相手は対象の⁠chat_room⁠バケットを購読しているはずなので、新しいKiiObjectを保存するだけで、自動的にプッシュ通知が相手に届きます。
  • メッセージの受信:メッセージの受信はプッシュ通知を契機におこないます。プッシュ通知を受信したら、対象の⁠chat_room⁠バケットを検索しメッセージを取得して画面に表示します。受信したメッセージがスタンプを含んでいる場合は、スタンプの画像をダウンロードします。同じ画像を何回もダウンロードする必要がないように、画像は端末にキャッシュとして保存しておきます。

この4つのフローの実装について、以下で説明していきます。

①新規スタンプのアップロード

スタンプはアプリケーションスコープの⁠chat_stamp⁠バケットに保存されるKiiObjectとして実装します。今まで説明してきたKiiObjectと異なる点は、Object Bodyとして画像ファイルを保持するところです。アップロード処理のフローは以下になります。

新規スタンプのアップロードのフロー
新規スタンプのアップロードのフロー

スタンプに関係する処理は基本的にChatStampクラスに実装されています。さっそく実際のコードを見ていきましょう。

public class ChatStamp extends KiiObjectWrapper {
   private File imageFile;
   private String uri;
   public ChatStamp(File imageFile) {
       super(getBucket().object());
       this.imageFile = imageFile;
   }
   public ChatStamp(KiiObject kiiObject) {
       super(kiiObject);
       this.uri = kiiObject.toUri().toString();
   }
   public ChatStamp(ChatMessage message) {
       super(KiiObject.createByUri(Uri.parse(message.getStampUri())));
       this.uri = message.getStampUri();
   }

   public void save() throws Exception {
       this.kiiObject.save();
       if (this.imageFile != null) {
           this.uri = this.kiiObject.toUri().toString();
           KiiUploader uploader = this.kiiObject.uploader(KiiChatApplication.getContext(), this.imageFile);
           uploader.transfer(null);
           // アップロードしたファイルを、KiiObjectのURIに応じた名前にリネームする
           File cacheFile = StampCacheUtils.getCacheFile(this.kiiObject.toUri().toString());
           this.imageFile.renameTo(cacheFile);
       }
   }
   ...
}

ChatStampクラスは大きく2種類のコンストラクタを提供しています。新たにスタンプを追加する場合は、ファイルを指定するコンストラクタを使用します。スタンプをKiiObjectとして保存した後は、そのKiiObjectのURIを指定するコンストラクタを使用します。

ChatStamp#save()メソッドでKiiObjectの保存と、画像ファイルのアップロードを行っています。

まずKiiObject#save() でKiiObjectを保存します。このタイミングでこのスタンプのURIが割り当てられます。次にKiiObject#uploader(Context, File) でKiiUploaderのインスタンスを生成し、KiiUploader#transfer(KiiRTransferProgressCallback)で画像ファイルをアップロードしています。実際にアップロードしているファイルはユーザがギャラリーアプリケーションから選択した画像ファイルを適切なサイズに縮小したファイルになります。

また、今回のケースではアップロードの進捗状況を管理する必要がないのでKiiRTransferProgressCallbackにはnullを指定していますが、サイズの大きいファイルなどをアップロードする場合はKiiRTransferProgressCallbackを使ってユーザに進捗状況を通知したほうがいいでしょう。

最後に、アップロードした画像ファイルをキャッシュに移動しています。こうすることでこのスタンプを再度表示する際にKiiCloudからダウンロードしないですみます。

②スタンプ一覧の取得

スタンプ一覧の取得は、大きく2つの処理に分割されます。

  • “chat_stamps⁠バケット内のすべてのオブジェクトを検索する(画像は含まない)
  • 取得したKiiObject全てに対して画像を取得する

“chat_stamps⁠バケットを検索しただけでは、スタンプの画像(Object Body)は取得できません。表示する必要が生じたタイミングでそれぞれ個別に画像をダウンロードする必要があります。

実際のソースコードは、画像のキャッシュ処理や、ダウンロードのキャンセル処理などを実装しているため、少々複雑になっていますが、ここではKiiCloudとのやりとりの部分に絞って説明します。興味のある方は是非、ソースコードをダウンロードして他の部分も読んでみてください。

まず、ChatStampクラスに実装されているスタンプ一覧を取得する処理を見ていきます。

スタンプ一覧の取得には第6回でも登場したKiiQueryを使用します。今回は、"chat_stamps"バケット内の全てのスタンプを取得して、⁠_created⁠プロパティの値によりソートします。

これはスタンプの表示順が毎回変わってしまうのを防止するためです。

“_created⁠はKiiObjectを保存するとKiiCloudが自動的に追加するプロパティでKiiObjectが保存された日時が保存されています。同様に更新日時が保存される"_modified"もあります。

これらのプロパティは一覧を時系列でソートしたい場合にとても役に立ちます。

public static List<ChatStamp> list() {
   List<ChatStamp> stamps = new ArrayList<ChatStamp>();
   try {
       // 作成日時でソートして、クエリ結果の順序を保証する
       KiiQuery query = new KiiQuery();
       query.sortByAsc(FIELD_CREATED);
       List<KiiObject> objects = getBucket().query(query).getResult();
       for (KiiObject object : objects) {
           stamps.add(new ChatStamp(object));
       }
       return stamps;
   } catch (Exception e) {
       Logger.e("Unable to list stamps", e);
       return stamps;
   }
}

つづいて画像をダウンロードする処理を見ていきましょう。アップロード処理のコードと非常に似ていて簡単に理解できるかと思います。

KiiObject#downloader(Context, File) KiiDownloaderのインスタンスを生成しKiiDownloader#transfer(KiiRTransferProgressCallback)で画像ファイルをダウンロードしています。

public Bitmap getImage() {
   try {
       byte[] image = null;
       if (this.imageFile != null) {
           // ファイルを指定してChatStampのインスタンスが生成された場合 (新規スタンプの追加時)
           image = readImageFromLocal(this.imageFile);
       } else if (this.uri != null) {
           // イメージがキャッシュされていれば、キャッシュから読み込む
           File cacheFile = StampCacheUtils.getCacheFile(this.uri);
           if (cacheFile.exists()) {
               image = readImageFromLocal(cacheFile);
           } else {
               // キャッシュに存在しない場合は、KiiCloudからダウンロードする
               Logger.i("downloads stamp image from KiiCloud");
               KiiDownloader downloader = kiiObject.downloader(KiiChatApplication.getContext(), cacheFile);
               downloader.transfer(null);
               image = readImageFromLocal(cacheFile);
           }
       }
       if (image != null) {
           return BitmapFactory.decodeByteArray(image, 0, image.length);
       }
       Logger.w("failed to download stamp");
       return null;
   } catch (Exception e) {
       Logger.e("failed to download stamp", e);
       return null;
   }
}

③メッセージの送信

メッセージの送信処理はChatActivityクラスに実装されています。メッセージはテキストとスタンプの2種類があり、どちらも⁠chat_room⁠バケットにKiiObjectとしてテキストを保存することで実現しています。

通常のテキストとスタンプの違いは、メッセージに⁠$STAMP:⁠というプリフィックスがついているかどうかです。スタンプの場合はこのプリフィックスの後にスタンプ画像を表すKiiObjectのURIが続きます。

コードは下記のようにChatMessageのインスタンスを生成して、KiiObject#save() メソッドで保存しているだけのシンプルな実装になっています。

// 送信ボタンが押された場合のテキストのメッセージを送信する処理
@Override
public void onClick(View v) {
   // 入力されたメッセージをバックグラウンドでKiiCloudに保存する
   btnSend.setEnabled(false);
   final ChatMessage message = new ChatMessage(kiiGroup);
   message.setMessage(editMessage.getText().toString());
   message.setSenderUri(KiiUser.getCurrentUser().toUri().toString());
   new SendMessageTask(message).execute();
}

// スタンプが選択された場合のスタンプを送信する処理
@Override
public void onSelectStamp(ChatStamp stamp) {
   // 選択されたスタンプをメッセージとしてバックグラウンドでKiiCloudに保存する
   new SendMessageTask(ChatMessage.createStampChatMessage(this.kiiGroup, stamp)).execute();
}

// バックグラウンドでKiiObject#save() メソッドを実行するインナークラス
private class SendMessageTask extends AsyncTask<Void, Void, Boolean> {
   private final ChatMessage message;
   private SendMessageTask(ChatMessage message) {
       this.message = message;
   }
   @Override
   protected Boolean doInBackground(Void... params) {
       try {
           // メッセージを保存する
           this.message.getKiiObject().save();
           return true;
       } catch (Exception e) {
           Logger.e("failed to send messsage", e);
           return false;
       }
   }
   @Override
   protected void onPostExecute(Boolean result) {
       if (result) {
           editMessage.setText("");
       } else {
           ToastUtils.showShort(ChatActivity.this, "Unable to send messsage");
       }
   }
}

//スタンプを表現するChatMessageを生成するファクトリメソッド
public static ChatMessage createStampChatMessage(KiiGroup kiiGroup, ChatStamp stamp) {
   ChatMessage message = new ChatMessage(kiiGroup);
   message.setMessage("$STAMP:" + stamp.getUri());
   message.setSenderUri(KiiUser.getCurrentUser().toUri().toString());
   return message;
}

④メッセージの受信

メッセージの受信処理は、プッシュ通知を契機に実行されます。

メッセージ受信処理のフロー
メッセージ受信処理のフロー

前回のチャットルームの実装で説明したのと同様にGCMPushReceiver#onReceiveにプッシュ通知を受け取った処理を記述します。今回はバケット内のオブジェクト作成を契機に、プッシュメッセージが通知されます。その場合のプッシュメッセージのメッセージタイプはPUSH_TO_APPになります。

受信したメッセージを表すReceivedMessageインスタンスは、メッセージタイプによってキャストするクラスが異なることに注意してください。

今回はメッセージタイプがPUSH_TO_APPなのでPushToAppMessageクラスにキャストしてメッセージからKiiObjectを取り出しています。

受信したメッセージが自身が所属するグループ(ChatRoom)宛のメッセージか確認した後にBroadcast Intentを使ってChatActivityに新規メッセージを受信したことを伝えます。

case PUSH_TO_APP:
   Logger.i("received PUSH_TO_APP");
   try {
       // 参加中のChatに新規メッセージが投稿された場合
       Logger.i("received PUSH_TO_USER");
       new Thread(new Runnable() {
           @Override
           public void run() {
               try {
                   KiiObject obj = ((PushToAppMessage)message).getKiiObject();
                   obj.refresh();
                   ChatMessage chatMessage = new ChatMessage(obj);
                   KiiGroup kiiGroup = KiiGroup.createByUri(Uri.parse(chatMessage.getGroupUri()));
                   // 自分がメンバーでないChatRoomは無視する
                   if (isMember(kiiGroup)) {
                       sendBroadcast(context, ApplicationConst.ACTION_MESSAGE_RECEIVED, chatMessage.getKiiObject().toJSON().toString());
                   }
               } catch (Exception e) {
                   Logger.e("Unable to subscribe group bucket", e);
               }
           }
       }).start();
   } catch (Exception e) {
       Logger.e("Unable to get the KiiObject", e);
   }
   break;

// BroadcastでActivityに新規メッセージを受信したことを伝える
private void sendBroadcast(Context context, String action, String message) {
   Intent intent = new Intent(action);
   intent.putExtra(ApplicationConst.EXTRA_MESSAGE, message);
   context.sendBroadcast(intent);
}

ChatActivityクラスでは、Broadcast Intentを受信した時にKiiCloudからメッセージの取得処理を実装します。具体的にはchat_roomバケットを検索して結果をListViewに表示します。検索の方法は2種類あり、条件無しで全件取得する方法と、最後にメッセージを取得した日時を検索条件にして、最新のメッセージのみを取得する方法です。最新のメッセージのみを取得する方法では、⁠_created⁠⁠ プロパティにKiiClause#greaterThan()メソッドを使って⁠◯◯より大きい⁠という条件を作成しています。

また、自身が送信したメッセージについてもサーバから取得して表示しているのは、一見すると無駄に思えますがメッセージの前後関係をメッセージがサーバーに保存された時間でソートするために必要な処理になります。

private final BroadcastReceiver handleMessageReceiver = new BroadcastReceiver() {
   @Override
   public void onReceive(Context context, Intent intent) {
       // GCMPushReceiverからBroadcast Intentを受信した場合
       updateMessage(false);
   }
};
private void updateMessage(boolean showProgress) {
   new GetMessageTask(showProgress).execute();
}
private class GetMessageTask extends AsyncTask<Void, Void, List<ChatMessage>> {
   private final boolean showProgress;
   private GetMessageTask(boolean showProgress) {
       this.showProgress = showProgress;
   }
   @Override
   protected void onPreExecute() {
       if (this.showProgress) {
           SimpleProgressDialogFragment.show(getSupportFragmentManager(), "Chat", "Loading...");
       }
   }
   @Override
   protected List<ChatMessage> doInBackground(Void... params) {
       try {
           ChatRoom chatRoom = new ChatRoom(kiiGroup);
           List<ChatMessage> messages = null;
           if (lastGotTime == null) {
               messages = chatRoom.getMessageList();
           } else {
               // 前にメッセージを取得済みの場合は、最後に取得したメッセージより新しいメッセージのみを取得する
               messages = chatRoom.getMessageList(lastGotTime);
           }
           if (messages.size() > 0) {
               // messagesは_createdで昇順にソート済みなので、リストの最後の要素が最新のメッセージとなる
               lastGotTime = messages.get(messages.size() - 1).getKiiObject().getCreatedTime();
           }
           return messages;
       } catch (Exception e) {
           return null;
       }
   }
   @Override
   protected void onPostExecute(List<ChatMessage> messages) {
       if (messages != null) {
           adapter.addAll(messages);
           adapter.notifyDataSetChanged();
       } else {
           ToastUtils.showShort(ChatActivity.this, "Unable to get message");
       }
       if (this.showProgress) {
           SimpleProgressDialogFragment.hide(getSupportFragmentManager());
       } else {
           vibrator.vibrate(500);
       }
       listView.setSelection(listView.getCount());
   }
}

// chat_roomを検索するクエリの生成 (ChatMessageクラス内)
public static KiiQuery createQuery(Long modifiedSinceTime) {
   KiiQuery query = null;
   if (modifiedSinceTime != null) {
       // 最新のメッセージのみ取得
       query = new KiiQuery(KiiClause.greaterThan(FIELD_CREATED, modifiedSinceTime));
   } else {
       // 全件取得
       query = new KiiQuery();
   }
   query.sortByAsc(FIELD_CREATED);
   return query;
}

メッセージをListViewに表示する処理はChatActivityクラスのインナークラスであるMessageListAdapterに実装されています。

具体的にはメッセージが自分自身が送信したメッセージかどうかによって表示レイアウトを変更して、メッセージがスタンプの場合はバックグラウンドでスタンプの画像を取得して表示します。画像のダウンロード処理は「 2. スタンプ一覧の取得」で説明したコードと同じです。

private class MessageListAdapter extends AbstractArrayAdapter<ChatMessage> {
   
   private static final int ROW_SELF_MESSAGE = 0;
   private static final int ROW_FRIEND_MESSAGE = 1;
   private static final int ROW_SELF_STAMP = 2;
   private static final int ROW_FRIEND_STAMP = 3;
   
   private final LayoutInflater inflater;
   private final String userUri;
   
   public MessageListAdapter(Context context, KiiUser kiiUser) {
       super(context, R.layout.chat_message_me);
       this.inflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
       this.userUri = kiiUser.toUri().toString();
   }
   @Override
   public View getView(int position, View convertView, ViewGroup parent) {
       ViewHolder holder;
       ChatMessage chatMessage = this.getItem(position);
       if (convertView == null) {
           switch (getRowType(chatMessage)) {
               case ROW_SELF_MESSAGE:
                   // ログイン中のユーザが送信したメッセージの場合
                   convertView = this.inflater.inflate(R.layout.chat_message_me, parent, false);
                   break;
               case ROW_SELF_STAMP:
                   // ログイン中のユーザが送信したスタンプの場合
                   convertView = this.inflater.inflate(R.layout.chat_stamp_me, parent, false);
                   break;
               case ROW_FRIEND_MESSAGE:
                   // 他のユーザが送信したメッセージの場合
                   convertView = this.inflater.inflate(R.layout.chat_message_friend, parent, false);
                   break;
               case ROW_FRIEND_STAMP:
                   // 他のユーザが送信したスタンプの場合
                   convertView = this.inflater.inflate(R.layout.chat_stamp_friend, parent, false);
                   break;
           }
           holder = new ViewHolder();
           if (chatMessage.isStamp()) {
               holder.message = null;
               holder.stamp = (ImageView)convertView.findViewById(R.id.row_stamp);
           } else {
               holder.message = (TextView)convertView.findViewById(R.id.row_message);
               holder.stamp = null;
       	}
           convertView.setTag(holder);
       } else {
           holder = (ViewHolder)convertView.getTag();
       }
       if (chatMessage.isStamp()) {
           ChatStamp stamp = new ChatStamp(chatMessage);
           imageFetcher.fetchStamp(stamp, holder.stamp);
       } else {
           String message = chatMessage.getMessage() == null ? "" : chatMessage.getMessage();
           holder.message.setText(message);
       }
       return convertView;
   }
   @Override
   public int getViewTypeCount() {
       // ListViewに表示する行の種類は「自分のメッセージ、スタンプ」「友達のメッセージ、スタンプ」の4種類あるので4を返す。
       return 4;
   }
   @Override
   public int getItemViewType(int position) {
       // 与えられた位置の行が、「自分のメッセージ」か「友達のメッセージ」かを判定する
       return getRowType(getItem(position));
   }
   private int getRowType(ChatMessage chatMessage) {
       if (TextUtils.equals(this.userUri, chatMessage.getSenderUri())) {
           if (chatMessage.isStamp()) {
               return ROW_SELF_STAMP;
           } else {
               return ROW_SELF_MESSAGE;
           }
       } else {
           if (chatMessage.isStamp()) {
               return ROW_FRIEND_STAMP;
           } else {
               return ROW_FRIEND_MESSAGE;
           }
       }
   }
}

以上で、メッセージ送受信機能についての解説を終えます。

今回の実装で簡単ではありますがメッセージのやりとりを行えるアプリケーションが完成しました。チャットアプリケーションのような通信アプリを作成する場合、サーバーサイドの実装が必要不可欠であり、実装、デプロイ、運用と様々なコストが発生します。

MBaaSの登場により、これらのコストを抑えて簡単にアプリケーションを実装することが可能になっています。無料プランから始められますので是非一度サインアップしてMBaaSの力を体験してみてください。

次回の第9回はチャットアプリケーションを拡張して、FacebookやTwitterと連携する方法を解説していきます。

おすすめ記事

記事・ニュース一覧