Blog Infos
Author
Published
Topics
, ,
Author
Published

Clean Architecture is a software architecture proposed by Uncle Bob, who is also the namer of the SOLID principles.

The diagram of Clean Architecture is as follows:

This diagram represents the architecture of an entire software system, rather than a single software, which includes at least the server-side and client-side components.

For Android monolithic app development, a more appropriate and precise Clean Architecture diagram is needed.

I have roughly summarized my past development experience, identified the essential parts of the app architecture, and then produced the following Android app architecture diagram under the guidance of Clean Architecture:

As well as the general data flow diagram:

Dependency Relationships

The fundamental rule of Clean Architecture is that the inner layers of the source code do not depend on the outer layers. Dependence is always unidirectional, going from the outer layer to the inner layer.

As shown above, the Model layer has no dependencies. The UseCase layer can depend on the Model and Repo layers but must not depend on the ViewModel layer. The UI layer depends on the ViewModel layer, but the ViewModel must not depend on the UI layer.

To achieve this level of source code dependency, we must rely on some tools to implement dependency injection. We can generally use a framework like Hilt or Koin.

Moreover, dependency injection should not be abused. Not all objects are suitable for dependency injection. It should only be used for modules with clear hierarchical relationships and definite dependencies. Clearly, there is no need to inject utility classes.

Model (Domain Model)

The business model, or domain model, is a specific model designed according to the software business. Generally, it would be a data class which does not contain any business logic and is just a pure model object.

As it is in the most inner layer of the whole architecture, it does not depend on any other modules and is relatively stable. This factor should be considered when designing. If the model changes, it means that all the upper layers that depend on it may change and need to be retested.

In terms of naming and package structure, domain models don’t need to have suffixes like Entity. You can directly name them like “User”. However, considering that this is on the most inner layer of the software and may be dependent on all modules, the naming should be as close to its design target as possible and can’t be too broad. They should be stored in the “model” package.

Adapter (Data Adapter)

The data adapter layer is mainly used for data conversion and has two main responsibilities:

  • Convert network interface entity data classes and domain models.
  • Convert among domain models.

The Adapter layer is quite pure, it only performs simple data conversions, and all functions exposed to the outside are idempotent functions.

If the data conversion process involves complex business logic, you can consider first handling it with UseCase before giving it to the Adapter. However, because the Adapter layer is closer to the inner layers than the UseCase layer, the Adapter cannot rely on the UseCase.

Usually, we name by starting with the class to be converted and ending with “Adapter”. For example, if we want to convert UserEntity into User, we would write it like this:

class UserEntityAdapter{
    
    fun toUser(entity: UserEntity): User {
        //...
    }
}
Repo

For Android development, the Repo layer should encapsulate data read and write operations from the network interface or local disk. Repo users do not need to be concerned with the specific implementation, and generally, Repo does not contain complex business logic but only simple data processing logic.

The Repo should hide implementation details, including not only whether the data is fetched from the network or local storage, but also the corresponding entity data classes. This means that the input and output parameters of functions exposed by the Repo layer should not include interface return entity classes or database table entity classes; they can only include domain models or basic types. The data class for the Room database table should be restricted within the Repo, and the data class for the Retrofit interface return data should also be limited within the Repo.

data class UserEntity(val name: String, val avatar: String)
interface UserService {
@GET("/user")
suspend fun getUserInfo(@query("id") id: String): UserEntity
}
data class User(val name: String, val avatar: String)
class UserEntityAdapter @Inject constructor() {
fun toUser(entity: UserEntity): User {
return User(name = entity.name, avatar = entity.avatar)
}
}
class UserRepo @Inject constructor(
private val userEntityAdapter: UserEntityAdapter,
) {
private val userService: UserService by lazy {
retrofit.create(UserService::class.java)
}
suspend fun getUser(id: String): User {
return userService.getUserInfo(id).let(userEntityAdapter::toUser)
}
}

In addition to the aforementioned data conversions, request data also needs to be transformed in the Repo layer. For Post requests, there might be a request entity. This entity data class should not be exposed either. Some transformations can be made at the request method input parameters in the Repo layer to make the input parameters simpler and more user-friendly.

Another role of the Repo layer is to convert un-user-friendly data models obtained from interfaces or databases into user-friendly data models.

