MicroBenchmark und Hilt

Viele Apps verwenden Hilt, um unterschiedliche Verhaltensweisen in verschiedene Build-Varianten einzufügen. Das kann besonders nützlich sein, wenn Sie Ihre App mit Microbenchmarking testen, da Sie so eine Komponente austauschen können, die die Ergebnisse verfälschen könnte. Das folgende Code-Snippet zeigt beispielsweise ein Repository, das eine Liste von Namen abruft und sortiert:

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();
    }
}

Wenn Sie beim Benchmarking einen Netzwerkaufruf einbeziehen, implementieren Sie einen gefälschten Netzwerkaufruf, um ein genaueres Ergebnis zu erhalten.

Wenn Sie beim Benchmarking einen echten Netzwerkaufruf einbeziehen, sind die Benchmark-Ergebnisse schwieriger zu interpretieren. Netzwerkaufrufe können von vielen externen Faktoren beeinflusst werden und ihre Dauer kann zwischen den Iterationen des Benchmarks variieren. Die Dauer von Netzwerkaufrufen kann länger sein als die Sortierung.

Gefälschten Netzwerkaufruf mit Hilt implementieren

Der Aufruf von dataSource.getPeople() im vorherigen Beispiel enthält einen Netzwerkaufruf. Die NetworkDataSource-Instanz wird jedoch von Hilt eingefügt und Sie können sie für das Benchmarking durch die folgende gefälschte Implementierung ersetzen:

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;
    }
}

Dieser gefälschte Netzwerkaufruf soll so schnell wie möglich ausgeführt werden, wenn Sie die Methode getPeople() aufrufen. Damit Hilt dies einfügen kann, wird der folgende Anbieter verwendet:

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);
    }
}

Die Daten werden aus Assets mit einem potenziell variablen I/O-Aufruf geladen. Dies geschieht jedoch während der Initialisierung und führt zu keinen Unregelmäßigkeiten, wenn getPeople() beim Benchmarking aufgerufen wird.

Einige Apps verwenden bereits Fakes in Debug-Builds, um alle Back-End-Abhängigkeiten zu entfernen. Sie müssen jedoch Benchmarks für einen Build ausführen, der dem Release-Build so nahe wie möglich kommt. Im Rest dieses Dokuments wird eine Struktur mit mehreren Modulen und mehreren Varianten verwendet wie unter Vollständige Projekteinrichtung beschrieben.

Es gibt drei Module:

  • benchmarkable: enthält den Code für das Benchmarking.
  • benchmark: enthält den Benchmark-Code.
  • app: enthält den restlichen App-Code.

Jedes der oben genannten Module hat eine Build-Variante namens benchmark sowie die üblichen Varianten debug und release.

Benchmark-Modul konfigurieren

Der Code für den gefälschten Netzwerkaufruf befindet sich im Source-Set debug des Moduls benchmarkable. Die vollständige Netzwerkimplementierung befindet sich im Source-Set release desselben Moduls. Die Asset-Datei mit den Daten, die von der gefälschten Implementierung zurückgegeben werden, befindet sich im debug Source-Set, um eine APK-Aufblähung im release-Build zu vermeiden. Die Variante benchmark muss auf release basieren und den Source-Set debug verwenden. Die Build-Konfiguration für die Variante benchmark des Moduls benchmarkable, die die gefälschte Implementierung enthält, sieht so aus:

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']
        }
    }
}

Fügen Sie im Modul benchmark einen benutzerdefinierten Test-Runner hinzu, der eine Application erstellt, in der die Tests ausgeführt werden können und die Hilt unterstützt:

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);
    }
}

Dadurch wird das Application Objekt, in dem die Tests ausgeführt werden, auf die HiltTestApplication Klasse erweitert. Nehmen Sie die folgenden Änderungen an der Build-Konfiguration vor:

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
}

Das vorherige Beispiel führt Folgendes aus:

  • Wendet die erforderlichen Gradle-Plug-ins auf den Build an.
  • Gibt an, dass der benutzerdefinierte Test-Runner zum Ausführen der Tests verwendet wird.
  • Gibt an, dass die Variante benchmark der Testtyp für dieses Modul ist.
  • Fügt die Variante benchmark hinzu.
  • Fügt die erforderlichen Abhängigkeiten hinzu.

Sie müssen testBuildType ändern, damit Gradle die Aufgabe connectedBenchmarkAndroidTest erstellt, die das Benchmarking durchführt.

Microbenchmark erstellen

Der Benchmark wird so implementiert:

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;
    }
}

Im vorherigen Beispiel werden Regeln für den Benchmark und Hilt erstellt. benchmarkRule führt die Zeitmessung des Benchmarks durch. hiltRule führt die Abhängigkeitsinjektion in der Benchmark-Testklasse durch. Sie müssen die inject() Methode der Hilt-Regel in einer @Before Funktion aufrufen, um die Injektion durchzuführen, bevor Sie einzelne Tests ausführen.

Beim Benchmark wird die Zeitmessung pausiert, während der LiveData Observer registriert ist. Anschließend wird mit einem Latch gewartet, bis LiveData aktualisiert wird, bevor der Vorgang beendet wird. Da die Sortierung in der Zeit zwischen dem Aufruf von peopleRepository.update() und der Aktualisierung von LiveData ausgeführt wird, wird die Dauer der Sortierung in die Benchmark-Zeitmessung einbezogen.

Microbenchmark ausführen

Führen Sie den Benchmark mit ./gradlew :benchmark:connectedBenchmarkAndroidTest aus, um ihn in vielen Iterationen durchzuführen und die Zeitmessungsdaten in Logcatauszugeben:

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

Im vorherigen Beispiel liegt das Benchmark-Ergebnis zwischen 0,6 ms und 1,4 ms für die Ausführung des Sortieralgorithmus für eine Liste mit 1.000 Elementen. Wenn Sie jedoch den Netzwerkaufruf in den Benchmark einbeziehen, ist die Varianz zwischen den Iterationen größer als die Zeit, die für die Sortierung selbst benötigt wird. Daher ist es notwendig, die Sortierung vom Netzwerkaufruf zu isolieren.

Sie können Code jederzeit refaktorieren, um die Sortierung einfacher isoliert auszuführen. Wenn Sie jedoch bereits Hilt verwenden, können Sie es stattdessen verwenden, um Fakes für das Benchmarking einzuschleusen.