An Android Room Database and Repository Tutorial

From Techotopia
Revision as of 17:25, 3 July 2018 by Neil (Talk | contribs)

Jump to: navigation, search

This chapter will combine the knowledge gained in the chapter entitled The Android Room Persistence Library with the initial project created in the previous chapter to provide a detailed tutorial demonstrating how to implement SQLite-based database storage using the Room persistence library. In keeping with the Android architectural guidelines, the project will make use of a view model and repository. The tutorial will make use of all of the elements covered in The Android Room Persistence Library, including entities, a Data Access Object, a Room Databases and asynchronous database queries.


Contents


About the RoomDemo Project

The user interface layout created in the previous chapter was the first step in creating a rudimentary inventory app designed to store the names and quantities of products. When completed, the app will provide the ability to add, delete and search for database entries while also displaying a scrollable list of all products currently stored in the database. This product list will update automatically as database entries are added or deleted.

Modifying the Build Configuration

Begin by launching Android Studio and opening the RoomDemo project started in the previous chapter. Before adding any new classes to the project, the first step is to add some additional libraries to the build configuration, specifically the Room persistence library and the RecyclerView library. Locate and edit the module level build.gradle file (app -> Gradle Scripts -> build.gradle (Module: app)) and modify the dependencies section as follows:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation 'com.android.support:recyclerview-v7:27.1.1'
    implementation "android.arch.persistence.room:runtime:1.1.1"
    annotationProcessor "android.arch.persistence.room:compiler:1.1.1"
.
.
}

Building the Entity

This project will begin by creating the entity which defines the schema for the database table. The entity will consist of an integer for the product id, a string column to hold the product name and another integer value to store the quantity. The product id column will serve as the primary key and will be auto-generated. Table 1-1 summarizes the structure of the entity:

Column Data Type
productId Integer / Primary Key / Auto Increment
productName String
productQuantity Integer


Add a class file for the entity by right clicking on the app -> java -> com.ebookfrenzy.roomdemo entry in the Project tool window and selecting the New -> Java Class menu option. In the Create New Class dialog, name the class Product and click on the OK button to generate the file.

When the Product.java file opens in the editor, modify it so that it reads as follows:

package com.ebookfrenzy.roomdemo;

public class Product {

    private int id;
    private String name;
    private int quantity;

    public Product(String name, int quantity) {
        this.id = id;
        this.name = name;
        this.quantity = quantity;
    }

    public int getId() {
        return this.id;
    }
    public String getName() {
        return this.name;
    }

    public int getQuantity() {
        return this.quantity;
    }

    public void setId(int id) {
        this.id = id;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setQuantity(int quantity) {
        this.quantity = quantity;
    }
}

The class now has variables for the database table columns and matching getter and setter methods. Of course this class does not become an entity until it at has been annotated. With the class file still open in the editor, add annotations and corresponding import statements:

package com.ebookfrenzy.roomdemo;

import android.arch.persistence.room.ColumnInfo;
import android.arch.persistence.room.Entity;
import android.arch.persistence.room.PrimaryKey;
import android.support.annotation.NonNull;

@Entity(tableName = "products")
public class Product {

    @PrimaryKey(autoGenerate = true)
    @NonNull
    @ColumnInfo(name = "productId")
    private int id;

    @ColumnInfo(name = "productName")
    private String name;

    private int quantity;
.
.
}

These annotations declare this as the entity for a table named products and assigns column names for both the id and name variables. The id column is also configured to be the primary key and auto-generated. Since a primary key can never be null, the @NonNull annotation is also applied. Since it will not be necessary to reference the quantity column in SQL queries, a column name has not been assigned to the quantity variable.

Creating the Data Access Object

With the product entity defined, the next step to create the DAO interface. Referring once again to the Project tool window, right-click on the app -> java -> com.ebookfrenzy.roomdemo entry and select the New -> Java Class menu option. In the Create New Class dialog, enter ProductDao into the Name field and select Interface from the Kind menu as highlighted in Figure 1-1:

Creating the Room DAO Interface Class 

Click on OK to generate the new interface and, with the ProductDao.java file loaded into the code editor, make the following changes:

package com.ebookfrenzy.roomdemo;

import android.arch.lifecycle.LiveData;
import android.arch.persistence.room.Dao;
import android.arch.persistence.room.Insert;
import android.arch.persistence.room.Query;

import java.util.List;

@Dao
public interface ProductDao {

    @Insert
    void insertProduct(Product product);
    
    @Query("SELECT * FROM products WHERE productName = :name")
    List<Product> findProduct(String name);

    @Query("DELETE FROM products WHERE productName = :name")
    void deleteProduct(String name);

    @Query("SELECT * FROM products")
    LiveData<List<Product>> getAllProducts();
}

The DAO implements methods to insert, find and delete records from the products database. The insertion method is passed a Product entity object containing the data to be stored while the methods to find and delete records are passed a string containing the name of the product on which to perform the operation. The getAllProducts() method returns a LiveData object containing all of the records within the database. This method will be used to keep the RecyclerView product list in the user interface layout synchronized with the database.

Adding the Room Database

The last task before adding the repository to the project is to implement the Room Database instance. Add a new class to the project named ProductRoomDatabase, this time with the Kind menu set to Class and the Abstract option enabled in the Modifiers section:

Creating the Room Database class


Once the file has been generated, modify it as follows using the steps outlined in the chapter entitled The Android Room Persistence Library:

package com.ebookfrenzy.roomdemo;

import android.arch.persistence.room.Database;
import android.arch.persistence.room.Room;
import android.arch.persistence.room.RoomDatabase;
import android.content.Context;

@Database(entities = {Product.class}, version = 1)
public abstract class ProductRoomDatabase extends RoomDatabase {

    public abstract ProductDao productDao();
    private static ProductRoomDatabase INSTANCE;

    static ProductRoomDatabase getDatabase(final Context context) {
        if (INSTANCE == null) {
            synchronized (ProductRoomDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = 
                         Room.databaseBuilder(context.getApplicationContext(),
                            ProductRoomDatabase.class, "product_database")
                            .build();

                }
            }
        }
        return INSTANCE;
    }
}

Adding the Repository

Add a new class named ProductRepository to the project, with the Kind menu set to Class and None enabled in the Modifiers section of the Create New Class dialog.

The repository class will be responsible for interacting with the Room database on behalf of the ViewModel and will need to provide methods that use the DAO to insert, delete and query product records. With the exception of the getAllProducts() DAO method (which returns a LiveData object) these database operations will need to be performed on separate threads from the main thread using the AsyncTask class.

In addition, the AsyncTask to perform a product search will need to return the results to the repository object. This topic was covered in the chapter entitled TBD and begins with the addition of an interface class to the project. Add a new interface class named AsyncResult and modify it so that it reads as follows:

package com.ebookfrenzy.roomdemo;

import java.util.List;

public interface AsyncResult {
    void asyncFinished(List<Product> results);
}

Next, modify the ProductRepository.java file, declaring it as implementing the AsyncResult interface and implementing the asyncFinished() method:

package com.ebookfrenzy.roomdemo;

import android.arch.lifecycle.MutableLiveData;

import java.util.List;

public class ProductRepository implements AsyncResult {

    private MutableLiveData<List<Product>> searchResults = 
                                              new MutableLiveData<>();

    @Override
    public void asyncFinished(List<Product> results){
        searchResults.setValue(results);
    }
}

In addition to the asyncFinished() method, the above code also declares a MutableLiveData variable named searchResults into which the results of a search operation are stored whenever a asynchronous search task completes (later in the tutorial, an observer within the ViewModel will monitor this live data object).

Remaining within the ProductRepository.java file, add the code for the search AsyncTask:

.
.
import android.os.AsyncTask;
.
.
       private static class queryAsyncTask extends 
					AsyncTask<String, Void, List<Product>> {

        private ProductDao asyncTaskDao;
        private ProductRepository delegate = null;

        queryAsyncTask(ProductDao dao) {
            asyncTaskDao = dao;
        }

        @Override
        protected List<Product> doInBackground(final String... params) {
            return asyncTaskDao.findProduct(params[0]);
        }

        @Override
        protected void onPostExecute(List<Product> result) {
            delegate.asyncFinished(result);
        }
    }
.
.

The AsyncTask class contains a constructor method into which must be passed a reference to the DAO object. The doInBackGround() method is passed a String containing the product name for which the search is to be performed, passes it to the findProduct() method of the DAO and returns a list of matching Product entity objects which will, in turn, be passed to the onPostExecute() method. Finally, the onPostExecute() method calls the asyncFinished() method to return the results to the repository instance where it is stored in the searchResults MutableLiveData object.

The repository will also need to include the following AsyncTask implementation for inserting products into the database:

.
.
private static class queryAsyncTask extends AsyncTask<String, Void, List<Product>> {

    private ProductDao asyncTaskDao;
    private ProductRepository delegate = null;

.
.

    @Override
    protected void onPostExecute(List<Product> result) {
        delegate.asyncFinished(result);
    }

