Swift Apprentice (New Edition)

Go from novice to expert in Swift with hands-on activities in Xcode playgrounds with our freshly-updated book.

Home Android & Kotlin Tutorials

Deep Dive Into Kotlin Data Classes for Android

In this Kotlin data classes tutorial, you’ll learn when and how to use data classes, how they vary from regular classes and what their limitations are.

5/5 6 Ratings

Version

  • Kotlin 1.4, Android 10.0, Android Studio 4.1

Mobile applications work with a lot of data. Whether from a database or a network API, data is at the heart of modern Android applications. Modeling data in code has traditionally been a complex affair on Android with Java. The language provides few tools to correctly construct, copy and compare data model classes. Luckily, Kotlin data classes on Android make this process easy, concise and fun.

In this tutorial, you’ll build Paddock Builder, an app to create your own Formula 1 team for the 2021 season. You’ll learn about:

  • Creating and using Kotlin data classes
  • The advantages of data classes over regular classes
  • Using data classes with various Android components
  • Limitations of data classes in object-oriented programming
Note: This tutorial assumes familiarity with the basics of Kotlin for Android development. If you’d like to revisit the basics, consider reading Kotlin for Android: An Introduction first.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial. Then, open the starter project in Android Studio to discover Paddock Builder.

Build and run the app. You’ll see the following screen:

First screen of "Paddock Builder"

Tapping the Build Your Team button allows you to select drivers for your team. But the driver selection doesn’t work, and the data models in the application use regular classes. So, you’ll implement the missing driver and team building functionality. Then eventually migrate the application to use data classes.

Before you do that, you need to learn more about data classes in Kotlin.

Data Classes

In object-oriented programming (OOP), a class contains both properties and functions. However, classes that serve only as data models focus on properties. In such classes, the compiler can derive some functionality from its member properties. Kotlin facilitates this use case with data classes.

Data classes specialize in holding data. The Kotlin compiler automatically generates the following functionality for them:

  • A correct, complete, and readable toString() method
  • Value equality-based equals() and hashCode() methods
  • Utility copy() and componentN() methods

To appreciate the amount of functionality a data class provides automatically, compare the following equivalent code snippets.

First, Java:

// Java
public final class User {
  @NotNull
  private final String name;
  @Nullable
  private final String designation;

  @NotNull
  public final String getName() {
    return this.name;
  }

  @Nullable
  public final String getDesignation() {
    return this.designation;
  }

  public User(@NotNull String name, @Nullable String designation) {
      this.name = name;
      this.designation = designation;
   }

  @NotNull
  public final String component1() {
    return this.name;
  }

  @Nullable
  public final String component2() {
    return this.designation;
  }

  @NotNull
  public final User copy(@NotNull String name, @Nullable String designation) {
    return new User(name, designation);
  }

  @NotNull
  public String toString() {
    return "User(name=" + this.name + ", designation=" + this.designation + ")";
  }

  public int hashCode() {
    String var10000 = this.name;
    int var1 = (var10000 != null ? var10000.hashCode() : 0) * 31;
    String var10001 = this.designation;
    return var1 + (var10001 != null ? var10001.hashCode() : 0);
  }

   public boolean equals(@Nullable Object var1) {
    if (this != var1) {
      if (var1 instanceof User) {
        User var2 = (User)var1;
        if (Intrinsics.areEqual(this.name, var2.name) && Intrinsics.areEqual(this.designation, var2.designation)) {
          return true;
        }
      }
      return false;
    } else {
      return true;
    }
  }
}

Now, Kotlin:

// Kotlin
data class User(
  val name: String,
  val designation: String?
)

As you can see, it’s incredible how much code data classes can save you from writing. The less code you write, the less code you need to maintain, and the faster you go. If you’re a fan of Formula 1, you will love going fast!

Declaring Data Classes

You declare data classes similar to how you declare regular classes, except:

  1. The keyword data must precede the keyword class.
  2. The primary constructor must not be empty, and it should contain only val or var properties.

Open Models.kt in repository inside the app module, where you’ll find two classes: Driver and Team. Refactor this code to use data classes by adding the data keyword, as shown below:

data class Driver(
    val id: String,
    val number: Int,
    val firstName: String,
    val lastName: String,
    val nationality: String,
    val currentTeamId: String,
)

data class Constructor(
    val id: String,
    val name: String,
    val drivers: List<Driver>
)

Build and run the app. The project should compile successfully and you should see no changes.

Constructing Data Classes

Data classes can have two types of constructors: primary and secondary.

The primary constructor on a data class can only declare properties. You can optionally create a secondary constructor, but it must delegate to the primary using the this keyword.

Here’s an example:

// Primary Constructor
data class GrandPrix(
  val name: String,
  val location: String,
  val year: Int,
  val numTeams: Int,
) {

  // Secondary Constructor
  constructor(
    name: String,
    location: String,
    year: Int,
  ): this(name, location, year, 10)
}

Open Grid.kt in repository. This file contains the details of all the teams and drivers. Note that refactoring Driver and Constructor didn’t break any code here. This is because data classes are constructed like regular classes: by invoking their constructors.

Using Data Classes

The process of selecting a driver doesn’t work well in the current app: There’s no visual feedback to show the selection status of any list item.

To fix this, open DriversList.kt in java ▸ build ▸ driver inside app module. This file contains the RecyclerView adapter responsible for the driver’s list in BuildDriversFragment. Currently, this adapter doesn’t know whether the user has selected a driver or not. As such, it cannot visually differentiate between selected and unselected drivers.

Create a new data class, DriverWithSelection, in the same file with the following content:

data class DriverWithSelection(
  val driver: Driver,
  val isSelected: Boolean
)

You’ll use this data class to differentiate between selected and unselected drivers in the list.

Now, refactor DriverViewHolder to the following:

@SuppressLint("SetTextI18n")
fun bind(driver: Driver, team: Constructor, isSelected: Boolean) { // 1
  binding.apply {
    driverName.text = "${driver.firstName} ${driver.lastName}"
    driverTeamName.text = team.name
    driverNumber.text = driver.number.toString()
    driverContainer.setBackgroundColor(getBackgroundColor(isSelected))  // 2
    driverContainer.setOnClickListener {
      onDriverClicked(driver)
    }
  }
  
  // getBackgroundColor(isSelected) method definition
}

Here, you have:

  1. Changed the bind method to accept a third parameter indicating the selection status of a driver.
  2. Used this property to change the background color.

Now, try to build and run the app. The compilation should fail, as the onBindViewHolder method of DriversListAdapter needs refactoring.

Head to the next section to learn how to fix this.

Destructuring Declarations

In DriversList.kt in build.driver, refactor DriversListAdapter to use the new DriverWithSelection:

class DriversListAdapter(
  private val onDriverClicked: (Driver) -> Unit
) : ListAdapter<DriverWithSelection, DriverViewHolder>(DriverDiffer()) { // 1
  /* ... */

  override fun onBindViewHolder(holder: DriverViewHolder, position: Int) {
    val (driver, isSelected) = getItem(position) // 2
    val team = ConstructorsRepository.forId(driver.currentTeamId)
    holder.bind(driver, team, isSelected) // 3
  }
}

Here’s what that code does:

  1. You changed the generic type parameter passed to the parent ListAdapter class from Driver to DriverWithSelection
  2. Retrieved the driver and isSelected values from the list.
  3. You passed the correct values to the bind method of the ViewHolder.

Notice the syntax used to declare the values driver and isSelected. This is known as a destructuring declaration, or simply, destructuring.

Destructuring allows you to succinctly extract values stored inside a data class. It uses the auto-generated componentN() methods to map declared values to the class’s properties. component1() returns the first property, component2() returns the second property, and so on.

Thus, the above code snippet is functionally equivalent to the following:

val item = getItem(position)
val driver = item.component1()
val isSelected = item.component2()

Be careful with the use of destructuring declarations. Generated componentN() methods can unexpectedly break your code if you change the order of properties in a data class without updating the order of values in the destructured declaration.

For example, consider the following snippet:

data class Driver(val name: String, val team: String)

fun main() {
  val seb = Drivers("Sebastian Vettel", "Aston Martin")
  val (sebName, sebTeam) = driver // "Sebastian Vettel", "Aston Martin"
}

If you change the definition of Driver some time later to include a driver number, it’d cause sebName and sebTeam to have incorrect values:

data class Driver(val number: Int, val name: String, val team: String)

fun main() {
  val seb = Drivers(5, "Sebastian Vettel", "Aston Martin")
  val (sebName, sebTeam) = driver // 5, "Sebastian Vettel"
}

With that warning out of the way, build and run the app. Unfortunately, the compilation should fail again. This is because DriversListAdapter uses the DriverDiffer class for DiffUtil support. You need to update the class to use DriverWithSelection. The implementation of DiffUtil is closely tied to the equals() method on a class.

Next, you’ll learn how data classes auto-generate this method to follow value-based equality.

Value-Based Equality

In DriversList.kt in java ▸ build ▸ driver inside app module, refactor DriverDiffer to use the new DriverWithSelection type.

