APACHE-2.0 License
This is a proof-of-concept for a compiler plugin that generates models from interfaces describing their public API, where the model business logic is implemented as a composable function.
@ComposeModel
interface TodoListModel {
// Data
val todos: List<TodoModel> = emptyList()
val completedTodos: List<TodoModel> = emptyList()
// Event handlers
fun onTodoAdded(todo: TodoModel)
fun onTodoCompleted(todo: TodoModel)
}
This interface defines a model for a todo list. We can write a composable function to render the list, but that's left as an exercise for the reader. What this library does is generate some code to help you implement the business logic for this model using Compose.
In other words, the model interface defines both the model's public API, and a DSL for implementing the model.
Here's an implementation:
@Composable fun TodoListModel(): TodoListModel = rememberTodoListModel {
onTodoAdded { todo ->
todos += todo
}
onTodoCompleted { todone ->
require(todone in todos) { "Invalid todo: $todone" }
todos -= todone
completedTodos += todone
}
}
rememberTodoListModel
is generated for you. The code inside the lambda gets a mutable version of
TodoListModel
– it can write to the properties, and when it calls the event handlers, it actually
passes lambdas that handle the events. The lambda is a composable function, and you can do stuff
like create private state with remember
and rememberSaveable
, launch coroutines with
LaunchedEffect
, etc. You can read CompositionLocal
s, but all the usual warnings about that
apply.
Let's demonstrate private state by adding a timer:
@ComposeModel
interface TodoListModel {
// Data
// …
val timer: String
// …
}
Note that the timer
property doesn't have a default value, so we'll have to specify it explicitly.
@Composable fun TodoListModel(): TodoListModel = rememberTodoListModel(
// When the function is re-generated after the above change, this parameter will be required,
// and the code won't compile until we specify it.
timer = Duration.ZERO.toString()
) {
// Run the timer loop in a coroutine for as long as the model is composed.
LaunchedEffect(Unit) {
val startTime = System.currentTimeNanos()
while(true) {
delay(1000)
timer = (System.currentTimeNanos() - startTime).nanoseconds.toString()
}
}
// …
}
You could even emit things (e.g. UI composables), because Compose doesn't provide any APIs for stopping you, but that's strongly discouraged. The model composable should only be responsible for the model's business logic – UI should be defined separately. The behavior is also undefined – models don't expect their children to emit UI, so there's no meaningful layout context.
The generated rememberTodoListModel
function and the builder interface are both internal
, so
they don't pollute your module's public API.
If you were to run this app, you'd find it automatically saves and restores the models on config
change. By default, rememberTodoListModel
will store your model in the UiSavedStateRegistry
.
Only the properties are stored, and they must all be auto-saveable (in the same sense as the default
autoSaver()
value used by rememberSaveable
). You can turn off this behavior by passing
saveable = false
to the @ComposeModel
annotation.
The plugin is implemented as a KSP processor.
MutableState
. Two overloads of each event handler function areMutableState
since nothingSaver
remember { Impl() }
or rememberSaveable { Impl() }
andProbably. There are quite a few potential issues:
remember*
function. This could maybeApplier
typeNothing
nodes), but that's problematic because:
This project is very rough. The code is super gross and undocumented, there's no real tests, and it's not published. There is a demo module that should build and run however, and you can checkout the repo and mess around if you like. There's some validation with vaguely useful error messages, but there's probably a lot of ways to get the plugin to just puke.
I don't expect I'll spend much more time on this, but if I wanted to make it a real thing, some features I'd like to add are:
@Transient
).Saver
s for individual properties.StateFlow
types. The builder interface would still just get aMutableState
it would be backed by aMutableStateFlow
.@ComposeModel
annotation should be a @StableMarker
to opt-in to compiler optimizations.equals
and hashcode
) and does so only using theView
s (similar to Workflow's LayoutRunner
)MutableState
.@ComposeModel
@ComposeModel(someProperty = true, someOtherProperty = false)
annotation class SquareModel
rememberFooAsState
or AsFlow
function that has the same signature as rememberFoo
MutableState<Foo>
or StateFlow<Foo>
instead of a Foo
, and pushes a new value-type Foo
(see// In the runtime artifact:
interface ComposeModelBuilder<ModelT : Any>
// Example of generated builder:
interface FooModelBuilder : ComposeModelBuilder<FooModel> { /* … */ }
Pair<FooModel, FooModelBuilder>
.fun <ModelT : Any, BuilderT : ComposeModelBuilder<ModelT>> doSomething(
modelFactory: () -> Pair<ModelT, BuilderT>,
customBuilder: BuilderT.() -> Unit
): ModelT {
val (model, builder) = factory()
// Do something with builder.
customBuilder(builder)
return model
}
// And be called like:
val fooModel = doSomething(createFooModel(arg1, arg2)) {
// Build the Foo somehow
}