A flexible, modern Android application architecture

A flexible, modern Android application architecture


Learn the principles of Android architecture: Learn the principles, don't follow the rules blindly.

This article aims to demonstrate practical application by example: teaching by demonstrating the Android architecture. Most importantly, this means showing how various architectural decisions are made. In some situations we are confronted with several possible answers, and in each case we rely on principles rather than memorizing a set of rules mechanically.

So let's build an app together.

Introducing the app we're going to build

We're going to build an app for planet spotters. It will roughly look like this:


Our application will have the following functionality:

  1. List of all planets discovered
  2. Ways to add new planets just discovered
  3. Ways to delete planets (in case you realize your discovery is actually just a smudge on a telescope lens)
  4. Add some sample planets to give users an idea of ​​how the app works
    It will have offline data caching as well as online access to the database.

As always, in my step-by-step guides, I encourage you to deviate from the norm: add additional features, consider possible future specification changes, and challenge yourself. Here, learning focuses on the thought process behind the code, not just the code itself. So if you want to get the best out of this tutorial, please don't just copy the code blindly.

Here's the repository link we'll end up with:

https://github.com/tdcolvin/PlanetSpotters

Introduce the architectural principles we will be using

We'll be inspired by SOLID principles, clear architecture principles, and Google's Modern Application Architecture principles.

We don't treat these principles as hard and fast rules, because we're smart enough to build something that works for our app (especially for our expected app growth). For example, if you follow a clear architecture like a religion, you will produce solid, reliable, scalable software, but your code may be too complex for a single-purpose application. Google's principles result in simpler code, but are less appropriate if the app might one day be maintained by multiple large development teams.

We'll start with Google's topology and be inspired by a clear architecture along the way.

Google's topology is as follows:
Google's Official Best Practice Architecture Guidelines
Let's implement this architecture step by step, and explore each part in more depth in a recent article of mine. But as a brief overview:

UI layer

The UI layer implements the user interface. It is divided into:

  • UI elements , which are all the proprietary code used to draw things on the screen. In Android, the main choices are Jetpack Compose (in this case, @Composablesput here) or XML (in this case, include XML files and resources here).
  • State holders , this is where you implement your preferred MVVM/MVC/MVP etc. topology. In this application, we will use view models.

Domain Layer

The domain layer is used for use cases that contain high-level business logic. For example, when we want to add a planet, AddPlanetUseCasethe steps required to do so are described. It's a series of "what", not "how": for example, we'd say "save the data of a planet object". This is an advanced command. We don't say "save it to the local cache", let alone "use the Room database to save it to the local cache" - these lower level implementation details go elsewhere.

Data Layer

Google urges us to have a single source of truth for the data in our apps; that is, the means to get the absolutely "correct" version of the data. This is what the data layer will provide us (everything except the data structure describing what the user just entered). It is divided into:

  • Repository, which manages data types. For example, we will have a repository of planet data that will provide CRUD (create, read, update, delete) operations for discovered planets. It will also handle cases where data is stored in a local cache and accessed remotely, selecting the appropriate source to perform different kinds of operations, and managing cases where two sources contain different copies of data. Here, we will talk about the situation of local caching, but we still won't talk about what third-party technology we will use to achieve it.
  • Data sources, which manage how data is stored. When a repository asks for "remote store X", it asks the data source to do so. The data source contains only the code needed to drive the proprietary technology -- maybe Firebase, or an HTTP API, or whatever.

Good Architecture Allows Delayed Decisions

At this stage, we know what the functionality of the application will be, and some basic idea of ​​how it will manage its data.

There are still some things we haven't decided yet. We don't know what the UI will look like, or what technology we'll use to build it (Jetpack Compose, XML, etc.). We don't know what form the local cache will take. We do not know what proprietary solution we will use to access the online data. We don't know if we'll be supporting phones, tablets, or other form factors.

Question: Do we need to know any of the above to formulate our architecture?
Answer: No need!

These are all low-level considerations (their code would be the outermost in a clear architecture). They are implementation details, not logic. SOLID's Dependency Inversion Principle tells us that we should not write code that depends on them.

In other words, we should be able to write (and test!) the rest of our application code without knowing any of the above. When we know exactly the answers to the above questions, nothing we have already written will need to change.

