2021/02/27

React NativeでローカルデータベースRealmを使ったスマホアプリをつくる

データをクラウド上ではなくユーザーのスマホ内に保存するタイプのスマホアプリをReact NativeとRealmで開発する方法

ユーザーが作成するデータをスマホにのみ保存し、サーバー上には保存しない React Native アプリを開発する場合、スマホ内のローカルデータベースにデータを保存することになります。本記事では、ローカルデータベースの1つである Realm を使った React Native アプリの開発方法を紹介します。

目次

なぜサーバーではなくスマホに保存するのか

最近はFirebaseのようなmBaaS (Mobile Backend as a Service) が登場したことで、アプリ開発者がインフラを触るハードルが下がってきています。それでもなおサーバーではなくスマホにデータベースを保存するモチベーションとして、以下が挙げられます。

  • サーバーの障害によってアプリが使えなくなることがない
  • インターネットが使えない場所でもアプリを利用できる
  • アプリ開発者がサーバーを維持管理する必要がなくなる
    • FirebaseのようなmBaaSでも、ユーザー増に伴い金銭的な負担が生じる
  • 大規模なデータ流出は起こらない
    • アプリ開発者のもとにユーザーのデータが集まらないため、データ流出の危険性は下がる

使用フレームワーク・ライブラリ

本記事では以下のフレームワーク、ライブラリを利用します。

  • React Native
    • クロスプラットフォーム対応のスマホアプリを開発できるフレームワーク。
  • Realm
    • オフラインデータベース。開発元はMongoDB社により2019年買収。MongoDB社が提供するMongoDB Realm(mBaaS)を使うことで、複数端末間でデータベースを同期することもできるが、本記事の対象外。

SQLiteとの比較

スマホ内にデータベースを保存する方法として、RealmのほかにSQLiteがあります。RealmとSQLiteをざっくり比較すると、以下のようになります。

  • RealmはSQLiteより軽い
    • Realmでは、クエリの結果は遅延読み込みされる
  • RealmではSQLを書く必要がない
  • ただし、RealmはExpoで使えない(SQLiteはExpoで使える)

本記事の前提

まだReact Native 開発環境のセットアップをしていない場合、Setting up the development environment(React Nativeドキュメント)にある "React Native CLI Quickstart" の内容を読み、指示に従ってください。

また、本記事の内容は、以下の環境で動作確認しています。

  • macOS Big Sur 11.2.1
  • React Native 0.63.4
  • Node.js v14.16.0
  • Xcode 12.4
  • Simulator 上の iPhone 11 / iOS 14.4

Realmの準備

React Native アプリの作成

ここでは、MyRealmAppという名前のReact Nativeアプリを作成してみます。なお、ExpoではRealmを使うことができませんので注意してください。

$ npx react-native init MyRealmApp

Realmのインストール

$ cd MyRealmApp
$ npm install realm
$ cd ios && pod install && cd ..

PCにインストールされているNode.jsのバージョンが古いとエラーになることがあります。

もし、上記のnpm install realmコマンドを実行した結果、The N-API version of this Node instance is (数値). This module supports N-API version(s) (数値). This Node instance cannot run this module. というエラーが表示された場合、Node.js のバージョンを上げてください。

スキーマの定義とRealmの初期化

本記事では例としてタスク管理アプリを設計してみます。タスクを表現するTask クラスと、各 Task にはサブタスクを設定できるよう、SubTask クラスを定義します。

まず、作成した React Native プロジェクト内に src フォルダを作成します。src フォルダは、 App.js と同階層のフォルダに作成してください。次に、src フォルダ内に以下のファイルを作成します。ファイル名は realm.js とします。

./src/realm.js

// Taskの定義
const taskSchema = {
  name: 'Task',
  primaryKey: '_id',
  properties: {
    _id: 'objectId', // 'string' や 'int' でも OK
    name: 'string',
    description: 'string?', // ?をつけると optional
    isDone: 'bool',
    createdAt: 'date',
    subTasks: 'SubTask[]', // クラス名 + '[]' で1対多のリレーションを設定できる
  },
};

