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

داده‌ها با استفاده از یک فراخوانی ورودی/خروجی با طول متغیر، از assetها بارگذاری می‌شوند. با این حال، این کار در طول مقداردهی اولیه انجام می‌شود و هنگام فراخوانی getPeople() در طول بنچمارک، هیچ بی‌نظمی ایجاد نمی‌کند.

برخی از برنامه‌ها از قبل از نسخه‌های جعلی در نسخه‌های اشکال‌زدایی (debug builds) برای حذف هرگونه وابستگی به backend استفاده می‌کنند. با این حال، شما باید تا حد امکان روی نسخه‌ای که به نسخه منتشر شده نزدیک است، بنچمارک بگیرید. بقیه این سند از یک ساختار چند ماژولی و چند متغیره استفاده می‌کند، همانطور که در بخش «راه‌اندازی کامل پروژه» توضیح داده شده است.

سه ماژول وجود دارد:

  • 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 را به build اعمال می‌کند.
  • مشخص می‌کند که از اجراکننده‌ی تست سفارشی برای اجرای تست‌ها استفاده شود.
  • مشخص می‌کند که نوع 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 متوقف می‌کند. سپس از یک قفل (latch) برای منتظر ماندن تا به‌روزرسانی LiveData قبل از اتمام استفاده می‌کند. از آنجایی که مرتب‌سازی در فاصله زمانی بین فراخوانی تابع peopleRepository.update() و دریافت به‌روزرسانی LiveData انجام می‌شود، مدت زمان مرتب‌سازی در زمان‌بندی بنچمارک لحاظ می‌شود.

میکرو بنچمارک را اجرا کنید

برای انجام بنچمارک در تکرارهای زیاد و چاپ داده‌های زمان‌بندی در Logcat ، بنچمارک را با استفاده از ./gradlew :benchmark:connectedBenchmarkAndroidTest اجرا کنید:

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

مثال قبلی نتیجه معیار را بین ۰.۶ میلی‌ثانیه و ۱.۴ میلی‌ثانیه برای اجرای الگوریتم مرتب‌سازی روی لیستی از ۱۰۰۰ مورد نشان می‌دهد. با این حال، اگر فراخوانی شبکه را در معیار لحاظ کنید، واریانس بین تکرارها بیشتر از زمانی است که خود مرتب‌سازی برای اجرا صرف می‌کند، از این رو نیاز به جداسازی مرتب‌سازی از فراخوانی شبکه وجود دارد.

شما همیشه می‌توانید کد را اصلاح کنید تا اجرای مرتب‌سازی به صورت جداگانه آسان‌تر شود، اما اگر در حال حاضر از Hilt استفاده می‌کنید، می‌توانید به جای آن از آن برای تزریق داده‌های جعلی برای بنچمارک استفاده کنید.