A cleaner architecture for Android apps

In these lecture notes I am going to present an updated version of the Directory app. In this version I am going to introduce a new architectural approach based on using a view model class that will clean up the architecture of the application.

We have already met ViewModel classes in the Android View portion of the course. ViewModel's can also be used in Compose applications.

Setting up the view model for our app

Below is the code for the view model class we will be using for our new version on the directory app.

import android.app.Application
import android.content.Context.MODE_PRIVATE
import androidx.lifecycle.AndroidViewModel
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.IOException

class DirectoryModel(application: Application): AndroidViewModel(application) {
    lateinit var people : List<Person>
    private val context = application

    init {
        try {
            val json = context.openFileInput("people.json").bufferedReader().use { it.readText() }
            val sType = object : TypeToken<List<Person>>() { }.type
            people = Gson().fromJson<List<Person>>(json, sType)
        } catch (e: IOException) {
            people = listOf(Person(name = "Acacia Ackles",office ="Steitz 131"),
                Person(name = "Joe Gregg",office = "Briggs 413"),
                Person(name = "Kurt Krebsbach",office ="Briggs 411"))
        }
    }

    private fun saveChanges() {
        val gson = Gson()
        val json : String = gson.toJson(people)
        try {
            context.openFileOutput("people.json", MODE_PRIVATE).use { fos ->
                fos.write(json.toByteArray())
            }
        } catch( e : IOException) {
            e.printStackTrace()
        }
    }

    fun createPerson(name: String,office: String) {
        val newPerson = Person(name = name,office = office)
        people = people + newPerson
        saveChanges()
    }

    fun removePerson(person : Person) {
        people = people.filter { it != person }
        saveChanges()
    }
}

A view model class is a class that extends either the ViewModel class, or as in this case, the AndroidViewModel class. In this example I had to set up a class that extends AndroidViewModel, since my code for reading and saving the list of people needs to have access to the application object for our app to be able to call methods such as openFileInput() and openFileOutput(). View model classes that don't need to work with files will typically inherit from ViewModel instead of AndroidViewModel.

A view model class acts as the container for the app's data. In this case that means storing a list of Person objects in the class. In addition to that data we also provide a set of convenience methods for adding and removing Person objects from the data list.

One important detail to note here is that I have switched to using a nonmutable list to store the list of people. Since the list is nonmutable we will have to rebuild the list any time we want to add or remove items. Take a look at the code in the createPerson() and removePerson() methods to see how I did that.

Working with the view model

The next thing we need to do is to rewrite our activity to use the view model class.

The first step in doing this is to add code to the activity to create the view model, and then arrange to pass the view model to any components that may need it.

The process starts in the activity class:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val vm = ViewModelProvider(
            this,
            ViewModelProvider.AndroidViewModelFactory.getInstance(application)
        ).get(DirectoryModel::class.java)
        setContent {
            DirectoryVMTheme {
                DirectoryApp(vm,modifier = Modifier.fillMaxSize())
            }
        }
    }
}

The view model objects is stored in the vm variable, and we pass it along to the DirectoryApp component, which is the root component for our application.

Because we are using a ViewModel that needs access to the application object we have to follow a more complex procedure for setting up the the ViewModel that makes use of a ViewModelProvider().

The first big benefit of using the view model is that it greatly simplifies the structure of that first component:

@Composable
fun DirectoryApp(vm : DirectoryModel,modifier: Modifier) {
    val navController = rememberNavController()

    NavHost(navController,startDestination="list") {
        composable(route="list") {
            ListScreen(vm = vm,
                toCreate = { navController.navigate("create") },
                modifier = modifier)
        }
        composable(route="create") {
            CreateScreen(vm = vm,toList = { navController.navigate("list")}, modifier = modifier)
        }
    }
}

All we need to do here is to pass the view model along to the components for the two app screens, along with a couple of navigation functions the screens can use to switch to a new screen. If you compare this with the code for the DirectoryApp in the original version of the app you will see that this version is a lot cleaner.

Here now is the code for the ListScreen component, which implements the first screen in the app:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListScreen(vm : DirectoryModel, toCreate : ()->Unit,modifier: Modifier) {
    val people = remember { mutableStateOf(vm.people) }

    fun doRemove(person : Person) {
        vm.removePerson(person)
        people.value = vm.people
    }

    Scaffold(topBar = {
        TopAppBar(
            title = {
                Text(
                    "Directory",
                )
            },
            actions = {
                IconButton(onClick = { toCreate() }) {
                    Icon(
                        imageVector = Icons.Default.Add,
                        contentDescription = "Add Person"
                    )
                }
            },
        )
    },
        modifier = Modifier.fillMaxSize())
    { innerPadding ->
        Column(verticalArrangement = Arrangement.Top, modifier = Modifier.fillMaxSize().padding(innerPadding)) {
            people.value.forEach { person ->
                key(person.id) {
                    PersonItem(person = person, onRemove = { doRemove(it) })
                }
            }
        }
    }
}

An important thing to note here is the people state variable. This gets initialized from the view model's list of people, and will get updated when we need to remove a person. I have also provided a convenience function to remove a person from the list: that function will get passed to the PersonItem component so it can implement swipe-to-delete functionality correctly.

Here is the code for the app's second screen. This also gets simplified by our use of a view model.

@Composable
fun CreateScreen(vm : DirectoryModel, toList : () -> Unit, modifier : Modifier) {
    val focusManager = LocalFocusManager.current
    val name = remember { mutableStateOf("") }
    val office = remember { mutableStateOf("") }

    fun makeNewPerson() {
        vm.createPerson(name.value,office.value)
        toList()
    }

    Column(verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxSize().padding(10.dp)) {
        Row(horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Bottom) {
            Text("Name:")
            Spacer(modifier=Modifier.width(10.dp))
            TextField(value = name.value,
                onValueChange = {newValue : String -> name.value = newValue},
                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
                keyboardActions = KeyboardActions(
                    onDone = { focusManager.clearFocus() })
            )
        }
        Spacer(modifier=Modifier.height(10.dp))
        Row(horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.Bottom) {
            Text("Office:")
            Spacer(modifier=Modifier.width(10.dp))
            TextField(value = office.value,
                onValueChange = {newValue : String -> office.value = newValue},
                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
                keyboardActions = KeyboardActions(
                    onDone = { focusManager.clearFocus() })
            )
        }
        Spacer(modifier=Modifier.height(20.dp))
        Button(onClick = { makeNewPerson() }) {
            Text("Save")
        }
    }
}

To make a new person all we have to do is to call the view model's createPerson() method and then call the toList() method to navigate back to the first screen. The createPerson() method will take care of updating the list of people, so that when we get back to the first screen it will display the updated list of people that it gets from the view model.