月: 2022年10月

GoogleオススメのAndroidアプリアーキテクチャ

最近AndroidDevelopersのAndroidアプリの設計ドキュメントが頻繁に更新されています。新しくオススメの設計方法が[強く推奨]、[推奨]、[大規模なアプリで推奨]に分類されているので、一つずつ確認します。(2022年11月時点のドキュメントであり将来変更があると思います)

https://developer.android.com/topic/architecture/recommendations

階層型アーキテクチャ

  • [強く推奨] データレイヤは、アプリデータをアプリの他の部分に公開し、アプリのビジネス ロジックの大部分を含みます。

データレイヤは、アプリデータをアプリの他の部分に公開し、アプリのビジネス ロジックの大部分を含みます。

  • リポジトリは、データソースが 1 つだけの場合でも作成する必要があります。
  • 小規模なアプリでは、data パッケージやモジュール内にデータレイヤ タイプを配置できます。
  • [強く推奨]明確に定義された UI レイヤを使用します。

UI レイヤは、アプリケーション データを画面に表示するもので、ユーザー インタラクションの主要なポイントとして機能します。

  • 小規模なアプリでは、ui パッケージやモジュール内にデータレイヤ タイプを配置できます。

詳しくは、こちらの UI レイヤに関するベスト プラクティスをご覧ください

  • [強く推奨]データレイヤでは、リポジトリを使用してアプリケーション データを公開する必要があります。

コンポーザブル、アクティビティ、ViewModel などの UI レイヤのコンポーネントを、データソースと直接やり取りさせないようにします。データソースの例:

  • データベース、DataStore、SharedPreferences、Firebase API
  • GPS 位置情報プロバイダ。
  • Bluetooth データ プロバイダ。
  • ネットワーク接続ステータス プロバイダ。

コルーチンとフローを使用してレイヤ間の通信を行います。

その他のコルーチンに関するベスト プラクティスをご覧ください。

複数の ViewModel 間でデータレイヤとやり取りするビジネス ロジックを再利用する必要がある場合、または特定の ViewModel のビジネス ロジックの複雑さを簡素化したい場合に、ドメインレイヤを使用します。

UIレイヤ

単方向データフロー(UDF)の原則に従い、ViewModel はオブザーバー パターンを使用して UI の状態を公開し、メソッド呼び出しを介して UI からアクションを受け取ります。

  • [強く推奨]メリットをアプリに適用できる場合は、AAC ViewModel を使用します。

AAC ViewModel を使用してビジネス ロジックを処理し、アプリデータを取得して UI の状態を UI(Compose または Android ビュー)に公開します。

詳しくは、ViewModel のベスト プラクティスをご覧ください。

ViewModel のメリットについては、こちらをご覧ください。

  • [強く推奨]ライフサイクル対応 UI 状態コレクションを使用します。

適切なライフサイクル対応コルーチン ビルダー(UI では repeatOnLifecycle、Jetpack Compose では collectAsStateWithLifecycle)を使用して、UI から UI の状態を収集します。

詳しくは、repeatOnLifecycle をご覧ください。

詳しくは、collectAsStateWithLifecycle をご覧ください。

  • [強く推奨]ViewModel から UI にイベントを送信しないようにします。

ViewModel でイベントをすぐに処理し、イベント処理の結果で状態を更新します。UI イベントについて詳しくは、こちらをご覧ください。

  • [推奨]単一アクティビティのアプリケーションを使用します

アプリに複数の画面がある場合、Navigation フラグメントまたは Navigation Compose を使用して画面間を移動し、アプリへのディープリンクを設定します。

Jetpack Compose を使用して、スマートフォン、タブレット、折りたたみ式デバイス、Wear OS 向けの新しいアプリを作成します。

ViewModel

  • [強く推奨] ViewModel が、Android のライフサイクルに依存しないようにします。

ViewModel が、ライフサイクルに関連する型への参照を保持しないようにします。Activity, Fragment, Context または Resources を依存関係として渡さないようにします。ViewModel で Context を必要とする場合は、それが適切なレイヤにあるかどうかを強く評価する必要があります。

