😫

Watermelon『Realmよ。俺と戦え。』 AsyncStorage『。。。。。』

2020/12/23に公開

React Native Advent Calendar 2020の23日目の記事です。

WatermelonDB?

WatermelonDBはReactNativeとReactでオフラインデータベースを扱うライブラリです。
データベースにはSQLiteとLokiJSを使うことができます。
アプリの起動速度に重きをおいている、lazyアクセスが得意なライブラリです。

オフラインデータベース

なぜオフラインデータベースを利用するか。
それは、起動速度や電波状況にかかわらずアプリケーションを利用することができるからです。
永続化の簡単な例として一番簡単な方法は、ReduxやMobXをアダプターを使ってAsyncStorageに永続化する方法です。しかし、アプリが数千、数万のデータを持つと起動が遅くなります。全データを読み込むにはコストがかかるためです。特にこれはアンドロイドで顕著に現れます。

Watermelonの強みとライバル

Watermelonでは、アクセスされるまでは何もロードされません。
クエリはネイティブスレッドで実行されるので、ほとんどのクエリは即時解決されます。

似たような仕組みとしては、Realmがあります。

…という堅い感じの紹介は終えて、いきなり結論にいきましょう!笑

Realm vs Watermelon vs AsyncStorage

早速ですが、計測のための簡単なTodoアプリを作りました!
画面上のUIはこうなっています。

それぞれのボタンを押すと

  • load: WatermelonDBからデータを読み出し、レンダリング
  • load3: AsyncStorageからデータを読み出し、レンダリング
  • load4: Realmからデータを読み出し、レンダリング

をします。
また、読み出すデータは

type Todos = Array<{
  title: string,
  is_done: boolean,
  miniLists: [{
     is_done: boolean,
     title: string, // <-1000文字
  }...],  // <- 一つのTodoにつきminiListsは5つ
}>

という構造になっています(名前は適当です、すみません…)。
この構造体のTodoを1万件格納しています。
これらのデータを読み出しレンダリングに必要な時間を計測してみます。
なお、レンダリングする項目は

  • Todoのタイトル
  • Todoに紐づく、全てのminiListのタイトル

です。

決着

ということで早速結果を見てみましょう!!
(iOSのエミュレーターとdevモードになっています・・・。動画編集が大変だったのでお許しください・・・。時間があればproductionモードAndroidなども追記します。)

↑60fpsで撮影しています。

結果、

  • watermelon: 40フレーム(668ms)
  • realm: 57フレーム(951.9ms)
  • AsyncStorage: 230フレーム(3841ms)
    (60フレームで1秒です)

となり、一番早かったのはWatermelonでした!
一度の検証なので状況が変われば結果も変わるかもしれませんが、めちゃくちゃ結果が覆ることはないと思われます。

RealmってSQLiteよりもX倍かパフォーマンスいいんじゃないの?

その通りです!普通に使った場合SQLiteはRealmよりも遅いみたいです。
ということはSQLiteをつかっているWatermelonも遅いはずなんですが・・・

その答えはLazyという点です!。
『Lazyって何?』っていいますと、アクセスするまで取得が行われないということを指します。

Realmでは、

  const todo = realm.objects('Todo');

このコードでTodoを全件取ってくるんです。
それも、4ms(0.4フレームぐらい)というかなり短い間に。
なぜこんなに早いのかというと実はデータちゃんと取ってないんですよね・・・。
その代わり、この処理は高速で行われます。

いつ取得されるかというと、

  const todo = realm.objects('Todo');
  const firstTodoTitle = todo[0].title;

このようにアクセスすると初めてデータが取得されるようになっています。
めちゃめちゃ賢い仕組みで、素晴らしいです!

このLazyの仕組みをSQLiteで使っているのがWatermelonです。
Watermelonで同じようにコードを書くと、

  const todosCollection = database.collections.get('todos');
  const todos = await todosCollection.query().fetch();
  const firstTodoTitle = todos[0].title;

このようになります。
この段階ではRealmほどではないですが、ある程度の量はlazyに読み込まれるようになっており、だいたい55msかかります。ここで時間がかかる代わりに、

const titleList = todos.map(todo => todo.title);

といった処理には滅法強いです。これに対してRealmでこのような処理をした場合は時間がかかります。

ここら辺がWatermelonの強みといったところですね。
この挙動がまさに起動時に重きを置いているといった点ですね。

Watermelonの弱み

もちろん素晴らしい仕組みをいくつか持っています。

とはいえ、いけていないところも何点か・・・。

  • ドキュメント少ない
  • 事例少ない
  • リレーションを持つと面倒

詳細は個別に書いていきますが、以上の点から『攻めとしてはいいけど守りはRealm』だと思いました。

ドキュメント少ない

Realmのドキュメントはかなり読みやすいと思います。
(日本語版もあるように見えるけど、かなり古いバージョンなので注意)。

