
Instruments classes at compile time to automatically track and visualize Compose State, Flows and function actions; adds diagram-style variable captures, optional stack traces, and pluggable loggers.
@Debuggable is a library that leverages the Kotlin Compiler Plugin (KCP) to automatically track and visualize state changes in Compose State and Coroutines Flow, as well as actions inside ViewModels/functions.
The compiler takes care of the tedious work of instrumenting logs and preventing memory leaks (unsubscribing observers), dramatically improving the debugging experience during development.
plugins {
kotlin("jvm") // or kotlin("android"), kotlin("multiplatform")
id("me.tbsten.debuggablecompilerplugin") version "0.2.0"
}Annotate a class with @Debuggable and every State, Flow, and var inside it is automatically tracked and logged.
@Debuggable
class SearchViewModel : ViewModel() {
// value changes are logged automatically
val searchQuery = MutableStateFlow("")
val uiState by mutableStateOf(UiState())
var count = 0
// function calls (actions) are also logged with their arguments
fun onSearchClicked(query: String) { ... }
}Use @IgnoreDebuggable to exclude a property, or @FocusDebuggable to switch the class into focus mode where only annotated members are tracked:
@Debuggable(isSingleton = true)
object UserForm {
var name: String = "" // tracked automatically
var age: Int = 0 // tracked automatically
@IgnoreDebuggable var internal: String = "" // excluded
}
// UserForm.name = "daisy" → "[Debuggable] name: daisy"
// UserForm.age = 30 → "[Debuggable] age: 30"
@Debuggable(isSingleton = true)
object UserForm2 {
var name: String = "" // excluded (focus mode active)
var age: Int = 0 // excluded
@FocusDebuggable var debugTarget: String = "" // tracked
}
// UserForm2.debugTarget = "new value" → "[Debuggable] debugTarget: new value"Add diagram = true to log intermediate variable values at each method call site — similar to Kotlin Power-Assert. The compiler rewrites call sites to capture the value of each input variable and emit them alongside the call:
@Debuggable(isSingleton = true, diagram = true)
object Calc {
fun process(value: Int): Int = value * 2
}
fun example() {
val h = 123
val f = 456
Calc.process(h + f)
}
// Logged: process(h + f) // h=123, f=456The inline comment shows each leaf variable and its concrete value at the moment of the call, making it easy to understand what was actually passed without a debugger.
Note: When the argument is a literal constant (e.g.
Calc.process(42)), there are no variable captures and the log is omitted.diagram = truealso replaces the normallogActionlog for that class — functions are not double-logged.
Add captureStack = true to append the caller's stack trace to every logAction log entry, making it easy to trace where a method was called from.
@Debuggable(isSingleton = true, captureStack = true)
object Counter {
fun tick(): Int { /* ... */ }
}
// Output:
// [Debuggable] tick()
// at com.example.MainActivity.onButtonClick(MainActivity.kt:42)
// at com.example.MainActivity$lambda(MainActivity.kt:28)Note: Stack capture is only supported on JVM and Android targets. On JS, wasmJs, and Native the log is emitted normally without a stack trace.
By default, logs go to Android Logcat (tag "Debuggable") on Android and stdout ([Debuggable] … prefix) everywhere else. You can route them to Timber, a file, a test collector, or anything else using any of three mechanisms (higher wins):
| Priority | Mechanism | Scope |
|---|---|---|
| 1 | @Debuggable(logger = MyLogger::class) |
The annotated class / variable |
| 2 | Gradle DSL debuggable { defaultLogger.set("FQN") }
|
The entire module (compile-time) |
| 3 | DefaultDebugLogger.current = DebugLogger { ... } |
The entire process (runtime) |
| 4 | Platform default (Logcat on Android, stdout elsewhere) | — |
All logger targets must be singleton object declarations that implement DebugLogger.
import me.tbsten.debuggable.runtime.logging.*
// (1) Per-class override
object AuthLogger : DebugLogger {
override fun log(receiver: Any?, propertyName: String, value: Any?) =
Log.d("Auth", "$propertyName: $value")
}
@Debuggable(isSingleton = true, logger = AuthLogger::class)
object AuthStore { /* ... */ }
// (2) Module-wide (Gradle DSL) — see section 4.
// (3) Process-wide runtime swap — set once during app startup:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
DefaultDebugLogger.current = AndroidLogcatLogger // built-in
}
}Built-in loggers shipped in debuggable-runtime:
| Logger | Source set | Description |
|---|---|---|
DebugLogger.Stdout |
commonMain |
println("[Debuggable] ...") sink. Default on non-Android platforms. |
SilentLogger |
commonMain | No-op sink. Silences logs while keeping the plugin enabled. |
PrefixedLogger(prefix, delegate) |
commonMain | Prepends a prefix and forwards to another DebugLogger. |
InMemoryLogger() |
commonMain | Records every message into a list. Designed for unit tests — assert against logger.messages. |
CompositeLogger(vararg loggers) |
commonMain | Fans out every message to multiple sinks (e.g. keep stdout while also capturing in memory). |
FileLogger(file, append) |
jvm + androidMain | Appends each message as a line to a java.io.File. Thread-safe; creates parent dirs; flushes per write. |
AndroidLogcatLogger / AndroidLogcatLogger(tag)
|
androidMain |
Log.d(tag, message). Default tag is "Debuggable". Default on Android. |
In-app log viewer (optional module debuggable-ui):
implementation("me.tbsten.debuggablecompilerplugin:debuggable-ui:0.2.0")val uiLogger = remember { UiDebugLogger(bufferSize = 500) }
DisposableEffect(uiLogger) {
val prev = DefaultDebugLogger.current
DefaultDebugLogger.current = uiLogger
onDispose { DefaultDebugLogger.current = prev }
}
DebuggableLogViewer(uiLogger, modifier = Modifier.fillMaxSize())UiDebugLogger maintains a ring buffer (default 1000 entries) as a StateFlow, and DebuggableLogViewer is a Compose Multiplatform composable that renders it with built-in substring filtering and auto-scroll. Currently ships for jvm + androidTarget — other KMP targets are follow-ups.
The Gradle plugin exposes per-feature toggles and a compile-time default logger so individual aspects can be disabled or redirected without runtime setup.
debuggable {
enabled.set(true) // master switch (default: true)
observeFlow.set(true) // wrap Flow/State initializers (default: true)
logAction.set(true) // log public method calls (default: true)
defaultLogger.set("") // FQN of a DebugLogger object to use as the module-wide
// default (empty = DefaultDebugLogger, which can still be
// replaced at runtime). Example:
// defaultLogger.set("com.example.myapp.MyDebugLogger")
}When enabled = false, the plugin is a complete no-op — no IR transformations, no runtime dependency surfaces in the output binary. When observeFlow or logAction is individually disabled, only that transformation is skipped.
A high-level view of what the plugin injects into your classes at compile time. You don't need to know any of this to use @Debuggable, but it helps when something behaves unexpectedly.
It determines the type of the class and injects runtime unsubscription logic.
DebugCleanupRegistry backing field (initialized inline) and wraps the existing close() body in try { ...original... } finally { registry.close() }. For ViewModels this fires when onCleared() runs (since ViewModel implements AutoCloseable as of Lifecycle 2.7); for plain AutoCloseable it fires on explicit close(). If the class inherits close() without overriding it, a compile-time warning is emitted instead of silently leaking.@Debuggable local is wrapped in try { ... } finally { registry.close() }, running cleanup on every function-exit path.Initialization expressions for target State or Flow properties are wrapped with debugging functions provided by the Runtime library. The wrapper launches a background observation in the registry's coroutine scope and returns the underlying state/flow unchanged.
// Before transformation
val uiState = mutableStateOf(UiState())
// After IR transformation (conceptual)
val uiState = mutableStateOf(UiState())
.debuggableState(name = "uiState", registry = $$debuggable_registry, logger = DefaultDebugLogger)The compiler checks the fully qualified class name (FQDN) of properties to determine whether the type is trackable.
androidx.compose.runtime.State / MutableState
kotlinx.coroutines.flow.Flow / StateFlow / MutableStateFlow
If @FocusDebuggable or similar is applied to a type other than these, a warning is emitted at compile time.
When enabled.set(false) is configured on the Gradle plugin side (the default for Release builds), the KCP performs no IR transformations. No debugging code or Runtime library dependencies remain in the production binary, so there is zero performance impact.
⚠️ Important: With default settings,@Debuggablelogs the current value of every trackedState/Flowand the full argument list of every annotated action — using each value'stoString(). That can include tokens, passwords, email addresses, and other personal data. Do not ship production builds with Debuggable enabled unless you understand this trade-off.
Recommended configurations:
// Recommended for most apps — Debuggable only in debug-style builds.
debuggable {
enabled.set(
providers.gradleProperty("debuggable.enabled")
.map { it.toBoolean() }
.orElse(project.findProperty("buildType") != "release"),
)
}// Alternative — keep the plugin enabled but silence output in release.
// Useful if you want the cleanup side of the injection (AutoCloseable /
// try-finally registry.close()) without the logs.
import me.tbsten.debuggable.runtime.logging.SilentLogger
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.BUILD_TYPE == "release") {
DefaultDebugLogger.current = SilentLogger
}
}
}Per-property / per-class opt-outs:
@IgnoreDebuggable on a property to exclude a single State / Flow from tracking.@Debuggable(logger = MyLogger::class) with MyLogger redacting sensitive values before forwarding to Logcat / stdout.@FocusDebuggable to flip a class into "only these properties" mode — everything else is ignored.R8 / ProGuard: the runtime AAR ships consumer rules under META-INF/proguard/debuggable-runtime.pro, so Android consumers don't need any keep rules of their own. The injected calls survive minification automatically.
Behaviors the plugin automatically manages for you. Reading this section helps you predict when your logs will appear and how to filter what gets tracked.
The observation lifecycle is automatically determined based on the nature of the class.
| Target | Condition | Cleanup Timing |
|---|---|---|
| ViewModel | Inherits from ViewModel
|
onCleared() (via the AutoCloseable.close() that ViewModel implements as of Lifecycle 2.7) |
| AutoCloseable | Implements AutoCloseable
|
When the close() method is executed |
| Singleton | isSingleton = true |
None (persists until process termination) |
| Local Variable | Variable inside a function | When function execution ends (Scope Exit) |
// Temporary observation inside a function
fun performTask() {
@Debuggable
val tempState = mutableStateOf("Pending")
// Automatically cleaned up when leaving the function
}
// When treating as a singleton
@Debuggable(isSingleton = true)
class GlobalSettings { ... }By default every Flow, State, and var in a @Debuggable class is tracked. Two annotations let you adjust that:
@IgnoreDebuggable — exclude a single property or function from tracking (default mode).@FocusDebuggable — switch the entire class into focus mode: only members explicitly annotated with @FocusDebuggable are tracked; everything else is ignored.@Debuggable
class ComplexViewModel : ViewModel() {
val trackedState = mutableStateOf(0) // tracked by default
@IgnoreDebuggable
val noiseState = mutableStateOf("") // excluded
}
// Focus mode — only @FocusDebuggable members are tracked
@Debuggable
class SelectiveViewModel : ViewModel() {
@FocusDebuggable
val importantState = mutableStateOf(0) // tracked
val otherState = mutableStateOf("") // ignored (focus mode active)
}The repository ships with two runnable samples under integration-test/ that consume the plugin from mavenLocal().
From the repo root:
./gradlew publishToMavenLocalThis installs debuggable-runtime, debuggable-compiler, and debuggable-gradle (version 0.2.0) into ~/.m2/.
| Sample | Target | Lifecycle pattern | README |
|---|---|---|---|
integration-test/cmp |
Compose Multiplatform Desktop (JVM) | @Debuggable(isSingleton = true) object |
cmp/README.md |
integration-test/android |
Android app | @Debuggable class : ViewModel(), AutoCloseable |
android/README.md |
Each sample README explains how to run it, what to click, and where in the source the @Debuggable annotation is applied. See integration-test/README.md for a side-by-side summary.
Both verification scripts support a parallel mode that mirrors CI's matrix on a single machine:
./scripts/smoke-test-all.sh # parallel (default: min(nproc/2, 4))
./scripts/smoke-test-all.sh --serial # one version at a time (easier to debug)
./scripts/smoke-test-all.sh --parallel 6 # override worker count
DEBUGGABLE_PARALLEL=6 ./scripts/test-all.shEach worker runs in an isolated rsync'd copy of the project (.local/tmp/…) so parallel Gradle invocations don't collide on build/. Per-version logs land in .local/tmp/{smoke,test}-all-<version>.log.
Every Kotlin 2.0+ stable patch through the latest beta is supported. Verified end-to-end
by scripts/smoke-test-all.sh (integration build of integration-test/cmp with each
target compiler) and scripts/test-all.sh (:debuggable-compiler:test under the
matching kctfork):
| Kotlin version | Status |
|---|---|
| 2.4.0-Beta1 | ✅ Verified |
| 2.3.21-RC2 | ✅ Verified |
| 2.3.20 | ✅ Verified (pinned build) |
| 2.3.10 | ✅ Verified |
| 2.3.0 | ✅ Verified |
| 2.2.21 | ✅ Verified |
| 2.2.20 | ✅ Verified |
| 2.2.10 | ✅ Verified |
| 2.2.0 | ✅ Verified |
| 2.1.21 | ✅ Verified |
| 2.1.20 | ✅ Verified |
| 2.1.10 | ✅ Verified |
| 2.1.0 | ✅ Verified |
| 2.0.21 | ✅ Verified |
| 2.0.20 | ✅ Verified |
| 2.0.10 | ✅ Verified |
| 2.0.0 | ✅ Verified |
The IR transformation logic lives in a metro-style per-Kotlin-version compat layer so a single plugin artifact can dispatch to whichever implementation matches the consumer's Kotlin compiler:
| Module | minVersion |
Target compiler | Notes |
|---|---|---|---|
debuggable-compiler-compat-k2000 |
2.0.0 | 2.0.10 | Pre-builders.kt split; createDiagnosticReporter still returns IrMessageLogger
|
debuggable-compiler-compat-k2020 |
2.0.20 | 2.0.21 | 2.1.20 未満 IR API (putValueArgument / extensionReceiver= / valueParameters) |
debuggable-compiler-compat-k21 |
2.1.20 | 2.1.21 | New arg/receiver APIs, but irCall etc. still on IrBuilderWithScope
|
debuggable-compiler-compat-k23 |
2.2.0 | 2.3.20 |
irCall on IrBuilder; new arguments[param]= API is the only one used |
At runtime, IrInjectorLoader (in debuggable-compiler/compat/) enumerates
IrInjector.Factory via ServiceLoader, skips any factories whose classes can't be
linked on the current runtime (e.g. k23 fails on 2.0.x because IrBuilder.irCall
doesn't exist there), and picks the highest minVersion that is still ≤ the running
compiler. 2.4.0-Beta1 goes through a reflection-only helper for the FirExtensionRegistrarAdapter
/ getAnnotation signature drift, which is narrow enough to live inside compat-k23.
See .github/workflows/ci.yml for the 17-version CI matrix.
@Debuggable is a library that leverages the Kotlin Compiler Plugin (KCP) to automatically track and visualize state changes in Compose State and Coroutines Flow, as well as actions inside ViewModels/functions.
The compiler takes care of the tedious work of instrumenting logs and preventing memory leaks (unsubscribing observers), dramatically improving the debugging experience during development.
plugins {
kotlin("jvm") // or kotlin("android"), kotlin("multiplatform")
id("me.tbsten.debuggablecompilerplugin") version "0.2.0"
}Annotate a class with @Debuggable and every State, Flow, and var inside it is automatically tracked and logged.
@Debuggable
class SearchViewModel : ViewModel() {
// value changes are logged automatically
val searchQuery = MutableStateFlow("")
val uiState by mutableStateOf(UiState())
var count = 0
// function calls (actions) are also logged with their arguments
fun onSearchClicked(query: String) { ... }
}Use @IgnoreDebuggable to exclude a property, or @FocusDebuggable to switch the class into focus mode where only annotated members are tracked:
@Debuggable(isSingleton = true)
object UserForm {
var name: String = "" // tracked automatically
var age: Int = 0 // tracked automatically
@IgnoreDebuggable var internal: String = "" // excluded
}
// UserForm.name = "daisy" → "[Debuggable] name: daisy"
// UserForm.age = 30 → "[Debuggable] age: 30"
@Debuggable(isSingleton = true)
object UserForm2 {
var name: String = "" // excluded (focus mode active)
var age: Int = 0 // excluded
@FocusDebuggable var debugTarget: String = "" // tracked
}
// UserForm2.debugTarget = "new value" → "[Debuggable] debugTarget: new value"Add diagram = true to log intermediate variable values at each method call site — similar to Kotlin Power-Assert. The compiler rewrites call sites to capture the value of each input variable and emit them alongside the call:
@Debuggable(isSingleton = true, diagram = true)
object Calc {
fun process(value: Int): Int = value * 2
}
fun example() {
val h = 123
val f = 456
Calc.process(h + f)
}
// Logged: process(h + f) // h=123, f=456The inline comment shows each leaf variable and its concrete value at the moment of the call, making it easy to understand what was actually passed without a debugger.
Note: When the argument is a literal constant (e.g.
Calc.process(42)), there are no variable captures and the log is omitted.diagram = truealso replaces the normallogActionlog for that class — functions are not double-logged.
Add captureStack = true to append the caller's stack trace to every logAction log entry, making it easy to trace where a method was called from.
@Debuggable(isSingleton = true, captureStack = true)
object Counter {
fun tick(): Int { /* ... */ }
}
// Output:
// [Debuggable] tick()
// at com.example.MainActivity.onButtonClick(MainActivity.kt:42)
// at com.example.MainActivity$lambda(MainActivity.kt:28)Note: Stack capture is only supported on JVM and Android targets. On JS, wasmJs, and Native the log is emitted normally without a stack trace.
By default, logs go to Android Logcat (tag "Debuggable") on Android and stdout ([Debuggable] … prefix) everywhere else. You can route them to Timber, a file, a test collector, or anything else using any of three mechanisms (higher wins):
| Priority | Mechanism | Scope |
|---|---|---|
| 1 | @Debuggable(logger = MyLogger::class) |
The annotated class / variable |
| 2 | Gradle DSL debuggable { defaultLogger.set("FQN") }
|
The entire module (compile-time) |
| 3 | DefaultDebugLogger.current = DebugLogger { ... } |
The entire process (runtime) |
| 4 | Platform default (Logcat on Android, stdout elsewhere) | — |
All logger targets must be singleton object declarations that implement DebugLogger.
import me.tbsten.debuggable.runtime.logging.*
// (1) Per-class override
object AuthLogger : DebugLogger {
override fun log(receiver: Any?, propertyName: String, value: Any?) =
Log.d("Auth", "$propertyName: $value")
}
@Debuggable(isSingleton = true, logger = AuthLogger::class)
object AuthStore { /* ... */ }
// (2) Module-wide (Gradle DSL) — see section 4.
// (3) Process-wide runtime swap — set once during app startup:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
DefaultDebugLogger.current = AndroidLogcatLogger // built-in
}
}Built-in loggers shipped in debuggable-runtime:
| Logger | Source set | Description |
|---|---|---|
DebugLogger.Stdout |
commonMain |
println("[Debuggable] ...") sink. Default on non-Android platforms. |
SilentLogger |
commonMain | No-op sink. Silences logs while keeping the plugin enabled. |
PrefixedLogger(prefix, delegate) |
commonMain | Prepends a prefix and forwards to another DebugLogger. |
InMemoryLogger() |
commonMain | Records every message into a list. Designed for unit tests — assert against logger.messages. |
CompositeLogger(vararg loggers) |
commonMain | Fans out every message to multiple sinks (e.g. keep stdout while also capturing in memory). |
FileLogger(file, append) |
jvm + androidMain | Appends each message as a line to a java.io.File. Thread-safe; creates parent dirs; flushes per write. |
AndroidLogcatLogger / AndroidLogcatLogger(tag)
|
androidMain |
Log.d(tag, message). Default tag is "Debuggable". Default on Android. |
In-app log viewer (optional module debuggable-ui):
implementation("me.tbsten.debuggablecompilerplugin:debuggable-ui:0.2.0")val uiLogger = remember { UiDebugLogger(bufferSize = 500) }
DisposableEffect(uiLogger) {
val prev = DefaultDebugLogger.current
DefaultDebugLogger.current = uiLogger
onDispose { DefaultDebugLogger.current = prev }
}
DebuggableLogViewer(uiLogger, modifier = Modifier.fillMaxSize())UiDebugLogger maintains a ring buffer (default 1000 entries) as a StateFlow, and DebuggableLogViewer is a Compose Multiplatform composable that renders it with built-in substring filtering and auto-scroll. Currently ships for jvm + androidTarget — other KMP targets are follow-ups.
The Gradle plugin exposes per-feature toggles and a compile-time default logger so individual aspects can be disabled or redirected without runtime setup.
debuggable {
enabled.set(true) // master switch (default: true)
observeFlow.set(true) // wrap Flow/State initializers (default: true)
logAction.set(true) // log public method calls (default: true)
defaultLogger.set("") // FQN of a DebugLogger object to use as the module-wide
// default (empty = DefaultDebugLogger, which can still be
// replaced at runtime). Example:
// defaultLogger.set("com.example.myapp.MyDebugLogger")
}When enabled = false, the plugin is a complete no-op — no IR transformations, no runtime dependency surfaces in the output binary. When observeFlow or logAction is individually disabled, only that transformation is skipped.
A high-level view of what the plugin injects into your classes at compile time. You don't need to know any of this to use @Debuggable, but it helps when something behaves unexpectedly.
It determines the type of the class and injects runtime unsubscription logic.
DebugCleanupRegistry backing field (initialized inline) and wraps the existing close() body in try { ...original... } finally { registry.close() }. For ViewModels this fires when onCleared() runs (since ViewModel implements AutoCloseable as of Lifecycle 2.7); for plain AutoCloseable it fires on explicit close(). If the class inherits close() without overriding it, a compile-time warning is emitted instead of silently leaking.@Debuggable local is wrapped in try { ... } finally { registry.close() }, running cleanup on every function-exit path.Initialization expressions for target State or Flow properties are wrapped with debugging functions provided by the Runtime library. The wrapper launches a background observation in the registry's coroutine scope and returns the underlying state/flow unchanged.
// Before transformation
val uiState = mutableStateOf(UiState())
// After IR transformation (conceptual)
val uiState = mutableStateOf(UiState())
.debuggableState(name = "uiState", registry = $$debuggable_registry, logger = DefaultDebugLogger)The compiler checks the fully qualified class name (FQDN) of properties to determine whether the type is trackable.
androidx.compose.runtime.State / MutableState
kotlinx.coroutines.flow.Flow / StateFlow / MutableStateFlow
If @FocusDebuggable or similar is applied to a type other than these, a warning is emitted at compile time.
When enabled.set(false) is configured on the Gradle plugin side (the default for Release builds), the KCP performs no IR transformations. No debugging code or Runtime library dependencies remain in the production binary, so there is zero performance impact.
⚠️ Important: With default settings,@Debuggablelogs the current value of every trackedState/Flowand the full argument list of every annotated action — using each value'stoString(). That can include tokens, passwords, email addresses, and other personal data. Do not ship production builds with Debuggable enabled unless you understand this trade-off.
Recommended configurations:
// Recommended for most apps — Debuggable only in debug-style builds.
debuggable {
enabled.set(
providers.gradleProperty("debuggable.enabled")
.map { it.toBoolean() }
.orElse(project.findProperty("buildType") != "release"),
)
}// Alternative — keep the plugin enabled but silence output in release.
// Useful if you want the cleanup side of the injection (AutoCloseable /
// try-finally registry.close()) without the logs.
import me.tbsten.debuggable.runtime.logging.SilentLogger
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.BUILD_TYPE == "release") {
DefaultDebugLogger.current = SilentLogger
}
}
}Per-property / per-class opt-outs:
@IgnoreDebuggable on a property to exclude a single State / Flow from tracking.@Debuggable(logger = MyLogger::class) with MyLogger redacting sensitive values before forwarding to Logcat / stdout.@FocusDebuggable to flip a class into "only these properties" mode — everything else is ignored.R8 / ProGuard: the runtime AAR ships consumer rules under META-INF/proguard/debuggable-runtime.pro, so Android consumers don't need any keep rules of their own. The injected calls survive minification automatically.
Behaviors the plugin automatically manages for you. Reading this section helps you predict when your logs will appear and how to filter what gets tracked.
The observation lifecycle is automatically determined based on the nature of the class.
| Target | Condition | Cleanup Timing |
|---|---|---|
| ViewModel | Inherits from ViewModel
|
onCleared() (via the AutoCloseable.close() that ViewModel implements as of Lifecycle 2.7) |
| AutoCloseable | Implements AutoCloseable
|
When the close() method is executed |
| Singleton | isSingleton = true |
None (persists until process termination) |
| Local Variable | Variable inside a function | When function execution ends (Scope Exit) |
// Temporary observation inside a function
fun performTask() {
@Debuggable
val tempState = mutableStateOf("Pending")
// Automatically cleaned up when leaving the function
}
// When treating as a singleton
@Debuggable(isSingleton = true)
class GlobalSettings { ... }By default every Flow, State, and var in a @Debuggable class is tracked. Two annotations let you adjust that:
@IgnoreDebuggable — exclude a single property or function from tracking (default mode).@FocusDebuggable — switch the entire class into focus mode: only members explicitly annotated with @FocusDebuggable are tracked; everything else is ignored.@Debuggable
class ComplexViewModel : ViewModel() {
val trackedState = mutableStateOf(0) // tracked by default
@IgnoreDebuggable
val noiseState = mutableStateOf("") // excluded
}
// Focus mode — only @FocusDebuggable members are tracked
@Debuggable
class SelectiveViewModel : ViewModel() {
@FocusDebuggable
val importantState = mutableStateOf(0) // tracked
val otherState = mutableStateOf("") // ignored (focus mode active)
}The repository ships with two runnable samples under integration-test/ that consume the plugin from mavenLocal().
From the repo root:
./gradlew publishToMavenLocalThis installs debuggable-runtime, debuggable-compiler, and debuggable-gradle (version 0.2.0) into ~/.m2/.
| Sample | Target | Lifecycle pattern | README |
|---|---|---|---|
integration-test/cmp |
Compose Multiplatform Desktop (JVM) | @Debuggable(isSingleton = true) object |
cmp/README.md |
integration-test/android |
Android app | @Debuggable class : ViewModel(), AutoCloseable |
android/README.md |
Each sample README explains how to run it, what to click, and where in the source the @Debuggable annotation is applied. See integration-test/README.md for a side-by-side summary.
Both verification scripts support a parallel mode that mirrors CI's matrix on a single machine:
./scripts/smoke-test-all.sh # parallel (default: min(nproc/2, 4))
./scripts/smoke-test-all.sh --serial # one version at a time (easier to debug)
./scripts/smoke-test-all.sh --parallel 6 # override worker count
DEBUGGABLE_PARALLEL=6 ./scripts/test-all.shEach worker runs in an isolated rsync'd copy of the project (.local/tmp/…) so parallel Gradle invocations don't collide on build/. Per-version logs land in .local/tmp/{smoke,test}-all-<version>.log.
Every Kotlin 2.0+ stable patch through the latest beta is supported. Verified end-to-end
by scripts/smoke-test-all.sh (integration build of integration-test/cmp with each
target compiler) and scripts/test-all.sh (:debuggable-compiler:test under the
matching kctfork):
| Kotlin version | Status |
|---|---|
| 2.4.0-Beta1 | ✅ Verified |
| 2.3.21-RC2 | ✅ Verified |
| 2.3.20 | ✅ Verified (pinned build) |
| 2.3.10 | ✅ Verified |
| 2.3.0 | ✅ Verified |
| 2.2.21 | ✅ Verified |
| 2.2.20 | ✅ Verified |
| 2.2.10 | ✅ Verified |
| 2.2.0 | ✅ Verified |
| 2.1.21 | ✅ Verified |
| 2.1.20 | ✅ Verified |
| 2.1.10 | ✅ Verified |
| 2.1.0 | ✅ Verified |
| 2.0.21 | ✅ Verified |
| 2.0.20 | ✅ Verified |
| 2.0.10 | ✅ Verified |
| 2.0.0 | ✅ Verified |
The IR transformation logic lives in a metro-style per-Kotlin-version compat layer so a single plugin artifact can dispatch to whichever implementation matches the consumer's Kotlin compiler:
| Module | minVersion |
Target compiler | Notes |
|---|---|---|---|
debuggable-compiler-compat-k2000 |
2.0.0 | 2.0.10 | Pre-builders.kt split; createDiagnosticReporter still returns IrMessageLogger
|
debuggable-compiler-compat-k2020 |
2.0.20 | 2.0.21 | 2.1.20 未満 IR API (putValueArgument / extensionReceiver= / valueParameters) |
debuggable-compiler-compat-k21 |
2.1.20 | 2.1.21 | New arg/receiver APIs, but irCall etc. still on IrBuilderWithScope
|
debuggable-compiler-compat-k23 |
2.2.0 | 2.3.20 |
irCall on IrBuilder; new arguments[param]= API is the only one used |
At runtime, IrInjectorLoader (in debuggable-compiler/compat/) enumerates
IrInjector.Factory via ServiceLoader, skips any factories whose classes can't be
linked on the current runtime (e.g. k23 fails on 2.0.x because IrBuilder.irCall
doesn't exist there), and picks the highest minVersion that is still ≤ the running
compiler. 2.4.0-Beta1 goes through a reflection-only helper for the FirExtensionRegistrarAdapter
/ getAnnotation signature drift, which is narrow enough to live inside compat-k23.
See .github/workflows/ci.yml for the 17-version CI matrix.