LoginSignup
40
28

More than 3 years have passed since last update.

MongoDB Realmで、超初心者でも無料でスマホアプリとクラウドDBをお手軽連携

Last updated at Posted at 2020-08-15

はじめに

この記事は、スマホアプリ未経験者がクラウドDBサービス「MongoDB Realm」を利用して、
サーバデータと連携したスマホアプリを作成した記事となります。

summary.png
私はスマホアプリ開発未経験の超初心者ですが、公式サイトが丁寧だったこともあり、目的の連携処理を実装することができました。
(実践編として、こちらの記事もご参照いただければと思います)

スマホアプリ初心者がバックエンド処理を作成する際の、一つの指針になればと思います。

英文チュートリアルを参考にしたため、間違いがあるかもしれませんが、見つけた場合はご指摘頂けますとありがたいです!

クラウドとスマホアプリ

世の中ではサービスのクラウド化が進んでいますが、
この流れに乗じスマホアプリの開発工数を減らせないか?ということで、
バックエンド処理をクラウド経由で簡単に構築しよう、という取り組みが進んでいます。
このようなサービスを「mBaaS」と呼ぶそうです。

AWSでの取り組み例:https://k-tai.watch.impress.co.jp/docs/news/1197672.html
Googleでの取り組み例(Firebase):https://gihyo.jp/dev/serial/01/firebase
Facebookでの取り組み例(Parse):https://html5experts.jp/shumpei-shiraishi/14370/

いずれもコンセプトは
「バックエンド開発を時短化し、お客様が直接触れる部分(フロントエンド)の開発に集中する」
ことに主眼を置いているようです。

MongoDB Realmとは?

前述の巨人たちにMongoDB社が後発で挑むべく、モバイル向けDB「Realm」を買収して作られたサービスが、
MongoDB Realm
です。
mBaaSの中でもデータアクセス機能に特化したサービス(どちらかというとPaaSに近い?)で、
クラウドDB(MongoDB Atlas)内のデータにスマホから簡単にアクセスできるようになります。
https://www.publickey1.jp/blog/20/mongodb_realmdbdb.html

システム構成

・クラウドDBであるMongoDB Atlas(下図の葉っぱマーク)がサーバに、
・スマホ用DBであるRealm(下図の雷マーク)がクライアントに
・これらを統合したプラットフォーム(各種APIを提供)がMongoDB Realm
となるようです。
mongodb_realm02.png
サーバとクライアントのデータをリアルタイム連携出来ることを売りにしています。

メリット

GAFAの先発サービスと比較した際のメリットとしては、下記のようなものがありそうです。

プラットフォーム依存性が低く、リソースや学習コストの流用がしやすい

AWSやGCPのプラットフォーム内に組み込まれていないので、
他のクラウドサービスやオンプレからの移植性が高いと思われます。
またモバイルやクラウド以外でも多用されるMongoDBがベースとなっている事も、
リソース流用や学習のしやすさに繋がりそうです。

無料で出来ることが多い

AWSやFirebaseのサービスを使ったことがないので比較はできませんが(使用された事がある方、コメント頂けるとありがたいです)、
無料枠で、リアルタイム同期を始めとした、今回やりたい事は基本的にできました。

無料枠はサーバ側DB (MongoDB Atlas)の容量上限がやや低い(500MB)ので、
軌道に乗ってデータ量が増えたら有料サービスにアップグレード、という使い方が適していそうです。

実際に試してみた

公式チュートリアルに記載されている、タスク管理アプリを作ってみました。

アプリの内容

スマホアプリでタスクを登録すると、クラウドDBにリアルタイム連携されるアプリです。
function.png
シンプルすぎて実用性は微妙ですが、クラウドDBとスマホアプリ間のデータの動きを見るには最適かと思います。

システム構成

公式チュートリアルではアプリ内容について殆ど説明されていなかったので、
理解しやすくするためにシステム構成も図示します。

DB構成は下図のようになります
realm_system.png

画面遷移は下図のようになります
screen_transition.png

必要なもの

・開発用PC(今回はWindows10を使用)
・Android Studio (Androidアプリ開発環境)
・MongoDB Atlasのアカウント (無料で作成できます)
・Androidスマホ(動作確認用)

作成手順

大まかには下記手順となります。
1. MongoDB Atlasの初期設定とサーバ側DBの作成
2. MongoDB Realmのサーバ側初期設定
3. Androidアプリの作成

かなり長いですが、従来のアプリ開発における

・オンプレサーバを立てて
・DB作成して
・API公開して
・Androidアプリのバックエンド処理を作成して
・AndroidアプリのUIを作成

という手順を全て網羅でき、リアルタイム同期までできてしまうと考えると、相当な簡略化ができるのではと思います!
(アプリ開発未経験なので、本当のところはよく分かりません(笑))

1. MongoDB Atlasの初期設定とサーバDBの作成

サーバ側DBをクラウドサービスであるMongoDB Atlasに作成します
※コレクションは後で作成するので、現時点では作成不要です。

MongoDB Atlasの構造について

Organization > Project > Cluster > DB > Collecion
という構造になっています。
DBが通常のRDBMSのDBに、CollectionがRDBMSのテーブルに相当します。
ClusterはMongoDB独自の構造で、こちらを見る限り、分散処理の効率を上げるためのカテゴリだそうです。
Organization、ProjectはクラウドのAtlas独自の構造です。詳細は不明ですが、チームやプロジェクト毎の管理を前提としたカテゴリのようです。

