Microbenchmark と Hilt

多くのアプリでは、Hilt を使用して、ビルド バリアントごとに異なる動作を挿入しています。これは、アプリのマイクロベンチマークを行う場合に特に便利です。結果を歪める可能性のあるコンポーネントを切り替えることができるためです。たとえば、次のコード スニペットは、名前のリストを取得して並べ替えるリポジトリを示しています。

Kotlin

class PeopleRepository @Inject constructor(
    @Kotlin private val dataSource: NetworkDataSource,
    @Dispatcher(DispatcherEnum.IO) private val dispatcher: CoroutineDispatcher
) {
    private val _peopleLiveData = MutableLiveData<List<Person>>()

    val peopleLiveData: LiveData<List<Person>>
        get() = _peopleLiveData

    suspend fun update() {
        withContext(dispatcher) {
            _peopleLiveData.postValue(
                dataSource.getPeople()
                    .sortedWith(compareBy({ it.lastName }, { it.firstName }))
            )
        }
    }
}}

Java

public class PeopleRepository {

    private final MutableLiveData<List<Person>> peopleLiveData = new MutableLiveData<>();

    private final NetworkDataSource dataSource;

    public LiveData<List<Person>> getPeopleLiveData() {
        return peopleLiveData;
    }

    @Inject
    public PeopleRepository(NetworkDataSource dataSource) {
        this.dataSource = dataSource;
    }

    private final Comparator<Person> comparator = Comparator.comparing(Person::getLastName)
            .thenComparing(Person::getFirstName);

    public void update() {
        Runnable task = new Runnable() {

            @Override
            public void run() {
                peopleLiveData.postValue(
                        dataSource.getPeople()
                                .stream()
                                .sorted(comparator)
                                .collect(Collectors.toList())
                );
            }
        };
        new Thread(task).start();
    }
}

ベンチマークにネットワーク呼び出しを含める場合は、より正確な結果を得るために、偽のネットワーク呼び出しを実装します。

ベンチマークに実際のネットワーク呼び出しを含めると、ベンチマーク結果の解釈が難しくなります。ネットワーク呼び出しは多くの外部要因の影響を受ける可能性があり、その所要時間はベンチマークの実行の反復処理ごとに異なる可能性があります。ネットワーク呼び出しの時間は、並べ替えよりも長くなることがあります。

Hilt を使用して疑似ネットワーク呼び出しを実装する

上記の例に示すように、dataSource.getPeople() の呼び出しにはネットワーク呼び出しが含まれています。ただし、NetworkDataSource インスタンスは Hilt によって挿入されるため、ベンチマーク用に次の疑似実装に置き換えることができます。

Kotlin

class FakeNetworkDataSource @Inject constructor(
    private val people: List<Person>
) : NetworkDataSource {
    override fun getPeople(): List<Person> = people
}

Java

public class FakeNetworkDataSource implements NetworkDataSource{

    private List<Person> people;

    @Inject
    public FakeNetworkDataSource(List<Person> people) {
        this.people = people;
    }

    @Override
    public List<Person> getPeople() {
        return people;
    }
}

この疑似ネットワーク呼び出しは、getPeople() メソッドを呼び出したときにできるだけ早く実行されるように設計されています。Hilt がこれを挿入できるように、次のプロバイダが使用されます。

Kotlin

@Module
@InstallIn(SingletonComponent::class)
object FakekNetworkModule {

    @Provides
    @Kotlin
    fun provideNetworkDataSource(@ApplicationContext context: Context): NetworkDataSource {
        val data = context.assets.open("fakedata.json").use { inputStream ->
            val bytes = ByteArray(inputStream.available())
            inputStream.read(bytes)

            val gson = Gson()
            val type: Type = object : TypeToken<List<Person>>() {}.type
            gson.fromJson<List<Person>>(String(bytes), type)
        }
        return FakeNetworkDataSource(data)
    }
}

Java

@Module
@InstallIn(SingletonComponent.class)
public class FakeNetworkModule {

    @Provides
    @Java
    NetworkDataSource provideNetworkDataSource(
            @ApplicationContext Context context
    ) {
        List<Person> data = new ArrayList<>();
        try (InputStream inputStream = context.getAssets().open("fakedata.json")) {
            int size = inputStream.available();
            byte[] bytes = new byte[size];
            if (inputStream.read(bytes) == size) {
                Gson gson = new Gson();
                Type type = new TypeToken<ArrayList<Person>>() {
                }.getType();
                data = gson.fromJson(new String(bytes), type);

            }
        } catch (IOException e) {
            // Do something
        }
        return new FakeNetworkDataSource(data);
    }
}

データは、可変長の I/O 呼び出しを使用してアセットから読み込まれます。ただし、これは初期化中に行われるため、ベンチマーク中に getPeople() が呼び出されても不規則性は発生しません。

