Infinito Nirone 7

白羽の矢を刺すスタイル

チームで育てるAndroidアプリ設計の一般販売が始まりました。

告知記事です。

Peaks のクラウドファンディングで執筆し先日電子版を先行リリースした「チームで育てるAndroidアプリ設計」の一般販売が始まりました。 これまではクラウドファンディングで出資いただいた方にのみ電子版・書籍の配送手続きをしていましたが、本日からクラウドファンディングに参加していない一般の方々にも電子版・物理本の購入をいただけるようになりました。

peaks.cc

チームで育てるAndroidアプリ設計とは

改めてこの書籍が何なのかを紹介すると、大小様々な規模のチームで継続的にAndroidアプリの開発をすすめていく中で直面するアーキテクチャの成長痛を乗り越えるためのノウハウを詰め込んだ本です。

アーキテクチャは一度整えればそれで終わるものではなく、プロダクトの成長やチームの成長とともに少しずつ形を変えていくものであるという考えのもと、最初の一歩としてどのようにアーキテクチャを選定しチームに根付かせていくか、またアプリの成長にともなって徐々に現れるひずみをどのように解消していくのか、実際の方法論を交えつつも根本にある思想や考え方、行動指針を示すことで、特定のチームにおける実例を他のチームにも活かせるプラクティスとしてまとめています。

新規事業の立ち上げから運用にいたるまでの比較的小規模なチームにおける事例を @kgmyshin さんが担当し、すでに成長を続けてきたサービスをさらに拡大していく比較的大規模なチームにおける事例を自分が担当しました。それぞれ4章分の内容があります。そして最後の章では大小それぞれの事例を振り返り、規模によらない共通点や規模によって異なるポイントをまとめています。

クラウドファンディング開始当初の意気込みなんかは次の記事に書いてありますのであわせてどうぞ。

blog.keithyokoma.dev

オンライン輪読会

実はこのプロジェクトはまだ続いていて、出版後にオンラインで輪読会を開催します。

techbookfest.connpass.com

全9回、各回で書籍の1章分の内容を輪読します。初回は早速明日4/27の22時からで、1週間ごとに読みすすめる予定です。 YouTube で配信予定で、アーカイブもあるので当日参加できない方もあとからご視聴いただけます。

Unique Index があるときの @Insert(onConflict = OnConflictStrategy.REPLACE), @Update, @Upsert

次のような Entity を例に @Insert, @Update, @Upsert の動きを見てみます。

@Entity(
    tableName = "sample_table",
    indices = [Index(value = ["unique_id"], unique = true)]
)
data class SampleEntity(
    @PrimaryKey
    @ColumnInfo(name = "id")
    val id: String,
    @ColumnInfo(name = "name")
    val name: String,
    @ColumnInfo(name = "age")
    val age: Int,
    @ColumnInfo(name = "unique_id")
    val uniqueId: String
)

@Dao
interface SampleDao {
    @Insert(onConflict = OnConflictStrategy.ABORT)
    suspend fun insert(entity: SampleEntity)

    @Update
    suspend fun update(entity: SampleEntity)

    @Upsert
    suspend fun upsert(entity: SampleEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertOrReplace(entity: SampleEntity)

    @Query("SELECT * FROM sample_table")
    suspend fun getAll(): List<SampleEntity>
}

この記事では Room の in memory database builder を使い、簡単にユニットテストで動きを確かめています。

@RunWith(RobolectricTestRunner::class)
class ExampleUnitTest {
    private lateinit var db: SampleDatabase
    private lateinit var dao: SampleDao

    @Before
    fun prep() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, SampleDatabase::class.java).build()
        dao = db.companionDeviceDao()
    }

    @After
    fun tearDown() {
        db.close()
    }

    // ... unit test follows
}

@Insert(onConflict = OnConflictStrategy.REPLACE)

ここで説明する REPLACE の挙動はすべて SQLite のドキュメントと整合しています。

sqlite.org

PrimaryKey が重複したとき

REPLACE なので、PrimaryKey が既存レコードと重複した場合は既存レコードが更新されます。 内部的に REPLACE は既存レコードを一旦削除したうえで新たにレコードを作成することで更新とします。

    @Test
    fun `insertOrReplace - insert then replace`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert
        dao.insertOrReplace(entity1)
        try {
            // replace with entity2, can't fail
            dao.insertOrReplace(entity2)
        } catch (e: SQLiteConstraintException) {
            fail("error is not expected: $e")
        }

        val result = dao.getAll()
        // only entity2 exists
        assertTrue(result.containsAll(listOf(entity2)))
    }

