#はじめに
この記事は、スマホアプリ未経験者がクラウドDBサービス**「MongoDB Realm」**を利用して、
サーバデータと連携したスマホアプリを作成した記事となります。
私はスマホアプリ開発未経験の超初心者ですが、公式サイトが丁寧だったこともあり、目的の連携処理を実装することができました。
(実践編として、こちらの記事もご参照いただければと思います)
スマホアプリ初心者がバックエンド処理を作成する際の、一つの指針になればと思います。
英文チュートリアルを参考にしたため、間違いがあるかもしれませんが、見つけた場合はご指摘頂けますとありがたいです!
#クラウドとスマホアプリ
世の中ではサービスのクラウド化が進んでいますが、
この流れに乗じスマホアプリの開発工数を減らせないか?ということで、
バックエンド処理をクラウド経由で簡単に構築しよう、という取り組みが進んでいます。
このようなサービスを「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
となるようです。
サーバとクライアントのデータをリアルタイム連携出来ることを売りにしています。
###メリット
GAFAの先発サービスと比較した際のメリットとしては、下記のようなものがありそうです。
####プラットフォーム依存性が低く、リソースや学習コストの流用がしやすい
AWSやGCPのプラットフォーム内に組み込まれていないので、
他のクラウドサービスやオンプレからの移植性が高いと思われます。
またモバイルやクラウド以外でも多用されるMongoDBがベースとなっている事も、
リソース流用や学習のしやすさに繋がりそうです。
####無料で出来ることが多い
AWSやFirebaseのサービスを使ったことがないので比較はできませんが(使用された事がある方、コメント頂けるとありがたいです)、
無料枠で、リアルタイム同期を始めとした、今回やりたい事は基本的にできました。
無料枠はサーバ側DB (MongoDB Atlas)の容量上限がやや低い(500MB)ので、
軌道に乗ってデータ量が増えたら有料サービスにアップグレード、という使い方が適していそうです。
#実際に試してみた
公式チュートリアルに記載されている、タスク管理アプリを作ってみました。
###アプリの内容
スマホアプリでタスクを登録すると、クラウドDBにリアルタイム連携されるアプリです。
シンプルすぎて実用性は微妙ですが、クラウドDBとスマホアプリ間のデータの動きを見るには最適かと思います。
###システム構成
公式チュートリアルではアプリ内容について殆ど説明されていなかったので、
理解しやすくするためにシステム構成も図示します。
#必要なもの
・開発用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」を押します
###クラスタの作成、ユーザの作成、ホワイトリストの設定
こちらをご参照いただければと思います。
なお、Realmを使用してスマホアプリと連携したい場合、MongoDBのバージョンを4.4とする必要がありますが、
2020/8現在、バージョン4.4を指定できるのは、プロバイダにAWSのN. Virginiaを指定したときのみなので、ご注意下さい。
私は下記のクラスタ設定としました。
##2. MongoDB Realmのサーバ側初期設定
サーバ・クライアント連携システムであるMongoDB Realmの、サーバ側設定を実施します。
こちらを参考にさせて頂きました
###2-1. Realmアプリケーションの作成
MongoDB Atlasのクラスタ設定画面から、「Realm」タブをクリックします。
!
今回の用途に合わせ、「Mobile」「Android」「No, I'm starting from scratch(新規作成)」を選択します
アプリケーション名(好きな名前を付けてください。今回は"HomeIoTApp"とします)と、
連携するMongoDB Atlasのクラスタを指定します
###2-2. ルールの作成
スキーマ等、サーバ側DBの従うルールを定義していきます
####DBおよびコレクションの作成
下記手順でDBおよびコレクションを作成します。
同様の手順で、DB「tracker」内に、「tasks」「users」の2個のコレクションを作成します。
(公式チュートリアルは「project」コレクションも作成していますが、最後まで使用しないので本記事では作成しません)
####スキーマの作成
下記手順でスキーマ(コレクション=RDBMSにおけるテーブルのフィールド構成)を作成します
(NoSQLなのにスキーマが必要なのがちょっと意外ですが、Realmとの連携に必要なようです)
各コレクションのスキーマには、下記を記述してください
{
"title": "Task",
"bsonType": "object",
"required": [
"_id",
"name",
"status"
],
"properties": {
"_id": {
"bsonType": "objectId"
},
"_partition": {
"bsonType": "string"
},
"name": {
"bsonType": "string"
},
"status": {
"bsonType": "string"
}
}
}
{
"title": "User",
"bsonType": "object",
"required": [
"_id",
"name"
],
"properties": {
"_id": {
"bsonType": "string"
},
"_partition": {
"bsonType": "string"
},
"name": {
"bsonType": "string"
}
}
}
###2-3. ユーザ権限の設定
Usersタブ → Providersタブをクリックします
Provider EnabledをONに
User Confirmation MethodをAutomatically confirm usersに
Password Reset MethodをRun a password reset functionに
Functionを、新たに作成したresetFuncに設定する
###2-4. 新規登録ユーザのDB追加処理作成
ユーザ新規登録時に、usersコレクションにも左記ユーザが追加されるように処理を設定します。
Triggersタブを選択し、Add a Triggerをクリック
下記の設定でトリガを作成
関数名"createNewUserDocument"を入力し、コードを貼り付けてSaveする
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-6. 変更をデプロイ
ここまでで設定に一区切りついたので、変更をアプリケーションに反映(デプロイ)させます。
画面の上の方に出てくるREVIEW&DEPLOYをクリックして、変更内容を確認したうえでDEPLOYをクリックします。
以後も設定に変更を加えた際は、適宜デプロイします
##3. Androidアプリの作成
※参考公式チュートリアル
MongoDB Realmによりサーバとデータ同期可能なAndoroidアプリを作成します。
Android Studioを使用します。
###3-1. プロジェクトの作成
Android Studioを開き、「Start a new Android Studio Project」をクリックします。
出てきたテンプレート選択画面で、「Empty Activity」を選択します。
好きなプロジェクト名、パッケージ名を記入し、
言語:Kotlin、Minimum SDK:API21を選択します。
(今回はプロジェクト名"RealmTutorial"、パッケージ名com.mongodb.realmtutorialとしました)
###3-2. Realmプラグインのインストール
必要なプラグインをインストールします
####Project全体へのインストール
左のタブから、2つあるBuild.gradleのうちProjectの方に以下の記述を追記し、プロジェクト全体にrealmプラグインをインストールします。
(2ヵ所のmavenはベータ版用の処理との記載なので、今後なくなるかもしれません)
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が依存するモジュールなので、後者より上に記載する必要あり
:
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でプロジェクトを開きなおしたのち、
プログラムを実行して正常にアプリが起動するか確認します(各種ライブラリがエラーを出さないか確認)
私の場合起動までに1分以上かかったので、気長に待ちましょう。
###3-3. Realmアプリケーションのグローバルインスタンス生成用クラス作成
Realmアプリケーションのインスタンスは、1個作成したものをアプリ全体で共有(グローバルインスタンス)するようです。
####クラスの作成
上記グローバルインスタンスを生成するクラスを、下記手順で作成します。
javaフォルダ内のパッケージフォルダを右クリックし、New → Kotlin File/Class
Classを選択し、下記のような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を開きます
例えばモジュール名がcom.mongodb.realmtutorialのとき、下記のように追記します
android:name="com.mongodb.realmtutorial.TaskTracker"
###3-4. ログイン画面の作成
MongoDB Realmのユーザアカウント作成orログインする画面を作成します。
####ログイン画面アクティビティの作成
下記操作で、ログイン画面のアクティビティ(Androidアプリにおける画面GUIに相当する部品)を作成します
javaフォルダ内のパッケージフォルダを右クリックし、New → Activity → Empty Activity
名前を"LoginActivity"に変え、Finish
####ログイン画面のレイアウト作成
下記操作で、画面レイアウトactivity_login.xmlを編集します
先ほどの操作でapp → res → layoutに生成した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に下記内容を追記します
<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タブで確認すると、下図のようなレイアウトになっているはずです。
####MainActivityからログイン画面への遷移作成
ログイン中ユーザが存在しないときはログイン画面に遷移するよう、
java → パッケージ名のフォルダ内にある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を下記のように書き換えます
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
パッケージに"model"という名前を付けます
####tasksコレクションに対応したデータモデルクラス「Task.kt」の作成
サーバDBの「tasks」コレクションに対応したクラス、「Task.kt」を作成します
作成したパッケージ名を右クリックし、New → Kotlin File/Class
Classを選択し、"Task"と名前を付けてEnter
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と同じフォルダに作成します
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を選択します
名前を"task_view"にしてOKを押します。
生成された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と名前を付け、下記のコードを記載します。
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を、下記のように修正します。
<?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を選択します
下図の手順で、「+」マークのアイコンを設定します
####ログアウトメニューの追加
ログアウトメニューのレイアウトactivity_task_menu.xmlを、下記手順で追加します。
app → resを右クリックし、New → Android Resource Directoryを選択します
Resource Typeを「menu」に変更し、OKを押します
生成したapp → res → menuフォルダを右クリックし、New → Menu Resource Fileを選択します
"activity_task_menu"と名前を付けてOKを押します
生成した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を下記のように修正します。
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」と出てきた場合、クリックします
以上で、アプリが完成です
##4. 動作確認
アプリが正しく動作するか確認します
###4.1 アカウント登録の確認
エミュレータでアプリを起動します
ログイン画面が出てきたら、好きなメールアドレスとパスワードを入力し、「CREATE ACCOUNT」を押します
チュートリアル画面が出てきたらアプリケーション側の処理は成功です。
念のため、一度ログアウトして再度ログインできるかも確認します
MongoDB Realmクラウド画面でも、登録したユーザがUserに追加されていたら登録成功です。
※usersコレクションにも内容の一部が追加されます
###4.2 タスク追加の確認
右下の「+」ボタンを押し、タスク名を入力して「Create」を押し、タスクを追加します
メイン画面にタスクが追加されていれば、アプリケーション側の処理は成功です。
MongoDB Realmクラウド画面でも、登録したタスクがtasksコレクションに追加されていたら登録成功です。
###4.3 実機での動作確認
こちらを参考に、実機でも動作確認を実施し、正常に動けば成功です。
開発者オプション項目が表示されない場合は、こちらを参照ください
#トラブルシューティング
いくつかハマりポイントがありましたが、特にデータモデル設定関係が厄介だと感じました。
私が遭遇したエラーと対処法を下記します。
##データモデルとスキーマの型一致を確認
データモデルとスキーマの型が一致していないと、下記のようなエラーが発生します
io.realm.exceptions.RealmMigrationNeededException: ・・ property .[フィールド名] has been made required
どのような型に設定すべきかは、MongoDB Realmクラウド画面の、下記から見ることができます(スキーマから自動生成)
上記エラーが出た時のデータモデルは、下図のようになっていました。
データモデルでは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のフィールド構成が変更されていないことが原因のようです。
参考