    private static class insertAsyncTask extends AsyncTask<Product, Void, Void> {

        private ProductDao asyncTaskDao;

        insertAsyncTask(ProductDao dao) {
            asyncTaskDao = dao;
        }

        @Override
        protected Void doInBackground(final Product... params) {
            asyncTaskDao.insertProduct(params[0]);
            return null;
        }
    }
}
.
.

Once again a constructor method is passed a reference to the DAO object, though this time the doInBackground() method is passed an array of Product entity objects to be inserted into the database. Since the app allows only one new product to be added at a time, the method simply inserts the first Product in the array into the database via a call to the insertProduct() DAO method. In this case, no results need to be returned from the task.

The only remaining AsyncTask will be used when deleting products from the database and should be added beneath the insertAsyncTask declaration as follows:

.
.
    private static class insertAsyncTask extends AsyncTask<Product, Void, Void> {
.
.
    }

    private static class deleteAsyncTask extends AsyncTask<String, Void, Void> {

        private ProductDao asyncTaskDao;

        deleteAsyncTask(ProductDao dao) {
            asyncTaskDao = dao;
        }

        @Override
        protected Void doInBackground(final String... params) {
            asyncTaskDao.deleteProduct(params[0]);
            return null;
        }
    }
}

With the AsyncTask classes defined, the repository class now needs to provide some methods that can be called by the ViewModel to initiate these operations. These methods will create and call appropriate AsyncTask instances and pass through a reference to the DAO. In order to be able to do this, however, the repository needs to obtain the DAO reference via a ProductRoomDatabase instance. Add a constructor method to the ProductRepository class to perform these tasks:

.
.
import android.app.Application;
.
.
public class ProductRepository implements AsyncResult {

    private MutableLiveData<List<Product>> searchResults = 
							new MutableLiveData<>();
    private ProductDao productDao;
    
    public ProductRepository(Application application) {

        ProductRoomDatabase db;
        db = ProductRoomDatabase.getDatabase(application);
        productDao = db.productDao();
    }
.
.

With a reference to DAO stored, the methods are ready to be added to the class file:

.
.
import android.arch.lifecycle.LiveData;
.
.
   public void insertProduct(Product newproduct) {
        new queryAsyncTask.insertAsyncTask(productDao).execute(newproduct);
    }

    public void deleteProduct(String name) {
        new queryAsyncTask.deleteAsyncTask(productDao).execute(name);
    }

    public void findProduct(String name) {
        queryAsyncTask task = new queryAsyncTask(productDao);
        task.delegate = this;
        task.execute(name);
    } 
.
.

In the cases of the insertion and deletion methods, the appropriate AsyncTask instance is created and passed the necessary arguments. In the case of the findProduct() method, the delegate property of the class is set to the repository instance so that the asyncFinished() method can be called after the search completes.

One final task remains to complete the repository class. The RecyclerView in the user interface layout will need to able to keep up to date the current list of products stored in the database. The ProductDao class already includes a method named getAllProducts() which uses a SQL query to select all of the database records and return them wrapped in a LiveData object. The repository needs to call this method once on initialization and store the result within a LiveData object that be observed by the ViewModel and, in turn, by the UI controller. Once this has been set up, each time a change occurs to the database table the UI controller observer will be notified and the RecyclerView can be updated with the latest product list. Remaining within the ProductRepository.java file, add a LiveData variable and call to the DAO getAllProducts() method within the constructor:

public class ProductRepository implements AsyncResult {
.
.
    private LiveData<List<Product>> allProducts;

    public ProductRepository(Application application) {

        ProductRoomDatabase db;
        db = ProductRoomDatabase.getDatabase(application);
        productDao = db.productDao();
        allProducts = productDao.getAllProducts();
    }
.
.
}

To complete the repository, add methods that the ViewModel can call to obtain a references to the allProducts and searchResults live data objects:

public LiveData<List<Product>> getAllProducts() {
    return allProducts;
}

public MutableLiveData<List<Product>> getSearchResults() {
    return searchResults;
}

Modifying the ViewModel

The ViewModel is responsible for creating an instance of the repository and for providing methods and LiveData objects that can be utilized by the UI controller to keep the user interface synchronized with the underlying database. As implemented in ProductRepository.java, the repository constructor requires access to the application context in order to be able to get a Room Database instance. To make the application context accessible within the ViewModel so that it can be passed to the repository, the ViewModel needs to subclass AndroidViewModel instead of ViewModel. Begin, therefore, by editing the MainViewModel.java file (located into the Project tool window under app -> java -> com.ebookfrenzy.roomdemo -> ui.main) and changing the class to extend AndroidViewModel and to implement the default constructor:

package com.ebookfrenzy.roomdemo.ui.main;

import android.app.Application;
import android.arch.lifecycle.AndroidViewModel;
import android.arch.lifecycle.LiveData;
import android.arch.lifecycle.MutableLiveData;
import android.arch.lifecycle.ViewModel;

import com.ebookfrenzy.roomdemo.Product;
import com.ebookfrenzy.roomdemo.ProductRepository;

import java.util.List;

public class MainViewModel extends AndroidViewModel {