MongoDB Atlasへの登録

MongoDB Atlasのトップページにアクセスし、「Start free」を押します
01_mongoatlastop.png

各種情報を入力し、登録します
02_register.png

クラスタの作成、ユーザの作成、ホワイトリストの設定

こちらをご参照いただければと思います。

なお、Realmを使用してスマホアプリと連携したい場合、MongoDBのバージョンを4.4とする必要がありますが、
2020/8現在、バージョン4.4を指定できるのは、プロバイダにAWSのN. Virginiaを指定したときのみなので、ご注意下さい。
私は下記のクラスタ設定としました。
05_selectplan.png

2. MongoDB Realmのサーバ側初期設定

サーバ・クライアント連携システムであるMongoDB Realmの、サーバ側設定を実施します。
こちらを参考にさせて頂きました

2-1. Realmアプリケーションの作成

MongoDB Atlasのクラスタ設定画面から、「Realm」タブをクリックします。
1_select_realm.png!

今回の用途に合わせ、「Mobile」「Android」「No, I'm starting from scratch(新規作成)」を選択します
2_start_new_realm_app.png

アプリケーション名(好きな名前を付けてください。今回は"HomeIoTApp"とします)と、
連携するMongoDB Atlasのクラスタを指定します
3_cretate_realm_app.png

後で使うので、アプリケーションIDを控えておきます
04_copy_appid.png

2-2. ルールの作成

スキーマ等、サーバ側DBの従うルールを定義していきます

DBおよびコレクションの作成

下記手順でDBおよびコレクションを作成します。
05_1_make_collection.png
同様の手順で、DB「tracker」内に、「tasks」「users」の2個のコレクションを作成します。
(公式チュートリアルは「project」コレクションも作成していますが、最後まで使用しないので本記事では作成しません)

スキーマの作成

下記手順でスキーマ(コレクション=RDBMSにおけるテーブルのフィールド構成)を作成します
(NoSQLなのにスキーマが必要なのがちょっと意外ですが、Realmとの連携に必要なようです)
06_1_generate_schema.png

各コレクションのスキーマには、下記を記述してください

tasksのスキーマ
{
  "title": "Task",
  "bsonType": "object",
  "required": [
    "_id",
    "name",
    "status"
  ],
  "properties": {
    "_id": {
      "bsonType": "objectId"
    },
    "_partition": {
      "bsonType": "string"
    },
    "name": {
      "bsonType": "string"
    },
    "status": {
      "bsonType": "string"
    }
  }
}
usersのスキーマ
{
  "title": "User",
  "bsonType": "object",
  "required": [
    "_id",
    "name"
  ],
  "properties": {
    "_id": {
      "bsonType": "string"
    },
    "_partition": {
      "bsonType": "string"
    },
    "name": {
      "bsonType": "string"
    }
  }
}

2-3. ユーザ権限の設定

Usersタブ → Providersタブをクリックします
14_configure_user_authentication1.png
Provider EnabledをONに
User Confirmation MethodをAutomatically confirm usersに
Password Reset MethodをRun a password reset functionに
Functionを、新たに作成したresetFuncに設定する
15_configure_user_authentication2.png

2-4. 新規登録ユーザのDB追加処理作成

ユーザ新規登録時に、usersコレクションにも左記ユーザが追加されるように処理を設定します。

Triggersタブを選択し、Add a Triggerをクリック
15_add_trigger1.png
下記の設定でトリガを作成
16_add_trigger2.png
関数名"createNewUserDocument"を入力し、コードを貼り付けてSaveする
17_add_trigger3.png
createNewUserDocumentの内容は下記となります

createNewUserDocument
exports = async function createNewUserDocument({ user }) {
  const cluster = context.services.get("mongodb-atlas");
  const users = cluster.db("tracker").collection("users");
  return await users.insertOne({
    _id: user.id,
    _partition: "Project HomeIoT",
    name: user.data.email,
  });
};

2-5. 同期の設定

下図のように、同期を設定します
21_add_sync.png

2-6. 変更をデプロイ

ここまでで設定に一区切りついたので、変更をアプリケーションに反映(デプロイ)させます。
画面の上の方に出てくるREVIEW&DEPLOYをクリックして、変更内容を確認したうえでDEPLOYをクリックします。
18_deploy_change.png
以後も設定に変更を加えた際は、適宜デプロイします

3. Androidアプリの作成

※参考公式チュートリアル
MongoDB Realmによりサーバとデータ同期可能なAndoroidアプリを作成します。
Android Studioを使用します。

3-1. プロジェクトの作成

Android Studioを開き、「Start a new Android Studio Project」をクリックします。
出てきたテンプレート選択画面で、「Empty Activity」を選択します。
30_select_template.png

好きなプロジェクト名、パッケージ名を記入し、
言語:Kotlin、Minimum SDK:API21を選択します。
(今回はプロジェクト名"RealmTutorial"、パッケージ名com.mongodb.realmtutorialとしました)
31_configure_project.png

3-2. Realmプラグインのインストール

必要なプラグインをインストールします

Project全体へのインストール

