Microbenchmark e Hilt

Molte app utilizzano Hilt per inserire comportamenti diversi in varianti di compilazione diverse. Questa funzionalità può essere particolarmente utile quando esegui il microbenchmarking della tua app, perché ti consente di sostituire un componente che può distorcere i risultati. Ad esempio, il seguente snippet di codice mostra un repository che recupera e ordina un elenco di nomi:

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

Se includi una chiamata di rete durante il benchmarking, implementa una chiamata di rete fittizia per ottenere un risultato più preciso.

L'inclusione di una chiamata di rete reale durante il benchmarking rende più difficile l'interpretazione dei risultati del benchmark. Le chiamate di rete possono essere influenzate da molti fattori esterni e la loro durata può variare tra le iterazioni dell'esecuzione del benchmark. La durata delle chiamate di rete può essere superiore a quella dell'ordinamento.

Implementare una chiamata di rete fittizia utilizzando Hilt

La chiamata a dataSource.getPeople(), come mostrato nell'esempio precedente, contiene una chiamata di rete. Tuttavia, l'istanza NetworkDataSource viene inserita da Hilt e puoi sostituirla con la seguente implementazione fittizia per il benchmarking:

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

Questa chiamata di rete fittizia è progettata per essere eseguita il più rapidamente possibile quando chiami il metodo getPeople(). Per consentire a Hilt di inserire questa chiamata, viene utilizzato il seguente provider:

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 dati vengono caricati dagli asset utilizzando una chiamata di I/O di lunghezza potenzialmente variabile. Tuttavia, questa operazione viene eseguita durante l'inizializzazione e non causerà irregolarità quando getPeople() viene chiamato durante il benchmarking.

Alcune app utilizzano già dati fittizi nelle build di debug per rimuovere eventuali dipendenze di backend. Tuttavia, devi eseguire il benchmark su una build il più simile possibile alla build di release. Il resto di questo documento utilizza una struttura multi-modulo e multi-variante come descritto in Configurazione completa del progetto.

Esistono tre moduli:

  • benchmarkable: contiene il codice per il benchmark.
  • benchmark: contiene il codice del benchmark.
  • app: contiene il codice dell'app rimanente.

Ognuno dei moduli precedenti ha una variante di compilazione denominata benchmark, oltre alle solite varianti debug e release.

Configurare il modulo benchmark

Il codice per la chiamata di rete fittizia si trova nel set di risorse debug del modulo benchmarkable, mentre l'implementazione di rete completa si trova nel set di risorse release dello stesso modulo. Il file di asset contenente i dati restituiti dall'implementazione fittizia si trova nel set di risorse debug per evitare qualsiasi aumento delle dimensioni dell'APK nella build release. La variante benchmark deve essere basata su release e utilizzare il set di risorse debug. La configurazione di compilazione per la variante benchmark del modulo benchmarkable contenente l'implementazione fittizia è la seguente:

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

Alla moda

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

Nel modulo benchmark, aggiungi un runner di test personalizzato che crea un oggetto Application per l'esecuzione dei test che supporta Hilt come segue:

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

In questo modo, l'oggetto Application in cui vengono eseguiti i test estende la HiltTestApplication classe. Apporta le seguenti modifiche alla configurazione della build:

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

Alla moda

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'esempio precedente esegue le seguenti operazioni:

  • Applica i plug-in Gradle necessari alla build.
  • Specifica che il runner di test personalizzato viene utilizzato per eseguire i test.
  • Specifica che la variante benchmark è il tipo di test per questo modulo.
  • Aggiunge la variante benchmark.
  • Aggiunge le dipendenze richieste.

Devi modificare testBuildType per assicurarti che Gradle crei l'attività connectedBenchmarkAndroidTest, che esegue il benchmarking.

Creare il microbenchmark

Il benchmark viene implementato come segue:

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'esempio precedente crea regole sia per il benchmark sia per Hilt. benchmarkRule esegue la misurazione dei tempi del benchmark. hiltRule esegue l'inserimento delle dipendenze nella classe di test del benchmark. Devi richiamare il inject() metodo della regola Hilt in una @Before funzione per eseguire l' inserimento prima di eseguire i singoli test.

Il benchmark stesso mette in pausa la misurazione dei tempi mentre viene registrato l'observer LiveData è. Poi utilizza un latch per attendere l'aggiornamento di LiveData prima di terminare. Poiché l'ordinamento viene eseguito nel tempo che intercorre tra la chiamata di peopleRepository.update() e la ricezione di un aggiornamento da parte di LiveData, la durata dell'ordinamento è inclusa nella misurazione dei tempi del benchmark.

Eseguire il microbenchmark

Esegui il benchmark con ./gradlew :benchmark:connectedBenchmarkAndroidTest per eseguire il benchmark su molte iterazioni e stampare i dati di misurazione dei tempi in Logcat:

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

L'esempio precedente mostra il risultato del benchmark tra 0,6 ms e 1,4 ms per eseguire l'algoritmo di ordinamento su un elenco di 1000 elementi. Tuttavia, se includi la chiamata di rete nel benchmark, la varianza tra le iterazioni è maggiore del tempo impiegato dall'ordinamento stesso, da cui la necessità di isolare l'ordinamento dalla chiamata di rete.

Puoi sempre eseguire il refactoring del codice per semplificare l'esecuzione dell'ordinamento in isolamento, ma se utilizzi già Hilt, puoi utilizzarlo per inserire dati fittizi per il benchmarking.