diff --git a/.github/workflows/baseline-profile.yml b/.github/workflows/baseline-profile.yml new file mode 100644 index 000000000..aff4e84d0 --- /dev/null +++ b/.github/workflows/baseline-profile.yml @@ -0,0 +1,82 @@ +name: Baseline profile generation + +on: + schedule: + - cron: '30 0 * * 0' + workflow_dispatch: + push: + paths: + - 'benchmark/**' + - '.github/workflows/baseline-profile.yml' + +jobs: + baseline-profile: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Free disk space + run: rm -rf ${ANDROID_SDK_ROOT}/ndk + + - name: Install NDK + run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11902837" + + - name: Install Rust Toolchain + run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: "./app/src/main/rust" + shared-key: ${{ runner.os }} + save-if: "false" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-encryption-key: ${{ secrets.GradleEncryptionKey }} + cache-read-only: true + + - name: CMake Cache + uses: actions/cache/restore@v4 + with: + path: app/.cxx + key: cmake- + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build + run: ./gradlew generateBaselineProfile + -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" + + - name: Update baseline profile + if: github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config push.autoSetupRemote true + git switch -c chore/baseline-profile + if git commit -am "Update baseline profile"; then + git push + gh pr create -f + fi + + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: reports + path: benchmark/build/reports diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23d668969..3e6a47af8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,6 +14,7 @@ plugins { alias(libs.plugins.spotless) alias(libs.plugins.aboutlibrariesPlugin) alias(libs.plugins.composeCompilerReportGenerator) + alias(libs.plugins.baselineprofile) } val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a") @@ -151,6 +152,13 @@ android { abortOnError = false } } + create("benchmark") { + initWith(buildTypes.getByName("release")) + matchingFallbacks += listOf("release") + applicationIdSuffix = ".benchmark" + signingConfig = signingConfigs.getByName("debug") + isDebuggable = false + } } buildFeatures { @@ -178,6 +186,10 @@ androidComponents { } } +baselineProfile { + mergeIntoMain = true +} + dependencies { // https://developer.android.com/jetpack/androidx/releases/activity implementation(libs.androidx.activity.compose) @@ -255,6 +267,9 @@ dependencies { implementation(libs.cronet.embedded) + implementation(libs.androidx.profileinstaller) + "baselineProfile"(project(":benchmark")) + debugImplementation(libs.compose.ui.tooling) implementation(libs.compose.ui.tooling.preview) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt index 5ebf07ce1..80eb3cdd3 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt @@ -135,6 +135,7 @@ import com.hippo.ehviewer.ui.tools.LabeledCheckbox import com.hippo.ehviewer.ui.tools.LocalDialogState import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass import com.hippo.ehviewer.updater.AppUpdater +import com.hippo.ehviewer.util.AppConfig import com.hippo.ehviewer.util.addTextToClipboard import com.hippo.ehviewer.util.calculateFraction import com.hippo.ehviewer.util.displayString @@ -242,17 +243,19 @@ class MainActivity : EhActivity() { } } - LaunchedEffect(Unit) { - runCatching { dialogState.checkDownloadLocation() } - runCatching { dialogState.checkAppLinkVerify() } - runSuspendCatching { - withIOContext { - AppUpdater.checkForUpdate()?.let { - dialogState.showNewVersion(this@MainActivity, it) + if (!AppConfig.isBenchmark) { + LaunchedEffect(Unit) { + runCatching { dialogState.checkDownloadLocation() } + runCatching { dialogState.checkAppLinkVerify() } + runSuspendCatching { + withIOContext { + AppUpdater.checkForUpdate()?.let { + dialogState.showNewVersion(this@MainActivity, it) + } } + }.onFailure { + snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString())) } - }.onFailure { - snackbarState.showSnackbar(getString(R.string.update_failed, it.displayString())) } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index 7b0319be1..a5925a61d 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.screen import android.content.Context import android.net.Uri import android.view.ViewConfiguration +import androidx.activity.compose.ReportDrawnWhen import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -78,6 +79,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.lerp import androidx.lifecycle.viewModelScope +import androidx.paging.LoadState import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingSource @@ -252,6 +254,7 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c } }.flow.cachedIn(viewModelScope) }.collectAsLazyPagingItems() + ReportDrawnWhen { data.loadState.refresh !is LoadState.Loading } FavouriteStatusRouter.Observe(data) val listMode by Settings.listMode.collectAsState() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/util/AppConfig.kt b/app/src/main/kotlin/com/hippo/ehviewer/util/AppConfig.kt index 71f24638b..9e8c9430a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/util/AppConfig.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/util/AppConfig.kt @@ -37,6 +37,8 @@ object AppConfig { it in setOf("arm64-v8a", "x86_64", "armeabi-v7a") } ?: "universal" + val isBenchmark = "nonMinified" in BuildConfig.BUILD_TYPE || "benchmark" in BuildConfig.BUILD_TYPE + fun matchVariant(name: String) = name.contains(BuildConfig.FLAVOR) && name.contains(abi) val commitTime = ParserUtils.formatDate(BuildConfig.COMMIT_TIME * 1000) diff --git a/benchmark/.gitignore b/benchmark/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/benchmark/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 000000000..5f074632b --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,65 @@ +import com.android.build.api.dsl.ManagedVirtualDevice + +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.baselineprofile) +} + +android { + namespace = "com.ehviewer.baselineprofile" + compileSdk = 34 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + minSdk = 28 + targetSdk = 34 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + targetProjectPath = ":app" + + flavorDimensions += listOf("api") + productFlavors { + create("default") { dimension = "api" } + create("marshmallow") { dimension = "api" } + } + + testOptions.managedDevices.devices { + create("pixel6Api34") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } +} + +baselineProfile { + managedDevices += "pixel6Api34" + useConnectedDevices = false +} + +dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} + +androidComponents { + onVariants { v -> + val artifactsLoader = v.artifacts.getBuiltArtifactsLoader() + v.instrumentationRunnerArguments.put( + "targetAppId", + v.testedApks.map { artifactsLoader.load(it)!!.applicationId }, + ) + } +} diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 000000000..227314eeb --- /dev/null +++ b/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/benchmark/src/main/java/com/ehviewer/baselineprofile/BaselineProfileGenerator.kt b/benchmark/src/main/java/com/ehviewer/baselineprofile/BaselineProfileGenerator.kt new file mode 100644 index 000000000..5879721a8 --- /dev/null +++ b/benchmark/src/main/java/com/ehviewer/baselineprofile/BaselineProfileGenerator.kt @@ -0,0 +1,51 @@ +package com.ehviewer.baselineprofile + +import androidx.benchmark.macro.junit4.BaselineProfileRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Direction +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BaselineProfileGenerator { + + @get:Rule + val rule = BaselineProfileRule() + + @Test + fun generate() { + rule.collect( + packageName = InstrumentationRegistry.getArguments().getString("targetAppId") + ?: throw Exception("targetAppId not passed as instrumentation runner arg"), + includeInStartupProfile = true, + ) { + pressHome() + startActivityAndWait() + + // Onboarding + if (device.hasObject(By.text("Username"))) { + device.findObject(By.scrollable(true))?.run { + scrollUntil(Direction.DOWN, Until.hasObject(By.text("Guest mode"))) + } + device.findObject(By.text("Guest mode")).click() + } + + // Gallery list + device.wait(Until.hasObject(By.text("NON-H")), 10_000) + device.findObject(By.scrollable(true))?.run { + fling(Direction.DOWN) + click() + } + + // Gallery detail + device.wait(Until.hasObject(By.text("Similar")), 10_000) + device.findObject(By.hasDescendant(By.text("Read")).scrollable(true))?.fling(Direction.DOWN) + + device.waitForIdle() + } + } +} diff --git a/benchmark/src/main/java/com/ehviewer/baselineprofile/StartupBenchmarks.kt b/benchmark/src/main/java/com/ehviewer/baselineprofile/StartupBenchmarks.kt new file mode 100644 index 000000000..4ef7a43cb --- /dev/null +++ b/benchmark/src/main/java/com/ehviewer/baselineprofile/StartupBenchmarks.kt @@ -0,0 +1,44 @@ +package com.ehviewer.baselineprofile + +import androidx.benchmark.macro.BaselineProfileMode +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StartupBenchmarks { + + @get:Rule + val rule = MacrobenchmarkRule() + + @Test + fun startupCompilationNone() = + benchmark(CompilationMode.None()) + + @Test + fun startupCompilationBaselineProfiles() = + benchmark(CompilationMode.Partial(BaselineProfileMode.Require)) + + private fun benchmark(compilationMode: CompilationMode) { + rule.measureRepeated( + packageName = InstrumentationRegistry.getArguments().getString("targetAppId") + ?: throw Exception("targetAppId not passed as instrumentation runner arg"), + metrics = listOf(StartupTimingMetric()), + compilationMode = compilationMode, + startupMode = StartupMode.COLD, + iterations = 10, + setupBlock = { + pressHome() + }, + measureBlock = { + startActivityAndWait() + }, + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 839ed35bb..3db8dadc9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,4 +8,6 @@ plugins { alias(libs.plugins.spotless) apply false alias(libs.plugins.aboutlibrariesPlugin) apply false alias(libs.plugins.composeCompilerReportGenerator) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.baselineprofile) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a624081b..99b00625c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,10 @@ okio = "3.9.0" splitties = "3.0.0" telephoto = "0.11.2" xmlutil = "0.90.1" +junit = "1.2.0" +uiautomator = "2.3.0" +benchmark = "1.3.0-beta01" +profileinstaller = "1.4.0-alpha01" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutlibraries" } @@ -110,8 +114,7 @@ serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbo serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "kotlinx-serialization" } -serialization-xml-core = { module = "io.github.pdvrieze.xmlutil:core-android", version.ref = "xmlutil" } -serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-android", version.ref = "xmlutil" } +serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } splitties-appctx = { module = "com.louiscad.splitties:splitties-appctx", version.ref = "splitties" } splitties-arch-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" } @@ -120,10 +123,15 @@ splitties-systemservices = { module = "com.louiscad.splitties:splitties-systemse telephoto-zoomable = { module = "me.saket.telephoto:zoomable", version.ref = "telephoto" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } +androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } + [bundles] coil = ["coil-compose", "coil-gif", "coil-network"] compose = ["compose-foundation", "compose-material-icons-extended", "compose-material3", "compose-material3-adaptive", "compose-ui-util"] -kotlinx-serialization = ["serialization-cbor", "serialization-json", "serialization-json-okio", "serialization-xml-core", "serialization-xml"] +kotlinx-serialization = ["serialization-cbor", "serialization-json", "serialization-json-okio", "serialization-xml"] splitties = ["splitties-appctx", "splitties-arch-room", "splitties-preferences", "splitties-systemservices"] [plugins] @@ -136,3 +144,5 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } spotless = { id = "com.diffplug.spotless", version = "6.25.0" } +android-test = { id = "com.android.test", version.ref = "agp" } +baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" } diff --git a/settings.gradle.kts b/settings.gradle.kts index af6a7ef2d..0c7c47189 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,3 +28,4 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") enableFeaturePreview("STABLE_CONFIGURATION_CACHE") rootProject.name = "EhViewer" include(":app") +include(":benchmark")