Simpler Android apps with Flow and Mortar

January 23, 2014

Doctor, it hurts when I go like that.

Don't go like that!

Henny Youngman

When Fragments were introduced to Android the Square Register team jumped on board. We were already chopping activities into subscreens in a clumsy way, and we did ourselves a lot of good moving to the new hotness. But we also bought ourselves a lot of headaches: default constructors only, offscreen fragments mysteriously being brought back to life at odd moments, no direct control over animation, an opaque back stack manager — to use Fragments well required mastering an increasingly arcane folklore.

So now we do something else, and we'd like to share a couple of new libraries that help us do it. We use Flow to keep track of what View to show when. Yes, we just use View instances directly as the building blocks of our apps. It's okay because the views stay simple (and testable) by injecting all their smarts from tidy little scopes defined with Mortar.

Flow: URLs for your app

The first new toy, Flow, is a backstack model that knows you likely have to deal both with the device's back button as well as an ActionBar's up button. The things that Flow manages are referred to as "screens," with a lowercase "S" — they're not instances of any particular class. To Flow a screen is a POJO that describes a particular location in your app. Think of it as a URL, or an informal Intent.

For example, imagine a music player app, with Album and Track screens:

@Screen(layout = R.layout.album_view)
class AlbumScreen {
  public final int id;
  public AlbumScreen(int id) { this.id = id; }
}

@Screen(layout = R.layout.track_view)
class TrackScreen implements HasParent<AlbumScreen> {
  public final int albumId;
  public final int trackId;

  public TrackScreen(int albumId, int trackId) {
    this.albumId = albumId;
    this.trackId = trackId;
  }

  @Override AlbumScreen getParent() {
    return new AlbumScreen(albumId);
  }
}

The two screen types provide just enough information to rummage around for the model classes that have the actual album and track info. The optional HasParent interface implemented by the TrackScreen here declares what should happen when the up button is tapped. Moving to the right track screen from a list on the album screen is as easy as:

setOnItemClickListener(new OnItemClickListener() {
  @Override public void onItemClick(AdapterView<?> parent, View view, 
      int position, long id) {
    flow.goTo(new Track(albumId, position));
  }
});

Going back or up are just as simple, flow.goBack() or flow.goUp().

So what happens on-screen when you call these methods? You decide. While Flow provides the @Screen annotation as a convenience for instantiating the view to show for a particular screen, actually displaying the thing is up to you. A really simple Activity might implement the Flow.Listener interface this way:

@Override public void go(Backstack backstack, Direction direction) {
  Object screen = backstack.current().getScreen();
  setContentView(Screens.createView(this, screen));
}

It shouldn't take a lot of imagination to see how to embellish this with animation based on the given Direction (FORWARD or BACK).

Mortar: Blueprints for each of those URLs

If Flow tells you where to go, Mortar tells you what to build when you get there.

Major views in our apps use Dagger to inject all their interesting parts. One of the best tricks we've found is to create @Singleton controllers for them. Configuration change? No problem. The landscape version of your view will inject the same controller instance that the portrait version was just using, making continuity a breeze. But all those controllers for all those views live forever, occupying precious memory long after the views they manage have been torn down. And just how can they get their hands on the activity's persistence bundle to survive process death?

Mortar solves both of these problems. Each section of a Mortar app (each screen if you're also using Flow) is defined by a Blueprint with its very own module. And the thing most commonly provided is a singleton Presenter, a view controller with a simple lifecycle and its own persistence bundle.

Going back to our music player example, using Mortar the AlbumScreen might look something like this:

@Screen(layout = R.layout.ablum_view)
class AlbumScreen implements Blueprint {
  final int id;
  public AlbumScreen(int albumId) { this.id = albumId; }

  @Override String getMortarScopeName() {
    return getClass().getName();
  }

  @Override Object getDaggerModule() {
    return new Module();
  }

  @dagger.Module(addsTo = AppModule.class)
  class Module {
    @Provides Album provideAlbum(JukeBox jukebox) {
      return jukebox.getAlbum(albumId);
    }
  }
}

We're imagining here an app-wide JukeBox service that provides access to Album model objects. See that @Provides Album method at the bottom? That's a factory method that will let the AlbumView inflated from R.layout.album_view simply @Inject Album album directly, without messing with int ids and the like.

public class AlbumView extends FrameLayout {
  @Inject Album album;

  public AlbumView(Context context, AttributeSet attrs) {
    super(context, attrs);
    Mortar.inject(context, this);
  }

  // ...
}

To take the example further, suppose the AlbumView is starting to get more complicated. We want it to edit metadata like the album name, and of course we don't want to lose unsaved edits when our app goes to the background. It's time to move the increasing smarts out of the view and over to a Presenter. Let's keep the Android view concerned with Android-specific tasks like layout and event handling, and keep our app logic cleanly separated (and testable!).

@Screen(layout = R.layout.ablum_view)
class AlbumScreen implements Blueprint {
  final int id;

  public AlbumScreen(int albumId) { this.id = albumId; }

  @Override String getMortarScopeName() {
    return getClass().getName();
  }

  @Override Object getDaggerModule() {
    return new Module();
  }

  @dagger.Module(addsTo = AppModule.class)
  class Module {
    @Provides Album provideAlbum(JukeBox jukebox) {
      return jukebox.getAlbum(albumId);
    }
  }

  @Singleton Presenter extends ViewPresenter<AlbumView> {
    private final Album album;

    @Inject Presenter(Album album) { this.album = album; }

    @Override onLoad(Bundle savedState) {
      super.onLoad(savedState);
      AlbumView view = getView();
      if (view != null) {
        view.setAlbumName(album.getName());
        view.setEditedName(savedState.getString("name-in-progress"));
      }
    }

    @Override onSave(Bundle outState) {
      super.onSave(outState);
      outState.putString("name-in-progress", getView().getEditedName());
    }

    void onSaveClicked() {
      album.setName(getView().getEditedName());
      view.clearEditedName();
    }
  }
}

The view will now look something like this (in part). Notice how the AlbumView lets the presenter know when it's actually in play, through overrides of onAttachedToWindow and onDetachedFromWindow.

public class AlbumView extends FrameLayout {
  @Inject AlbumScreen.Presenter presenter;

  private final TextView newNameView;

  public AlbumView(Context context, AttributeSet attrs) {
    super(context, attrs);
    Mortar.inject(context, this);

    this.newNameView = (TextView) findViewById(R.id.new_name)

    findViewById(R.id.save_button).setOnClickListener(new OnClickListener() {
      public void onClick(View view) {
       presenter.onSaveClicked();
      }
    });
  }

  @Override protected void onAttachedToWindow() {
    super.onAttachedToWindow();
    presenter.takeView(this);
  }

  @Override protected void onDetachedFromWindow() {
    super.onDetachedFromWindow();
    presenter.dropView(this);
  }

  // ...
}

Because AlbumScreen.Presenter is scoped to just this screen, we have confidence that it will be gc'd when we go elsewhere. The AlbumScreen class itself serves as a self-contained, readable definition about this section of the app. And, do you see those onLoad and onSave methods on the Presenter? Those are the entire Mortar lifecycle. We just haven't found a need for anything more.

It works for us

So that's how we're doing it these days, and life is pretty good. Flow and Mortar are both still taking shape, though — hopefully with help from you.

Ray Ryan
Opinionated Android developer at Square.

Comments

Get support help at squareup.com/support. We'll delete off-topic comments.