מיקרובנצ'מרק ו-Hilt

אפליקציות רבות משתמשות ב-Hilt כדי להחדיר התנהגויות שונות לווריאציות שונות של build. האפשרות הזו יכולה להיות שימושית במיוחד כשמבצעים בדיקות ביצועים ברמת המיקרו באפליקציה, כי היא מאפשרת להחליף רכיב שעלול להטות את התוצאות. לדוגמה, קטע הקוד הבא מציג מאגר שמאחזר רשימת שמות וממיין אותה:

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

אם כוללים קריאה לרשת בזמן ההשוואה לשוק, יש להטמיע שיחת רשת מזויפת כדי לקבל תוצאה מדויקת יותר.

אם כוללים שיחת רשת אמיתית בזמן ההשוואה לשוק, קשה יותר להבין את הנתונים תוצאות ההשוואה לשוק. קריאות לרשת יכולות להיות מושפעות מגורמים חיצוניים רבים, והמשך שלהן יכול להשתנות בין חזרות של הפעלת מדד הביצועים. משך השיחות ברשת עשוי להימשך זמן רב יותר מהמיון.

הטמעת שיחת רשת מזויפת באמצעות Hilt

הקריאה אל dataSource.getPeople(), כמו שמוצג בדוגמה שלמעלה, מכיל שיחת רשת. עם זאת, מופע NetworkDataSource מוזרק על ידי Hilt, ואפשר להחליף אותו בהטמעה המזויפת הבאה לצורך השוואה למדד ביצועים:

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

קריאת הרשת המזויפת הזו נועדה לפעול במהירות האפשרית כשקוראים לשיטה 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)
    }
}

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

הנתונים נטענים מהנכסים באמצעות קריאה של קלט/פלט שאורכה עשוי להשתנות. עם זאת, הפעולה הזו מתבצעת במהלך האתחול ולא תגרום לשיבושים כלשהם. כשמתבצעת קריאה אל getPeople() במהלך ההשוואה לשוק.

באפליקציות מסוימות כבר נעשה שימוש ב-fakes בגרסאות build לניפוי באגים כדי להסיר יחסי תלות לקצה העורפי. עם זאת, צריך לבצע את בדיקת הביצועים בגרסה של ה-build שהכי קרובה לגרסת ה-release. בהמשך המסמך נעשה שימוש במבנה של מודולים מרובים עם וריאנטים מרובים, כפי שמתואר בקטע הגדרת פרויקט מלאה.

יש שלושה מודולים:

  • benchmarkable: מכיל את הקוד לבדיקה.
  • benchmark: מכיל את קוד ההשוואה.
  • app: מכיל את שאר קוד האפליקציה.

בכל אחד מהמודולים הקודמים יש וריאנט build בשם benchmark, יחד עם הווריאציות הרגילות debug ו-release.

הגדרת מודול מדדי הביצועים

הקוד של קריאת הרשת המזויפת נמצא בקבוצת המקור debug של המודול benchmarkable, וההטמעה המלאה של הרשת נמצאת בקבוצת המקור release של אותו מודול. קובץ הנכס שמכיל את הנתונים שהוחזרו על ידי ההטמעה המזויפת נמצאת במקור debug שהוגדר כדי למנוע עומס יתר של APK את ה-build של release. הווריאנט benchmark צריך להתבסס על release ועל משתמשים בקבוצת המקור debug. תצורת ה-build של הווריאנט 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)
    }
}

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

כך האובייקט Application שבו מריצים את הבדיקות מאריך את כיתה HiltTestApplication. צריך לבצע את השינויים הבאים ב-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
}

הדוגמה הקודמת מבצעת את הפעולות הבאות:

  • מחילה על ה-build את יישומי הפלאגין המתאימים לשדרוג.
  • מציינת שהרצת הבדיקות בהתאמה אישית משמשת להרצת הבדיקות.
  • מציינת שווריאנט benchmark הוא סוג הבדיקה של המודול הזה.
  • הוספת הווריאנט benchmark.
  • הוספת יחסי התלות הנדרשים.

צריך לשנות את testBuildType כדי להבטיח ש-Gradle ייצור את משימה אחת (connectedBenchmarkAndroidTest), שמבצעת את ההשוואה לשוק.

יצירת המיקרו-בדיקות

ההשוואה לשוק מיושמת באופן הבא:

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

הדוגמה הקודמת יוצרת כללים גם לנקודת ההשוואה וגם ל-Hilt. benchmarkRule מבצע את תזמון נקודת השוואה. hiltRule מבצע את הזרקת התלות בכיתה של בדיקת העמידה בתקן. צריך להפעיל את ה-method inject() של כלל Hilt בפונקציה @Before כדי לבצע את הפונקציה הזרקה לפני הרצת בדיקות ספציפיות.

מדד הביצועים עצמו משהה את הזמן בזמן שהמשתמש LiveData רשום. לאחר מכן, הוא משתמש ב-latch כדי להמתין עד לעדכון של 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 אלפיות שנייה של אלגוריתם המיון ברשימה של 1,000 פריטים. עם זאת, אם תכללו את קריאת הרשת בבדיקת הביצועים, השונות בין החזרות (iterations) תהיה גדולה יותר מהזמן שלוקח למיון לפעול בעצמו, ולכן צריך לבודד את המיון מקריאת הרשת.

תמיד אפשר לבצע רפאקציה של הקוד כדי שיהיה קל יותר להריץ את המיון בנפרד, אבל אם אתם כבר משתמשים ב-Hilt, תוכלו להשתמש בו כדי להחדיר ערכים מזויפים לצורך בדיקת ביצועים במקום זאת.