左のタブから、2つあるBuild.gradleのうちProjectの方に以下の記述を追記し、プロジェクト全体にrealmプラグインをインストールします。
(2ヵ所のmavenはベータ版用の処理との記載なので、今後なくなるかもしれません)
32_install_project_gradle.png

build.gradle(Project)
buildscript {
      :
    repositories {
          :
        maven {
            url 'http://oss.jfrog.org/artifactory/oss-snapshot-local'
        }
    }
    dependencies {
          :
        classpath "io.realm:realm-gradle-plugin:10.0.0-BETA.5"
    }
allprojects {
    repositories {
          :
        maven {
            url 'http://oss.jfrog.org/artifactory/oss-snapshot-local'
        }
    }
}
  :

アプリケーションへのインストール

左のタブから、2つあるBuild.gradleのうちModule: appの方に以下の記述を追記し、アプリケーションにrealmプラグインをインストールします。
※上で控えたアプリケーションIDが必要になります
※kotlin-kaptはrealm-androidが依存するモジュールなので、後者より上に記載する必要あり
33_install_app_gradle.png

build.gradle(Module:app)
  :
apply plugin: 'kotlin-kapt'
apply plugin: 'realm-android'
  :
android {
      :
    buildTypes {
        def appId = "上で控えたアプリケーションID"  // Replace with proper Application ID
        debug {
            buildConfigField "String", "MONGODB_REALM_APP_ID", "\"${appId}\""
        }
        release {
            buildConfigField "String", "MONGODB_REALM_APP_ID", "\"${appId}\""
            minifyEnabled false
            signingConfig signingConfigs.debug
        }
    }

    compileOptions {
        sourceCompatibility 1.8
        targetCompatibility 1.8
    }
}

realm {
    syncEnabled = true
}

dependencies {
      :
    implementation 'com.google.android.material:material:1.1.0'
      :
    implementation "io.realm:android-adapters:4.0.0"
    implementation "androidx.recyclerview:recyclerview:1.1.0"
      :
}

ここで一度File → Close Projectでプロジェクトを開きなおしたのち、
プログラムを実行して正常にアプリが起動するか確認します(各種ライブラリがエラーを出さないか確認)
34_run_emulator.png
私の場合起動までに1分以上かかったので、気長に待ちましょう。

3-3. Realmアプリケーションのグローバルインスタンス生成用クラス作成

Realmアプリケーションのインスタンスは、1個作成したものをアプリ全体で共有(グローバルインスタンス)するようです。

クラスの作成

上記グローバルインスタンスを生成するクラスを、下記手順で作成します。
javaフォルダ内のパッケージフォルダを右クリックし、New → Kotlin File/Class
35_make_global_realmapp_instance.png
Classを選択し、下記のようなTaskTracker.ktを作成

TaskTracker.kt
package com.mongodb.realmtutorial//パッケージ名に合わせて修正

import android.app.Application
import android.util.Log

import io.realm.Realm
import io.realm.log.LogLevel
import io.realm.log.RealmLog
import io.realm.mongodb.App
import io.realm.mongodb.AppConfiguration

//Realmアプリケーションのインスタンス(グローバルインスタンスとして、アプリケーション全体で共有する)
lateinit var taskApp: App

// global Kotlin extension that resolves to the short version
// of the name of the current class. Used for labelling logs.
inline fun <reified T> T.TAG(): String = T::class.java.simpleName

/*
* TaskTracker: Sets up the taskApp Realm App and enables Realm-specific logging in debug mode.
*/
class TaskTracker : Application() {

    override fun onCreate() {
        super.onCreate()
        //Realmライブラリの初期化
        Realm.init(this)
        //Realmアプリケーションにアクセスしインスタンス化
        taskApp = App(
            AppConfiguration.Builder(BuildConfig.MONGODB_REALM_APP_ID)
                .build())

        // デバッグモード時に追加ロギングを有効に
        if (BuildConfig.DEBUG) {
            RealmLog.setLevel(LogLevel.ALL)
        }

        Log.v(TAG(), "Initialized the Realm App configuration for: ${taskApp.configuration.appId}")
    }
}

マニフェストに登録

上記クラスをマニフェストに登録します。これによりアプリ起動時に上記インスタンスが初期化されるようです。
app → manifests → AndroidManifest.xmlを開きます
36_add_globalinstance_class_to_manifest.png
例えばモジュール名がcom.mongodb.realmtutorialのとき、下記のように追記します

AndroidManifest.xml
        android:name="com.mongodb.realmtutorial.TaskTracker"

3-4. ログイン画面の作成

MongoDB Realmのユーザアカウント作成orログインする画面を作成します。

ログイン画面アクティビティの作成

下記操作で、ログイン画面のアクティビティ(Androidアプリにおける画面GUIに相当する部品)を作成します
javaフォルダ内のパッケージフォルダを右クリックし、New → Activity → Empty Activity
37_make_login_activity1.png
名前を"LoginActivity"に変え、Finish
38_make_login_activity2.png

ログイン画面のレイアウト作成

下記操作で、画面レイアウトactivity_login.xmlを編集します

先ほどの操作でapp → res → layoutに生成したactivity_login.xmlを開く
39_edit_login_activity_layout.png
activity_login.xmlを下記のように編集

activity_login.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LoginActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:paddingLeft="24dp"
        android:paddingTop="12dp"
        android:paddingRight="24dp">

        <com.google.android.material.textfield.TextInputLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="8dp">

            <EditText
                android:id="@+id/input_username"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/username"
                android:inputType="text" />
        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.textfield.TextInputLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:layout_marginBottom="8dp">

            <EditText
                android:id="@+id/input_password"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/password"
                android:inputType="textPassword" />
        </com.google.android.material.textfield.TextInputLayout>

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/button_login"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="24dp"
            android:layout_marginBottom="12dp"
            android:padding="12dp"
            android:text="@string/login" />

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/button_create"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="24dp"
            android:padding="12dp"
            android:text="@string/create_account" />
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

上記だけだと変数指定部分でエラーが出るので、res → values
→ strings.xmlに下記内容を追記します
40_edit_stringsxml.png

strings.xml
<resources>
    <string name="app_name">RealmTutorial</string>//ここはプロジェクト名が元々記載されている
    <string name="username">Email</string>
    <string name="password">Password</string>
    <string name="create_account">Create account</string>
    <string name="login">Login</string>
    <string name="more">\u22EE</string>
    <string name="new_task">Create new task</string>
    <string name="logout">Log Out</string>
</resources>

Designタブで確認すると、下図のようなレイアウトになっているはずです。
41_confirm_login_activity_layout.png

MainActivityからログイン画面への遷移作成

ログイン中ユーザが存在しないときはログイン画面に遷移するよう、
java → パッケージ名のフォルダ内にあるMainActivity.ktを下記のように書き換えます

MainActivity.kt
package com.mongodb.realmtutorial//パッケージ名に合わせて修正

import android.content.Intent
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import io.realm.mongodb.User

class MainActivity : AppCompatActivity() {
    private var user: User? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun onStart() {
        super.onStart()
        try {
            user = taskApp.currentUser()
        } catch (e: IllegalStateException) {
            Log.w(TAG(), e)
        }
        if (user == null) {
            // if no user is currently logged in, start the login activity so the user can authenticate
            startActivity(Intent(this, LoginActivity::class.java))
        }
    }
}

ログイン処理クラスの作成

ログイン画面でボタンを押した際の処理を、下記のように実装します。

java → パッケージ名のフォルダ内にあるLoginActivity.ktを下記のように書き換えます

LoginActivity.kt
package com.mongodb.realmtutorial//パッケージ名に合わせて修正

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import io.realm.mongodb.Credentials

class LoginActivity : AppCompatActivity() {
    //各種入力フォームを保持するインスタンス
    private lateinit var username: EditText//ユーザ名(Eメールアドレス)入力用テキストボックス
    private lateinit var password: EditText//パスワード入力用テキストボックス
    private lateinit var loginButton: Button//ログインボタン
    private lateinit var createUserButton: Button//新規ユーザ作成ボタン

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //入力フォームインスタンスの生成
        setContentView(R.layout.activity_login)
        username = findViewById(R.id.input_username)
        password = findViewById(R.id.input_password)
        loginButton = findViewById(R.id.button_login)
        createUserButton = findViewById(R.id.button_create)
        //ボタンを押したときの処理
        loginButton.setOnClickListener { login(false) }//ログインホタン
        createUserButton.setOnClickListener { login(true) }//新規ユーザ作成ボタン
    }

    override fun onBackPressed() {
        //戻るボタンでメイン画面に戻れないようにする(メイン画面に戻るにはログインが必須)
        // Disable going back to the MainActivity
        moveTaskToBack(true)
    }

    private fun onLoginSuccess() {
        //ログインに成功したら、メイン画面に戻る
        // successful login ends this activity, bringing the user back to the main activity
        finish()
    }

    private fun onLoginFailed(errorMsg: String) {
        //ログインに失敗したら、ログに書き込んだ上でメッセージ表示
        Log.e(TAG(), errorMsg)
        Toast.makeText(baseContext, errorMsg, Toast.LENGTH_LONG).show()
    }

    private fun validateCredentials(): Boolean = when {
        //ユーザ名とパスワードが空欄でないことを確認
        // zero-length usernames and passwords are not valid (or secure), so prevent users from creating accounts with those client-side.
        username.text.toString().isEmpty() -> false
        password.text.toString().isEmpty() -> false
        else -> true
    }

    /**
     * ログインボタンを押したときの処理
     * @param[createUser]:trueなら新規ユーザ作成、falseなら通常のログイン
     */
    // handle user authentication (login) and account creation
    private fun login(createUser: Boolean) {
        if (!validateCredentials()) {
            onLoginFailed("Invalid username or password")
            return
        }

        //処理中はボタンを押せないようにする
        // while this operation completes, disable the buttons to login or create a new account
        createUserButton.isEnabled = false
        loginButton.isEnabled = false

        val username = this.username.text.toString()
        val password = this.password.text.toString()

        if (createUser) {//新規ユーザ作成のとき
            // ユーザ名(E-mailアドレス)+パスワードでユーザ作成実行
            // register a user using the Realm App we created in the TaskTracker class
            taskApp.emailPasswordAuth.registerUserAsync(username, password) {
                // re-enable the buttons after user registration completes
                createUserButton.isEnabled = true
                loginButton.isEnabled = true
                if (!it.isSuccess) {//ユーザ作成失敗時は、メッセージを表示
                    onLoginFailed("Could not register user.")
                    Log.e(TAG(), "Error: ${it.error}")
                } else {//成功時は、そのまま通常ログイン
                    Log.i(TAG(), "Successfully registered user.")
                    // when the account has been created successfully, log in to the account
                    login(false)
                }
            }
        } else {//通常ログインのとき
            val creds = Credentials.emailPassword(username, password)
            taskApp.loginAsync(creds) {
                // re-enable the buttons after
                loginButton.isEnabled = true
                createUserButton.isEnabled = true
                if (!it.isSuccess) {//ログイン失敗時は、メッセージを表示
                    onLoginFailed(it.error.message ?: "An error occurred.")
                } else {//成功時は、メイン画面に戻る
                    onLoginSuccess()
                }
            }
        }
    }
}

