Book Image

Clean Android Architecture

By : Alexandru Dumbravan
Book Image

Clean Android Architecture

By: Alexandru Dumbravan

Overview of this book

As an application’s code base increases, it becomes harder for developers to maintain existing features and introduce new ones. In this clean architecture book, you'll learn to identify when and how this problem emerges and how to structure your code to overcome it. The book starts by explaining clean architecture principles and Android architecture components and then explores the tools, frameworks, and libraries involved. You’ll learn how to structure your application in the data and domain layers, the technologies that go in each layer, and the role that each layer plays in keeping your application clean. You’ll understand how to arrange the code into these two layers and the components involved in assembling them. Finally, you'll cover the presentation layer and the patterns that can be applied to have a decoupled and testable code base. By the end of this architecture book, you'll be able to build an application following clean architecture principles and have the knowledge you need to maintain and test the application easily.
Table of Contents (15 chapters)
1
Part 1 – Introduction
6
Part 2 – Domain and Data Layers
10
Part 3 – Presentation Layer

Exploring the evolution of Android

In this section, we will look at key releases and changes that have been made to the Android framework and supporting libraries that have shaped the development of applications and how applications have evolved because of these changes.

We started by looking at an example of what the code in an older Android application looked like before looking at the design principles we should incorporate into our work. Now, let's see how the Android framework evolved and how some of our questions from the beginning have been answered. We will analyze some of the newer libraries, frameworks, and technologies that we can incorporate into an Android application.

Fragments

The introduction of fragments was meant to solve important issues developers were facing – that is, the activity code would become too big and hard to manage. They were released on Android Honeycomb, which was an Android release that only targeted tablets. The introduction of fragments was also meant to solve the issue of having different displays for activities in landscape versus activities in portrait. Fragments are meant to control portions of an activity's user interface.

Another improvement fragments brought was the ability to change and replace fragments at runtime. There was even a separate back stack for Fragments that the activity would be responsible for. This comes at a couple of costs: the life cycle of the fragment was even more complex than the life cycle of the activity, where you would have fragments that had their views destroyed but the fragments themselves weren't. Another cost was the communication between two fragments. If you needed to update the user interface being handled by Fragment1 because of a change in Fragment2, you would need to communicate through the activity. This meant that every time a Fragment needed to be reused by a different activity, then the activity would be forced to adapt to this:

Figure 1.3 – Activity and fragment life cycle

Figure 1.3 – Activity and fragment life cycle

In the preceding figure, we can see the difference between the lifecycle of activities and the lifecycle of fragments. We can observe how fragments have their own internal lifecycle for managing the views that they display between the onCreateView method and onDestroyView methods. This is often the reason why in many applications, you will see these methods used to load data and on the opposite site unsubscribing from any operations that might trigger a change in the user interface.

The Gradle build system

Initially, Android development used the Eclipse IDE and Ant as its build system. This came with certain limitations for applications. Things such as flavors were not available at the time. The release of Android Studio, along with the Gradle build system, provided new opportunities and features. This allows us to write extra scripts and easily integrate plugins and tools, such as performance monitoring of an application, Google Play services, Firebase Crashlytics, and more. This is often done through ".gradle" files. These files are written in a language called Groovy. Another improvement that was added was the usage of the ".gradle.kts" extensions, where we can provide the same configurations using the Kotlin language. The following code shows what the build.gradle file for a module looks like:

plugins {
    id 'com.android.application'
}
android {
    compileSdk 31
    defaultConfig {
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
        }
    }
    compileOptions {
    }
}
dependencies {
    implementation ""
}

In the plugins section, we can define external plugins that will provide certain methods and scripts that our project can use. Examples include annotation processing plugins, the Parcelize plugin, and Room plugins. In this case, the com.android.application plugin offers us the android configuration, which we can then use to specify the app version, what Android versions we want the app to be accessible from, various compilation options, and configurations for how the app should be built for the end user. In the dependencies section, we specify which external libraries we want to add to the project.

Networking

Quite a few popular networking libraries have emerged, mainly in the open sourcing community. A large proportion of the applications in Google Play rely on HTTP communication and a large proportion of them use JSON data. With the addition of networking libraries, JSON serialization/deserialization to POJOs also became adopted. What this means for developers is that the communication with the backend is simplified – we no longer need to concern ourselves with how the actual communication is done; we only point to where we want the data from and provide the models that are required for this communication. The libraries will take care of the rest. Some of the most popular libraries include Volley and Retrofit. In terms of object serialization, we have libraries such as Moshi and GSON.

Humble objects

