A simple, hierarchical navigation bus and back stack for Android, with optional Rx bindings, and Toothpick integration for automatic dependency-scope management.
2 sample projects are provided below:
- Simple Example: Demonstrates basic usage in a simple Android project, without use of the optional Rx and Toothpick integrations.
- MVP Example: Demonstrates Okuki's full capabilities using RxJava 1.x, Toothpick, and Parcelable-State save/restore. It also implements an Okuki-centric approach to the MVP (Model-View-Presenter) design pattern.
- MVVM Example: Another full-featured demonstration, this time using RxJava 2.x and MVVM pattern with Android Data Binding.
Gradle:
repositories {
jcenter()
}
...
dependencies {
compile 'com.cainwong.okuki:okuki:0.3.1'
// For RxJava 1.x Bindings
compile 'io.reactivex:rxjava:1.2.5'
compile 'com.cainwong.okuki:okuki-rx:0.3.1'
// -OR- for RxJava 2.x Bindings
compile 'io.reactivex.rxjava2:rxjava:2.0.2'
compile 'com.cainwong.okuki:okuki-rx2:0.3.1'
// For Android Parcelable State Save/Restore
compile 'com.cainwong.okuki:okuki-android:0.3.1'
// For Toothpick integration
compile 'com.github.stephanenicolas.toothpick:toothpick-runtime:1.0.5'
annotationProcessor 'com.github.stephanenicolas.toothpick:toothpick-compiler:1.0.5'
compile 'com.cainwong.okuki:okuki-toothpick:0.3.0'
}
Okuki's purpose is to communicate and remember hierarchical application UI state changes in a
consistent, abstracted way across an application. This is done by the creation of Place
classes
that represent unique UI states or destinations. Think of a Place as a URL or route that maps to
a UI state. Square's Flow library is based on a similar concept. However, where Flow
defines a specific mechanism for implementing UI changes (as well Resource management and
lifecycle), Okuki makes no requirements on how UI state changes are implemented. For example Okuki
can support single- or multi-Activity architectures, with or without using Fragments and/or custom
views.
Each UI state is presented by an instance of an Object extended from the class Place<T>
. Each
Place defines a type of payload T
that will be carried by the instance. Okuki places no
restriction on the Type of of payload that a Place may carry. However, if you wish to make use of
OkukiParceler
in the okuki-android
extension (see Saving and Restoring State below), you will
need to limit your payloads to Parcelable
and/or Serializable
Types. If you require no payload,
you can use Type Void
or extend SimplePlace
which Okuki includes for convenience.
All Places are hierarchical. By default, each Place sits at the root-level of the hierarchy. However,
Places can be nested in a hierarchy by annotating them with @PlaceConfig
and setting the
parent
parameter.
@PlaceConfig(parent = ContactsPlace.class)
public class ContactDetailsPlace extends Place<Integer> {
public ContactDetailsPlace(Integer data) {
super(data);
}
}
In the example above, ContactDetailsPlace
is an immediate descendant of ContactsPlace
. If
another class is created that identifies ConactDetailsPlace
as its parent, both this new class
and ContactDetailsPlace
would be descendants of ContactsPlace
.
Okuki provies 3 types of Listeners. Each Listener registers to receive Place
instances as they are
requested. The difference between the listeners is what type of requested Places they receive.
PlaceListeners receive only Places only of a specified type. The type is specified via the generic
attribute P
in PlaceListener<P extends Place>
.
The following listener would receive only instances of ContactDetailsPlace
:
PlaceListener<ContactDetailsPlace> contactDetailsPlaceListener = new PlaceListener<ContactDetailsPlace>{
@Override
public void onPlace(ContactDetailsPlace place) {
int id = place.getData();
// GET CONTACT DATA UPDATE UI, ETC.
}
};
okuki.addPlaceListener(contactDetailsPlaceListener);
BranchListeners also receive Places of a specified type, but also receive any Place that is a
descendant of the specified type. So given the classes defined in the Place Hierarchy section
above, the following code would listen for Place requests for ContactsPlace
and ContactDetailsPlace
,
as well as any other descendents of these classes:
BranchListener contactsBranchListener = new BranchListener<ContactsPlace>() {
@Override
public void onPlace(Place place) {
// NAVIGATE TO "CONTACTS" SECTION OF APPLICATION, ETC.
}
};
okuki.addBranchListener(contactsBranchListener);
A registered GlobalListener
will receive all Places that are requested, regardless of type or
hierarchy. One simple use-case for such a listener would be a logging mechanism that reports all
requested Places for the purpose of debugging.
GlobalListener logListener = new GlobalListener(){
@Override
public void onPlace(Place place) {
log("Place requested: " + place);
}
};
okuki.addGlobalListener(logListener);
Okuki keeps strong references to registered Listeners, so be sure the unregister them as appropriate for your application lifecyle.
okuki.removePlaceListener(contactDetailsPlaceListener);
...
okuki.removeBranchListener(contactsBranchListener);
...
okuki.removeGlobalListener(logListener);
In addition to onPlace(Place)
each of the Listeners also implements an onError(Exception e)
method.
The default behavior of this method is to throw a Runtime exception. It is recommended that you
override this behavior in your Listener implementations. If using RxOkuki
, exceptions are delegated
through the standard Rx error propagation.
A very important aspect of Okuki's behavior is that the most recently requested Place is automatically
provided to a Listener immediately when the listener is added (subject to the scope of the listener
as described by the various listener definitions above). So in the previous example, logListener
would automatically receive the most recently requested Place on okuki.addGlobalListener(logListener)
.
contactsBranchListener
would also receive the most recent place at
okuki.addBranchListener(contactsBranchListener)
, but only if the most recent place was of type
ContactsPlace
or one of its descendants.
Issuing a place request is as simple as calling okuki.gotoPlace(Place place)
. Okuki takes each
received Place
and broadcasts it to all registered listeners capable of receiving Places of the
given type. Additionally, a method is provided for specifying a HistoryAction
to perform when
issuing the request: okuki.gotoPlace(Place place, HistoryAction historyAction)
. More about
HistoryAction
and the history back stack follows.
In addition for providing a mechanism for requesting an receiving places, Okuki provides a history
back stack of the places requested. This back stack may be used to easily navigate to back to previously
requested Places. To go back to the previous Place, simply call okuki.goBack()
. When doing so, the
most recent Place is popped off the back stack and broadcast to configured listeners.
Okuki also provides the method okuki.getHistory()
that provides direct access to the history. This
allows you to rewrite any portion of the back stack as needed without triggering any Place requests.
By default each Place is added to the top of the history back stack. But Okuki supports several
options for how a Place request affects the history. Use the following HistoryAction
values as
follows via the method okuki.gotoPlace(Place place, HistoryAction historyAction)
:
ADD
: The default behavior. Adds the requested Place to the top of the history back stack.REPLACE_TOP
: This will remove the most recent Place from the top of the history back stack, and then place the provided Place at the top of the stack. If the stack is already empty, behaves the same asADD
.TRY_BACK_TO_SAME
: Searches backwards in the stack to find an equivalent Place (Object.equals(Object o)
). If found, pops the stack back to the found Place (including popping the found Place), and then adds the requested Place to the back stack. If not found, behaves the same asADD
.TRY_BACK_TO_SAME_TYPE
: Searches backwards in the stack to find a Place of the same type (instance of the same Class). If found, pops the stack back to the found Place (including popping the found Place), and then adds the requested Place to the back stack. If not found, behaves the same asADD
.NONE
: Broadcasts the requested place to the Listeners without altering the history in any way, including adding the requested Place to the back stack.
Okuki is single-threaded (i.e. NOT thread-safe). The reason for this is that it is designed for communicating UI changes and is intended to be run on a single thread (the Android Main/UI Thread).
RxBindings are provided in the optional package okuki.rx
for RxJava 1.x, and okuki.rx2
for RxJava 2.x. Using these bindings simplifies use of Okuki as you no longer need to manage instances of the various Listener
classes. Simply subscribe
and unsubscribe like this:
Subscription globalSub = RxOkuki.onAnyPlace(okuki).subscribe(place -> logPlace(place));
Subscription branchSub = RxOkuki.onBranch(okuki, ContactsPlace.class).subscribe(place -> gotoContacts());
Subscription placeSub = RxOkuki.onPlace(okuki, ContactsPlace.class).subscribe(place -> dislayContact(place.getData()));
...
subscription.unsubscribe(globalSub);
subscription.unsubscribe(branchSub);
subscription.unsubscribe(placeSub);
The optional Okuki-Android package provides a mechanism for saving and restoring the state of an Okuki instance as a Parcelable. With it you can maintain Okuki's state (current Place and Place History Back Stack) across Android configuration changes and process death.
Do the following to enable Okuki State save/restore:
- Add the dependency for Okuki-Android. (See Setup above.)
- Ensure that all of your
Place
classes either haveVoid
payload types (which includesSimplePlace
), or payload types that implementParcelable
orSerializable
. - Call
OkukiParceler.extract(Okuki okuki)
to get a ParcelableOkukiState
object that can be written into aBundle
. (Note: the OkukiState may be null if no PlaceRequest has yet been made.) - Call
OkukiParceler.apply(Okuki okuki, OkukiState okukiState)
to restore the saved state to your Okuki instance.
Toothpick integration is provided via the optional package okuki.toothpick
. This integration allows
you to use Places and their hierarchy as your Toothpick Scope hierarchy, and to define modules to load
at each level of the hierarchy. Once configured, a PlaceScoper
listens for Place requests and
automatically opens a Toothpick scope that reflects the respective Place hierarchy. Additionally, any
scopes that is not part of the newly requested Place hierachy are automatically closed, freeing up
resources not needed in the new Place hierarchy.
Do the following to enable the Toothpick integration:
- Add the dependencies for both Toothpick and Okuki-Toothpick. (See Setup above.)
- Create a
PlaceScoper
instance for your Okuki instance using itsBuilder
, also specifying any Modules that you like to configure dependencies that should be available globally:
placeScoper = new PlaceScoper.Builder().okuki(okuki).modules(new AppModule(), new NetworkModule()).build();
- Use the
@ScopeConfig
annotation onPlace
classes to define additional Modules that should apply only to the respective portion of the hierachy defined by the Place. (These modules will themselves automatically receive any injections available from their parent scope.):
@ScopeConfig(modules = KittensModule.class)
public class KittensPlace extends SimplePlace {
public KittensPlace() {
}
}
- Use your instance of
PlaceScoper
to perform all of your injections across your application. (Do do so you'll need provide some kind of static accessor to yourPlaceScoper
instance.):
APP_INSTANCE.placeScoper.inject(obj);
To best understand how Okuki and Toothpick work together, see the MVP Example.
For more information about using the wonderful Toothpick library, please its Github repository and the thorough documentation on the wiki there.