Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ jobs:
- name: Static Analysis
run: ./gradlew lintKotlin detektAll

- run: echo "Assembling main outputs"
- run: echo "Assembling production and benchmark outputs"
- name: Assemble
run: ./gradlew assemble
run: >
./gradlew
:app:assembleLiveDebug
:app:assembleLiveRelease
:baselineprofile:assembleFixtureBenchmarkRelease
- run: echo "Build status report=${{ job.status }}."

- run: echo "Uploading build artifacts"
- name: Upload a Build Artifact (APK)
uses: actions/upload-artifact@v4
with:
name: app
path: app/build/outputs/apk/debug/app-debug.apk
name: app-live-release
path: app/build/outputs/apk/live/release/app-live-release.apk
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ For the details of handling the preview of composable functions in this code-ba
- CI
- Git Hooks
- GitHub Actions
- Baseline and Startup Profiles
- Static Analysis(Kotlinter, Detekt) (For the detail, please read [this article](https://blog.kotlin-academy.com/detekt-gradle-configuration-guide-d6d2301b823a))

### We are porting the project to KMP. Here are the steps:
Expand Down Expand Up @@ -75,6 +76,67 @@ For the details of handling the preview of composable functions in this code-ba
### WearOS devices (Android-based smartwatches)
![Wear OS screenshots](asset/wearos.jpg)

## 🚀 Baseline Profiles and startup benchmarks

The `baselineprofile` module generates Baseline and Startup Profiles for the app and measures their
effect on cold startup. Profile generation and benchmarks use the `fixture` backend flavor, which
returns deterministic market data and avoids depending on the rate-limited production API. The
`live` flavor continues to use the production backend.

Fixture-only implementation classes are excluded from generated profile rules. The resulting
profiles are merged into the main source set and packaged with the production release:

```text
app/src/main/generated/baselineProfiles/baseline-prof.txt
app/src/main/generated/baselineProfiles/startup-prof.txt
```

### Generate the profiles

Start an API 33+ emulator or connect an API 33+ physical device, then run:

```bash
ANDROID_SERIAL=<device-serial> ./gradlew :app:generateBaselineProfile
```

Use `adb devices -l` to find the device serial. Profile generation can run on an emulator, but
performance benchmarks should run on a physical device.

### Measure startup performance

Run the cold-start benchmarks on a physical device:

```bash
ANDROID_SERIAL=<device-serial> \
./gradlew :baselineprofile:connectedFixtureBenchmarkReleaseAndroidTest
```

The benchmark compares startup with no compilation against startup with the generated Baseline
Profile. It records:

- **TTID (Time To Initial Display):** time until the first activity frame is rendered.
- **TTFD (Time To Full Display):** time until market content is loaded and the screen reports that
it is fully drawn.

Results and Perfetto traces are written under:

```text
baselineprofile/build/outputs/connected_android_test_additional_output/
```

### Reference result

The following result was measured over 20 cold-start iterations on a physical Samsung SM-S731B
running Android 16 (API 36):

| Metric | No profile | Baseline Profile | Improvement |
|---|---:|---:|---:|
| Median TTID | 294.4 ms | 263.6 ms | 10.5% faster |
| Median TTFD | 428.2 ms | 340.8 ms | 20.4% faster |

Benchmark numbers are device-specific and should primarily be used to detect regressions and
compare changes under the same test conditions.

## Additional Resources

- [Git Hooks](documentation/GitHooks.md) - Learn about Git Hooks used in this project for code formatting and analysis.
Expand Down
26 changes: 26 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.detekt)
alias(libs.plugins.kotliner)
alias(libs.plugins.kotlinx.serialization)
alias(libs.plugins.androidx.baselineprofile)
}