// SubTaskの定義
const subTaskSchema = {
  name: 'SubTask',
  primaryKey: '_id',
  properties: {
    _id: 'objectId',
    name: 'string',
    isDone: 'bool',
    createdAt: 'date',
  },
};

// Realmの初期化
export const openRealm = () => {
  const config = {
    schema: [taskSchema, subTaskSchema],
    schemaVersion: 1, // スキーマを変更したらインクリメントする(後述)
  };

  return new Realm(config);
};

export {BSON} from 'realm';

データベースの操作

React NativeでRealmを使う前に、まずRealm単体でデータベースを操作する方法を見ていきます。

このセクションのコードは、作成した React Native プロジェクトのフォルダに入っている App.js 内の適当な場所に書いた上で npx react-native run-ios コマンドを実行すると Simulator 上で動作確認できます。

npx react-native run-ios コマンドがエラーで失敗する場合、Flipperのバージョンを変更してみてください(Stack Overflow 英語版)

オブジェクトの新規作成

import {openRealm, BSON} from './src/realm';

const realm = openRealm();

// 更新系はすべて realm.write(() => { }) (=トランザクション)内に書く
realm.write(() => {
  // サブタスクを作成しない場合
  realm.create('Task', {
    _id: new BSON.ObjectId(),
    name: 'タスクの名前',
    isDone: false,
    createdAt: new Date(),
  });

  // サブタスクを作成する場合
  realm.create('Task', {
    _id: new BSON.ObjectId(),
    name: 'タスクの名前',
    isDone: false,
    createdAt: new Date(),
    subTasks: [
      {
        _id: new BSON.ObjectId(),
        name: 'サブタスクの名前',
        isDone: false,
        createdAt: new Date(),
      },
    ],
  });
});

オブジェクトの取得

filtered で利用可能なクエリについては公式ドキュメントを参照してください。

import {openRealm} from './src/realm';

const realm = openRealm();

// タスクを全部取得
const tasks = realm.objects('Task');

console.log(tasks[0].name); // 「タスクの名前」と表示される
console.log(tasks[1].subTasks[0].name); // 「サブタスクの名前」と表示される

// フィルタの例 — 完了しているタスクのみ取得
const done = tasks.filtered('isDone == true');
console.log(`完了しているタスクは${done.length}件です。`);

// ソートの例 — 名前順で取得
const sorted = tasks.sorted('name');
console.log(sorted[0].name);

オブジェクトの更新

JavaScriptのオブジェクトを操作するのと同じ方法でRealm上のデータを更新できます。

import {openRealm} from './src/realm';

const realm = openRealm();

realm.write(() => {
  // 更新対象のタスク
  const task = realm.objects('Task')[0];

  // 更新
  task.name = '新しいタスクの名前';
  task.isDone = !task.isDone;
});

オブジェクトの削除

import {openRealm} from './src/realm';

const realm = openRealm();

realm.write(() => {
  // 削除対象のタスク
  const task = realm.objects('Task')[0];

  if (task) {
    // まずサブタスクを削除
    realm.delete(task.subTasks);

    // 削除
    realm.delete(task);
  }
});

React NativeでRealmを使う

React Nativeアプリ内でTaskを操作できるようにするためのコンテキストと React Hook を作ります。

以下の例は、GitHubの mongodb-university/realm-tutorial-react-native リポジトリ内のコードを参考に、本記事用に書き換えたものです。

まずsrc フォルダ内にproviders フォルダを作成します。providersフォルダ内にTaskProvider.jsという名前でファイルを作成し、以下のコードを書きます。このコードの詳細については、コード中のコメントを参照してください。

./src/providers/TasksProvider.js

import React, {useContext, useState, useEffect, useRef} from 'react';
import {openRealm, BSON} from '../realm';

const TasksContext = React.createContext(null);

