Declarative analytics: gAnalytics
Yet another way to handle analytics in app
What distinguishes the assembly line and programmer. Yes, many things, including the main one: they work for different purposes. The each mechanism in assembly line created for one single task and it does that task over and over again. Every day, every month. The assembly line, in general, performs set of such tasks and repeats over and over again, producing an incredible amount of final product (or intermediate one).
As opposed to that, programmer behaves differently. Starting to write a code, that he wrote already before, he begins to feel a tiny discomfort deep inside. And every time he repeats itself, his inner disturbance grows, until at some point of time !BOOM! and he starts to write the code, that does machine job for him. It can be appeared differently and requires more or less time to decide to do that. Most simple way is to move common blocks to another functions, common behavior to another classes and etc. More hard ways include some kind of metaprogramming and code generation. But the main goal is one: reduce the time of development. And in that case the DRY principle is one of the primary allies for every programmer.
About Analytics in Apps
This kind of area is not such “boilerplated”, as other fields in android apps development, but by the certain coincidence, I decided to spend my (and your) time to talk about that.
During my short period of application development I’ve faced with that type of code over and over again:
Here is an example with 4 functions, but imagine 20 classes with 4–14 methods in each. And in every method you will have the same line over and over again:
ServiceLocator.getInstance().getAnalytics().track(CATEGORY, "some_string");
Okey, you can declare a private static final Analytics analytics
and put ServiceLocator.getInstance().getAnalytics()
here. Then you can declare a new method track
:
public static void track(String action) {
ServiceLocator.getInstance().getAnalytics().track(CATEGORY, action);
}
Then in above example’s case, you will have next result:
Yes, we have (in average) fewer symbols in each line, but now we have one additional method and one additional field. Now imagine that some of our beginning methods have to pass a label in analytics method, so you will have to create at least one additional method:
public static void track(String action, String label) {
analytics.track(CATEGORY, action, label);
}
And now, let’s imagine that we have 20 Analytic classes. And in each, we need to create 1–2 additional methods and one field. Isn’t that a boilerplate? Moreover, we have repeatings in name of class, Category, names of methods and actions. So we need to resolve it somehow.
The last thing is that we, of course, can have one method in whole application (example with Google Analytics):
public static void track(String category, String action, String label) {
tracker.send(HitBuilders.EventBuilder()
.setCategory(category)
.setAction(action)
.setLabel(label)
.build());
}
And then just use it everywhere with passing hardcoded strings. But this approach has many disadvantages, that why it is better to have all analytics in one explicit place and get access to it throw special restricted methods.
So, let’s recap above text: we need to find a way to eliminate aforementioned boilerplate and provide a more convenient way to work with analytics.
Declarative solution (Proxy approach)
What if I told you, that you could have above example described in that way (written in Kotlin):
@HasPrefix(splitter = "_")
interface AnalyticsCandyShop {
fun near()
fun opened()
fun closed()
fun sell()
}
Looks nice, isn’t it? You will have only interfaces with such methods and then, the system will create somehow their implementations and will give them to you. From my point of view less ‘boilerplatable’ will be only if you declare all your analytics in some text file and then the system will generate code for you.
So, how can we achieve that solution? The answer is simple. We need to instantiate a proxy, that will implement our interface and then apply all our annotations or conversions to it. Here is a simple snippet of code:
interface AnalyticsWrapper {
fun <T : Any> create(kClass: KClass<T>) = create(kClass.java)
fun <T : Any> create(clazz: Class<T>): T
}
class AnalyticsSingleWrapper : AnalyticsWrapper {
@Suppress("unchecked_cast")
override fun <T : Any> create(clazz: Class<T>): T {
return Proxy.newProxyInstance(clazz.classLoader, arrayOf<Class<*>>(clazz)) { proxy, method, args ->
//do processing annotations, making conversions
//or
//some other work !HERE!
} as T
}
}
That’s all you need. It is a core thing for our solution. Same approach use, for example, Retrofit (here is a link to the same snippet).
Next, we want to be able to receive an event and then pass our actions to any analytics library. So to do that we need to pass an special EventProvider
to our AnalyticsWrapper
and then construct an Event
inside it:
interface EventProvider {
fun provide(event: Event)
}class AnalyticsSingleWrapper(private val eventProvider: EventProvider) : AnalyticsWrapper {
@Suppress("unchecked_cast")
override fun <T : Any> create(clazz: Class<T>): T {
return Proxy.newProxyInstance(clazz.classLoader, arrayOf<Class<*>>(clazz)) { proxy, method, args ->
//build here category, action, label and value
val event = Event(category, action, label, value)
eventProvider.provide(event)
} as T
}
Here freedom is started. You can create any annotations that you want; group your analytics in an interface, that will return interfaces; add caching mechanism; provide a settings or configuration for default behavior. Some of that done in my library, but I’ve not published it since it is not finished. But I’ve done some work and will describe to you, what annotations I’ve added and how to use them.
If you will like it, please, like this post, or star my project, and I will know, that my work is interesting not only just for me :)
Annotations
Here I’ve added a description for some annotations of whole list. Currently, I have five annotations. So let’s go through all of them:
@Category (Scope: Class)
When you need to explicitly define a category, that will be used for the interface you need to annotate it with @Category
annotation. For example, interface and category have completely different names, or they use different naming conventions (handling conventions will be added later). Then that annotation will help you.
@Action (Scope: Method)
Same thing as @Category
, but used for methods. Methods in our approach are actions for analytics, so their names may be different or, as I told before, use different naming conventions.Then you need to annotate a method with @Action
annotation.
@Label (Scope: Parameter)
When you need to add label into Analytics, you can define a parameter in requested method and mark it with @Label
annotation. Also, you can just pass a parameter without an annotation. The system will know, that it is a label. Also, you may specify a LabelConverter
, that is needed when you pass a complex object and you want to explicitly define how to transform it to label string. By default, this annotation uses simple converter that calls toString
method for every object (in future I can add a more complex behavior, for example, for enums call name
instead of toString
).
I have no @Value
annotation because for now, it is pointless. We know, that it have to be Number
, so you just need to pass a Number
parameter in the requested method and the system will understand that this is a value. You can check test cases for that to know more.
@HasPrefix (Scope: Class/Method)
In some cases, as my first example, actions for analytics use a name of category as their prefix. For example, you have category candyshop and action candyshopopened. For such cases, it isn’t necessary to write fun candyshopopened()
for your analytic’s interface. You can write @HasPrefix fun opened()
. And if the whole interface has such action, then you can write:
@HasPrefix
interface CandyShop() {
fun opened()
}
And that’s all. Also @HasPrefix
annotation support defining a special splitter and explicit prefix name. For example:
@HasPrefix(name = "cs", splitter = "_-_")
interface CandyShop() {
fun opened()
}
Will give you an action cs_-_opened instead of candyshopopened.
@NoPrefix (Scope: Class/Method)
In case you using @HasPrefix
annotation for your interface, but actually, some of the methods don’t need it, you can annotate them with @NoPrefix
annotation. For example:
@HasPrefix
interface SomeCategory {
fun action1()
@NoPrefix fun action2()
}
Will produce next two events:
- Category: somecategory, Action: somecategoryaction1
- Category: somecategory, Action: action2
Currently using this annotation for an interface is pretty useless, since it is the default behavior. But in future a will add setting up a defaults, so this annotation may be beneficial in next stages.
Default behaviour
I’ve made some effort to create a smart enough system.
- When you name your interface with analytics prefix, it will no be counted for the output category. For example,
interface AnalyticsCandyShop
will produce a category named candyshop. If (for some unknown reason) you want to use that prefix in output category, you need to specify name explicitly through@Category
annotation. Explicit annotation for exactly that case would be clumsy. - When a method has only one parameter, it is automatically be a Label for your event. So defining a
@Label
annotation would be redundant in that case. Still, you can do it, if, for example, you have a custom converter that you need to use for the label. - When a method has two parameters and one of them is
Number
, then it is automatically a Value and another parameter will be a Label. If both parameters areNumber
then first will be a Label and second a Value. - If method will have two parameters and only one will be a
Number
and it will be annotated with@Label
. Then you will have an exception, since, in the case of two method parameters, one of them have to be aNumber
without@Label
annotation. If you will have no numbers in two parameters, then you will have the similar exception. And if you will have more than two parameters in the method, then you will have an exception, because you allowed to have up to two parameters, no more.
Future
That’s all I’ve got for now. In future a want to add a few major things to that kind of library:
- Naming Conventions. Currently, by default interfaces have a lowercase convention and methods lowerCamelCase convention. Of course, it is not cover the most cases. Often you need a snake_case, often CamelCase. And we need to add a special annotation for that (not just for the interface but for global library settings). Or in most cases you methods and interfaces will be boilerplated by redundant
@Action
and@Category
annotations. - Global settings. For example, all your analytics uses prefixes for action, so instead of adding to each interface
@HasPrefix
annotation, it will be better to define somewhere a default behavior and that’s all.
I thing that will reduce writing a code more.
Conclusion
Currently, I have no version of this library, since it is in alpha, and I think it is not needed so strong. I just wanted to show you guys, how you can organize sending analytics in your application. If you want a detailed example of my approach and some version of the library available through JCenter, then, please, let me know about that. And I will add it as fast as I can.
Thank you all for reading. Like, subscribe and also I will really appreciate if you share your thoughts about that kind of approach.