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 インスタンスは注入されます。 次のような疑似実装に置き換えることができます。 ベンチマーク:

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: 残りのアプリコードが含まれます。

上記の各モジュールには、benchmark という名前のビルド バリアントと、 通常の debug バリアントと release バリアント。

ベンチマーク モジュールを設定する

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

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 モジュールで、Application を作成するカスタム テストランナーを追加します。 は、次のような Hilt をサポートするテストを実行します。

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 タスク。ベンチマークを実行します。

microbenchmark を作成する

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

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 が更新を受け取ると、 並べ替えの所要時間はベンチマーク時間に含まれます。

microbenchmark を実行する

./gradlew :benchmark:connectedBenchmarkAndroidTest でベンチマークを実行する ベンチマークを多数の反復処理で実行し、タイミング データを Logcat:

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

上記の例は、実行するベンチマーク結果を 0.6 ~ 1.4 ms の間で示しています。 1,000 アイテムのリストの並べ替えアルゴリズム。ただし、 ベンチマークでネットワーク呼び出しを行うと、反復処理のばらつきが大きくなります。 並べ替え自体の実行にかかる時間より長いため、 ネットワーク呼び出しからの並べ替えです。

いつでもコードをリファクタリングして、並べ替えを簡単に実行できます。 すでに Hilt を使用している場合は、それを使用して疑似を注入できます。 おすすめします