This means that the code production phase can begin long before designers have finalized the design and stakeholders have decided on third-party technologies to use. Therefore, good architecture allows for delayed decisions. (And have the flexibility to undo any such decisions without causing a lot of code clutter).

Architecture diagram of our project

Below is our first attempt at putting the planet spotter app into Google Topology.

Data Layer
We will have a repository of planetary data, and two data sources: one for the local cache and one for the remote data.

The UI Layer
will have two state holders, one for the planet list page and one for the add planet page. Each page will also have its own set of UI elements, the technology used can remain undetermined for now.

Domain layer
We have two perfectly valid ways to structure our domain layer:

We only add use cases where business logic is repeated. In our app, the only logic that repeats is where planets are added: users need it when they add the sample planet list, but also when they manually enter their own planet details. Therefore, we will only create one use case: AddPlanetUseCase. In other cases (such as deleting a planet), the state holder will interact directly with the repository.
We add every interaction with the repository as a use case so that there is never a direct connection between the state holder and the repository. In this case we will have use cases for adding planets, removing planets and listing planets.
The benefit of option #2 is that it follows the rules of a clear architecture. But personally I think it's a bit heavy for most applications, so I tend to go with option #1. That's what we're going to do here, too.

This brings us to the following architecture diagram:
app architecture diagram

where to start writing code

What code should we start with?
The rule is:
start with high-level code and work your way down.

This means writing the use cases first, because doing so will tell us what the requirements are for the repository layer. Once we know what the repository needs, we can write the requirements that the data source needs to meet in order to operate.

Likewise, since use cases tell us all possible actions a user can take, we know that all input and output comes from the UI. From this information, we will understand what the UI needs to contain, so we can write the state holders (view models). Then, with state holders, we know which UI elements need to be written.

Of course, we can delay writing UI elements and data sources (i.e. all low-level code) indefinitely until senior engineers and project stakeholders agree on the technology to be used.

This concludes the theoretical part. Now let's start building the application. I will guide you as you make your decisions.

Step 1: Create Project

Open Android Studio and create a "No Activity" project:
Android Studio “no activity” project
New project detail window in Android Studio
On the next screen, name it PlanetSpottersand leave everything else the same: Add Dependency Injection
We will need a dependency injection framework, which helps to apply SOLID's Dependency Inversion principles. Here, my top choice is Hilt, luckily, also the one specifically recommended by Google.

To add Hilt, add the following to your root Gradle file: and then add this to the app/build.gradlefile: (Note that we're setting compatibility here to Java 17, which is required for Kapt and used by Hilt. You'll need Android Studio Flamingo or higher).

Finally, @HiltAndroidApprewrite Applicationthe class by adding annotations. com.tdcolvin.planetspottersThat is, create a file in your app's packages folder (here PlanetSpottersApplication) with the following content: ... and then tell the OS to instantiate it by adding it to the manifest: ... Once we have our main activity, we will need to add to it @AndroidEntryPoint. But for now, this completes our Hilt setup.

Finally, we'll add support for other useful libraries by app/build.gradleadding : Step 1: List everything a user can do and see
This step is required before writing use cases and repositories. Recall that a use case is a single task that a user can perform, described at a high level (what rather than how).

So let's start writing the tasks; an exhaustive list of all the tasks a user can perform and view in the application.

Some of these tasks will eventually be coded as use cases. (In fact, under Clean Architecture, all these tasks must be written as use cases). Other tasks will be handled by the UI layer directly interacting with the repository layer.

A written specification is required here. No UI design required, but of course it helps with visualization if you have one.

Here is our list:

  1. Get a list of discovered planets, which updates automatically Input
    : None
    Output: Flow<List<Planet>>
    Action: Request the current list of discovered planets from the repository to keep us updated when changes occur.

  2. Get details of a single discovered planet, which is automatically updated
    Input: String - The ID of the planet we want to fetch
    Output: Flow<Planet>
    Action: Request the planet with the given ID from the repository, and ask to keep us updated when changes occur.

  3. To add/edit a newly discovered planet
    type:

    • planetId:String?- If non-null, the planet id to edit. If empty, we are adding new planets.
    • name:String- the name of the planet
    • distanceLy:Float- Distance of planet to Earth (light years)
    • discovered:Date-Date found
      Output: None (Success is determined by completing without exceptions)

    Action: Create a Planet object from the input and pass it to the repository (to add to its data source).

  4. Add some example planets
    Input: None
    Output: None
    Action: Ask repository to add three example planets with discovery date current time: Trenzalore (300 ly), Skaro (0.5 ly), Gallifrey (40 ly).

  5. delete a planet
    Input: String - ID of the planet to delete
    Output: None
    Action: Ask the repository to delete the planet with the given ID.

