ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

realmをマルチモジュール化したときに気をつけること

こんにちは、tkyです。

Androidアプリ開発のDBとしてRealmを使用して、Realmをマルチモジュール化したときにどのようなことに気をつけるとよいのか私が実際に直面した問題を説明したいと思います。

目的

マルチモジュール化の目的として、Realmの依存をappのようなコアなモジュールから切り離したかったことがあります。

管理のしやすさ、問題が発生したときのスコープを限定させる目的があります。

また、Realmオブジェクトは@RealmObjectというアノテーションを付けますが、差分ビルド毎にアノテーション処理を行っているらしく

差分ビルドにオーバーヘッドが発生している為、ビルド速度を短縮する目的もありました。

やったこと

  • realmモジュールを作成する
  • RealmModuleを作成する

前者はAndroidのモジュールとしてのrealmモジュールで、

後者はRealmのスキーマとしての依存関係をまとめるためのRealm内で使われる単位のモジュールです。この記事で言いたいこととなります。

新しいドキュメント docs.mongodb.com

古いドキュメント docs.mongodb.com

RealmMoudle

RealmModuleは何のためにいるのかというと、異なるライブラリ間で定義されたRealmのオブジェクト名などがコンフリクトしないように(しても大丈夫なように)

そのライブラリやモジュールで使用するRealmObjectを定義するというのが目的となります。

デフォルトでは自動で作られるものらしいのですがコレを明示的に定義しないと

appモジュールからrealmモジュールにアクセスするときに名前の解決ができなかったりしてちょっと厄介になります。

(そもそもアクセス依存しないように作る、というのはもちろんその通りです。)

@RealmClass
open class Foo : RealmObject()

@RealmClass
open class Bar : RealmObject()

// コレを明示的に定義することで命名の競合管理できる
@RealmModule(
    classes = [
        Foo::class,
        Bar::class,
    ]
)
class RealmModule


fun initializeRealm() {
    val realmConfiguration = RealmConfiguration.Builder()
       .modules(RealmModule())
       .build()
    Realm.setDefaultConfiguration(realmConfiguration)
}

まとめ

  • Realmをモジュールに分割するときはRealmModuleも一緒につくろう
  • 永続化領域をモジュールに分割できた
  • 差分ビルドは気持ち早くなった気がする

余談

RealmのDIモジュールもRealmModuleになってしまうので

DI側は RealmDaggerModule というように命名しています。Hiltにする場合とかもあるかもしれないので素直に RealmDIModule とかでも良いと思います。

RealmのCoroutines対応で気をつけたこと

こんにちは、tkyです。

「Realmのtransaction処理、Coroutinesで書きたいな〜」

そう思ったことありませんか?ありますよね。

最新のRealm(10.0)ではkotlin拡張がいくつか存在して executeTransactionAwait を使うと時間のかかる書き込み処理などをsuspend funで記述することができるようになります。

docs.mongodb.com

こちらでRxで書いていた処理をCotourinesで書き換える事ができるようになりますが、いくつか問題も出てきたのでその辺りをこのブログで共有できればと思います。

出てきた問題

  • UIスレッドで処理できない(初期化時に明示的にuiスレッド許可する必要あり)
  • 異なるスレッドでRealmインスタンスを生成できない

UIスレッドで処理できない