Because activities and fragments are difficult to unit test, the code inside them needed to be split into testable sections and untestable sections. Because of this necessity, two patterns emerged: Model View Presenter (MVP) and Model View ViewModel (MVVM). Sometimes, these patterns are referred to as architecture patterns. This shouldn't be confused with the entire architecture of the app. The idea is to turn activities and fragments into humble objects with no logic, keep the references to the user interface objects, and shift the logic into the presenter and ViewModel, which we can write unit tests for. We will focus more on the particularities of each in Chapter 8, Implementing an MVVM Architecture.

Functional paradigms

Just like objected-oriented languages have adopted paradigms from functional programming, so has the Android development world in the form of RxJava. Functional programming works on the premise that programs are built from composing functions rather than imperative statements such as the ones in Java. RxJava is a library that allows developers to implement event-driven applications. It offers observables (for emitting data) and subscribers (for subscribing to that data). What made this library appealing to developers was how it deals with threading. Let's assume you wanted an operation to be executed on a separate thread, and then you wanted to transform your data – all you need to do here is invoke the data you want, apply mapping functions, and then subscribe to get the final result. The added benefit is that you can chain different operations, have them processed, and get the result with all of the operations. All of this removes the need for creating and managing different AsyncTasks or threads.

Kotlin adoption

RxJava introduced some aspects of functional programming. Its adoption and transition into Kotlin programming has added others. One of the most important is the concept of mutability. In Java, all variables are mutable unless they're declared otherwise through the final keyword. In Kotlin, all the variables must have their mutability declared. Why is this important? Because of multi-threading. If you had an application where multiple threads were executed at the same time and they all interacted with the same object, you would end up in a situation where you would either modify the same value at the same time or create deadlocks in which a thread would wait for another thread to release a resource, but the second thread would need access to a resource that the first thread is currently holding. This introduction helps developers aim for a greater degree of immutability, which would increase thread safety because immutable variables are thread-safe. Lambdas represent another great feature of Kotlin that allows boilerplate code to be reduced when you're dealing with callbacks. Other benefits of the adoption of Kotlin include that you can remove boilerplate code by introducing data classes, which represent POJOs, and introducing sealed classes, which allow developers to define enum-like structures that can carry data.

Dependency injection

Dependency injection represents the decoupling of object invocation and object creation. Why is this important? Mainly because of testing. It's easier to write unit tests for classes that have their dependencies injected rather than adding extra responsibilities, such as creating new instances for all of the dependencies in that class. Another benefit is in situations where we depend on abstractions. If we have a dependency on an abstraction, we can easily switch between different implementations, depending on different circumstances. Several libraries have emerged to tackle this issue: Dagger, Koin, and Hilt. Dagger is more of a general library that is not only Android applicable, but also applicable for other Java-based platforms. It aims to manage our dependencies using components and modules. Components are responsible for how the dependencies are managed, while modules are responsible for providing the appropriate dependencies. It relies on annotation processors, which generate the necessary code that will be responsible for managing our dependencies. Koin is what's referred to as a service locator library. It keeps a collection of all the dependencies and when a particular dependency is required, it will look it up and provide it. Koin is an Android-specific library, and it provides support for injecting specific Android dependencies. Hilt is the newest of these libraries and it is built on top of Dagger. It removes the boilerplate code that was required for Dagger and provides support for Android dependencies as well.

Android architecture components

This is represented by a set of libraries that help developers make their apps scalable, testable, and maintainable. These libraries affect components that deal with activity and fragment life cycles, persisting data, background work, and UIs. Here, we have seen the introduction of concepts such as life cycle owners (such as activities and fragments), the Android ViewModel, and LiveData. These are meant to solve problems developers had with managing the state of a life cycle owner when it's destroyed and recreated by the system. It puts the logic that, in the past, was handled by the life cycle owners and delegated to the Android ViewModel. The combination of the Android ViewModel and LiveData has helped developers implement the MVVM pattern, which is also life cycle aware. This means that developers no longer have to concern themselves with stopping a background task when the life cycle owner is destroyed.

The introduction of Room means that developers no longer have to deal with interacting with the SQLite framework, which caused a lot of boilerplate code to be written to define tables and various queries. Developers no longer need to deal with the SQLite interaction and the many dependencies that come with it; instead, they can focus on creating their own models and providing the abstractions for what needs to be queried, deleted, updated, and deleted; Room will take care of the actual implementations. DataStore does for SharedPreferences what Room does for SQLite. This is for when we want to store data in key-value pairs instead of using an entire table. DataStore provides two options for storing data: safely typed data and no type safety data.