Now that we have this list, we can start coding use cases and repositories.

Step 2: Writing Use Cases

According to the first step, we have a list of tasks that the user can perform. Earlier, we decided to code the task "add planet" as a use case. (We decided to add use cases only when tasks are repeated in different areas of the application).

This gives us a use case

val addPlanetUseCase: AddPlanetUseCase =

//Use our instance as if it were a function:
addPlanetUseCase()

The following is AddPlanetUseCasethe implementation code:

class AddPlanetUseCase @Inject constructor(private val planetsRepository: PlanetsRepository) {
    
    
    suspend operator fun invoke(planet: Planet) {
    
    
        if (planet.name.isEmpty()) {
    
    
            throw Exception("Please specify a planet name")
        }
        if (planet.distanceLy < 0) {
    
    
            throw Exception("Please enter a positive distance")
        }
        if (planet.discovered.after(Date())) {
    
    
            throw Exception("Please enter a discovery date in the past")
        }
        planetsRepository.addPlanet(planet)
    }
}

Here, PlanetsRepositoryis an interface listing the methods the repository will have. More on this later (especially why we create interfaces instead of classes). But let's create it now so our code compiles:

interface PlanetsRepository {
    
    
    suspend fun addPlanet(planet: Planet)
}

PlanetThe data types are defined as follows:

data class Planet(
    val planetId: String?,
    val name: String,
    val distanceLy: Float,
    val discovered: Date
)

addPlanetThe method (just like the function in the use case invoke) is declared as suspendbecause we know it will involve background work. We'll add more methods to this interface in the future, but for now this will suffice.

By the way, you might ask why we went to the trouble of creating such a simple use case. The answer is that it may become more complex in the future, and external code can be insulated from that complexity.

Step 2.1: Test Use Cases

We've written the use case now, but we can't run it. First, it depends on PlanetsRepositorythe interface, for which we don't have an implementation yet. Hilt doesn't know what to do with it.

But we can write test code, provide a fake PlanetsRepositoryinstance, and run it using our test framework. That's what you should do now.

Since this is a tutorial on architecture, the details of testing are out of scope, so this step is left as an exercise for you. But note that good architectural design lets us split components into easily testable parts.

Step 3: Data Layer, WritingPlanetsRepository

Remember, the warehouse's job is to integrate disparate data sources, handle differences between them, and provide CRUD operations.

Using Dependency Inversion and Dependency Injection

According to Clean Architecture and Dependency Inversion principles (more on that in my last post), we want to avoid external code depending on the repository for internal code. This way, use cases or view models (for example) are not affected by changes to the repository code.

This explains why we previously PlanetsRepositorycreated as an interface (rather than a class). The calling code will only depend on the interface, but it will receive the implementation through dependency injection. So now we're going to add some more methods to the interface, and create its implementation, which we'll call DefaultPlanetsRepository.

(Also: some development teams follow the convention of calling implements <interface name>Implas, eg PlanetsRepositoryImpl. I think this convention is bad for reading: the class name should tell you why an interface is implemented. So I avoid this convention. But I mention it because it is widely used.)

Making data available with Kotlin Flows

If you haven't touched Kotlin Flows, please stop what you are doing and read related materials now. They will change your life.

https://developer.android.com/kotlin/flow

They provide a data "pipeline" that changes as new results become available. As long as callers subscribe to the pipeline, they will receive updates when there are changes. So now our UI can automatically update when the data is updated with little extra work. Compared to the past, we have to manually signal to the UI that the data has changed.

While other similar solutions exist, such as RxJavaand MutableLiveData, which do similar things, they are not as flexible and easy to use as Flows.

Add commonly used WorkResultclasses

WorkResultClasses are a common return type for data layers. It allows us to describe whether a particular request was successful, and is defined as follows:

//WorkResult.kt
package com.tdcolvin.planetspotters.data.repository

sealed class WorkResult<out R> {
    
    
    data class Success<out T>(val data: T) : WorkResult<T>()
    data class Error(val exception: Exception) : WorkResult<Nothing>()
    object Loading : WorkResult<Nothing>()
}