    private ProductRepository repository;
    private LiveData<List<Product>> allProducts;
    private MutableLiveData<List<Product>> searchResults;

    public MainViewModel (Application application) {
        super(application);
        repository = new ProductRepository(application);
        allProducts = repository.getAllProducts();
        searchResults = repository.getSearchResults();
    }
}

The constructor essentially creates a repository instance and the uses it to get references to the results and live data objects so that they can be observed by the UI controller. All that now remains within the ViewModel is to implement the methods that will be called from within the UI controller in response to button clicks and when setting up observers on the LiveData objects:

MutableLiveData<List<Product>> getSearchResults() {
    return searchResults;
}

LiveData<List<Product>> getAllProducts() {
    return allProducts;
}

public void insertProduct(Product product) {
    repository.insertProduct(product);
}

public void findProduct(String name) {
    repository.findProduct(name);
}

public void deleteProduct(String name) {
    repository.deleteProduct(name);
}

Creating the Product Item Layout

The name of each product in the database will appear within the RecyclerView list in the main user interface. This will require a simple layout resource file containing a TextView to be used for each row in the list. Add this file now by right-clicking on the app -> res -> layout entry in the Project tool window and selecting the New -> Layout resource file menu option. Name the file product_list_item and change the root element to LinearLayout before clicking on OK to create the file and load it the layout editor. With the layout editor in Design mode, drag a TextView object from the palette onto the layout where it will appear by default at the top of the layout:

Designing the RecyclerView row item layout


With the TextView selected in the layout, use the Attributes tool window to set the ID of the view to product_row and the layout_height to 30dp. Select the LinearLayout entry in the Component Tree window and set the layout_height attribute to wrap_content.

Adding the RecyclerView Adapter

As outlined in details the chapter entitled TBD, a RecyclerView instance requires an adapter class to provide the data to be displayed. Add this class now by right clicking on the app -> java -> com.ebookfrenzy.roomdemo -> ui.main entry in the Project tool window and selecting the New -> Java Class... menu option. In the Create New Class Dialog, name the class ProductListAdapter. With the resulting ProductListAdapter.java class loaded into the editor, implement the class as follows:

package com.ebookfrenzy.roomdemo.ui.main;

import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import com.ebookfrenzy.roomdemo.Product;
import com.ebookfrenzy.roomdemo.R;

import java.util.List;

public class ProductListAdapter extends RecyclerView.Adapter<ProductListAdapter.ViewHolder> {

    private int productItemLayout;
    private List<Product> productList;
    
    public ProductListAdapter(int layoutId) {
        productItemLayout = layoutId;
    }

    void setProductList(List<Product> products) {
        productList = products;
        notifyDataSetChanged();
    }

    @Override
    public int getItemCount() {
        return productList == null ? 0 : productList.size();
    }

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(
		parent.getContext()).inflate(productItemLayout, parent, false);
        ViewHolder myViewHolder = new ViewHolder(view);
        return myViewHolder;
    }

    @Override
    public void onBindViewHolder(final ViewHolder holder, final int listPosition) {
        TextView item = holder.item;
        item.setText(productList.get(listPosition).getName());
    }
    
    static class ViewHolder extends RecyclerView.ViewHolder {
        TextView item;
        ViewHolder(View itemView) {
            super(itemView);
            item = itemView.findViewById(R.id.product_row);
        }
    }
}

Preparing the Main Fragment

The last remaining component to modify is the MainFragment class which needs to configure listeners on the Buttons views and observers on the live data objects located in ViewModel. Before adding this code, some preparation work needs to be performed to add some imports, variables and to obtain references to view ids. Edit the MainFragment.java file and modify it as follows:

.
.
import android.widget.EditText;
import android.widget.TextView;
import com.ebookfrenzy.roomdemo.R;
.
.
public class MainFragment extends Fragment {

    private MainViewModel mViewModel;
    private ProductListAdapter adapter;

    private TextView productId;
    private EditText productName;
    private EditText productQuantity;
.
.
   @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        mViewModel = ViewModelProviders.of(this).get(MainViewModel.class);