RealmException if called from the UI thread, unless an explicit opt-in has been declared in {@link RealmConfiguration.Builder#allowWritesOnUiThread(boolean)}.

対策その1ー UIスレッドの操作を許可する

Realmインスタンスを作る時にBuilderにallowWritesOnUiThreadを指定するだけです。一応これで解決自体はできますが、

UIスレッドを止めてしまうことになるのでパフォーマンスに気をつけたいところです。

val realmConfiguration = RealmConfiguration.Builder()
    .allowWritesOnUiThread(true)
    .build()

対策その2ー Realm処理用のコルーチンディスパッチャを用意する

UIスレッドを止めないようにRealmを扱うための専用のスレッドとコルーチンディスパッチャを用意する方法があります。

シングルスレッドで動作できるコルーチンディスパッチャを以下のように定義します。

// これをDaggerなどを使ってDIすると良いでしょう
fun providesRealmDispatcher(): CoroutineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()

// 関数にwithContextを使ってRealmを処理するスレッドをディスパッチャで指定する
override suspend fun write(value: String) = withContext(realmDispatcher) {
    Realm.getDefaultInstance().use { realm ->
        realm.executeTransactionAwait {
            it.copyToRealmOrUpdate(
                Hoge.create(
                    value
                )
            )
        }
    }
}

一応これで書き込み時にUIスレッドで処理できない問題は解決するのですが、別の問題も現れてきました。

異なるスレッドでRealmインスタンスを生成できない

こんなエラーが出てきます

java.lang.IllegalStateException: Realm access from incorrect thread. Realm objects can only be accessed on the thread they were created.

これはWrite処理はsuspend関数対応しましたが、Read処理については通常の関数として処理してしまったことで

  • Read処理が走る・・・ViewModelScopeのIOスレッドで最初にRealmインスタンスが作られる
  • Write処理が走る・・・Realm用のディスパッチャスレッドからRealmインスタンスにアクセスする

この時RealmインスタンスはViewModelScopeのIOディスパッチャで作られたものです。

それを別のスレッド(Realm用のスレッドから作られたディスパッチャ)からアクセスしようとしたために発生したものでした

そのためWrite処理だけでなくRead処理も含めてRealm処理用のコルーチンディスパッチャ上で動作させる必要があります

この問題についてはStackOverFlowでもちょくちょくみる内容となっています。

stackoverflow.com

// 書き込むデータはString型としています

override suspend fun read(key: String): String = withContext(realmDispatcher) {
    Realm.getDefaultInstance().use { realm ->
        realm.where(Hoge::class.java).equalTo("key", key).findFirst()?.value
    }
}

override suspend fun write(value: String) = withContext(realmDispatcher) {
    Realm.getDefaultInstance().use { realm ->
        realm.executeTransactionAwait {
            it.copyToRealmOrUpdate(
                Hoge.create(
                    value
                )
            )
        }
    }
}

まとめ

Realmをコルーチンで記述するときは

  • 専用のDispacherを作成して
  • Read処理もWrite処理も全てコルーチン化すると良い

それでは良いKotlinライフを。

IntentのresolveActivityを考慮したRobolectricテスト

こんにちは、tkyです。

今日はテストの話です。

画面遷移をテストする時Robolectricを使うと比較的簡単にテストできます。

今回はRobolectric v4.3を使用して

  • 自身のアプリの別Activityに遷移する場合 (=明示的Intent)
  • 外部アプリに遷移する場合 (=暗黙的Intent)

この2つについてこんな感じでできるよ〜というのを紹介したいと思います。

また、Robolectricの説明や導入方法などは記載いたしませんのでご了承願います。

補足ですが、検証にはAssertJを使用しています。

例えば次のような画面遷移をする関数があるとします。

class MainActivity: AppComponentActivity() {

    // 自アプリ内の別Activityへ遷移する
    fun openSubActivity() {
        val intent = SubActivity.createIntent(this)
        startActivity(intent)
    }

    // 別アプリのブラウザアプリを開く
    fun openBrowser() {
        val uri = Uri.parse("https://google.com")
        startActivity(Intent(Intent.ACTION_VIEW, uri))
    }
}

テストコードは次のように書くことができます。

外部ブラウザのように暗黙的インテントによって画面遷移する場合、何が起動するかわからないのでActivityを検証することが難しいです。

そのため、発行したインテントを検証することでコードの正当性を担保します。

class MainActivityTest{

    @Test
    fun `openSubActivityしたらSubActivityに遷移すること`() {
        val activity = Robolectric.buildActivity(MainActivity::class.java)
        activity.openSubActivity() // ここでSubActivityに遷移する
        val shadowActivity = Shadows.shadowOf(activity.get())
        val intent = shadowActivity.peekNextStartedActivity()
        // intentのShadowを作成して次に起動するActivityを検証できるようにする
        val shadowIntent = Shadows.shadowOf(intent)
        val nextActivity = shadowIntent.intentClass
        // 遷移先が正しいこと
        Assertions.assertThat(nextActivity).isEqualTo(SubActivity::class.java)
    }

    @Test
    fun `openBrowser_外部ブラウザへ遷移する`() {
        val activity = Robolectric.buildActivity(RootActivity::class.java)
        activity.openBrowser() // ここで外部ブラウザに遷移する
        val shadowActivity = Shadows.shadowOf(activity.get())
        val intent = shadowActivity.peekNextStartedActivity()
        // urlスキームが正しくパースできること
        Assertions.assertThat(intent.data?.toString()).isEqualTo("https://google.com")
        // 外部ブラウザなので何が起動するか不明。暗黙的インテントのACTIONでassertする
        Assertions.assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW)
    }
}