WorkResultCalling code can check whether the given is Success, Erroror Loadingobject (the latter indicates that it has not yet completed), so as to determine whether the request was successful.

Step 4: Implement the Repository interface

Let's put the above together and PlanetsRepositorymake a specification for the methods and properties that make up our .

It has two methods for getting planets. The first method gets a single planet by its ID:

fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>

The second method gets a Flow representing a list of planets:

fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>

Each of these methods is a single source of data for each. Each time we will return the data stored in the local cache, because we need to handle the case where these methods are run frequently, and local data is faster and cheaper than accessing remote data sources. But we also need a way to refresh the local cache. This will update the local data source from the remote data source:

suspend fun refreshPlanets()

Next, we need methods to add, update and delete planets:

suspend fun addPlanet(planet: Planet)

suspend fun deletePlanet(planetId: String)

So our interface now looks like this:

interface PlanetsRepository {
    
    
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    suspend fun refreshPlanets()
    suspend fun addPlanet(planet: Planet)
    suspend fun deletePlanet(planetId: String)
}

Write code while writing data source interface

In order to write a class that implements this interface, we need to pay attention to which methods the data source will require. Recall that we have two data sources: LocalDataSourceand RemoteDataSource. We haven't decided which third-party technology to use to implement them -- and we don't need one right now.

Let's create the interface definition now, ready to add method signatures if needed:

//LocalDataSource.kt
package com.tdcolvin.planetspotters.data.source.local

interface LocalDataSource {
    
    
  //Ready to add method signatures here...
}
//RemoteDataSource.kt
package com.tdcolvin.planetspotters.data.source.remote

interface RemoteDataSource {
    
    
  //Ready to add method signatures here...
}

Now ready to populate these interfaces, we can write DefaultPlanetsRepository. Let's look at each method one by one:

Both methods are simple to write getPlanetFlow()and we return the data from the local source. getPlanetsFlow()
(Why not a remote source? Because a local source exists for fast, resource-light access to data. A remote source may always be up to date, but it is slower. If we strictly need the latest data, we can use the below before calling getPlanetsFlow(). refreshPlanets())

//DefaultPlanetsRepository.kt 
override fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>> {
    
    
    return localDataSource.getPlanetsFlow()
}

override fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>> {
    
    
    return localDataSource.getPlanetFlow(planetId)
}

So it depends on the sum function LocalDataSourcein . We will now add them to the interface so our code will compile.getPlanetFlow()getPlanetsFlow()

//LocalDataSource.kt
interface LocalDataSource {
    
    
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
}

writing refreshPlanets()method

To update the local cache, we get the current list of planets from the remote data source and save it to the local data source. (The local data source can then "sense" the change and emit a new list of planets via getPlanetsFlow()the returned .)Flow

//DefaultPlanetsRepository.kt 
override suspend fun refreshPlanets() {
    
    
    val planets = remoteDataSource.getPlanets()
    localDataSource.setPlanets(planets)
}

This required adding a new method to each data source interface, which now looks like this:

//LocalDataSource.kt 
interface LocalDataSource {
    
    
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
    suspend fun setPlanets(planets: List<Planet>)
}
//RemoteDataSource.kt
interface RemoteDataSource {
    
    
    suspend fun getPlanets(): List<Planet>
}

When writing addPlanet()and deletePlanet()functions, they all follow the same pattern: perform a write operation on the remote data source, and if successful, reflect the changes into the local cache.

We expect the remote data source to assign a unique ID to the Planet object, and once it's in the database, the RemoteDataSource's addPlanet()functions will return the updated Planet object with a non-null ID.

//PlanetsRepository.kt 
override suspend fun addPlanet(planet: Planet) {
    
    
    val planetWithId = remoteDataSource.addPlanet(planet)
    localDataSource.addPlanet(planetWithId)
}

override suspend fun deletePlanet(planetId: String) {
    
    
    remoteDataSource.deletePlanet(planetId)
    localDataSource.deletePlanet(planetId)
}

Our final data source interface is as follows:

//LocalDataSource.kt
interface LocalDataSource {
    
    
    fun getPlanetsFlow(): Flow<WorkResult<List<Planet>>>
    fun getPlanetFlow(planetId: String): Flow<WorkResult<Planet?>>
    suspend fun setPlanets(planets: List<Planet>)
    suspend fun addPlanet(planet: Planet)
    suspend fun deletePlanet(planetId: String)
}
//RemoteDataSource.kt 
interface RemoteDataSource {
    
    
    suspend fun getPlanets(): List<Planet>
    suspend fun addPlanet(planet: Planet): Planet
    suspend fun deletePlanet(planetId: String)
}

Step 5: State Holder, Writing PlanetsListViewModel

Recall that the UI layer consists of UI elements and a state holder layer:

At this point we still don't know what technology we're going to use to draw the UI, so we can't write UI element layers yet. But that's no problem; we can keep writing state holders, confident that they won't have to change once we've made a decision. That's one more benefit of good architecture!

Writing a canonical
UI for PlanetsListViewModel will have two pages, one for listing and deleting planets and one for adding or editing planets. PlanetsListViewModel takes care of the former. This means it needs to expose data to the UI elements of the planet list screen, and must be ready to receive events from the UI elements in order for the user to perform actions.

Specifically, our PlanetsListViewModel needs to expose:

  • Flow that describes the current state of the page (the key is to include a list of planets)
  • How to refresh the list
  • How to delete a planet
  • A way to add some sample planets to help users understand how the app functions

PlanetsListUiStateObject: the current state of the page

I find it useful to encapsulate the entire state of the page in a single data class:

//PlanetsListViewModel.kt
data class PlanetsListUiState(
    val planets: List<Planet> = emptyList(),
    val isLoading: Boolean = false,
    val isError: Boolean = false
)

Notice I've defined this class in the same file as the view model. It contains only simple objects: no Flows etc, just primitive types, arrays and simple data classes. Note that all fields have default values ​​- this will help later.

