Tuesday, June 28, 2011

A Deep Dive Into Location Part 2: Being Psychic and Staying Smooth

This is part two of A Deep Dive into Location. This post focuses on making your apps psychic and smooth using the Backup Manager, AsyncTask, Intent Services, the Cursor Loader, and Strict Mode.
The code snippets used are available as part of the Android Protips: A Deep Dive Into Location open source project. More pro tips can be found in my Android Pro Tips presentation from Google I/O. 
Being Psychic

You've just had to factory reset your device - never a good day - but yay! You've opted in to "backup my settings" and Android is happily downloading all your previously installed apps. Good times! You open your favourite app and... all your settings are gone.


Backup Shared Preferences to the Cloud using the Backup Manager

If you're not using the Backup Manager to preserve user preference to the cloud I have a question for you: Why do you hate your users? The Backup Manager was added to Android in Froyo and it's about as trivial to implement as I can conceive.

All you need to do is extend the BackupAgentHelper and create a new SharedPreferencesBackupHelper within it's onCreate handler.

As shown in the PlacesBackupAgent, your Shared Preferences Backup Helper instance takes the name of your Shared Preference file, and you can specify the key for each of the preferences you want to backup. This should only be user specified preferences - it's poor practice to backup instance or state variables.

public class PlacesBackupAgent extends BackupAgentHelper {
  @Override
  public void onCreate() {
    SharedPreferencesBackupHelper helper = new
      SharedPreferencesBackupHelper(this, PlacesConstants.SHARED_PREFERENCE_FILE);
    addHelper(PlacesConstants.SP_KEY_FOLLOW_LOCATION_CHANGES, helper);
  }
}


To add your Backup Agent to your application you need to add an android:backupAgent attribute to the Application tag in your manifest.

<application android:icon="@drawable/icon" android:label="@string/app_name"
             android:backupAgent="PlacesBackupAgent">