const TasksProvider = ({children}) => {
  const [tasks, setTasks] = useState([]);

  const realmRef = useRef(null);

  useEffect(() => {
    realmRef.current = openRealm();

    const tasks = realmRef.current.objects('Task').sorted('createdAt', true);
    setTasks(tasks);

    // Task のデータが更新されたら setTasks する
    tasks.addListener(() => {
      const tasks = realmRef.current.objects('Task').sorted('createdAt', true);
      setTasks(tasks);
    });

    return () => {
      // クリーンアップ
      if (realmRef.current) {
        realmRef.current.close();
      }
    };
  }, []);

  // タスクの新規作成
  const createTask = (newTaskName) => {
    const projectRealm = realmRef.current;
    projectRealm.write(() => {
      projectRealm.create('Task', {
        _id: new BSON.ObjectId(),
        name: newTaskName || '新しいタスク',
        isDone: false,
        createdAt: new Date(),
      });
    });
  };

  // タスクの isDone を更新する
  const setIsTaskDone = (task, isDone) => {
    const projectRealm = realmRef.current;

    projectRealm.write(() => {
      task.isDone = isDone;
    });
  };

  // タスクを削除する
  const deleteTask = (task) => {
    const projectRealm = realmRef.current;
    projectRealm.write(() => {
      projectRealm.delete(task);
    });
  };

  // useTasks フックで Task を操作できるようにする
  return (
    <TasksContext.Provider
      value={{
        createTask,
        deleteTask,
        setIsTaskDone,
        tasks,
      }}>
      {children}
    </TasksContext.Provider>
  );
};

// Task を操作するための React Hook
const useTasks = () => {
  const task = useContext(TasksContext);
  if (task == null) {
    throw new Error('useTasks() called outside of a TasksProvider?');
  }
  return task;
};

export {TasksProvider, useTasks};

以上で作成したTasksProviderをApp.jsに追加し、useTasksフックをコンポーネント内で使ってみます。ここでは、src フォルダ内にcomponents フォルダを作成し、その中にMain.js というファイルでコンポーネントを定義します。

./App.js

import React from 'react';
import {TasksProvider} from './src/providers/TasksProvider';
import {Main} from './src/components/Main';
import {StatusBar} from 'react-native';

const App = () => {
  return (
    <TasksProvider>
      <StatusBar barStyle="light-content" />
      <Main />
    </TasksProvider>
  );
};

export default App;

./src/components/Main.js

import React, {useState, useCallback} from 'react';
import {
  SafeAreaView,
  FlatList,
  View,
  Text,
  TouchableOpacity,
  TextInput,
  KeyboardAvoidingView,
  StyleSheet,
  TouchableWithoutFeedback,
  Keyboard,
} from 'react-native';
import {useTasks} from '../providers/TasksProvider';

// コンポーネント間の余白を作るための関数
const spacer = (size) => {
  return <View style={{height: size, width: size}} />;
};

export const Main = () => {
  const [inputText, setInputText] = useState('');
  const {createTask, deleteTask, setIsTaskDone, tasks} = useTasks();

  const onSubmitEditing = useCallback(
    (event) => {
      setInputText(event.nativeEvent.text);
      createTask(inputText);
      setInputText('');
    },
    [inputText, setInputText, createTask],
  );

  return (
    <KeyboardAvoidingView
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      style={styles.container}>
      <View style={styles.header}>
        <SafeAreaView />
        <View style={styles.headerContent}>
          {spacer(15)}
          <Text accessibilityRole="header" style={styles.headerTitle}>
            My Tasks
          </Text>
          {spacer(15)}
          <TextInput
            placeholder="Add Task..."
            onSubmitEditing={onSubmitEditing}
            value={inputText}
            onChange={(e) => setInputText(e.nativeEvent.text)}
            style={styles.headerInput}
          />
          {spacer(24)}
        </View>
      </View>
      <TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
        {tasks.length > 0 ? (
          <FlatList
            style={styles.tasksContainer}
            contentContainerStyle={styles.tasksContentContainer}
            data={tasks}
            keyExtractor={(item) => item._id.toHexString()}
            renderItem={({item}) => (
              <View style={styles.taskItem}>
                <TouchableOpacity
                  style={styles.chechboxContainer}
                  onPress={() => setIsTaskDone(item, !item.isDone)}>
                  <View
                    style={[
                      styles.checkbox,
                      {
                        borderColor: item.isDone ? '#2563EB' : '#60A5FA',
                        backgroundColor: item.isDone ? '#2563EB' : '#fff',
                      },
                    ]}>
                    {item.isDone && <Text style={styles.checkboxIcon}></Text>}
                  </View>
                </TouchableOpacity>
                <View style={styles.taskContent}>
                  <Text
                    style={[
                      styles.taskName,
                      {
                        textDecorationLine: item.isDone
                          ? 'line-through'
                          : 'none',
                        color: item.isDone ? '#9CA3AF' : '#111827',
                      },
                    ]}>
                    {item.name}
                  </Text>
                </View>
                <TouchableOpacity
                  style={styles.deleteButton}
                  onPress={() => deleteTask(item)}>
                  <View>
                    <Text style={styles.deleteButtonText}>Delete</Text>
                  </View>
                </TouchableOpacity>
              </View>
            )}
          />
        ) : (
          <View style={styles.emptyContent}>
            <Text style={styles.emptyMessage}>No Tasks</Text>
          </View>
        )}
      </TouchableWithoutFeedback>
    </KeyboardAvoidingView>
  );
};

