Microbenchmark et Hilt

De nombreuses applications utilisent Hilt pour injecter différents comportements dans différentes variantes de compilation. Cela peut être particulièrement utile lorsque vous effectuez un microbenchmark de votre application, car cela vous permet de remplacer un composant qui peut fausser les résultats. Par exemple, l'extrait de code suivant montre un dépôt qui récupère et trie une liste de noms :

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

Si vous incluez un appel réseau lors de l'analyse comparative, implémentez un faux appel réseau pour obtenir un résultat plus précis.

L'inclusion d'un véritable appel réseau lors de l'analyse comparative rend l'interprétation des résultats plus difficile. Les appels réseau peuvent être affectés par de nombreux facteurs externes, et leur durée peut varier entre les itérations de l'exécution de l'analyse comparative. La durée des appels réseau peut être plus longue que le tri.

Implémenter un faux appel réseau à l'aide de Hilt

L'appel à dataSource.getPeople(), comme illustré dans l'exemple précédent, contient un appel réseau. Toutefois, l'instance NetworkDataSource est injectée par Hilt, et vous pouvez la remplacer par l'implémentation factice suivante pour l'analyse comparative :

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

Ce faux appel réseau est conçu pour s'exécuter le plus rapidement possible lorsque vous appelez la méthode getPeople(). Pour que Hilt puisse l'injecter, le fournisseur suivant est utilisé :

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

Les données sont chargées à partir des assets à l'aide d'un appel d'E/S de longueur potentiellement variable. Toutefois, cela se fait lors de l'initialisation et n'entraîne aucune irrégularité lorsque getPeople() est appelé lors de l'analyse comparative.

Certaines applications utilisent déjà des données factices sur les builds de débogage pour supprimer toutes les dépendances de backend. Toutefois, vous devez effectuer l'analyse comparative sur un build aussi proche que possible du build de version. Le reste de ce document utilise une structure multimodule et multivariante comme décrit dans Configuration complète du projet.

Il existe trois modules :

  • benchmarkable : contient le code à analyser.
  • benchmark : contient le code d'analyse comparative.
  • app : contient le reste du code de l'application.

Chacun des modules précédents comporte une variante de compilation nommée benchmark, ainsi que les variantes habituelles debug et release.

Configurer le module d'analyse comparative

Le code du faux appel réseau se trouve dans l'ensemble de sources debug du module benchmarkable, et l'implémentation réseau complète se trouve dans l'ensemble de sources release du même module. Le fichier d'asset contenant les données renvoyées par l'implémentation factice se trouve dans l'ensemble de sources debug pour éviter tout gonflement de l'APK dans le build release. La variante benchmark doit être basée sur release et utiliser l'ensemble de sources debug. La configuration de compilation pour la variante benchmark du module benchmarkable contenant l'implémentation factice est la suivante :

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

Dans le module benchmark, ajoutez un test runner personnalisé qui crée une Application pour que les tests s'exécutent dans celui qui prend en charge Hilt comme suit :

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

Cela permet à l'objet Application dans lequel les tests sont exécutés d'étendre la HiltTestApplication classe. Apportez les modifications suivantes à la configuration de compilation :

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
}

L'exemple précédent effectue les opérations suivantes :

  • Applique les plug-ins Gradle nécessaires au build.
  • Spécifie que le test runner personnalisé est utilisé pour exécuter les tests.
  • Spécifie que la variante benchmark est le type de test pour ce module.
  • Ajoute la variante benchmark.
  • Ajoute les dépendances requises.

Vous devez modifier testBuildType pour vous assurer que Gradle crée la tâche connectedBenchmarkAndroidTest, qui effectue l'analyse comparative.

Créer le microbenchmark

L'analyse comparative est implémentée comme suit :

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

L'exemple précédent crée des règles pour l'analyse comparative et Hilt. benchmarkRule effectue le timing de l'analyse comparative. hiltRule effectue l'injection de dépendances sur la classe de test d'analyse comparative. Vous devez appeler la inject() méthode de la règle Hilt dans une fonction @Before pour effectuer l' injection avant d'exécuter des tests individuels.

L'analyse comparative elle-même met en pause le timing pendant l'enregistrement de l'observateur LiveData est enregistré. Elle utilise ensuite un verrou pour attendre la mise à jour de LiveData avant de terminer. Comme le tri s'exécute entre l'appel de peopleRepository.update() et la réception d'une mise à jour par LiveData, la durée du tri est incluse dans le timing de l'analyse comparative.

Exécuter le microbenchmark

Exécutez l'analyse comparative avec ./gradlew :benchmark:connectedBenchmarkAndroidTest pour effectuer l'analyse comparative sur de nombreuses itérations et imprimer les données de timing dans Logcat :

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

L'exemple précédent montre le résultat de l'analyse comparative entre 0,6 ms et 1,4 ms pour exécuter l'algorithme de tri sur une liste de 1 000 éléments. Toutefois, si vous incluez l'appel réseau dans l'analyse comparative, la variance entre les itérations est supérieure au temps nécessaire à l'exécution du tri lui-même. Il est donc nécessaire d'isoler le tri de l'appel réseau.

Vous pouvez toujours refactoriser le code pour faciliter l'exécution du tri de manière isolée, mais si vous utilisez déjà Hilt, vous pouvez l'utiliser pour injecter des données factices à des fins d'analyse comparative.