Android development is always moving forward with new features to make building apps easier; UI construction with compose, dependency injection with Hilt, game development extensions, emoji compatibility libraries, the list goes on. New projects have no concerns leveraging these sorts of features. However, legacy projects have to find a balance when migrating to new software systems. At Savoir-faire Linux, we maintain a variety of android projects and among them is one we’ve been maintaining for more than half a decade now. Throughout the years, we’ve integrated many modernizations to the project; one of the best ones was migrating to view bindings.
The Problem
In the past with this project, the code for all our frontend classes looked something like this.
class CountActivity : AppCompatActivity() {
private lateinit var btnDown: Button
private lateinit var btnUp: Button
private lateinit var tvCount: TextView
private lateinit var llCount: LinearLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_count)
btnDown = findViewById(R.id.btnSync)
btnUp = findViewById(R.id.btnExport)
tvCount = findViewById(R.id.tvCount)
llCount = findViewById(R.id.llSync)
...
}
...
}
For each of our frontend classes, we had a large block of assignments that we’d have to make using findViewById()
to keep references for each UI element and more easily be able to interact with them. As we can see, this does not help with code brevity, which harms readability, and adds overhead on making any new frontend classes. Not to mention, findViewById()
is not null safe or type safe.
The major issue with findViewById()
null safety is that the id used exists in the global context of all layouts. It’s easy to accidentally target a layout outside the one you’re dealing with and produce a null pointer exception. On top of that, returns from findViewById()
must be correctly cast to the relevant type for the view, making a cast to the wrong type another easy mistake.
We would preferably want some kind of solution that isn’t as cluttered and is more resistant to implementation errors.
View Bindings
The answer to our woes is view bindings. Any modern project is likely to already be using these, but legacy projects may not have migrated to them yet. If that’s the case for one of your projects, then you should. Perhaps it’s findViewById()
, or perhaps your project is still using one of the deprecated alternatives such as Butterknife or Kotlin Synthetics (the recent release of Kotlin 1.8 has actually made the plugin completely unavailable). These other options are lacking compared to view bindings, sharing many of the same issues, as is briefly visualized in the following table.
| View Binding | findViewById() | Butterknife | Kotlin Synthetics |
---|
Low Code Footprint | ✔️ | ❌ | ❌ | ✔️ |
---|
Isolated Layout IDs | ✔️ | ❌ | ❌ | ❌ |
---|
Null Safety | ✔️ | ❌ | ❌ | ❌ |
---|
Type Safety | ✔️ | ❌ | ❌ | ✔️ |
---|
Undeprecated | ✔️ | ✔️ | ❌ | ❌ |
---|
View bindings clearly come out as the best option.
In order to enable the use of view bindings, you must first edit the app level gradle file of your project to contain the following.
android {
...
buildFeatures {
viewBinding true
}
}
Then in your activities, for example, you would set your content view and binding as follows.
class CountActivity : AppCompatActivity() {
private lateinit var binding: ActivityCountBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCountBinding.inflate(layoutInflater)
setContentView(binding.root)
...
}
...
}
Finally to access elements of the view you would simply do that through the binding.
fun updateCount(count: Int) {
binding.tvCount.text = count.toString()
}
It’s that easy, but what about using them in contexts outside of activities?
Fragments
In fragments, there’s a little more work to do. A fragment’s lifecycle is such that the view is destroyed before the fragment itself is. Due to this, we want to be sure to null out the view binding of the fragment on view destruction in order to prevent memory leaks. This means our class would look something like the following.
class CountFragment : Fragment() {
private var _binding: FragmentCountBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCountBinding.inflate(inflater, container, false)
...
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
...
}
...
}
One thing to note is that the unlike the standard inflate, which returns the root view of the layout, the binding inflate function returns the binding so the root needs to be read from it for our onCreateView()
return. Also, while it isn’t necessary, we define an auxiliary binding property to avoid having to write null checks everywhere. In fragments, accessing the view is always dangerous, regardless of how you implement it, so be sure to always deal with the view between the relevant lifecycle calls and never outside of that.
Recycler Adapters
In the case of recycler adapters and their view holders, we don’t have any special considerations for lifecycle. The recycler adapter manages multiple individual view holders and the view in each of the holders only exists as long as the holder itself does. We implement bindings in adapters as follows.
class CountAdapter() : RecyclerView.Adapter() {
override fun onCreateViewHolder(parent: ViewGroup, position: Int): CountViewHolder {
val binding = ViewHolderCountBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return CountViewHolder(binding)
}
...
inner class CountViewHolder(val binding: ViewHolderCountBinding) : RecyclerView.ViewHolder(binding.root) {
...
}
}
Custom Views
One case we had to deal with in our migration that may be less common is the use of custom views with layout inflation. Usually you would only do this when your custom view is a ViewGroup, otherwise you wouldn’t need a layout file. Implementing view bindings in such a view would simply be done during construction, like this.
class CountView(context: Context) : LinearLayout(context) {
private var binding: ViewCountBinding
init {
binding = ViewCountBinding.inflate(LayoutInflater.from(context), this, true)
...
}
...
}
One thing to be careful of here is to match the ViewGroup you are extending to the one at the top level of your layout file, but that’s regardless of using view bindings or not.
Custom Views Holders
Another similar case that we encountered in our migration work was custom classes which would inflate a view and keep it in a property. This mechanism is a part of a whole dynamic view building system we have for generating piecewise UIs from json data. To avoid getting muddied in the details of that, here’s a simpler example of what a custom view holder could look like.
class CountViewHolder(context: Context, val parent: ViewGroup) {
var view: View
private var cbCount: CheckBox? = null
init {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
view = inflater.inflate(R.layout.custom_view_holder_count, parent, false)
cbCount = view.findViewById(R.id.cbCount)
}
fun render() {
parent.addView(view)
}
}
In this case, we just replace the view with a view binding and access the view itself via the binding root when needed.
class CountViewHolder(context: Context, val parent: ViewGroup) {
var binding: CustomViewHolderCountBinding
init {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
binding = CustomViewHolderCountBinding.inflate(inflater, parent, false)
}
fun render() {
parent.addView(binding.root)
}
}
Menus
What about menus? Unfortunately for us, menus do not use layout resource files so view bindings aren’t generated for them. So even though menus have their own xml definitions and we can use traditional layouts within them, view bindings can’t help us here.
How it Works
As just mentioned, view binding only works on layout resources. When you enable view binding in your app, performing a build will autogenerate a java class for each layout file. Each of these java classes then contains what might otherwise be in your front end classes: members which define each UI element and assignments for them that are done with findViewById()
(actually findChildViewById()
but it accomplishes the same thing).
For example, in our fragment from earlier we have the layout fragment_count.xml
and the autogenerated binding class FragmentCountBinding.java
.
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/tvCount"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
public final class FragmentCountBinding implements ViewBinding {
@NonNull private final FrameLayout rootView;
@NonNull public final TextView tvCount;
private FragmentCountBinding(@NonNull FrameLayout rootView, @NonNull TextView tvCount) {
this.rootView = rootView;
this.tvCount = tvCount;
}
...
@NonNull public static FragmentCountBinding inflate(@NonNull LayoutInflater inflater, @Nullable ViewGroup parent, boolean attachToParent) {
View root = inflater.inflate(R.layout.fragment_count, parent, false);
if (attachToParent) {
parent.addView(root);
}
return bind(root);
}
@NonNull public static FragmentCountBinding bind(@NonNull View rootView) {
int id;
missingId: {
id = R.id.tvCount;
TextView tvCount = ViewBindings.findChildViewById(rootView, id);
if (tvCount == null) {
break missingId;
}
return new FragmentCountBinding((FrameLayout) rootView, tvCount);
}
String missingId = rootView.getResources().getResourceName(id);
throw new NullPointerException("Missing required view with ID: ".concat(missingId));
}
}
When we call inflate()
on this binding, it calls its bind()
method, extracts each view from the layout, and then creates and returns the view binding complete with convenient references to each child view in the layout and the root.
Miscellaneous Advice
In our migration process, we encountered a few hiccups that weren’t directly related to view binding implementation so here are a few extra pointers.
Placeholder View IDs
Any includes in your layouts need to have IDs whereas you previously may not have had them set. An ID is needed for the view bindings to properly recognize and bind them into the parent view.
Layout Variants
If you use any layout variants, like for landscape/portrait, you may have discrepancies in what views exist in one versus the other. You’ll be happy to know that there is nothing to worry about in these cases. Any views that don’t exist in all variants will be nullable and will only be set if the relevant view is loaded.
Layout Filenames
Use unique layout filenames and IDs. One real head-scratcher problem we had was with a particular binding which android studio refused to work properly with. Autocompletes wouldn’t work on it and it would give unresolved reference errors in the editor, but the app built and ran fine. The view binding file was even generated and seemed fully intact. Turns out the problem was the placeholder we were trying to access in the binding was identical to another in one of our external modules. We changed the name of the module’s placeholder and voila, everything worked as expected again.
Unresolved errors
You may experience issues with certain parts of your bindings giving you random unresolved reference highlights. If you don’t think it’s related to the above issue and your app still builds and runs fine then try a Build > Clean Project
then Build > Rebuild Project
. If that doesn’t work, try File > Invalidate Caches...
, that usually sorts things out for us.
That’s a Wrap
Hopefully we’ve made a convincing enough demonstration for you that view bindings are easy to use and you should migrate to using them if you haven’t. With the examples provided, there should be no trouble fully integrating them into any app. Otherwise, we hope there’s information in here that could give you a hand next time you’re having trouble with your own view bindings.