これで一応テスト可能となりますが、ここで公式のIntent起動ドキュメントを見てみましょう。

developer.android.com

注意:端末に暗黙的インテントを受け取ることができるアプリがない場合、startActivity() を呼び出すとアプリがクラッシュします。まず、インテントを受け取るアプリの存在を確認するために、Intent オブジェクトの resolveActivity() を呼び出してください。結果が null 以外の場合は、インテントを処理できるアプリが少なくとも 1 つあるということなので、startActivity() を安全に呼び出すことができます。結果が null の場合は、そのインテントは使用しないでください。可能であればそのインテントを呼び出す機能を無効にしてください。

要するに以下のようにIntentが起動できるか resolveActivity を使ってチェックしてから起動することを推奨しています。

    if (intent.resolveActivity(packageManager) != null) {
        startActivity(intent)
    }
class MainActivity: AppComponentActivity() {

    // 自アプリ内の別Activityへ遷移する
    fun openSubActivity() {
        val intent = SubActivity.createIntent(this)
        startActivity(intent)
    }

    // 別アプリのブラウザアプリを開く
    fun openBrowser() {
        val uri = Uri.parse("https://google.com")
        val intent = Intent(Intent.ACTION_VIEW, uri)
        // intentを受け取れるアプリがいるか確認してから起動する
        if (intent.resolveActivity(packageManager) != null) {
            startActivity(intent)
        }
    }
}

こうすると何が起こるのかというとRobolectricではresolveActicvity()の戻りがnullになってしまいstartActivityが実行されないことでテストが通らなくなってしまいます。

これを解決するためにShadowPackageManagerを使用してresolveActivity()をモックできるようにします。

    @Test
    fun openByUrlScheme_open_browser_外部ブラウザへ遷移する() {
        val activity = Robolectric.buildActivity(RootActivity::class.java)
        // ▼▼▼▼ 追加ここから ▼▼▼▼
        // ダミーでComponentNameを返す。Intentを検証するのでパッケージ名とActivity名は何でも良い
        val componentName = ComponentName(“com.some.other.package”, “MainActivity”)
        val intentFilter = IntentFilter(Intent.ACTION_VIEW).apply {
            addCategory(Intent.CATEGORY_DEFAULT)
            addCategory(Intent.CATEGORY_BROWSABLE)
            addDataScheme(“https”)
        }
        // PackageManagerのShadowを作成してIntentFilterを追加しておく
        Shadows.shadowOf(ApplicationProvider.getApplicationContext<MyApplication>().packageManager).apply {
            addActivityIfNotPresent(componentName)
            addIntentFilterForActivity(componentName, intentFilter)
        }
        // ▲▲▲▲ 追加ここまで ▲▲▲▲
        activity.openBrowser() // ここで外部ブラウザに遷移する
        val shadowActivity = Shadows.shadowOf(activity.get())
        val intent = shadowActivity.peekNextStartedActivity()
        // urlスキームが正しくパースできること
        Assertions.assertThat(intent.data?.toString()).isEqualTo(“https://google.com”)
        // 外部ブラウザなので何が起動するか不明。暗黙的インテントのACTIONでassertする
        Assertions.assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW)
    }

