Микробенчмарк и Hilt

Многие приложения используют Hilt для внедрения различного поведения в разные варианты сборки. Это может быть особенно полезно при микробенчмаркинге вашего приложения, поскольку позволяет исключить компонент, который может исказить результаты. Например, в следующем фрагменте кода показан репозиторий, который извлекает и сортирует список имен:

Котлин

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

Ява

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 внедряется Hilt, и вы можете заменить его следующей поддельной реализацией для тестирования:

Котлин

class FakeNetworkDataSource @Inject constructor(
    private val people: List<Person>
) : NetworkDataSource {
    override fun getPeople(): List<Person> = people
}

Ява

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 мог это внедрить, используется следующий провайдер:

Котлин

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

Ява

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

Данные загружаются из ресурсов с использованием вызова ввода-вывода потенциально переменной длины. Однако это делается во время инициализации и не приведет к каким-либо нарушениям при вызове getPeople() во время сравнительного анализа.

Некоторые приложения уже используют подделки в отладочных сборках, чтобы удалить любые зависимости от серверной части. Однако вам необходимо протестировать сборку, максимально приближенную к релизной сборке. В остальной части этого документа используется многомодульная многовариантная структура, как описано в разделе Полная настройка проекта .

Есть три модуля:

  • benchmarkable : содержит код для тестирования.
  • benchmark : содержит код теста.
  • app : содержит оставшийся код приложения.

Каждый из предыдущих модулей имеет вариант сборки под названием benchmark , а также обычные варианты debug и release .

Настройте модуль тестирования

Код для поддельного сетевого вызова находится в наборе исходных кодов debug benchmarkable модуля, а полная сетевая реализация находится в наборе исходных кодов release того же модуля. Файл ресурсов, содержащий данные, возвращаемые поддельной реализацией, находится в исходном наборе debug , чтобы избежать раздувания APK в сборке release . Вариант benchmark должен быть основан на release и использовать набор исходного кода debug . Конфигурация сборки benchmark варианта benchmarkable модуля, содержащего поддельную реализацию, выглядит следующим образом:

Котлин

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

классный

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, следующим образом:

Котлин

class HiltBenchmarkRunner : AndroidBenchmarkRunner() {

    override fun newApplication(
        cl: ClassLoader?,
        className: String?,
        context: Context?
    ): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

Ява

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 . Внесите следующие изменения в конфигурацию сборки:

Котлин

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

классный

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 , которая выполняет тестирование производительности.

Создайте микробенчмарк

Бенчмарк реализован следующим образом:

Котлин

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

Ява

@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 выполняет внедрение зависимостей в классе эталонного теста. Вы должны вызвать метод inject() правила Hilt в функции @Before , чтобы выполнить внедрение перед запуском каких-либо отдельных тестов.

Сам тест приостанавливает отсчет времени, пока регистрируется наблюдатель LiveData . Затем он использует защелку, чтобы дождаться обновления LiveData перед завершением. Поскольку сортировка выполняется в промежутке между вызовом peopleRepository.update() и получением LiveData обновления, продолжительность сортировки включается в контрольное время.

Запустите микротест

Запустите тест с помощью ./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 мс для запуска алгоритма сортировки по списку из 1000 элементов. Однако если вы включите сетевой вызов в тест, разница между итерациями превысит время, необходимое для выполнения самой сортировки, поэтому необходимо изолировать сортировку от сетевого вызова.

Вы всегда можете провести рефакторинг кода, чтобы упростить изолированную сортировку, но если вы уже используете Hilt, вы можете вместо этого использовать его для внедрения подделок для сравнительного анализа.