一部のアプリでは、バックエンドの依存関係を削除するために、デバッグビルドでフェイクをすでに使用しています。ただし、できる限りリリースビルドに近いビルドでベンチマークを行う必要があります。このドキュメントの残りの部分では、プロジェクトの完全なセットアップで説明されているように、マルチモジュール、マルチバリアント構造を使用します。

モジュールは次の 3 つです。

  • benchmarkable: ベンチマークするコードが含まれています。
  • benchmark: ベンチマーク コードが含まれています。
  • app: 残りのアプリコードが含まれます。

上記の各モジュールには、通常の debug バリアントと release バリアントに加えて、benchmark という名前のビルド バリアントがあります。

ベンチマーク モジュールを構成する

疑似ネットワーク呼び出しのコードは benchmarkable モジュールの debug ソースセットにあり、完全なネットワーク実装は同じモジュールの release ソースセットにあります。偽の実装によって返されるデータを含むアセット ファイルは、release ビルドで APK が肥大化しないように、debug ソースセットにあります。benchmark バリアントは release に基づき、debug ソースセットを使用する必要があります。フェイク実装を含む benchmarkable モジュールの benchmark バリアントのビルド構成は次のとおりです。

Kotlin

android {
    ...
    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
        create("benchmark") {
            initWith(getByName("release"))
        }
    }
    ...
    sourceSets {
        getByName("benchmark") {
            java.setSrcDirs(listOf("src/debug/java"))
            assets.setSrcDirs(listOf("src/debug/assets"))
        }
    }
}

Groovy

android {
    ...
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                'proguard-rules.pro'
            )
        }
        benchmark {
            initWith release
        }
    }
    ...
    sourceSets {
        benchmark {
            java.setSrcDirs ['src/debug/java']
            assets.setSrcDirs(listOf ['src/debug/assets']
        }
    }
}

benchmark モジュールに、Hilt をサポートするテストを実行するための Application を作成するカスタム テスト ランナーを追加します。

Kotlin

class HiltBenchmarkRunner : AndroidBenchmarkRunner() {

    override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

Java

public class JavaHiltBenchmarkRunner extends AndroidBenchmarkRunner {

    @Override
    public Application newApplication(
            ClassLoader cl,
            String className,
            Context context
    ) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
        return super.newApplication(cl, HiltTestApplication.class.getName(), context);
    }
}

これにより、テストが実行される Application オブジェクトが HiltTestApplication クラスを拡張します。ビルド構成を次のように変更します。

Kotlin

plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.benchmark)
    alias(libs.plugins.jetbrains.kotlin.android)
    alias(libs.plugins.kapt)
    alias(libs.plugins.hilt)
}

android {
    namespace = "com.example.hiltmicrobenchmark.benchmark"
    compileSdk = 34

    defaultConfig {
        minSdk = 24

        testInstrumentationRunner = "com.example.hiltbenchmark.HiltBenchmarkRunner"
    }

    testBuildType = "benchmark"
    buildTypes {
        debug {
            // Since isDebuggable can't be modified by Gradle for library modules,
            // it must be done in a manifest. See src/androidTest/AndroidManifest.xml.
            isMinifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "benchmark-proguard-rules.pro"
            )
        }
        create("benchmark") {
            initWith(getByName("debug"))
        }
    }
}

dependencies {
    androidTestImplementation(libs.bundles.hilt)
    androidTestImplementation(project(":benchmarkable"))
    implementation(libs.androidx.runner)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.junit)
    implementation(libs.androidx.benchmark)
    implementation(libs.google.dagger.hiltTesting)
    kaptAndroidTest(libs.google.dagger.hiltCompiler)
    androidTestAnnotationProcessor(libs.google.dagger.hiltCompiler)
}

Groovy

plugins {
    alias libs.plugins.android.library
    alias libs.plugins.benchmark
    alias libs.plugins.jetbrains.kotlin.android
    alias libs.plugins.kapt
    alias libs.plugins.hilt
}

android {
    namespace = 'com.example.hiltmicrobenchmark.benchmark'
    compileSdk = 34

    defaultConfig {
        minSdk = 24

        testInstrumentationRunner 'com.example.hiltbenchmark.HiltBenchmarkRunner'
    }

    testBuildType "benchmark"
    buildTypes {
        debug {
            // Since isDebuggable can't be modified by Gradle for library modules,
            // it must be done in a manifest. See src/androidTest/AndroidManifest.xml.
            minifyEnabled true
            proguardFiles(
                getDefaultProguardFile('proguard-android-optimize.txt'),
                'benchmark-proguard-rules.pro'
            )
        }
        benchmark {
            initWith debug"
        }
    }
}

dependencies {
    androidTestImplementation libs.bundles.hilt
    androidTestImplementation project(':benchmarkable')
    implementation libs.androidx.runner
    androidTestImplementation libs.androidx.junit
    androidTestImplementation libs.junit
    implementation libs.androidx.benchmark
    implementation libs.google.dagger.hiltTesting
    kaptAndroidTest libs.google.dagger.hiltCompiler
    androidTestAnnotationProcessor libs.google.dagger.hiltCompiler
}

