16.
Saving Bookmarks with Room
Written by Kevin D Moore
Now that users can tap on places to get an info window pop-up, it’s time to give them a way to bookmark and edit a place.
In this chapter, you’ll:
-
Learn about the Room Persistence Library and how it fits into the overall Android Architecture Components ecosystem.
-
Create a Room database to manage bookmarks.
-
Store bookmarks when the user taps on a map info window.
-
Learn about LiveData and use it to update the View automatically.
Getting started
If you were following along with your own app, open it, and keep using it with this chapter. If not, don’t worry! Locate the projects folder for this chapter, and open the PlaceBook app in the starter folder. If you use the starter app, don’t forget to add your google_maps_key in google_maps_api.xml. Read Chapter 13 for more details about the Google Maps key.
The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.
In ListMaker, you used Shared Preferences to store data permanently. While Shared Preferences is a great way to manage simple key-value pairs, it’s not designed to store large amounts of structured data.
For PlaceBook, you’ll use the Room Persistence Library to store the bookmarks in a structured database. Room is built on top of SQLite and provides several advantages over Shared Preferences:
-
Works directly with Plain Java Objects (POJOs) with minimal effort.
-
Provides advanced search and sorting through SQL queries.
-
Manages relationships between different data types.
-
Efficiently stores large amounts of data.
Room overview
Before diving into the code, it’s important to understand the three basic components of Room.
-
Database: This is the main interface to the underlying SQLite database. This component maintains one or more Data Access Objects (DAOs) and is annotated with the list of all Entities used by the database. A database class inherits from
RoomDatabaseand uses the@Databaseannotation. -
Entity: This represents a single data type stored in the database. Room creates a table in the database for each entity, and the rows of the table represent individual entity items.
Entities are defined as POJO classes using the
@Entityannotation. All properties on the entity class are automatically defined as fields in the database unless you use the@Ignoreannotation. At least one entity property should be designated as the primary key using the@PrimaryKeyannotation. -
DAO: Data Access Objects are the heroes of Room. This is where you define the interface for accessing the database. DAOs should be the only part of your app that talks directly to the database. The database class must contain at least one abstract method that returns a DAO annotated interface.
The following diagram illustrates how these three components fit into PlaceBook.
You’ll learn more about how these three components work together as you proceed through this chapter.
Room and Android Architecture Components
Room is part of a larger set of libraries known as the Android Architecture Components. The other components are:
- Lifecycle management: Provides several classes to help build lifecycle-aware objects.
- LiveData: Holds data that can be observed for changes and respects lifecycles.
- ViewModel: Manages View-related data without being tied to configuration changes. This is the bridge between UI Views and the rest of the app.
Don’t worry about the details of these components right now; they’ll be covered in more detail as you build out the app.
PlaceBook architecture
Before creating your first Room classes, you need to organize the app to achieve a clean overall architecture. You can separate the app into distinct areas of responsibility along these lines:
- Data access and persistence (Room).
- Data model (Model).
- Data abstraction (Repository).
- Business/Domain logic (ViewModel).
- User interface (Activity/Fragment).
One key goal is to ensure that communication only flows in one direction between these layers. This will result in a loosely coupled architecture that’s easy to modify without causing side effects.
The overall architecture will look like this:
The arrows represent lines of communication and visibility. Notice that the UI layer is completely independent of all other layers except for the ViewModel. The ViewModel layer knows nothing about the UI layer.
As the rest of the app is built out, you’ll be uncompromising about sticking with the communication flow shown in the above diagram. It will sometimes take a little more work to adhere strictly to this pattern, but the payoff for larger apps is worth the effort. Even for a small app such as PlaceBook, you can immediately recognize some benefits:
-
The way you store data in Room can be completely replaced with minimal impact. The only layers affected are the Persistence layer itself and its immediate parent, the Data Access layer.
-
The UI layer can be fully replaced without any other layer being any the wiser.
-
You can easily test all of the layers without any active UI running.
Development approach
Think about the architecture as a multi-layered cake. Have you ever seen somebody eat a cake one layer at a time? That would be a little odd! Likewise, you’re not going to build out the app one layer at a time. You’re going to take one slice at a time. Each slice may cut through all of the layers as you slowly build out the final product.
In the Project navigator, click java/com.raywenderlich.placebook and select File ▸ New ▸ Package to create the following packages. This will help organize the project to match the architecture:
- db: Data access and persistence. You’ll keep the Room Database and DAO objects here.
- model: Model objects. This includes all Room Entities as POJOs.
- repository: Data abstraction. This provides a layer of abstraction for all data access.
- ui: User interface. All Views and View control logic belong here.
- viewmodel: Business/Domain logic. Contains ViewModel classes that drive the user interface and app logic.
In the Project navigator, drag the MapsActivity class from the root package to the ui package.
Accept the default settings from the dialog and click Refactor.
The project tree-view should look like this:
Adding the architecture components
The Architecture Components are provided as separate libraries from Google’s Maven repository. The gradle file is already set up to use this repository, but you’ll need to import the individual libraries.
First, define gradle extension properties for the library versions.
Open the project build.gradle (Project: PlaceBook) and add the following lines to the ext section:
lifecycle_version = '2.2.0'
room_version = '2.2.6'
It’s time to bring in the individual components.
Open the app build.gradle (Module: app) and add the following line at the top of the file before the android section.
apply plugin: 'kotlin-kapt'
This is required for the kapt line in the dependencies section.
Add the following lines in the dependencies section.
// 1
implementation "androidx.activity:activity-ktx:1.1.0"
// 2
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// 3
implementation "androidx.room:room-runtime:$room_version"
// 4
kapt "androidx.room:room-compiler:$room_version"
Let’s go through the above dependencies:
- Adds Kotlin extensions to make it easier to bind ViewModels to Activities. You’ll learn more about this later.
- Adds the main Lifecycle classes along with extensions such as LiveData.
- Adds the Room library.
- Adds the Kotlin annotation processor for the Room library.
Room classes
Now you’re ready to add the basic classes required by Room. This includes the Entities, DAOs, and the Database. Behind the scenes, Room takes your class structure and does all of the hard work to create an SQLite database with tables and column definitions.
Room names the database PlaceBookDatabase, and the model class Bookmark. The following diagram will help visualize the process that Room uses to convert your classes into the underlying database:
Entities
PlaceBook only requires a single entity type to store Bookmarks.
Create a new Kotlin file named Bookmark.kt in the model package, and replace the contents with the following:
// 1
@Entity
// 2
data class Bookmark(
// 3
@PrimaryKey(autoGenerate = true) var id: Long? = null,
// 4
var placeId: String? = null,
var name: String = "",
var address: String = "",
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var phone: String = ""
)
Here’s what’s going on in the code above:
-
The
@Entityannotation tells Room that this is a database entity class.Note: Although not used in this example, there are several attributes you can apply to the Entity annotation.
foreignKeys(): List of ForeignKey constraints.indices(): List of indices to include on the table.primaryKeys(): List of primary key column names. Not required if using thePrimaryKeyannotation.tableName(): Table name to use in the database. Defaults to class name. -
The
Bookmarkclass’s primary constructor is defined using arguments for all properties with default values defined. By defining default values, you have the flexibility to construct a Bookmark with a partial list of properties.Note: Room looks for arguments on the constructor and class properties when defining fields for the table. In this case, you’re only using properties to define the table fields.
-
The id property is defined using the
@PrimaryKeyannotation. There must be at least one of these per Entity class. TheautoGenerateattribute tells Room to automatically generate incrementing numbers for this field.In database terminology, this would be considered a surrogate or synthetic key and provides a unique identifier for each Bookmark record.
-
The rest of the fields are defined with default values.
When creating the new class in Bookmark.kt, you might need to import these if Android Studio did not automatically add them for you:
import androidx.room.Entity
import androidx.room.PrimaryKey
DAOs
Next, you’ll define the data access object that reads and writes from the database.
Create a new Kotlin file named BookmarkDao.kt in the db package, and replace the contents with the following:
// 1
@Dao
interface BookmarkDao {
// 2
@Query("SELECT * FROM Bookmark")
fun loadAll(): LiveData<List<Bookmark>>
// 3
@Query("SELECT * FROM Bookmark WHERE id = :bookmarkId")
fun loadBookmark(bookmarkId: Long): Bookmark
@Query("SELECT * FROM Bookmark WHERE id = :bookmarkId")
fun loadLiveBookmark(bookmarkId: Long): LiveData<Bookmark>
// 4
@Insert(onConflict = IGNORE)
fun insertBookmark(bookmark: Bookmark): Long
// 5
@Update(onConflict = REPLACE)
fun updateBookmark(bookmark: Bookmark)
// 6
@Delete
fun deleteBookmark(bookmark: Bookmark)
}
Note: When you add this code, you may get an error about the references to
IGNOREandREPLACE. Place the cursor onIGNOREand press Option-Return on macOS or Ctrl-Enter on Windows, and select theandroidx.room.OnConflictStrategy.IGNOREoption — you may have to add this import manually.Place the cursor on
REPLACEand press Option-Return on macOS or Ctrl-Enter on Windows and select theandroidx.room.OnConflictStrategy.REPLACEoption.
BookmarkDao defines what would traditionally be known as CRUD database operations. The CRUD operations consist of:
- C: Create. Create new objects in the database.
- R: Read. Read objects from the database.
- U: Update. Update objects in the database.
- D: Delete. Delete objects in the database.
All access to the Bookmark data will be through this class. You can name the methods anything you like, but the real power is in the annotations. The @Query, @Insert, @Update and @Delete annotations provide Room with valuable information. Room uses this to generate the code that automatically converts your data entities to rows in the database and vice versa.
There are several new concepts introduced with this class:
-
The
@Daoannotation tells Room that this is a Data Access Object. DAO classes must be either interfaces or abstract classes. Room will create the concrete class at runtime based on the method definitions you define. -
loadAll()uses the@Queryannotation to define an SQL statement to read all of the bookmarks from the database and return them as aListofBookmarks.Note: SQL stands for Structured Query Language and is a well-known method for working with relational databases such as SQLite. You won’t need to know a lot of SQL to build out PlaceBook. If you want to learn more about SQL, and specifically the syntax used for SQLite, read https://sqlite.org/lang.html.
You’re wrapping the returned List with LiveData, which provides a couple of advantages:
LiveData objects can be observed by another object. LiveData notifies any observers when the data changes. This provides a great way to keep user interface elements up to date when items change in the database.
LiveData objects do their work in a background thread. By default, Room won’t allow you to make calls to DAO methods on the main thread. By returning LiveData objects, your method becomes an asynchronous query, and there is no restriction to calling it from the main thread.
-
This method returns a single Bookmark object. Here the
@Queryannotation is used to tell Room how to retrieve a single Bookmark. This method loads a Bookmark based on thebookmarkId. To do the actual database query, Room takes the arguments passed into your method and replaces the matching:?strings in the query, where?matches an argument name on the method. In this case,:bookmarkIdis replaced with the value of thebookmarkIdargument passed intoloadBookmark().You also define an asynchronous version named
loadLiveBookmarkthat returns aLiveDatawrapper around a singleBookmark. -
The
@Insertannotation is used to defineinsertBookmark(). This saves a singleBookmarkto the database and returns the new primary key id associated with the new bookmark. TheonConflictattribute of the@Insertannotation defines what happens if there is an existing record with the same primary key. This is not a concern for PlaceBook, as you’re using an auto-generated primary key.Note: To learn more about conflict options, please see this page: https://sqlite.org/lang_conflict.html.
-
The
@Updateannotation is used to defineupdateBookmark(). This updates a singleBookmarkin the database using the passed inbookmarkargument. TheonConflictattribute of the@Updateannotation is set toREPLACEso that the existing bookmark in the database is replaced with the new bookmark data. -
Finally, the
@Deleteannotation is used to definedeleteBookmark(). This deletes an existing bookmark based on the passed inBookmark.
Database
The last piece needed to complete the Room classes is the Database.
Create a new Kotlin file named PlaceBookDatabase.kt in the db package, and replace the contents with the following:
// 1
@Database(entities = arrayOf(Bookmark::class), version = 1)
abstract class PlaceBookDatabase : RoomDatabase() {
// 2
abstract fun bookmarkDao(): BookmarkDao
// 3
companion object {
// 4
private var instance: PlaceBookDatabase? = null
// 5
fun getInstance(context: Context): PlaceBookDatabase {
if (instance == null) {
// 6
instance = Room.databaseBuilder(
context.applicationContext,
PlaceBookDatabase::class.java,
"PlaceBook").build()
}
// 7
return instance as PlaceBookDatabase
}
}
}
Here’s how this code works:
-
The
@Databaseannotation is used to identify a Database class to Room.entitiesis a required attribute on the@Databaseannotation and defines an array of all entities used by the database. This database will store a single entity type ofBookmark. If you were storing multiple entity types, they would be separated by commas inside thearrayOfconstruct.Room requires your database class to be abstract and inherit from
RoomDatabase. -
The abstract method
bookmarkDaois defined to return a DAO interface. Note that there can be as many DAOs as you would like, but PlaceBook only needs one. You are declaring this as abstract because Room takes care of implementing the actualBookmarkDaoclass for you based on theBookmarkDaointerface you defined earlier.
This is all that’s required for the Database class. The rest of the code is added so that the Database interface object can be used as a singleton. This is recommended by Google because spinning up new Database objects can be an expensive operation.
-
Define a
companion objectonPlaceBookDatabase. -
Define the one and only
instancevariable on the companion object. -
Define
getInstance()to take in aContextand return the singlePlaceBookDatabaseinstance. -
If this is the first time
getInstanceis being called, create the singlePlaceBookDatabaseinstance.Room.databaseBuilder()is used to create a Room Database based on the abstractPlaceBookDatabaseclass. -
Return the
PlaceBookDatabaseinstance.
Note: Now that you have the database defined, you can test out a great feature of Room. It verifies the SQL in your @Query annotations at compile time.
If you have an error in the SQL syntax, such as referring to a non-existent table name, it will give you an error. It will also warn if the return type on your method doesn’t match the return type of your SQL statement.
Test this out by changing
BookmarktoBookmarksin one of the @Query strings in Bookmark.kt, and then rebuild the project. This results in a compile error that reads “Error:There is a problem with the query: [SQLITE_ERROR] SQL error or missing database (no such table: Bookmarks)”.If you’ve ever worked with Android SQLite databases before Room was available, you’ll realize what a big help this is. Room provides a safety net to prevent common typos in your SQL statements.
Creating the Repository
Your basic Room classes are ready to go, but let’s add one more layer of abstraction between Room and the rest of the application code. By doing this, you make it easy to change out how and where the app data is stored. This abstraction layer will be provided using a Repository pattern. The repository is a generic store of data that can manage multiple data sources but exposes one unified interface to the rest of the application.
Although the repository in PlaceBook will have a single data source, the BookmarkDao class, the power is that it could utilize multiple data sources or swap out a data source completely without affecting other parts of the application. The app you’ll build in Section IV makes full use of the Repository pattern.
To manage your bookmarks, you’ll create a single repository class named BookmarkRepo. This class will internally use BookmarkDao from PlaceBookDatabase to access the underlying bookmarks in the database. It will define some basic methods for saving and loading bookmarks.
Create a Kotlin file named BookmarkRepo.kt in the repository package, and replace the contents with the following:
// 1
class BookmarkRepo(context: Context) {
// 2
private val db = PlaceBookDatabase.getInstance(context)
private val bookmarkDao: BookmarkDao = db.bookmarkDao()
// 3
fun addBookmark(bookmark: Bookmark): Long? {
val newId = bookmarkDao.insertBookmark(bookmark)
bookmark.id = newId
return newId
}
// 4
fun createBookmark(): Bookmark {
return Bookmark()
}
// 5
val allBookmarks: LiveData<List<Bookmark>>
get() {
return bookmarkDao.loadAll()
}
}
Here’s the code breakdown:
-
Define the
BookmarkRepoclass with a constructor that passes in an object namedcontext. AContextobject is required to get an instance of thePlaceBookDatabaseclass. -
Two properties are defined that
BookmarkRepowill use for its data source. The first is thePlaceBookDatabasesingleton instance, and the second is theDAOobject fromPlaceBookDatabase. Note that thebookmarkDaoproperty must followdbas it depends ondbbeing created first. -
Create
addBookmark()to allow a singleBookmarkto be added to the repo. This method returns the unique id of the newly savedBookmarkor null if theBookmarkcould not be saved. This method usesinsertBookmark()onbookmarkDaoto add theBookmarkto the database. It then assigns thenewIdto the Bookmark and returns thenewIdto the caller. -
Add
createBookmark()as a helper method to return a freshly initializedBookmarkobject. In this case, you return a simpleBookmarkobject. Having your application code get all new objects from the repository gives the repository an opportunity to apply special initialization code if necessary, although none is required in this case. -
Create the
allBookmarksproperty that returns aLiveDatalist of allBookmarksin the Repository. You callloadAll()on thebookmarkDaoand return the results to the caller.
You’ll see how this class is used in detail as you build out the ViewModel.
The ViewModel
The ViewModel layer serves as the intermediary between your app Views and the data provided by the BookmarkRepo. The ViewModel drives the UI based on the repository data and updates the repository data based on user interactions.
You’ll typically have one ViewModel for each View (Activity or Fragment) in your app. The naming convention used for ViewModel classes is to simply append ViewModel to the View class prefix. Your first View model will be used to manage the MapsActivity View.
Create a Kotlin file named MapsViewModel.kt in the viewmodel package to go along with the MapsActivity. Replace the contents with the following:
// 1
class MapsViewModel(application: Application) :
AndroidViewModel(application) {
private val TAG = "MapsViewModel"
// 2
private val bookmarkRepo: BookmarkRepo = BookmarkRepo(
getApplication())
// 3
fun addBookmarkFromPlace(place: Place, image: Bitmap?) {
// 4
val bookmark = bookmarkRepo.createBookmark()
bookmark.placeId = place.id
bookmark.name = place.name.toString()
bookmark.longitude = place.latLng?.longitude ?: 0.0
bookmark.latitude = place.latLng?.latitude ?: 0.0
bookmark.phone = place.phoneNumber.toString()
bookmark.address = place.address.toString()
// 5
val newId = bookmarkRepo.addBookmark(bookmark)
Log.i(TAG, "New bookmark $newId added to the database.")
}
}
Here’s what’s happening:
-
When creating a
ViewModel, it should inherit fromViewModelorAndroidViewModel. Inheriting fromAndroidViewModelallows you to include the application context which is needed when creating theBookmarkRepo. -
Create the
BookmarkRepoobject, passing in the application context.getApplication()is provided by the baseAndroidViewModelclass. -
Declare the method
addBookmarkFromPlacethat takes in a GooglePlaceand aBitmapimage. This will be called by theMapsActivitywhen it wants to create a bookmark for a Google Place that has been identified by the user. -
Use
BookmarkRepo.createBookmark()to create an emptyBookmarkobject and then fill it in using the Place data. If thelatLngproperty isnull, you use the?:operator to set thelongitudeandlatitudevalues to0.0. -
Finally, save the
Bookmarkto the repository and print out an info message to verify that the bookmark was added.
Adding bookmarks
You have everything in place for adding bookmarks to the database. Now you just need to detect when the user taps on a place info window.
The next section of code uses some features of Java 8, and therefore requires Java 8 source compatibility. You’ll update the project to have Java 8 source code compatibility.
Open the app build.gradle (Module: app) and add the following lines to the bottom of the android section:
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
This tells Android Studio to target Java 8 when compiling the project source code.
In MapsActivity.kt, add the following property at the top of the class before onCreate().
private val mapsViewModel by viewModels<MapsViewModel>()
You’re declaring a private member to hold the MapsViewModel. This is initialized when the map is ready.
You may be wondering about the odd syntax for creating the MapsViewModel. A big benefit of using the ViewModel class is that it is aware of lifecycles. In this case, by viewModels<MapsViewModel> is a lazy delegate that creates a new mapsViewModel only the first time the Activity is created. If a configuration change happens, such as a screen rotation, by viewModels<MapsViewModel> returns the previously created MapsViewModel.
It is this viewModels delegate that requires the Java 8 options that were added in the build.gradle file above.
Next, you’ll do some cleanup of the onMapReady() function. It will continue to grow as you add new capabilities to MapsActivity, so this is a good time to refactor before it gets out of hand.
Create a new method named setupMapListeners and move the calls to map.setInfoWindowAdapter and map.setOnPoiClickListener from onMapReady() into this new method:
private fun setupMapListeners() {
map.setInfoWindowAdapter(BookmarkInfoWindowAdapter(this))
map.setOnPoiClickListener {
displayPoi(it)
}
}
Add a call to setupMapListeners() before the call to getCurrentLocation() in onMapReady().
The new version of onMapReady() should now match this:
override fun onMapReady(googleMap: GoogleMap) {
map = googleMap
setupMapListeners()
getCurrentLocation()
}
The next step is to respond to the user tapping on an info window and then call MapsViewModel.addBookmarkFromPlace() with the Place and Bitmap objects.
Houston, we have a problem!
As the code is now, when you add a marker, you’re setting the marker tag to the place image only. You don’t have access to the original Place object. What’s needed is a way to set both the full Place object and the Bitmap image as the Marker tag. You can solve this by creating a private class to hold both pieces of information.
Add the following internal class to the bottom of the MapsActivity class before the final closing }:
class PlaceInfo(val place: Place? = null,
val image: Bitmap? = null)
This defines a class with two properties to hold a Place and a Bitmap.
In displayPoiDisplayStep(), replace the line “marker?.tag = photo” with this line:
marker?.tag = PlaceInfo(place, photo)
Now, the marker tag holds the full place object and the associated bitmap photo.
In BookmarkInfoWindowAdapter.kt, in getInfoContents(), replace the line that calls setImageBitmap to this:
imageView.setImageBitmap((marker.tag as
MapsActivity.PlaceInfo).image)
You’re casting the marker.tag to a PlaceInfo object and then accessing the image property to set it as the imageView bitmap. Now, you’ll handle the action when the user taps the info window for a place. Add the following method to MapsActivity.kt:
private fun handleInfoWindowClick(marker: Marker) {
val placeInfo = (marker.tag as PlaceInfo)
if (placeInfo.place != null) {
mapsViewModel.addBookmarkFromPlace(placeInfo.place,
placeInfo.image)
}
marker.remove()
}
This method handles taps on a place info window. You get the placeInfo from the marker.tag, verify that the data is not null, and then call mapsViewModel.addBookmarkFromPlace() to add the place to the repository. Finally, you remove the marker from the map.
Add the following line to the end of setupMapListeners():
map.setOnInfoWindowClickListener {
handleInfoWindowClick(it)
}
Here, you set up a listener to call handleInfoWindowClick() whenever the user taps an info window.
Now, whenever the user taps a place info window, it calls handleInfoWindowClick() which in turn calls mapsViewModel.addBookmarkFromPlace(), and adds a bookmark to the database.
Build and run the app.
Tap on a place so that it shows a marker. Tap on the marker, and then tap on the info window.
Ok, that didn’t turn out exactly as planned! It was supposed to trigger a call to addBookmarkFromPlace() and add the bookmark to the database. Check the Logcat window and see if you can identify the problem.
You should have seen the info message “New bookmark 1 added to the database.”, but instead you get the following exception:
java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
This exception is thrown on the call to addBookmarkFromPlace() and as the message explains, it’s because the database cannot be accessed on the main thread. There are several ways to fix this problem, and the easiest would be to configure Room to allow database access on the main thread. This would only be a stop-gap measure though. The proper solution is to make sure that addBookmarkFromPlace() runs in a background thread.
One way to attack the problem is to use Kotlin Coroutines.
Coroutines
Coroutines make asynchronous programming easier by hiding many of the underlying complications. This frees you to think about your code in a more traditional sequential fashion that is easier to comprehend. You’ll learn more about Coroutines in future chapters, but for now, you only need to know about the launch coroutine builder.
Note: If you aren’t familiar with asynchronous programming concepts, it’s just a fancy way to say that more than one thing is happening at a time. Normally, your code executes in a serial fashion on the main thread of execution.
With asynchronous programming, multiple code paths are executed simultaneously by using background threads.
To learn more about asynchronous programming with Android, please check out the following link: https://developer.android.com/guide/components/processes-and-threads.html
To learn more about coroutines take a look at our Kotlin Coroutines by Tutorials (https://bit.ly/3vfnV1n) book.
A coroutine represents a suspendable computation. Suspendable means that the computation may be suspended without stopping the main execution thread.
The launch coroutine builder is used to launch (or start) a coroutine. Coroutines are always started in the context of a CoroutineScope. The CoroutineScope defines the lifetime of the coroutine. Kotlin provides a GlobalScope context that applies to the lifetime of the whole application. When launching a coroutine, you provide a block of code known as a suspending lambda expression. The GlobalScope context automatically dispatches your lambda expression in a background thread.
Having the call to addBookmarkFromPlace() run in the background is as easy as wrapping it with the launch coroutine builder in the GlobalScope.
Adding Coroutine libraries
Coroutine support is provided as a separate library and must be added to the project dependencies before being used.
First, define a gradle extension property for the coroutine library version.
Open the project build.gradle (Project: PlaceBook) and add the following line to the ext section:
coroutines_version = '1.4.2'
Open the app build.gradle (Module: app) and add the following lines in the dependencies section.
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
Note: After making the above change to the gradle file, don’t forget to click Sync Now so that Gradle loads the new dependencies.
Creating a Coroutine
Open MapsActivity.kt and replace the call to addBookmarkFromPlace in handleInfoWindowClick() with the following:
GlobalScope.launch {
mapsViewModel.addBookmarkFromPlace(placeInfo.place,
placeInfo.image)
}
You use the launch coroutine builder to launch a coroutine in the GlobalScope. The GlobalScope context is used, so the code inside the lambda expressions runs in the background.
Build and run the app again and repeat the process of tapping an info window. Check the Logcat window; this time you’ll see the “New bookmark 1 added to the database.” message.
Observing database changes
You’ve made a huge step forward by saving bookmarks to the database, but the user has no way of identifying places that have been bookmarked. The goal is to have the UI automatically reflect the current state of the bookmark database. This is where your use of the ViewModel starts to pay off.
You’re going to add a LiveData property to the ViewModel and then observe this LiveData from the MapsActivity. You’ll display blue colored markers for all bookmarks stored in the database.
ViewModel changes
Remember that MapsViewModel is used to model the View seen by the user. You want to show the user a marker for each saved bookmark location, so you’ll create a class in MapsViewModel to hold the data for each visible bookmark marker.
Add the following internal class to MapsViewModel.kt before the final }:
data class BookmarkMarkerView(
var id: Long? = null,
var location: LatLng = LatLng(0.0, 0.0))
Note: If Android Studio can’t resolve
LatLng, addimport com.google.android.gms.maps.model.LatLngto the top of MapsViewModel.kt.
This will hold the information needed by the View to plot a marker for a single bookmark.
You can now store a list of these bookmark Views. Add the following property at the top of MapsViewModel.kt inside the class definition:
private var bookmarks: LiveData<List<BookmarkMarkerView>>? = null
Here, you’re defining a LiveData object that wraps a list of BookmarkMarkerView objects.
As mentioned earlier, LiveData is an observable data holder class provided as part of the Android Architecture Components. When another object observes the LiveData object, it will be notified when any data maintained by the LiveData is changed. You’ll see how to observe the LiveData object in the next section.
Now that you have an object to store the bookmark marker views, you need to populate them from the bookmarks stored in the database. This is done by reading the bookmarks from the bookmark repo and converting them into BookmarkMarkerView objects.
Add the following method to MapsViewModel:
private fun bookmarkToMarkerView(bookmark: Bookmark) = BookmarkMarkerView(
bookmark.id,
LatLng(bookmark.latitude, bookmark.longitude))
This is a helper method that converts a Bookmark object from the repo into a BookmarkMarkerView object. This is used by the next method.
Now, add the following method:
private fun mapBookmarksToMarkerView() {
// 1
bookmarks = Transformations.map(bookmarkRepo.allBookmarks) { repoBookmarks ->
// 2
repoBookmarks.map { bookmark ->
bookmarkToMarkerView(bookmark)
}
}
}
This method maps the LiveData<List<Bookmark>> objects provided by BookmarkRepo into LiveData<List<BookmarkMarkerView>> objects that can be used by MapsActivity. Although you could remove the mapping and return the LiveData<List<Bookmark>> directly to MapsActivity, it’s best not to expose MapsActivity to the details of the underlying Bookmark object.
-
Use the
Transformationsclass to dynamically mapBookmarkobjects intoBookmarkMarkerViewobjects as they get updated in the database.Transformationsis provided by the Lifecycle package as a convenient way to transform values in a LiveData object before they are returned to the observer.Transformations.maptakes in an argument for a LiveData object and returns the transformed LiveData object. It’s your job to define the mapping method that converts from one data type to another. This mapping method is described in Step 2 below. -
Transformations.mapprovides you with a list of Bookmarks returned from the bookmark repo. You store these in thebookmarksvariable.
Keep in mind that this is a dynamic function and will get called anytime the bookmark data changes in the database.
You take the repoBookmarks list provided by the Transformations.map function and convert them into BookmarkMarkerViews. You do this by using the map function on the repoBookmarks list. Using map on a list is an easy way to convert a list of objects from one type to another.
The class property bookmarks is assigned to the result of Transformations.map. This results in the bookmarks property sending out notifications to any observers when any data changes in the Bookmarks table.
To finish up this class, you need a method to initialize and return the bookmark marker views to the MapsActivity.
Add the following method to MapsViewModel:
fun getBookmarkMarkerViews() : LiveData<List<BookmarkMarkerView>>? {
if (bookmarks == null) {
mapBookmarksToMarkerView()
}
return bookmarks
}
This method returns the LiveData object that will be observed by MapsActivity. bookmarks are null the first time this method is called. If it’s null, then it calls mapBookmarksToMarkerView() to set up the initial mapping.
That’s all of the changes required in MapsViewModel.
MapsActivity changes
Now you’re ready to update MapsActivity to listen for changes in the View model. First, you need a method to add a bookmark marker to the map.
Open MapsActivity and add the following method:
private fun addPlaceMarker(
bookmark: MapsViewModel.BookmarkMarkerView): Marker? {
val marker = map.addMarker(MarkerOptions()
.position(bookmark.location)
.icon(BitmapDescriptorFactory.defaultMarker(
BitmapDescriptorFactory.HUE_AZURE))
.alpha(0.8f))
marker.tag = bookmark
return marker
}
This is a helper method that adds a single blue marker to the map based on a BookmarkMarkerView. This is very similar to the code that adds a marker when tapping on a place. The main difference is that it doesn’t use the default red color.
Next, you’ll need a method to display all of the bookmark markers. Add the following method:
private fun displayAllBookmarks(
bookmarks: List<MapsViewModel.BookmarkMarkerView>) {
bookmarks.forEach { addPlaceMarker(it) }
}
This method walks through a list of BookmarkMarkerView objects and calls addPlaceMarker() for each one.
Next, you’ll create a method that observes the changes to the bookmark marker views in the maps View model.
Add the createBookmarkMarkerObserver() method to MapsActivity.kt:
private fun createBookmarkMarkerObserver() {
// 1
mapsViewModel.getBookmarkMarkerViews()?.observe(
this, {
// 2
map.clear()
// 3
it?.let {
displayAllBookmarks(it)
}
})
}
Note: Make sure to use
androidx.lifecycle.Observerto resolveObserver.
This method observes changes to the BookmarkMarkerView objects from the MapsViewModel and updates the View when they change.
-
Start by using
getBookmarkMarkerViews()onMapsViewModelto retrieve aLiveDataobject. To be notified when the underlying data changes on the LiveData object, you call theobservemethod. The first argument isthis, and it represents the LifeCycle Owner. You’re telling the observer to follow the lifecycle of the current activity.The second argument is a new
Observerlambda expression to process the updated bookmarks. The lambda expression will run each time the data changes. -
Once you have the updated data, clear all existing markers on the map.
-
Call
displayAllBookmarks()passing in the list of updatedBookmarkMarkerViewobjects as represented by theitvariable.
The only item left is to call createBookmarkMarkerObserver() when setting up the model View.
Add the following line after setupMapListeners() in onMapReady():
createBookmarkMarkerObserver()
Build and run the app. If you previously added some places to the database by tapping on the info windows, you’ll see blue markers appear on the map.
Add a new bookmark for another place by tapping on it, and then tapping on the info window. You’ll notice that the map automatically updates to display the new blue marker for the saved bookmark.
This happens even though you didn’t make any direct calls to display markers when the application started!
The following illustrates how this is working:
When you first observe a LiveData, it calls your observer immediately with the current set of data. From then on, the observer is notified anytime the underlying data changes.
Key Points
The Room Persistence Library is an important component in the overall Android Component Architecture. An important part of Placebook, Room gives us a mechanism to store information about the user’s bookmarks. In this chapter you learned:
- Room is a great way to create databases and save data.
- It is easy to create a database and store bookmarks.
- LiveData can be used to listen for database changes.
- Coroutines are a great way to run database commands in the background.
Where to go from here?
There’s one problem with this new implementation: If you tap on any of the blue markers, the app will crash. Can you guess why? If not, don’t worry! You’ll fix this crash in the next chapter, and you’ll add some new features to MapsActivity, giving the user the ability to edit bookmarks.