With the addition of these new persistence libraries, the Repository pattern was adopted. The idea behind this pattern is to create a class that will interact with all the data sources we have in our application. As an example, let's imagine we have some data we will need to fetch from our backend that will then need to be stored locally in case we want the user to view it offline. Our repository would be responsible for fetching the data from the network class and then storing it using the persistence class. The repository would sit in between the local and remote classes and the classes that would want access to that data.

Regarding the UI, we now have access to view binding and data binding. Both of these deal with how activities and fragments deal with the views that are declared in our XML layout files. View binding generates references for each view we defined in our XML. This solves an issue that developers would have in the past where a view would be deleted from your XML file, but your application would still run because of another view with the same name in another file. This would cause crashes in the past because the findViewById function would return null. With view binding, we know at compile time what views we have in our hierarchy and what views we don't. Data binding allows us to bind our views to data sources. For example, we can bind a TextView in our XML file directly to a field in our source code. This approach tends to work well with the MVVM pattern, in which the ViewModel updates certain fields that are bound by views in our XML. This would update what the view would display without it interacting with the activity.

Coroutines and flows

Coroutines came as a feature of the Kotlin language. The idea behind coroutines is to execute data asynchronously in a very simplified manner. We no longer have to create threads or AsyncTasks (which have been deprecated) and manage concurrency because it's managed under the hood. Other features include that it's not bound to a particular thread, and it can be suspended and resumed. Flows represent an extension of coroutines where we can have multiple emissions of data, such as RxJava, providing similar benefits.

Jetpack Compose

This allows developers to build UIs directly in Kotlin without the use of XML files through composable functions. This removes the amount of code that needs to be written for building your UI. Compatibility with the other Android architecture component libraries is provided, allowing for easier integration into your application. The following is an example of what Compose looks like:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ExampleTheme {
                Surface {
                    ExampleScreen()
                }
            }
        }
    }
}
@Composable
fun ExampleScreen() {
    Column(modifier = Modifier.padding(16.dp)) {
        TextField(
            value = "",
            onValueChange = {
                // Handle text change
            },
            label = { Text("Input") }
        )
        Text(text = "Example text")
        Button(onClick = {
            // Handle button click
        }) {
            Text(text = "Button")
        }
    }
}

In this example, we can see a screen that contains an input field, some text that displays Example Text, and a button with the text Button. The layout of the screen is defined as a function annotated with the @Compose annotation. This content is then set in an activity through the setContent method, where a theme is provided. We will expand on how Jetpack Compose works later in this book.

Now, let's look at what our example code from the The architecture of a legacy app section will look like after we transition it through some of the aforementioned Android frameworks and updates. All our code will now be migrated to Kotlin. We will be using libraries such as Retrofit and Moshi for networking and JSON serialization and Hilt for dependency injection, as well as ViewModel, LiveData, and Compose for the UI layer. We will discuss how these libraries work in the following chapters.

The ConcreteData class will look this:

@JsonClass(generateAdapter = true)
data class ConcreteData(
    @Json(name = "field1") val field1: String,
    @Json(name = "field1") val field2: String
)

The ConcreteData class is now a Kotlin data class and will use the Moshi library for JSON conversion. Next, let's see what our HTTP request will look like when we use something such as Retrofit to handle our HTTP communication:

interface ConcreteDataService {
 
    @GET("/path")
    suspend fun getConcreteData(): ConcreteData
}

Because we use Retrofit and OkHttp, we only need to define the template for the endpoint we want to connect to and the data we want; the libraries will handle the rest. The suspend keyword will come in handy for Kotlin flows.

Now, let's define a repository class that will be responsible for invoking this HTTP call on a separate thread:

class ConcreteDataRepository @Inject constructor(private val concreteDataService: ConcreteDataService) {
 
    fun getConcreteData(): Flow<ConcreteData> {
        return flow {
            val fooList = concreteDataService.
                getConcreteData()
            emit(fooList)
        }.flowOn(Dispatchers.IO)
    }
}

ConcreteDataRepository will have a dependency on ConcreteDataService, which it will call to fetch the data. It will be responsible for retrieving the data on a separate thread by using Kotlin flows. The constructor will be annotated with the @Inject annotation because we are using Hilt, which will inject ConcreteDataService into ConcreteDataRepository.

Now, let's create a ViewModel that will depend on the repository to load the appropriate data:

@HiltViewModel
class MainViewModel @Inject constructor(private val concreteDataRepository: ConcreteDataRepository) :
    ViewModel() {
 
    private val _concreteData = MutableLiveData
        <ConcreteData>()
    val concreteData: LiveData<ConcreteData> get() = 
        _concreteData
 
    fun loadConcreteData() {
        viewModelScope.launch {
            concreteDataRepository.getConcreteData()
                .collect { data ->
                    _concreteData.postValue(data)
                }
        }
    }
}

MainViewModel will then use ConcreteDataRepository to retrieve the data, subscribe to the result, and post the result in LiveData, which MainActivity will subscribe to.

Now, let's create MainActivity:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Screen()
        }
    }
}
 
@Composable
fun Screen(mainViewModel: MainViewModel = viewModel()){
    mainViewModel.loadConcreteData()
    UpdateText()
}
 
@Composable
fun UpdateText(mainViewModel: MainViewModel = viewModel()) {
    val concreteData by mainViewModel.concreteData.
        observeAsState(ConcreteData("test", "test"))
    MessageView(text = concreteData.field1)
 
}
 
@Composable
fun MessageView(text: String) {
    Text(text = text)
}

MainActivity is now written using Jetpack Compose. It will trigger the data load when the screen is created and then subscribe to LiveData from ViewModel, which will update the text on the screen when the data is loaded.

Since we are using Hilt for dependency injection, we will need to define our external dependencies in a module, as follows:

@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
 
    @Singleton
    @Provides
    fun provideHttpClient(): OkHttpClient {
        return OkHttpClient
            .Builder()
            .readTimeout(15, TimeUnit.SECONDS)
            .connectTimeout(15, TimeUnit.SECONDS)
            .build()
    }
}

First, we must provide the OkHttp client, which is used to make the HTTP requests.

Next, we will need to provide the JSON serialization:

@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
    … 
    @Singleton
    @Provides
    fun provideConverterFactory(): MoshiConverterFactory = MoshiConverterFactory.create()
}

We are using the Moshi library for JSON serialization, so we will have to provide a Factory that will be used by Retrofit for JSON conversion.

Next, we need to provide a Retrofit object:

@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
    …
    @Singleton
    @Provides
    fun provideRetrofit(
        okHttpClient: OkHttpClient,
        gsonConverterFactory: MoshiConverterFactory
    ): Retrofit {
        return Retrofit.Builder()
            .baseUrl("schema://host.com")
            .client(okHttpClient)
            .addConverterFactory(gsonConverterFactory)
            .build()
    }
}

The Retrofit object will need a base URL that will act as the host for our backend service, OkHttpClient, and the JSON converter factory, which were provided earlier.

Finally, we will need to provide the template we defined previously:

@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
 
    @Singleton
    @Provides
    fun provideConcreteDataService(retrofit: Retrofit): 
        ConcreteDataService =
           retrofit.create(ConcreteDataService::class.java)
}

Here, we will use Retrofit to create an instance of ConcreteDataService that will be injected into ConcreteDataRepository by Hilt.

Finally, we need to initialize Hilt in the Application class:

@HiltAndroidApp
class MyApplication : Application()

This code represents a 10-year jump in time when it comes to Android development. Going back to the questions we asked for the initial example in the Legacy analysis section, we can see that we answered quite a few. If we want to introduce persistence into the application, we now have a repository that can manage that for us. We also have a lot of classes that can be individually unit tested because of the introduction of Hilt and because we have delimited separated from the Android framework dependencies. We have also introduced flows, which allow us to manipulate and handle the data in case we need to connect to multiple sources and handle multi-threading more easily. The introduction of Kotlin and Retrofit also allowed us to reduce the amount of code. If we were to make a diagram of this, it would look as follows:

Figure 1.4 – A class diagram for a newer Android application

Figure 1.4 – A class diagram for a newer Android application

Here, we can see that the dependencies between the classes go from one direction to the other, which is another positive. The introduction of Retrofit saved us a lot of hassle when dealing with HTTP requests. But an issue remains with regards to how ConcreteData is handled. We can see that it travels from ConcreteDataService into MainActivity. Imagine if we wanted to provide the data from a different URL with a different POJO representation. This means that all of the classes will have to be changed to accommodate for this. This violates the single responsibility principle because the ConcreteData class is used to serve multiple actors in our application. In the next section, we will try to seek a solution to this problem and address ways to properly structure our classes and components.

With that, we have explored the evolution of the Android platform and tools, what an application may look like using the latest tools and libraries, and how this evolution solved many problems developers had in the past. However, we still haven't solved all of them. In the next section, we will talk about the concept of clean architecture and how we can use it to make our application flexible and more adaptable to changes.