It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

ExposedにおけるDSLとDAOの使い分け

概要

最近、サーバサイドKotlinを触り始めていて、データベース管理のライブラリにJetBrains製のExposedを使っています。

Exposedの使用方法として大きくDSL方式とDAO方式の2種類あるのですが、この使い分けについて個人的な悩みがあったので記載しました。

https://github.com/JetBrains/Exposed

結論

  • データベースレイヤーとアプリケーションレイヤーを切り分けて管理したい場合は、DSLを中心に使う
  • アプリケーションレイヤーでデータベースについて管理しても問題ない場合は、DAOを中心に使う

ドキュメント

ExposedのドキュメントはgithubWikiがあります。 必要十分ではありますが、お世辞にも手厚いわけではないとうのが、個人的な感想です。しかし、基本的な使い方については、ここを参照するのが一番わかり易いので、まずは一読するのが良いかと思います。

https://github.com/JetBrains/Exposed/wiki

DSLとDAO

Exposedを使って、データベースを操作するには大きく DSL(Domain Specific Language)DAO(Data Access Object) という2つの方法があります。

説明するまでもないかもしれませんが、簡単にこの2つの違いを説明しておきます。

DSLは、SQLのクエリビルダ的な位置づけで、SQLシンタックスに寄せられたAPIを使うことでSQL発行をオブジェクト経由で行うことができます。

Users.select { Users.name eq "user name" }
    .limit(10)
    .orderBy(Users.id to SortOrder.ASC)
    .firstOrNull()

usersというテーブルがあるとして、このようなDSLを実行すると以下の様なSQLに変換され実行されます。 完全にSQLと同じわけではありませんが、ある程度SQLを触ったことがあれば操作できるようなSQLDSLとしての位置づけのAPIです。

SELECT users.id, users.created_at, users.updated_at FROM users WHERE users.name = 'user name' ORDER BY users.id ASC LIMIT 10

一方、DAOはオブジェクトを中心にオブジェクトのAPIを実行することで、内部でSQLを発行してくれるという形式になります。代表される類似のものとしては、RubyOnRailsのActiveRecordなどがこれに該当します。

User.find { Users.name eq "user name" }
  .sortedBy { it.id }

先程のusersテーブルについてDAO経由でアクセスするとこのようになります。API名も特にSQLなどは意識されておらず、直感的に理解できる形になっています。

これは以下のようなSQLに変換されます。

SELECT users.id, users.created_at, users.updated_at FROM users WHERE WHERE users.name = 'user name'

考え方としては、SQLを中心にSQLを組み立てるためのオブジェクトが用意されているのがDSL。 オブジェクトによる直感的なデータ操作を中心に、対応する操作のSQLを発行できるのがDAOという感じです。

リレーションの違い

Exposedは基本的にRDBMSを対象としたライブラリなので、テーブル間のリレーションを操作します。 このときのDSLとDAOの違いをみていきます。

テーブル

データベースにおいて、以下のような関係があるテーブルがあるとします。(MySQLを想定)

CREATE TABLE `companies` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='会社';

