Open6

手書きホワイトボードのKakeruに関する技術的な話

Kaido IwamotoKaido Iwamoto

そもそもKakeruって何?

Kakeruは、手書きメモやホワイトボードのためのWebアプリです。
PCやタブレット、スマホなどの色々なデバイスで利用できます。

書いたものがクラウドに保存されるのはもちろんですが、URLを共有すれば他の人にリアルタイムで内容を共有したり、一緒に書き込んだりすることができます。

マウスや指を使ってフリーハンドで書き込むだけのシンプルな作りですが、気軽なメモや学習時のノートなどに利用していただいています。
ご興味のある方はぜひお試しください。

https://about.kakeru.app/ja

ちなみに、Kakeruは2019年から開発しています。
趣味でなんとなく作って公開しておいたところ、検索からの流入によってこの2年くらいは結構ユーザーが増えています

Kaido IwamotoKaido Iwamoto

データの保存について

描いた線の情報やタイトルなどのメタデータは全てGoogle Firestoreに保存しています。
料金が0円から始まって規模が大きくなってもスケールし、リアルタイム同期もできるためFirestoreを選びました。

作り始めた当初は、WebSocketで接続したバックエンドがMongoDBにデータを保存するという構成も試したのですが、バックエンドを作り込んだりメンテしたりするのが手間なのでやめました。
このアプリケーションには、Firestoreはなかなか悪くなかったです。
ただ、データが大量に溜まってくるとストレージ料金がそこそこかかるようになります。

レコードの構造

/pictures/{pictureId} にボードのメタデータなどを保存し、 /pictures/{pictureId}/paths/{pathId} にパスの情報を保存しています。

つまり、描いた線1本に1つのpathsサブコレクションのレコードが割り当てられています。

なかなか富豪的な使い方ですよね?
これには、一応いくつかの理由があります。

  • 描いた線の数が増えるとデータ量が増えるので、1つのレコードに保持するとサイズ制限を超えてしまう可能性がある
  • 高頻度でデータが更新されることがあるので、レコードを分散させた方が安心
    (Firestoreのドキュメントによると、1つのレコードは1秒に1回まで更新が可能なため)
  • 消しゴムや投げ輪ツールによるパスの操作をする際に、1パス1レコードになっていた方が楽である

(この辺はFirestore特有の細かい話なので、誰得感がありますが...)

おかげでストレージの容量が膨らんでしまい、現在100GBくらいです。

ボードのメタデータ

ボードのメタデータは、こんな情報があります

  • タイトル
  • 作成、更新日時
  • 作者のID
  • アクセスレベル
    • 誰でも読み書き可
    • 誰でも読み込み可
    • 自分だけ読み書き可

パスのメタデータ

  • ベジェ曲線の制御点のリスト(をバイナリで表現したもの)
  • 太さ
  • X,Y方向のオフセット
  • タイムスタンプ

pointsという名前で保存しているベジェ曲線の制御点のリストに関しては、色々な歴史があります。

  • 最初はベジェ曲線ではなく、ただの点列だった(x,y座標を交互に入れた数値の配列)
  • 次に、描いた線を滑らかにするためにベジェ曲線の制御点列に(同様の数値の配列)
  • 去年、データサイズを減らすためにバイナリ化

バイナリ化に関して少し補足します。

  • Firestoreでは、数値の配列は要素数×8バイトのサイズ(=Float64の配列)で計算される
  • そこで、ストレージ容量の増加を抑えるためにFloat32を使ってデータを保存することに
    • 制御点のx,y座標を交互に入れたFloat32Arrayを用意し、その裏にあるバイト列(Uint8Array→Bytes)をFirestoreに保存する
    • 結果として、かなりストレージ量の増加を抑えられた
      • 既存のデータの一部も変換を行ったところ、全体のデータ量が2/3くらいに

オフセットは、投げ輪ツールでパスを移動した時に設定されます。
パスを移動した際に、ベジェ曲線の制御点全ての座標を変更するのはややコストがかかるのではと思い、導入しました。

タイムスタンプは、パスを描き終わったタイミングのものです。
パスの一覧をタイムスタンプの順番で取得することで、描いた順にパスを重ねて描画することができます。
そこまでは良かったのですが、パスをコピペした際にこの順番が崩れてしまうことに去年気づきました。
そのうち直そうと思っています...。

Kaido IwamotoKaido Iwamoto

そもそもデータに関しては、古いデータも永遠にFirestoreに保存しなくても良いのでは?と思っています。

古いボードはアーカイブ済み=読み込み専用にして、データは何らかのファイルに書き出してS3のようなストレージに置いておくのです。
そうするとかなりストレージを節約できて良いのでは...?と思っています。

(有料会員はアーカイブされるまでの期間が長い、とかの差別化も)

Kaido IwamotoKaido Iwamoto

1つ、データに関する小ネタを。

パスの点列のデータには色々な歴史があると書きましたが、今も古い形式のデータは残っています。
例えばこちら↓

リンク先で拡大していただくと分かるのですが、線がガタガタしています。

まず、Firestoreのデータはそのまま描画部分で扱うことはせず、FirestoreのnpmパッケージにおけるDataConverterを使って変換を行なっています。
生の点列(Array<number>)を座標の配列(Array<{x: number; y: number}>)に変換したり、
バイナリ化されたパスとバイナリ化されていないパスのデータを同じ形式で表現したりといったように。

その後、描画のコードにおいてはベジェ曲線と点列の2種類が入力として与えられるようになります。
コアの描画に関する処理は非常にシンプルなので、現在も2つのパターンが正しく描画できるようになっています。

(画像の話は後で書きますが、ボードの画像を生成して配信する処理も、今までの全てのデータ形式に対応するようにしてあります)

Kaido IwamotoKaido Iwamoto

フロントエンドの技術構成

使用している主な技術:

  • React
  • react-router
  • styled-component
  • Firebase
  • ビルド周り
    • TypeScript
    • Vite
    • Workbox
    • Prettier
    • ESLint
Kaido IwamotoKaido Iwamoto

描画について

画面の大部分を占めるボードの内容を表示する領域には、canvas要素を使っています。
そのcanvas要素でコンテンツを表示したり、書き込みを受け付けたりする仕組みを紹介します。

canvas要素をレンダリングしているのは、Canvasというクラスコンポーネントです。
このコンポーネントは、canvas要素を表示したのち、ほとんど更新しません。
マウント時にcanvasのcontextを取得し、あとはそれを利用して内容の描画などを行います。

drawメソッド

画面に表示すべきもの全てを描画するメソッドです。

スクロール位置などを加味して画面内に描画されるべきパスや、現在描いている線、スクロールバー、投げ輪ツールの軌跡など、いろいろ描画するものがあります。
CanvasRenderingContext2Dオブジェクトを使って、一度画面をクリアしたのち、素直に図形を描きます。

ただ、基本的にこのdrawメソッドを直接呼ぶことはありません。

tickDrawメソッド

drawメソッドを呼び出すためのメソッドです。
パスの情報が更新された時、スクロール位置やズームが変更された時、線を描いている最中にポインターイベントが発火した時など、画面を再描画する必要がある際にこれを呼びます。

ただし、直接drawメソッドを呼び出すわけではなく、requestAnimationFrame経由で呼び出します。
こうすることで、最適なフレームレートで画面を描画することができます。
元々iPadやスマホで快適に動作することを目指して作ったので、canvas + requestAnimationFrameで描画のパフォーマンスを上げることは非常に高い優先度がありました。

(続く)