ViewModel は、以下を使用してデータレイヤまたはドメインレイヤとやり取りします。

  • アプリケーション データを受信するための Kotlin Flow。
  • viewModelScope を使用してアクションを実行するための suspend 関数。
  • [強く推奨]画面レベルで ViewModel を使用します。

再利用可能な UI で ViewModel を使用しないようにします。ViewModel は、以下で使用します。

  • 画面レベルのコンポーザブル。
  • View のアクティビティ / フラグメント。
  • Jetpack Navigation を使用する場合のデスティネーションまたはグラフ。

再利用可能な UI コンポーネントの複雑さに対処するには、プレーンな状態ホルダークラスを使用します。これにより、状態を外部でホイスティングして制御できるようになります。

AndroidViewModel ではなく ViewModel クラスを使用します。Application クラスは ViewModel で使用しないようにします。代わりに、依存関係を UI またはデータレイヤに移行します。

  • [推奨]UI 状態を公開します。

ViewModel が、uiState という単一のプロパティを介して UI にデータを公開する必要があります。UI に互いに関係のない複数のデータが表示されている場合、VM が複数の UI 状態プロパティを公開する可能性があります。

  • uiState を StateFlow にする必要があります。
  • データが階層の他のレイヤからのデータ ストリームとして来る場合は、stateIn 演算子と WhileSubscribed(5000) ポリシー(例)を使用して uiState を作成する必要があります。
  • データレイヤから来るデータ ストリームがない単純なケースでは、不変の StateFlow として公開される MutableStateFlow を使用できます(例)
  • ${Screen}UiState をデータクラスとして指定できます。データクラスには、データ、エラー、読み込みシグナルを含めることができます。異なる状態が排他的である場合、このクラスがシールクラスになることもあります。

Lifecycle

  • [強く推奨]ライフサイクル メソッドをオーバーライドしないようにします。

アクティビティやフラグメントの onResume などのライフサイクル メソッドをオーバーライドしないようにします。代わりに LifecycleObserver を使用します。ライフサイクルが特定の Lifecycle.State に達したときにアプリが処理を実行する必要がある場合は、repeatOnLifecycle API を使用します。

依存関係を処理する

依存関係挿入のベスト プラクティス、可能であれば特にコンストラクタ挿入のベスト プラクティスを活用してください。

  • [強く推奨]必要に応じてコンポーネントにスコープを設定します。

型が共有する必要のある可変データを含む場合、あるいは、型がアプリ内で広く使用されており、その初期化にコストがかかる場合は、依存関係コンテナにスコープを設定します。

  • [推奨]Hilt を使用します。

単純なアプリでは、Hilt または手動依存関係挿入を使用します。複雑なプロジェクトの場合は Hilt を使用します。たとえば、次の場合:

  • ViewModel を使用する複数の画面 – 統合
  • WorkManager の使用 – 統合
  • ナビゲーション グラフにスコープ設定された ViewModel など、Navigation の高度な使用 – 統合

テスト

プロジェクトが Hello World アプリのように単純なものでない限り、最低でも以下でテストする必要があります。

  • フローを含む ViewModel の単体テスト。
  • データレイヤ エンティティの単体テスト。つまり、リポジトリとデータソースです。
  • CI の回帰テストとして役立つ UI ナビゲーション テスト。
  • [強く推奨]モックよりもフェイクを優先します。

詳しくは、Android ドキュメントでテストダブルを使用するをご覧ください。

  • [強く推奨]StateFlow をテストします。

StateFlow をテストする場合:

モデル

  • [推奨]複雑なアプリではレイヤごとにモデルを作成します。

複雑なアプリでは、必要に応じて、別のレイヤやコンポーネントで新しいモデルを作成します。以下の例を考えてみましょう。

  • リモート データソースは、ネットワーク経由で受け取るモデルを、アプリが必要とするデータのみを含むシンプルなクラスにマッピングできます
  • リポジトリは、UI レイヤが必要とする情報だけで DAO モデルをシンプルなデータクラスにマッピングできます。
  • ViewModel では、UiState クラスにデータレイヤ モデルを含めることができます。

強く推奨はほぼ必須と見て差し支えないと思います。


リリースビルドしたときだけ失敗するFirebaseRemoteConfig

FirebaseRemoteConfigを使ってJsonデシリアライズしてそれをアプリ内で使うとします。

