Android Architecture
General Tips
- Do not create/initiate dialogs from async tasks. See Dialogs And Async Tasks.
- Do not use time-consuming operations in main (UI) thread.
- Do not use static variables to persist data. See Caching Data.
- To differentiate development and production behavior
BuildConfig.Debug
variable could be used. - Use OkHttp for making HTTP requests (instead of Volley). Volley is less technically advanced than OkHttp since it does not easily support JSON PATCH requests and has other problems.
- Use async tasks and services wisely. See Async Tasks vs. Services.
- Always have in mind that an Android app can be killed by the OS at any time so devices should expect termination as early as
onStop
. - For performance it is better to have shallow layouts than deep and narrow (not more that 10 nested views for an 80 view hierarchy).
- Fragment should be written to depend on host activity interface but not on concrete activity methods (to remain reusable). See Communicating With The Activity.
- RetainInstanceState fragments should only be used to hold data that should not be recreated after screen rotation. This also means that these fragments must not have a UI. They hold data like:
- Async tasks.
- Threads.
- Data collections.
- Results for caching.
- Never use
fragment.getString()
,fragment.getResources()
methods (and similar ones) from fragments with async tasks (or in any other case, where calling the method might result in afragment not attached to activity
error). In any case, just to be safe, it is always better to useapplication_context.getString()
andapplication_context.getResources()
. - Padding is used when touch space of an object needs to be enlarged while margin is used when space needs to be created between objects.
- Never nest a ViewPager inside a ViewPager. This is an anti-pattern even if it works.
- Categorize content using one of these components: navigation drawer, tabs, action bar spinners.
- Do not have horizontally scrollable content (or ViewPager) inside a tab because it will not work out of the box and will be counterintuitive for users. Android tabs are meant to be scrollable (swipeable). See Tabs With Horizontally Scrollable Content for more info.
- Never use
getFragmentManager()
from a fragment to manage children of a fragment. UsegetChildFragmentManager()
(this is especially important when setting up a view pager inside a fragment).getFragmentManager()
is used when you need to add fragments to (or find fragments in) an activity. - Use Master/Detail flow pattern (on small screens: pressing a list item opens details screen; on large screens: details are shown on the right and the list with the selected item - on the left). See demo.
- If alarms are used then they have to be rescheduled every time the phone is rebooted or the app is updated. There are two intent actions for this: BOOT_COMPLETED and MY_PACKAGE_REPLACED.
- Write tasks in a synchronous manner. This way it is easy to combine them and use them with different background jobs runner implementations like services, async tasks, and handlers.
- Use snackbars to provide feedback with destructive operations (e.g. ‘Archived’ -> ‘Undo’).
Digging Deeper
Dialogs And Async Tasks
There are two main reasons why a dialog needs to be shown when using async tasks:
- To disable the UI while performing the task.
- To show results after the task has been completed.
Disabling The UI While Performing The Task
Problem
Probably the easiest way to disable UI while loading the necessary data would be to show an unclosable dialog until the task finishes. Although, if the task starts or finishes while the screen is rotating or the app is in the background, there will be a crash.
Solution
Instead of a dialog, a placeholder (like a spinning progress bar) could be shown in the place where the content should be. This way, when the content is loaded, the placeholder could be hidden and the content shown. Since hiding and showing elements do not involve fragment transactions, there would be no crashes.
Showing Results After The Task Has Been Completed
Problem
It is usually really tempting to show an alert dialog when an async task finishes its job. However, there is a problem with that: every time a dialog is created after onSaveInstanceState
(this could happen after the home button was pressed but the async task did not finish its job yet; or during screen rotation), the app will result in a crash since there cannot be any fragment transactions after onSaveInstanceState
.
Solution
One way to safely create a dialog after the async task finishes is to always cancel an async task before onSaveInstanceState
is called (like onPause
) and restart it after the fragment is recreated (like onStart
).
If the background job should not be stopped after screen rotation or pressing the home button then using a service might be a better idea. Reporting work status from a service is quite easy. However, it is possible to implement an async task that continues working even after the activity is recreated. See Async Task vs. Services for the implementation.
Caching Data
Problem
Some data is too expensive to recalculate on, e.g. screen rotation. So it needs to be stored somewhere and reused. The first solution that may come to mind, would be using static variables but the problem with this is that static data could be garbage collected at any time.
Solution
For this problem retainInstanceState
fragments could be used. Or as the last resort, the data could be attached to the Application
object which is not garbage collected until the app is active.
In any case, caching data is usually an over-optimization and it is better to recalculate the data and not bother with retained fragments or application singleton.
Tabs With Horizontally Scrollable Content
Problem
There is a need to include horizontally scrollable content inside an Android tab. Since tabs are horizontally swipeable this will not work as expected.
Solutions
There are a couple of ways to solve this problem:
- Use a navigation drawer instead and leave the horizontally scrollable content.
- Change the horizontally scrollable content to vertically scrollable content.
- Use spinners instead of tabs to categorize content and leave the horizontally scrollable content.
- Implement tabs like buttons and change fragments after pressing category button and leave horizontally scrollable content (last resort).
Async Tasks vs. Services
Async tasks and services should be used in different situations.
Async task is used when a job is short and ‘lives’ only as long as the activity. Service is used when a job needs to continue execution even when the activity is recreated or closed.
So loading the data from the DB to show on screen would use an async task since the data should be refreshed every time the activity is created. However, fetching and syncing data from the server or submitting results should probably be done in a service if it is independent from the activity.
It is possible to write async tasks that would ‘live’ across orientation changes. The main idea is to use a retained fragment for holding the async task:
public class TaskFragment extends Fragment {
private static final String TAG = TaskFragment.class.getSimpleName();
/**
* Callback interface through which the fragment can report the task's
* progress and results back to the Activity.
*/
static interface TaskCallbacks {
void onPreExecute();
void onProgressUpdate(int percent);
void onCancelled();
void onPostExecute();
}
private TaskCallbacks mCallbacks;
private DummyTask mTask;
private boolean mRunning;
/**
* Hold a reference to the parent Activity so we can report the task's current
* progress and results. The Android framework will pass us a reference to the
* newly created Activity after each configuration change.
*/
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
if (!(activity instanceof TaskCallbacks)) {
throw new IllegalStateException("Activity must implement the TaskCallbacks interface.");
}
// Hold a reference to the parent Activity so we can report back the task's
// current progress and results.
mCallbacks = (TaskCallbacks) activity;
}
/**
* This method is called once when the Fragment is first created.
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
/**
* Note that this method is not called when the Fragment is being
* retained across Activity instances. It will, however, be called when its
* parent Activity is being destroyed for good (such as when the user clicks
* the back button, etc.).
*/
@Override
public void onDestroy() {
super.onDestroy();
cancel();
}
/**
* Start the background task.
*/
public void start() {
if (!mRunning) {
mTask = new DummyTask();
mTask.execute();
mRunning = true;
}
}
/**
* Cancel the background task.
*/
public void cancel() {
if (mRunning) {
mTask.cancel(false);
mTask = null;
mRunning = false;
}
}
/**
* Returns the current state of the background task.
*/
public boolean isRunning() {
return mRunning;
}
private class DummyTask extends AsyncTask<Void, Integer, Void> {
@Override
protected void onPreExecute() {
// Proxy the call to the Activity.
mCallbacks.onPreExecute();
mRunning = true;
}
@Override
protected Void doInBackground(Void... ignore) {
for (int i = 0; !isCancelled() && i < 100; i++) {
SystemClock.sleep(100);
publishProgress(i);
}
return null;
}
@Override
protected void onProgressUpdate(Integer... percent) {
mCallbacks.onProgressUpdate(percent[0]);
}
@Override
protected void onCancelled() {
mCallbacks.onCancelled();
mRunning = false;
}
@Override
protected void onPostExecute(Void ignore) {
mCallbacks.onPostExecute();
mRunning = false;
}
}
}
Activity that holds the fragment may look like this:
public class MainActivity extends FragmentActivity implements TaskFragment.TaskCallbacks {
private static final String TAG = MainActivity.class.getSimpleName();
private static final String TAG_TASK_FRAGMENT = "task_fragment";
private TaskFragment mTaskFragment;
private Button mButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mButton = (Button) findViewById(R.id.task_button);
mButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mTaskFragment.isRunning()) {
mTaskFragment.cancel();
} else {
mTaskFragment.start();
}
}
});
FragmentManager fm = getSupportFragmentManager();
mTaskFragment = (TaskFragment) fm.findFragmentByTag(TAG_TASK_FRAGMENT);
// If the Fragment is non-null, then it is being retained
// over a configuration change.
if (mTaskFragment == null) {
mTaskFragment = new TaskFragment();
fm.beginTransaction().add(mTaskFragment, TAG_TASK_FRAGMENT).commit();
}
if (mTaskFragment.isRunning()) {
mButton.setText(getString(R.string.cancel));
} else {
mButton.setText(getString(R.string.start));
}
}
@Override
public void onPreExecute() {
// Do something
}
@Override
public void onProgressUpdate(int percent) {
// Do something
}
@Override
public void onCancelled() {
// Do something
}
@Override
public void onPostExecute() {
// Do something
}
}
So as the fragment is retained (meaning it is not destroyed on configuration changes) it can be reused on configuration changes: mTaskFragment = (TaskFragment) fm.findFragmentByTag(TAG_TASK_FRAGMENT)
. If the fragment is not present it is created now: mTaskFragment = new TaskFragment()
. And on button click it is started: mTaskFragment.start()
.
Full code can be seen here.
Persistence
For complex data persistence usually, SQLite is used.
There are a couple of ways how to operate it:
- Using a content provider.
- Using an ORM like GreenDAO.
Using ORM (GreenDAO)
This is the simplest way to operate SQLite database, although, there are some caveats to be aware of:
- Migrations are difficult. It is not trivial to write a migration that, e.g. adds one column. GreenDAO approach would be to drop the database and recreate the schema (simple as increasing one number). But this has caveats too like forgetting to increase that number or unable to handle a situation where data that MUST be present is actually not present (when e.g. a logged in user has his data erased, the data has to be fetched once again or the user has to be logged out).
- Asynchronous query loading has to be implemented separately using something like async tasks. With content providers, this can be done using loaders.
- UI is not updated when DB data changes. This can be done with content providers and needs to be implemented manually with ORMs like GreenDAO.
The benefits of using and ORM are:
- There is usually no need to deal with SQL (only when there is a need for a very complex query).
- Query results consist of a list of simple objects (not query rows).
- Less boilerplate code.
Using a Content Provider
This is what Android guides suggest but it has a number of disadvantages:
- A lot of boilerplate.
- Need to write SQL.
- Results are cursors to SQL rows and not plain old objects.
The benefits of using a content provider:
- It is ‘the Android way’.
- Simple solutions to update UI when DB data changes.
- Ability to use loaders for asynchronous queries.
So usually using an ORM will be the preferred way because it is a solution that requires less work to get started. Only apps that need to provide data to other apps need to use content providers or apps that want to stick with ‘the Android way’ which has above-mentioned benefits.
Before Deployment
Before deployment, it is not enough to simply test the app. It is as equally as important to test the update process itself because there are a lot of times when updating an app fails. Imagine a situation where a new column has been added. If GreenDAO is used (so there are problems with migrations) then after updating a column all of the data will be erased. If the app expects that some data is always present like email of a logged in user, then the app will crash for users that are logged in. So not only these cases should be handled but tested too.
Steps to test the update process:
- Push the app to Testing environment (Alpha or Beta).
- Build a production .APK with the current app (latest production release).
- Install the .APK to phone for testing.
- Upgrade installed app to newest app in Testing (it is possible to do that for people in the list of testers).
The update process should be tested in all of the main situations (like logged in users and logged out).
Data Migrations
Data migrations could be done in one of the several ways:
- Recreating the tables and refetching the data every time there are changes to DB schema.
- Writing data migration files.
Recreating The Tables And Refetching The Data Every Time There Are Changes To DB Schema
This is the simplest and usually preferred solution. It is enough to make changes as if the DB is created for the first time and increase the schema version so that Android would run it automatically after the update. It is even simpler when there is no necessity to fetch the data automatically after deletion, e.g. the user can be logged out and when he logs in again the data will be fetched as per usual login.
Writing Data Migration Files
This is usually a complex process. And is not very well documented for ORMs like GreenDAO. So it is usually not worth the effort and it is best to stick with the first option.
Application Architecture
There are some general tips for an app architecture:
- Structure app by activities. This means that all of the code that is used in an activity (like adapters, fragments, async tasks and etc.) should be located inside a folder named after the activity, e.g.
login_activity
. - All of the code that needs to be shared between activities should be placed in a shared folder (as in the same level package as the activities). So there might be three folders:
login_activity
,survey_activity
andshared
. - The code inside
_activity
andshared
folders might be further categorized (if there is a need) to subfolders likeadapters
,tasks
,services
,dialogs
and etc. There might even be ashared
folder inside the_activity
folder to share data between activity code.
Translations
Tips for writing translations in Android:
- Scope translations by activity name. So the key for login activity title would be
login_activity_title
. Translations can further be scoped by fragment or some other part of the activity likelogin_activity_panel_fragment_header
. This way all of the translations for one activity would be in the same place. - Use shared translations to define translations that are used in multiple activities. These translations are not scoped and put in one place like at the top of translations file. Those translations might be used directly but sometimes it is a better idea to use references. By using references it becomes much easier to change the translation in a specific activity: just replace the reference with the new translation.
An example of referencing a shared translation might look like this:
<?xml version='1.0' encoding='UTF-8'?>
<resources>
<!-- Shared strings -->
<string name="ok">OK</string>
<!-- SurveyActivity strings -->
<string name="survey_activity_privacy_policy_dialog_positive">@string/ok</string>
<!--LoginActivity strings-->
<string name="login_activity_first_run_tos_dialog_positive">@string/ok</string>
</resources>
So it is always easy to change survey activity or login activity dialog positive button text simply by removing the reference and writing a custom translation. There is no need to modify the shared translation or create a separate one.
Video Activity In App
Implementing a video activity in Android can be a challenge (biggest problem - flickering issue). This is why there need to be some guidelines on how to do it. A VideoActivity might look like this:
public class VideoActivity extends BaseActivity implements MediaPlayer.OnCompletionListener {
public static final String TAG = VideoActivity.class.getSimpleName();
public static final String EXTRA_VIDEO_ID_KEY = TAG + "-EXTRA_VIDEO_ID";
public static final String CURRENT_POSITION_KEY = TAG + "-CURRENT_POSITION";
public static final String VIDEO_IS_PLAYING_KEY = TAG + "-VIDEO_IS_PLAYING";
private int mCurrentPosition = 0;
private boolean mVideoIsPlaying = true;
private VideoView mVideoView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_video);
setupVideo(savedInstanceState);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home:
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
protected void onPause() {
mCurrentPosition = mVideoView.getCurrentPosition();
mVideoIsPlaying = mVideoView.isPlaying();
mVideoView.pause();
mVideoView.setBackgroundResource(R.color.video_background_idle);
super.onPause();
}
@Override
protected void onResume() {
super.onResume();
mVideoView.resume();
}
@Override
protected void onSaveInstanceState(Bundle outState) {
outState.putInt(CURRENT_POSITION_KEY, mCurrentPosition);
outState.putBoolean(VIDEO_IS_PLAYING_KEY, mVideoIsPlaying);
super.onSaveInstanceState(outState);
}
private void setupVideo(Bundle savedInstanceState) {
if (savedInstanceState != null) {
mCurrentPosition = savedInstanceState.getInt(CURRENT_POSITION_KEY);
mVideoIsPlaying = savedInstanceState.getBoolean(VIDEO_IS_PLAYING_KEY);
}
Intent intent = getIntent();
Long videoId = intent.getLongExtra(EXTRA_VIDEO_ID_KEY, -1L);
mVideoView = (VideoView) findViewById(R.id.video);
mVideoView.setMediaController(new MediaController(this));
mVideoView.setOnCompletionListener(this);
// Set the video on the video view; could use either and existing file or a URL
setVideo(videoId, mVideoView);
mVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mp) {
mVideoView.seekTo(mCurrentPosition);
if (mVideoIsPlaying) {
mVideoView.start();
}
mVideoView.postDelayed(new Runnable() {
@Override
public void run() {
mVideoView.setBackgroundResource(0);
}
}, 666);
}
});
}
@Override
public void onCompletion(MediaPlayer v) {
finish();
}
}
The layout ‘activity_video.xml’ might look like this:
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/video_background_idle">
<VideoView
android:id="@+id/video"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:background="@color/video_background_idle" />
</FrameLayout>
The video background idle color in colors.xml is defined like this:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="video_background_idle">#ffffff</color>
</resources>
The only unclear part would be how setVideo(videoId, mVideoView)
might work. It could work by setting the absolute path of the video:
public void setVideo(int videoId, VideoView videoView) {
String videoFileName = ...; // calculated using videoId; something like 'video.mp4'
videoView.setVideoPath(Application.get().getFilesDir().getAbsolutePath() + "/" + videoFileName);
}
or it could work by setting the video URI:
public void setVideo(int videoId, VideoView videoView) {
String videoURL = ...; // calculated using videoId; something like 'http://www.test.com/video.mp4'
videoView.setVideoURI(Uri.parse(videoURL));
}
Finally, orientation changes should be ignored so add this to ‘AndroidManifest.xml’:
<activity
android:name="VideoActivity"
...
android:configChanges="orientation|screenSize" />
Main aspects that should be noted:
- A video overlay (R.color.video_background_idle) is used to hide video flickering when it is loading. It is removed only after 666 ms after the video becomes prepared.
- Video is paused
onPause
and resumedonResume
. - Current video position (mCurrentPosition) and current video state (mVideoIsPlaying) are persisted and restored during fragment lifecycle.
Another way to open a video is to use a dedicated app (e.g., using ‘open with’). See Open With Another App.
Open With Another App
Problem
The video or other media file is stored inside the app’s private storage and the lack of permissions will prevent other apps from opening that file.
Solutions
There are two options:
- Make those files ‘Context.MODE_WORLD_READABLE’ (which is deprecated but good for diagnosing whether it is a permission problem).
- Use a FileProvider to implement file sharing.
So the second solution might look like this:
File path = Application.get().getFilesDir();
File file = new File(path, videoFileName);
Uri contentUri = FileProvider.getUriForFile(mContext, "com.example.fileprovider", file);
Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW);
intent.setDataAndType(contentUri, "video/mp4");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
mContext.startActivity(intent);
Communicating With The Activity
Problem
There is a need for a fragment to communicate with the activity so it could respond to some kind of events.
Solution
One way would be to retrieve the activity, typecast it to a concrete class and call specific methods but this solution would not scale very well when the same fragment will be used in a different activity. The best solution would be to use an interface, which will have to be implemented by the hosting activity. Fragment code might look like this:
public class SomeFragment extends Fragment {
OnSomeEventListener mCallback;
// Hosting Activity must implement this interface
public interface OnSomeEventListener {
public void onSomeEvent();
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
// This makes sure that the container activity has implemented
// the callback interface. If not, it throws an exception
try {
mCallback = (OnSomeEventListener) activity;
} catch (ClassCastException e) {
throw new ClassCastException(activity.toString()
+ " must implement OnSomeEventListener");
}
}
...
}
So a fragment calls onSomeEvent()
when it wants to notify the activity:
mCallback.onSomeEvent();
Hosting activity implements the interface:
public static class MainActivity extends Activity
implements SomeFragment.OnSomeEventListener {
...
public void onSomeEvent() {
// Do something
}
}
Communicating between fragments is quite simple too. See here.
Master/Detail For Multilevel List
Problem
There needs to be a Master/Detail flow for a multilevel list. Although, there are a couple of ways how to do it and both have their advantages and disadvantages.
Solutions
There are two main solutions for this:
- Pressing a list item in master pane opens lower level list in detail pane.
- Pressing a list item in master pane opens lower level list in master pane.
Pressing a List Item In Master Pane Opens Lower Level List In Detail Pane
So the pressed item in master pane opens a lower level list in detail pane and that pressed item becomes selected in master pane. Pressing a list item in detail pane should move the whole detail pane to master pane and the detail pane should show contents of the pressed item (either a lower level list or main content). This transition happens by opening a new activity, which has a back button in the toolbar with the name of the category selected inside the detail pane.
It is somewhat difficult to implement this kind of navigation especially because there is a need to preserve selected items while navigating back and forth. Also, it might be a bit confusing for the users.
Pressing a List Item In Master Pane Opens Lower Level List In Master Pane
After pressing an item in master pane, the list fragment is replaced with a lower level list of that item. If the pressed item does not resolve in a list then the contents of that item are shown in the detail pane. So the detail pane is filled only when a list item does not point to another list but a concrete item.
Since traversal of the multilevel list happens by swapping fragments and not opening new activities it might be difficult to implement navigation to the previous list.
Finally, for a two-level list it might be a better idea to use an ExpandableListView.