(There are some good reasons you might not even want a Planet object in the class above. Clean Architecture purists will point out that there are too many hierarchical jumps between where a Planet is defined and where it is used. The State Hoist principle tells us to only provide the exact data we need. For example, right now we only need the Planet's name and distance, so we should only have that, not the entire Planet object. Personally, I think this would unnecessarily complicate the code and make future changes more difficult, but you are free to disagree !)

So, with this class defined, we can now create a state variable inside the view model to expose it:

//PlanetsListViewModel.kt
package com.tdcolvin.planetspotters.ui.planetslist

...

@HiltViewModel
class PlanetsListViewModel @Inject constructor(
    planetsRepository: PlanetsRepository
): ViewModel() {
    
    
    private val planets = planetsRepository.getPlanetsFlow()

    val uiState = planets.map {
    
     planets ->
        when (planets) {
    
    
            is WorkResult.Error -> PlanetsListUiState(isError = true)
            is WorkResult.Loading -> PlanetsListUiState(isLoading = true)
            is WorkResult.Success -> PlanetsListUiState(planets = planets.data)
        }
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = PlanetsListUiState(isLoading = true)
    )
}

Note that the and parameters .stateIn(...)used in can safely limit the lifetime of this StateFlow.scopestarted

Add example planet

To add our 3 example planets, we repeatedly invoke the use case created for this purpose.

//PlanetsListViewModel.kt 
fun addSamplePlanets() {
    
    
    viewModelScope.launch {
    
    
        val planets = arrayOf(
            Planet(name = "Skaro", distanceLy = 0.5F, discovered = Date()),
            Planet(name = "Trenzalore", distanceLy = 5F, discovered = Date()),
            Planet(name = "Galifrey", distanceLy = 80F, discovered = Date()),
        )
        planets.forEach {
    
     addPlanetUseCase(it) }
    }
}

refresh and delete

The refresh and delete functions are very similar, just calling the corresponding repository functions.

//PlanetsListViewModel.kt
fun deletePlanet(planetId: String) {
    
    
    viewModelScope.launch {
    
    
        planetsRepository.deletePlanet(planetId)
    }
}

fun refreshPlanetsList() {
    
    
    viewModelScope.launch {
    
    
        planetsRepository.refreshPlanets()
    }
}

Step 6: WritingAddEditPlanetViewModel

AddEditPlanetViewModelUsed to manage screens for adding new planets or editing existing ones.

As we did before - and really, this is good practice for any view model - we'll define a data class for everything the UI displays and create a single source of truth for it.

//AddEditPlanetViewModel.kt
data class AddEditPlanetUiState(
    val planetName: String = "",
    val planetDistanceLy: Float = 1.0F,
    val planetDiscovered: Date = Date(),
    val isLoading: Boolean = false,
    val isPlanetSaved: Boolean = false
)

@HiltViewModel
class AddEditPlanetViewModel @Inject constructor(): ViewModel() {
    
    
    private val _uiState = MutableStateFlow(AddEditPlanetUiState())
    val uiState: StateFlow<AddEditPlanetUiState> = _uiState.asStateFlow()
}

If we are editing a planet (rather than adding a new one), we want the initial state of the view to reflect the current state of the planet.

As a good practice, this screen will only pass the ID of the planet we want to edit. (We don't pass the whole planet object - it could get too large and complicated). Android's lifecycle component provides SavedStateHandle, from which we can get the planet ID and load the planet object.

//AddEditPlanetViewModel.kt 
@HiltViewModel
class AddEditPlanetViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val planetsRepository: PlanetsRepository
): ViewModel() {
    
    
    private val planetId: String? = savedStateHandle[PlanetsDestinationsArgs.PLANET_ID_ARG]

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

    init {
    
    
        if (planetId != null) {
    
    
            loadPlanet(planetId)
        }
    }

    private fun loadPlanet(planetId: String) {
    
    
        _uiState.update {
    
     it.copy(isLoading = true) }
        viewModelScope.launch {
    
    
            val result = planetsRepository.getPlanetFlow(planetId).first()
            if (result !is WorkResult.Success || result.data == null) {
    
    
                _uiState.update {
    
     it.copy(isLoading = false) }
            }
            else {
    
    
                val planet = result.data
                _uiState.update {
    
    
                    it.copy(
                        isLoading = false,
                        planetName = planet.name,
                        planetDistanceLy = planet.distanceLy,
                        planetDiscovered = planet.discovered
                    )
                }
            }
        }
    }
}

Notice how we update the UI state using the following pattern:

_uiState.update {
    
     it.copy( ... ) }

In one simple line of code, it creates a new one AddEditPlanetUiStatewhose value is copied from the previous state and uiStatesends it through Flow.

Here are the functions we use to update various properties of the planet with this technique:

//AddEditPlanetViewModel.kt
fun setPlanetName(name: String) {
    
    
    _uiState.update {
    
     it.copy(planetName = name) }
}

fun setPlanetDistanceLy(distanceLy: Float) {
    
    
    _uiState.update {
    
     it.copy(planetDistanceLy = distanceLy) }
}

Finally, we save the planet object using AddPlanetUseCase:

//AddEditPlanetViewModel.kt 
class AddEditPlanetViewModel @Inject constructor(
    private val addPlanetUseCase: AddPlanetUseCase,
    ...
): ViewModel() {
    
    

    ...

    fun savePlanet() {
    
    
        viewModelScope.launch {
    
    
            addPlanetUseCase(
                Planet(
                    planetId = planetId,
                    name = _uiState.value.planetName,
                    distanceLy = uiState.value.planetDistanceLy,
                    discovered = uiState.value.planetDiscovered
                )
            )
            _uiState.update {
    
     it.copy(isPlanetSaved = true) }
        }
    }
    
    ...
    
}

Step 7: Writing Data Sources and UI Elements

Now that we have the entire architecture set up, we can write code at the lowest level, the UI elements and data sources. For UI elements, we have the option to use Jetpack Compose to support phones and tablets. For local data sources, we can write a cache that uses the Room database, and for remote data sources, we can simulate accessing remote APIs.

These layers should be kept as thin as possible. For example, the code for UI elements should not contain any calculations or logic, but simply display the state provided by the view model on the screen. Logic should be placed in the view model.

For data sources, only a minimal amount of code needs to be written to implement LocalDataSourceand RemoteDataSourcefunction in the interface.

Specific third-party technologies such as Compose and Room are beyond the scope of this tutorial, but you can see example implementations of these layers in the code repository.

leave the low-level part for last

Note that we were able to save the lowest-level parts of these apps for last. This is very beneficial as it allows sufficient time for stakeholders to make decisions about which third-party technologies to use and how the application should be presented. Even after we've written this code, we can change these decisions without affecting the rest of the app.

Github address

The full code repository is at:

https://github.com/tdcolvin/PlanetSpotters。

Guess you like

Origin blog.csdn.net/u011897062/article/details/131913442