この辺調べても古いバージョンのRobolectricでの対応方法などが出てきて新しいバージョンでの解決方法がなかったので結構ハマりました。

これでGoogleが推奨する外部ブラウザを開くような暗黙的インテントの実装をした状態でテストもできるようになりました。

是非試してみてください。

kotlinで別パッケージの同じクラス名の衝突を回避する

こんにちは、tkyです。

Kotlinの小ネタです。

タイトルのとおりなのですが、こういったことってほとんどないとは思うのでいざというときどうするか迷う系のやつです。

ドメインとしてのクラス名と被ってしまう

例えば イベント情報 を取り扱うドメインがあったとして Event クラスを作ったとします。

package com.hatenablog.ticktakclock.model.Event

data class Event(val id: Int /* その他引数 */) {}

次にFirebaseに送るトラッキングイベントを Event クラスとしてモデル化します

package com.hatenablog.ticktakclock.track.Event

sealed class Event(val name: String) {
    class Favorite(): Event("user_favorite") // お気に入りしたことをトラッキング
}

この2つのクラスを一緒に使おうとしたときにクラス名の衝突が起こってしまい、どちらかはフルパッケージ名で記述する必要が出てきます。

クラス名が衝突した!その前に

まず名前が衝突しないようにクラス名の変更リファクタリングを視野に入れましょう。

この場合、ドメインとしてのEventは共通仕様なので変えることがかなり難しいですが、トラッキングイベントの方は他の領域関係なく考え直すことができます。

package com.hatenablog.ticktakclock.track.TrackingEvent のように変えることで衝突が発生しない平和な世界を作れるはずです。

※個人的にはパッケージ名にtrackが入っているので更にTrackingEventという命名をつけることに多少違和感を覚えますが、名前回避のため・・・・

import にas を使う

外部のライブラリのクラス名と衝突してしまってどうしても平和的な解決ができない場合、 こちらのドキュメントに書いてあるとおり、 as キーワードを使うことでそのファイル内で別の名前を使用することができるようになります。

package com.hatenablog.ticktakclock.model.Event

package com.hatenablog.ticktakclock.track.Event as TrackingEvent // このファイル内でのみTrackingEventとして扱える

dogwood008.github.io

まとめ

あまりこういったことに出会わないため忘れがちですがいざというときに使えるテクニックかなと思います。

とはいえ前提として名前が被らないようにクラス設計するテクニック自体も身につけていきたいところですね。

  • asキーワードで局所的に別名を指定することができる
  • asキーワード使う前にもとのクラス名をリファクタできるか考えること
  • まだまだKotlinの知らない(あまり使っていない)言語機能ありそう

apk容量削減のためにやったこと

こんにちは、tkyです。

アプリ容量が日に日に多くなっていく中、できることを探してみました。

developer.android.com

公式に削減のための方法が乗っていて、そのうちの2つをやってみました。

効果

僕が携わっているアプリプロダクトは長い間運用されているのですが、 過去の画像リソースなど使用しないものや未使用コードなどもいくつかあることを以前から確認していました。

次の公式に乗っている方法を試してみたところ 18%ほど削減できました🎉

(めっちゃ未使用リソースが多かった・・・)

ちょっとしたファイル整理だけですが、apk容量が削減されることでユーザーにもたらす影響も結構あるのでぜひ試してみると良いかもしれません。

未使用コード、未使用リソースの削除

一番着手しやすいかもしれませんね。

menu > Analyze > Inspect code で不要になっているファイルやリソースを洗い出して削除していきます。

