Better Analytics in Android with Annotation Processing and KotlinPoet
Leveraging the power of code generation to simplify analytic events.
Nowadays, it’s very common to require analytics for your Android application to track your app’s performance. With so many 3rd party services and libraries out there, you’re usually spoiled with choices.
Using these analytics is also very straightforward. They usually come with a very simple and easy to use APIs. Typically, you will pass a list of keys and values to a function that is provided by the analytics SDK.
For example in the Firebase logEvent
API which uses Bundle
:
val params = Bundle()
params.putString("image_name", name)
params.putString("full_text", text)
firebaseAnalytics.logEvent("share_image", params)
Managing these event parameters can get cumbersome really quickly since the keys and values are arbitrary and can also change rapidly depending on your analytic goals. Moreover, you need to ensure that you’re passing the correct key-value pairs or you will receive incorrect analysis.
With this article, I want to show how we can leverage Kotlin’s sealed class together with annotation processing and code generation to solve these problems. Ideally, we will use the Kotlin class’ constructor parameters and generate the code that convert these parameters to our analytics events’ key-values arguments.
Setting up
Let’s create a new Android app in Android Studio and add a new module named tracker
that acts as our analytics tracking tools. In this tracker
module, we will create an EventTracker
that accepts a Bundle
as a parameter for events.
For annotation processing to work, we will need to create 2 separate modules. An annotation
module that contains the annotation that we will use to annotate our analytics events. And processor
module that contains the annotation processing and code generation.
We will start by adding the required dependencies to our processor
module in build.gradle
:
Note: Both
annotation
andprocessor
modules need to be Java/ Kotlin libraries instead of Android libraries.
Let’s add our annotation to annotation
module and name it AnalyticsEvent
:
Now, let’s create our annotation processor in processor
module AnalyticsEventProcessor
which extends from KotlinAbstractProcessor
and register it to the build flow by adding Google’s auto-service annotation:
There are other options that you can add to your annotation processor, you can look for the list of options here.
Processing your annotated class
To start processing classes which are annotated with @AnalyticsEvent
annotation, we will call roundEnv.getElementsAnnotatedWith()
that returns a list of all elements which has been annotated with our annotation. And then check whether each element in the list is a Kotlin sealed class or not, using extension property .kotlinMetadata
from the KotlinMetadata library.
override fun process(
annotations: Set<TypeElement>,
roundEnv: RoundEnvironment
): Boolean {
val annotatedElements =
roundEnv.getElementsAnnotatedWith(ANNOTATION) for (annotatedElement in annotatedElements) { // Check if the annotatedElement is a Kotlin sealed class
val analyticsElement =
getAnalyticsElement(annotatedElement) ?: continue
}
return true
}fun getAnalyticsElement(element: Element): TypeElement? {
val kotlinMetadata = element.kotlinMetadata
if (kotlinMetadata !is KotlinClassMetadata ||
element !is TypeElement
) {
// Not a Kotlin class
return null
}
val proto = kotlinMetadata.data.classProto
if (proto.modality != ProtoBuf.Modality.SEALED) {
// Is not a sealed class
return null
}
return element
}
Next, we will iterate through all the child classes using enclosedElements
and check whether each child class extends from the enclosing sealed class using typeUtils
.
override fun process(
annotations: Set<TypeElement>,
roundEnv: RoundEnvironment
): Boolean { ... for (annotatedElement in annotatedElements) { // Check if the annotatedElement is a Kotlin sealed class
val analyticsElement = ... // Get all the declared inner classes as our analytics events
val declaredAnalyticsEvents =
getDeclaredAnalyticsEvents(analyticsElement)
}
return true
}
fun getDeclaredAnalyticsEvents(
analyticsElement: TypeElement
): Map<ClassName, List<String>> { val supertype = analyticsElement.asType() val enclosedElements = analyticsElement.enclosedElements for (element in enclosedElements) { val type = element.asType() if (element !is TypeElement) {
// Inner element is not a class
continue
} else if (!typeUtils.directSupertypes(type)
.contains(supertype)
) {
// Inner class does not extend from
// the enclosing sealed class
continue
} }
}
We can then get the class’ name and the constructor’s parameters to generate our codes. Here, we can make use of the KotlinPoet and KotlinMetadata library to help us.
fun getDeclaredAnalyticsEvents(
analyticsElement: TypeElement
): Map<ClassName, List<String>> {
val analyticsEvents =
mutableMapOf<ClassName, List<String>>()
... for (element in enclosedElements) { ...
val kotlinMetadata = element.kotlinMetadata
as KotlinClassMetadata // Make use of KotlinPoet's ClassName
// to easily get the class' name.
val eventClass = element.asClassName() // Extract the primary constructor
// and its parameters as the event's parameters.
val proto = kotlinMetadata.data.classProto
val nameResolver = kotlinMetadata.data.nameResolver if (proto.constructorCount == 0) {
// element has no constructor
continue
} val mainConstructor = proto.constructorList[0]
val eventParameters = mainConstructor.valueParameterList
.map { valueParameter ->
// Resolve the constructor parameter's name
// using nameResolver.
nameResolver.getString(valueParameter.name)
} analyticsEvents[eventClass] = eventParameters
}
return analyticsEvents
}
Our processor will now look like this:
Generating codes with KotlinPoet
Since we now have all the required data which we need to generate our codes, we are ready to write the code generation part of our processor.
To create an extension function, we can use .receiver(class)
method from FunSpec.Builder
. But, as we don’t have the dependency to the AnalyticsTracker
nor Bundle
, we need to do some workaround by utilizing the ClassName
class.
val EVENT_TRACKER_CLASS = ClassName(
"com.your.analytics.package",
"EventTracker"
)
val BUNDLE_CLASS = ClassName("android.os", "Bundle")fun generateCode(
analyticsElement: TypeElement,
analyticEvents: Map<ClassName, List<String>>,
outputDir: File
) { val className = analyticsElement.asClassName() val extensionFunSpecBuilder = FunSpec.builder("logEvent")
.receiver(EVENT_TRACKER_CLASS)
.addParameter("event", className)
.addStatement("val %L: %T", "name", String::class)
.addStatement("val %L: %T", "params", BUNDLE_CLASS)
.beginControlFlow("when (%L)", "event") for ((eventName, eventParamList) in analyticEvents) {
...
} extensionFunSpecBuilder.endControlFlow()
.addStatement(
"%L(%L, %L)",
"logEvent",
"event",
"params"
)
}
You can take a look at KotlinPoet’s documentation here.
We will then add our when
statement that converts our event
to Bundle
. To make this easier, we will make use of bundleOf
function from androidx library.
Edit: KotlinPoet’s 1.1.0 Added support for
MemberName
for top level functions and members. Use that instead ofClassName
.
val BUNDLE_OF_FUNCTION = ClassName("androidx.core.os", "bundleOf")fun generateCode(
analyticsElement: TypeElement,
analyticEvents: Map<ClassName, List<String>>,
outputDir: File
) { ...for ((eventName, eventParamList) in analyticEvents) {
val codeBlock = CodeBlock.builder()
.addStatement("is %T -> {", eventName)
.addStatement(
"%L = %S",
"event",
eventName.simpleName.convertCase(
CaseFormat.UPPER_CAMEL,
CaseFormat.LOWER_UNDERSCORE
)
)
.apply {
if (eventParamList.isNotEmpty()) {
addStatement("%L = %T(", "params", BUNDLE_OF_FUNCTION) for ((index, paramList) in eventParamList.withIndex()) {
...
addStatement(
"%S to %L.%L%L",
paramList.convertCase(
CaseFormat.LOWER_CAMEL,
CaseFormat.LOWER_UNDERSCORE
),
"params",
paramList,
...
)
} addStatement(")")
} else {
// Event parameter is empty, pass empty Bundle
addStatement("%L = %T()", "params", bundleClass)
}
}
.addStatement("}")
.build() extensionFunSpecBuilder.addCode(codeBlock)
}
... }.../**
* Helper function to convert String case
* using guava's [CaseFormat].
*/
fun String.convertCase(
fromCase: CaseFormat,
toCase: CaseFormat
): String {
return fromCase.to(toCase, this)
}
We will use guava library to convert the class and parameter name from
UpperCamelCase
andlowerCamelCase
tosnake_case
. The guava library comes from AutoService dependency.
Now that we have our extensionFunSpecBuilder
completed. We will use FileSpec
to write our generated function to a .kt
file.
fun generateCode(
analyticsElement: TypeElement,
analyticEvents: Map<ClassName, List<String>>,
outputDir: File
) { for ((eventName, eventParamList) in analyticEvents) { val className = analyticsElement.asClassName()
val extensionFunSpecBuilder = ... ... FileSpec.builder(className.packageName, className.simpleName)
.addFunction(extensionFunSpecBuilder.build())
.build()
.writeTo(outputDir)
}
}
Our generateCode
function for AnalyticsEventProcessor
will now look like this:
Using the generated code
To make use of our newly generated analytics code, add the required dependencies to our app
module’s build.gradle
:
Then run Build -> Rebuild Project
and we should be able to see our generated class under the generated
folder.
Note: There’s a bug in Android Studio 3.3.0 where AS does not recognize files generated into
build/generated/source/kaptKotlin
, this has been fixed in AS 3.3.1 https://issuetracker.google.com/issues/122883561.
Once the classes have been successfully generated. We can invoke the generated function anywhere within our application like such:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(toolbar)
fab.setOnClickListener {
EventTracker.logEvent(
MyEvent.ShareImage(
"image.jpg",
"some string message"
)
)
}
}
}
Other benefit of using this approach is that we have code completion to help us easily call our analytics events. Also, since we’re using Kotlin, we can utilize named parameters to improve the readability of our events.
Now run and deploy our APK to the device or emulator to confirm that the analytics event is logged to Logcat when you press the button.
Summary
Annotation processing and code generation have a wide range of application and can be used for repeatable mundane codes that can easily be automated so that developers can focus on building features that gives values to the users.
There might be some initial investment that we have to incur in order to build the annotation processing, but I believe that the benefits and productivity improvements will outweigh the cost.
I hope this article can help you understand how annotation processing and code generation works and allow you to build your own annotation processing for your Android projects.
You can find the sample Github project here: