つよしつよし工房

リアルタイムDB「Firestore」を使ってお絵かきチャットを作ってみる

はじめに

Firebaseのデータベースサービス「Firestore」を使って、お絵かきチャットを作ってみました。この記事では、お絵かきチャットの実装について、ソースコードを交えて紹介したいと思います。

対象の読者

「チャットのような、リアルタイムにデータ連携するアプリを作ってみたいけど、実装が難しそう。。。」

こんな思いを持っている方にぜひご覧いただきたい内容になっています。読んでいただいた後には、

「Firestoreを使えば、とても簡単に実現できるんだ!」

と思っていただけるはずです。

Firebaseについての前提知識は不要です。実装紹介はVue.jsのコードですが、html, javascriptの基礎知識があれば概要は理解できるように説明したいと思います。

作ったアプリ

まずは、作成したアプリを紹介します。

複数人でボードを共有し、手書きで書いた線が全員に共有されるアプリです。離れた場所にいる相手と一緒に、ライブお絵かきを楽しんだり、言葉で伝えるのが難しいことを図で説明したいときなどに役立ちます。

最初に誰かが部屋を作成し、開いた部屋のURLを他のメンバーに配布して入ってもらえば、メンバー間でボードを共有することができます。

ホワイトボードを開く

実装紹介 - Firestore編

ここからは、お絵かきチャットを作成するためにやったことを紹介します。

まずはFirestore編ということで、Firebaseの登録から、同コンソール上での操作を解説してゆきます。

Firebase登録、Firestore初期化

このあたりは公式サイトに手順が書いてあるので簡単に流します。

  1. Firebaseの公式サイトでユーザ登録
  2. Firebaseプロジェクトを作成
  3. メニューの「Firestore」 から、ナビゲーションに従いFirestoreを初期化

Firestoreについて簡単に解説

Firestoreは、以下の特徴を持つデータベースです。

お絵かきチャットを作成するうえで大事な部分は、データをクライアントとリアルタイムで同期できる点です。認証とオフラインの機能は、今回は利用しません。

データ構造を考える

お絵かきチャットを実現するために必要なデータ構造について、構想を立てていきます。

Firestoreでは、コレクションドキュメントという概念があります。

また、ドキュメントはその下に更にコレクションを持つことができます。これはサブコレクションと呼ばれ、データ間に従属関係がある場合に利用されます。

お絵かきチャットでは、絵を共有する場である「部屋」と、部屋の中でユーザが描く「線」のデータ管理が必要になりますので、それぞれ、「rooms」「elements」というコレクションに収納しようと思います。線は各部屋ごとに扱うので、elementsはroomsのサブコレクションとします。イメージは下図のようになります。

※各ドキュメントの名称は、実際には連番ではなくハッシュ文字列になります。

線(elements)の各ドキュメントには、以下のような色と座標列のデータを持たせます。このデータにより、一本一本の線が表現されます。

{
    points: [{x: 100, y: 100}, {x: 200, y: 100}, {x: 200, y: 200}],
    color: "black"
}

なお、Firestoreはドキュメントを追加した時にコレクションがなければ自動的に作成される仕組みなので、コレクションをあらかじめ作っておく必要はありません。

アクセスルールの設定

FireStore管理画面の「ルール」タブから、各ドキュメントへのアクセス権限を設定してゆきます。以下の5種類のアクセスに対して、許可する条件を設定してゆきます。

※get,listは合わせてread、create,update,deleteは合わせてwriteとも表記されます。

参考までに、以下のルールにすると、すべてのユーザからのすべてドキュメントに対して、書き込み、読み込みを許可します。一応、この設定でアプリは動作します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

しかし、この設定だと、roomsコレクションに対してlistをすることにより、他の人が建てた部屋のIDが分かってしまい、勝手に入ることができてしまいます。

そこで、roomsコレクションのlistを禁止することで、部屋の一覧を取得できなくします。これで、部屋の作成者と、作成者からURLを受け取ったメンバー以外は部屋のIDを知ることができなくなるはずです。また、update, deleteも、現バージョンでは使用しないので禁止しておきます。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /rooms/{doc} {
      allow get, create: if true;
      allow list, update, delete: if false;
      match /elements/{doc} {
        allow read, write: if true;
      }
    }
  }
}