CREATE TABLE `employees` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `company_id` bigint unsigned NOT NULL,
  PRIMARY KEY (`id`),
  KEY `company_id` (`company_id`),
  CONSTRAINT `employees_fk` FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`)
) ENGINE=InnoDB COMMENT='従業員';

この2つのテーブルは、employeesテーブルがcampaniesテーブルのキーを外部キーとして参照することで関係しています。

DSL

まずはDSLの操作から説明します。DSLに使うテーブルオブジェクトは以下になります。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

object Employees : IntIdTable("employees") {
    val name = varchar("name", 255)
    val companyId = integer("company_id")
}

データ作成は以下のようになります。

// NOTE: insertAndGetIdというAPIを使うと主キーを取得できる
val companyId = Companies.insertAndGetId {
    it[name] = "〇〇株式会社"
}
Employees.insert {
    it[Employees.name] = "佐藤拓郎"
    // 外部キーをセットする
    it[Employees.companyId] = companyId.value
}

全てのカラムをそのままセットする形です。発行されるSQLは以下のとおりです。

INSERT INTO companies (`name`) VALUES ('〇〇株式会社')
INSERT INTO employees (company_id, `name`) VALUES (2, '佐藤拓郎')

次にデータ取得になります。 複数のテーブルを結合するにはSQLと同じくjoinというAPIを使用します。

 val result = Employees.join(
    otherTable = Companies,
    joinType = JoinType.INNER,
    onColumn = Employees.companyId,
    otherColumn = Companies.id
).select {
    Employees.name eq "佐藤拓郎"
}.firstOrNull()

発行されるSQLは以下のとおりです。

SELECT employees.id, employees.`name`, employees.company_id, companies.id, companies.`name` FROM employees INNER JOIN companies ON employees.company_id = companies.id WHERE employees.`name` = '佐藤拓郎';

DAO

次はDAOの操作について説明します。DAOを使う場合でも、DSLで使用するテーブルのオブジェクト定義は必要になります。

DAOで外部テーブルとの関連を表現するためにはテーブルオブジェクトで reference を使って外部キーを指定します。

またDAO側のオブジェクトは、referencedOn を使って直接Companyを参照します。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

class Company(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Company>(Companies)

    var name by Companies.name
}

object Employees : IntIdTable("employees") {
    val name = varchar("name", 255)

    // 外部キーを指定して、Companyと関連を作成する
    val company = reference("company_id", Companies)
}

class Employee(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Employee>(Employees)

    var name by Employees.name
    // referencedOnを使ってCompanyへの参照を作成する
    var company by Company referencedOn Employees.company
}

データ作成は以下のようになります。 EmployeeのcompanyにはCompany.newで作成されたCompanyオブジェクトを直接設定しています。

val company = Company.new {
    this.name = "〇〇株式会社"
}
val employee = Employee.new {
    this.name = "佐藤拓郎"
    // 関連するCompanyオブジェクトをセットする
    this.company = company
}

発行されるSQLDSLと同じです。

INSERT INTO companies (`name`) VALUES ('〇〇株式会社')
INSERT INTO employees (company_id, `name`) VALUES (8, '佐藤拓郎')

次にデータ取得になります。 DAOの場合JOINをする必要はありません。employee.companyにアクセスするタイミングでcompanyを取得するためのSQLが実行されます。

val employee = Employee.findById(10)
println(employee?.company?.readValues)

発行されるSQLは、JOINされたものではなくテーブル毎に実行されます。

SELECT employees.id, employees.`name`, employees.company_id FROM employees WHERE employees.id = 10
SELECT companies.id, companies.`name` FROM companies WHERE companies.id = 17

データベース側のデフォルト値

細かい話ではあるのですが、DAOを使用している場合は、データベースのDDLでデフォルト値を使用している場合、DAOを使っているとうまく処理できない場合があります。

例えば以下の様なテーブル定義になっているとします。

CREATE TABLE `companies` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL DEFAULT "デフォルト会社名",
  PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='会社';

このとき、データベース側にデフォルト指定があるからといって、以下のように書くとエラーになります。

val company = Company.new {}
Can't add a new batch because columns: companies.`name` don't have default values. DB defaults don't support in batch inserts

DAOを使ったアクセス時はデータベースのデフォルト値を考慮してくれないようです。 こういったケースではデータベース側にデフォルト値を設定するのではなく、Exposedのテーブルオブジェクトにデフォルト値を設定します。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

class Company(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<Company>(Companies)

    var name by Companies.name
}

DSLでインサートを行う場合には、テーブルオブジェクトに default は不要です。

object Companies : IntIdTable("companies") {
    val name = varchar("name", 255)
}

// エラーは発生しない
Companies.insert {}

DAOを使う場合は、基本的にデフォルト設定はExposed側で行う必要があるようです。

バッチインサート

https://github.com/JetBrains/Exposed/wiki/DSL#batch-insert

Exposedには、大量にデータ挿入が発生したときに、SQLのbulk insertに変換するためのbatch-insertという機能があります。

この機能についても、データベース側でデフォルト値が設定されているとエラーが発生して使用できませんでした。

まとめ

ExposedはDAOとDSLという2種類の使用方法がありますが、完全に分離されているわけではなく、DAOを中心に使いたいのであれば、テーブル定義オブジェクトもDAO用に使う必要がありそうです。
その際は、データベース側でデフォルト値を設定しているとうまく使用できないことがあるので注意が必要です。
(他にも制限があるかもしれません)

この様な挙動は、Exposed自体のテーブル作成機能を使った場合は発生しないと思うので、フォローされていないのかもしれませんが、データベースのマイグレーション管理を別に行っている場合やExposedへの移行を検討している場合は気をつけた方が良いかもしれません。

個人的な運用になりますが、私はデータベースレイヤーとアプリケーションレイヤーは、ある程度は切り離して考えたく、マイグレーションもExposedに依存していないため、現状はDSLしか使わない運用で開発しています。
(reference機能も使わず、データベースレイヤーからモデルへのレイヤー変換処理を書いています。)
(バルクインサートも使いたい箇所があったのですが、今は使っていません)

これは、設計に対しての考え方にもよると思いますので、ある程度アプリケーションでデータベース定義を管理しても問題ないと考えている場合は、素直にDAO中心に記載したほうが工数は少なくなるかとも思います。

この辺り、うまく運用されている事例や、認識齟齬などあれば気軽にコメントいただければ幸いです。

サーバサイドKotlinでのGraphQLのグローバルIDの変換処理

やりたいこと・背景

サーバサイドKotlin + GraphQLでAPIを作成しています。 GraphQLを書いているとアプリケーション内部のドメインに紐づくモデルを、GraphQLスキーマに変換することは日常的にあるかなと思いますが、このときモデルのIDをGraphQLのグローバルIDに変換するのが手間だと思います。 Kotlinはこのあたり柔軟に書けるのでコードとして書いてみました。

GraphQLのグローバルIDについて、詳細を知りたい方は以下を参照。

GraphQL Global Object Identification Specification

グローバルIDのフォーマット

今回のコードでは、GraphQLのグローバルフォーマットは、<Model名>:<Id(int)> としてこれをBase64エンコードしたものをクライアントに返しています。

例:

  • Userモデル id=123の場合

エンコード前文字列: User:123 エンコード後文字列: VXNlcjoxMjM=

使用しているライブラリ

※ 基本的なコードの構成は、ライブラリに依存するものではありませんが、該当のライブラリのクラスやメソッドをつかっているため記載しています。

コード全体

// GraphQLのライブラリとしてgraphql-kotlinを使用しているためグローバルIDは当該のクラスを使用する
import com.expediagroup.graphql.generator.scalars.ID
// ktorにbase64エンコード/デコード処理が公開されていたため使用(何でも良い)
import io.ktor.util.decodeBase64String
import io.ktor.util.encodeBase64
import <独自のパッケージ名>.ModelId

// ドメインに紐づくモデルのID
// 各モデルごとに継承して使う
open class ModelId<T>(val value: T) {
    override fun toString() = value.toString()

    override fun hashCode(): Int = value.hashCode()

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is ModelId<*>) return false
        return other.value == value
    }
}

// Userモデル用に継承したID
class UserId(value: Int) : ModelId<Int>(value)

data class User(
    val id: UserId,
    val mane: String,
)

// モデルID => GraphQLのグローバルID変換
fun ModelId<Int>.toId(): ID {
    try {
        // モデル毎 のクラス名を取得(ex: Userモデルなら UserId)
        val cName = this::class.simpleName!!
    // suffixが "Id"であることを決め打ちしてモデル名を取得
        // ※ このあたりはサンプルなので適当にしています。
        val modelName = cName.substring(0, cName.length - 2)
    // 「モデル名:id値」で文字列にしてエンコードする
        return ID("$modelName:${this.value}".encodeBase64())
    } catch (e: Throwable) {
        throw IllegalStateException("Encode ID Failure.")
    }
}

// GraphQLのグローバルID => モデルID変換
fun <T : ModelId<Int>> ID.toModelId(): T {
    try {
        // base64からモデルをデコード
    val decodedId = value.decodeBase64String()
    // :で分割してモデル名を取得
        val modelAndId = decodedId.split(":")
    // モデル名 + Id でモデル毎のIdクラスの名前を作る(ex: Userモデルなら UserId)
        val cName = "${modelAndId.first()}Id"
    // リフレクションでインスタンスを生成する
        val fullClass = Class.forName("<独自のモデルのパッケージ名>.$cName")
        val constructor = fullClass.getConstructor(Int::class.java)
        return constructor.newInstance(modelAndId.last().toInt()) as T
    } catch (e: Throwable) {
        throw IllegalArgumentException("Parse ModelId Failure.")
    }
}

使い方

internal class ModelIdExtensionsKtTest : StringSpec({
    "toId: Should return Id by Model." {
        UserId(123).toId() shouldBe ID("UserId:123".encodeBase64())
    }

    "toModelId: Should return ModelId from ID." {
        val modelId = ID("UserId:123".encodeBase64()).toModelId<UserId>()
        modelId.value shouldBe 123
    }
})

感想

このあたり、TypeScriptなど、拡張が手軽にできない言語だと、サクッと書けないのでKotlinは便利です

【サーバーサイドKotlin】ことはじめ

背景・スキルなど

クライアントサイドの開発(6割)

サーバーサイドの開発(4割) - Ruby On Rails - NodeJS(Express系)

サーバーサイドは、Java/Kotlinともに未経験であり、SpringBootなど名前くらいは知っていますが、特性などは何もわかりません。

サーバーサイドKotlinを選んだきっかけ

普段は比較的小 ~ 中規模のアプリケーション開発が多く、APIのWebインタフェースには最近graphQLを用いることが多いです。しかしながら、バックエンドでTypeScriptを使用していると、シンタックスが弱く冗長な書き方をすることが多く、ストレスを感じていました。

Android開発ではKotlinを使用しており、言語の特性は理解していますが、Kotlinは最近のモダンな機能を概ね網羅しており、ストレスなく書くことができると考えたかというのが理由です。

Webアプリケーションの選定

Webアプリケーションのフレームワークとしては、有名所としては以下のようなものがあります。

JVM系ということでJavaの資産が使えるため、もっと沢山ありそうですが、さっと調べた感じはこのあたりでした。 Spring BootはJava時代からの大御所ということで、情報にこまることはなさそうですが、個人的にJavaのコードを多く有している、Javaシンタックスに引っ張られているというのは少し気になります。

Kotlin実装のものでJetBrains製ということもあり、Ktorを第一候補をして考えています。

GraphQL ライブラリの選定

GraphQLのパース等を時前でやるのは大変なのでこの辺のライブラリも使うのが一般的だと思います。

こちらはあまり数がなさそうですが、この辺が利用者が多そうです。 他にもgraphql-javaというJavaベースのものが存在していますが、graphql-kotolinは、graphql-java依存のライブラリなのでこちらは選択肢には含めませんでした。 選択というほど選択ではないですが、包括機能を考えると、graphql-kotlin が良いかなと思いました。

まとめ

とりあえず、JVM系(コンパイル言語系)のサーバーサイドははじめてなので、未知数ではありますが、

  • webフレームワーク: ktor
  • graphqlライブラリ: graphql-kotlin

この辺を使って、サーバサイドKotlinを勉強したいと思います。

【SwiftUI】ピンチによるViewの拡大と縮小

概要

SwiftUIを使ってピンチイン・ピンチアウトによってView(画像に関わらずViewコンポーネント)を拡大・縮小する方法について記載します。

環境

  • Swift: 5.2.4
  • Xcode: 11.6

サンプル環境

f:id:inon29:20200815130637p:plain

struct ContentView: View {
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
        }
    }
}

今回は、画面に円を一つ配置し、この円をピンチジェスチャーで拡大/縮小します。

ピンチジェスチャーの取得

SwiftUIのViewコンポーネントには、gesture というModifierが用意されています。
このgestureというModifierを使うとタップやドラッグ、ピンチといったジェスチャーを簡単にトラッキングすることができます。

struct ContentView: View {
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
                .scaleEffect(self.scale)
                .gesture(MagnificationGesture()
                    .onChanged { value in
                        print("onChanged: ", value)
                    }
                    .onEnded { value in
                        print("onEnded: ", value)
                    }
                )
        }
    }
}

ピンチのジェスチャーを取得するには、 MagnificationGesture()gesture Modifierに渡します。
Gestureコンポーネントには、「値の変更」をコールバックする onChanged、「イベントの完了」をコールバックする onEndedなどイベント検知のModifierが用意されており、これらをセットすることによりジェスチャーの値を取得できます。
MagnificationGestureを使用した場合、onChanged、onEndedに渡される value には、現在の拡大倍率を基準とした拡大率(縮小率)がCGFloat型で取得できます。

Viewを拡大・縮小する

struct ContentView: View {
    @State var scale: CGFloat = 1.0
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
                .scaleEffect(self.scale)
                .gesture(MagnificationGesture()
                    .onChanged { value in
                        self.scale = value
                    }
                    .onEnded { value in
                        print("onEnded: ", value)
                    }
                )
        }
    }
}

拡大率は取得できたのでこれを使用して、円のView自体をピンチジェスチャーに合わせて拡大・縮小します。
Viewの拡大・縮小を行うには、scaleEffect というModifierを使います。scaleEffectに元の大きさを1.0とした拡大率をセットすることでViewを拡大(縮小)することができます。
先程記述した、MagnificationGestureのonChangedで変更倍率を受け取り、@Stateで宣言したscale変数に値をセットします。
そして、scale変数をscaleEffectにセットすることで、ピンチジェスチャーの倍率変更に合わせてViewを拡大(縮小)することができます。

変更倍率を保持する

上記までの処理を動かすと一見上手く動いているように見えますが、今は ピンチジェスチャーを行うたびに画像の大きさが1.0倍に一旦戻ってしまうという状態になっています。

これは、onChanged, onEnded で取得できる value が 「現在のサイズからの拡大率」であるためです。
(元画像から2倍に拡大されていても、ピンチジェスチャーが一度終わっていれば次のピンチジェスチャーでは1.0から始まる)

これだと使いにくいため、ピンチジェスチャーが複数回行われても、元画像の拡大率を維持した状態になるように変更します。

struct ContentView: View {
    @State var lastValue: CGFloat = 1.0
    @State var scale: CGFloat = 1.0
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color.blue)
                .frame(width: 200, height: 200)
                .scaleEffect(self.scale)
                .gesture(MagnificationGesture()
                    .onChanged { value in
                        // 前回の値から拡大率を計算する
                        let delta = value / self.lastValue
                        // 現在の拡大率を再設定する
                        self.scale = self.scale * delta
                        // 現在の拡大率を覚えておく
                        self.lastValue = value
                    }
                    .onEnded { value in
                        // 次のジェスチャーイベントではvalueはまた1.0から始まるため
                        // ジェスチャーイベント完了時に1.0に戻しておく
                        self.lastValue = 1.0
                    }
                )
        }
    }
}

上記が変更後のコードです。

onChangevalueをそのままscaleにセットするのではなく、(同一ジェスチャーイベントでの)前回のvalue(lastValue)との拡大率の変更を計算してscaleにセットしています。
またジェスチャーイベントで取得できるvalueは、毎回1.0から始まるため、onEndedlastValue変数を1.0に初期化しています。

このようにすることで、拡大率をリセットさせずにピンチジェスチャーによる拡大・縮小を行えます。

【Android】【Jetpack】Roomの使いかた

概要

JetpackSQLiteを使いやすくするためのライブラリである「Room」というものがあります。
モバイルのデータベースといえば、昔から主流はSQLiteでしたが個人的には最近はRealmを使うことが多くSQLiteはしばらく触ってませんでした。
(SQLiteは、使うまでの手続きがどうしても面倒で...)
しかし、Roomを使うとそのへんが簡単になりそうとのことなので、一度試してみようとおもいます。

環境

  • kotlin: 1.3.72
  • AndroidStudio: 4.0
  • compileSdkVersion: 29

環境のセットアップ

まずは app/build.gradle に依存モジュールを追加します。
現時点での最新の安定版は「2.2.5」です。
Roomでは、アノテーションプロセッシングを使うので、Kotlinで実装する場合は、kotlin-kaptが必要です。

apply plugin: 'kotlin-kapt'
・・・
dependencies {
・・・
    def room_version = "2.2.5"
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"
・・・
} 

次にデータベーススキーマの情報をjsonファイルとして書き出す設定です。

    defaultConfig {
・・・
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = [
                        "room.schemaLocation":"$projectDir/schemas".toString(),
                        "room.incremental":"true",
                        "room.expandProjection":"true"]
            }
        }
    }

これを記載することで、各バージョン時点でのスキーマファイルがプロジェクト配下の schemas ディレクトリへ作成されます。
今回の対象ではありませんが、スキーマファイルを使うことでマイグレーションテストを書くことができるようになるなどメリットがあるため設定しておくのが良いかと思います。

Roomの構成概要

Roomは主に3つの役割のコンポーネントで構成されます。

  • エンティティ: データベース内のテーブルを表現する。カラムとプロパティの紐付け。
  • DAO: データベースにアクセスしてEntityを操作する。CRADをここに記載するイメージ。
  • データベース: データベースへの接続を管理する。

これらのコンポーネントを実装していきます。

エンティティの作成

まずはテーブルを表現するための、エンティティクラスを作成します。

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

// テーブルには@Entityをつける
@Entity(tableName = "users")
data class User(
    // 主キーには @PrimaryKey をつける
    @PrimaryKey var id: String,
    // カラムには @ColumnInfo をつける
    @ColumnInfo val name: String,
    // 実カラム名との関連はname引数で渡す
    @ColumnInfo(name = "phone_number") val phoneNumber: String
)
  • エンティティには @Entity というアノテーションをつけます
    • DBの実テーブル名との関連を表現することもできます
  • idは主キーとして扱いたいため、@PrimaryKey アノテーションをつけます
  • その他カラムには @ColumnInfoとつけます
    • DBの実カラム名との関連を表現することもできます
  • その他にも外部キーを表現するための @ForeignKey なども使えます

DAOの作成

次にDAOを作成します。
DAOは、データベースを操作するメソッドを記述した interface として作成します。

import androidx.room.*

// DAOには @Dao をつける
@Dao
interface UserDao {
    // @Queryを使うと実行SQLを定義することができる
    @Query("SELECT * from users")
    fun getAllUsers(): List<User>

    // SQLに引数を渡す場合は 「:<引数名>」 で関連付ける
    @Query("SELECT * from users where id = :id LIMIT 1")
    fun findById(id: String): User

    // @Insertなど用意されたアノテーションをつけると引数にEntityを渡すだけで処理ができる
    @Insert
    fun insert(user: User)

    @Update
    fun update(user: User)

    @Delete
    fun delete(user: User)
}
  • DAOには @Dao というアノテーションをつけます
  • @Query アノテーションをつかうことで、SQLを直接記述できます
  • @Insert, @Update, @Delete をつかうと引数にエンティティを渡すだけで処理できます

データベースの作成

最後にデータベースを作成します。

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

// entities には、関連付けるテーブルのエンティティを渡す
@Database(entities = [User::class], version = 1)
abstract class SampleDatabase: RoomDatabase() {

    abstract fun userDao(): UserDao

    companion object {
        // シングルトンで使えるようにする
        @Volatile
        private var instance: SampleDatabase? = null

        // データベースインスタンスは、マルチスレッドで参照される可能性があるため synchronized でアクセス
        fun getInstance(context: Context): SampleDatabase =
            instance ?: synchronized(this) {
                // データベースインスタンスは、databaseBuilderを使って作成
                Room.databaseBuilder(context, SampleDatabase::class.java, "thchbooster.db").build()
            }
    }
}

データベースのインスタンスは、 Room.databaseBuilder を使って作成します。

注: シングル プロセスで実行するアプリの場合は、AppDatabase オブジェクトをインスタンス化する際にシングルトン設計パターンに従ってください。各 RoomDatabase インスタンスは非常に高コストであり、単一のプロセス内で複数のインスタンスにアクセスする必要はほとんどありません。

公式のドキュメントにある通り、データベースインスタンスは毎回生成するのではなくシングルトンインスタンスとして保持します。

また、データベースのインスタンスに、各DAOオブジェクトへの参照を abstruct メソッドとして保持することでDAOへ参照できるようになります。

DB操作のサンプル

これらのRoomコンポーネントを使ってDBへアクセスしてみます。

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import kotlin.concurrent.thread

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

        findViewById<Button>(R.id.btn_add_user).setOnClickListener {
            thread {
                val db = SampleDatabase.getInstance(this.application)
                val user = User("1", "bob", "090xxxxxxxx")
                db.userDao().insert(user)
            }
        }

        findViewById<Button>(R.id.btn_get_user).setOnClickListener {
            thread {
                val db = SampleDatabase.getInstance(this.application)
                val user = db.userDao().findById("1")
                println(user)
            }
        }

    }
}

DAO経由で insert 処理と findByIdによるセレクトの処理を記載しました。
注意すべきなのは、データベースへのアクセス(DAOのメソッド実行)は、メインスレッドで行うことができないためスレッドを分ける必要があります。

感想

SQLiteをイチから実装するよりも随分スッキリしていていい感じな気がしています。
realmのようなORMと比べると若干手続きに手間がかかる気もしますが、生のSQLを記載できる点やスキーマファイルを吐き出せる点など柔軟な設計もできるのは良いかなと思います。
自前で書くと結構汚くなってしまいそうなコンポーネントごとのレイヤーもある程度強制的に分離してくれるのは嬉しい点です。
既存でSQLiteを使っているならRoomに置き換えることのデメリットはあまりないような気がします。

参考

【Android】【Jetpack】Navigationコンポーネントの使い方 ①

概要

Android Jetpackの機能の一つに「Navigation コンポーネント」という画面遷移を管理してくれる機能があります。
Navigationコンポーネント自体は発表されて数年たちそれほど最新の話ではないのですが、僕自身は触ったことがなかったので改めて触ってみました。

環境のセットアップ

まずは app/build.gradle に依存モジュールを追加します。
現時点での最新の安定版は「2.2.2」なのですが、「2.3.0」から navigation-dynamic-features-fragment という新しいモジュールが追加されています。
使い方は現時点ではまだ良くわかってませんが、動作検証なので最新のモジュールバージョン「2.3.0-alpha06」を使ってみようと思います。

dependencies {
・・・
    def nav_version = "2.3.0-alpha06"
    implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
    implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
    implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
    androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
・・・
} 

次に Safe Args Gradle プラグイン を追加します。
このプラグインは必須ではないのですが、これを使うと画面遷移処理が安全に行えるようになるので入れておきます。
まず ルートディレクトリのbuild.gradle に以下を追加します。

buildscript {
    repositories {
        // 記載がなければ追加
        google()
    }
    dependencies {
        def nav_version = "2.3.0-alpha06"
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}

次に app/build.gradleプラグインの適用を記載します。

apply plugin: "androidx.navigation.safeargs.kotlin"

以上で、環境設定はおわりです。

「ナビゲーショングラフ」を作成する

NavigationComponentを使い始めるためにまずは、ナビゲーショングラフ というものを作成します。
ナビゲーショングラフは、xmlで書かれたリソースファイルで、画面遷移の関連を記載したファイルです。
まずは、リリースファイルにこのxmlを追加します。

  • AndroidStudioから、resディレクトリを右クリックして、「New > Android Resource File」 を選択してダイアログを表示します。
  • File name は任意ですが、今回は「nav_graph」にしています。
  • Resource type には Navigation を選択します

f:id:inon29:20200607104334p:plain

f:id:inon29:20200607104412p:plain

resディレクトリには、navigation というディレクトリが作成されその中に「nav_graph.xml」というファイルが作成されます。
このファイルを開くとAndroidStudio上でビジュアルエディタによってナビゲーショングラフを編集することができます。

遷移するフラグメントを作成

今回遷移するサンプル画面として

  • FirstFragment
  • SecondFragment

という2つのフラグメントを用意します。

フラグメントは、先程作成した nav_graph のメニューから「Create new destination」というメニューを選択すると作成できます。
(普通にファイル追加で作成しても大丈夫ですが、ナビゲーショングラフのメニューから作成すると、自動でナビゲーショングラフのエディター内に追加してくれます)

f:id:inon29:20200607104816p:plain

f:id:inon29:20200607104827p:plain

FirstFragmentのViewには「家のアイコン」がついています。
これは、一番最初に表示される画面を指していて、後で変更することもできます。

画面間の移動を関連付け

2つのフラグメントの遷移の関連付けを行います。
ナビゲーショングラフのエディタ上でFirstFragmentからSecondFragmentに対して関連をドラッグすることで2つの画面の遷移を関連づけることができます。

f:id:inon29:20200607104950p:plain

以上で、ナビゲーショングラフの設定は完了です。

このとき、nav_graph.xmlの中身は次のようになっています。

■ nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation 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"
    android:id="@+id/nav_graph"
    app:startDestination="@id/firstFragment">

    <fragment
        android:id="@+id/firstFragment"
        android:name="com.example.nav2.FirstFragment"
        android:label="fragment_first"
        tools:layout="@layout/fragment_first" >
        <action
            android:id="@+id/action_firstFragment_to_secondFragment"
            app:destination="@id/secondFragment" />
    </fragment>
    <fragment
        android:id="@+id/secondFragment"
        android:name="com.example.nav2.SecondFragment"
        android:label="fragment_second"
        tools:layout="@layout/fragment_second" />
</navigation>

app:startDestination="@id/firstFragment" は初期起動画面の指定。

<action
    android:id="@+id/action_firstFragment_to_secondFragment"
    app:destination="@id/secondFragment" />

このactionタグでどの画面からどの画面への遷移を紐付けているようです。

起動時のActivityに「ナビゲーションホスト」を作成する

ナビゲーショングラフを作ったのでそれをアプリケーションに適用する作業をしていきます。

ナビゲーショングラフを適用するには ナビゲーションホスト を作る必要があります。
ナビゲーションホストは、ナビゲーショングラフで表現された画面の遷移を実行して中身のフラグメントを書き換えるためのコンテナです。
このコンテナを配置すれば画面遷移の管理は、ナビゲーションホストがよしなにやってくれるというものらしいです。

これを、起動されるActivityにセットしていきます。
今回のアクティビティ(MainActivity)のlayout xmlは次のようになっています。

■ actibity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"

        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

ナビゲーションホストには、androidx.navigation.fragment.NavHostFragment クラスを使用します。
app:navGraph="@navigation/nav_graph"属性では、ナビゲーショングラフのxmlファイルを指定します。
app:defaultNavHost="true"属性は、システムの戻るボタン(バックボタン)をインターセプトするためのフラグで、trueにするとバックボタンを押下したときにFragmentの前画面へ遷移してくれます。

Activity側の設定はこれだけです。
この状態でアプリを起動すると、ナビゲーショングラフで設定した startDestination (家のアイコンがついているフラグメント) が初期表示されます。

画面遷移を実装

最後に FirstFragmentからSecondFragmentへの画面遷移を実装します。

まずは、遷移元であるFirstFragmentに遷移のトリガーとしてボタンを配置します。

■ fragment_first.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:orientation="vertical"
    tools:context=".FirstFragment">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textSize="30dp"
        android:textAlignment="center"
        android:text="First Fragment" />

    <Button
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Move to SecondFragment" />
</LinearLayout>

次に、FirstFragment.kt上に遷移処理を書いていきます。

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    val view = inflater.inflate(R.layout.fragment_first, container, false)
    view.findViewById<Button>(R.id.button) .setOnClickListener {
        val action = FirstFragmentDirections.actionFirstFragmentToSecondFragment()
        view.findNavController().navigate(action)
    }
    return view
}

各画面間の遷移処理は、NavController を使って行います。
view.findNavController() で NavControllerを取得し、navigate メソッドで遷移を実行しています。

actionを取得する処理として FirstFragmentDirections.actionFirstFragmentToSecondFragment() というメソッドを使用しています。
これは、一番はじめの環境設定時に適用した Safe Args Gradle プラグイン による遷移方法です。
このプラグインを使うと <遷移元クラス名>Directions というクラス(今回はFirstFragmentDirections)がコンパイル時に自動生成され、IDの指定などを行わずに安全に遷移を行うことができます。

ちなみにプラグインを使わない場合は、次のように書くことも可能です。

view.findViewById<Button>(R.id.button) .setOnClickListener {
    view.findNavController().navigate(R.id.action_firstFragment_to_secondFragment)
}

R.id.action_firstFragment_to_secondFragment のIDは、ナビゲーショングラフのxmlファイル(nav_graph.xml)でactionタグに設定されているIDです。

以上で実装は完了です。

アプリを起動して、「Move to SecondFragment」というボタンを押下するとSecondFragmentへ移動が確認できます。

まとめ

今更ですが、Navigation コンポーネントについてチュートリアルをやってみました。

AndroidにおけるFragmentの遷移は、状態管理が複雑でAndroidを開発初めて開発するときは必ずと言っていいほどつまずくポイントの一つでしたが、Navigationコンポーネントを使うことでこのあたりの学習コストが大幅に減らせるようになったと感じました。
これからAndroidの開発をしていく人は、積極的に使っていくのが良さそうです。

パラメタの引き渡し方法や、他Activityとの絡みなどまだ疑問な点もあるのでまた触ってみたいと思います。

参考リンク

【iOS】【Swift】Combineの基本的な使い方メモ

概要

iOS13から Combine というフレームワークが追加されました。
RxSwiftやRxJavaなどのRx系のライブラリを使わずとも純正のフレームワークでReactive Programingができるようになるというものです。

Rx系は、そこまで経験がないので、まずは基本的な使い方からサンプルを書いてみました。

環境

  • iOS: 13.5
  • Swift: 5.2.4

基本的な使い方(サンプルコード)

func first() -> AnyPublisher<String, Error> {
    // NOTE: 成功時にString型の値を返す、失敗時は、Errorを返す Futuerの作成
    return Future<String, Error> { promise in
        promise(.success("1"))
    }
    // NOTE: AnyPublisher型への変換
    .eraseToAnyPublisher()
}

func second(_ value: String) -> AnyPublisher<String, Error> {
    return Future<String, Error> { promise in
        promise(.success(value + " 2"))
    }
    .eraseToAnyPublisher()
}

func third(_ value: String) -> AnyPublisher<String, Error> {
    return Future<String, Error> { promise in
        promise(.success(value + " 3"))
    }
    .eraseToAnyPublisher()
}

var cancellable: [AnyCancellable] = []
first()
    .flatMap( { firstResult in second(firstResult) })
    .flatMap( { secondResult in third(secondResult) })
    // NOTE: イベントの購読
    // receiveCompletion -> イベントの結果(成功 or Error)を受け取る
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("success")
        case let .failure(error):
            print("failure:", error)
        }
        // receiveValue -> イベントが返す値を受け取る
    }, receiveValue: { result in
        print(result)
    })
    .store(in: &cancellable)

この処理を実行すると、次のような出力になります。

1 2 3
success

first()が発行した値("1")をsecond()が受け取り("2")と結合、それをさらthird()に渡し("3")を結合するといったように連続的にイベント処理が行われます。

まず、 Combineには3つの役割が存在します。

  • Publisher: イベントの発行者
  • Subscriber: イベントを購読する
  • Operator: イベントの値を加工する

これらは、Rx系ライブラリでも名前は異なりますが、同様の機能が存在するので覚えておいた方が良さそうです。

イベントの発行

今回は、Publisher の作成には Future を使っています。

Future は、1回の結果(1つの値 or エラー)を返すための Publisher です。

Combineの色々なサンプルを見るとPublisherやSubscriberを1から実装することは少なく、Comibneが用意していくれるユーザビリティな機能で生成することが多そうです。
Futureの他にも Just, Deferredなど用途に合わせたPublisherを作成するクラスが純正で用意されいます。

func first() -> AnyPublisher<String, Error> {
    // NOTE: 成功時にString型の値を返す、失敗時は、Errorを返す Futuerの作成
    return Future<String, Error> { promise in
        promise(.success("1"))
    }
    // NOTE: AnyPublisher型への変換
    .eraseToAnyPublisher()
}

まず、Futuerのインスタンスを作成します。

Futureには、ジェネリクスで『成功した時の型と失敗した時の型』を指定し、実行したい処理をクロージャに渡して初期化します。
クロージャには、結果を返すコールバック(promise)が渡されるため、 .success または .failure で指定した型の値を返します。

eraseToAnyPublisher は、Publisher プロトコルに準拠している型を AnyPublisher型に変換してくれるメソッドで、生成されるPublisherをこのようにAnyPublisher型に変換しておくことで、他のPublisherと組み合わせて使うときに便利に使えるようです。

イベントの購読

first()
    .flatMap( { firstResult in second(firstResult) })
    .flatMap( { secondResult in third(secondResult) })
    // NOTE: イベントの購読
    // receiveCompletion -> イベントの結果(成功 or Error)を受け取る
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("success")
        case let .failure(error):
            print("failure:", error)
        }
    // receiveValue -> イベントが返す値を受け取る
    }, receiveValue: { result in
        print(result)
    })

発行側はできたので、購読側を作ります。
Subscriberも1から作るのではなく、sink() というメソッドを呼び出すことでイベントを購読することができます。
(flatMap については後述します。)

sinkメソッドは、2つのクロージャで構成されています。
receiveCompletion はイベントの結果(成功or失敗)を受け取るためのクロージャで、receiveValue は成功時のイベントで返ってきた値を受け取るためのクロージャです。
失敗した場合は、receiveValueは呼び出されないようです。

イベントの加工

first()
    .flatMap( { firstResult in second(firstResult) })
    .flatMap( { secondResult in third(secondResult) })

最後にOperatorについてです。
Operatorを使うとイベントで流れる結果の値を様々な形で加工することができます。
Operatorも多くの種類があるため、用途によって使い分けることが必要です。

今回は、flatMap というOperatorを使用しています。

flatMap を使うと前のイベントから受け取った値を加工して新たな Publisher として返すことができます。
これを使うと、「複数のイベントを組み合わせて一つのイベントとして扱う」といったことができます。

今回は、firts() と同じように、second(), third() というPublisherを返すメソッドをを用意し、前のイベントの文字列を結合して結果を返す連続的なイベント処理を実装しました。

イベントの購読をキャンセル

var cancellable: [AnyCancellable] = []
first()
  ・・・
    .store(in: &cancellable)

イベントの購読を中止するには、sink() メソッドが返す AnyCancellable を受け取り、cancel()メソッドを呼び出します。
今回は、store() メソッドを使い、AnyCancellbleのインスタンスをコレクションに保持しています。

こうしておくと、インスタンス初期化時にまとめて購読して、インスタンス破棄時にまとめてキャンセルするような使い方ができます。

■ キャンセルする場合

for c in cancellable {
    c.cancel()
}

注意点

幾つか実装していて気をつける点があったので記載します。

cancellableは、 イベント購読終了まで生存していなくてはならない

sink()が返すAnyCancellableは、イベントの購読を完了するまでオブジェクトとして生存しておく必要があります。

class SomeClass {
    func subscribe() {
        var cancellable: AnyCancellable
        cancellable = createPublisher()
            .sink(receiveCompletion: { completion in
                ・・・
            }, receiveValue: { result in
                ・・・
            })
    }
}

例えばあるクラスのインスタンス中で、イベントの購読処理をした場合、上記の書き方ではメソッド終了時に購読もキャンセルされイベントを受け取ることができません。

class SomeClass {
    var cancellable: AnyCancellable
    func subscribe() {
        cancellable = createPublisher()
            .sink(receiveCompletion: { completion in
                ・・・
            }, receiveValue: { result in
                ・・・
            })
    }
}

このように、オブジェクトの生存スコープに保持しておく必要があります。

Futureの初期化時のクロージャは、 初期化のタイミングで実行される

Futureの初期化時に渡すイベントのクロージャは、購読前に実行されます。

let publisher = Future<String, Error> { promise in
    print("call future")
    promise(.success("success"))
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
    publisher.sink(receiveCompletion: {c in}, receiveValue: {v in})
}

例えば、このように購読までに時間が空いたとしても、Future生成時に中のクロージャは実行されています。

これは意図した仕様のようで、もし購読開始までイベント処理の実行を遅らせたい場合は次のように書くようです。

Deferred {
    Future<String, Error> { promise in
        print("call future")
        promise(.success("success"))
    }
}

Deferred を使うと引数で受け取ったPublisherの実行を購読まで待機してくれます。

まとめ

基本的な実装について試してみましたが、Combineには他にも多くの機能があるため、使いこなすためにはまだ色々と調べる必要があるなと感じました。

おそらくComibineの使い方や考えかたは、Rx系のライブラリと似ていると思うので、 今までRxを使用したライブラリを使っている人はそれほど難しくはないのかもしれません。

それでもiOSの純正で使用できるということは大きなメリットではあると思うので、この機会にReactive Programingに入門するのも良いのではないでしょうか。

参考リンク