件のWatermelonDBのドキュメントはこちら!
watermelonDB documantation

これを読んでいくと。。。

T...TODO...!!
とかがあったり。。。

リレーションを持つときのhow to』が全然ドキュメントの中になかったりなど、どうすんねん・・・・。っていうことが多々ありました。

事例少ない

あんまり利用例や書き方が載っていなかったりして結構めんどくさかったです・・・。
参考になるものとしては

ぐらいでしょうか。

リレーションを持つと面倒

詳しい書き方は後で書きますが、

type MiniList = {
  title: string,
  is_done: boolean,
};
type Todos = Array<{
  title: string,
  is_done: boolean,
  miniLists: MiniList[],
}>;

このような構造を持つとき、miniListはTodoとリレーションを持っていることにします。
そして、ここでいま『miniListsを取得したい』とします。
するとコードはこのようになります。

  const todosCollection = database.collections.get('todos');
  const todos = await todosCollection.query().fetch();
  const firstTodoMiniList = await todos[0].miniLists.fetch();

よく見てください。
await todos[0].miniLists.fetch()とminiListを取得するために一手間かかっており純粋なアクセスではなくなっています。
それが、Realmではこのようになります。

  const todo = realm.objects('Todo');
  const firstTodoMiniList = todo[0].miniLists;

とても直感的です。
ここら辺もWatermelonの辛みと言えるかなと思います。
(もっと直感的なインターフェースがあったらお教えください。)

以上の点が弱みと感じたところになります。
攻めて爆速にしたいのか、速度を上げながら安定的にしたいのか、など要件に合わせて使っていくのが良いと思いました。

実装方法

実装方法は3つに分けて説明します。

  • データベースの構築
  • データの操作
  • オブザーバー

データベースの構築

スキーマ定義

import {appSchema, tableSchema} from '@nozbe/watermelondb';

export default appSchema({
  version: 18,
  tables: [
    tableSchema({
      name: 'todos',
      columns: [
        {name: 'title', type: 'string'},
        {name: 'is_done', type: 'boolean'},
      ],
    }),
    tableSchema({
      name: 'mini_lists',
      columns: [
        {name: 'title', type: 'string'},
        {name: 'is_done', type: 'boolean'},
        {name: 'todo_id', type: 'string', isIndexed: true},
      ],
    }),
  ],
});

割と直感的に書けます。
columnsのtypeにはstring, boolean, numberの3つを選ぶことができます。
idは自動で挿入されるため記述の必要はありません。
name, type の他には

  • isOptional: boolean: オプショナルな値か
  • isIndexed: boolean: インデックスをするか

を指定することができます。

また、versionは非常に便利な機能で、versionを更新することによってデータベースをリセットすることができます。
ただし、プロダクション運用を開始したフェーズでは、データはリセットしたくないけどスキーマだけを変更したいシチュエーションが発生します。
この時にはMigrationsを使います。

モデル定義

モデルの定義です。

// TodoModel
import {Model} from '@nozbe/watermelondb';
import {children, field} from '@nozbe/watermelondb/decorators';

export default class Todo extends Model {
  static table = 'todos';
  static associations = {
    mini_lists: {type: 'has_many', foreignKey: 'todo_id'},
  };
  @field('title') title;
  @field('is_done') is_done;
  @children('mini_lists') miniLists;
}
// MiniListModel
import {Model} from '@nozbe/watermelondb';
import {field, relation} from '@nozbe/watermelondb/decorators';

export default class MiniList extends Model {
  static table = 'mini_lists';
  static associations = {
    todos: {type: 'belongs_to', key: 'todo_id'},
  };
  @field('title') title;
  @field('is_done') is_done;
  @relation('todos', 'todo_id') todos;
}

Modelを継承して、staticとして

  • table: テーブル名
  • associations: リレーション
    • type: 1対多、多対1などのリレーション関係をいれます。
    • foreignKey: 外部キー。自動的に定義されるidと紐づきます。
    • key: 結合キー

以上のプロパティを定義してリレーション関係を明示的にします。
クラスなので、独自のメソッドをここで定義することもできます。

fieldデコレーターを使ってアクセスしたいプロパティを定義します。

コネクター定義

// database
import {Database} from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
import Todo from './todo'; // TodoModel
import MiniList from './miniList'; // MiniListModel
import schema from './schema';

const adapter = new SQLiteAdapter({
  schema,
});

export const database = new Database({
  adapter,
  modelClasses: [Todo, MiniList],
  actionsEnabled: true,
});

コネクターを定義します。
ここではSQLiteを選んでいますが、webアプリケーションの場合などのために、LokiJSを使うことができます。

データの操作

コレクションの取得

// todosコレクションの取得
export const todos = database.collections.get('todos');