data class Data(
    val id: Long,
    @Json(name = "relation_id")
    val relationId: Long,
    @Json(name = "is_answer")
    val isAnswer: Boolean
)

このままデバッグビルドでアプリを実行するとisAnswer、relationIdが取得でき、期待した動作になりますが、リリースビルドすると正しいJSONが取得できているにも関わらずisAnswer = false、relationId = 0になってしまいます。これはリリースビルドしたときにJSONのis_answer、relation_idの値が読み取れないことで発生します。コンパイル時に難読化が作用してしまっている影響です。修正方法としては@field:JSON(name = “”)を追加するとリリースビルドでも正しくデシリアライズできます。

data class Data(
    val id: Long,
    @field:Json(name = "relation_id")
    @Json(name = "relation_id")
    val relationId: Long,
    @field:Json(name = "is_answer")
    @Json(name = "is_answer")
    val isAnswer: Boolean
)

他にもproguard-rules.proの設定でも修正できると思います。

デバッグビルドで期待動作をするのにリリースビルドすると期待動作をしない場合は難読化を疑ってみると良いかもしれません。


[Kotlin]関数の戻り値の型を書かないと発生するバグ

下記のようなコードがあるとします。

interface HasAnalogStick {
    fun lowerStick()
}
interface GameConsole

object WiiU : GameConsole, HasAnalogStick {
    override fun lowerStick() { /* スティックを倒す */ }
}
object NintendoSwitch : GameConsole, HasAnalogStick {
    override fun lowerStick() { /* スティックを倒す */ }
}

fun randomGameConsole() = listOf(WiiU, NintendoSwitch).random()

fun main() {
    val console = randomGameConsole()
    println(console.toString() + ": lower stick")
    (console as HasAnalogStick).lowerStick()
}

https://pl.kotl.in/bqS8bIsil

このコードはコンパイルが通り、実行可能ですし、fun randomGameConsole()でWiiUが返って来てもNintendoSwitchが返ってきてもメイン関数内の処理も全て実行されます。

のちにリファクタが必要になりobject Famicomとobject SuperFamicomが追加されたとします。

interface HasAnalogStick {
    fun lowerStick()
}
interface GameConsole

object Famicom : GameConsole
object SuperFamicom : GameConsole
object WiiU : GameConsole, HasAnalogStick {
    override fun lowerStick() { /* スティックを倒す */ }
}
object NintendoSwitch : GameConsole, HasAnalogStick {
    override fun lowerStick() { /* スティックを倒す */ }
}

fun randomGameConsole() = listOf(Famicom, SuperFamicom, WiiU, NintendoSwitch).random()

fun main() {
    val console = randomGameConsole()
    println(console.toString() + ": lower stick")
    (console as HasAnalogStick).lowerStick()
}

https://pl.kotl.in/7sJJg3HCT

このコードだとどうでしょうか?コンパイルが通るので一見良さそうに見えますが、実行時にfun randomGameConsole()でFamicomまたはSuperFamicomが返されるとそれらはHasAnalogStickを継承していないので、実行時エラーになります。実行しないとわからないエラーがあるのは良くないコードです。このような実行しないとわからないエラーを無くすにはfun randomGameConsole()の戻り値の型を書くようにしましょう。fun randomGameConsole(): HasAnalogStickと宣言すればコンパイル時にエラーとなるのでコードを実行しなくてもエラーがあることがわかります。

Kotlinでは関数の戻り値の型は省略することは可能ですが、特に理由が無いのであれば戻り値の型を書きましょう。


答えが分からないものを模索しながら作り続ける世界に我々は突入した。和田卓人氏による「組織に自動テストを根付かせる戦略」

ソフトウエアテストで著名なt_wada氏の発表を4つに分けてpublickeyでまとめられています。4keysにも関わってくる内容なので、とても心に刺さります。

https://publickey1.jp/blog/22/12022.html

LeanとDevOpsの科学もKindleで半額なのでオススメです(2022/10/25まで)

LeanとDevOpsの科学[Accelerate] テクノロジーの戦略的活用が組織変革を加速する impress top gearシリーズ Kindle版

https://amzn.to/3TpFwQz