43
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Android Orma with Kotlin

Last updated at Posted at 2017-11-11

実用的なライブラリをKotlin交えて紹介しようプロジェクト3

このたびは日本で有名なORMライブラリ、Ormaに触れていきます
個人的には、クラウド同期が必要ないのであればRealmより断然おすすめです

一応こちらのPRに、実装過程を載せました(Orma+Dagger2+Databinding+Kotlin)

Orma

Ormagfxさんが開発した、日本製のORMライブラリです
有名なRealmなどと異なりSQLiteをベースに作成されています

メリット

  • パフォーマンスがよい(参考
  • 比較的簡単に実装が可能
  • 学習コストが低い
  • 豊富なUtilityメソッド
  • カラムの追加/削除などの単純なマイグレーションであれば意識しなくてよい
  • パフォーマンスのチューニングが容易
  • つまり、細かい設定が可能

デメリット

  • クラウドサービスとの同期には向いていない
  • 同期の話があるとRealm、Firebaseに軍杯があがるイメージ
  • 細かい設定が可能、というのは裏を返せば、いちいち設定する必要があるということ

使い方

基本的には以下ステップです

  1. gradleに設定
  2. Modelクラス作成
  3. OrmaDatabaseを経由してDB操作

Gradle

まずは下準備をしましょう

Kotlinはこちら

build.gradle
dependencies {
    // for Orma Database
    kapt 'com.github.gfx.android.orma:orma-processor:4.2.5'
    compile 'com.github.gfx.android.orma:orma:4.2.5'
}

kapt {
    // Avoid NonExistentClass Error (Dagger2と併用している場合、書いておくと幸せになれる)
    correctErrorTypes = true
}

Javaはこちら

build.gradle
dependencies {
    // for Orma Database
    annotationProcessor 'com.github.gfx.android.orma:orma-processor:4.2.5'
    compile 'com.github.gfx.android.orma:orma:4.2.5'
}

Model

ではOrmaで利用するデータモデルを作成しましょう
Ormaでは、用意されているアノテーションをつけることで
TableおよびColumn要素を宣言することができます

Table

以下のように、クラスに@Tableと宣言することでTableを定義できます

@Table
class Person {
	// ...
}

こうするとクラス名と同名のTableが作成されます。
もちろん、テーブル名を明示することも可能です。

@Table("persons")
class Person {
	// ...
}

Column

Ormaではカラムを設定する場合、変数に@Columnをつけるのと
GetterとSetterにそれぞれ@Getter,@Setterのアノテーションをつける必要があります

JavaとKotlinでは少し違うのでどちらも載せておきます

Person.kt
@Table("persons")
class Person {
    @Column
    var firstName: String?
}
Person.java
@Table
class Person {

	@Column
	private String firstName;
	
	@Getter
	public String getFirstName() {
		return firstName;
	}
	
	@Setter
	public void setFirstName(String firstName) {
		this.firstName = firstName;
	}
}

このケースでいうと、firstNameというカラム名が自動作成されます

Nullable

Kotlinは意識しなくてよいです

Javaの場合、Ormaで作成するカラムは、何も指定しない場合はNOT NULLです
Nullを許容する場合には@Nullableをつけるようにしてください

Person.java
	@Column
	@Nullable
	private String firstName;

value

変数名とは異なる名前を与えたい場合は、@Columnで名前を指定可能です

Person.java
    @Column(value = "first_name")
    @get:Getter
    @set:Setter
    var firstName: String?

また、一定のルールであればオプションをつけると勝手に命名してくれるそうです

が、指定する方法をわすれました。
優しい人、おしえてください。。

index

そのColumnに対して条件検索などをかけたい場合、
@Columnの中でindexed = trueをつける必要があります

Person.java
    @Column(value = "first_name", indexed = true)
    var firstName: String?

ここでは触れませんが、こうすることで
例えば「firstNameがBobの人」といったレコードの抽出が可能になります

default

初期値を指定したい場合は、
@Columnの中でdefaultExpr = "[初期値]"をつける必要があります

Person.java
    @Column(defaultExpr = "Male", indexed = true)
    var sex: Gender? = null

注意点としては、整数であっても文字列で指定する必要があります
Booleanの場合は 1/0 で指定する必要があります

PrimaryKey

SQLにはほぼほぼ必須なPrimaryKeyも設定することができます
Keyの名称は自動的に BaseColumn._ID (_id) になります

Person.java
    @PrimaryKey(autoincrement = true)
    var id: Long = 0

勝手にインクリメントしてくれるオプションもあるので、
特に何もない場合はつけておきましょう

SingleAssociation

SQLの外部キーのように他モデルに関連づけができます
実際の動作イメージとしては以下になります

  • 他モデルへの紐付けが行われる(PrimaryKeyがキー)
  • ON UPDATE CASCADEON DELETE CASCADEがつきます
  • SingleAssociationインスタンス経由でgetされた場合に紐付け先のインスタンスが作られる
Person.kt
    @Column
    var building: SingleAssociation<Building>? = null
Building.kt
@Table
class Building {

    @PrimaryKey(autoincrement = true)
    var id: Long = 0

    @Column(indexed = true)
    var name: String? = null

    @Column
    var lat: Double = 0.0

    @Column
    var lng: Double = 0.0
}

このケースでいうと、Buildingのレコードが消えると、
そのレコードidをもつPersonレコードが一緒に消えてくれます

Type Adapter

例えば自分の作成した独自のenum classであったり、
Utilityデータクラスがあったとして、それを格納したい場合があると思います

もちろん、それを直接格納することはできませんが、
Serialize/Deserialize方法を指定することで、
文字列として擬似的に格納し、データクラスでは独自クラスのインスタンスとして扱うことができます

このようにStaticなメソッドとして定義します

TypeAdapter.kt
@StaticTypeAdapters(
        StaticTypeAdapter(
                targetType = Gender::class,
                serializedType = String::class,
                serializer = "serializeGender",
                deserializer = "deserializeGender")
)
class TypeAdapters {
    companion object {
        @JvmStatic
        fun serializeGender(gender: Gender): String = gender.name

        @JvmStatic
        fun deserializeGender(serialized: String): Gender = Gender.valueOf(serialized)
    }
}
Gender.kt
enum class Gender { Male, Female }
Person.kt
    @Column
    var sex: Gender? = null

OrmaDatabase

さて、Model(Columnを定義したTable)クラスを作成したらビルドしましょう
すると以下クラスが自動生成されます

  • OrmaDatabase
  • Table名_Relation
  • Table名_Selection
  • Table名_Inserter
  • Table名_Updater
  • Table名_Deleter
  • Table名_Sheme

(後半6つの名前は@Tableのオプションからカスタマイズ可能)

これらはDatabaseを操作するためのクラスであり、
すべてOrmaDatabaseを起点としてインスタンス化されます

デフォルトの設定では以下が有効になっているので、必要に応じて切り替えましょう

  • 実行SQLをlogcat出力
  • メインスレッド上での書き込みで例外

Singleton

これはOrmaDatabaseを利用するうえでの推奨です
OrmaDatabaseはそれをインスタンス化する際に少し時間がかかるので
シングルトンで作成してしまい、それを使い回すことを推奨しています

Dagger2を利用したケースのみ書きます
それなに?って方はこちらの記事を読んでください

AppModule.kt
    @Singleton
    @Provides
    fun provideOrmaDatabase(): OrmaDatabase {
        return OrmaDatabase.builder(mContext)
                .name("Original_Database_Name")
                .readOnMainThread(AccessThreadConstraint.NONE)
                .writeOnMainThread(AccessThreadConstraint.NONE)
                .build()
    }

実際に実装する際は、これをもつUtilityクラスを作成することになるかと思います
以下に一例を載せておきます

PersonDao.kt
class PersonDao @Inject constructor(private val orma: OrmaDatabase) {
	// ...
}

Relation

RelationはTableにアクセスするためのUtilityクラスのようなものです

RelationはSelecter, Inserter, Updater, Deleterの作成が可能ですが、逆はできません。
すべてのコントローラクラスで条件付けをすること(WHERE句の指定)は可能ですが、
この仕様のためRelationクラスでレコードの条件付けをするように推奨されています

PersonDao.kt

    private fun relation(): Person_Relation = mOrma.relationOfPerson()

    private fun relationById(id: Long): Person_Relation = relation().idEq(id)

    private fun relationInMale(): Person_Relation = relation().sexEq(Gender.Male)

    private fun relationInFemale(): Person_Relation = relation().sexEq(Gender.Female)

また、isEmpty(), count() などのUtilityメソッドも用意されています

PersonDao.kt
    val isEmpty: Boolean get() = relation().isEmpty

    val count: Int get() = relation().count()

Selection

以下用途で利用されます

  • @Tableで指定したモデルクラスをインスタンス化する
  • Cursorを取得する
  • isEmpty, count などのUtilityメソッドを使う(Relationも使える)

リストとしても取得できますし、
指定した条件での最初のレコードを取得することも可能です

PersonDao.kt
    fun findAll(): List<Person> = relation().selector().toList()

    fun findById(id: Long): Person? = relationById(id).selector().valueOrNull()

また、パフォーマンスのことを考えてCursorだけを取得することも可能です(後述)

Inserter

モデルインスタンスを渡すことでinsertが可能です
戻り値はSQLiteのInsert同様、idが返ります

また、配列を渡すことで一気にInsertすることも可能です

PersonDao.kt
    fun insert(person: Person): Long = relation().inserter().execute(person).also { person.id = it }

    fun insert(persons: List<Person>) = relation().inserter().executeAll(persons)

Updater

それぞれのカラムの何を設定するか(SET句)を指定することでレコードの更新を行います
これもSQLite同様、戻り値が更新件数になります

PersonDao.kt
    fun update(person: Person): Int {
        return getUpdaterBy(person).apply {
            name(person.name)
            sex(person.sex)
            height(person.height)
            weight(person.weight)
            bmi(person.bmi)
            test(person.test)
        }.execute()
    }

    fun updateBmi(person: Person): Int {
        return getUpdaterBy(person).apply {
            height(person.height)
            weight(person.weight)
            bmi(person.bmi)
        }.execute()
    }

更新カラムが少ない方がパフォーマンスが良いと思うので
場合によって取捨選択すると良いでしょう

Deleter

Relation、あるいはDeleterで指定した条件(WHERE句)で絞ったレコードを削除します
これもSQLite同様、戻り値が更新件数になります

PersonDao.kt
    fun delete(person: Person): Int = relationById(person.id).deleter().execute()

    fun deleteAll(): Int = relation().deleter().execute()

Sheme

ここにはテーブルのカラム名やCreateするときのSQL文など、
各種設定値が格納されています。

パフォーマンスチューニングでSQLを発行するときなどに、割と使ったりします

Transaction

このTransaction間に行った処理の間に問題が発生した場合、
ロールバックされるという仕組みです。

また、DBアクセスの排他制御をしたいときにも使えます。

PersonDao.kt
ormaDatabase.transactionSync {
    // この区間を実行中、他の要求は一時停止されます
}

ormaDatabase.transactionNonExclusiveSync {
    // この区間を実行中、SELECT以外の要求は一時停止されます
}

Migration

Columnの増減

私はこれが好きでOrma使ってるのですが、
Ormaは Columnの増減レベルだと自動的にやってくれます

方法は簡単で、@Columnをつけた変数を増やしたり減らしたりするだけです

しかし注意点として、新しく追加するカラムは以下のどちらかを行う必要があります

  • Nullableをつける(Kotlinでは?をつける)
  • defaultExprで初期値を指定する

手動Migration

少し複雑なマイグレーション(例えばカラムAとBを足したものを、新しいカラムCに設定するなど)は手動で記述する必要があります

その場合はOrmaDatabaseの作成時に指定します
操作はSQLiteHelper経由でSQLを発行して行います
バージョンはアプリのVersionCodeに相当するので、
アプリのバージョンを指定してマイグレーションを行います

AppModule.kt
int VERSION; // a past version of VERSION_CODE

OrmaDatabase orma = OrmaDatabase.builder(this)
        .migrationStep(VERSION, new ManualStepMigration.ChangeStep() {
            @Override
            public void change(@NonNull ManualStepMigration.Helper helper) {
              Log.(TAG, helper.upgrade ? "upgrade" : "downgrade");
              helper.execSQL("DROP TABLE foo");
              helper.execSQL("DROP TABLE bar");
            }
        })
        // ... other configurations
        .build();

応用

パフォーマンスチューニング

膨大なレコード数があるとき、
Selecterを使ってレコード抽出する場合に有効です

OrmaのSelecterでレコードを抽出してインスタンス化するとき
以下のようなフローで処理が行われて戻り値が作成されています

  1. SQL発行
  2. Cursorの作成
  3. モデルのインスタンス化
  4. モデルに対してCursorから値を注入

ようするに我々がSQLiteHelperを使って通常行なっていることを自動でやってくれているわけです
すると 不要なカラムまで取得しているというケースが発生してしまい、
それがボトルネックとなりリスト生成に時間がかかったりしてしまいます。

特定のColumn抽出

Selecterに用意されているexecuteWithColumns()を利用します
この引数に、必要なカラムをTableName_Schemeから指定することで、
特定のカラムだけもつCursorが作成されます

PersonDao.kt
    fun getBmiListCursor(): Cursor {
        return personDao.relation().selector().executeWithColumns(
                Person_Schema.INSTANCE.height.name,
                Person_Schema.INSTANCE.weight.name,
                Person_Schema.INSTANCE.bmi.name
        )
    }

    companion object {
        fun createByCursor(cursor: Cursor): Person {
            return create.apply {
                height = cursor.getDouble(0)
                weight = cursor.getDouble(1)
                bmi = cursor.getDouble(2)
            }
        }
    }

これでもまだ遅い!という場合は、たいていモデルのインスタンス化が原因です
そのときはモデルのインスタンス化すら省略するようにすると爆速になったりします

参考までに、私が仕事で行った8000件のどの位置情報レコードを、
現在の位置情報との距離でソートするときに行ったパフォーマンスの差を書いておきます

チューニング 結果
toList()後、ソート 300秒超え
Cursorで取得、Modelインスタンスでソート 30〜60秒
Cursorで取得、Doubleの位置情報だけ抽出してソート 1秒弱

仕事柄、かなり古い端末での利用シーンを想定していたのでこのスコアですが、
おかげざまで実用に耐えるパフォーマンスを出せました。ヤッタネ
(ちなみに私のXperia XZ Premiumでも真ん中のスコアが7秒でした)

一応補足として、以下理由により直接のSQL発行はできませんでした

  • 位置情報計算のfunctionがSQLiteでは未対応
  • 現在の位置情報という変動値と比較するので、まず8000件のデータを取ってきてソートが必須

複雑なQuery指定

一部、直接SQLを書かないと、たとえIndex指定をしたとしても
OrmaのサポートされているUtilityメソッドだけではカバーできないものがあります。

例えば曖昧検索(LIKE)、あるいはANDやORの組み合わせを利用したものです。
(Ormaは重ねる条件が基本的にすべてAND、あるいはすべてOR、という指定しかできない)

そんなときは自分で作っちゃいましょう

WHERE句の作成

Where句だけの設定であれば以下のようにrelationで指定ができます
例えば、LIKEはこんな感じ

PersonDao.kt
relation().where(
		"${Person_Scheme.INSTANCE.name.getEscapedName()} LIKE ?",
		"%${keyWord}%")

第一引数で指定した文字列の?に、第二引数の文字列が挿入されます

SQL文を全部発行

SQL全文はこんな感じで打ち込むと、Cursorが取得できます

PersonDao.kt
ormaDatabase.connection.rawQuery("あなたの最強SQL Query")

追記

Getter Setter アノテーション不要

データモデルを作成するときに、ColumnアノテーションさえつければOKのようです。
JavaのときのようにGetter/Setterにアノテーションをつける必要はないようです。

ということで、修正しました。

43
26
0

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
43
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?