- 投稿日:2021-01-19T21:33:30+09:00
Android UIテストでActivityとFragmentにコンストラクタインジェクションする
DIの仕組みが導入されていないAndroidプロダクトでのUIテストは、Activity・Fragmentからアクセスされる外部依存の差し替えをどうするかが課題になる場合があります。
テスト実行時にはアクセスしたくない依存がある場合(例:API通信)、これに当てはまります。ここではコンスタラクタとデフォルト引数を駆使しながら、テストコード上で依存を差し替えできるようにする方法について紹介します。
ActivityとFragmentのコンストラクタインジェクション
ActivityとFragmentは基本的にカスタムのコンストラクタを作成しません。
システムによって引数を持たない空のコンストラクタからインスタンスが生成されるためです。一方、カスタムコンストラクタの引数にすべてデフォルト値をセットした場合は、引数なしのコンストラクタを追加で生成します。そのコンストラクタからインスタンスを生成したときは、プロパティはデフォルト値で設定されています。
これを利用して、プロダクトコードではデフォルト引数の値を参照し、テストではコンストラクタから実装を差し替えるようにすることで、大きな改修なくUIテストでのコンストラクタインジェクションができるようにします。
テストコードでは、ActivityScenarioやFragmentScenarioといったAPIを使った際に、起動するインスタンスを差し替えできるようにします。
そのインスタンス差し替えを実現するのがInterceptingActivityFactoryとFragmentFactoryです。
InterceptingActivityFactoryを使ったActivityの起動
InterceptingActivityFactoryは、MonitoringInstrumentationというクラスにセットすることができ、このクラスはAndroidのInstrumentation Test上でのみアクセス可能です。
サンプルは次のとおりです。
// 起動したいActivity // 依存クラスをコンストラクタでうけとる // デフォルト値にはプロダクトコードで利用する依存を返すようにする class MyActivity(val dependency: Dependency = DefaultDependency()) // 以下、テストコード // InstrumentationをMonitoringInstrumentationにキャスト val monitoringInstrumentation = InstrumentationRegistry.getInstrumentation() as MonitoringInstrumentation // インスタンス差し替えのセットアップ monitoringInstrumentation.interceptActivityUsing(object : InterceptingActivityFactory { // 起動するActivityのインスタンスを返却する関数 override fun create(classLoader: ClassLoader?, className: String?, intent: Intent?): Activity { // テスト用の依存クラスをセットしたActivityのインスタンス return MyActivity(TestDependency()) } // どのクラスの場合にActivity起動の割り込みをするかをbool値で返す // trueの際に上のcreate関数が呼ばれる override fun shouldIntercept(classLoader: ClassLoader?, className: String?, intent: Intent?): Boolean { return className == MyActivity::class.java.name } }) // ActivityScenarioでActivityを起動 val intent = Intent(context, MyActivity::class.java) val scenario = launchActivity<MyActivity>(intent) scenario.onActivity { activity -> // ここで取得できるのはInterceptingActivityFactoryで差し替えたActivity }補足
- サンプルではInterceptingActivityFactoryの
create
関数内でActivityのインスタンスを作成しています。この生成処理を関数の外から渡すことは可能です。ただ、Activityのインスタンス生成はメインスレッドで実行する必要があり、テストコードはメインスレッドでは実行されません。なので、Activityを返すFunction Typeを渡して、create
関数内でinvoke
するようにすると、汎用的にすることができます- インスタンスの生成が行われたタイミングではActivityのonCreateは実行されていません。一方、依存クラスの中にはActivityからの情報が必要なものもあるかもしれません。コンストラクタで渡すのは実際の依存クラスではなくて依存クラスのFactoryにし、ライフサイクルにあったタイミングで依存クラスのインスタンスの生成を行うほうが望ましいと思います。
- Activityのインスタンス差し替えの仕組みとしてAppcomponentFactoryがありますが、こちらはAPI28以上のみ利用可となっています。
FragmentFactoryを使ったFragmentの起動
FragmentFactoryは、プロダクトコードでも使用可能なAPIで、FragmentManagerにセットすることでFragmentのインスタンス生成をコントロールすることができます。
テストコードではFragmentScenarioの引数に渡すことで利用することができます。(FragmentScenarioの内部で使われるテスト用ActivityのsupportFragmentManagerにセットされます)サンプルは次のとおりです。
// 起動したいFragment // 依存クラスをコンストラクタでうけとる // デフォルト値にはプロダクトコードで利用する依存を返すようにする class MyFragment(val dependency: Dependency = DefaultDependency()) // 以下、テストコード // インスタンス差し替えのセットアップ // FragmentScenarioの引数にセットするだけでOK val scenario = launchFragmentInContainer<MyFragment>( factory = object : FragmentFactory() { // 起動するFragmentのインスタンスを返却する関数 override fun instantiate(classLoader: ClassLoader, className: String): Fragment { if (className == MyFragment::class.java.name) { return MyFragment() } return super.instantiate(classLoader, className) } }) scenario.onFragment { activity -> // ここで取得できるのはFragmentFactoryで差し替えたFragment }まとめ
InterceptingActivityFactoryとFragmentFactoryを利用して、UIテストでActivityとFragmentのコンストラクタインジェクションを実現する方法を紹介しました。
DIライブラリは導入していないけど、UIテストを導入したいと思っているAndroidプロダクトの参考になれば幸いです。
- 投稿日:2021-01-19T21:21:12+09:00
Kotlin SAM変換(ラムダ式)を元に戻す
金田著「はじめてのandroidプログラミング 第5版」を使って、勉強しています。
「第8章タイマーを使ってスライドショーを実装する」で使っているkotlinの文法のテクニックを解析してみます。
今日のテーマは、「SAM変換を元に戻す」です。初心者にとって、ラムダ式は難しいですよね。javaのインターフェースでメソッドをひとつしか持たないものをSAMインターフェースと呼び、KolinではSAMインターフェースを引数としたメソッドを、ラムダ式で置き換えることができます。これをSAM変換といいます(本書p121から引用)。
そこで、SAM変換しているコード、変換を使わない元のコードに戻してみようと思います。
以下、本書を持っている前提で説明します。本書を使って勉強中の方、一緒に勉強しましょう。0.前提
p227まで写経が済んでいるとします。
1.原型
p227まで写経が済んでいる状態のコードを示します。説明に不要な部分は、著作権に配慮して省略しています。
MainActicity.ktclass MainActivity : AppCompatActivity() { // 省略 private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { // 省略 timer(period = 5000) { handler.post { // 省略 } } } } }2.SAM変換する前に戻す
上のソースコードはp226の下側からp227の上側に書かれたコードに従って書かれている。これをp226の下から11行目から始まるコードに従ってSAM変換する前に戻す。
class MainActivity : AppCompatActivity() { // 省略 private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { // 省略 val handler = Handler(Looper.getMainLooper()) timer(period = 5000) { handler.post ( // <==ここは丸括弧。波括弧にすると、これ以降のコードが動かなくなる。 object : Runnable { // p120で説明されているオブジェクト式 override fun run() { // 省略 } } } ) // <==ここは丸括弧 } } }Runnableインターフェースはrun()メソッドひとつだけを持つSAMインターフェースだから、SAM変換できる。
3.オブジェクト式で書かれている部分を、オブジェクト式(無名インナークラス)ではない形に戻す
p226に「Handlerクラスを使う」という水色の囲みこみがある。これに従って、無名インナークラスでない、普通のクラスとインスタンスを使ったコードに戻す。コード中に書かれた(1)から(6)のコメントに書き換えの方法を記したので順を追って読んでほしい。
MainActivity.ktprivate lateinit var binding: ActivityMainBinding // (4) (3)にあったbindingプロパティーをMainActivityクラスの外に移動(追記) class MainActivity : AppCompatActivity() { // 省略 // private lateinit var binding: ActivityMainBinding // (3)TestObjectクラスをMainActivityクラスの外にに作ったため、bindingプロパティーもMainActivityクラスの外に移動(削除) override fun onCreate(savedInstanceState: Bundle?) { // 省略 val testObject = TestObject() // (5) (2)で作ったTestObjectクラスのインスタンスを生成する。(追記) timer(period = 5000) { handler.post( // object : Runnable { // (1)Runインターフェースを実装したクラスを無名クラスでない形にするため外に出す(削除) // override fun run() { // // 省略 // } // } // } testObject // (6) (5)のインスタンスを(1)の無名クラスのインスタンスの代わりにhandler.post()メソッドに与える ) } } } private class TestObject : Runnable { // (2)(1)にあった無名クラスを、無名クラスでない普通のクラスに直す(追記) override fun run() { // 省略 } } }4.TestObjectクラスをMainActivytyクラスのネストクラスにする
最後に、今回のテーマではないが、TestObjectクラスを既にあるMainActivityクラスの中にあるネストされたMyAdapterクラス同様に、MainActivytyクラスのネストクラスにしてみる。
MainActivity.ktprivate lateinit var binding: ActivityMainBinding // (3)TestObjectクラスをMainActivityクラスのネストクラスに変更しても、bindingプロパティーはMainActivityクラスの中には入れられない。 class MainActivity : AppCompatActivity() { // 省略 class TestObject : Runnable { // (2) (1)のTestObjectクラスをネストクラスにする(移動) override fun run() { //省略 } } } override fun onCreate(savedInstanceState: Bundle?) { // 省略 val testObject = TestObject() timer(period = 5000) { handler.post( testObject ) } } } //private class TestObject : Runnable { // (1)テストクラスをMyAdapterクラスのようにMainActivityクラスのようにネストクラスにする(削除) // override fun run() { // // 省略 // } // } //}この時、(3)のbindingプロパティーは、MyAdapterクラスの中に戻すことはできない。これは興味深いことだ。おそらくクラスをjavaにデコンパイルすると、TestObjectクラスはMainActivityクラスの中にはないのであろう。AndroidStudioのデコンパイルの方法がわからなくなったため、確証が得られないのが残念である。後日追記したい。
5.TestObjectクラスのインナークラス化(2020年1月20追記)
第4項でbindingプロパティーをMainActivityクラスの中に入れられない現状が起きていていたが、これを解決する方法がインナークラス化である。インナークラス化することにより、インナークラスの外にある外部変数をインナークラス内部から参照できるようになる。このことは、本書p250の中段付近に書かれていたので本記事に反映させた。
MainActivity.kt// private lateinit var binding: ActivityMainBinding // (2)(1)でTestObjectクラスにinnerを付けたことにより、bindingプロパティーをMainActivityクラスの中に入れることができる。(削除) class MainActivity : AppCompatActivity() { // 省略 // class TestObject : Runnable { // (変更) inner class TestObject : Runnable { // (1) classの前にinnerキーワードを付ける override fun run() { // 省略 } } private lateinit var binding: ActivityMainBinding // (3)(2)で削除したbindingプロパティーをMainActivityクラスの中に入れる(追加)。 override fun onCreate(savedInstanceState: Bundle?) { // 省略 } }
- 投稿日:2021-01-19T20:34:46+09:00
【Android】APIのレベルによるエラーの対処方法
プログラミング勉強日記
2021年1月19日
最近Android開発をしていて、普段と違うAndroid端末を利用したときにエラーが出た。その解決方法を示す。エラー内容
Android端末のAPIよりもプロジェクトのAPIの方が新しく、このエラーが出た。なので、プロジェクトのAPIをAndroid端末のAPIに合わせることで解決する。
Session 'app': Installation did not succeed. The application could not be installed: INSTALL_FAILED_OLDER_SDK解決方法
1. 必要なSDKをインストールする
ツール→SDKマネージャー→Android SDK
から選択して端末と同じSDKをインストールする。2. SDKを適用する
ファイル→プロジェクト構造→モジュール→デフォルト構造
の最小SDKバージョン
にAndroid端末と同じSDKを適用する。
- 投稿日:2021-01-19T17:34:16+09:00
Android QのScoped StorageでJPEG/MPEG撮影→外部Storageに保存→EXIF編集
TL;DR
Android QからScoped StorageのFeatureによってCameraで撮影したJPEG/MPEGを外部ストレージ直下の任意Directoryに保存することができなくなりました.
DCIM
やPicture
/Video
のような汎用Directoryに保存することになりますが,それでもAndroid PまでのようにExternal StorageのPathに直接ファイルを保存することはできません.そのため,
MediaStore
(ContentProvider
/ContentResolver
)を使ってExternal Storageの汎用Directoryにファイルを保存するようにします.撮影したJPEGのEXIFを
ExifInterface
で自力で編集している場合も直接External StorageのPathからは読み書きできないので,MediaStore
経由で読み書きするようにします.Permissionについて
Android PまではExternal Storageへの保存のために,↓ の宣言が必要でしたが,
MediaStore
経由で保存するようにするため,必要なくなりました.AndroidManifest.xml<!-- Android Q では無視される.SettingsのApp権限画面にも出てこない --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />JPEG保存 + EXIF編集
ざっくりの流れは ↓ のような感じです.
1. MediaStoreに保存するJPEGファイルのURIを作成 (作成中Flagを立てておく) ↓ 2. URIのOutputStreamにファイルを書き込み ↓ 3. ExifInterfaceからURIのFileDescriptorを読み込み/編集/書き込み ↓ 4. URIの作成中Flagを落とす ↓ 5. MediaStore経由で他App(Album等)からJPEGが見えるようになる1. 保存するJPEGファイルのURIを作成 (作成中Flagを立てておく)
// @param context ApplicationのContext // @param fileName 保存するFile名 (拡張子なし) // @return 書き込み可能なURI fun openJpegUri(context: Context, fileName: String): Uri? { val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "$fileName.jpg") put(MediaStore.Images.Media.TITLE, fileName) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM) // 保存先Directory Pathの指定 put(MediaStore.Images.Media.IS_PENDING, 1) // 保存中Flag } val storageUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val contentUri: Uri? = context.contentResolver.insert(storageUri, values) return contentUri }
MediaStore.Images.Media.RELATIVE_PATH
の値でSub Directoryを作成するか,など制御できます.
MediaStore
以下のどのclassのgetContentUri()
を使うかで,RELATIVE_PATH
に指定するtop directoryに何を使えるか,があらかじめ決まっているようです.
(たとえば,MediaStore.Images.Media.getContentUri()
だとpicture/**
かDCIM/**
のどちらかしか使えない,使うと例外発生)2. URIのOutputStreamにファイルを書き込み
// @param context ApplicationのContext // @param jpeg 撮影したJPEGファイルのbyte配列 // @return 書き込み成否 fun storeJpeg(context: Context, uri: Uri, jpeg: ByteArray): Boolean { val os = context.contentResolver.openOutputStream(uri) if (os != null) { os.write(jpeg) os.flush() os.close() } else { return false } return true }3. ExifInterfaceからURIのFileDescriptorを読み込み/編集/書き込み
// @param context ApplicationのContext // @param uri 保存したJPEGのURI fun modJpegExif(context: Context, uri: Uri) { val fd = context.contentResolver.openFileDescriptor(uri, "rw")?.fileDescriptor if (fd != null) { val exif = ExifInterface(fd) // setGpfInfo()などでEXIF情報を編集 exif.saveAttributes() } }
ExifInterface
の中で,
FDからJPEG読込 → appのcache directoryに保存/EXIF編集 → FDに編集後のJPEG書込
という処理が行われているので,”rw”モードでopenしなければ書込時に例外発生で失敗します.URIに
IS_PENDING == 1
の保存中Flagが立っていないと,他のApp/ServiceかMediaScannerがFileを開いてしまうのか,
openFileDescriptor()
で返ってきたFDが書き込み時にEBADFD
の例外発生で失敗してしまうことがあります.詳しく追ってないですが,
IS_PENDING
の値を true/false にしたときもEBADFD
のエラーが発生していました.boolean値が入りそうな名前ですが,この例のとおり 1/0 でないとダメかもしれません,
公式Docのサンプルでは 1/0 になっています.4. URIの作成中Flagを落とす
// @param context ApplicationのContext // @param uri 保存したJPEGのURI fun closeJpegUri(context: Context, uri: Uri) { val values = ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) } context.contentResolver.update(uri, values, null, null) }5. MediaStore経由で他App(Album等)からJPEGが見えるようになる
IS_PENDING == 0
をセットして以降,↓ のようなコードでDCIM/
以下に保存したJPEGが見えるようになります.val targetUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val projection: Array<String> = arrayOf( MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.RELATIVE_PATH, ) val cursor = context.contentResolver.query( targetUri, projection, "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?", arrayOf("${Environment.DIRECTORY_DCIM}/%"), null) if (cursor != null) { val displayNameIndex = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) val relPathIndex = cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH) if (cursor.count > 0) { cursor.moveToFirst() while (!cursor.isAfterLast) { val name = cursor.getString(displayNameIndex) val relPath = cursor.getString(relPathIndex) // $relpath$name == "DCIM/xxxx.jpg" cursor.moveToNext() } } cursor.close() }MPEG保存
MPEGの場合もJPEGとやることはほぼ同じです.
1. MediaStoreに保存するMPEGファイルのURIを作成 (作成中Flagを立てておく) ↓ 2. URIのFileDescriptorにMediaRecorder/MediaMuxerから書き込み ↓ 3. URIの作成中Flagを落とす1. MediaStoreに保存するMPEGファイルのURIを作成 (作成中Flagを立てておく)
fun openMpegUri(context: Context, fileName: String): Uri? { val values = ContentValues().apply { put(MediaStore.Video.Media.DISPLAY_NAME, "$fileName.mp4") put(MediaStore.Video.Media.TITLE, fileName) put(MediaStore.Video.Media.MIME_TYPE, "video/avc") put(MediaStore.Video.Media.DATE_ADDED, System.currentTimeMillis() / 1000) put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM) // 保存先Directory Pathの指定 put(MediaStore.Video.Media.IS_PENDING, 1) // 保存中Flag } val storageUri = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val contentUri: Uri? = context.contentResolver.insert(storageUri, values) return contentUri }2. URIのFileDescriptorにMediaRecorder/MediaMuxerから書き込み
// MediaRecorderの場合 val fd = context.contentResolver.openFileDescriptor(mpegUri, "rw")?.fileDescriptor if (fd != null) { val recorder = MediaRecorder() recorder.setOutputFormat() recorder.setOutputFile(fd) recorder.prepare() } // MediaMuxerの場合 val fd = context.contentResolver.openFileDescriptor(mpegUri, "rw")?.fileDescriptor if (fd != null) { val mpegMuxer = MediaMuxer( fd, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) }3. URIの作成中Flagを落とす
fun closeMpegUri(context: Context, uri: Uri) { val values = ContentValues().apply { put(MediaStore.Video.Media.IS_PENDING, 0) } context.contentResolver.update(uri, values, null, null) }おわり
JPEG/MPEGの撮影/保存をAndroid Q Scoped Storageに対応させました.
Android QではまだLegacy Supportで回避できるようですが,Android Rでは完全対応必須という話もあるようなので,このあたりで完全対応しておく必要がありそうです.AndroidはOS UpdateのたびにStorageまわりのAPIの挙動も仕様もガラガラ変わって大変でしたが,Scoped Storageでだいぶ抽象化がすすんだので,これでしばらくは安定してくれるかなあ.
---///
- 投稿日:2021-01-19T10:33:01+09:00
HiltViewModelでAbstractなViewModelをInjectしてみるチャレンジ
ちょっとHiltViewModelで何ができて何ができないのかわからないので調べてみましょう。
HiltViewModelは基本的にはこういう感じで利用できます。
val viewModelHilt: VideoPlayerHiltViewModel by viewModels()@HiltViewModel class VideoPlayerHiltViewModel @Inject constructor(val videoPlayer: VideoPlayer) : ViewModel() { }https://github.com/takahirom/hilt-sample-app のComponent図です。(arunkumar9t2/scabbardによる生成 )どうやらActivityRetainedComponentの下にViewModelのComponentがいるようですね。
上記のViewModelを追加したときのViewModel Componentの図です。一体何が起こっているんでしょうか?
以下のように利用しているとしましょう。
@AndroidEntryPoint class PlayerFragment : Fragment(R.layout.fragmenet_player) { val viewModelHilt: VideoPlayerHiltViewModel by viewModels()Hiltは以下のようなクラスを生成して、それをsuperクラスとして利用します。どうやらデフォルトのFactoryがいるようです。by viewModels()はこのFacotryをデフォルトで利用しています。
public abstract class Hilt_PlayerFragment extends Fragment implements GeneratedComponentManagerHolder { .... @Override public ViewModelProvider.Factory getDefaultViewModelProviderFactory() { return DefaultViewModelFactories.getFragmentFactory(this); } }DefaultViewModelFactories.getFragmentFactoryはどうなっているでしょうか?
public static ViewModelProvider.Factory getFragmentFactory(Fragment fragment) { return EntryPoints.get(fragment, FragmentEntryPoint.class) .getHiltInternalFactoryFactory() .fromFragment(fragment); }どうやらInternalFactoryFactoryというのをDaggerのComponentから作るようです。
@EntryPoint @InstallIn(FragmentComponent.class) interface FragmentEntryPoint { InternalFactoryFactory getHiltInternalFactoryFactory(); }InternalFactoryFactoryのコンストラクタには
@Inject
がついており、Daggerが自動的にインスタンスを作ることができます。
基本的にはHiltViewModelFactoryを作るだけです。/** Internal factory for the Hilt ViewModel Factory. */ public static final class InternalFactoryFactory { private final Application application; private final Set<String> keySet; private final ViewModelComponentBuilder viewModelComponentBuilder; @Nullable private final ViewModelProvider.Factory defaultActivityFactory; @Nullable private final ViewModelProvider.Factory defaultFragmentFactory; @Inject InternalFactoryFactory( Application application, @HiltViewModelMap.KeySet Set<String> keySet, ViewModelComponentBuilder viewModelComponentBuilder, // These default factory bindings are temporary for the transition of deprecating // the Hilt ViewModel extension for the built-in support @DefaultActivityViewModelFactory Set<ViewModelProvider.Factory> defaultActivityFactorySet, @DefaultFragmentViewModelFactory Set<ViewModelProvider.Factory> defaultFragmentFactorySet) { this.application = application; this.keySet = keySet; this.viewModelComponentBuilder = viewModelComponentBuilder; this.defaultActivityFactory = getFactoryFromSet(defaultActivityFactorySet); this.defaultFragmentFactory = getFactoryFromSet(defaultFragmentFactorySet); } ViewModelProvider.Factory fromActivity(ComponentActivity activity) { return getHiltViewModelFactory(activity, activity.getIntent() != null ? activity.getIntent().getExtras() : null, defaultActivityFactory); } ViewModelProvider.Factory fromFragment(Fragment fragment) { return getHiltViewModelFactory(fragment, fragment.getArguments(), defaultFragmentFactory); } private ViewModelProvider.Factory getHiltViewModelFactory( SavedStateRegistryOwner owner, @Nullable Bundle defaultArgs, @Nullable ViewModelProvider.Factory extensionDelegate) { ViewModelProvider.Factory delegate = extensionDelegate == null ? new SavedStateViewModelFactory(application, owner, defaultArgs) : extensionDelegate; return new HiltViewModelFactory( owner, defaultArgs, keySet, delegate, viewModelComponentBuilder); } @Nullable private static ViewModelProvider.Factory getFactoryFromSet(Set<ViewModelProvider.Factory> set) { // A multibinding set is used instead of BindsOptionalOf because Optional is not available in // Android until API 24 and we don't want to have Guava as a transitive dependency. if (set.isEmpty()) { return null; } if (set.size() > 1) { throw new IllegalStateException( "At most one default view model factory is expected. Found " + set); } ViewModelProvider.Factory factory = set.iterator().next(); if (factory == null) { throw new IllegalStateException("Default view model factory must not be null."); } return factory; } }HiltViewModelFactoryは以下です。基本的に
ViewModelFactoriesEntryPoint
からViewModelのProviderを取得します。public final class HiltViewModelFactory implements ViewModelProvider.Factory { @EntryPoint @InstallIn(ViewModelComponent.class) interface ViewModelFactoriesEntryPoint { @HiltViewModelMap Map<String, Provider<ViewModel>> getHiltViewModelMap(); } /** Hilt module for providing the empty multi-binding map of ViewModels. */ @Module @InstallIn(ViewModelComponent.class) interface ViewModelModule { @Multibinds @HiltViewModelMap Map<String, ViewModel> hiltViewModelMap(); } private final Set<String> viewModelInjectKeys; private final ViewModelProvider.Factory delegateFactory; private final AbstractSavedStateViewModelFactory viewModelInjectFactory; public HiltViewModelFactory( @NonNull SavedStateRegistryOwner owner, @Nullable Bundle defaultArgs, @NonNull Set<String> viewModelInjectKeys, @NonNull ViewModelProvider.Factory delegateFactory, @NonNull ViewModelComponentBuilder viewModelComponentBuilder) { this.viewModelInjectKeys = viewModelInjectKeys; this.delegateFactory = delegateFactory; this.viewModelInjectFactory = new AbstractSavedStateViewModelFactory(owner, defaultArgs) { @NonNull @Override @SuppressWarnings("unchecked") protected <T extends ViewModel> T create( @NonNull String key, @NonNull Class<T> modelClass, @NonNull SavedStateHandle handle) { ViewModelComponent component = viewModelComponentBuilder.savedStateHandle(handle).build(); Provider<? extends ViewModel> provider = EntryPoints.get(component, ViewModelFactoriesEntryPoint.class) .getHiltViewModelMap() .get(modelClass.getName()); if (provider == null) { throw new IllegalStateException( "Expected the @ViewModelInject-annotated class '" + modelClass.getName() + "' to be available in the multi-binding of " + "@ViewModelInjectMap but none was found."); } return (T) provider.get(); } }; } @NonNull @Override public <T extends ViewModel> T create(@NonNull Class<T> modelClass) { if (viewModelInjectKeys.contains(modelClass.getName())) { return viewModelInjectFactory.create(modelClass); } else { return delegateFactory.create(modelClass); } } }そしてHiltViewModelMapが登場します。
@EntryPoint @InstallIn(ViewModelComponent.class) interface ViewModelFactoriesEntryPoint { @HiltViewModelMap Map<String, Provider<ViewModel>> getHiltViewModelMap(); }もう一度図を見てみましょう。どうやらBindsModuleがViewModelの実際のクラスとViewModelを紐付けているようですね。
@Module @InstallIn(ViewModelComponent.class) public abstract static class BindsModule { @Binds @IntoMap @StringKey("com.github.takahirom.hiltsample.VideoPlayerHiltViewModel") @HiltViewModelMap public abstract ViewModel binds(VideoPlayerHiltViewModel vm); }このようにすることで、ViewModelのインスタンスとHiltを結びつけているようです。
つまり、、AbstractなViewModelをInjectできるのでは。。?そして、AssitedInjectも共存できるのでは。。?これによってテストとかで置き換えたりとかできたりするんじゃ??
結論からいうとできましたが、@StringKey
でクラス名を指定したりなど、結構自動生成でない書き方は厳しい感じで、しかも渡したい引数もFragmentのインスタンスなどがないと参照できないため、ViewModelのスコープでは参照できないため、あまり意味がありませんでした。
https://github.com/takahirom/hilt-sample-app/commit/a93fd366fb6929a927bfc936c0d71fdace4738af@AndroidEntryPoint class PlayerFragment : Fragment(R.layout.fragmenet_player) { private val videoPlayerViewModel: AbstractVideoPlayerViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) videoPlayerViewModel.play() } } @Module @InstallIn(ViewModelComponent::class) abstract class BindsModule { @Binds @IntoMap @StringKey("com.github.takahirom.hiltsample.AbstractVideoPlayerViewModel") @HiltViewModelMap abstract fun binds(viewModel: AbstractVideoPlayerViewModel): ViewModel } @Module @InstallIn(ActivityRetainedComponent::class) object KeyModule { @Provides @IntoSet @HiltViewModelMap.KeySet fun provide(): String { return "com.github.takahirom.hiltsample.AbstractVideoPlayerViewModel" } } @Module @InstallIn(ViewModelComponent::class) class FactoryModule { @Provides fun provide(factory: VideoPlayerViewModel.Factory): AbstractVideoPlayerViewModel { return factory.create("?????") } } abstract class AbstractVideoPlayerViewModel : ViewModel() { abstract fun play() abstract fun isPlaying(): Boolean } class VideoPlayerViewModel @AssistedInject constructor( private val videoPlayer: VideoPlayer, @Assisted private val videoId: String ) : AbstractVideoPlayerViewModel() { ...書いた後に知ったのですが、Assited Inject + HiltViewModelは以下で議論中みたいで、もしかすると後々実装されたりするかもです。
https://github.com/google/dagger/issues/2287
- 投稿日:2021-01-19T09:25:16+09:00
Dagger 2.31のAssisted Inject
例えば、動画を再生しているアプリで、このVideoのIDの動画を再生したい!みたいなときにViewModelにidを渡したくなったりしますよね?そしてそのViewModelにRepositoryとかのインスタンスをinjectしつつ、videoIdを渡してvideoIdがNullableであるとかlateinitであるとかを意識せずに簡単に使えるようにしたいですよね。これができるのがAssited Injectです。
これを使うとコンストラクタに自分で指定した引数を追加することができるのでNullableやlateinitにせずに引数を追加できます。
この用途に、これまでsquare/AssistedInjectを使っていましたが、Dagger 2.31でこの機能が追加されました。コード
基本的にinterfaceでFactoryを作ってそれに
@AssistedFactory
をつけて、そこに渡したい引数を追加したメソッドを追加し、作りたいクラスのコンストラクタに@AssistedInject
をつけて、カスタマイズしたい引数に@Assisted
をつけるだけです。class VideoPlayerViewModel @AssistedInject constructor( private val videoPlayer: VideoPlayer, @Assisted private val videoId: String ) : ViewModel() { fun play() { videoPlayer.play() } @AssistedFactory interface Factory { fun create(videoId: String): VideoPlayerViewModel } }そして、FactoryをActivityやFragmentで以下のようにinjectさせてあげるだけです。
@Inject lateinit var videoPlayerViewModelAssistedFactory: VideoPlayerViewModel.FactoryあとはvideoPlayerViewModelAssistedFactory.create(videoId)とかで呼び出せばViewModelが作れます。
Assisted Injectの説明としてはこれで問題ないのですが、注意が必要な点として、ViewModelは自由にインスタンスを作っていいものではなく、画面回転でもViewModelStoreで保持されるようにする必要があるため、FragmentなどのProviderを利用して取得する必要があるということです。サンプル
square/AssistedInjetから移行する完全なサンプルは以下です。以下では
by viewModels{}
を使うことでViewModelStoreで保持させつつ、AssitedInjectを利用しています。
square/AssistedInjetと違う点としてAssistedModule
というのを作る必要がなくなったので非常にシンプルに利用できるようになったので、とても使いやすいです。
https://github.com/takahirom/hilt-sample-app/commit/f54e58d72057e8d81921fa0d4e3bcfe809e9c5b9