pngからベクターDrawableにする

各種リソースを用意するとそれだけで圧迫してしまうのでベクターで書き出したリソースを使用するのが良さそうです。

実際はデザイナーと相談してデザインデータをベクターで書き出せるようにする必要があるかと思います。

material.io

上記から適当なベクター素材をDLします。

f:id:ticktakclock:20210115155924p:plain
material iconのベクター素材をDLする

AndroidStudioでメニューからNew > Vector Assetを選択します

f:id:ticktakclock:20210115160231p:plain
AndroidStudioでvector assetを選択

先程DLしたsvgを選択すると@drawable/ic_favorite_24pxでDrawableで指定することができます。

f:id:ticktakclock:20210115160254p:plain
先程DLした素材を選択

minSdkVersionを19->23にする時に考慮したこと

こんにちは、tkyです。

私が業務で担当しているAndroidのアプリサポートバージョンをAPI19->23に引き上げました。

すでにリリースから数ヶ月経過しましたが、その時にやったことなどをまとめたいと思います。

OSの分布を知る

  • 世界的なOS分布 developer.android.com
  • プロダクトのOS分布

    アナリティクスツールなどで確認

切りたいバージョンのユーザーが何%行っていたら切るか、というのは社内でもそこまで定まっておらず、他の会社で採用している5%や3%などといった数値を参考に踏み切りました。

ちなみに5系以下は全体の6%でした。そこそこいました。

社内に持ちかけ

POと話す機会は何回かあり、以前より

  • 古いOSVerでWebViewの表示不正やクラッシュがある
  • 特定の動画フォーマットが再生できない

などの問題が起こっていたことと、たまたまOSサポートバージョンの話になった時に「やりましょう」という流れになりました。

開発しやすくなるなら、という目線で快く進める方向になったのは一番大きいです。

(正直意思決定権を持つ人が納得してくれるかがサポートバージョンを上げる最難関ポイントだと思っています)

クリティカルな不具合は直しておく

目に見えてわかっている不具合は予め対応しておきましょう。後に修正できても古いOSを持つユーザーにはそれは届きません。

minsdkversionを23に引き上げる

単純にapp.gradleの記述を返るだけです。

- minSdkVersion 19
+ minSdkVersion 23

MultiDexサポートライブリは不要に

minSdkVersion が 21未満の場合、64k制限のためにMultidexオプションを付けることがありますが、

minSdkVersion が 21 以上の場合は multidex がデフォルトで有効のため、multidex サポートライブラリは必要ありません。

ApplicationクラスやGradleにmultidex関連の処理を書いている場合削除しておきましょう。

VERSION分岐コードの精査

例えば以下のようなコードがプロジェクト内に存在するとします。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    // lollipop以上でしか使えないAPIを使用するコード
    doSomething()
} else {
   // lollipop未満の場合はこちらを使う
    doSomethingLegacy()
}

minSdkVersionが23になる場合、lollipop未満の条件に当てはまることが無いので

doSomething()

だけで問題ないことになります。条件が if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) であっても同様ですね。

このように VERSION_CODESGrepをかけて削除できそうな箇所を探してリファクタリングしていきました。 Marshmallowでランタイムパーミッションなどが入っているでこういった条件分岐を書いたりしている箇所があるかもしれません。自身のプロジェクト内を探してみてください。

動作確認で何をしたか

特に何もしていませんが、検証端末に5系端末を使っていたので念の為インストール出来ないよね、という確認はしました。

次回からはこの端末もお役御免という形になりました。ありがとう。

ユーザーに事前に通知すること

運用面で考慮したことですが、事前にOSのサポートが終了となる旨をユーザーにお伝えしなければびっくりしてしまいますよね。

そのため直前のバージョンを以てサポートを終了とする旨をお知らせページなどで通知するようにしました。

ユーザーはアプデできないということを意識する