roomsの下にあるelementsコレクションは、すべてのアクセスを許可しました。部屋のIDを知っている時点で正規のメンバーとみなすため、特に制限は必要ないからです。

なお、今回のアプリでは認証を行わないため、条件は大雑把に true/false で指定していますが、Authenticationサービスと連携してユーザ認証をすることもできます。認証を使う場合のアクセスルールの記法については、この記事の対象外とします。

接続情報の取得

クライアントアプリケーションからFirestoreへ接続するためには、APIキーなどの接続情報が必要です。Firebase管理画面の「設定」から、以下のようなjson形式の接続情報が取得できるので、コピーして控えておきます。

{
  apiKey: "xxxx",
  authDomain: "xxxx.firebaseapp.com",
  databaseURL: "https://xxxx.firebaseio.com",
  projectId: "xxxx",
  storageBucket: "xxxx.appspot.com",
  messagingSenderId: "xxxx",
  appId: "xxxx",
  measurementId: "xxxx"
}

実装紹介 - Vue.js編

いよいよ、Vue.jsを使った画面の実装に進みます。ここでは、お絵かきの画面(WhiteBoard.vue)に絞り、かつエッセンスとなる部分だけをかいつまんで紹介します。コード全体が見たい方は、GitHubで公開していますので、ご覧ください。

プロジェクト作成と実装の下準備

まず、以下の準備を行います。ここでは説明を割愛させていただきます。

  1. Node.jsのインストール
  2. VueCLIのインストール
  3. VueCLIで新規プロジェクト作成

作成したプロジェクトにfirebaseパッケージをインストールしておきます。

> npm install firebase

さきほど取得した接続情報を使って、firebaseの初期設定をします。これにより、アプリはあなたのFirebaseを識別して接続できるようになります。この設定は、Vue.jsで共通のエントリポイントとなる main.js に記述しておきます。

import firebase from "firebase";
const firebaseConfig = {
  apiKey: "xxxx",
  authDomain: "xxxx.firebaseapp.com",
  databaseURL: "https://xxxx.firebaseio.com",
  projectId: "xxxx",
  storageBucket: "xxxx.appspot.com",
  messagingSenderId: "xxxx",
  appId: "xxxx",
  measurementId: "xxxx"
};
firebase.initializeApp(firebaseConfig);

初期処理:Firestoreへの接続とデータの同期

ここからは、お絵かきの画面(WhiteBoard.vue)のコードを紹介してゆきます。

まずはfirebaseパッケージをインポート。

import firebase from "firebase";

ページを開いたときに実行される初期処理から作ってゆきます。Vue.jsでは、初期処理をコンポーネントの関数 created() に記述してゆきます。

まず、firestoreのインスタンスを取得。

this.db = firebase.firestore();

次に、コレクションへの参照を取得します。今回必要なのは、現在の部屋における線のコレクション(elements)なので、以下のように記述します。なお、this.$route.params.roomId は、URLに埋め込まれたルームIDが収納されていますので、これにより今自分がいる部屋を特定できます。

this.elementsCollectionRef = this.db
  .collection("rooms")
  .doc(this.$route.params.roomId)
  .collection("elements");

続いて、ここが大事なポイントです。取得したコレクション参照に対して、onSnapshotコールバック関数を設定してあげます。このコールバック関数は、自分または他の誰かがコレクションへ変更を加えたときに呼び出されるものです。

this.elementsCollectionRef.onSnapshot((querySnapshot) => {
  this.elements = [];
  querySnapshot.forEach((doc) => {
    const element = doc.data();
    element.id = doc.id;
    this.elements.push(element);
  });
});

コールバック関数の中身を見ると、変更された後の最新の線のデータを this.elements という配列にすべてpushしています。結果として this.elements が常にDBの最新の状態と同期することになります。

表示処理:SVG要素テンプレートの構築

次に、this.elements を画面(html)に反映させる処理を作っていきます。