So, replace the existing class with the following code:

class DriverDiffer : DiffUtil.ItemCallback<DriverWithSelection>() {
  override fun areItemsTheSame(
      oldItem: DriverWithSelection,
      newItem: DriverWithSelection,
  ): Boolean {
    return oldItem.driver.id == newItem.driver.id
  }

  override fun areContentsTheSame(
      oldItem: DriverWithSelection,
      newItem: DriverWithSelection,
  ): Boolean {
    return oldItem == newItem
  }
}

Notice the use of == in areContentsTheSame. This method checks if its two parameters contain the same contents. The auto-generated equals() method on a data class makes this check easy. It compares two instances using value equality rather than referential equality.

If two variables have referential equality, they contain the same contents and refer to the same object instance. If two variables have value equality, they contain the same contents but might point to different object instances.

In most situations, you need value equality rather than referential equality. In the rare event where you do need referential equality, use the === operator.

For instance, check out these examples to understand it better:

/* ----- Referential-equality ----- */
object MercedesW10

// Both values point to the same object instance
val mercedes2019Car = MercedesW10
val racingPoint2020Car = MercedesW10


/* ----- Value-equality -----*/
data class MercedesW11(val goesFast: Boolean)

// Both variables have the same contents, but point to different instances in memory
val mercedes2020Car = MercedesW11(goesFast = true)
val racingPoint2021Car = MercedesW11(goesFast = true)

The generated equals() calls equals() on each property of the data class. Therefore, it works correctly even for complex property types such as lists. However, arrays are an exception, as Array.equals() checks for referential equality only.

Fixing BuildDriversViewModel

You need to make a few more changes to get the app working again.

Navigate to BuildDriversViewModel.kt in java ▸ build ▸ driver inside app module.

First, change _driversWithSelection and driversWithSelection to use DriverWithSelection:

class BuildDriversViewModel : ViewModel() {
  // Use the new DriverWithSelection class
  private val _driversWithSelection = MutableStateFlow<List<DriverWithSelection>>(emptyList())
  
  /* ... */

  val driversWithSelection: Flow<List<DriverWithSelection>>
    get() = _driversWithSelection

  /* ... */
}

Second, initialize _driversWithSelection with the list of all drivers and their selection status:

class BuildDriversViewModel : ViewModel() {
  /* ...  */

  init {
      // Initialize with the list of all drivers and their selection status set to false
    _driversWithSelection.value = DriversRepository.all().map { driver ->
      DriverWithSelection(driver, false)
    }
  }

  /* ...  */
}

Finally, update toggleDriver to update _driversWithSelection every time a driver is selected/unselected:

class BuildDriversViewModel : ViewModel() {
  /* ... */

  fun toggleDriver(driver: Driver) {
    if (driver in _selectedDrivers) {
      _selectedDrivers.remove(driver)
    } else {
      _selectedDrivers.add(driver)
    }
    updateSelectionSet()
  }

  private fun updateSelectionSet() {
    _driversWithSelection.value = DriversRepository.all().map { driver ->
      DriverWithSelection(driver, driver in _selectedDrivers)
    }
  }
}

Now, every time you select/unselect a driver in the list, toggleDriver will trigger a new emission in the driversWithSelection flow. This will update the data in the list.

Note: It won’t disrupt the user’s scrolling because DriversListAdapter uses DiffUtil. Rather, it efficiently calculates and applies differences between the old and the new lists.

Build and run. You should now have a working driver selection list!

An image showing the driver selection screen with two selected drivers

Data Classes in Hash-Based Data Structures

Navigate to BuildDriversViewModel in java ▸ build ▸ driver inside app module. Notice _selectedDrivers, which is a Set that keeps track of selected drivers. It’s constructed using the factory method mutableSetOf(), which returns a LinkedHashSet.

A HashSet is a hash-based data structure that uses an object’s hashCode() to establish its identity. The default implementation doesn’t follow value equality. Therefore, it’s possible to have multiple objects with the same value in a HashSet, since they’d have different hashes. So, it’s important to manually override this method with a correct implementation on any class used with hash-based data structures.

For instance, have a look at the following example using a regular class:

class GrandPrix(val location: String)

fun main() {
  val grandPrixes = setOf<GrandPrix>(
    GrandPrix("Silverstone"),
    GrandPrix("Silverstone"),
  )
  
  println("Without data class: $grandPrixes")
  // Prints "Without data class: [GrandPrix@279f2327, GrandPrix@2ff4acd0]"
}

Now, have a look at the following code, which is using a data class:

data class GrandPrix(val location: String)

fun main() {
  val grandPrixes = setOf<GrandPrix>(
    GrandPrix("Silverstone"),
    GrandPrix("Silverstone"),
  )
  
  println("With data class: $grandPrixes")
  // Prints "With data class: [GrandPrix(location=Silverstone)]"
}

The auto-generated hashCode() method on a data class follows value equality. So, it prevents the presence of multiple instances of the same value in a hash-based data structure.

Copying Data Classes

Now that you can successfully recruit drivers, it’s time to name your team!

In the app, work your way through the driver selection screen and arrive at the team details screen. You should be able to name your team and see the drivers you selected.

You’ll improve the underlying code with data classes.

Navigate to BuildTeamViewModel.kt in java ▸ build ▸ team inside app module. You’ll find TeamDetails to hold the name of a team. Refactor it to be a data class instead:

data class TeamDetails(val teamName: String = "") 

Then, modify updateTeamName to use the following code:

fun updateTeamName(newName: String) {
  val teamDetails = _teamDetails.value
  _teamDetails.value = teamDetails.copy(teamName = newName)
}

Note the use of copy() on teamDetails. This is an auto-generated method to create copies of an existing data class instance with modified values. It accepts optional arguments for each property on the class and returns a new instance, modifying only the values passed to it by performing a shallow copy.

Here, you used it to create a new instance of TeamDetails using the previous class and modified teamName. Certainly, it’s incredibly useful for copying immutable data classes with many properties.

With this change in place, build and run the app. You should be able to name your team! Go ahead and proceed to the next step.

A screenshot of the "build-screen" team from Paddock Builder

If everything went well, you should be able to see your new team for the 2021 Formula 1 season!

Image showing completed version of "Paddock Builder"

Tests

Next, you’ll run the tests in the app to confirm everything is working correctly. These tests ensure that the connection between the Repository and the ViewModel work as intended.

So, to run them, select the Unit Tests run configuration in Android Studio:

And then ensure that all tests passed:

Extras

While the tutorial for the app ends here, read on to learn a few more interesting tidbits about data classes. After all, this is a deep dive, and the topic of data classes is very deep.

Easy toString()

The default implementation of the toString() method of every class returns the class’s name, followed by a hexadecimal representation of its object’s location in memory. This isn’t very useful, and overriding it with a manual implementation requires you to update it every time a property is added/removed from the class.

For example, check out the following code:

class GrandPrix(val location: String)

fun main() {
  val britishGp = GrandPrix("Silverstone")
  println(britishGp)
  // Prints "GrandPrix@5451c3a8"
}

Now, check out the same example using a data class:

data class GrandPrix(val location: String)

fun main() {
  val britishGp = GrandPrix("Silverstone")
  println(britishGp)
  // Prints "GrandPrix(location=Silverstone)"
}

The auto-generated toString() method returns a correct, formatted and standard string representation of an object. This is useful, for example, for logging and debugging, as it helps you view the structure and values of a class in the log itself.

Note: Be careful with logging data classes that contain sensitive data, such as passwords or credit card numbers. You should never log these. Or, at least masquerade the data by overriding toString().

Extension Functions

If you want to add functionality to a data class, it can be tempting to add some member methods to it. However, you may be polluting its internals with utility methods. In such cases, consider using extension functions instead to add functionality.

For instance:

// 1
fun Driver.shortRepresentation(): String {
  return "$number ${lastName.substring(0, 3).toUpperCase()}"
}

fun main() {
  val seb = Drivers.SebastianVettel
  // 2
  println(seb.shortRepresentation())
  // Prints "5 VET"
}

Here in the code block:

  1. shortRepresentation is an extension function
  2. The same extension function is available on the instance of Driver class

Data Class Limitations

Data classes have a few limitations when compared to regular classes:

  • They have little utility other than holding data. They can’t be open, abstract, sealed or inner classes.
  • The compiler forbids manually implementing copy() and componentN() methods.
  • Any parent interface or class of a data class must not have a copy() method.
  • The copy() method returns a shallow copy rather than a deep copy.

Another cost associated with using data classes is the increased method count in the compiled code. While not an issue on modern Android versions, it’s something to consider when developing for older releases.

Where to Go From Here?

You can download the final version of this project using the Download Materials button at the top or bottom of this tutorial.

Congratulations! You learned a lot in this tutorial and now know the ins and outs of Kotlin data classes. If you’re wondering what to learn next, you can read the following resources:

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

Average Rating

5/5

Add a rating for this content

6 ratings

More like this

Contributors

Comments