How To Use Hilt to Setup a Solid Architecture in Android

Using the Hilt framework at its full potential — our success story!

Dhanesh Katre
Better Programming

--

Want to build a complete end-to-end feature flow with proper state management while also surviving configuration changes and of course, shooing off all the bugs? Here’s how Hilt helped us to create the most suitable architecture for this exact use case…

What was the problem?

A few months ago, while we initially developed a brand new feature, we had used single activity + nav-graph, along with only one ViewModel to control the entire flow.

The thought process behind this was to have a central place for data and data processing, but we failed to foresee the growing complexity of the end-to-end flow!

As a result, this monolithic architecture caused data and logic processing to be concentrated into one big ViewModel, with over 2k+ lines of code in a single file!

The codebase turned into an Augean stable real quick, resulting in tons of bugs, a hefty amount of production issues, and total havoc in surviving configuration changes! Thoughts of making this flow better were lingering in our minds for long time…

Fast forward a few months, a requirement came to enhance that feature, to cover more use cases and all possible flows. The feature could only scale up vastly. We decided to seize the moment, refactoring our code to make it more maintainable with lesser bugs and also a true survivor in cases of all the configuration changes!

What did we do?

We analyzed the latest trends in Android development, few design patterns in this scenario, and came up with our own mods to form a new suitable architecture with the help of the dependency injection framework, Hilt.

The following diagram shows the general architecture we adhered to:

Our new architecture built with the help of HILT

Components used?

  1. ViewBinding
  2. Hilt for Dependency Injection
  3. NavGraph + Single Activity Principle
  4. ViewModels for surviving configurations + Logic processing
  5. Repository pattern for Data I/O operations (Room + Retrofit)
  6. Hilt provided an activity-scoped instance of Data holder
  7. Kotlin Coroutines for asynchronous tasks
  8. Kotlin Flows for input form validation

Advantages?

  1. Breaks overall monolithic architecture down into small parts, which are easier to manage and maintain individually
  2. Having a separate Data vendor (Repository), Data holder (Data Source) and Data processor (ViewModel) helps achieving cleaner code as it follows the Separation of Concerns paradigm effectively
  3. Controlling navigation directly from ViewModels, making navigation state easier to manage while maintaining high testability
  4. Controlling progress indicators, Toast messages, resources and few general interactions from a common point in ViewModels, making state management and testing easier
  5. Full interoperability with Kotlin Coroutines + Flow APIs resulting in clean, concise and fully testable asynchronous code with lesser bugs
  6. Logical processing + state management happens from ViewModels, hence surviving configuration change is a child’s play!
  7. [Additional] Using data binding along with this architecture will further reduce some code lines from fragments and activity
  8. [Additional] Fully supported for Jetpack Compose, as underlying architecture can be retained by just changing layouts to Composables

Show me the Code

The essential components of the entire architecture are BaseViewModel, BaseFragment, and the DataSource classes that control pretty much the entire architecture!

The code for BaseViewModel is given below:

BaseViewModel here controls almost all the general activities used throughout the flow, including fragment to fragment navigation (with default nav arguments, so less cluttered nav graph XML), showing/hiding the progress dialog (loader), showing relevant toasts, etc.

ViewModels can inherit from this BaseViewModel to use all of the functionality directly!

The code for the BaseFragment is:

A base class for all the fragments in the feature, connected by a nav graph. It especially supports ViewBinding and our beloved BaseViewModel, while observing the common live data emitted from BaseViewModel.

As ViewBinding is used, creation and destruction of views can be easily generalized in this fragment, so child fragment doesn’t have to take care of it! (It can also be used without ViewBinding, in which case the constructor needs a little bit of tweaking)

Notice that this base fragment bears an abstract instance of BaseViewModel, which every fragment can decide upon the provision, on its own! (So, if some of the fragments still want to share the ViewModel, it is possible)

The code for the DataSource:

The heart of our feature, data holder class, or a data source (inspired from clean architecture) is a special Hilt provided class that holds the gathered data, state variables throughout the flow!

The specialty of this class is @ActivityRetainedScope annotation! That means, all the members that have this dependency injected, will receive the same instance of it, as long as they live in the same activity instance.

This was otherwise possible by the creation of a Singleton class, but Hilt further helps to reduce memory usage by cleaning unwanted states after the flow is terminated fully!

This way, state corruption can be avoided in case the same flow is getting reused elsewhere. The hilt also helps to provide the same instance across the configuration changes, so the state is always stored in a stable manner.

Example Usage

One use case of FragmentTwo here, inheriting from BaseFragment, and doing all the UI handling in setupUI() method, while taking care of ViewModel activities in separate dedicated method setupVM().

Notice that the view creation and disposal is not handled by individual fragments, but is managed by BaseFragment itself! So much lesser code with concerns separated properly.

Also, a point, since we allow fragments to manage ViewModel instance creation on their own, we can directly use by viewModels() extension for that purpose! If some fragment still wants to share the ViewModel with the other fragments, it is also possible with using by navGraphViewModels() or by activityViewModels() functions as per the requirement.

Given below is the ViewModelTwo used in association with our FragmentTwo:

ViewModelTwo assists our FragmentTwo in surviving the configuration changes as well as in processing and validating the events/data coming from it!

When a value is entered in edit text, it is passed onto ViewModel for storage and validation, and a flow is exposed towards fragment which is used to further control states such as continue button’s enablement or edit text’s error visibility.

On click of the button, the entered value gets stored in our data store, which is injected with the help of Hilt, and the same value can then be used by the next ViewModel appearing inflow, as the data source instance will be the same for any of the injected component.

Finally, based on the entered value, the navigation is being taken care of from the view model. All of this code is fully testable from a unit testing perspective, resulting in higher code coverage and lesser bugs!

You can check out the full codebase, an entire working application using this architecture I’ve built on: https://github.com/dkexception/architecture-with-hilt

Summary

Using Hilt effectively in unison with other Android components, helped us a lot in developing quality features in time.

The secret to developing great applications is to follow some of the core principles and standard guidelines. This architecture solves many of the common problems related to Android applications and is much recommended for flow-based feature developments.

What do you think about this architecture? Would you like to improve it furthermore? Anything that you’re concerned about? Please do let me know in the comments!

Special thanks to Rahul for bringing up the data source idea, and always supporting it throughout the flow development!

--

--