今回、お絵かきキャンバスの要素としてはSVGを利用しようと思います。SVGとはベクター形式の画像であり、タグ要素で描画を表すため、htmlの一部として表現できます。例えば、多角線を表す要素であるpolylineは下例のように stroke 属性で色を、points 属性で繋げる座標列(px)を表現します。

<polyline
  stroke="black"
  points="100,100 200,100 200,200"
  fill="none"
  stroke-linecap="round"
  stroke-width="5"
/>

Vue.jsでは、htmlの骨組み(template)をあらかじめ定義し、そこに変数をバインドしておくことで、変数が変化したとき自動的にhtmlも変化するようになります(リアクティブ)。以下のように、this.elements をバインドしたSVG要素のテンプレートを作成します。

<svg
  ref="svgElement"
  class="canvas"
  version="1.1"
  xmlns="http://www.w3.org/2000/svg"
  @mousedown.prevent="dragStart"
  @mousemove.prevent="dragMove"
  @mouseup.prevent="dragEnd"
  @touchstart.prevent="dragStart"
  @touchmove.prevent="dragMove"
  @touchend.prevent="dragEnd"
>
  <polyline
    v-for="element in elements"
    :key="element.id"
    fill="none"
    :stroke="element.color"
    stroke-linecap="round"
    stroke-width="5"
    :points="pointsAttr(element.points)"
  />
</svg>

svg要素の下にpolyline要素を配置していますが、Vue.jsのテンプレート記法 v-for を使うことで、配列 this.elements の各要素に対してpolyline要素を繰り返し配置します。なお、:stroke などのコロンで始まる属性は変数の値をバインドしています。

変数をバインドするとき、stroke 属性は element.color に入っている色の文字列をそのままバインドすればよいですが、points 属性は element.points が配列であるため、そのままバインドしても正しく表示されません。そのため、関数 pointsAttr を通して、カンマ・スペースつなぎの文字列に変換してからバインドします。

以上で、DBと同期した this.elements をバインドしたこのsvg要素も、DBとリアルタイムに同期するようになりました。

ユーザ操作による線の描画処理

次に、ユーザがマウスで画面に線を描く処理を作ります。さきほどtemplateのsvg要素に @mousedown.prevent 等のアットマークで始まる属性がありましたが、これはイベント関数のバインドを表します。バインドされた dragStart , dragMove , dragEnd の関数の中身は以下のとおりです。

dragStart() {
  const newElement = { points: [], color: this.selectedColor };
  this.elements.push(newElement);
  const rect = this.$refs.svgElement.getBoundingClientRect();
  this.dragMoveHandler = () => {
    if (event.touches) {
      event.clientX = event.touches[0].clientX;
      event.clientY = event.touches[0].clientY;
    }
    newElement.points.push({
      x: event.clientX - rect.x,
      y: event.clientY - rect.y,
    });
  };
  this.dragEndHandler = () => {
    if (newElement.points.length === 0) return;
    this.elementsCollectionRef.add(newElement);
  };
},
dragMove() {
  if (this.dragMoveHandler) {
    this.dragMoveHandler();
  }
},
dragEnd() {
  if (this.dragEndHandler) {
    this.dragEndHandler();
    this.dragMoveHandler = null;
    this.dragEndHandler = null;
  }
}

各関数の概要は以下の通りです。

コードではイベントハンドラ関数を着脱したり、svg要素上のポインタの相対座標を求めたりしていますが、本題から逸れるため説明は割愛させていただきます。

最終的に、dragEnd でFirestoreにデータが追加されるので、その瞬間に、同じ部屋に入っている全クライアント(自分含む)では onSnapshot で設定したコールバック関数が呼び出され、あなたが描いた線が自動的に表示されます。

以上のようにして、お絵かきのリアルタイム同期を実装することができます。

さいごに

実装の紹介は以上になります。かいつまんでの説明となったため、ここで紹介しきれていない部分もありますが、リアルタイムアプリを作るイメージは掴めたと思います。

お絵かきチャットに限らず、Firestoreを利用すれば様々なリアルタイムアプリを簡単に作成できるので、ぜひ、あなたのアイデアを形にしてみてください。