基本的な処理の流れは下記となります
・ログインボタン:ユーザ名&パスワード入力確認 → ログイン → 成功したらメイン画面に遷移
・新規ユーザ作成ボタン:ユーザ名&パスワード入力確認 → ユーザ作成 → 成功したらログインボタン処理に移る

3-5. データモデルの作成

サーバ側DBと同期するためには、
同期対象のコレクションの定義(≒スキーマ)をKotlinで表現した、
フィールド定義クラス(データモデル)を作成する必要があります。

modelパッケージの作成

下記の手順で、モデルを保持するパッケージ(フォルダ)を作成します。

javaフォルダ内のパッケージフォルダを右クリックし、New → Package
42_make_model_package.png
パッケージに"model"という名前を付けます
43_make_model_package2.png

tasksコレクションに対応したデータモデルクラス「Task.kt」の作成

サーバDBの「tasks」コレクションに対応したクラス、「Task.kt」を作成します

作成したパッケージ名を右クリックし、New → Kotlin File/Class
44_make_model_class1.png
Classを選択し、"Task"と名前を付けてEnter
45_make_model_class2.png

Task.kt
package com.mongodb.realmtutorial.model//パッケージ名に合わせて修正

import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import io.realm.annotations.Required
import org.bson.types.ObjectId

