phicdy devlog

Androidアプリ開発やその他技術系の記事をたまに書きます

AndroidとかiOSとかモバイル多め。その他技術的なことも書いていきます。

SQLDelightを導入するに当たって詰まったポイント

Roomは使ったことがあったので比較対象としてSQLDelightを試してみた際に詰まったポイントをメモ。

github.com

cashapp.github.io

環境

  • 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ファイルを作りマイグレーションの処理を書く。

cashapp.github.io

今まで4回バージョンを上げてきたならそれぞれのバージョンアップで行ってきたマイグレーションを1.sqm, 2.sqm, 3.sqm, 4.sqmに記載する。再ビルドするとDatabaseImpl.Schemaのversionが5を返すようになる。

Dagger Hilt 2.31-alphaの変更に対応する

github.com

やること

  • 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

developer.android.com

基本的にはこれを使う。検証の設定をすることで対象のURLから自分のアプリしか開かないように設定できる。 ただし多少の制限があるのでFirebase Dynamic Linksと使い分ける。

メリット

  • サーバサイドの実装が不要で工数が低め
  • Android 6.0以上でアプリのsha256 fingerprintを起動したいドメインの .well-known/assetlinks.json に置くことでアプリの検証ができ、そのドメインを開くデフォルトのアプリになれる

developer.android.com

デメリット

  • 同じドメイン間の遷移ではアプリを開けない
  • アプリが入っていないときはブラウザでそのURLを開くしかない
  • LINEやSlackなどのアプリ内ブラウザからの起動ができない

Firebase Dynamic Links

firebase.google.com

Firebaseが提供するサービス。App Linksより更に機能がある。おそらく中身はApp Linksを使っているので検証の設定は同様に必要。 工数がかかることもあるが一番機能はあるので全部Firebase Dynamic Linksにしてしまうのも1つの手。

メリット

  • 同じドメイン間での遷移でもアプリを起動できる(おそらくFirebase Dynamic Linksのドメインを一回挟むため)
  • アプリがインストールされていないときにGoogle Playに飛ばすなどの挙動を設定できる
  • LINEやSlackなどのアプリ内ブラウザからも起動できる

デメリット

  • なんか不安定(起動しなかったり空のページが残ったり)
  • 起動時にくるくるのアニメーションが出る
  • パラメータが動的な場合、サーバサイドの実装が必要で工数がかかる
  • 固定でもFirebase上でURLの設定が必要

firebase.google.com

おわりに

これを使えば絶対にいいというものはないので求める体験や工数と相談でどれを使うとよいか選択するとよさそう

Android Architecture ComponentのNavigationとマルチモジュール

ここ何日かNavigationを使ったプロジェクトでどうマルチモジュールにするかを考えてました。

結論としては下図のように各featureはInterfaceのみに依存し、appで全画面遷移を解決するのが楽でした。

f:id:phicdy:20200828212710p:plain

各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を取り直して進捗を通知できる。