android {
Expand Down Expand Up @@ -59,6 +60,19 @@ android {
compose = true
}

// Use a separate build variant to be able to provide a mock version of MarketApi for
// benchmark build
flavorDimensions += "backend"
productFlavors {
create("live") {
dimension = "backend"
}

create("fixture") {
dimension = "backend"
}
}

testOptions {
unitTests {
isIncludeAndroidResources = true
Expand Down Expand Up @@ -87,13 +101,25 @@ kotlin {
}
}

baselineProfile {
hideSyntheticBuildTypesInAndroidStudio = true

variants {
create("fixtureRelease") {
mergeIntoMain = true
from(project(":baselineprofile"))
}
}
}

dependencies {
// Koin
implementation(libs.koin.android)
implementation(libs.koin.workmanager)

// Compose BOM
implementation(platform(libs.compose.bom))
implementation(libs.profileinstaller)
androidTestImplementation(platform(libs.compose.bom))

// Test
Expand Down
18 changes: 18 additions & 0 deletions app/src/fixture/java/ir/composenews/BackendModules.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ir.composenews

import ir.composenews.remotedatasource.api.MarketsApi
import org.koin.dsl.module

internal val fixtureRemoteDataSourceModule =
module {
single<MarketsApi> { FixtureMarketsApi() }
}

/**
* This replaces only MarketsApi. The existing MarketRepositoryImpl, MarketsPagingSource,
* SQLDelight storage, domain use cases, and UI remain active.
*/
internal val backendModules =
listOf(
fixtureRemoteDataSourceModule,
)
68 changes: 68 additions & 0 deletions app/src/fixture/java/ir/composenews/FixtureMarketsApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package ir.composenews

import ir.composenews.network.ApiResponse
import ir.composenews.remotedatasource.api.MarketsApi
import ir.composenews.remotedatasource.dto.MarketChartResponse
import ir.composenews.remotedatasource.dto.MarketDetailResponse
import ir.composenews.remotedatasource.dto.MarketResponse

// Keep benchmark fixture data deterministic.
@Suppress("MagicNumber")
internal class FixtureMarketsApi : MarketsApi {
// There are 60 entries so the LazyColumn is
// guaranteed to be scrollable.
private val markets =
(1..60).map { index ->
MarketResponse(
id = "market-$index",
name = "Market $index",
symbol = "m$index",
currentPrice = 1_000.0 + index,
priceChangePercentage24h = index / 10.0,
imageUrl = "",
)
}

override suspend fun getMarkets(
currency: String,
order: String,
perPage: Int,
page: Int,
sparkline: Boolean,
): ApiResponse<List<MarketResponse>> {
val offset = ((page - 1) * perPage).coerceAtLeast(0)
val pageItems = markets.drop(offset).take(perPage)
return ApiResponse.Success(pageItems)
}

override suspend fun getMarketChart(
id: String,
currency: String,
days: Int,
): ApiResponse<MarketChartResponse> =
ApiResponse.Success(
MarketChartResponse(
prices =
listOf(
listOf(1_000.0, 50_000.0),
listOf(2_000.0, 51_000.0),
),
),
)

override suspend fun getMarketDetail(id: String): ApiResponse<MarketDetailResponse> =
ApiResponse.Success(
MarketDetailResponse(
id = id,
marketCapRank = 1,
name = "Fixture Market",
marketData =
MarketDetailResponse.MarketData(
high24h = MarketDetailResponse.MarketData.High24h(51_000.0),
low24h = MarketDetailResponse.MarketData.Low24h(49_000.0),
marketCap = MarketDetailResponse.MarketData.MarketCap(1_000_000_000L),
marketCapRank = 1,
),
),
)
}
8 changes: 8 additions & 0 deletions app/src/live/java/ir/composenews/BackendModules.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package ir.composenews

import ir.composenews.remotedatasource.di.remoteDatasourceModule

internal val backendModules =
listOf(
remoteDatasourceModule,
)
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
android:name="ir.composenews.ui.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ComposeNews">
android:theme="@style/Theme.ComposeNews"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

Expand Down
Loading
Loading