// スタイル
const styles = StyleSheet.create({
  container: {flex: 1},
  header: {
    backgroundColor: '#2563EB',
  },
  headerContent: {paddingHorizontal: 24},
  headerTitle: {color: 'white', fontSize: 32, fontWeight: 'bold'},
  headerInput: {
    borderRadius: 6,
    height: 40,
    paddingHorizontal: 12,
    marginHorizontal: -12,
    fontSize: 18,
    backgroundColor: '#fff',
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2.5,
    },
    shadowOpacity: 0.3,
    shadowRadius: 2.4,
    elevation: 4,
  },
  tasksContainer: {flex: 1},
  tasksContentContainer: {paddingBottom: 20},
  taskItem: {
    borderBottomWidth: 1,
    borderBottomColor: '#E5E7EB',
    flexDirection: 'row',
    alignItems: 'center',
  },
  chechboxContainer: {padding: 20},
  checkbox: {
    width: 25,
    height: 25,
    borderRadius: 12,
    borderWidth: 1.5,
    alignItems: 'center',
    justifyContent: 'center',
  },
  checkboxIcon: {color: 'white', fontWeight: 'bold', fontSize: 18},
  taskContent: {flex: 1},
  taskName: {
    fontSize: 20,
    fontWeight: '600',
  },
  deleteButton: {padding: 20},
  deleteButtonText: {color: '#EF4444'},
  emptyContent: {
    justifyContent: 'center',
    alignItems: 'center',
    flex: 1,
  },
  emptyMessage: {
    fontSize: 20,
    fontWeight: '500',
    color: '#9CA3AF',
  },
});

動作確認

ここまでできたら、一度動作確認してみます。今回は Simulator 上の iPhone で動作確認します。以下のコマンドで、シミュレータを立ち上げます。

