Microbenchmark e Hilt

Muitos apps usam o Hilt para injetar comportamentos diferentes em variantes de build diferentes. Isso pode ser particularmente útil ao fazer microbenchmarking do seu app, porque permite substituir um componente que pode distorcer os resultados. Por exemplo, o snippet de código a seguir mostra um repositório que busca e classifica uma lista de nomes:

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 você incluir uma chamada de rede ao fazer o comparativo de mercado, implemente uma chamada de rede falsa para ter um resultado mais preciso.

Incluir uma chamada de rede real ao fazer o comparativo dificulta a interpretação dos resultados. As chamadas de rede podem ser afetadas por muitos fatores externos, e a duração delas pode variar entre as iterações da execução do comparativo. A duração das chamadas de rede pode levar mais tempo do que a classificação.

Implementar uma chamada de rede simulada usando o Hilt

A chamada para dataSource.getPeople(), conforme mostrado no exemplo anterior, contém uma chamada de rede. No entanto, a instância NetworkDataSource é injetada pelo Hilt, e você pode substituí-la pela seguinte implementação falsa para comparativo de mercado:

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

Essa chamada de rede falsa foi projetada para ser executada o mais rápido possível quando você chama o método getPeople(). Para que o Hilt possa fazer a injeção, o seguinte provedor é usado:

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

Os dados são carregados de recursos usando uma chamada de E/S de comprimento potencialmente variável. No entanto, isso é feito durante a inicialização e não causa irregularidades quando getPeople() é chamado durante o comparativo de mercado.

Alguns apps já usam fakes em builds de depuração para remover dependências de back-end. No entanto, é preciso fazer o comparativo em um build o mais próximo possível do build de lançamento. O restante deste documento usa uma estrutura de vários módulos e variantes, conforme descrito em Configuração completa do projeto.

Há três módulos:

  • benchmarkable: contém o código para comparação.
  • benchmark: contém o código de comparativo de mercado.
  • app: contém o restante do código do app.

Cada um dos módulos anteriores tem uma variante de build chamada benchmark, além das variantes debug e release usuais.

Configurar o módulo de comparativo de mercado

O código da chamada de rede falsa está no conjunto de origem debug do módulo benchmarkable, e a implementação completa da rede está no conjunto de origem release do mesmo módulo. O arquivo de recurso que contém os dados retornados pela implementação falsa está no conjunto de origem debug para evitar o aumento do APK no build release. A variante benchmark precisa ser baseada em release e usar o conjunto de origem debug. A configuração de build para a variante benchmark do módulo benchmarkable que contém a implementação falsa é a seguinte:

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

No módulo benchmark, adicione um executor de testes personalizado que crie um Application para que os testes sejam executados e que seja compatível com o Hilt da seguinte maneira:

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

Isso faz com que o objeto Application em que os testes são executados estenda a classe HiltTestApplication. Faça as seguintes mudanças na configuração de 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)
}

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
}

O exemplo anterior faz o seguinte:

  • Aplica os plug-ins do Gradle necessários ao build.
  • Especifica que o executor de testes personalizado é usado para executar os testes.
  • Especifica que a variante benchmark é o tipo de teste para este módulo.
  • Adiciona a variante benchmark.
  • Adiciona as dependências necessárias.

É necessário mudar o testBuildType para garantir que o Gradle crie a tarefa connectedBenchmarkAndroidTest, que realiza o comparativo de mercado.

Criar o microbenchmark

O comparativo de mercado é implementado da seguinte forma:

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

O exemplo anterior cria regras para o comparativo e o Hilt. O benchmarkRule executa a comparação. O hiltRule realiza a injeção de dependência na classe de teste de comparativo de mercado. É necessário invocar o método inject() da regra do Hilt em uma função @Before para realizar a injeção antes de executar testes individuais.

O próprio comparativo de mercado pausa a marcação de tempo enquanto o observador LiveData está registrado. Em seguida, ele usa um latch para esperar até que o LiveData seja atualizado antes de concluir. Como a classificação é executada no tempo entre a chamada de peopleRepository.update() e o momento em que LiveData recebe uma atualização, a duração da classificação é incluída na comparação de desempenho.

Executar a microcomparação

Execute o comparativo de mercado com ./gradlew :benchmark:connectedBenchmarkAndroidTest para realizar o comparativo em várias iterações e imprimir os dados de tempo no Logcat:

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

O exemplo anterior mostra o resultado do comparativo de mercado entre 0,6 ms e 1,4 ms para executar o algoritmo de classificação em uma lista de 1.000 itens. No entanto, se você incluir a chamada de rede no comparativo, a variância entre as iterações será maior do que o tempo que a classificação leva para ser executada. Por isso, é necessário isolar a classificação da chamada de rede.

É sempre possível refatorar o código para facilitar a execução da classificação de forma isolada, mas, se você já estiver usando o Hilt, use-o para injetar simulações para comparativos de mercado.