Unique Index が重複したとき1

既存レコードを replace する場面で Unique Index が重複すると、既存レコードの削除に加え重複する Unique Index を持つ別の既存レコードも削除します。

    @Test
    fun `insertOrReplace - insert then replace - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid2")
        val entity3 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert as first entity
        dao.insertOrReplace(entity1)
        // insert as pk/unique id is different
        dao.insertOrReplace(entity2)
        // replace entity1 with unique id conflicting with entity 2
        // then remove both entity1 and entity2 then insert entity3
        dao.insertOrReplace(entity3)

        val result = dao.getAll()
        // only entity3 exists
        assertTrue(result.containsAll(listOf(entity3)))
    }

Unique Index が重複したとき2

PrimaryKey は重複せず Unique Index が重複するときも、重複する Unique Index を持つ既存レコードが削除されます。

    @Test
    fun `insertOrReplace - insert 2 times`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid1")
        // insert
        dao.insertOrReplace(entity1)
        // insert as pk is different from entity1, but conflicting unique id
        dao.insertOrReplace(entity2)

        val result = dao.getAll()
        // only entity2
        assertTrue(result.containsAll(listOf(entity2)))
    }

@Update

Unique Index が重複したとき

Unique Index の重複はゆるされないので update は失敗し、SQLiteConstraintException がスローされます。 @Insert(onConflict = OnConflictStrategy.REPLACE) とは異なり、元のデータは変更されず update を呼び出す直前のままです。

    @Test
    fun `insert and update - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid2")
        val entity3 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert
        dao.insert(entity1)
        // insert
        dao.insert(entity2)
        try {
            // update entity 1 to make unique id conflict with entity2
            dao.update(entity3)
            fail("can't complete update as unique id conflicts")
        } catch (_: SQLiteConstraintException) {
            // expected
        }

        val result = dao.getAll()
        assertTrue(result.containsAll(listOf(entity1, entity2)))
    }

@Upsert

@Upsert は PrimaryKey の重複するデータがあれば Update, なければ Insert とする処理です。 次に示すテストでは entity1 の upsert は Insert、entity2 の upsert は Update になります。

    @Test
    fun `upsert - insert then update`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert
        dao.upsert(entity1)
        try {
            // update entity2, can't fail
            dao.upsert(entity2)
        } catch (e: SQLiteConstraintException) {
            fail("error is not expected: $e")
        }

        val result = dao.getAll()
        assertTrue(result.containsAll(listOf(entity2)))
    }

Unique Index が重複する Update 1