((以下のコマンドがエラーで失敗する場合、Flipperのバージョンを変更してみてください(Stack Overflow 英語版)

$ npx react-native run-ios

以下の動画のように動作すれば成功です。もしエラーが発生する場合、「DerivedDataの削除」「iosフォルダ内でpod install 再実行」などをお試しください(詳細はエラーメッセージでググってみてください)。

ここまで書いてきたコードでは、タスクの編集ができません。タスク名を自由に編集できるようにしてみると良いかもしれません(本記事では省略します)。

スキーマ変更時の注意点(schemaVersionとマイグレーション)

アプリストアで配信中のアプリをアップデートする際、データベースのスキーマが変更になる場合、schemaVersion をインクリメントする必要があります。また、必要に応じて、旧スキーマのデータを新スキーマのデータへ移行する処理(マイグレーション)を書く必要があります。

例として、TaskのisDone を変更してみます。ここまで、タスクの完了状況はisDoneで管理してきました。isDone はタスクの完了・未完了しか表すことができないため、新しくstatus として、タスクの状態を以下の3つに分類できるようにしてみます。

  • Open : タスク未着手
  • InProgress : タスク進行中
  • Complete : 完了

スキーマを書き換える

./realm.js に書いた Task のスキーマ定義を以下のように変更します。この作業はスキーマ定義を直接書き換えます。

// 略
const taskSchema = {
  name: "Task",
  primaryKey: "_id",
  properties: {
    _id: "objectId",
    name: "string",
    description: "string?",
    // isDone: 'bool',
    status: "string", // isDone を削除し status を追加
    createdAt: "date",
    subTasks: "SubTask[]",
  },
};
// 略

マイグレーション処理を書く

./realm.js ファイルを保存し、React Native開発環境を再度実行すると、コンソールに以下のようなエラーが表示されます。

Error: Migration is required due to the following errors:
- Property 'Task.isDone' has been removed.
- Property 'Task.status' has been added.

このエラーメッセージを日本語に訳すと以下のようになります。

エラー: 以下のエラーのため、マイグレーションが必要です
- プロパティ 'Task.isDone' が削除されました
- プロパティ 'Task.status' が追加されました

このようなエラーが表示された場合、./realm.js ファイルにある openRealm 関数内の config 定数を以下のように書き換えます。

// 略

// Realmの初期化
export const openRealm = () => {
  const config = {
    schema: [taskSchema, subTaskSchema],
    // schemaVersion: 1,
    schemaVersion: 2, // ① schemaVersion を 1 → 2 へ変更

    // ②マイグレーション処理を追加
    migration: (oldRealm, newRealm) => {
      // 現在保存されているデータの schemaVersion が 2 未満の場合に実行
      if (oldRealm.schemaVersion < 2) {
        const oldObjects = oldRealm.objects('Task');
        const newObjects = newRealm.objects('Task');
        // 全TaskデータのisDoneをstatusに変換
        for (const objectIndex in oldObjects) {
          const oldObject = oldObjects[objectIndex];
          const newObject = newObjects[objectIndex];
          newObject.status = oldObject.isDone ? 'Complete' : 'Open';
        }
      }
    },
  };

  return new Realm(config);
};

// 略

このファイルを保存し、再度アプリを実行すると、マイグレーションが実行され、データベースが更新されます。まだ isDone がコード上で使われているため、必要に応じて status を使うように修正を行ってください(本記事では省略します)。

ここでのポイントは以下の2点です。

① schemaVersion を 1 → 2 へ変更

Realm にデータベーススキーマが変更になったことを伝えるため、schemaVersion をインクリメントします。schemaVersion は、データベーススキーマに変更がある場合のみ変更します。一度 schemaVersion の値を増やした場合、この数値を減らさないでください。

② マイグレーション処理を追加

ユーザーのスマホ内に保存されている古いスキーマのデータベース内のデータを、新しいスキーマのデータベースに変換する処理を書きます。多くの場合、oldRealmのデータをもとに、対応するnewRealm内のデータを全部書き換えるようなコードになると思います。もしくは、optional ではない新しいプロパティが追加された場合、そのプロパティの初期値を設定するコードになると思います。

RealmデータベースGUIで操作できる「Realm Studio」

最後に、Realm Studioを紹介します。Realm Studio はWindows、Linux、macOSに対応したRealmデータベースの管理アプリです。Realm Studio を使うことで、Realmデータベースの中身をGUI上で確認・操作することができます。

Realm Studioは、Realm 公式サイト内にあるRealm Studioのページからダウンロードできます。Realm Studio を初めて起動するとメールアドレスの登録画面が表示されるのでメールアドレスを登録します。その後、以下の画面が表示されます。

この画面が表示されたら [Open Realm file] をクリックし、Realm ファイルを開きます。Realm ファイルのパスは、以下のコードをApp.js等に書いて実行することで確認できます(コンソールにファイルパスが表示されます)。

import {openRealm} from './src/realm.js';
console.log(openRealm().path);

無事ファイルを開けると、以下のような画面になります。この画面から、データの確認・編集ができます。

おわりに

React Nativeアプリでオフラインデータを扱うライブラリとしてRealmを紹介してみました。クラウド上にデータを置かないアプリ開発の参考になれば幸いです。

参考文献

このページを共有