You also need to specify an API key (which you can obtain from here: http://code.google.com/android/backup/signup.html)

<meta-data android:name="com.google.android.backup.api_key"
           android:value="Your Key Goes Here" />


To trigger a backup you just tell the Backup Manager that the data being backed up has changed. I do this within the SharedPreferenceSaver classes, starting with the FroyoSharedPreferenceSaver.

public void savePreferences(Editor editor, boolean backup) {
  editor.commit();
  backupManager.dataChanged();
}


Being Smooth: Make everything asynchronous. No exceptions.

Android makes it easy for us to write apps that do nothing on the main thread but update the UI.


Using AsyncTask

In this example, taken from PlaceActivity, I'm creating and executing an AsyncTask class to lookup the best previous known location. This isn't an operation that should be particularly expensive - but I don't care. It isn't directly updating the UI, so it has no business on the main application thread.

AsyncTask<void, void, void> findLastLocationTask = new AsyncTask<void, void, void>() {
  @Override
  protected Void doInBackground(Void... params) {
    Location lastKnownLocation =
      lastLocationFinder.getLastBestLocation(PlacesConstants.MAX_DISTANCE,
      System.currentTimeMillis()-PlacesConstants.MAX_TIME);

    updatePlaces(lastKnownLocation, PlacesConstants.DEFAULT_RADIUS, false);
    return null;
  }
};
findLastLocationTask.execute();


You'll note that I'm not touching the UI during the operation or at its completion, so in this instance I could have used normal Thread operations to background it rather than use AsyncTask.

Using the IntentService

Intent Services implement a queued asynchronous worker Service. Intent Services encapsulate all the best practices for writing services; they're short lived, perform a single task, default to Start Not Sticky (where supported), and run asynchronously.

To add a new task to the queue you call startService passing in an Intent that contains the data to act on. The Service will then run, executing onHandleIntent on each Intent in series until the queue is empty, at which point the Service kills itself.

I extended Intent Service for all my Service classes, PlacesUpdateService, PlaceDetailsUpdateService, PlaceCheckinService, and CheckinNotificationService.

Each implementation follows the same pattern, as shown in the PlacesUpdateService extract below.

@Override
protected void onHandleIntent(Intent intent) {
  String reference = intent.getStringExtra(PlacesConstants.EXTRA_KEY_REFERENCE);
  String id = intent.getStringExtra(PlacesConstants.EXTRA_KEY_ID);

  boolean forceCache = intent.getBooleanExtra(PlacesConstants.EXTRA_KEY_FORCEREFRESH, false);
  boolean doUpdate = id == null || forceCache;

  if (!doUpdate) {
    Uri uri = Uri.withAppendedPath(PlaceDetailsContentProvider.CONTENT_URI, id);
    Cursor cursor = contentResolver.query(uri, projection, null, null, null);

    try {
      doUpdate = true;
      if (cursor.moveToFirst()) {
        if (cursor.getLong( cursor.getColumnIndex(           PlaceDetailsContentProvider.KEY_LAST_UPDATE_TIME)) >
          System.currentTimeMillis()-PlacesConstants.MAX_DETAILS_UPDATE_LATENCY)
            doUpdate = false;
      }
    }
    finally {
      cursor.close();
    }
  }

  if (doUpdate)
    refreshPlaceDetails(reference, forceCache);
}


Note that the queue is processed on a background thread, so I can query the Content Provider without having to spawn another background thread.

CursorLoaders are awesome. Use them.

Loaders are awesome; and thanks to the compatibility library, they're supported on every platform back to Android 1.6 - that’s about 98% of the current Android device install base.

Using CursorLoaders is a no-brainer. They take a difficult common task - obtaining a Cursor of results from a Content Provider - and implement, encapsulate, and hide all the bits that are easy to get wrong.

I've already fragmented and encapsulated my UI elements by creating three Fragments -- PlaceListFragment, PlaceDetailFragment, and CheckinFragment. Each of these Fragments access a Content Provider to obtain the data they display.

The list of nearby places is handled within the PlaceListFragment, the relevant parts of which are shown below.

Note that it's entirely self contained; because the Fragment extends ListFragment the UI is already defined. Within onActivityCreated I define a Simple Cursor Adapter that specifies which Content Provider columns I want to display in my list (place name and my distance from it), and assign that Adapter to the underlying List View.

The final line initiates the Loader Manager.

public void onActivityCreated(Bundle savedInstanceState) {
  super.onActivityCreated(savedInstanceState);
  activity = (PlaceActivity)getActivity();

  adapter = new SimpleCursorAdapter(activity,
    android.R.layout.two_line_list_item,
    cursor,
    new String[]
      {PlacesContentProvider.KEY_NAME, PlacesContentProvider.KEY_DISTANCE},
    new int[] {android.R.id.text1, android.R.id.text2}, 0);

  // Allocate the adapter to the List displayed within this fragment.
  setListAdapter(adapter);

  // Populate the adapter / list using a Cursor Loader.
  getLoaderManager().initLoader(0, null, this);
}


When the Loader is initiated we specify the parameters we would normally pass in to the Content Resolver when making a Content Provider query. Instead, we pass those parameters in to a new CursorLoader.

public Loader<cursor> onCreateLoader(int id, Bundle args) {
  String[] projection = new String[]
    {PlacesContentProvider.KEY_ID,
    PlacesContentProvider.KEY_NAME,
    PlacesContentProvider.KEY_DISTANCE,
    PlacesContentProvider.KEY_REFERENCE};

  return new CursorLoader(activity, PlacesContentProvider.CONTENT_URI,
    projection, null, null, null);
}


The following callbacks are triggered when the Loader Manager is initiated, completed, and reset respectively. When the Cursor has been returned, all we need to do is apply it to the Adapter we assigned to the List View and our UI will automatically update.

The Cursor Loader will trigger onLoadFinished whenever the underlying Cursor changes, so there's no need to register a separate Cursor Observer or manage the Cursor lifecycle yourself.

public void onLoadFinished(Loader loader, Cursor data) {
  adapter.swapCursor(data);
}

public void onLoaderReset(Loader loader) {
  adapter.swapCursor(null);
}


The PlaceDetailFragment is a little different; in this case we don't have an Adapter backed ListView to handle our UI updates. We initiate the Loader and define the Cursor parameters as we did in the Place List Fragment, but when the Loader has finished we need to extract the data and update the UI accordingly.

Note that onLoadFinished is not synchronized to the main application thread, so I'm extracting the Cursor values on the same thread as the Cursor was loaded, before posting a new Runnable to the UI thread that assigns those new values to the UI elements - in this case a series of Text Views.

public void onLoadFinished(Loader loader, Cursor data) {
  if (data.moveToFirst()) {
    final String name = data.getString(
      data.getColumnIndex(PlaceDetailsContentProvider.KEY_NAME));
    final String phone = data.getString(
      data.getColumnIndex(PlaceDetailsContentProvider.KEY_PHONE));
    final String address = data.getString(
      data.getColumnIndex(PlaceDetailsContentProvider.KEY_ADDRESS));
    final String rating = data.getString(
      data.getColumnIndex(PlaceDetailsContentProvider.KEY_RATING));
    final String url = data.getString(
      data.getColumnIndex(PlaceDetailsContentProvider.KEY_URL));

    if (placeReference == null) {
      placeReference = data.getString(
        data.getColumnIndex(PlaceDetailsContentProvider.KEY_REFERENCE));
      updatePlace(placeReference, placeId, true);
    }

    handler.post(new Runnable () {
      public void run() {
        nameTextView.setText(name);
        phoneTextView.setText(phone);
        addressTextView.setText(address);
        ratingTextView.setText(rating);
        urlTextView.setText(url);
      }
    });
  }
}


Using Strict Mode will prevent you from feeling stupid

Strict Mode is how you know you've successfully moved everything off the main thread. Strict Mode was introduced in Gingerbread but some additional options were added in Honeycomb. I defined an IStrictMode Interface that includes an enableStrictMode method that lets me use whichever options are available for a given platform.

Below is the enableStrictMode implementation within the LegacyStrictMode class for Gingerbread devices.

public void enableStrictMode() {
  StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
    .detectDiskReads()
    .detectDiskWrites()
    .detectNetwork()
    .penaltyDialog()
    .build());
}


The only thing I hate more than modal dialogs in apps is apps that freeze because a network read or disk write is blocking the UI thread. As a result I've enabled detection of network and disk read/writes and reports using a modal dialog.

I've applied Strict Mode detection to the entire app by extending the Application class to instantiate the appropriate IStrictMode implementation and enable Strict Mode. Note that it is only turned on in developer mode. Be sure to flick that switch in the constants file when you launch.

public class PlacesApplication extends Application {
  @Override
  public final void onCreate() {
    super.onCreate();

    if (PlacesConstants.DEVELOPER_MODE) {
      if (PlacesConstants.SUPPORTS_HONEYCOMB)
        new HoneycombStrictMode().enableStrictMode();
      else if (PlacesConstants.SUPPORTS_GINGERBREAD)
        new LegacyStrictMode().enableStrictMode();
    }
  }
}

21 comments:

  1. Anonymous11:01 pm BST

    Great article, thank you.

    PS: I had to write this post in portrait on my android (sgs2@2.3.3) because the text field would not be placed correctly in the remaining space on top of the landscape keyboard ;-)

    ReplyDelete
  2. I was wondering exactly what data should we backup. If I had a game, should I use the backup manager to backup which levels the user has completed? Because your article seems to say No, that I should not backup game state: "This should only be user specified preferences - it's poor practice to backup instance or state variables."

    ReplyDelete
  3. What about using Loader with ArrayAdapters? Probably I should use ArrayList instead of Cursor and AsyncTaskLoader instead of CursorLoader. What do you think?

    Thanks for the article. It was really helpful.

    ReplyDelete
  4. Do you know if there are plans to support maps in fragments? I can't move my app to 3.x until that works.

    ReplyDelete
  5. I love these articles!
    Can you fix typo, AsyncTask needs "Void" parameters, not "void".
    Thanks!

    ReplyDelete
  6. Anonymous1:16 pm BST

    @Robert Massaioli:

    The article sais, that u should not backup application variables or other data that change often and more importantly may not work anymore in later versions..

    anything that is user relevant, like settings or gamesaves should be backed.. but not in the form of "deep code data structures" but as clearly defined and all version compatible data..

    ReplyDelete
  7. Thanks for writing useful blogs about android. seems like there is no inbuilt way of drawing charts in Android. Any comments about which third party library you would suggest? I would not want to use google charts API since that will require network connection.

    ReplyDelete
  8. Anonymous2:32 pm BST

    Thanks!

    ReplyDelete
  9. How can I auto-magically switch my api-key from debug to release?

    Is there a trick to do that??

    thanks
    -cellurl

    ReplyDelete
  10. @Casey Borders

    Though its certainly not perfect, there is a workaround:

    http://groups.google.com/group/android-developers/browse_frm/thread/b705bbd72d28d000/df84933c3a460c71

    ReplyDelete
  11. I implemented your logic into my app but it seems that the onReceive() method of the singleUpdateReceiver in GingerbreadLastLocationFinder class is called twice. On the second attempt to unregister the receiver I get an IllegalArgumentException because the receiver was already unregistered the first time. Now I know I can wrap the call or the contents of the method in a try-catch block to get around this issue but it just seems a little disconcerting that this is happening. The code itself seems pretty basic so I'm at a loss for why this is happening.

    ReplyDelete
    Replies
    1. Hi Jerrell,

      I run on the same error. Can you please drop your code here? I would like to know where exactly you've added the try-catch block.

      @Reto: Of course an official fix would be much better ;-)

      Thanks a lot, JC

      Delete
  12. Hi,

    I'm having all sorts of trouble using an AsyncTask to perform all my database reads. I talked to you in the Office Hours and you mentioned I should be doing this (I specifically asked about reads).

    I see I should be using CursorLoader, but I can't find any documentation on how to do this against a SQLiteDatabase (ie. not a ContentProvider).

    I've put a question up on the developers mailing list so others can benefit from any answer to this question.
    http://groups.google.com/group/android-developers/t/15c1c334cf6832a2

    I reckon you're the person when it comes to this stuff. If you could take a look and point me in the right direction I'd really appreciate it.

    Cheers,
    Julius.

    ReplyDelete
  13. Hi Reto,

    I'm having all sorts of trouble taking my reads off the main UI thread. I asked you about this in the Office Hours and you mentioned I should be doing it.

    I've asked a question in the developers mailing list and was hoping you could take a look:
    http://groups.google.com/group/android-developers/t/15c1c334cf6832a2

    If you could point me in the right direction that would be great.

    Cheers,
    Julius.

    ReplyDelete
  14. Hi Reto,

    I've just (finally) added data backup to my application and it works perfectly on 1 device: settings are restored after re-installing the app.

    But I was surprised the data saved from 1 device (running Android 2.3) doesn't show up on another device (running Android 4.0) after a first install the app.

    Here are the logs:
    D/PackageManager(203): New package installed in /data/app/my.android.app-1.apk
    V/BackupManagerService(203): restoreAtInstall pkg=my.android.app token=1
    D/BackupManagerService(203): MSG_RUN_RESTORE observer=null
    D/BackupManagerService(203): initiateOneRestore packageName=@pm@
    V/BackupServiceBinder(203): doRestore() invoked
    V/BackupManagerService(203): No next package, finishing restore
    I/BackupManagerService(203): Restore complete.

    Does that mean that when the user gets a new device (running a newer version of Android), the settings from the old device are not restored on the new device?

    What Google use to backup my WiFi settings, wallpaper and all the other settings that get restored when I get a new device?

    Thanks

    ReplyDelete
  15. The location finding code works quite fine. Just having trouble with GingerbreadLastLocationFinder... Sometimes the class crashes on location update as a receiver has not been registered.

    This does not happen always. Normally the location update works perfects. Any idea what can be the reason for these occasionally crashes?

    Seems to be the same issue Jerrell has posted Jul 27, 2011.

    Thanks, JC

    ReplyDelete
  16. Anonymous1:43 pm BST

    How do I make this app work on an emulator, I downloaded the source code, compiled it and intalled it on my personal phone. However, I cant seem to make it work on an emulator.

    ReplyDelete
  17. API Key is not needed anymore

    ReplyDelete
  18. From reading the google documentation my understanding was that it is not necessary to trigger a manual backup with backupManager.dataChanged() if you are using the sharepreferencebackuphelper as it will do this automatically.

    In your BackupAgentHelper, you must use one or more "helper" objects, which automatically backup and restore certain types of data, so that you do not need to implement onBackup() and onRestore().
    Android currently provides backup helpers that will backup and restore complete files from SharedPreferences and internal storage.


    Is this not the case?

    ReplyDelete
  19. Anonymous6:56 am GMT

    Hi Reto Meier,

    Could you help me this issue?
    "
    java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{com.radioactiveyak.location_best_practices/com.radioactiveyak.location_best_practices.UI.PlaceActivity}: java.lang.ClassNotFoundException: com.radioactiveyak.location_best_practices.UI.PlaceActivity in loader dalvik.system.PathClassLoader[/data/app/com.radioactiveyak.location_best_practices-1.apk]
    "
    when I run on eclipse simulator.

    ReplyDelete
  20. Hello,
    I am experiencing the same ClassNotFound prob with PlaceActivity when running on Experia S

    I have no problems with the android setup of the project in Eclispe, all libs are here and the keys are also set.

    what can it be?

    ReplyDelete