コレクションの取得はこのようにします。
コレクションを取得することによってデータを操作することができるため必須ともいえるコードです。

オブザーバーの取得

import {database} from '../model'; // database
// オブザーバーの取得
export const observerTodo = () => todos.query().observe();

オブザーバーの利用例については後述します。

Read

import {Q} from '@nozbe/watermelondb';

export const getAllTodo = () => todos.query().fetch();
export const getTodo = (title: string) => todos.query(Q.where('title', title)).fetch();

読み込みはこのように行います。
クエリはQをインポートすることででき、ここで検索条件をつけることができます。

Create

export const addTodo = async ({title, miniList}) => {
  await database.action(async () => {
    await todos.create((entry) => {
      entry.title = title;
      entry.is_done = false;
    });
  });
};

createはこうします。
このコードではTodoを作成し、データベースに保存しています。

      entry.title = title;
      entry.is_done = false;

ちょうどこの辺りが永続化するデータに値を付与している箇所になります。
このときidは自動的に作られるため代入する作業は不要です。

リレーションを持つときのCreate

export const addTodo = async ({title, miniList }) => {
  await database.action(async () => {
    await todos.create((entry) => {
      entry.title = title;
      entry.is_done = false;
      entry.collections.get('mini_lists').create((childEntry) => {
        childEntry.todos.set(entry);
        childEntry.title = miniList.title;
        childEntry.is_done = miniList.is_done;
      });
    });
  });
};

リレーションを持つ場合はこのようにします。

      entry.collections.get('mini_lists').create((childEntry) => {
        childEntry.todos.set(entry);
        childEntry.title = miniList.title;
        childEntry.is_done = miniList.is_done;
      });

この箇所がリレーションを持つデータを作成する際のキモになります。
まずコレクションを取得して、createメソッドの中でTodoと同じ形式でデータに値を与えていきます。
このとき、childEntry.todos.set(entry)と親のentrysetすることによってデータの紐付けが行われます。

Update

export const updateDone = async (todo) => {
  await database.action(async () => {
    await todo.update((todo) => {
      todo.is_done = !todo.is_done;
    });
  });
};

Createのときのcreateupdateに変えただけです。簡単ですね!

Delete

export const deleteTodo = async (todo) => {
  await database.action(async () => {
    await todo.markAsDeleted();
    await todo.destroyPermanently();
  });
};

markAsDeleteddestroyPermanentlyのメソッドを実行することで削除されます。

オブザーバー

オブザーバーの利用例を書いていきます。
オブザーバーの機能を使うことによってデータベースが変更された際に自動でコンポーネントを更新することができるようになります。
ただし、その分ズブズブ度合いが上がります。

import React from 'react';
import {FlatList} from 'react-native';
import Todo from './Todo';
import {observerTodo} from '../helpers/watermelon';
import withObservables from '@nozbe/with-observables';

function Todos(props) {
  return (
    <FlatList
      data={props.todos}
      renderItem={({item}) => <Todo item={item} />}
    />
  );
}

const enhanceTodos = withObservables([], () => {
  return {
    todos: observerTodo(),
  };
});

export default enhanceTodos(Todos);

オブザーバーの利用はこのようにします。

import withObservables from '@nozbe/with-observables';

withObservablesを読み込み、

const enhanceTodos = withObservables([], () => {
  return {
    todos: observerTodo(),
  };
});

export default enhanceTodos(Todos);

HOCでobserverTodoを返り値に含めます。
すると、

function Todos(props) {
  return (
    <FlatList
      data={props.todos}
      renderItem={({item}) => <Todo item={item} />}
    />
  );
}

propsにて、返り値としたプロパティが渡されるためこれがTodoモデルになっています。
このようにHOCする際にオブザーバーを含めることによって、Todoが更新されるたびにリアクティブに更新が行われます。

結論

Watermelonは起動時の速度向上にはRealmよりも優れていました。とはいえRealmの速度が格段に劣るわけではありません。
やはり論点として気になるところは書きっつらです。
Realmは極めて直感的にかけるのに対してWatermelonは癖が強いように感じます。
今後この辺りが整備されてくると話は変わってくるかなと思います。
長くなりましたが、結論としてはWatermelonは癖の強さを受け入れることができれは良い選択肢になるかなと思います。

また、

  • jsの文字数には限界がありJSON.stringifyできないこともある
  • よみだしが遅い
  • Androidだと容量に制限がある

といった観点から大容量のデータを保存するときはAsyncStorageはお勧めできません。redux-persistも同じですね。
保存する容量が小さいなら特に問題はありません。

おわりに

以上で終わります。

Build a fully offline app using React Native and WatermelonDBがポストされたので気になって調べてみました。

実のところ実務でオフラインデータベースを扱ったことがないためより実践的なテクニックやプロダクションでの辛みを書くことはできませんが、情報としてお役に立てますと幸いです。

Discussion