Furthermore, now with the existence of a BFF, for some simpler business scenarios, we can make some compromises for convenience, meaning the response data entity class from the interface can penetrate the Repo layer and go directly to the ViewModel layer or even used in UiState. However, it should be understood that this is just a compromise for convenience and not the best practice; the scope of impact should be strictly controlled.

UseCase (use case)

A UseCase usually refers to the business logic in specific application scenarios. The use case guides the input and output of data among models and directs the business entities to use key business logic to achieve the use case’s design goals.

Therefore, a UseCase often only includes a specific business logic segment. Its input and output are both basic types or domain models, and it is an idempotent function, a pure function. So Google suggests that each UseCase should only contain one public function, used in a manner similar to the following:

class DoSomethingUseCase {
operator fun invoke(xxx: Foo): Bar {
// ...
}
}

By utilizing Kotlin features, we can make UseCase feel like using a function directly when in use.

However, considering dependency and management issues, it’s better for UseCase not to be implemented directly as a function. Instead, define a class as shown above, and then expose a function with an overloaded operator.

When using UseCase, you can use it like this:

class LoginViewModel @Inject constructor(
private val doSomething: DoSomethingUseCase,
): ViewModel(){
fun onLoginClick(){
doSomething()
}
}
Problems with UseCase

UseCases are very fine-grained, with essentially each UseCase being a single function. In a complex business context, there will be numerous UseCases. As the business grows, managing them will become increasingly difficult.

Therefore, UseCases need an effective means for management. First, their package names should be divided according to their functionality. UseCases with the same business functions should have the same package names.

Secondly, we should not fall into the extreme situation where all business logic is handled with UseCases. Often, we can organize some highly similar functionalities within a single class, which provides multiple public methods. This kind of writing has been prevalent in the past, such as various Managers, Helpers, Resolvers, etc. They can effectively reduce the number of UseCases and simplify the process.

UiState

UiState is a collection class used to describe the current UI state; generally, it should be a data class.

UiState must be immutable. If you want to change one of its values, you should create a new object using the data class’s copy method, like this:

data class LoginUiState(
val name:String,
val avatar: String,
val consentAgreed: Boolean
)
fun onAgreeChecked(){
uistate = uiState.copy(
consentAgreed = true,
)
}

Data in the UiState should be as convenient for the UI to use directly as possible since UiState is designed for the UI itself. For example, for a formatted time display, it’s best to place the formatting logic in the ViewModel or a deeper layer, rather than giving UiState a timestamp directly and letting the UI layer handle the formatting. Simple-looking logic may still be prone to errors, and the UI layer may not have the capability to handle exceptions.

In ViewModel, if you need to update UiState, you can use the update method directly.

_uiState.update {
    it.copy(
        name = "zhangke"
    )
}

ViewModel

ViewModel is responsible for managing UI states and executing corresponding business logic.

Therefore, the lifecycle of the ViewModel is consistent with the page.

Usually, we use the ViewModel provided by Jetpack directly, but you can also create your own types of ViewModels as long as you control the lifecycle properly.

ViewModel mainly handles two things:

  • Providing the current UI state to the outside, converting among domain models.
  • Receiving UI events and responding to them

We provide the current UI state to the outside by wrapping the UiState in a StateFlow.

private val _uiState = MutableStateFlow()
val uiState:StateFlow<UserUiState> = _uiState.asStateFlow()

Receiving UI events is an important aspect to consider. The ViewModel’s task is to receive UI events, such as user gesture input. As for what actions need to be done after the user clicks, this is the ViewModel’s internal logic and should not be exposed externally.

UI Layer

The UI Layer we refer to here is a single page. In addition to the conventional Activity/Fragment, for Compose, a single page may correspond to a Composable function, depending on how the UI layer is implemented.

The UI layer should be entirely data-driven. Its purpose is to render UiState 100% of the time. When UiState changes, the UI changes accordingly. This is something declarative UI frameworks handle very well.

Although the UI layer can also handle some simple events, most events still need to be processed by the ViewModel.

The above describes some key concepts in Android’s clean architecture. I’ve been developing in line with this architecture for over a year now, and it indeed ensures a clean structure. However, for more complex business scenarios, especially those that may need to penetrate multiple layers and span across standard lifecycles, more refined designs would be necessary.

This article was previously published on proandroiddev.com

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu