© Ted Hagos 2020
T. HagosLearn Android Studio 4https://doi.org/10.1007/978-1-4842-5937-5_16

16. Jetpack, LiveData, ViewModel, and Room

Ted Hagos1 
(1)
Manila, National Capital Region, Philippines
 
What we’ll cover:
  • Lifecycle aware components

  • ViewModel

  • LiveData

  • Room

We saw a bit of the Architecture components in Chapter 10. In this chapter, we’ll look at some other libraries in the Architecture components, namely, Room; it’s a persistence library that sits on top of SQLite. If you’ve used an ORM before (Object Relational Mapper), you can think of Room as something similar to that.

In this chapter, we’ll also explore some more libraries in the Architecture components that go hand in hand with the Room libraries. We’ll look at lifecycle aware components, LiveData, and ViewModel; these, together with Room, are some of the libraries you’ll need to build a fluid and fluent database application.

Lifecycle Aware Components

Lifecycle aware components perform actions in response to a change in the lifecycle status of another component. If you’re familiar with the observable-observer design pattern, lifecycle aware components operate like that.

We need to deal with some new vocabularies:
  • Lifecycle owner —A component that has a lifecycle like an Activity or a Fragment; it can enter various states in its lifecycle, for example, CREATED, RESUMED, PAUSED, DESTROYED, and so on. A lifecycle observer can tap into a lifecycle owner and be notified when the lifecycle status changes, for example, when the Activity enters the CREATED state—after it enters onCreate(), for example; I sometimes refer to the lifecycle owner as an observable.

  • Lifecycle observer —An object that listens to the changes in the lifecycle status of a lifecycle owner. It’s a class that implements the LifecycleObserver interface.

With the lifecycle aware components, we can observe a component like Activity and perform actions as it enters any of its lifecycle statuses.

It’s helpful to create a new project so you can follow the discussion in this chapter. Create a project with an empty Activity, then create another class named MainActivityObserver. You can create this class by right-clicking the project (as shown in Figure 16-1), then choosing NewJava Class.
../images/457413_2_En_16_Chapter/457413_2_En_16_Fig1_HTML.jpg
Figure 16-1

Add a Java class to the project

Name the new class “MainActivityObserver.”

Next, we need to add a dependency on the build.gradle file (module level). Edit the project’s Gradle file to match Listing 16-1.
dependencies {
    def lifecycle_version = "2.2.0"
    implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
    annotationProcessor "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
    ...
}
Listing 16-1

build.gradle file, module level

Note

The lifecycle_version at the time of writing is “2.2.0”; this will be different for you since you’ll be reading this at a later time. You can visit https://bit.ly/lifecyclerelnotes to find out the current version of the lifecycle libraries.

Your project needs to refresh after editing the Gradle file.

To demonstrate the lifecycle concepts, we will examine two classes:
  • MainActivity —This is a simple Activity, pretty much like any other Activity that the IDE generates when you create a project with an empty Activity. The code sample is shown in Listing 16-2.

  • MainActivityObserver —A Java class that will implement the LifecycleObserver interface; this will be our listener object. The code is listed and annotated in Listing 16-1.

The classes MainActivity and MainActivityObserver show an example of setting up an observer-observable relationship between a lifecycle owner (MainActivity) and a lifecycle observer (MainActivityObserver). Edit the MainActivityObserver class to match Listing 16-2.
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleObserver;
import androidx.lifecycle.OnLifecycleEvent;
public class MainActivityObserver implements LifecycleObserver {  ❶
  @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) ❷
  public void onCreateEvent() {  ❸
    System.out.println("EVENT: onCreate Event fired");  ❹
  }
  @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
  public void onPauseEvent() {
    System.out.println("EVENT: onPause Event fired");
  }
  @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
  public void onResumeEvent() {
    System.out.println("EVENT: onResume Event fired");
  }
}
Listing 16-2

MainActivityObserver class

If you want to observe other components’ lifecycle changes, you need to implement the LifecycleObserver interface. This line makes this class an observer.

Use the OnLifecycleEvent annotation to tell the Android Runtime that the decorated method is supposed to be called when the lifecycle event happens; in this case, we’re listening to the ON_CREATE event of the observed object. The parameter to the decorator indicates which lifecycle event we’re listening to.

This is the decorated method. It gets called when the object it is observing enters the ON_CREATE lifecycle status. You can name this method anything you want; I just named it onCreateEvent() because it’s descriptive. Otherwise, you’re free to name it to your liking; the method’s name doesn’t matter because you already decorated it, and the annotation is sufficient.

This is where you do something interesting in response to a lifecycle status change.

Next, edit the MainActivity class to match Listing 16-3.
public class MainActivity extends AppCompatActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    getLifecycle().addObserver(new MainActivityObserver()); ❶
  }
}
Listing 16-3

MainActivity class

From the point of view of the MainActivity (it’s the one being observed), the only thing we need to do here is to add an observer object using the addObserver() method of the LifeCycleOwner interface—yes, the AppCompatActivity implements LifeCycleOwner; that’s the reason we can call the getLifecycle() method within our Activity. You simply need to pass an instance of an observer class (in our case, it’s the MainActivityObserver) to set up lifecycle awareness between an Activity and a regular class.

The application doesn’t do much. It merely logs messages to the Logcat window every time there is a change in the lifecycle state of MainActivity. Still, it demonstrates how we can add an observer to the lifecycle of any Activity.

ViewModel

The Android framework manages the lifecycle of UI controllers like Activities and Fragments; it may decide to destroy or re-create an Activity (or Fragment) in response to some user actions, for example, clicking the back button, or device events, for example, rotating the screen. These configuration changes are out of your control.

If the runtime decides to destroy the UI controller, any transient UI-related data that you’re currently storing in them will be lost.

It’s best to create another project (with an empty Activity) to follow the discussion in this section; then add a Java class to the project and name it “RandomNumber.” Listings 16-4, 16-5, and 16-6 show a simple app that displays a random number every time the Activity is created.

Listing 16-4 shows the code for the random number generator. It only has the two methods getNumber() and createRandomNumber(); each method leaves a Log statement, so we can inspect when and how many times the methods were called. The logic for the getNumber() method is simple—if the minitialized variable is false, that means we’re creating an instance of the RandomNumber class for the first time; so, we’ll create the random number and then simply return it. Otherwise, we’ll return whatever is the current value of the minitialized variable.
import android.util.Log;
import java.util.Random;
public class RandomNumber  {
  private String TAG = getClass().getSimpleName();
  int mrandomnumber;
  boolean minitialized = false;
  String getNumber() {
    if(!minitialized) {
      createRandomNumber();
    }
    Log.i(TAG, "RETURN Random number");
    return mrandomnumber + "";
  }
   void createRandomNumber() {
    Log.i(TAG, "CREATE NEW Random number");
    Random random = new Random();
    mrandomnumber = random.nextInt(100);
    minitialized = true;
  }
}
Listing 16-4

RandomNumber class

Listing 16-5 shows the code for our MainActivity. Everything happens inside the onCreate() method. When MainActivity enters the CREATED state, we create an instance of the RandomNumber class; we call the getNumber() method, and we set the value of the TextView to the result of the getNumber() method.
public class MainActivity extends AppCompatActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    RandomNumber data = new RandomNumber();
    ((TextView) findViewById(R.id.txtrandom)).setText(data.getNumber());
  }
}
Listing 16-5

MainActivity class

Listing 16-6 shows the layout code for activity_main.
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".MainActivity">
  <TextView
    android:id="@+id/txtrandom"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello World!"
    android:textSize="36sp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
Listing 16-6

activity_main.xml

When you run this code for the first time, you’ll see a random number displayed on the TextView, no surprises there. You’ll also see the Log entries for createNumber() and getNumber() in the Logcat window; no surprises there either. Now, while the app is running on the emulator, try to change the device’s orientation—you’ll notice that every time the screen orientation changes, the displayed number on the TextView changes as well. You’ll also see that additional logs for the createNumber() and getNumber() methods show up in Logcat. This is because the runtime destroys and re-creates the MainActivity every time the screen orientation changes. Our RandomNumber object also gets destroyed and re-created along with the MainActivity—our UI data cannot survive across orientation changes.

This is a good case for using the ViewModel library so that the UI data can survive the destruction and re-creation of the Activity class. We only need to do three things to implement ViewModel:
  1. 1.

    Add the lifecycle extensions to our project’s dependencies, like what we did earlier. Go back to Listing 16-1 for instructions.

     
  2. 2.

    To make the RandomGenerator class a ViewModel, we will extend the ViewModel class from the AndroidX lifecycle libraries.

     
  3. 3.

    From the MainActivity, we’ll get an instance of the RandomNumber class using the factory method of the ViewModelProviders class, instead of merely creating an instance of the RandomNumber class.

     
Listing 16-7 shows the changes in the RandomNumber class; the RandomNumber class is transformed automatically to a ViewModel object by extending the ViewModel class.
import java.util.Random;
import androidx.lifecycle.ViewModel;
public class RandomNumber extends ViewModel {
  private String TAG = getClass().getSimpleName();
  int mrandomnumber;
  boolean minitialized = false;
  String getNumber() {
    if(!minitialized) {
      createRandomNumber();
    }
    Log.i(TAG, "RETURN Random number");
    return mrandomnumber + "";
  }
  void createRandomNumber() {
    Log.i(TAG, "CREATE NEW Random number");
    Random random = new Random();
    mrandomnumber = random.nextInt(100);
    minitialized = true;
  }
}
Listing 16-7

RandomNumber extends ViewModel

The class remains mostly the same as its previous version shown in Listing 16-4; the only difference is that now it extends ViewModel.

Now, let’s implement the changes on the MainActivity; Listing 16-8 shows the modified MainActivity which uses ViewModelProviders to get an instance of the RandomNumber class—which is our ViewModel object.
import androidx.lifecycle.ViewModelProviders;
import android.os.Bundle;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    RandomNumber data;
    data = ViewModelProviders.of(this).get(RandomNumber.class); ❶
    ((TextView) findViewById(R.id.txtrandom)).setText(data.getNumber());
  }
}
Listing 16-8

MainActivity and ViewModelProviders

This is the only change we need to do in MainActivity. Instead of directly managing the ViewModel object (the RandomNumber class) by creating an instance of it inside the onCreate() method, we’ll let the ViewModelProviders class manage the scope of our ViewModel object.

LiveData

Going back to our RandomNumber example, we have an app that shows a random number every time the app is launched. The app uses ViewModel already, so we don’t have the problem of losing data every time the Activity is destroyed and re-created. The basic data flow is shown in Figure 16-2.
../images/457413_2_En_16_Chapter/457413_2_En_16_Fig2_HTML.jpg
Figure 16-2

Data flow for the RandomNumber example

But what if you need to fetch another number? For that, we may add a trigger on the MainActivity, like a Button, and then it will call getNumber() on the ViewModel—but how are we going to refresh the TextView in the MainActivity? There are already a couple of ways to do this, and you might have encountered them already. One way to facilitate data exchange between our ViewModel and Activity is the creative use of interfaces (but we won’t discuss that here) or by using an EventBus like Otto (we also won’t discuss that here)—but now, thankfully, because of Architecture components, we can use LiveData. The new data flow is depicted in Figure 16-3.
../images/457413_2_En_16_Chapter/457413_2_En_16_Fig3_HTML.jpg
Figure 16-3

RandomNumber sample with LiveData

The user clicks the FETCH button, which calls the getNumber() function; actually, we’re going to call the createNumber() first, then call the getNumber(). This way, we’re fetching a new random number. There are ways to do this more elegantly, but this is the quickest way to do it, so bear with me.

Our ViewModel object gets a new random number. This isn’t a simple String anymore; we’re going to change it to a MutableLiveData to become observable.

From the MainActivity, we’ll get an instance of the LiveData coming from our ViewModel object and write some codes to observe it; next, we simply react to changes on the LiveData.

Let’s see how that works in code. Listings 16-9 and 16-10 show the code for our ViewModel and MainActivity, respectively.
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
public class RNModel extends ViewModel {
  private String TAG = getClass().getSimpleName();
  MutableLiveData<String> mrandomnumber = new MutableLiveData<>(); ❶
  boolean minitialized = false;
  MutableLiveData<String> getNumber() { ❷
    if(!minitialized) {
      createRandomNumber();
    }
    Log.i(TAG, "RETURN Random number");
    return mrandomnumber;
  }
  void createRandomNumber() {
    Log.i(TAG, "CREATE NEW Random number");
    Random random = new Random();
    mrandomnumber.setValue(random.nextInt(100) + ""); ❸
    minitialized = true;
  }
}
Listing 16-9

ViewModel with LiveData

The value of mrandomnumber is what we return to the MainActivity. We want this to be an observable object. To do this, we change its type from int to MutableLiveData.

We have to make that type change here too since mrandomnumber is now MutableLiveData; this function has to return MutableLiveData.

To set the value of the MutableLiveData, use the setValue() method.

Now we can move on to the changes in MainActivity. Listing 16-10 shows the modified and annotated code for MainActivity.
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProviders;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    final RNModel data;
    data = ViewModelProviders.of(this).get(RNModel.class);
    final TextView txtnumber = (TextView) findViewById(R.id.txtrandom);
    MutableLiveData<String> mnumber = data.getNumber(); ❶
    Button btn = (Button) findViewById(R.id.button);  ❷
    btn.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        data.createRandomNumber();
        data.getNumber();
      }
    });
    mnumber.observe(this, new Observer<String>() { ❸
      @Override
      public void onChanged(String val) { ❹
        txtnumber.setText(val);
      }
    });
  }
}
Listing 16-10

MainActivity

Let’s fetch the random number from the ViewModel. The random number isn’t of String type anymore; it’s MutableLiveData.

This is the boilerplate code for a button click. We need this trigger to fetch a random number from the ViewModel.

To observe a LiveData, we call the observe() method; the method takes two arguments. The first argument is the lifecycle owner (MainActivity, so we pass this); the second argument is an Observer object. We used an anonymous class here to create the Observer object.

This onChanged() method is called every time the value of the random number (mrandomnumber) in the ViewModel changes; so, when it changes, we set the value of the TextView accordingly.

Cool, right? If you’re still not sold on using LiveData, here are a couple of things to consider. When you use LiveData
  • You’re sure the UI always matches the data state. You’ve already seen this from the example. LiveData follows the Observer pattern; it notifies the observer when its value changes.

  • There are no memory leaks. Observers are bound to lifecycle objects. If, for example, our MainActivity enters the paused state (for whatever reason, maybe another Activity is on the foreground), the LiveData won’t be observed; if the MainActivity is destroyed, the LiveData again won’t be observed, and it will clean up after itself—which also means we won’t need to handle the lifecycles of MainActivity and the ViewModel manually.

Room

If you want to include database functionalities to your app, you might want to look at Room. Before Room, the two popular ways to build database apps were either using Realm or just using good ole SQLite. Dealing with SQLite was considered to be a bit low level; it felt too close to the metal and, as such, was a bit cumbersome to use. Realm was quite popular among developers, but it wasn’t a first-party solution, no matter the popularity. Thankfully, we now have Room.

Room is an abstraction on top of SQLite; if you’ve used an ORM before, like Hibernate, it’s similar to that. Room has several advantages over using plain vanilla SQLite; with Room
  • You don’t have to deal with raw queries for basic database operations.

  • It verifies the SQL queries at compile time, so you don’t need to worry about SQL injection—remember those?

  • There is no impedance mismatch between your database objects and Java objects. Room takes care of it; you only need to deal with Java objects.

Room has three major components, the Entity, the Dao, and the Database component. Their relationship with the application is shown in Figure 16-4.
../images/457413_2_En_16_Chapter/457413_2_En_16_Fig4_HTML.jpg
Figure 16-4

Room components

  • Entity—An Entity is used to represent a database table. You code it as a class that’s decorated by the @Entity annotation.

  • Dao or Data Access Object is a class that contains methods to access the tables in the database. This is where you code your CRUD (create, read, update, and delete). This is an interface that’s decorated by the @Dao annotation.

  • Database—This component holds a reference to the database. It’s an abstract class that is annotated by the @Database annotation.

Before you can use Room in a project, you need to add its dependencies to the build.gradle file (module level), as shown in Listing 16-11.
dependencies {
    def room_version = "2.1.0-alpha07"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"
    . . .
}
Listing 16-11

Room dependencies

Listings 16-12, 16-13, 16-14, and 16-15 show the four Java source files that demonstrate Room’s basic usage.
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity(tableName = "person") ❶
public class Person {
  @PrimaryKey(autoGenerate = true) ❷
  @NonNull public int uid;
  @ColumnInfo(name="last_name") ❸
  public String last_name;
  public String first_name;
  public Person(String lname, String fname) {
    last_name = lname;
    first_name = fname;
  }
  public Person() {}
}
Listing 16-12

Person class, the Entity

The @Entity annotation makes this an Entity. If you don’t pass the tableName argument, the table’s name will default to the name of the decorated class. You will only need to pass this argument if you want the table’s name to be different from the decorated class. So, what I wrote here is unnecessary and redundant because I set the value of tableName to “person,” which is the same as the decorated class name.

We’re making the uid member variable the primary key; we’re also saying it can’t be null.

The member variables of the class will automatically become the fields on the table. The column names on the table will take after the member variables’ names unless you use the @ColumnInfo annotation. If you want the name of the table field (column) to be different from the name of the member variable, use the @ColumnInfo decoration, as shown here, and set the name to your preferred column name.

import java.util.List;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;
@Dao ❶
interface PersonDAO { ❷
  @Insert  ❸
  void insertPerson(Person person);
  @Update
  void updatePerson(Person person);
  @Delete
  void deletePerson(Person person);
  @Query("SELECT * FROM person") ❹
  public List<Person> listPeople();
}
Listing 16-13

PersonDAO, the Data Access Object

A DAO needs to be annotated by the @Dao decorator.

Daos have to be written as interfaces.

Use the @Insert decorator to indicate that the decorated method will be used for inserting records to the table. Similarly, you will decorate methods for update, query, and delete with @Update, @Query, and @Delete, respectively.

Use the @Query to write SQL select statements. Each @Query is verified at compile time; if there is a problem with the query, a compilation error occurs instead of a runtime error. That should put your mind at ease.

import android.content.Context;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
@Database(entities = {Person.class}, version = 1) ❶
public abstract class AppDatabase extends RoomDatabase { ❷
  private static AppDatabase minstance;
  private static final String DB_NAME = "person_db";
  public abstract PersonDAO getPersonDAO(); ❸
  public static synchronized AppDatabase getInstance(Context ctx) { ❹
    if(minstance == null) {
      minstance = Room.databaseBuilder(ctx.getApplicationContext(), ❺
          AppDatabase.class,
          DB_NAME)
          .fallbackToDestructiveMigration()
          .build();
    }
    return minstance;
  }
}
Listing 16-14

AppDatabase, the database holder

Use the @Database to signify that this class is the database holder. Use the entities argument to specify the Entities that are in the database. If you have more than one Entity, use commas to separate the list. The second argument is the version; this is an integer value that specifies the version of your DB.

A Database class is abstract and extends the RoomDatabase.

You need to provide an abstract class that will return an instance of the DAO object.

You need to provide a static method to get an instance of the Database. It doesn’t have to be a singleton, like what we did here, but I imagine you don’t want more than one instance of the Database class.

Use the databaseBuilder() method to create an instance of the RoomDatabase. There are three arguments to the builder method: (1) an application context; (2) the abstract class, which is annotated by @Database; and (3) the name of the database file. This will be the filename of SQLite dB.

Now that all our Room components are in place, we can use them from our app. Listing 16-15 shows how to use Room components from an Activity.
public class MainActivity extends AppCompatActivity {
  private AppDatabase db;
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    Button btn = (Button) findViewById(R.id.button);
    btn.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        saveData();
        System.out.println("Clicked");
      }
    });
    db = AppDatabase.getInstance(this); ❶
  }
  private void saveData() {
    final String mlastname = ((TextView) findViewById(R.id.txtlastname)).getText().toString();
    final String mfirstname = ((TextView) findViewById(R.id.txtfirstname)).getText().toString(); ❷
    new Thread(new Runnable() { ❸
      @Override
      public void run() {
        Person person = new Person(mlastname, mfirstname); ❹
        PersonDAO dao = db.getPersonDAO(); ❺
        dao.insertPerson(person); ❻
        List<Person> people = dao.listPeople(); ❼
        for(Person p:people) { ❽
          System.out.printf("%s , %s ", p.last_name, p.first_name );
        }
      }
    }).start();
  }
}
Listing 16-15

MainActivity

To begin using the RoomDatabase, get an instance of using the factory method we coded in the AppDatabase earlier.

Let’s collect the data from the TextViews.

Room follows best practices, so it won’t allow you to run any database query on the main UI thread. You need to create a background thread and run all your Room commands in there. Here, I used a quick and dirty Thread and Runnable objects, but you’re free to use any other means of background execution, for example, AsyncTask.

Create a Person object using the inputs from the TextViews.

Let’s get an instance of the DAO.

Do an insert using the insertPerson() method we coded in the DAO earlier.

Let’s do a SELECT.

And list all entries in our person table.

In a real app, you probably wouldn’t access the database from a UI controller like an Activity; you might want to put in a ViewModel class. That way, the UI controller’s responsibility is strictly to present data and not to act as a model.

If you use Room with ViewModel and LiveData, it can provide a more responsive UI experience. I didn’t cover it here, but it’s a great exercise to pursue after this chapter.

Summary

  • AppCompatActivity objects are now lifecycle owners. You can write another class and listen to the lifecycle changes of a lifecycle owner, then react accordingly; Fragments too are lifecycle owners—don’t forget to use AndroidX artifacts on your project when working with lifecycle aware components.

  • ViewModel makes your UI data resilient to the destruction and re-creation of UI controllers (like Activities and Fragments).

  • LiveData makes the relationship between your UI object and model data bidirectional. A change in one is automatically reflected in the other.

  • Room is an ORM for SQLite. It’s a first-party solution and it’s part of the Architecture components—there’s little reason we shouldn’t use it.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.226.251.68