מיקרובנצ'מרק ו-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() במהלך ההשוואה לשוק.

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

יש שלוש יחידות:

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

מגניב

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

מגניב

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, שמבצעת את הבדיקה.

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

הערכת הביצועים מיושמת באופן הבא:

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

בדוגמה שלמעלה נוצרים כללים גם ל-benchmark וגם ל-Hilt. ‫benchmarkRule מבצע את התזמון של הערכת הביצועים. hiltRule מבצע את הזרקת התלות במחלקת הבדיקה של נקודת ההשוואה. כדי לבצע את ההזרקה לפני הרצת בדיקות נפרדות, צריך להפעיל את השיטה 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.6ms ל-1.4ms להרצת אלגוריתם המיון ברשימה של 1,000 פריטים. עם זאת, אם תכללו את הקריאה לרשת בבדיקה, השונות בין החזרות תהיה גדולה יותר מהזמן שלוקח להפעיל את המיון עצמו, ולכן צריך לבודד את המיון מהקריאה לרשת.

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