上記の例では、次の処理が行われます。

  • 必要な Gradle プラグインをビルドに適用します。
  • カスタム テストランナーを使用してテストを実行することを指定します。
  • benchmark バリアントがこのモジュールのテストタイプであることを指定します。
  • benchmark バリアントを追加します。
  • 必要な依存関係を追加します。

testBuildType を変更して、Gradle がベンチマークを実行する connectedBenchmarkAndroidTest タスクを作成するようにする必要があります。

マイクロベンチマークを作成する

ベンチマークは次のように実装されます。

Kotlin

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class PeopleRepositoryBenchmark {

    @get:Rule
    val benchmarkRule = BenchmarkRule()

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    private val latch = CountdownLatch(1)

    @Inject
    lateinit var peopleRepository: PeopleRepository

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun benchmarkSort() {
        benchmarkRule.measureRepeated {
            runBlocking {
                benchmarkRule.getStart().pauseTiming()
                withContext(Dispatchers.Main.immediate) {
                    peopleRepository.peopleLiveData.observeForever(observer)
                }
                benchmarkRule.getStart().resumeTiming()
                peopleRepository.update()
                latch.await()
                assert(peopleRepository.peopleLiveData.value?.isNotEmpty() ?: false)
           }
        }
    }

    private val observer: Observer<List<Person>> = object : Observer<List<Person>> {
        override fun onChanged(people: List<Person>?) {
            peopleRepository.peopleLiveData.removeObserver(this)
            latch.countDown()
        }
    }
}

Java

@RunWith(AndroidJUnit4.class)
@HiltAndroidTest
public class PeopleRepositoryBenchmark {
    @Rule
    public BenchmarkRule benchmarkRule = new BenchmarkRule();

    @Rule
    public HiltAndroidRule hiltRule = new HiltAndroidRule(this);

    private CountdownLatch latch = new CountdownLatch(1);

    @Inject
    JavaPeopleRepository peopleRepository;

    @Before
    public void setup() {
        hiltRule.inject();
    }

    @Test
    public void benchmarkSort() {
        BenchmarkRuleKt.measureRepeated(benchmarkRule, (Function1<BenchmarkRule.Scope, Unit>) scope -> {
            benchmarkRule.getState().pauseTiming();
            new Handler(Looper.getMainLooper()).post(() -> {
                awaitValue(peopleRepository.getPeopleLiveData());
            });
            benchmarkRule.getState().resumeTiming();
            peopleRepository.update();
            try {
                latch.await();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            assert (!peopleRepository.getPeopleLiveData().getValue().isEmpty());
            return Unit.INSTANCE;
        });
    }

    private <T> void awaitValue(LiveData<T> liveData) {
        Observer<T> observer = new Observer<T>() {
            @Override
            public void onChanged(T t) {
                liveData.removeObserver(this);
                latch.countDown();
            }
        };
        liveData.observeForever(observer);
        return;
    }
}

上記の例では、ベンチマークと Hilt の両方のルールを作成しています。benchmarkRule はベンチマークのタイミングを実行します。hiltRule は、ベンチマーク テストクラスで依存関係インジェクションを実行します。個々のテストを実行する前にインジェクションを実行するには、@Before 関数で Hilt ルールの inject() メソッドを呼び出す必要があります。

LiveData オブザーバーが登録されている間、ベンチマーク自体はタイミングを一時停止します。次に、ラッチを使用して、LiveData が更新されるまで待機してから終了します。並べ替えは peopleRepository.update() が呼び出されてから LiveData が更新を受け取るまでの間に実行されるため、並べ替えの時間はベンチマークのタイミングに含まれます。

マイクロベンチマークを実行する

./gradlew :benchmark:connectedBenchmarkAndroidTest を指定してベンチマークを実行すると、ベンチマークが何度も繰り返され、タイミング データが Logcat に出力されます。

PeopleRepositoryBenchmark.log[Metric (timeNs) results: median 613408.3952380952, min 451949.30476190476, max 1412143.5142857144, standardDeviation: 273221.2328680522...

上記の例は、1,000 個のアイテムのリストで並べ替えアルゴリズムを実行した場合のベンチマーク結果が 0.6 ミリ秒~ 1.4 ミリ秒であることを示しています。ただし、ベンチマークにネットワーク呼び出しを含めると、イテレーション間の差異が、並べ替え自体の実行にかかる時間よりも大きくなります。そのため、並べ替えをネットワーク呼び出しから分離する必要があります。

コードをリファクタリングして、並べ替えを単独で実行しやすくすることもできますが、すでに Hilt を使用している場合は、代わりに Hilt を使用してベンチマーク用のフェイクを挿入できます。