-
Notifications
You must be signed in to change notification settings - Fork 907
Android Tutorial 3
Note: If you haven't completed tutorial 2 yet, we encourage you to do so before jumping into this tutorial.
Welcome to the RIBs tutorials, which have been designed to give you a hands-on walkthrough through the core concepts of RIBs. As part of the tutorials, you'll be building a simple TicTacToe game using the RIBs architecture and associated tooling.
For tutorial 3, we'll start off where tutorial 2 ended. You can either continue with the project you completed in tutorial 2, or use the source code here. Follow the README to install and open the project before reading any further.
The goals of this tutorial are to learn the following:
- Pass dependencies from a parent RIB to a child RIB.
- Pass data from a parent RIB to a child RIB via a stream.
- Understand how to properly unsubscribe from streams.
- Reinforce unit testing concepts.
This tutorial will focus on adding two new features to the TicTacToe app:
- Adding the ability to propagate the name entered by the player when logging in (currently the game just displays
FixedName
). - Add the concept of scores to track state across multiple games.
To do this, player names will be passed into the build method of the LoggedInBuilder
and the names will be available as a dependency for LoggedIn
RIB and its children.
To keep track of scores, a new score stream will be added to the LoggedIn
scope. Games will signal game state changes via a listener pattern.
When done this tutorial the application will contain the following new behavior:
- Actual player names will be displayed instead of dummy data.
- When a player wins a game of tic tac toe, the app will route back to the
OffGame
screen. - The
OffGame
screen will now display a win count for each player.
You'll be writing this code inside the tutorial3 module.
The LoggedInBuilder
should receive this player names as dynamic dependencies in its build method. These dependencies are passed in via the build method because they don't exist in the parent component and are ephemeral objects that only exist for a short period of time.
Changes to the LoggedInBuilder
:
public LoggedInRouter build(String playerOne, String playerTwo) {
LoggedInInteractor interactor = new LoggedInInteractor();
Component component = DaggerLoggedInBuilder_Component.builder()
.parentComponent(getDependency())
.interactor(interactor)
.build();
return component.loggedinRouter();
}
In addition to passing in the player names, we must add an entry to the Dagger component builder for the LoggedIn
RIB. This allows the LoggedIn
RIB and any of its children to inject the player names.
New methods added to the module in the LoggedInBuilder
's component builder:
@BindsInstance
Builder playerOne(@Named("player_one") String playerOne);
@BindsInstance
Builder playerTwo(@Named("player_two") String playerTwo);
NOTE: These dependencies are provided using the new
BindsInstance
Dagger API. It's similar to using @Provides, but allows us to not have to pass the player names into the module via its constructor.
Now that Dagger knows about the new dependencies, we must go back and update the build method again to bind the variables:
@NonNull
public LoggedInRouter build(String playerOne, String playerTwo) {
LoggedInInteractor interactor = new LoggedInInteractor();
Component component = DaggerLoggedInBuilder_Component.builder()
.parentComponent(getDependency())
.interactor(interactor)
.playerOne(playerOne)
.playerTwo(playerTwo)
.build();
return component.loggedinRouter();
}
With these changes, the player names are now available as a dependency in the LoggedIn
RIB. However, the app won't compile until we provide the names to the build method.
Next, we must pass the player names into the LoggedInBuilder
's build method. To do this, the attachLoggedIn
method in RootRouter
must be updated to receive player names as parameters:
void attachLoggedIn(String playerOne, String playerTwo) {
// No need to attach views in any way.
attachChild(loggedInBuilder.build(playerOne, playerTwo));
}
Next, we'll pass the player names to the router method when the user hits the login button. To do this, we'll update the call to attachLoggedIn
in RootInteractor
:
class LoggedInListener implements LoggedOutInteractor.Listener {
@Override
public void requestLogin(String playerOne, String playerTwo) {
getRouter().detachLoggedOut();
getRouter().attachLoggedIn(playerOne, playerTwo);
}
}
Now that all the DI plumbing is done, the app should compile again.
Now that the name
dependency is wired up, we can use the data in the LoggedIn
RIB and its children. Let's use the name data to display the names on the screen when in the OffGame
RIB.
Since the OffGame
RIB is a child of the LoggedIn
RIB, it can declare that it needs the player name dependencies in its ParentComponent
:
public interface ParentComponent {
@Named("player_one") String playerOne();
@Named("player_two") String playerTwo();
OffGameInteractor.Listener offGameListener();
}
Next, we'll update the OffGamePresenter
interface to add a new method to pass in player names:
interface OffGamePresenter {
void setPlayerNames(String playerOne, String playerTwo);
Observable<Irrelevant> startGameRequest();
}
Next, implement the new presenter method in OffGameView
.
Lastly, we'll want to inject the names into the OffGameInteractor
:
@Inject @Named("player_one") String playerOne;
@Inject @Named("player_two") String playerTwo;
And call the new presenter method in didBecomeActive
:
@Override
protected void didBecomeActive(@Nullable Bundle savedInstanceState) {
super.didBecomeActive(savedInstanceState);
presenter.setPlayerNames(playerOne, playerTwo);
...
}
Now, running the app should show the correct player names when in the off game state:
Next, we'll add a new ScoreStream
class, this will be owned by the LoggedIn
RIB and will be used to emit a map of UserName
objects to scores. The OffGame
RIB can then observe this stream and display a win count for each player.
To get started, we'll make a new class in the logged_in
package called MutableScoreStream
. This class will hold the current scores, and have methods to update the stored value:
class MutableScoreStream implements ScoreStream {
private final BehaviorRelay<ImmutableMap<String, Integer>> scoresRelay = BehaviorRelay.create();
MutableScoreStream(String playerOne, String playerTwo) {
scoresRelay.accept(ImmutableMap.of(playerOne, 0, playerTwo, 0));
}
void addVictory(String userName) {
ImmutableMap<String, Integer> currentScores = scoresRelay.getValue();
ImmutableMap.Builder<String, Integer> newScoreMapBuilder = new ImmutableMap.Builder<>();
for (Map.Entry<String, Integer> entry : currentScores.entrySet()) {
if (entry.getKey().equals(userName)) {
newScoreMapBuilder.put(entry.getKey(), entry.getValue() + 1);
} else {
newScoreMapBuilder.put(entry.getKey(), entry.getValue());
}
}
scoresRelay.accept(newScoreMapBuilder.build());
}
}
A few things worth noting here:
Each time the score is updated, a new copy of the score map is emitted (as opposed to mutating the existing map and emitting it again).
This class is intentionally package-private, since only the LoggedIn
RIB should know about it.
Now that we have our MutableScoreStream
, we'll want to create an immutable version that child RIBs can observe. We'll call this just ScoreStream
:
public interface ScoreStream {
Observable<ImmutableMap<String, Integer>> scores();
}
Next, we'll circle back to the MutableScoreStream
and have it implement ScoreStream
:
class MutableScoreStream implements ScoreStream {
private final BehaviorRelay<ImmutableMap<String, Integer>> scoresRelay = BehaviorRelay.create();
MutableScoreStream(String playerOne, String playerTwo) {
scoresRelay.accept(ImmutableMap.of(playerOne, 0, playerTwo, 0));
}
void addVictory(String userName) {
ImmutableMap<String, Integer> currentScores = scoresRelay.getValue();
ImmutableMap.Builder<String, Integer> newScoreMapBuilder = new ImmutableMap.Builder<>();
for (Map.Entry<String, Integer> entry : currentScores.entrySet()) {
if (entry.getKey().equals(userName)) {
newScoreMapBuilder.put(entry.getKey(), entry.getValue() + 1);
} else {
newScoreMapBuilder.put(entry.getKey(), entry.getValue());
}
}
scoresRelay.accept(newScoreMapBuilder.build());
}
@Override
public Observable<ImmutableMap<String, Integer>> scores() {
return scoresRelay.hide();
}
}
Now that we have all the required classes and interfaces, we'll need to provide them. Since the MutableScoreStream
is owned by the LoggedIn
RIB, a new provider needs to be added to the LoggedInBuilder
:
@LoggedInScope
@LoggedInInternal
@Provides
static MutableScoreStream mutableScoreStream(
@Named("player_one") String playerOne,
@Named("player_two") String playerTwo) {
return new MutableScoreStream(playerOne, playerTwo);
}
It's worth pointing out the @LoggedInInternal
qualifier - this a Dagger qualifier that is generated for free with every RIB when using the IntelliJ template. It's a package-private qualifier to prevent child RIBs from listing the MutableScoreStream
in their ParentComponent
.
Also worth noting, using @LoggedInScope
ensures that the score stream is a singleton for the logged in RIB and all of its children.
Now that the mutable class is on the dependency graph, we can also add a provider for the immutable ScoreStream
interface (which child RIBs are allowed to use) in the module in the LoggedInBuilder
:
@Binds
abstract ScoreStream scoreStream(@LoggedInInternal MutableScoreStream mutableScoreStream);
If you haven't seen @Binds
before, this is just shorthand for creating an @Provides
method that takes the MutableScoreStream
and returns a ScoreStream
.
Step 3: Subscribing to the score stream in the OffGame scope
Now that the score stream is wired up, we can subscribe to it in the OffGame
RIB.
First, we must add a dependency to the OffGame
RIB's parent component:
public interface ParentComponent {
@Named("player_one") String playerOne();
@Named("player_two") String playerTwo();
OffGameInteractor.Listener listener();
ScoreStream scoreStream();
}
Now we can inject the ScoreStream
into the OffGameInteractor
, but before we do that, we need to add a new presenter API to pass the scores to the views:
interface OffGamePresenter {
void setPlayerNames(String playerOne, String playerTwo);
void setScores(Integer playerOneScore, Integer playerTwoScore);
Observable<Object> startGameRequest();
}
Next, implement the new API in the OffGameView
to update the displayed scores (there are two UTextView
fields in OffGame
to hold the scores - playerOneScore
and playerTwoScore
).
Now that the presenter and view have been updated, we can subscribe to the stream in the OffGameInteractor
. First, we must inject the stream into the OffGameInteractor
:
@RibInteractor
public class OffGameInteractor
extends Interactor<OffGameInteractor.OffGamePresenter, OffGameRouter> {
@Inject @Named("player_one") String playerOne;
@Inject @Named("player_two") String playerTwo;
@Inject Listener listener;
@Inject OffGamePresenter presenter;
@Inject ScoreStream scoreStream;
...
}
Now we can subscribe to the score stream in OffGameInteractor
's didBecomeActive
lifecycle method and pass new scores to the presenter when they arrive:
scoreStream.scores()
.subscribe(new Consumer<ImmutableMap<String,Integer>>() {
@Override
public void accept(ImmutableMap<String, Integer> scores)
throws Exception {
Integer playerOneScore = scores.get(playerOne);
Integer playerTwoScore = scores.get(playerTwo);
presenter.setScores(playerOneScore, playerTwoScore);
}
});
Now, when we run the app, it'll display the current score for each player instead of dummy text:
Let's take a look at our Rx subscription to the ScoreStream
again - what happens if we want to detach and garbage collect the OffGame
RIB? Currently, it will cause a memory leak because it's subscribed to the ScoreStream
which is scoped to the LoggedInScope
. To fix this, we update our code to use AutoDispose
to automatically unsubscribe when OffGame
is detached:
scoreStream.scores()
.to(new ObservableScoper<ImmutableMap<String, Integer>>(this))
.subscribe(new Consumer<ImmutableMap<String,Integer>>() {
@Override
public void accept(ImmutableMap<String, Integer> scores)
throws Exception {
Integer playerOneScore = scores.get(playerOne);
Integer playerTwoScore = scores.get(playerTwo);
presenter.setScores(playerOneScore, playerTwoScore);
}
});
Now that the score stream is all wired up and OffGame
is displaying its data, it would be useful to actually update it.
Since the TicTacToe
RIB is a child of the LoggedIn
RIB, we'll want to use the listener pattern here.
More specifically we'll want:
A new listener defined in the TicTacToe
RIB. This listener should have a method to signal that the game has ended and a specific player has won.
The LoggedIn
interactor should implement this listener when called it should update the MutableScoreStream
and route the user back to the OffGame
RIB so they can view their stats. (when injecting the MutableScoreStream
into the LoggedInInteractor
, be sure to also use the @LoggedInInternal
qualifier, otherwise, Dagger won't be able to find the dependency).
Hopefully, this is a review of concepts that have been used in past steps, so there are no example code snippets here. However, if you need help or are stuck, don't hesitate to reach out.
If you reach this point, it means the app now saves names and keeps track of scores. However, because we have added quite a bit of new dependencies, the unit tests will not compile.
Now is a great time to update the unit tests to build along with adding some new test cases.
Some example ideas include:
- Ensuring
OffGame
correctly sets the scores when the score stream emits. - Ensuring the
TicTacToe
RIB correctly calls its listener when a game ends. - Ensuring the
OffGameView
properly formats score data.
Congratulations! You completed tutorial 3. The completed source for this tutorial can be found here.
Now onwards to tutorial 4.
Copyright © 2017 Uber Technologies Inc.
Once you've read through the documentation, learn the core concepts of RIBs by running through the tutorials and use RIBs more efficiently with the platform-specific tooling we've built.