Reflection and Composables
For production code in ordinary apps, using reflection is often considered to be poor form. Looking up classes and functions at runtime can lead to some difficult-to-debug problems and can result in long-term maintenance headaches. And, in fairly ordinary app code, reflection usually is unnecessary.
Where reflection starts to become more important is in libraries, tools, and frameworks. Sometimes, you just do not know in advance what the classes and functions are that you need to work with. Reflection is used in a bunch of places in the Android SDK, such as instantiating activities (based on manifest entries) and fragments (based on names retained after configuration changes).
So, someday, somebody was going to want to invoke a @Composable
function from Jetpack
Compose via reflection.
A week or so ago, I was that somebody. đź‘‹
The challenge is that what you write in terms of a composable function and what the compiler compiles are two different things, courtesy of that Kotlin compiler plugin that powers a lot of the Compose magic.
On dev11
, your function gets one additional parameter: a Composer
.
Fortunately, getting the Composer
is easy: just reference currentComposer
.
You can then do something like this to find a zero-parameter top-level @Composable
function via reflection and call it:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val clazz = Class.forName("your.package.goes.here.AndYourClassNameTooKt")
val demoMethod = clazz.methods.find { it.name == "YourAwesomeComposable" }
demoMethod?.let {
it.isAccessible = true
it.invoke(null, currentComposer)
}
}
}
}
dev12
changed the rules somewhat. Now, there are 2+ additional Int
parameters. As Google’s Leland Richardson described it in a Slack thread,
the additional parameters are:
- the
Composer
- an
Int
that serves as a “key” (and is scheduled to go away in a future update) - one
Int
for every 15 parameters to the function that you wrote, representing changes - one
Int
for every 31 parameters to the function that you wrote, if there are any default expressions in the function declaration
For the purposes of calling some high-level composable via reflection, passing 0
for the “key” and “changes” values works. So, in dev12
, the invoke()
changes slightly:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val clazz = Class.forName("your.package.goes.here.AndYourClassNameTooKt")
val demoMethod = clazz.methods.find { it.name == "YourAwesomeComposable" }
demoMethod?.let {
it.isAccessible = true
it.invoke(null, currentComposer, 0, 0)
}
}
}
}
This approach is not particularly maintainable, in that as Compose evolves, so will
the augmented parameter list. With luck, this feature request
will give us access to something like this invokeComposableMethod()
that we can use to more safely call these functions.
On the whole, reflection is something to be used sparingly, carefully, and only if you are working on something really really cool. But, sometimes, it is your best option, so having a working recipe for calling composables via reflection is important. Hence, I’m grateful for Leland’s assistance in identifying how to do this for the current generation of Compose.