        productId = getView().findViewById(R.id.productID);
        productName = getView().findViewById(R.id.productName);
        productQuantity = getView().findViewById(R.id.productQuantity);

        listenerSetup();
        observerSetup();
        recyclerSetup();
    }
.
.
}

At various stages in the code, the app will need to clear the product information displayed in the user interface. To avoid code repetition, add the following clearFields() convenience function:

private void clearFields() {
    productId.setText("");
    productName.setText("");
    productQuantity.setText("");
} 

Before the app can be built and tested, the three setup methods called from the onActivityCreated() method above need to be added to the class.

Adding the Button Listeners

The user interface layout for the main fragment contains three buttons each of which needs to perform a specific task when clicked by user. Edit the MainFragment.java file and add the listenerSetup() method:

.
.
import com.ebookfrenzy.roomdemo.Product;
import android.widget.Button;
.
.
    private void listenerSetup() {

        Button addButton = getView().findViewById(R.id.addButton);
        Button findButton = getView().findViewById(R.id.findButton);
        Button deleteButton = getView().findViewById(R.id.deleteButton);

        addButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {

                String name = productName.getText().toString();
                String quantity = productQuantity.getText().toString();

                if (!name.equals("") && !quantity.equals("")) {
                    Product product = new Product(name, 
					Integer.parseInt(quantity));
                    mViewModel.insertProduct(product);
                    clearFields();
                } else {
                    productId.setText("Incomplete information");
                }
            }
        });

        findButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                mViewModel.findProduct(productName.getText().toString());
            }
        });

        deleteButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                mViewModel.deleteProduct(productName.getText().toString());
                clearFields();
            }
        });
    }
.
.
}

The addButton listener performs some basic validation to ensure that the user has entered both a product name and quantity and uses this data to create a new Product entity object (note that the quantity string is converted to an integer to match the entity data type). The ViewModel insertProduct() method is then called and passed the Product object before the fields are cleared.

The findButton and deleteButton listeners pass the product name to either the ViewModel findProduct() or deleteProduct() method.

Adding LiveData Observers

The user interface now needs to add observers to remain synchronized with the searchResults and allProducts live data objects within the ViewModel. Remaining in the Mainfragment.java file, implement the observer setup method as follows:

.
.
import android.arch.lifecycle.Observer;
.
.
import java.util.List;
import java.util.Locale;
.
.
   private void observerSetup() {

        mViewModel.getAllProducts().observe(this, new Observer<List<Product>>() {
            @Override
            public void onChanged(@Nullable final List<Product> products) {
                adapter.setProductList(products);
            }
        });

        mViewModel.getSearchResults().observe(this, 
                                    new Observer<List<Product>>() {
            @Override
            public void onChanged(@Nullable final List<Product> products) {

                if (products.size() > 0) {
                    productId.setText(String.format(Locale.US, "%d", 
                                           products.get(0).getId()));
                    productName.setText(products.get(0).getName());
                    productQuantity.setText(String.format(Locale.US, "%d",              
                                           products.get(0).getQuantity()));
                } else {
                    productId.setText("No Match");
                }
            }
        });
    }
.
.
}

The “all products” observer simply passes the current list of products to the setProductList() method of the RecyclerAdapter where the displayed list will be updated.

The “search results” observer checks that at least one matching result has been located in the database, extracts the first matching Product entity object from the list, gets the data from the object, converts it where necessary and assigns it to the TextView and EditText views in the layout. If the product search failed, the user is notified via a message displayed on the product ID TextView.

Initializing the RecyclerView

Add the final setup method to initialize and configure the RecyclerView and adapter as follows:

.
.
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
.
.
    private void recyclerSetup() {

        RecyclerView recyclerView;

        adapter = new ProductListAdapter(R.layout.product_list_item);
        recyclerView = getView().findViewById(R.id.product_recycler);
        recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
        recyclerView.setAdapter(adapter);
    }
.
.
}

Testing the RoomDemo App

Compile and run the app on a device or emulator, add some products and make sure that they appear automatically in the RecyclerView. Perform a search for an existing product and verify that the product ID and quantity fields update accordingly. Finally, enter the name for an existing product, delete it from the database and confirm that it is removed from the RecyclerView product list.

Summary

This chapter has demonstrated the use of the Room persistence library to store data in a SQLite database. The finished project made use of a repository to separate the ViewModel from all database operations and demonstrated the creation of entities, a DAO and a room database instance, including the use of asynchronous tasks when performing some database operations.