Detect Android performance regressions on-device, explained in plain English — one Gradle dependency, zero configuration.
Droid Lens is an Android debug library that attaches to your app in one line, monitors performance in real time, and when something goes wrong — slow frames, memory leaks, Compose recomposition explosions, sluggish startup — it tells you exactly what's wrong and how to fix it, in plain English, using an on-device LLM. No Android Studio. No profiler. No guessing.
┌─────────────────────────────────┐
│ 🔬 Droid Lens ╌╌ ✕ │
├─────────────────────────────────┤
│ FPS: 24 🔴 Frame: 41.2ms │
│ Mem: 187MB / 512MB ████░░ 36% │
│ ▂▃▁▇████▆▃▂▁▃▄▇ ─ ─ 16ms │
├─────────────────────────────────┤
│ 🐌 HIGH Jank Frame │
│ CAUSE: Layout inflation on │
│ main thread in RecyclerView │
│ FIX: Move to background with │
│ AsyncLayoutInflater │
│ [Dismiss] │
├─────────────────────────────────┤
│ 🔄 CRITICAL Recomposition Spike│
│ "ProductCard" 47x/sec (3x base)│
│ ⏳ Analyzing... │
├─────────────────────────────────┤
│ 🤖 AI Ready │
└─────────────────────────────────┘
- 🎯 Zero-configuration setup — one
init()call, everything auto-instruments - 🖼️ Jank frame detection — Choreographer-based frame timing with sub-millisecond precision, P95 tracking, consecutive jank counting
- 🔄 Compose recomposition tracking — detects composables recomposing far above their baseline;
Modifier.droidLensTracked()for targeted monitoring - 💾 Memory leak detection — WeakReference + ReferenceQueue ObjectWatcher pattern (inspired by LeakCanary) with Activity and Fragment auto-watching
- 🚀 App startup tracing — measures
Application.onCreate(), time-to-first-frame, and time-to-interactive via Jetpack Startup - 🧠 On-device LLM diagnosis — Gemma 2B INT4 via MediaPipe runs entirely on-device; your profiling data never leaves the phone
- 📊 Floating overlay UI — drag-repositionable Compose overlay rendered over WindowManager, live sparkline chart, per-issue explanation cards
- 📐 Baseline comparison — Room DB stores session baselines; regression detector uses sliding window comparison against your captured baseline
- 📄 Session export — JSON + Markdown reports with LLM-written summaries, share via FileProvider
- 🔒 Debug-only — use
debugImplementationso zero library code ships to production users
JitPack publishing is planned for v0.1.0 release (Phase 11). Until then, clone and include the module directly.
// settings.gradle.kts
include(":droidlens-core")
project(":droidlens-core").projectDir = file("../DroidLens/droidlens-core")
// app/build.gradle.kts
debugImplementation(project(":droidlens-core"))// settings.gradle.kts
dependencyResolutionManagement {
repositories {
maven { url = uri("https://jitpack.io") }
}
}
// app/build.gradle.kts
debugImplementation("com.github.saswatsroy:droidlens:0.1.0-alpha")class MyApp : Application() {
override fun onCreate() {
super.onCreate()
DroidLens.init(this) {
frameJankThreshold(16L) // flag frames > 16ms
recompositionSpikeMultiplier(3.0f) // flag 3x above baseline
memoryGrowthThreshold(50.0f) // flag 50MB heap growth
overlayPosition(OverlayPosition.TOP_RIGHT)
llmEnabled(true)
llmModelPath(filesDir.absolutePath + "/gemma-2b-it-cpu-int4.bin")
}
DroidLens.captureBaseline() // snapshot current perf as "normal"
DroidLens.showOverlay() // show the floating overlay
}
}@Composable
fun ProductCard(product: Product) {
Column(
modifier = Modifier.droidLensTracked("ProductCard") // 👈 add this
) {
Text(product.name)
// ...
}
}val report: SessionReport = DroidLens.stopSession()
// report.detectedRegressions, report.llmDiagnoses, etc.That's it. Everything else is automatic.
All options have sensible defaults. Override only what you need.
| Option | Type | Default | Description |
|---|---|---|---|
frameJankThreshold |
Long (ms) |
16 |
Frames exceeding this are flagged as jank |
recompositionSpikeMultiplier |
Float |
3.0 |
Flag if recompositions exceed baseline × this |
memoryGrowthThreshold |
Float (MB) |
50.0 |
Flag if heap grows by this much vs baseline |
startupRegressionThreshold |
Long (ms) |
200 |
Flag startup slower than baseline by this |
overlayEnabled |
Boolean |
true |
Show/hide the floating overlay |
overlayPosition |
OverlayPosition |
TOP_RIGHT |
TOP_RIGHT, TOP_LEFT, BOTTOM_RIGHT, BOTTOM_LEFT |
llmEnabled |
Boolean |
true |
Enable on-device LLM diagnosis (disable on low-RAM devices) |
llmModelPath |
String |
"" |
Path to the Gemma 2B INT4 model file |
maxIssuesInOverlay |
Int |
5 |
Max issue cards shown at once |
sessionName |
String |
"default" |
Tag used for baseline grouping |
exportEnabled |
Boolean |
false |
Auto-export JSON report on session end |
exportPath |
String |
"" |
Output path for exports (defaults to filesDir/droidlens/) |
Droid Lens integrates with the OpenRouter API and uses Poolside: Laguna XS.2 (Free) to generate intelligent performance diagnoses and optimization suggestions.
Since inference is performed in the cloud:
- ☁️ No large AI model needs to be downloaded.
- 📱 Minimal impact on device storage and memory.
- ⚡ No significant CPU/GPU usage during profiling.
- 🔄 Model updates are handled remotely.
Sign up for a free account on OpenRouter.
Navigate to:
Account Settings → API Keys
Create a new API key and keep it secure.
Pass the generated API key during SDK initialization.
DroidLens.init(this) {
llmEnabled(true)
openRouterApiKey("sk-or-v1-...")
}Because Droid Lens communicates with OpenRouter over the internet, ensure your application declares the following permission in the Android Manifest:
<uses-permission android:name="android.permission.INTERNET" />Never hardcode or commit API keys to public repositories.
Recommended approach:
- Store secrets in
local.properties - Expose them through
BuildConfig - Exclude sensitive files from version control
Example:
# local.properties
OPENROUTER_API_KEY=sk-or-v1-...openRouterApiKey(BuildConfig.OPENROUTER_API_KEY)Droid Lens is designed to work even when AI-based diagnosis is unavailable.
The SDK automatically falls back to its built-in rule engine when:
- ❌
llmEnabled(false)is configured - 📶 No internet connection is available
- 🔑 OpenRouter API key is missing or invalid
⚠️ LLM requests fail unexpectedly
This ensures profiling insights remain available without external dependencies.
Detected Issue
Frame rendering exceeded the target render budget.
Diagnosis
Frame exceeded render budget.
Check for blocking operations on the main thread.
Consider using LaunchedEffect or background execution
for expensive work.
Detected Issue
Excessive or unnecessary Compose recompositions.
Diagnosis
Excessive recomposition detected.
Common causes:
• Unstable State objects
• Non-stable class parameters
• Lambda captures creating new instances during composition
Detected Issue
Objects remain in memory after their expected lifecycle has ended.
Diagnosis
Object retained after expected lifecycle end.
Check for:
• Static references
• Unregistered listeners
• Inner classes holding outer class references
• Lifecycle-aware cleanup issues
For the best developer experience:
DroidLens.init(this) {
llmEnabled(true)
openRouterApiKey(BuildConfig.OPENROUTER_API_KEY)
}This enables:
- 🤖 AI-powered diagnostics
- 📈 More contextual performance analysis
- 🛠 Actionable optimization recommendations
- 🔄 Automatic fallback to rule-based insights when needed
droidlens/
├── droidlens-core/ # Main library module (AAR)
│ ├── instrumentation/
│ │ ├── FrameInstrumentation.kt # Choreographer frame timing
│ │ ├── ComposeRecompositionTracker.kt # Recomposition counting
│ │ ├── MemoryWatcher.kt # Heap tracking + ObjectWatcher
│ │ └── StartupTracer.kt # App startup phase timing
│ ├── analysis/
│ │ ├── BaselineManager.kt # Room DB: store + retrieve baselines
│ │ ├── RegressionDetector.kt # Sliding window comparison engine
│ │ └── AnomalyClassifier.kt # Classifies regression type + severity
│ ├── llm/
│ │ ├── LLMInferenceEngine.kt # MediaPipe LLM Inference wrapper
│ │ ├── PromptBuilder.kt # Structured JSON → LLM prompt
│ │ └── DiagnosisResult.kt # Sealed class for LLM output
│ ├── overlay/
│ │ ├── DroidLensOverlay.kt # WindowManager floating overlay
│ │ ├── OverlayViewModel.kt # State management for overlay UI
│ │ └── composables/
│ │ ├── MetricsDashboard.kt # Live frame rate + memory graph
│ │ ├── IssueCard.kt # Tappable issue with LLM explanation
│ │ └── SparklineChart.kt # Lightweight real-time chart (Canvas)
│ ├── db/
│ │ ├── DroidLensDatabase.kt # Room database definition
│ │ ├── BaselineDao.kt
│ │ ├── DetectedIssueDao.kt
│ │ └── entities/
│ │ ├── BaselineSnapshot.kt
│ │ └── DetectedIssue.kt
│ ├── config/
│ │ └── DroidLensConfig.kt # Configuration DSL
│ └── DroidLens.kt # Public API entry point
└── app/ # Demo + test application
├── MainActivity.kt
├── JankSimulator.kt # Intentional jank frames for testing
└── LeakSimulator.kt # Intentional object leaks for testing
Choreographer ──► FrameInstrumentation ──► CircularFrameBuffer
│
ComposeModifier ──► RecompositionTracker │
▼
WeakReference ──► ObjectWatcher ──► MemoryWatcher
│
Jetpack Startup ──► StartupTracer │
▼
RegressionDetector
(sliding window
vs. baseline)
│
▼
LLMInferenceEngine
(Gemma 2B / fallback)
│
▼
OverlayViewModel
(StateFlow/Flow)
│
▼
DroidLensOverlay
(WindowManager + Compose)
| Work | Thread |
|---|---|
| Choreographer frame callback | Main (< 0.1ms, immediately dispatched) |
| Frame analysis, regression detection | Dispatchers.Default |
| Room DB reads/writes | Dispatchers.IO |
| LLM inference | Dispatchers.IO (30s timeout) |
| ObjectWatcher GC checks | Dispatchers.IO |
| Overlay UI rendering | Main (Compose, excluded from measurements) |
// Lifecycle
DroidLens.init(application, config) // initialise; call in Application.onCreate()
DroidLens.startSession(name) // begin a named profiling session
DroidLens.stopSession(): SessionReport // end session, get report
DroidLens.isRunning(): Boolean
// Baseline management
DroidLens.captureBaseline() // snapshot current metrics as "normal"
DroidLens.clearBaseline()
// Overlay
DroidLens.showOverlay() // requires DRAW_OVER_APPS permission
DroidLens.hideOverlay()
// Report
DroidLens.getSessionReport(): SessionReport
DroidLens.shareReport() // triggers system share sheetdata class SessionReport(
val sessionName: String,
val durationMs: Long,
val totalJankFrames: Int,
val avgFrameTimeMs: Float,
val recompositionSpikes: List<RecompositionSpike>,
val memoryLeaks: List<MemoryLeak>,
val detectedRegressions: List<Regression>,
val llmDiagnoses: List<DiagnosisResult>,
val exportedJsonPath: String?
)Jank frames
| Severity | Condition |
|---|---|
MEDIUM |
frameTimeMs > threshold (e.g. > 16ms) |
HIGH |
frameTimeMs > threshold × 2 (e.g. > 32ms) |
CRITICAL |
frameTimeMs > threshold × 3 (e.g. > 48ms) |
Recomposition spikes
| Severity | Condition |
|---|---|
MEDIUM |
count > baseline × 2 |
HIGH |
count > baseline × 3 |
CRITICAL |
count > baseline × 5 |
Memory growth
| Severity | Condition |
|---|---|
MEDIUM |
growth > threshold |
HIGH |
growth > threshold × 1.5 |
CRITICAL |
growth > threshold × 2 |
The included app/ module has simulators for every regression type:
// JankSimulator.kt
Button("Simulate Jank") // blocks main thread 50ms once
Button("Simulate Sustained Jank")// blocks 20ms/frame for 3 seconds
// LeakSimulator.kt
Button("Leak an Activity") // static reference to finished Activity
Button("Leak a Coroutine Scope") // CoroutineScope in composable, never cancelled./gradlew :droidlens-core:testDebugUnitTest./gradlew :droidlens-core:connectedAndroidTest./gradlew clean build
./gradlew :droidlens-core:lint
./gradlew :droidlens-core:assembleRelease # produces AAR- Phase 1 — Project scaffold + Gradle setup
- Phase 2 — Configuration DSL + public API
- Phase 3 — Frame instrumentation (Choreographer)
- Phase 4 — Compose recomposition tracking
- Phase 5 — Memory leak detection (ObjectWatcher)
- Phase 6 — Room DB baseline storage + regression detector
- Phase 7 — On-device LLM inference (MediaPipe + Gemma 2B)
- Phase 8 — Floating overlay UI (Compose + WindowManager)
- Phase 9 — App startup tracer (Jetpack Startup)
- Phase 10 — Session export (JSON + Markdown reports)
- Phase 11 — JitPack publishing (
com.github.saswatsroy:droidlens:0.1.0-alpha) - Phase 12 — Optional Spring Boot backend (team dashboard, Kafka CI alerts)
Contributions are welcome! Please follow these steps:
- Fork the repository
- Create a feature branch:
git checkout -b feature/your-feature - Follow the existing code style — KDoc on every public function,
try-catchor sealed-class result on every coroutine and IO call - Write unit tests for any new logic (use
TestCoroutineDispatcherfor coroutine tests) - Run
./gradlew clean build lint testDebugUnitTest— all must pass - Submit a pull request with a clear description
- All async work via Kotlin Coroutines + Flow — no
AsyncTask, noThread - Main thread frame callback overhead must stay under 0.1ms
- No new external dependencies without discussion — the library keeps its footprint lean
Dispatchers.Defaultfor CPU-bound analysis,Dispatchers.IOfor DB and file work
Copyright 2024 Saswat Suman Roy
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Saswat Suman Roy NIT Rourkela
Droid Lens — because your users shouldn't be your profiler.
