Exploring Conductor — Android App Development without Fragments (Part 1)

A typical Android app usually has one or more activities and a number of fragments sitting within those activities. Fragments are generally used to provide back stack manipulation and the ability of creating reusable UI (a fragment can be embedded in different activities) and more flexible UI (multiple fragments can be placed within a single activity, which is particularly useful in landscape mode or tablets).

However, it is also well-known in the Android community that fragments are usually sources of many unwanted issues. Many Android developers, e.g. Square in their phenomenal article Advocating Against Android Fragments, pointed out the following key problems with fragments: overly complicated life-cycle, asynchronous fragment transaction, and the confusion between using fragments vs. using custom views since in most cases, UIs built with fragments can be alternatively built with custom views which are much simpler than fragments. To many developers, avoiding fragments is a step towards achieving simplicity and stability for their apps.

Conductor is a recent attempt to provide a framework that allows building view-based Android apps and thus completely avoiding the use of fragments. In this series of posts, I am going to show you how Conductor is used.

This first part of the series provides a discussion on Conductor’s components, its comparison with Fragments, and a demo showing how it is used to build Android apps.

Conductor and its components

Controller: Controllers can be considered fragment-equivalence in Conductor. Each controller is a wrapper of its view. Whenever a view needs to be pushed into an activity, a controller is required. Similar to fragments, controllers have their own lifecycle and it is significantly simpler and predictable than fragments’ lifecycle.

Router: Routers are responsible for back stack management in Conductor. Router objects are attached to Activity/containing ViewGroup pairs. Each router holds references to a list of controllers in the order that they are pushed into the back stack.

ControllerChangeHandler: ControllerChangeHandler is responsible for rendering the view change whenever a controller is pushed into or popped from a Router. By default, Conductor uses SimpleSwapChangeHandler which instantly swaps views without any animation. Conductor also provides a number of custom ControllerChangeHandlers that support common change animation such as sliding or fading. You can create your own custom ControllerChangeHandler to provide your desired animations. This will be covered in the second and third part of this series.

RouterTransaction: RouterTransaction is used to define data about adding Controllers into a Router . Each RouterTransaction is defined with a compulsory Controller object and optionally with a pushControllerChangeHandler and a popControllerChangeHandler.

Controller vs. Fragment

Lifecycle

Controller’s lifecycle (taken from https://github.com/bluelinelabs/Conductor)

As can be seen from the diagram, controllers’ lifecycle is much simpler compared to that of fragments (interested readers can view a full fragment’s lifecycle diagram created by Square here). Whenever a Controller is pushed into a Router, its constructor is called if it does not exist. onCreateView is then invoked to create a view to be displayed. onAttach is then called when the controller is attached to its ViewGroup. Whenever the current Controller is popped from the back stack or a new Controller is pushed in, or the host activity is stopped, the controller is detached from its host ViewGroup and thus onDetach callback is made. By default, Conductor releases the Controller’s view reference after it is detached and thus the onDestroyView callback is then made.

Controllers can be set to retain their views after they are detached. This is useful for controllers with view hierarchies that are expensive to tear down and rebuild. In that case, onDestroyView would not be called after onDetach and thus onCreateView would also not be called if the controller was brought back to front again.

Asynchronicity. Unlike FragmentTransaction, controller transactions are executed immediately. This eliminates the chance of having unknown states in your apps.

Controllers are retained during orientation changes, which is similar to Fragment.setRetainInstance(true). This is extremely useful in case a device is rotated (and thus the current view is destroyed) while a long operation (e.g., a network request) is running in the background to obtain data for the current view. In such cases, since the controller is not destroyed, it offers you a great chance to continue tracking the operation’s progress and update the view when the result becomes available. For instance, if MVP is used, a presenter, which can be used to track the operation and update the view, can be created as an instance variable of the controller and consequently survive orientation changes.

It’s now time to code!

In this app, we are going to have one Activity and 2 Controllers, each controller for one screen. For readability reasons, I only briefly include code that directly shows how Conductor is used in the app. I also assume readers are familiar with how Android works and basic concepts like RecyclerView, ViewHolder, Adapter. The complete source code of the demo can be found here.

Activity

<android.support.design.widget.CoordinatorLayout
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"
android:fitsSystemWindows="true">

<android.support.design.widget.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"/>

</android.support.design.widget.AppBarLayout>

<com.bluelinelabs.conductor.ChangeHandlerFrameLayout
android:id="@+id/controller_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

</android.support.design.widget.CoordinatorLayout>

There is nothing special with the XML layout code except that I use Conductor’s ChangeHandlerFrameLayout to create the container view group which the Controllers are attached to. ChangeHandlerFrameLayout prevents users from interacting with the UI when the Controllers are being changed (i.e., when a controller is pushed/popped from the back stack). This is particularly important when we perform controller change animations.

The Activity's JAVA code looks like this:

MainActivity.java...
private Router router;
@BindView(R.id.controller_container)
ViewGroup container;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ButterKnife.bind(this);

router = Conductor
.attachRouter(this, container, savedInstanceState);

if (!router.hasRootController()) {
router.setRoot(RouterTransaction
.with(new CountryGridController()));
}

}

@Override
public void onBackPressed() {
if (!router.handleBack()) {
super.onBackPressed();
}

}
...

Router is a compulsory component that enables Conductor to works. Every Activity needs a Router to handle Controllers and manage back stack. Routers survive orientation changes so once created, they are kept in the memory until their host activities are intentionally destroyed by users or developers (e.g., back press, finish() call), or by Android system when reclaiming memory.

