Microbenchmark 및 Hilt

많은 앱이 Hilt를 사용하여 다양한 빌드 변형에 다양한 동작을 삽입합니다. 이는 앱을 마이크로벤치마킹할 때 특히 유용합니다. 결과를 왜곡할 수 있는 구성 요소를 바꿀 수 있습니다. 예를 들어 코드 스니펫은 이름 목록을 가져오고 정렬하는 저장소를 보여줍니다.

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

자바

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 인스턴스는 삽입됩니다. 이를 위해 다음과 같은 모조 구현으로 대체할 수 있습니다. 벤치마킹:

Kotlin

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가 이를 삽입할 수 있도록 하려면 공급자는 다음과 같습니다.

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

자바

@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/O 호출을 사용하여 애셋에서 로드됩니다. 그러나 이는 초기화 중에 수행되며 이상이 발생하지 않습니다. 벤치마킹 중에 getPeople()가 호출될 때

일부 앱은 이미 디버그 빌드에서 가짜 함수를 사용하여 백엔드 종속 항목을 삭제합니다. 하지만 출시 빌드에 가까운 빌드를 벤치마킹해야 하는데, 있습니다. 이 문서의 나머지 부분에서는 다중 모듈, 다중 변형 구조를 사용합니다. 이 작업은 전체 프로젝트 설정에 설명된 대로 포함되어야 합니다.

다음과 같은 세 가지 모듈이 있습니다.

  • benchmarkable: 벤치마킹할 코드를 포함합니다.
  • benchmark: 벤치마크 코드를 포함합니다.
  • app: 나머지 앱 코드를 포함합니다.

위의 각 모듈에는 benchmark이라는 빌드 변형과 함께 일반적인 debugrelease 변형입니다.

벤치마크 모듈 구성

모조 네트워크 호출의 코드는 debug 소스 세트에 있습니다. benchmarkable 모듈, 전체 네트워크 구현은 release에 있음 같은 모듈의 소스 세트입니다. 에서 반환한 데이터가 포함된 애셋 파일입니다. 모조 구현은 APK 팽창을 방지하기 위해 debug 소스 세트에 있습니다. release 빌드 benchmark 변형은 releasedebug 소스 세트를 사용합니다. benchmark 변형의 빌드 구성 다음은 모조 구현을 포함하는 benchmarkable 모듈의 예입니다.

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

benchmark 모듈에서 Application를 만드는 맞춤 테스트 실행기를 추가합니다. 다음과 같이 Hilt를 지원하는 테스트를 실행할 수 있습니다.

Kotlin

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 클래스 빌드를 다음과 같이 변경합니다. 구성:

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
}

앞의 예시에서는 다음을 실행합니다.

  • 필요한 Gradle 플러그인을 빌드에 적용합니다.
  • 맞춤 테스트 실행기를 사용하여 테스트를 실행하도록 지정합니다.
  • benchmark 변형이 이 모듈의 테스트 유형임을 지정합니다.
  • benchmark 변형을 추가합니다.
  • 필요한 종속 항목을 추가합니다.

testBuildType를 변경하여 Gradle이 connectedBenchmarkAndroidTest 작업: 벤치마킹을 실행합니다.

Microbenchmark 만들기

벤치마크는 다음과 같이 구현됩니다.

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

자바

@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는 벤치마크 테스트 클래스의 종속 항목 삽입 먼저 @Before 함수에서 Hilt 규칙의 inject() 메서드를 사용하여 다음을 실행합니다. 삽입한 후 삽입해야 합니다.

벤치마크 자체는 LiveData 관찰자가 있는 동안 타이밍을 일시중지합니다. 있습니다. 그런 다음 래치를 사용하여 LiveData가 업데이트될 때까지 기다립니다. 있습니다. 정렬은 다음 시점 사이의 시간에 실행되므로 peopleRepository.update()가 호출되고 LiveData가 업데이트를 받으면 정렬 기간은 벤치마크 타이밍에 포함됩니다.

Microbenchmark 실행

./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밀리초 사이의 벤치마크 결과를 보여줍니다. 정렬 알고리즘을 사용하여 작업을 수행해야 합니다. 하지만 네트워크 호출이 벤치마크보다 크면 반복 간의 편차가 더 큽니다. 정렬 자체가 실행되는 데 걸리는 시간보다 훨씬 짧기 때문에 네트워크 호출로부터의 정렬입니다.

정렬을 더 쉽게 할 수 있도록 언제든지 코드를 리팩터링할 수 있습니다. 이미 Hilt를 사용 중이라면 벤치마킹을 사용합니다