SQLDelightを導入するに当たって詰まったポイント
Roomは使ったことがあったので比較対象としてSQLDelightを試してみた際に詰まったポイントをメモ。
環境
- Android Studio 4.1.3
- SQLDelgiht 1.5.0
- Gradle 6.8.3
- 既存アプリで別のデータベース管理中(バージョンは5)
SQL Delight Gradle plugin applied in project ':' but no supported Kotlin plugin was found
Getting Startedのドキュメントを見ると apply plugin: 'com.squareup.sqldelight'
をプロジェクトルートのbuild.gradleに適用しているように見えるがこれは適用する必要がない。
使用するモジュールに対して適用する
サンプルもプロジェクトルートには適用していない
https://github.com/cashapp/sqldelight/blob/master/sample/build.gradle
使用するモジュールで適用している。
https://github.com/cashapp/sqldelight/blob/master/sample/common/build.gradle#L3
primary keyエラー
/your/project/app/src/main/sqldelight/com/phicdy/hoge/Fuga.sq line 2:16 - ')', ',', <column constraint real> or AS expected, got 'primary' 1 CREATE TABLE fuga ( 2 _id integer primary key autoincrement, ^^^^^^^ 3 name text, 4 )
primary key autoincrementは大文字である必要がある。
Unable to find method 'kotlin.jvm.internal.FunctionReferenceImpl.(ILjava/lang/Class;Ljava/lang/String;Ljava/lang/String;I)V'kotlin.jvm.internal.FunctionReferenceImpl.(ILjava/lang/Class;Ljava/lang/String;Ljava/lang/String;I)V
Gradle 6.8以上にアップデートする。
Fuelで似た問題があった。 https://stackoverflow.com/questions/64238451/execution-failed-for-task-void-kotlin-jvm-internal-mutablepropertyreference1i
Gradle 6.7まではKotlin 1.3系が使われおりKotlin 1.4系と互換性がないことが問題ぽい。
Gradle 6.8でKotlin 1.4になっている。 https://docs.gradle.org/6.8/release-notes.html
SQLDelightもKotlin 1.4系なのでGradle 6.8以上を使う。
デフォルトのデータベースバージョンが1でクラッシュ
AndroidSqliteDriverのコンストラクタでSupportSQLiteOpenHelperのインスタンスを引数のfactoryから生成している。factory.create()の中でSupportSQLiteOpenHelper.Callbackを設定している。コンストラクタにcallbackを指定しなければデフォルトではAndroidSqliteDriver.Callback(schema)が使われる。中の実装を見るとschema.versionがバージョンとして指定される。
open class Callback( private val schema: SqlDriver.Schema, vararg callbacks: AfterVersion, ) : SupportSQLiteOpenHelper.Callback(schema.version) {
schemaはドキュメントに従えばDatabase.Schemaを渡す。これは内部で生成されるクラス。Database.SchemaはDatabaseImpl.Schemaでversionは生成時に固定されている。
internal val KClass<Database>.schema: SqlDriver.Schema get() = DatabaseImpl.Schema internal fun KClass<Database>.newInstance(driver: SqlDriver): Database = DatabaseImpl(driver) private class DatabaseImpl( driver: SqlDriver ) : TransacterImpl(driver), Database { public override val feedQueries: FeedQueriesImpl = FeedQueriesImpl(this, driver) public override val filtersQueries: FiltersQueriesImpl = FiltersQueriesImpl(this, driver) public object Schema : SqlDriver.Schema { public override val version: Int get() = 1
このバージョンをあげるにはMigrationsのドキュメントに従ってsqmファイルを作りマイグレーションの処理を書く。
今まで4回バージョンを上げてきたならそれぞれのバージョンアップで行ってきたマイグレーションを1.sqm, 2.sqm, 3.sqm, 4.sqmに記載する。再ビルドするとDatabaseImpl.Schemaのversionが5を返すようになる。
Dagger Hilt 2.31-alphaの変更に対応する
やること
implementation "androidx.hilt:hilt-lifecycle-viewmodel:$androidx_hilt_version"
を消すkapt "androidx.hilt:hilt-compiler:$androidx_hilt_version"
を消す- ApplicationComponentをSingletonComponentに置き換える
@ViewModelInject
を@Inject
に置き換え、クラスに@HiltViewModel
を付ける
@ViewModelInject
では ActivityRetainedComponentに紐付いていたが @HiltViewModel
では ViewModelComponent
に紐づくようになった。
ButtonのstateListAnimatorをnullにするとelevationがなくなるのはなぜか
環境
- テーマはTheme.MaterialComponents.Light
- appcompat 1.2.0
- material 1.2.1
- minSdkVersion 23
コードを追う
- 継承関係を追うと、Theme.MaterialComponents.Light -> Base.Theme.MaterialComponents.Light -> Base.V21.Theme.MaterialComponents.Light -> Base.V14.Theme.MaterialComponents.Light -> Platform.MaterialComponents.Light -> Base.Theme.AppCompat.Light
- Base.Theme.AppCompat.Lightはバージョンごとに違うが大本の親はBase.V21.Theme.AppCompat.Light
- Base.V21.Theme.AppCompat.LightのbuttonStyleは?android:attr/buttonStyleなので親を見る
- Base.V21.Theme.AppCompat.LightはBase.V7.Theme.AppCompatを継承
- Base.V7.Theme.AppCompatはbuttonStyleに@style/Widget.AppCompat.Buttonを指定
- Widget.AppCompat.ButtonはBase.Widget.AppCompat.Buttonを継承
- Base.Widget.AppCompat.Buttonはv21でandroid:Widget.Material.Buttonを継承
<style name="Widget.Material.Button"> <item name="background">@drawable/btn_default_material</item> <item name="textAppearance">?attr/textAppearanceButton</item> <item name="minHeight">48dip</item> <item name="minWidth">88dip</item> <item name="stateListAnimator">@anim/button_state_list_anim_material</item> <item name="focusable">true</item> <item name="clickable">true</item> <item name="gravity">center_vertical|center_horizontal</item> </style>
ここのstateListAnimatorで指定しているbutton_state_list_anim_materialでelevationを指定しているのでこれを消せばよい https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/anim/button_state_list_anim_material.xml
Kotlin Coroutinesで非同期に処理を複数開始して全ての結果を待つ
例えばListの各値に対して非同期に処理を開始してその全ての結果を待って通知したいとする。 こういったときはcoroutineScope内でListの項目ごとにasyncしてdeferredのlistを作りawaitAll()すればよい。結果もListとして受け取ることができる。
suspend fun execute() { coroutineScope { val deferredList = list.map { item -> async { api.execute(item.id) } // Deferred<ExecuteResult> } val result: List<ExecuteResult> = deferredList.awaitAll() ... }
Deep LinkとApp LinksとFirebase Dynamic Linksの使い分け
メール配信からアプリを起動させたい場合や、Webサービスの機能をアプリとして開きたい場合、起動したいURLをAndroidManifestのIntent Filterに定義してアプリを起動できるようにする。方法はだいたい3つに絞られるので使った体感をまとめておく。
Deep Link
商用のアプリでは基本的に使わない。保障はしないけどとりあえずアプリを起動できるようにはしておきたい場合に使う。
メリット
- 工数が最も低く、アプリの設定だけで完結する
デメリット
- 誰でも同じIntent Filterを設定できるのでアプリの起動を他アプリに取られる可能性がある
- アプリがインストールされていないときはブラウザでそのURLを開くしかない
App Links
基本的にはこれを使う。検証の設定をすることで対象のURLから自分のアプリしか開かないように設定できる。 ただし多少の制限があるのでFirebase Dynamic Linksと使い分ける。
メリット
- サーバサイドの実装が不要で工数が低め
- Android 6.0以上でアプリのsha256 fingerprintを起動したいドメインの .well-known/assetlinks.json に置くことでアプリの検証ができ、そのドメインを開くデフォルトのアプリになれる
デメリット
- 同じドメイン間の遷移ではアプリを開けない
- アプリが入っていないときはブラウザでそのURLを開くしかない
- LINEやSlackなどのアプリ内ブラウザからの起動ができない
Firebase Dynamic Links
Firebaseが提供するサービス。App Linksより更に機能がある。おそらく中身はApp Linksを使っているので検証の設定は同様に必要。 工数がかかることもあるが一番機能はあるので全部Firebase Dynamic Linksにしてしまうのも1つの手。
メリット
- 同じドメイン間での遷移でもアプリを起動できる(おそらくFirebase Dynamic Linksのドメインを一回挟むため)
- アプリがインストールされていないときにGoogle Playに飛ばすなどの挙動を設定できる
- LINEやSlackなどのアプリ内ブラウザからも起動できる
デメリット
- なんか不安定(起動しなかったり空のページが残ったり)
- 起動時にくるくるのアニメーションが出る
- パラメータが動的な場合、サーバサイドの実装が必要で工数がかかる
- 固定でもFirebase上でURLの設定が必要
おわりに
これを使えば絶対にいいというものはないので求める体験や工数と相談でどれを使うとよいか選択するとよさそう
Android Architecture ComponentのNavigationとマルチモジュール
ここ何日かNavigationを使ったプロジェクトでどうマルチモジュールにするかを考えてました。
結論としては下図のように各featureはInterfaceのみに依存し、appで全画面遷移を解決するのが楽でした。
各featureはInterfaceにのみ依存する
画面遷移するfeatureモジュールに依存してしまうと依存するfeatureモジュールに変更があるたびに再ビルドが必要になります。 マルチモジュールの利点である差分ビルドの高速化の恩恵が得られにくくなります。
また各feature間で相互に画面遷移する場合、モジュール間が循環依存になりビルドが通らなくなります。 そこで画面遷移のInterfaceのみを持つモジュールを作り、各featureモジュールはInterfaceのモジュールに依存するようにします。
appモジュールが全てを解決する
画面遷移のInterfaceの実装をどこに置くか考えるにあたってappモジュールが一番楽でした。 appモジュールはDIの都合上全てのモジュールに依存することが多いので画面遷移のInterfaceの実装での依存性解決が楽です。
SingleActivityとnav graphもappモジュールに置いてしまいます。nav graphも全てのFragmentを知っている必要があります。各featureモジュール間を依存させない方針にしたので全てのFragmentを知ることができるappモジュールでnav graphを解決するのが楽です。
実装
nav graphをappモジュールに用意します。
<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/featureAFramgnet"> <fragment android:id="@+id/featureAFramgnet" android:name="com.phicdy.example.feature.FeatureAFragment" android:label="featureA"> <action android:id="@+id/action_featureAFragment_to_featureBFragment" app:destination="@id/featureBFramgnet" /> </fragment> <fragment android:id="@+id/featureBFramgnet" android:name="com.phicdy.example.feature.FeatureBFragment" android:label="featureB" /> </navigation>
NavigationProviderのInterfaceを定義します。
interface NavigationProvider { fun featureAtoB(fragment: Fragment) }
appモジュールで実装をDIで提供します。
@Module object AppModule { @Provides @Singleton fun provideNavigationProvider(): NavigationProvider = object : IntentProvider { override fun featureAtoB(fragment: Fragment) { fragment.findNavController().navigate(R.id.action_featureAFragment_to_featureBFragment) } } }
featureAではNavigationProviderで画面遷移します。
class FragmentA: DaggerFragment { @Inject lateinit var navigationProvider: NavigationProvider fun navigateToFragmentB() { navigationProvider.featureAtoB(this) } }
おわりに
Navigationは得られる恩恵の割にはいろいろ複雑になるから無理していれなくてもいいんじゃないと思い始めました
WorkManagerでWorkerを一意に実行して画面が死んでも復帰する
最近WorkManagerを使ってて少し嵌ったので書く。
環境
- WorkManager 2.2.0
- 最新はappcompat 1.1.0に依存していて問題がちらほらあるので使わない
Workerを一意に実行する
Workerを一意に実行するにはWorkManagerのbeginUniqueWorkを使う。WorkContinuationからはできないので注意。第一引数に一意にするための名前を指定する。後に実行結果のLiveDataを取り直すときに同じ名前を使う。第二引数にはExistingWorkPolicy.KEEPを指定する。KEEPにすることで何度呼んでも実行中であれば再実行されることはない。第三引数には最初に実行するWorkerを指定する。一つでもいいしListも渡せる。
val firstWork = OneTimeWorkRequestBuilder<FirstWorker>().build() val second1Work = OneTimeWorkRequestBuilder<Second1Worker>().build() val second2Work = OneTimeWorkRequestBuilder<FirstWorker>().build() val uniqueName = "unique" WorkManager.getInstance(context) .beginUniqueWork(uniqueName, ExistingWorkPolicy.KEEP, firstWork) .then(listOf(second1Work, second2Work)) .enqueue()
画面が死んでも再observeできるLiveDataを返す
実行結果のLiveDataを返すときはenqueue()の返り値のWorkContinuationのworkInfoLiveDataではなくWorkManagerのgetWorkInfosForUniqueWorkLiveData(uniqueName)を使う。workInfoLiveDataだと初回は値が通知されるLiveDataが返るが実行中に再度呼ぶと通知されない。恐らく再度呼ばれた際に実行されたWorkerはないということで通知されないと考えられる。
return workManager.getWorkInfosForUniqueWorkLiveData(uniqueName)
あとはこのLiveDataをobserveするだけで画面が死んでも再度uniqueNameを起点にLiveDataを取り直して進捗を通知できる。