In the code above, Conductor.attachRouter(this, container, savedInstanceState) is called within Activity.onCreate to get an existing Router instance if there is one, or if not, create a fresh Router instance and restore the previously destroyed Router instance’s state if any. A Router’s state data contains a back stack which then contains Controllers and their associated ControllerChangeHandler instances.

When a Router instance is freshly created with an empty back stack, it does not have a root controller (the first controller in the back stack) and therefore, a CountryGridController is created and assigned as the root controller. This means by default the app shows the country grid when it is started. When it is restored from the previously saved state, the top Controller in the restored back stack is shown. This allows the app remains at the same screen where the user was previously at.

Country Grid Screen

protected View onCreateView(@NonNull LayoutInflater inflater, 
@NonNull ViewGroup container) {
return inflater.inflate(R.layout.country_grid,
container, false);
}

and the Controller has direct access to the view’s components (e.g., Button, TextView). I however prefer keeping View and its components out of Controller by creating a View subclass for the following 2 key reasons:

  • Having a separate View class makes it easier to implement custom ControllerChangeHandlers which are specific to that View. For example, that allows us to access specific components in the view to do animations (e.g., move down a certain ImageView, scale up a FAB). This will be seen clearly in part 2 and 3 of this series.
  • Keep Views out of Controllers improves separation of concern. A Controller, which is responsible for interacting with Router and listening to Activity's lifecycle, should not know and have access to View's components.

Below is how CountryGridView and its XML layout looks like.

country_grid.xml
<net.huannguyen.conductorexample.countrygrid.CountryGridView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
CountryGridView.java
public class CountryGridView extends RecyclerView {

private GridEventHandler eventHandler;

// Assume a list of countries that has already been obtained.
// Data skipped for readability
private static final List<Country> COUNTRIES = .....

public CountryGridView(Context context, AttributeSet attrs) {
super(context, attrs);
setLayoutManager(new GridLayoutManager(getContext(), 2);
setAdapter(new CountryAdapter(COUNTRIES));
}

private void onCountryClicked(Country country) {
eventHandler.onCountryClicked(country);
}

public void setEventHandler(GridEventHandler eventHandler) {
this.eventHandler = eventHandler;
}

private class CountryAdapter extends Adapter<CountryViewHolder> {
... // implementation details skipped for readability
}

class CountryViewHolder extends ViewHolder {
... // implementation details skipped for readability
// each viewholder's itemView has a flag ImageView and
// and a country name TextView.
// CountryGridView.onCountryClicked() is called whenever
// the flag ImageView is clicked.
}
}
GridEventHandler.java
public interface GridEventHandler {
void onCountryClicked(@NonNull Country country);
}

CountryGridView has to extend RecyclerView since we need a grid to display countries. CountryGridView delegates the click event on each country’s flag to a GridEventHandler which is implemented by CountryGridController. Note: A presenter would be more appropriate to handle click events in this case (if MVP is used). However I prefer keeping the code simple for demo purposes and thus do it with CountryGridController instead. The Controller code now looks like:

CountryGridController.java
public class CountryGridController extends Controller
implements GridEventHandler {

@Override
protected View onCreateView(@NonNull LayoutInflater inflater,
@NonNull ViewGroup container) {
CountryGridView view = (CountryGridView)
inflater.inflate(R.layout.country_grid, container, false);
view.setEventHandler(this);
return view;
}

@Override
public void onCountryClicked(@NonNull Country country) {
getRouter().pushController(RouterTransaction
.with(new CountryDetailController(country))
.pushChangeHandler(new FadeChangeHandler())
.popChangeHandler(new FadeChangeHandler()));
}
...
}

As seen above, Router.pushController(RouterTransaction) is used to push a new Controller into the back stack. RouterTransaction.pushChangeHandler(ControllerChangeHandler) and RouterTransaction.popChangeHandler(ControllerChangeHandler) specify the change handlers for pushing a controller into or popping a controller from a back stack, respectively. I used FadeChangeHandler for both in this example, which fades out the current Controller's view and then fades in the new Controller's view.

Country Detail Screen

To support the state saving and restoring, Controller offers a constructor that takes a Bundle as its parameter. This Bundle is saved (together with other data like back stack, controller change handlers) when the activity is destroyed by the system, and restored when the activity is recreated. Therefore, the implementation of CountryDetailController looks like this (the implementation of Country which implements Parcelable is skipped):

CountryDetailController.java
public class CountryDetailController extends Controller {

private static final String KEY_COUNTRY = "KEY_COUNTRY";

private Country country = getArgs().getParcelable(KEY_COUNTRY);

public CountryDetailController(Country country) {
this(new BundleBuilder(new Bundle())
.putParcelable(KEY_COUNTRY, country)
.build());
}

public CountryDetailController(Bundle args) {
super(args);
}
...
}
BundleBuilder.java
public class BundleBuilder {
private final Bundle bundle;

public BundleBuilder(Bundle bundle) {
this.bundle = bundle;
}

public BundleBuilder putParcelable(String key, Parcelable value) {
bundle.putParcelable(key, value);
return this;
}

public Bundle build() {
return bundle;
}
}

That’s it! We’ve now got an app that fully works using Conductor.

Final Words

I also highly recommend you to go through Conductor’s source code for better understanding of the framework. It is a pretty small yet interesting repo to read and learn from. Hope you enjoy playing with Conductor and stay tuned for the next parts of the series about ControllerChangeHandler!

This post is part of a blog series on Conductor, the table of content as follows:

Android Developer & UX enthusiast