Unique Index が重複することになる Update をかける Upsert をする場合、Unique Index の制約に違反する Update を実行した時点で SQLiteConstraintExcpetion がスローされます。 @Insert(onConflict = OnConflictStrategy.REPLACE) とは異なり、元のデータは変更されず upsert を呼び出す直前のままです。

    @Test
    fun `upsert - insert then update - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid2")
        val entity3 = SampleEntity("id1", "hoge", 14, "uid2")
        // insert as first entity
        dao.upsert(entity1)
        // insert as pk is different
        dao.upsert(entity2)
        try {
            // update entity1 with unique id conflicts with entity 2
            dao.upsert(entity3)
            fail("can't complete upsert as unique id conflicts")
        } catch (_: SQLiteConstraintException) {
            // expected
        }

        val result = dao.getAll()
        assertTrue(result.containsAll(listOf(entity1, entity2)))
    }

Unique Index が重複する Update 2

PrimaryKey が異なり Unique Index が重複する Upsert をする場合、処理自体は Insert 相当になるはずですが Upsert の関数は例外をスローせず、かつ @Insert(onConflict = OnConflictStrategy.REPLACE) のような削除もしません。 結果的にデータの変更は何も起きないが例外もないことになります。この場合 upsert 関数に返り値を定義し、変更された行数を数えることで upsert がうまくいったかどうか判断する必要があります。

    @Test
    fun `upsert - insert 2 times - unique id conflicts`() = runTest {
        val entity1 = SampleEntity("id1", "name", 12, "uid1")
        val entity2 = SampleEntity("id2", "hoge", 14, "uid1")
        // insert
        dao.upsert(entity1)
        // try insert as pk is different from entity1, but conflicting unique id with entity1
        // this function won't throw exception for SQL Constraint violation, but just
        // fails to insert entity2
        dao.upsert(entity2)

        val result = dao.getAll()
        // only entity1 exists
        assertTrue(result.containsAll(listOf(entity1)))
    }

Room の Migration を Robolectric でテストする

AndroidX Room はテスト用にライブラリを提供していて、MigrationTestHelper を使うと Room Database のマイグレーションがテストできます。 ただ現状 MigrationTestHelper を使ったテストの例はどれも instrumented test となっていて、物理あるいは仮想デバイス上でテストを動かすことになります。

この記事ではRobolectric をつかってマイグレーションのテストを JVM 上で実行する手順を説明します。

ビルドスクリプトの準備

Robolectric でテストを実行するにあたり、事前準備としてデータベースのスキーマを JSON に吐き出しておく必要があります。 また吐き出した JSON をテスト実行時に読み出すため、test の source set にスキーマが保存されているディレクトリのパスを追加します。

// スキーマを export する先を指定
ksp {
    arg("room.schemaLocation", "$projectDir/schemas")
}

// export されたスキーマの path を assets に追加
extensions.configure<LibraryExtension> {
    sourceSets["test"].assets.directories += "$projectDir/schemas"
}

MigrationTestHelper を使って単体テストを書く

単体テスト自体は instrumented test の書き方とさほど変わりません。 RunWith で RobolectricTestrunner を指定する点が異なるくらいで、他はほぼそのまま書き換えられます。

class FooBarMigration : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // ...
    }
}

@RunWith(RobolectricTestRunner::class)
class FooBarMigrationTest {
    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        FooBarDatabase::class.java,
    )

    @Test
    fun `foobar migration - v1 to v2`() {
        helper.createDatabase("foobardb", 1).apply {
            // v1 スキーマで追加のテストセットアップ(データを insert したりなど)
        }.use {
            // v1 スキーマのデータベースで事前のチェック
        }

        helper.runMigrationsAndValidate("foobardb", 2, true, FooBarMigration()).use {
            // v2 スキーマでデータベースのチェック
        }
    }
}

MediaSessionManager を使って現在 active な MediaController を取得したい

MediaSessionManager は端末上で有効な media session を提供するクラスです。通常のアプリであれば、NotificationListenerService と組み合わせて現状どの media session が端末上で動いているかを取得する目的で使います。 端末上で動いている media session を取得する方法は次の2種類です。

  • MediaSessionManager.OnActiveSessionsChangedListener を使う
  • MediaSessionManager#getActiveSessions() を使う

どちらも media session に紐づいた MediaController のリストが得られます。

複数の MediaController からひとつ再生コントロールを握っているものを選ぶ

media session は音楽アプリが作成・保持し Android システムに登録するもので、音楽アプリが複数インストールされた端末では当然複数の異なる media session が存在することになります(同一のアプリが複数の media session を持つことも想定されているようですが、ここではひとつの音楽アプリがひとつの media session を持つ前提で考えます)。 MediaController は media session に紐づくので、複数の音楽アプリが動いているときには MediaController も複数存在します。media session 自体は排他的な動きをしない *1 ので、MediaSessionManagerMediaController をリストに詰めて返します。ただし、同じデバイスで複数のアプリが同時に音楽を再生できるわけではなく、ある音楽アプリが再生を開始すると他の音楽アプリは再生を停止します。

それでは、リストで得られる MediaController から現在再生コントロールの権限を握っているものを選び取るにはどうしたらよいでしょうか。

MediaSessionManager のドキュメントでは、MediaController のリストは重要度でソートされリストの先頭が最も重要な MediaController となることが書かれています。 この「重要度」を決めるファクターが何なのか、具体的な言及がないためいざ使おうとすると困るのですが、AOSP のコードを追いかけていくとソート順を決めるファクターに言及があります。

android.googlesource.com

この Javadoc からは PlaybackState#isActive() が true なものが先頭に来ることがわかります。 PlaybackState#isActive() は、現在再生リストの先頭にあり media session にも登録されている media item を読み込み中、再生中だったり、次の曲へ曲送りをしている最中だったりする場合に true になります。

ここで次のような操作をしたときに MediaSessionMangager#OnActiveSessionsChangedListener の動きを観測すると、Javadoc の記述通りの動きをしていることがわかります。ただし再生状態の変化が必ずしも OnActiveSessionsChangedListener を呼ばないことに気をつけます。

  1. 音楽アプリ A を起動する
  2. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController]
  3. 音楽アプリ A で再生を開始する
  4. OnActiveSessionsChangedListener は呼ばれない
  5. 音楽アプリ B を起動する
  6. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]

例えば、次のような操作をしたときも概ね想像通りリストがソートされます。

  1. 音楽アプリ A を起動する
  2. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController]
  3. 音楽アプリ A で再生を開始する
  4. OnActiveSessionsChangedListener は呼ばれない
  5. 音楽アプリ A で再生を停止する
  6. OnActiveSessionsChangedListener は呼ばれない
  7. 音楽アプリ B を起動する
  8. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]
  9. 音楽アプリ B で再生を開始する
  10. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ B の MediaController, 音楽アプリ A の MediaController]

ここまでで、 複数の MediaController からひとつ再生コントロールを握っているものを選ぶには OnActiveSessionsChangedListener 等で提供されるすべての MediaController の再生状態を監視し、 PlaybackState#isActive()true になったものを選択すればよいことがわかります。

複数の MediaController の再生状態が再生中になる場合

さて、先程の手順を少し変え、音楽アプリ A での再生停止をせず直接音楽アプリ B で再生を開始するとどうなるでしょうか。いつも次のような結果になるとは限りませんが、すこし直感に反する動きを見る場合があります。これはそれぞれの音楽アプリで再生・停止を実行するタイミングの微妙なズレで瞬間的に複数のアプリの PlaybackState#isActive()true になってしまうケースで起こることが考えられます。

  1. 音楽アプリ A を起動する
  2. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController]
  3. 音楽アプリ B を起動する
  4. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]
  5. 音楽アプリ A で再生を開始する
  6. OnActiveSessionsChangedListener は呼ばれない
  7. 音楽アプリ B で再生を開始する
  8. OnActiveSessionsChangedListener で得られるリスト: [音楽アプリ A の MediaController, 音楽アプリ B の MediaController]

MediaSessionManager がソート順を決められないため、音楽アプリ B が先頭に来るはずが実際には音楽アプリ A が先頭のままになっています。

ここまでは瞬間的に複数の音楽アプリが再生中であると判定してしまう可能性があることを見てみましたが、実際には単なるタイミングの問題による瞬間的な事象以外にも、複数の音楽アプリが再生中になり得るケースがあります。 少し前に述べた 同じデバイスで複数のアプリが同時に音楽を再生できるわけではなく、ある音楽アプリが再生を開始すると他の音楽アプリは再生を停止する という制約とは相容れないように見えますが、 Cast に代表されるスマートデバイスを考慮に入れるとこのケースも考えなくてはなりません。

Cast 等別のデバイスで音楽を再生するとき

多くの音楽アプリが Cast に対応しており、Nest Hub などで音楽を再生できるような機能をもっています。ある音楽アプリが Cast を使って他のデバイスで音楽を再生したとき、他の音楽アプリは Android スマートフォン上(あるいは Bluetooth 等でペアリングしたヘッドセット)で音楽の再生ができるようになります。 Cast を使用して再生しているアプリも、Android スマートフォン上で再生しているアプリも、どちらも media session を active にしているため、MediaSessionManager が返す MediaController も再生中のものはすべて PlaybackState#isActive()true になります。

このケースでどちらを選び取るかはアプリの仕様に依るところになりますが、Cast などのデバイスで再生しているのか Android スマートフォン上で再生しているかを見分けるには PlaybackInfo#getPlaybackType() を使います。 PlaybackInfo#getPlaybackType() は LOCAL か REMOTE かどちらかの定数を返してきます。LOCAL は Android スマートフォン上で再生、REMOTE は Cast などのデバイスで再生していることを示します。

Legacy MediaController と Jetpack MediaController

ここまで述べてきた MediaController ですが、2026年3月現在、実は大きく 2 種類の MediaController があります。 - Legacy MediaController: android.media.session.MediaControllerMediaSessionManager が返すもの - Jetpack MediaController: androidx.media3.session.MediaController

現状多くの場合 Jeptack MediaController を使うことが推奨されていて、Legacy MediaController から Jetpack MediaController へ変換する便利関数も用意されていますが、少し気をつけなければいけない点があります。

Playback State

Legacy MediaController のいう Playback State は音楽データを処理する仕組みの状態(初期状態、コンテンツの読み込み中、再生可能状態など)とコンテンツの再生状態(再生中、停止中など)の2つの状態を合わせた状態を指します。 一方 Jetpack MediaController のいう Playback State にはコンテンツの再生状態再生状態は含まれず、音楽データを処理する仕組みの状態のみを表すようになっています。 よって Jetpack MediaController で再生状態の確認をする場合は MediaController#isPlaying 等別の関数・コールバックを見ることになります。

Playback Info と Device Info

Legacy MediaController が提供する Playback Info に相当するものは Jetpack MediaController では Device Info です。 どちらも Playback type として LOCAL か REMOTE かを確認する手段を提供していますが、定数の定義が微妙に異なります。

  • Playback Info (定数はどちらも int)
    • LOCAL: 1
    • REMOTE: 2
  • Device Info (定数はどちらも int)
    • LOCAL: 0
    • REMOTE: 1

定数の比較を確実にするため、パッケージ名・クラス名を対応する MediaController に合わせて変える必要があります。

*1:ある音楽アプリの media session が active になったとき、他の音楽アプリの media session が inactive になるような制御はありません。

2026 をやっていく

2025 年を振り返り忘れたまま 2026 年になってしまったので、とりあえず 2026 年をやっていく記事を書いておきます。

バイクの大型免許を取る

自分の体格ではそもそもバイクの選択肢が少なく、780mm のようなごく標準的なシート高がギリギリ許容範囲の上限いっぱいなので大型バイクとなるとなおのこと選択肢は限られてしまいます。 ローダウンやアンコ抜きという追加のカスタムをすることでシート高を下げるという手もありますが、800mm を超えるシート高ではそれらのカスタムでも焼け石に水で自分からすると大して下がってないこともあり難しいところです。

そんな中メーカーが用意しているオプションで50mmほどシート高を下げられるモデルを用意しているのが BMW F800GS。 中型免許をとったばかりのときは恐怖しかなかったアドベンチャータイプのバイクですが、アフターマーケットのパーツではなくメーカー標準のオプションで760mmまでシート高が下がるのがありがたく、またこれだけのシート高がスタンダードな大型バイクもなかなかないので、ひとまずはこれに乗れるよう免許を取りに行こうと考えています。

AI の勉強をちゃんとする

あんまり真面目につかっておらずちょっとしたアドバイスを貰いに行く程度にしか使っていませんでしたが、気がついたらコードレビューの精度がめちゃくちゃ高くなっていたりして日頃の開発にガッツリ組み込んで使えるレベルになってきているので、もっと自分で使いながら勉強しなければ。

動画編集技術を上げる

去年の北海道ツーリング動画、基本切って貼ってつなぎ合わせる編集で動画を作りましたが、もう少し工夫できたらいいな。

youtu.be

kotlinx.datetime 0.7.x へのバージョンアップと DayOfWeek

DayOfWeek は曜日を示す列挙型で、月曜日を起点として順序付けの整数(1から7まで)を持っています*1。Java の実装は java.time.DayOfWeek で、Kotlin では kotlinx.datetime.DayOfWeek です。

kotlinx.datetime 自体は DateTime 専用のライブラリとして提供されていて、バージョン 0.6.x まで DayOfWeek の実体は java.time.DayOfWeek へのエイリアスになっていたので、実装はすべて java.time.DayOfWeek のものでした。

kotlinx.datetime 0.7.x では java.time への依存を減らす目的で破壊的変更が入っており、DayOfWeek も改めて Kotlin で書き直され java.time.DayOfWeek へのエイリアスではなくなりました。仕様としては java.time.DayOfWeek も kotlinx.datetime.DayOfWeek も ISO-8601 に従っているのでほぼ同じように扱えますが、書き直す過程で java.time.DayOfWeek にあったメソッドが置き換わったり完全になくなったりしているため、場合によってはパッケージの置き換え以外のマイグレーションが必要です。

DayOfWeek の足し算・引き算

DayOfWeek の足し算・引き算は曜日をn日分ずらす操作です。java.time.DayOfWeek にはそれぞれ plus / minus メソッドが定義されていて、Kotlin がうまくオペレーターのオーバーロードとして解釈してくれるので、次のコードはコンパイル・実行可能です。

// kotlinx.datetime 0.6.x までで動作するコード

// 月曜日の翌日なので火曜日になる
val tuesday = DayOfWeek.MONDAY + 1

// 月曜日の前日なので日曜日になる (内部で順序付けの整数をうまく計算してくれて7番目の曜日である日曜日にしてくれる)
val sunday = DayOfWeek.MONDAY - 1

kotlinx.datetime 0.7.x では書き直された enum に plus / minus メソッドの定義がないのでそもそもコンパイルができません。 このような操作が必要な場合は、次のように自分で拡張関数を用意しオペレーターをオーバーロードします。

// 曜日の宣言順序が Java の enum と一致しているので中身は Java の実装そのままでも可
operator fun DayOfWeek.plus(days: Int): DayOfWeek {
    val amount = (days % 7).toInt()
    return DayOfWeek.entries[(ordinal + (amount + 7)) % 7]
}

operator fun DayOfWeek.minus(days: Int): DayOfWeek {
    return plus(-(days % 7))
}

DayOfWeek.of

整数型から DayOfWeek を得るためのメソッドで、DayOfWeek をシリアライズするときに便利です(ISO-8601 で定義された数字とマッピングしてくれる)が、kotlinx.datetime 0.7.x の DayOfWeek は次の関数を使います。

public fun DayOfWeek(isoDayNumber: Int): DayOfWeek {
    require(isoDayNumber in 1..7) { "Expected ISO day-of-week number in 1..7, got $isoDayNumber" }
    return DayOfWeek.entries[isoDayNumber - 1]
}

*1:ISO-8601 で規格化されていますが、java.util.Calendar に定義されている曜日を表す定数はこの規格に従っていません

渋峠

バイクの納車から早いものでもう5ヶ月、気がつけばODOメーターは6000kmを優に超えている。明らかに乗り過ぎである。

そんなこんなでアクションカメラも揃えつつあり、ちょくちょく YouTube に動画も上げている。

www.youtube.com

www.youtube.com

www.youtube.com

直近だとGWに休暇を取って渋峠にある日本国道最高地点の石碑を見に行った。

www.youtube.com

実はここは以前自転車で登っていて、そのときは草津側からのヒルクライムでやってきていたが、今年はその道が雪崩の影響で通行止めだったので嬬恋村から万座ハイウェイを経由してたどり着いた。 その万座ハイウェイも過去にヒルクライムイベントに参加したときに自転車で走っている。

www.youtube.com

この他にももっと行ってみたい場所がある。動画編集はとてつもなく大変だが頑張ってあげていくぞ。!!!11

AndroidX Room でインデックスの手動マイグレーションを実装する

テーブルに張ったインデックスの制約が変わったときなど、手動マイグレーションをするときは次のようにマイグレーションを実装する。

  1. 同じカラムを持つ新しい別のテーブルを用意する
  2. 元のテーブルから新しいテーブルにデータを移し替える
  3. 元のテーブルをドロップする
  4. 新しいテーブルの名前を元のテーブルの名前に変更する
  5. インデックスを貼る
class SampleIndexMigration : Migration(1, 2) {
  override fun migrate(db: SupportSQLiteDatabase) {
    db.execSQL("CREATE TABLE IF NOT EXISTS `sample_new` (`foo` TEXT NOT NULL, `bar` TEXT NOT NULL, `baz` TEXT NOT NULL, PRIMARY KEY(`foo`)) ")
    db.execSQL("INSERT INTO `sample_new` (`foo`, `bar`, `baz`) SELECT `foo`, `bar`, `baz` FROM `sample`")
    db.execSQL("DROP TABLE `sample`")
    db.execSQL("ALTER TABLE `sample_new` RENAME TO `sample`")
    db.execSQL("CREATE INDEX IF NOT EXISTS `index_sample_baz` ON `sample` (`baz`)")
  }
}

SQL 文は Room が自動で生成する json から引っ張ってくると楽ができる(createSql で探して対応する SQL 文をコピペしよう)。