データをクラウド上ではなくユーザーのスマホ内に保存するタイプのスマホアプリをReact NativeとRealmで開発する方法
2021/02/27
2023/10/1追記 — React NativeでRealmを使いたい場合、Realm React(英語ページ) の利用がおすすめです。この記事は、Realm Reactがリリースされる以前に公開した記事です。
•
ユーザーが作成するデータをスマホにのみ保存し、サーバー上には保存しない React Native アプリを開発する場合、スマホ内のローカルデータベースにデータを保存することになります。本記事では、ローカルデータベースの1つである Realm を使った React Native アプリの開発方法を紹介します。
本記事では、最初に Realm でのデータ操作方法(CRUD : 作成、取得、更新、削除)を紹介します。次に、タスク管理アプリの開発を通して React Native での Realm の利用方法を紹介します。最後に、データベーススキーマを変更した際の注意点(マイグレーション)や、GUI 上で Realm データベースを操作できる「Realm Studio」を紹介します。
最近はFirebaseのようなmBaaS (Mobile Backend as a Service) が登場したことで、アプリ開発者がインフラを触るハードルが下がってきています。それでもなおサーバーではなくスマホにデータベースを保存するモチベーションとして、以下が挙げられます。
本記事では以下のフレームワーク、ライブラリを利用します。
スマホ内にデータベースを保存する方法として、RealmのほかにSQLiteがあります。RealmとSQLiteをざっくり比較すると、以下のようになります。
まだReact Native 開発環境のセットアップをしていない場合、Setting up the development environment(React Nativeドキュメント)にある "React Native CLI Quickstart" の内容を読み、指示に従ってください。
また、本記事の内容は、以下の環境で動作確認しています。
本記事では、最終的に以下の動画のようなタスク管理アプリを作成します。
ここでは、MyRealmAppという名前のReact Nativeアプリを作成してみます。なお、ExpoではRealmを使うことができませんので注意してください。
$ npx react-native init MyRealmApp
$ 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 のバージョンを上げてください。
本記事では例としてタスク管理アプリを設計してみます。タスクを表現するTask
クラスと、各 Task にはサブタスクを設定できるよう、SubTask
クラスを定義します。
まず、作成した React Native プロジェクト内に src フォルダを作成します。src フォルダは、 App.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アプリ内でTaskを操作できるようにするためのコンテキストと React Hook を作ります。
以下の例は、GitHubの mongodb-university/realm-tutorial-react-native リポジトリ内のコードを参考に、本記事用に書き換えたものです。
まずsrc
フォルダ内にproviders
フォルダを作成します。providers
フォルダ内にTaskProvider.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
というファイルでコンポーネントを定義します。
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>
);
};
スタイル定義です。./src/components/Main.js
には、上記の「前半」のコードと合わせて以下のコードも入力してください。
// スタイル
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
をインクリメントする必要があります。また、必要に応じて、旧スキーマのデータを新スキーマのデータへ移行する処理(マイグレーション)を書く必要があります。
例として、TaskのisDone
を変更してみます。ここまで、タスクの完了状況はisDone
で管理してきました。isDone
はタスクの完了・未完了しか表すことができないため、新しくstatus
として、タスクの状態を以下の3つに分類できるようにしてみます。
./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点です。
Realm にデータベーススキーマが変更になったことを伝えるため、schemaVersion をインクリメントします。schemaVersion は、データベーススキーマに変更がある場合のみ変更します。一度 schemaVersion の値を増やした場合、この数値を減らさないでください。
ユーザーのスマホ内に保存されている古いスキーマのデータベース内のデータを、新しいスキーマのデータベースに変換する処理を書きます。多くの場合、oldRealmのデータをもとに、対応するnewRealm内のデータを全部書き換えるようなコードになると思います。もしくは、optional ではない新しいプロパティが追加された場合、そのプロパティの初期値を設定するコードになると思います。
最後に、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を紹介してみました。クラウド上にデータを置かないアプリ開発の参考になれば幸いです。
エンジニア、UIデザイナー。 サブスク管理アプリSubma や 学園祭運営支援ウェブシステムPortalDots などを開発している個人開発者です。 もっと詳しく
学園祭運営ウェブシステム PortalDots 5 で、複数の参加登録フォームを作成できるようになりました
2023/05/08
学園祭実行委員会で内製・OSS化したウェブシステムの設計・デザイン、そして失敗について
2022/12/01
大学祭運営ウェブシステム PortalDots 4 をリリースしました —— ダークテーマ!!!
2022/03/27
なぜ「大学祭実行委員会」が「ウェブシステム」を作るのか —— PortalDots の存在意義と導入方法
2021/05/30
大学祭運営ウェブシステム PortalDots 3 をリリースしました —— スタッフモードのリニューアルなど
2021/05/30
大学祭の参加団体向けウェブシステムをOSS化してみた
2021/01/05
ウェブサイトをNext.js + TypeScript + Tailwind CSSでリニューアルした
2021/01/04