Kotlin-android-synthetics performance analysis (with ButterKnife)
Introduction
After comment that synthetic is no longer recommended practice and some arguments I decided to go deeper into issue with performance of kotlin-android-synthetics by analyzing generated Java and byte code and comparing to other approaches (such as vanilla-findViewById and ButterKnife).
Vanilla-findViewById
This is sample Activity we’ll work with throughout the article.
Approach with findViewById will be our baseline.
Sample consists of:
- Activity class
- One TextView property inside Activity with lateinit modifier
- Initializing TextView property in onCreate in Activity
- Setting dynamically few properties on TextView afterwards
Here is what we’ll get if we try to look at generated java code:
We immediately have a number of questions:
- We haven’t used synthetics in this Activity, we just have
apply plugin: 'kolin-android-extensions'
in our build.gradle (which is added by default).
Why we have _$_findViewCache, _$_findCachedViewById and _$_clearFindViewByIdCache? - Why we have three null-checks for our TextView property (which can throw throwUninitializedPropertyAccessException)?
- If keys for view cache are primitive integers, why we have HashMap? Keys in HashMap are Integer objects, so each get with primitive int will end up with autoboxing primitive value into Integer object (which pollutes memory)
Let’s try to answer these questions one by one.
Code generation issue
I have no exact answer to the question why we have synthetic code generated for class which hasn’t used them.
Most likely (as LayoutContainers are supported — including Activity, Fragment, View) code is generated for all these classes without checking whether some features of synthetics are actually used in them.
Maybe it is just easier to generate to all supported classes than to check where exactly code is needed.
Also it might help with incremental builds as after code generation and first build success there is no need to redo it again.
This might look like an overhead from code perspective (and it basically is). Thankfully we have multidex, so number of methods is no longer an issue.
For release build ProGuard (or R8) will remove unused methods and fields and will do many other optimizations so the problem will gone (eventually there even won’t be update() method in resulting release byte-code as code will be inlined into onCreate method)
Lateinit checks
Various Intrinsic checks are generated by Kotlin for Java. On Kotlin level it is easy to enforce and check that values are not null/nullable etc., but when it comes to Java there are no guarantees that everything will work well.
As TextView property is not final it is possible that between two lines value will be set to null. Intrinsic checks ensure that if some contract is violated exception is thrown as soon as possible.
This again might look as an overhead if we know that we’ll initialize TextView in onCreate (and we’ll check that value is not null) and that we’ll not try to change property concurrently and that we don’t call update() method before onCreate, so that additional checks are not really required (or at least it could be one).
But it is something we know, not the compiler.
One could look at generated Java code and decide to do little trick with .apply function:
private fun update() {
textView.apply {
text = "Text"
setTextColor(Color.RED)
textSize = 14.0f
}
}
So the resulting Java code will be:
private final void update() {
TextView var10000 = this.textView;
if (this.textView == null) {
Intrinsics.throwUninitializedPropertyAccessException("textView");
}
TextView var1 = var10000;
var1.setText((CharSequence)"Text");
var1.setTextColor(-65536);
var1.setTextSize(14.0F);
}
So we have only one check and then update all properties on local property. Neat.
Though such optimizations are definitely premature as anyway in release build ProGuard will do better optimization work without making your code look a bit weird.
HashMap/SparseArray
Why HashMap is used instead of SparseArray?
As keys are integers each lookup will trigger boxing of the integer value which will badly impact memory usage and make GC to trigger more often.
Seems SparseArray is better option because we’ll have primitive integers as keys, why it is not used?
Actually there is a way to generate code with SparseArray. For that it is needed to add to build.gradle:
androidExtensions {
defaultCacheImplementation = "SPARSE_ARRAY"
}
After that we’ll have SparseArray as cache for views.
Also it is possible to disable cache by using “NONE”, though this option hardly ever useful.
Result
To conclude, evaluation of vanilla approach:
+ one-time initialization (in onCreate)
+ fast subsequent getters (property keeps reference to View)
– a lot of boilerplate (properties, findViewById calls)
ButterKnife
Let’s look at the same example with ButterKnife:
Generated Java code (synthetics part is removed):
So, basically everything is the same. The difference is only that additional getter and setter for our TextView was generated.
It is actually redundant (and will be removed by ProGuard), because ButterKnife injector will work directly on field:
So, basically using ButterKnife is similar to vanilla approach. The only difference is that we don’t have to write a lot of findViewById calls (though we still need to write one line per property — Binds annotation — but it is anyway better as we have actual property and view id near to each other).
There is small downside that ButterKnife uses reflection to instantiate ViewBinding class. But this is usual trade-off between reflection and code generation.
Result
To conclude, evaluation of ButterKnife approach:
+ one-time initialization (in onCreate)
+ fast subsequent getters (property keeps reference to View)
+/– quite a lot of boilerplate (still need to define properties, though no need to write a lot of findViewById mehod calls, instead just one method bind — but it is needed to add Binds annotation to each property)
Synthetics
Same sample using synthetics:
Generated Java code:
Here we have version with SparseArray to not have, as discussed above, useless autoboxing of integer keys.
The main issue with generated code is that even as we call three methods on same property sequentially, we still have 3 lookups in view cache (yes, findViewById will be called just once — at first time, but why to get value from cache all the time?).
Again, we can work-around this by using .apply, as we did in vanilla approach. Then generated code will be like:
private final void update() {
TextView var1 = (TextView)this._$_findCachedViewById(id.textView);
var1.setText((CharSequence)"Text");
var1.setTextColor(-65536);
var1.setTextSize(14.0F);
}
Looks like we’ve improved our code a bit, as now we’ll call cache only once (and if HashMap is used, then we’ll not have two additional boxing of integer primitive).
That actually looks pretty good.
But if we look at generated dex byte-code for release build, then it turns out everything is not that easy and straightforward.
Below are two listings, first one is byte-code from decompiled release APK of original example, second one for case with “optimization” of .apply.
Original:
With .apply “optimization”:
Again, few questions and observations:
- It turned out that HashMap is in the byte-code, though we added in config that we want to use SparseArray (and generated Java code was exactly showing us that we have SparseArray).
So it seems optimization during release compilation replaced SparseArray with HashMap (for unknown reason)
So we not only have .field private j:Ljava/util/HashMap; we also have autoboxing in place invoke-static {p1}, Ljava/lang/Integer;->valueOf(I)Ljava/lang/Integer;
So looks like our optimization is really good as we avoid useless autoboxing?
Interesting stuff, though I don’t know why it happens - Byte-code for our apply “optimization” is shorter. We exactly see that in first listing there are getters from HashMap coming first and only then setters on TextView are called, when in second listing setters on TextView are called almost one by one.
Additional thing is that by not calling view cache multiple times ProGuard was able to inline code related to view cache directly to onCreate method (so we don’t have method to call view cache by id)
So it seems our apply “optimization” worked and we have smaller byte-code, also avoided additional autoboxing in HashMap and not calling view cache multiple times.
Is it enough to recommend using apply “optimization”? I think no. Though there is some impact in the resulting release byte-code it is still matter of optimizations on byte-code level. We usually should not do any code tricks to make byte-code faster.
Even if right now solution works, then we need to check over time that this optimization is still working.
Otherwise it is better to have clean code.
Result
To conclude, evaluation of Synthetics approach:
+ no boilerplate (automatic properties creation and binding*)
+/– dynamic initialization (views are not binded in onCreate but on first call. Whether it is a plus or not actually depends)
– slow getters (either HashMap with autoboxing of key or relatively slow lookup in SparseArray — which seems still converted to HashMap in release; possible delay for first time get)
Conclusion
So, use or not use?
Actually it depends.
I think synthetics is pretty useful tool for common cases when you have simple screens and not using e.g. ‘includes’ and other stuff.
If one needs more control, then definitely vanilla approach is better (though has more boilerplate).
ButterKnife looks as something between the two and because of that [still] a good tool to work with.
Though next generation of helper tools for working with view bindings I would expect to be built on top of the idea of ButterKnife and just additionally generate properties with Bind annotations automatically.
Synthetics approach seems a bit too broad with a lot of things underneath, with less control. Approach which only looks good for beginners (as you don’t need to think about many things), though dangerous and actually seems to be designed for professionals.
Synthetics approach definitely has the worst performance comparing to vanilla or ButterKnife, but I hope that some optimizations will be done in the future so it will become really good approach.
Happy coding!