//"tasks"コレクションに相当するデータモデルを定義するクラス(projectの初期値は適宜修正)
open class Task(_name: String = "Task", project: String? = "Project HomeIoT") : RealmObject() {
    //コレクションに存在するプロパティ(フィールド)の定義と、初期値の入力
    @PrimaryKey var _id: ObjectId = ObjectId()
    var _partition: String? = project
    var name: String = _name

    //statusプロパティの定義
    @Required
    private var status: String = TaskStatus.Open.name
    var statusEnum: TaskStatus
        get() {
            // because status is actually a String and another client could assign an invalid value,
            // default the status to "Open" if the status is unreadable
            return try {
                TaskStatus.valueOf(status)
            } catch (e: IllegalArgumentException) {
                TaskStatus.Open
            }
        }
        set(value) { status = value.name }
}

※project: String = "Project HomeIoT"の部分は、_partitionによるカテゴリ分けに応じて適宜修正してください。
参考
今回はカテゴリ分けはしないので、一律 "Project HomeIoT"を指定します。
(本来は、_partitionを使用してユーザごとに表示内容を変えること等を想定しているようです)

TaskStatus.ktの作成

上記のTask.ktでエラーが出ているTaskStatus部分に対処するため、
ステータスを"Open", "In Progress", "Complete"の3種類で表現するための列挙型クラスTaskStatus.ktを、
Task.ktと同じフォルダに作成します

TaskStatus.kt
package com.mongodb.realmtutorial.model//パッケージ名に合わせて修正

enum class TaskStatus(val displayName: String) {
    Open("Open"),
    InProgress("In Progress"),
    Complete("Complete"),
}

3-6. タスク一覧画面と、UIによるステータス更新処理の作成

タスク一覧画面のレイアウト作成

activity_login.xmlと同じフォルダに、タスク一覧画面のレイアウトであるtask_view.xmlを作成します

app → res → layoutフォルダを右クリックし、New → Layout Resource Fileを選択します
46_make_taskview_layout1.png
名前を"task_view"にしてOKを押します。
47_make_taskview_layout2.png
生成されたtask_view.xmlを下記のように変更します

task_view.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#ffffff"
    android:orientation="horizontal"
    android:layout_margin="1dp"
    android:padding="8dp">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:layout_alignParentStart="true"
        android:padding="8dp">

        <TextView
            android:id="@+id/name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="1dp"
            android:textColor="#000000"
            android:textSize="18sp"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/status"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:padding="1dp"
            android:textColor="#5d5d5d"
            android:textSize="16sp" />

    </LinearLayout>


    <TextView
        android:id="@+id/menu"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_alignParentEnd="true"
        android:layout_alignParentTop="true"
        android:text="@string/more"
        android:textSize="44sp"
        android:textAppearance="?android:textAppearanceLarge"
        android:paddingEnd="16dp"
        tools:ignore="RelativeOverlap,RtlSymmetry" />

</RelativeLayout>

タスク表示&ステータス更新処理クラスの作成

DBから受け取ったタスクをレイアウト上に表示&UIを通じてDB上のステータスを更新するクラスを作成します。

Task.kt&TaskStatus.ktがあるパッケージ名を右クリックし、New → Kotlin File/Classを選択
Classを選択してTaskAdapter.ktと名前を付け、下記のコードを記載します。

TaskAdapter.kt
package com.mongodb.realmtutorial.model//パッケージ名に合わせて修正

import android.util.Log
import android.view.*
import android.widget.PopupMenu
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.mongodb.realmtutorial.R//パッケージ名に合わせて修正
import com.mongodb.realmtutorial.TAG//パッケージ名に合わせて修正
import io.realm.OrderedRealmCollection
import io.realm.Realm
import io.realm.RealmRecyclerViewAdapter
import io.realm.kotlin.where
import org.bson.types.ObjectId

/*
* Taskコレクションデータ表示用クラス(Realm取得データをRecyclerViewに表示するためのクラスRealmRecyclerViewAdapterを継承)
* TaskAdapter: extends the Realm-provided RealmRecyclerViewAdapter to provide data for a RecyclerView to display
* Realm objects on screen to a user.
*/
internal class TaskAdapter(data: OrderedRealmCollection<Task>) : RealmRecyclerViewAdapter<Task, TaskAdapter.TaskViewHolder?>(data, true) {

    //xmlレイアウトからViewを生成して、含まれるView一覧をTaskViewHolder形式で返す
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder {
        //task_view.xmlから全体Viewを生成
        val itemView: View = LayoutInflater.from(parent.context).inflate(R.layout.task_view, parent, false)
        //全体Viewに含まれるView一覧を返す
        return TaskViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        val obj: Task? = getItem(position)
        holder.data = obj
        holder.name.text = obj?.name
        holder.status.text = obj?.statusEnum?.displayName

        //手動でステータス変更 or タスク削除するためのポップアップ処理
        // multiselect popup to control status
        holder.itemView.setOnClickListener {
            run {
                val popup = PopupMenu(holder.itemView.context, holder.menu)
                val menu = popup.menu

                //ステータス変更をポップアップメニューに追加(現在のステータスは除外)
                // the menu should only contain statuses different from the current status
                if (holder.data?.statusEnum != TaskStatus.Open) {
                    menu.add(0, TaskStatus.Open.ordinal, Menu.NONE, TaskStatus.Open.displayName)
                }
                if (holder.data?.statusEnum != TaskStatus.InProgress) {
                    menu.add(0, TaskStatus.InProgress.ordinal, Menu.NONE, TaskStatus.InProgress.displayName)
                }
                if (holder.data?.statusEnum != TaskStatus.Complete) {
                    menu.add(0, TaskStatus.Complete.ordinal, Menu.NONE, TaskStatus.Complete.displayName)
                }

                //タスク削除をポップアップメニューに追加
                // add a delete button to the menu, identified by the delete code
                val deleteCode = -1
                menu.add(0, deleteCode, Menu.NONE, "Delete Task")

                //ポップアップメニューのボタンをクリックした際の処理
                // handle clicks for each button based on the code the button passes the listener
                popup.setOnMenuItemClickListener { item: MenuItem? ->
                    var status: TaskStatus? = null
                    when (item!!.itemId) {
                        TaskStatus.Open.ordinal -> {//ステータスを「開始」に変更
                            status = TaskStatus.Open
                        }
                        TaskStatus.InProgress.ordinal -> {//ステータスを「実行中」に変更
                            status = TaskStatus.InProgress
                        }
                        TaskStatus.Complete.ordinal -> {//ステータスを「完了」に変更
                            status = TaskStatus.Complete
                        }
                        deleteCode -> {//タスクを削除
                            removeAt(holder.data?._id!!)
                        }
                    }

                    //ステータスが変更されたとき、変更をRealmに反映させる
                    // if the status variable has a new value, update the status of the task in realm
                    if (status != null) {
                        Log.v(TAG(), "Changing status of ${holder.data?.name} (${holder.data?._id}) to $status")
                        changeStatus(status!!, holder.data?._id)
                    }
                    true
                }
                popup.show()
            }}
    }

    //ステータス変更をRealmに反映
    private fun changeStatus(status: TaskStatus, _id: ObjectId?) {
        //Realmインスタンスを新規作成する
        // need to create a separate instance of realm to issue an update, since this event is
        // handled by a background thread and realm instances cannot be shared across threads
        val bgRealm = Realm.getDefaultInstance()
        //トランザクションを同期処理で実行(ステータス変更はバックグラウンドスレッドのみで実行される必要があるため)
        // execute Transaction (not async) because changeStatus should execute on a background thread
        bgRealm!!.executeTransaction {
            //選択したタスクとIDが等しいデータをRealmからクエリで選択し、ステータスを変更
            // using our thread-local new realm instance, query for and update the task status
            val item = it.where<Task>().equalTo("_id", _id).findFirst()
            item?.statusEnum = status
        }
        // always close realms when you are done with them!
        bgRealm.close()
    }

    //タスクの削除
    private fun removeAt(id: ObjectId) {
        // need to create a separate instance of realm to issue an update, since this event is
        // handled by a background thread and realm instances cannot be shared across threads
        val bgRealm = Realm.getDefaultInstance()
        // execute Transaction (not async) because remoteAt should execute on a background thread
        bgRealm!!.executeTransaction {
            //選択したタスクとIDが等しいデータをRealmからクエリで選択し、削除
            // using our thread-local new realm instance, query for and delete the task
            val item = it.where<Task>().equalTo("_id", id).findFirst()
            item?.deleteFromRealm()
        }
        // always close realms when you are done with them!
        bgRealm.close()
    }

    //RecyclerViewの中に含まれるViewおよびデータを定義するクラス
    internal inner class TaskViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        var name: TextView = view.findViewById(R.id.name)
        var status: TextView = view.findViewById(R.id.status)
        var data: Task? = null
        var menu: TextView = view.findViewById(R.id.menu)

    }
}

3-7. メインアクティビティの修正

プロジェクト作成時に生成したメインアクティビティのレイアウトactivity_main.xmlおよびクラスMainActivity.ktを、
タスク一覧画面と紐づけるように&データ同期できるように修正します。

レイアウトの修正

レイアウトactiviti_main.xmlを、下記のように修正します。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/activity_task"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/task_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@null" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/floating_action_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="16dp"
        android:contentDescription="@string/new_task"
        app:srcCompat="@mipmap/ic_plus"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

このままだと「@mipmap/ic_plus」のところでエラーが出るので、下記手順でタスク追加用アイコン画像を指定します。

app → res → mipmapを右クリックし、New → Image Assetを選択します
48_add_action_button_icon1.png
下図の手順で、「+」マークのアイコンを設定します
49_add_action_button_icon2.png

ログアウトメニューの追加

ログアウトメニューのレイアウトactivity_task_menu.xmlを、下記手順で追加します。

app → resを右クリックし、New → Android Resource Directoryを選択します
50_make_option_menu_layout1.png
Resource Typeを「menu」に変更し、OKを押します
51_make_option_menu_layout2.png
生成したapp → res → menuフォルダを右クリックし、New → Menu Resource Fileを選択します
52_make_option_menu_layout3.png
"activity_task_menu"と名前を付けてOKを押します
53_make_option_menu_layout4.png
生成したactivity_task_menu.xmlを、下記のように書き換えます

activity_task_menu.xml
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".CounterActivity">
    <item
        android:id="@+id/action_logout"
        android:orderInCategory="100"
        android:title="@string/logout"
        android:text="@string/logout"
        app:showAsAction="always"/>
</menu>

MainActivityクラスの修正

MainActivity.ktを下記のように修正します。

MainActivity.kt
package com.mongodb.realmtutorial//パッケージ名に合わせて修正

import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import io.realm.Realm
import io.realm.mongodb.User
import io.realm.kotlin.where
import io.realm.mongodb.sync.SyncConfiguration
import com.mongodb.realmtutorial.model.TaskAdapter//パッケージ名に合わせて修正
import com.mongodb.realmtutorial.model.Task//パッケージ名に合わせて修正

class MainActivity : AppCompatActivity() {
    private lateinit var realm: Realm
    private var user: User? = null
    private lateinit var recyclerView: RecyclerView
    private lateinit var adapter: TaskAdapter
    private lateinit var fab: FloatingActionButton

    override fun onStart() {
        super.onStart()
        //ログイン中ユーザの取得
        try {
            user = taskApp.currentUser()
        } catch (e: IllegalStateException) {
            Log.w(TAG(), e)
        }
        //ログイン中ユーザが存在しない時、ログイン画面を表示する
        if (user == null) {
            // if no user is currently logged in, start the login activity so the user can authenticate
            startActivity(Intent(this, LoginActivity::class.java))
        }
        //ログイン中ユーザが存在するとき
        else {
            //Realm設定に、ログイン中ユーザおよびpartitionを適用
            //(partitionValueは、使用するpartitionに合わせて変更)
            // configure realm to use the current user and the partition corresponding to "My Project"
            val config = SyncConfiguration.Builder(user!!, "Project HomeIoT")
                .waitForInitialRemoteData()
                .build()

            //上記設定をデフォルトとして保存
            // save this configuration as the default for this entire app so other activities and threads can open their own realm instances
            Realm.setDefaultConfiguration(config)

            //バックグラウンド処理でRealm DBと同期し、成功したらRecyclerViewを呼び出す
            // Sync all realm changes via a new instance, and when that instance has been successfully created connect it to an on-screen list (a recycler view)
            Realm.getInstanceAsync(config, object: Realm.Callback() {
                override fun onSuccess(realm: Realm) {
                    // since this realm should live exactly as long as this activity, assign the realm to a member variable
                    //同期したRealmインスタンスを親クラスMainActivityのインスタンスに設定
                    this@MainActivity.realm = realm
                    //RecyclerViewを初期設定して呼び出す
                    setUpRecyclerView(realm)
                }
            })
        }
    }
    override fun onStop() {
        super.onStop()
        user.run {
            realm.close()
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //MainActivityクラス内のプロパティ初期化(lateinit変数のためonCreate実行時に初期化される)
        // default instance uses the configuration created in the login activity
        realm = Realm.getDefaultInstance()//Realmのインスタンス
        recyclerView = findViewById(R.id.task_list)//RecyclerViewのインスタンス
        fab = findViewById(R.id.floating_action_button)//タスク作成ボタンのインスタンス

        //タスク作成ボタンを押したとき、新しいタスク名を入力するダイアログを作成
        // create a dialog to enter a task name when the floating action button is clicked
        fab.setOnClickListener {
            val input = EditText(this)
            val dialogBuilder = AlertDialog.Builder(this)
            dialogBuilder.setMessage("Enter task name:")
                .setCancelable(true)
                .setPositiveButton("Create") { dialog, _ -> run {
                    dialog.dismiss()
                    val task = Task(input.text.toString())
                    // all realm writes need to occur inside of a transaction
                    realm.executeTransactionAsync { realm ->
                        realm.insert(task)
                    }
                }
                }
                .setNegativeButton("Cancel") { dialog, _ -> dialog.cancel()
                }

            val dialog = dialogBuilder.create()
            dialog.setView(input)
            dialog.setTitle("Create New Task")
            dialog.show()
        }
    }

    //アクティビティ終了時の処理(TaskAdapterインスタンス削除&realmインスタンスをClose)
    override fun onDestroy() {
        super.onDestroy()
        recyclerView.adapter = null
        // if a user hasn't logged out when the activity exits, still need to explicitly close the realm
        realm.close()
    }

    //logoutメニューをMainActivity上に設置
    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.activity_task_menu, menu)
        return true
    }

    //logoutメニューを押したときの処理(ログアウト)
    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.action_logout -> {
                user?.logOutAsync {
                    if (it.isSuccess) {
                        // always close the realm when finished interacting to free up resources
                        realm.close()
                        user = null
                        Log.v(TAG(), "user logged out")
                        startActivity(Intent(this, LoginActivity::class.java))
                    } else {
                        Log.e(TAG(), "log out failed! Error: ${it.error}")
                    }
                }
                true
            }
            else -> {
                super.onOptionsItemSelected(item)
            }
        }
    }

    //RecyclerViewを初期設定して呼び出し、ID順で表示
    private fun setUpRecyclerView(realm: Realm) {
        // a recyclerview requires an adapter, which feeds it items to display.
        // Realm provides RealmRecyclerViewAdapter, which you can extend to customize for your application
        // pass the adapter a collection of Tasks from the realm
        // sort this collection so that the displayed order of Tasks remains stable across updates
        adapter = TaskAdapter(realm.where<Task>().sort("_id").findAll())
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = adapter
        recyclerView.setHasFixedSize(true)
        recyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
    }
}

下図のように右上に「Sync Now」と出てきた場合、クリックします
54_sync.png

以上で、アプリが完成です

4. 動作確認

アプリが正しく動作するか確認します

4.1 アカウント登録の確認

エミュレータでアプリを起動します
34_run_emulator.png

ログイン画面が出てきたら、好きなメールアドレスとパスワードを入力し、「CREATE ACCOUNT」を押します
55_run_create_account.png

チュートリアル画面が出てきたらアプリケーション側の処理は成功です。
念のため、一度ログアウトして再度ログインできるかも確認します
56_run_create_account2.png

MongoDB Realmクラウド画面でも、登録したユーザがUserに追加されていたら登録成功です。
※usersコレクションにも内容の一部が追加されます
57_run_create_account3.png

4.2 タスク追加の確認

右下の「+」ボタンを押し、タスク名を入力して「Create」を押し、タスクを追加します
58_run_create_task1.png
メイン画面にタスクが追加されていれば、アプリケーション側の処理は成功です。
59_run_create_task2.png
MongoDB Realmクラウド画面でも、登録したタスクがtasksコレクションに追加されていたら登録成功です。
60_run_create_task3.png

61_run_create_task4.png

4.3 実機での動作確認

こちらを参考に、実機でも動作確認を実施し、正常に動けば成功です。
開発者オプション項目が表示されない場合は、こちらを参照ください

トラブルシューティング

いくつかハマりポイントがありましたが、特にデータモデル設定関係が厄介だと感じました。
私が遭遇したエラーと対処法を下記します。

データモデルとスキーマの型一致を確認

データモデルとスキーマの型が一致していないと、下記のようなエラーが発生します
io.realm.exceptions.RealmMigrationNeededException: ・・
property .[フィールド名] has been made required

どのような型に設定すべきかは、MongoDB Realmクラウド画面の、下記から見ることができます(スキーマから自動生成)
trouble_schema.png

上記エラーが出た時のデータモデルは、下図のようになっていました。
trouble_datamodel.png

データモデルではString型、スキーマではString?型となっており、Null許容の有無が型の違いとして認識されたようです。

open class Task(_name: String = "Task", project: String? = "Project HomeIoT") : RealmObject() {
    //コレクションに存在するプロパティ(フィールド)の定義と、初期値の入力
    @PrimaryKey var _id: ObjectId = ObjectId()
    var _partition: String? = project

のように、データモデル側でもString?型に変更したところ、エラーが解消しました。

フィールド構成変更後はアプリを再インストール

フィールド構成変更後(データモデルの変更etc.)にそのままアプリを起動すると、
io.realm.exceptions.RealmMigrationNeededException: ・・
property .[フィールド名] has been made optional

というエラーが出ます。
アプリのフィールド構成が変更されたのに、Realmのフィールド構成が変更されていないことが原因のようです。
参考

上記現象は、下図のようにアプリを一度アンインストールする事で解消しました。
trouble_uninstall1.png

trouble_uninstall2.png

40
28
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
28