20210119のAndroidに関する記事は6件です。

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プロダクトの参考になれば幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin SAM変換(ラムダ式)を元に戻す

 金田著「はじめてのandroidプログラミング 第5版」を使って、勉強しています。

 「第8章タイマーを使ってスライドショーを実装する」で使っているkotlinの文法のテクニックを解析してみます。
 今日のテーマは、「SAM変換を元に戻す」です。初心者にとって、ラムダ式は難しいですよね。javaのインターフェースでメソッドをひとつしか持たないものをSAMインターフェースと呼び、KolinではSAMインターフェースを引数としたメソッドを、ラムダ式で置き換えることができます。これをSAM変換といいます(本書p121から引用)。
 そこで、SAM変換しているコード、変換を使わない元のコードに戻してみようと思います。
 
 以下、本書を持っている前提で説明します。本書を使って勉強中の方、一緒に勉強しましょう。

0.前提

 p227まで写経が済んでいるとします。

1.原型

 p227まで写経が済んでいる状態のコードを示します。説明に不要な部分は、著作権に配慮して省略しています。

MainActicity.kt
class 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.kt
private 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.kt
private 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?) {
    // 省略
    }
}


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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をインストールする。

image.png

image.png

2. SDKを適用する

 ファイル→プロジェクト構造→モジュール→デフォルト構造最小SDKバージョンにAndroid端末と同じSDKを適用する。

image.png

image.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Android QのScoped StorageでJPEG/MPEG撮影→外部Storageに保存→EXIF編集

TL;DR

Android QからScoped StorageのFeatureによってCameraで撮影したJPEG/MPEGを外部ストレージ直下の任意Directoryに保存することができなくなりました.

DCIMPicture/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でだいぶ抽象化がすすんだので,これでしばらくは安定してくれるかなあ.

---///

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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がいるようですね。
image.png

上記のViewModelを追加したときのViewModel Componentの図です。一体何が起こっているんでしょうか?

image.png

以下のように利用しているとしましょう。

@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を紐付けているようですね。
image.png

  @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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む