実用的なライブラリをKotlin交えて紹介しようプロジェクト3
このたびは日本で有名なORMライブラリ、Ormaに触れていきます
個人的には、クラウド同期が必要ないのであればRealmより断然おすすめです
一応こちらのPRに、実装過程を載せました(Orma+Dagger2+Databinding+Kotlin)
Orma
Ormaはgfxさんが開発した、日本製のORMライブラリです
有名なRealmなどと異なりSQLiteをベースに作成されています
メリット
- パフォーマンスがよい(参考)
- 比較的簡単に実装が可能
- 学習コストが低い
- 豊富なUtilityメソッド
- カラムの追加/削除などの単純なマイグレーションであれば意識しなくてよい
- パフォーマンスのチューニングが容易
- つまり、細かい設定が可能
デメリット
- クラウドサービスとの同期には向いていない
- 同期の話があるとRealm、Firebaseに軍杯があがるイメージ
- 細かい設定が可能、というのは裏を返せば、いちいち設定する必要があるということ
使い方
基本的には以下ステップです
- gradleに設定
- Modelクラス作成
- OrmaDatabaseを経由してDB操作
Gradle
まずは下準備をしましょう
Kotlinはこちら
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はこちら
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では少し違うのでどちらも載せておきます
@Table("persons")
class Person {
@Column
var firstName: String?
}
@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
をつけるようにしてください
@Column
@Nullable
private String firstName;
value
変数名とは異なる名前を与えたい場合は、@Column
で名前を指定可能です
@Column(value = "first_name")
@get:Getter
@set:Setter
var firstName: String?
また、一定のルールであればオプションをつけると勝手に命名してくれるそうです
が、指定する方法をわすれました。
優しい人、おしえてください。。
index
そのColumnに対して条件検索などをかけたい場合、
@Column
の中でindexed = true
をつける必要があります
@Column(value = "first_name", indexed = true)
var firstName: String?
ここでは触れませんが、こうすることで
例えば「firstNameがBobの人」といったレコードの抽出が可能になります
default
初期値を指定したい場合は、
@Column
の中でdefaultExpr = "[初期値]"
をつける必要があります
@Column(defaultExpr = "Male", indexed = true)
var sex: Gender? = null
注意点としては、整数であっても文字列で指定する必要があります
Booleanの場合は 1/0 で指定する必要があります
PrimaryKey
SQLにはほぼほぼ必須なPrimaryKeyも設定することができます
Keyの名称は自動的に BaseColumn._ID (_id) になります
@PrimaryKey(autoincrement = true)
var id: Long = 0
勝手にインクリメントしてくれるオプションもあるので、
特に何もない場合はつけておきましょう
SingleAssociation
SQLの外部キーのように他モデルに関連づけができます
実際の動作イメージとしては以下になります
- 他モデルへの紐付けが行われる(PrimaryKeyがキー)
-
ON UPDATE CASCADE
とON DELETE CASCADE
がつきます - SingleAssociationインスタンス経由でgetされた場合に紐付け先のインスタンスが作られる
@Column
var building: SingleAssociation<Building>? = null
@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なメソッドとして定義します
@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)
}
}
enum class Gender { Male, Female }
@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を利用したケースのみ書きます
それなに?って方はこちらの記事を読んでください
@Singleton
@Provides
fun provideOrmaDatabase(): OrmaDatabase {
return OrmaDatabase.builder(mContext)
.name("Original_Database_Name")
.readOnMainThread(AccessThreadConstraint.NONE)
.writeOnMainThread(AccessThreadConstraint.NONE)
.build()
}
実際に実装する際は、これをもつUtilityクラスを作成することになるかと思います
以下に一例を載せておきます
class PersonDao @Inject constructor(private val orma: OrmaDatabase) {
// ...
}
Relation
RelationはTableにアクセスするためのUtilityクラスのようなものです
RelationはSelecter, Inserter, Updater, Deleterの作成が可能ですが、逆はできません。
すべてのコントローラクラスで条件付けをすること(WHERE句の指定)は可能ですが、
この仕様のためRelationクラスでレコードの条件付けをするように推奨されています
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メソッドも用意されています
val isEmpty: Boolean get() = relation().isEmpty
val count: Int get() = relation().count()
Selection
以下用途で利用されます
-
@Table
で指定したモデルクラスをインスタンス化する - Cursorを取得する
- isEmpty, count などのUtilityメソッドを使う(Relationも使える)
リストとしても取得できますし、
指定した条件での最初のレコードを取得することも可能です
fun findAll(): List<Person> = relation().selector().toList()
fun findById(id: Long): Person? = relationById(id).selector().valueOrNull()
また、パフォーマンスのことを考えてCursorだけを取得することも可能です(後述)
Inserter
モデルインスタンスを渡すことでinsertが可能です
戻り値はSQLiteのInsert同様、idが返ります
また、配列を渡すことで一気にInsertすることも可能です
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同様、戻り値が更新件数になります
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同様、戻り値が更新件数になります
fun delete(person: Person): Int = relationById(person.id).deleter().execute()
fun deleteAll(): Int = relation().deleter().execute()
Sheme
ここにはテーブルのカラム名やCreateするときのSQL文など、
各種設定値が格納されています。
パフォーマンスチューニングでSQLを発行するときなどに、割と使ったりします
Transaction
このTransaction間に行った処理の間に問題が発生した場合、
ロールバックされるという仕組みです。
また、DBアクセスの排他制御をしたいときにも使えます。
ormaDatabase.transactionSync {
// この区間を実行中、他の要求は一時停止されます
}
ormaDatabase.transactionNonExclusiveSync {
// この区間を実行中、SELECT以外の要求は一時停止されます
}
Migration
Columnの増減
私はこれが好きでOrma使ってるのですが、
Ormaは Columnの増減レベルだと自動的にやってくれます
方法は簡単で、@Column
をつけた変数を増やしたり減らしたりするだけです
しかし注意点として、新しく追加するカラムは以下のどちらかを行う必要があります
- Nullableをつける(Kotlinでは
?
をつける) - defaultExprで初期値を指定する
手動Migration
少し複雑なマイグレーション(例えばカラムAとBを足したものを、新しいカラムCに設定するなど)は手動で記述する必要があります
その場合はOrmaDatabaseの作成時に指定します
操作はSQLiteHelper経由でSQLを発行して行います
バージョンはアプリのVersionCodeに相当するので、
アプリのバージョンを指定してマイグレーションを行います
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でレコードを抽出してインスタンス化するとき
以下のようなフローで処理が行われて戻り値が作成されています
- SQL発行
- Cursorの作成
- モデルのインスタンス化
- モデルに対してCursorから値を注入
ようするに我々がSQLiteHelperを使って通常行なっていることを自動でやってくれているわけです
すると 不要なカラムまで取得しているというケースが発生してしまい、
それがボトルネックとなりリスト生成に時間がかかったりしてしまいます。
特定のColumn抽出
Selecterに用意されているexecuteWithColumns()
を利用します
この引数に、必要なカラムをTableName_Scheme
から指定することで、
特定のカラムだけもつCursorが作成されます
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はこんな感じ
relation().where(
"${Person_Scheme.INSTANCE.name.getEscapedName()} LIKE ?",
"%${keyWord}%")
第一引数で指定した文字列の?
に、第二引数の文字列が挿入されます
SQL文を全部発行
SQL全文はこんな感じで打ち込むと、Cursorが取得できます
ormaDatabase.connection.rawQuery("あなたの最強SQL Query")
追記
Getter Setter アノテーション不要
データモデルを作成するときに、ColumnアノテーションさえつければOKのようです。
JavaのときのようにGetter/Setterにアノテーションをつける必要はないようです。
ということで、修正しました。