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 で配信予定で、アーカイブもあるので当日参加できない方もあとからご視聴いただけます。

Target SDK Version を 31 に上げるときに引っかかるポイント: SparseArray

Kotlin を使用したアプリケーションという前提で、次のような構成のアプリがあり、このアプリの Target SDK Version および Compile SDK Version を 31 (Android 12) に上げることを考えます。

Target SDK Version を上げる前の構成: build.gradle

android {
    compileSdk 30

    defaultConfig {
        applicationId "dev.keithyokoma.sample"
        minSdk 23
        targetSdk 30
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.6.0'
}

SparseArray に値をセットする

上記の構成のとき、次のようなコードは正しく意図通り動作します。 注目すべきは SparseArray に値をセットするコードです。AndroidX Core KTX の set 拡張関数 を使うことで、メソッド呼び出しではなく指定した添字への値の代入のように記述できます。

import androidx.core.util.set

val array = SparseArray<String>()
array[0] = "test" // AndroidX Core KTX の set 拡張関数を使っている

Target SDK Version および Compile SDK Version を 31 にあげる

ここで構成を変え、Target SDK Version と Compile SDK Version を最新の 31 に上げます。 するとビルド時にエラーが出力されます。

Call requires API level S (current min is 23): android.util.SparseArray#set

エラーメッセージが示す関数が androidx.core.util.set ではなく android.util.SparseArray#set になっています。これは、Android 12 (API Level 31) から新たに SparseArray#set メソッドが追加されたためです。 この新しい SparseArray#set メソッドを Kotlin で解釈したとき、Java で定義されたメソッドを添え字での値の代入として扱えるようにする仕組みが働き、array[0] = "test" はそのまま正しくコンパイル可能なコードになります。 同時に AndroidX Core KTX の set 拡張関数が使われなくなり(31 に上げると、上記 Kotlin のコードの import 文が unused になります)、minSdkVersion = 31 を要求する新しいメソッドを使っていることになるため、このようなエラーになります。

回避策

Target SDK Version および Compile SDK Version を 31 にした状態では拡張関数が利用できないので、次のように拡張関数が呼び出していた SparseArray#put を使います。

val array = SparseArray<String>()
array.put(0, "test")

類似のケース

実は Android 11 (API 30) 対応でも SparseArray の使用方法で実行時にエラーとなるケースがあったようです。

https://issuetracker.google.com/issues/168724187?pli=1

BottomNavigationView で setupWithNavController と setOnNavigationItemSelectedListener を同時に使いたい

Android Jetpack の Navigation Component と BottomNavigationView を組み合わせる場合、setupWithNavController 拡張関数を呼びだすだけで nav graph と BottomNavigationView の menu を紐付けてくれるようになります。

val navView: BottomNavigationView = ...
val navController: NavController = ...

navView.setupWithNavController(navController)

ここで、BottomNavigationView の menu を選択したときにコールバックを受けたい場合、次のように setOnNavigationItemSelectedListener を使ったコードを書くと BottomNavigationView の menu と nav graph の紐付けが壊れて画面の切り替えができなくなります。 setupWithNavController 拡張関数は内部で setOnNavigationItemSelectedListener を使って BottomNavigationView の menu を選択したときの処理を記述しているため、次のコードはその処理を上書きしてしまい、その結果画面の切り替えができなくなります。

val navView: BottomNavigationView = ...
val navController: NavController = ...

navView.setupWithNavController(navController)
navView.setOnNavigationItemSelectedListener { menu ->
  // Callback
  Log.d("Sample", "$menu is selected!!!")
  true
}

NavController の addOnDestinationChangedListener を使って各 menu に対応する destination に切り替わったことを検知するロジックを作ると、BottomNavigationMenu の menu を選択したときのコールバックと同等の機能が実現できます*1。 もし setOnNavigationItemSelectedListener を使いたい場合は、次のように setupWithNavController の実装を持ってくる必要があります。

val navView: BottomNavigationView = ...
val navController: NavController = ...

navView.setupWithNavController(navController)
navView.setOnNavigationItemSelectedListener { menu ->
  // Callback
  Log.d("Sample", "$menu is selected!!!")  
  NavigationUI.onNavDestinationSelected(menu, navController) // この部分が setupWithNavController でやっていること
}

Hilt 1.0.0 へのマイグレーション

Google I/O 2021 で Hilt がついに安定版に到達し、1.0.0 がリリースとなったことが告知されました。

この記事を執筆時点で Dagger Hilt および AndroidX Hilt の最新版は次のとおりです。

Dagger Hilt: 2.36 AndroidX Hilt: 1.0.0

それぞれにコンポーネントがあり別々のバージョン番号があるので少し分かりづらい状況になっていますが、少なくとも Hilt が安定版となったのは Dagger Hilt 2.35 からであることに特に注意します。

バージョンアップにともなうマイグレーション作業

ここでは主に Dagger Hilt 2.34 より前からのマイグレーション作業にフォーカスして記述します。

Dagger Hilt 2.34: @ViewModelInject の置き換え

@ViewModelInject が廃止され、@HiltViewModel に置き換わりました。これにともなって、コンストラクタに @Inject アノテーションをつける必要があります。

@HiltViewModel // @ViewModelInject ではなく @HiltViewModel を使い、コンストラクタに @Inject をつける
class SampleViewModel @Inject constructor(
  ...
) : ViewModel()

Dagger Hilt 2.34: SavedStateHandle のための @Assisted の削除

ViewModelSavedStateHandle を inject するために利用していた @Assisted が不要になりました。単純に削除するだけで OK です。

@HiltViewModel
class SampleViewModel @Inject constructor(
  ...
  savedStateHandle: SavedStateHandle, // @Assisted を消す
) : ViewModel()

Dagger Hilt 2.34: androidx.hilt:hilt-lifecycle-viewmodel への依存の削除

AndroidX Hilt には ViewModel 対応のためのアーティファクトとして androidx.hilt:hilt-lifecycle-viewmodel が用意されていますが、安定版では必要ないため削除します。Google Maven Repository には 1.0.0-alpha-03 までのバージョンがアップロードされていますが、純粋に必要なくなったため 1.0.0 のリリースはありません。依存を削除しましょう。 (AndroidX Hilt 側のリリースノートではなく Dagger Hilt 側のリリースノートに記述があるためすこし紛らわしいですが…… twitter を検索すると gerrit code review で androidx.hilt:hilt-lifecycle-viewmodel への依存を切るための差分がサブミットされている様子も見つかります。)

Dagger Hilt 2.36: Fragment#getContext の振る舞いの修正

これまで、Hilt において Fragment#getContext がうっかり Framgent が削除されたあとでもContext インスタンスを返していました。これは通常の Fragment とは異なる動きであり、通常の Fragment のように振る舞うことが本来の動作であったため、2.36 で修正が入ります。ただし、この修正には相当のインパクトが見込まれるため、 -Adagger.hilt.android.useFragmentGetContextFix=true をつかって feature flag を有効にしない限り、2.36 でも以前のバージョンと同じく Hilt の Fragment#getContextFragment が削除されたあとでも Context インスタンスを返します。

Deprecated アノテーションが deprecated になってしまったのを undeprecated した流れ

Android 12 の preview 段階で @Deprecated アノテーションが deprecated になったのがちょっと話題になりましたが、Android 12 Beta 1 では deprecated になったのを undeprecated にしたことが What's new in Android で語られました。

youtu.be

@Deprecated アノテーションは少し特殊で、Javadoc に @deprecated タグを記述することでもクラスやメソッドなどが非推奨であることを示します。 Android 12 では @Deprecated アノテーションに変更があり、forRemoval メソッドと since メソッドが増えています。これらを用いて、非推奨となったクラスやメソッドが将来的に削除予定かどうかを簡単に示せるようになります。

developer.android.com

このメソッドの追加にあわせて @Deprecated アノテーションの Javadoc も拡充されていて、より詳しく @Deprecated アノテーションの役割や挙動を説明するようになりました。 ここで Javadoc 内の @Deprecated アノテーションを表記するために {@code @Deprecated} と言う形でコードブロックの記法を使うようになりましたが、このコードブロック内の @Deprecated が Javadoc の @deprecated タグと解釈され、結果として @Deprecated アノテーションそのものが deprecated と解釈されてしまった、というのが @Deprecated アノテーションが deprecated となった経緯のようです。

うっかり deprecated になってしまった @Deprecated を undeprecated にするため、コードブロック内であっても @ をエスケープする(&#64;にする)差分が作られ、晴れて undeprecated することができたようです。

val age = 0x20

やったね僕も二十歳(16進数)になりました!

去年の自分はなにか目標でも立ててたのかと思って振り返ったら何もありませんでした。代わりに DroidKaigi がコロナ禍の始まりとかちあって中止となったことで、それまでのいろいろな準備をせめて何らかの形でアウトプットしようという供養エントリを書いていました。

blog.keithyokoma.dev

まだまだコロナ禍は収まる気配はなく、DroidKaigi も平年のような大きな会場を利用しての開催予定をたてるには難しい状況ですが、Podcast を始めたり2021年版公式アプリを公開したりと少しづつ活動の幅を広げてきています。

自分自身はというと、去年は思ってもいませんでしたが新しい会社で働いていて引き続き Android アプリをもりもり作っています。 今は Android エンジニアが自分ひとりということもあり、とにかく Android アプリの実装に必要な判断を自分ひとりでやっている状況なので、はやくコードそのものだけでなく設計やら作り方など含めてレビューしてくれる仲間を求めています。もちろん単にアプリを作るだけじゃなくて、いろんなアイディアを話し合う仲間でもあってほしいですが。 スタートアップということでまだまだ足りないものだらけですが、着実に前進していて楽しく仕事をしています。新しいもの、足りてないものをもりもり作るのが好きな人は声をかけてもらえれば、いつでもお話できます。

あとはクラウドファンディングでの技術書執筆プロジェクトもすすめています。リリースがだいぶ近づいており、佳境という感じです。絶対にリリースするぞ!

干し芋

ViewBinding を用いた View の操作をユニットテストする

ViewBinding を使うと View のアクセスを簡略化 & @NonNull を標準として堅牢にロジックを作れるようになります。 ここでこの ViewBinding を用いて次のような View の操作をメソッドとして定義したクラスを作ったとして、そのクラスにあるメソッドをユニットテストすることを考えてみます。

例えば、次のような TextView がおいてあるレイアウトを定義してみたとします。

<ConstraintLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  >

  <TextView
    android:id="@+id/sample_text"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    />

</ConstraintLayout>

ViewBinding では上記の XML から FragmentSampleBinding のような名前の ViewBinding クラスが生成されます。これをつかって View の操作を司るクラスを次のように定義してみます(特に MVP パターンを推奨するつもりはなく、ただ View の操作を司るクラスの命名として Presenter を用いています)。

class SamplePresenter(
  private val binding: FragmentSampleBinding
) {

  fun setSampleText(text: String) {
    binding.sampleText.text = text
  }
}

シンプルですね。findViewById の必要がなくなり、かつ Null Safe に View へアクセスできます。

ここで setSampleText 関数に渡した文字列が sample_text を ID とした TextView のテキストとして設定されることをユニットテストしてみます。 ユニットテストでは Robolectric を使わず、モックフレームワークとして mockk を使う場面を想定しています。

ユニットテストの準備として FragmentSampleBinding をうまくモックするとテストの見通しがよくなりますが、 ViewBinding で自動生成されるクラスの構成の問題で素直にモックが生成できません。

次にその自動生成コードを示しますが、モックの生成で問題となるのは各 View のフィールドです。 mockk ではモック対象のクラスが持っているフィールドを mock できず、かつ ViewBinding が持っている View のフィールドは final なのであとから代入することもできません。

(${module}/build/generated/data_binding_base_class_source_out/ 配下に生成されます)

public final class FragmentHomeBinding implements ViewBinding {
  @NonNull
  private final ConstraintLayout rootView;

  @NonNull
  public final TextView sampleText;
}

これを回避するには、ViewBinding クラスが持っている bind メソッドを経由して各フィールドに mock を差し込みます。

bind メソッドは次のような実装になっています。

public final class FragmentHomeBinding implements ViewBinding {

  @NonNull
  public static FragmentHomeBinding bind(@NonNull View rootView) {
    int id;
    missingId: {
      id = R.id.sample_text;
      TextView sampleText = rootView.findViewById(id);
      if (sampleText == null) {
        break missingId;
      }

      return new FragmentSampleBinding((ConstraintLayout) rootView, sampleText);
    }
    String missingId = rootView.getResources().getResourceName(id);
    throw new NullPointerException("Missing required view with ID: ".concat(missingId));
  }
}

この定義をもとに mock を差し込む方法を次に示します。 bind メソッドに渡す rootView は ConstraintLayout に明示的にキャストされるため、mock するときも ConstraintLayout としてモックを作ります。また基本的に XML 内の View はすべて rootView から探索するため、 XML 上の親子関係によらずレイアウト内の View をすべて rootView から返すようにモックします。

class SamplePresenterTest : BehaviorSpec() {
  init {
    Given("SamplePresenter") {
      val sampleText: TextView = mockk()
      val rootView: ConstraintLayout = mockk {
        every { findViewById<TextView>(eq(R.id.sample_text)) } returns sampleText
      }
      val binding = FragmentSampleBinding = FragmentSampleBinding.bind(rootView)
      val presenter = SamplePresenter()

      When("Set text to sample_text view") {
        presenter.setSampleText("foo")

        Then("`foo` is set") {
          verify { sampleText.text = eq("foo") }
        }
      }
    }
  }
}

レイアウト内にある要素が増えるとその分だけ View のモックが手間になりますが、モックを作るファクトリメソッドを定義しておき 必要に応じてテストケース固有のモックを差し込めるようにしておくと潰しの効く構成になります。