当たり前ですが、5系を切ると5系を使っているユーザーは最新Verにアプデができなくなります。

サーバーレベルでサポートを切るかどうかは別の話で、今回はアプリをアプデできないもののサービスは引き続き利用できる状態としました。

アプリ起動時「新しいバージョンのアプリがあります。アップデートしてください」というようなアプデを促すダイアログを出しているアプリは多いでしょう。

ただし5系ユーザーにこのダイアログを出すとPlay Storeに行っても勿論最新アプリはないので混乱を招いてしまいます。

実際にアプデが入ったときのユーザーの行動を事前に予測しておくことが大事だと思いました。

リリース後に起こった問題について

  • 間違ったAPIを消していた

WebViewのとあるメソッドで、TARGETがLOLLIPOPのメソッドがあったのでそもそも使われることが無いだろうというので消したコードが実はMASHMALLOW以下のメソッドだった。。

WebViewに手を入れるときは全OSバージョンチェックしたほうが良いな・・・と改めて反省・・・

まとめ

  • OSサポートバージョンを23に引き上げた
  • これでWebView関連の不具合に当たることも少なくなるといいな
  • 他にもこんなことを考慮した、みたいなのがあればぜひ教えていただきたい

Go言語でgit checkoutを補助するコマンドを作った

こんにちは、tkyです。

Go勉強してコマンドを作ってみました。

github.com

git checkout をサポートする gith というコマンドです。

f:id:ticktakclock:20201123102923p:plain
デモ1

こんな感じにブランチが選択式で表示されるので選べばそのブランチに移動できるようなものです。

リモートブランチをチェックアウトするときはチェックアウトするブランチ名を決めることができます。

f:id:ticktakclock:20201123103223p:plain
デモ2

※例えばorigin/developブランチからfeature/XXXXのようなことをできるように

主要モジュール

これがすべてです。対話式のコマンドを完成させるのに一番ラクな方法かと思います。

サンプルも結構あって実際に見て使い方の理解も深まりました。

github.com

また今回はコマンドに引数とか細かいことは何もやらないでいたために urfave/cli 使用はしませんでした。

作った経緯など

僕はSourceTreeやGitKrakenなどのGUIツールは使っておらず、CUIでブランチの移動とかcommit, pushなどをしています。

gitでブランチを切り替えながら作業している時に

「あれ〜?あのブランチ名なんだったけ〜??」

となることがよくあります。

例えば「ホーム画面のレイアウトを修正」で

  • feature/home_layout
  • feature/home_screen_layout
  • feature/update_home_layout

なんだったかな〜と・・・ブランチ名をノリで付けている弊害であとで困る典型的なパターンに陥っています。

一応運用回避としてGitHubのIssueで管理している場合、

feature/#{IssueMumber}_your_task_name

という感じでIssue番号をプレフィックスにつけておくと判別できますね。

とはいえやっぱりブランチ名調べて git checkout する必要があるのでもう少し楽にしたいな〜というところからコマンド作成に至りました。

Goを選んだ理由は「Goだとコマンドが作りやすい」「Goやったことないからやってみたい」です。

Goの勉強方法

Tour of Goやってました。基本ここで学びます。

tour.golang.org

あとはこちらのスライドで並行で理解していく感じでした。

docs.google.com

ざっくりとこの辺を理解してからコマンド作成をはじめました。

  • 変数の定義方法
  • 関数の定義方法
  • for文, if文の定義方法
  • ポインタ
  • 配列、スライス
  • 構造体

※ポインタについてはもともとC言語やってたので何も困ることはありませんでした

おまけ

.bash_profile とかにこんな感じのことを書いておくと検索文字列に引っかかった一番最初のブランチをチェックアウトする事ができます。

コマンドを作る方法は色々ありますね!

fun gitissue() {
  git branch | grep $1 -m1 | xargs git checkout 
}
$gitissue #123

みなさんも自作コマンドで開発を楽にしていきましょう!