Slide 1

Slide 1 text

Architecting Single-Activity Applications With or without Fragments Gabor Varadi @zhuinden

Slide 2

Slide 2 text

What do we think we know about Activities? From https://developer.android.com/guide/components/intents-filters.html „Starting an activity An Activity represents a single screen in an app. You can start a new instance of an Activity by passing an Intent to startActivity(). The Intent describes the activity to start and carries any necessary data.”

Slide 3

Slide 3 text

What do we think we know about Fragments? From https://developer.android.com/guide/components/fragments.html „A Fragment represents a behavior or a portion of user interface in an Activity. You can combine multiple fragments in a single activity to build a multi-pane UI and reuse a fragment in multiple activities.”

Slide 4

Slide 4 text

What is the truth?

Slide 5

Slide 5 text

Dianne Hackborn „How should I design my Android application?” Activity Once we have gotten in to this entry-point to your UI, we really don't care how you organize the flow inside. Make it all one activity with manual changes to its views, use fragments (a convenience framework we provide) or some other framework, or split it into additional internal activities. Or do all three as needed. As long as you are following the high-level contract of activity (it launches in the proper state, and saves/restores in the current state), it doesn't matter to the system.

Slide 6

Slide 6 text

What does that tell us? • Activities are not „screens”, they are entry points to the app (like a main function) • The high-level Activity contract is showing UI for current state, handling initial state, and persist state across configuration change and process death • Fragments are just a „convenience framework” — technically they are ViewControllers with lifecycle integration • Android does NOT care how you handle the flow inside your application!

Slide 7

Slide 7 text

Wait, process death? • Step 1: put app in background with HOME • Step 2: press „Terminate application” • Step 3: restart app from launcher • Step 4: enjoy strange behavior  (app restart, statics are cleared, savedInstanceState != null)

Slide 8

Slide 8 text

What IS the flow inside your application? • Navigation – where you are in your application (and what to show) – where you came from, back/up navigation – remembering navigation state across config change and process death • Scoping – what data needs to be shown – what services need to exist (singleton and subscopes) – how to keep scoped services alive across config change

Slide 9

Slide 9 text

Some magic tricks (that we need to understand first)

Slide 10

Slide 10 text

Passing objects through the context hierarchy: getSystemService() trick • Any object can be exposed via the Context hierarchy by overriding getSystemService() • Objects from Activity (and the activity!) can be exposed directly via Activity.getSystemService() • Objects in subscope of Activity can be exposed through ContextWrapper.getSystemService() by inflating the view with a cloned layout inflater LayoutInflater.from(baseContext) .cloneInContext(contextWrapper);

Slide 11

Slide 11 text

Exposing objects through the Context from the Activity public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; public static MainActivity get(Context context) { // noinspection ResourceType return (MainActivity)context.getSystemService(TAG); } @Override public Object getSystemService(String name) { if(TAG.equals(name)) { return this; // <-- now MainActivity.get(context) works } return super.getSystemService(name); } }

Slide 12

Slide 12 text

public class KeyContextWrapper extends ContextWrapper { public static final String TAG = "Backstack.KEY"; LayoutInflater layoutInflater; final Object key; public KeyContextWrapper(Context base, @NonNull Object key) { super(base); this.key = key; } public static T getKey(Context context) { // noinspection ResourceType Object key = context.getSystemService(TAG); // noinspection unchecked return (T) key; } @Override public Object getSystemService(String name) { if(Context.LAYOUT_INFLATER_SERVICE.equals(name)) { if(layoutInflater == null) { layoutInflater = LayoutInflater.from(getBaseContext()) .cloneInContext(this); } return layoutInflater; } else if(TAG.equals(name)) { return key; // <-- now KeyContextWrapper.getKey(context) works } return super.getSystemService(name); } }

Slide 13

Slide 13 text

Back to application flows!

Slide 14

Slide 14 text

Scoping Allowing data and services to exist for the entire duration of when the screen is visible, and not be killed on configuration changes. Child scopes should be able to inherit from their superscope. Things that set out to solve scoping problem: - Activity: onRetainCustomNonConfigurationInstance() - Fragment: retained fragments - Loaders - square/Mortar: MortarScope - lyft/Scoop: Scoop - zhuinden/Service-Tree: ServiceTree - Architectural Components: ViewModel

Slide 15

Slide 15 text

Goal of scoping • The goal is to make sure the data and services exist for as long as the scope • When the scope is destroyed (as it is no longer needed), the data and services are torn down along with it • In advanced use: – scoped data becomes a dependency that is provided to constructor, but obtained asynchronously and observed for changes (LiveData, BehaviorRelay, Observable + RxReplayingShare) – Dagger component is subscoped, and provides the data as scoped dependency – The Dagger component is stored in the scope to survive configuration changes

Slide 16

Slide 16 text

@Subscope @dagger.Component( dependencies = {SingletonComponent.class}, modules = {ChatModule.class}) ) interface ChatComponent { ChatPresenter chatPresenter(); void inject(ChatView chatView); } @dagger.Module static class ChatModule { private final int chatId; @Provides @Subscope Observable chat(ChatRepository chatRepository) { return chatRepository.getChat(chatId); } } @Subscope static class ChatPresenter implements Presenter { @Inject ChatPresenter(Observable chat) { // ... } }

Slide 17

Slide 17 text

String scopeTag = chatKey.toString(); MortarScope childScope = parentScope.findChild(scopeTag); if (childScope == null) { childScope = parentScope.buildChild() .withService(DaggerService.SERVICE_NAME, key.createComponent(parentScope)) .build(scopeTag); } return childScope; ---------------------------------------------------- ChatComponent component = DaggerService.getService(context); ---------------------------------------------------- MortarScope.getScope(context).destroy(); Creating/Destroying Scopes: Mortar

Slide 18

Slide 18 text

The common approach to simplifying the problem • Create only Singleton scope (and a single global injector), everything else is unscoped • Unscoped dependencies have their state persisted to Bundle, and restored if state exists • Also: if the ViewController is preserved even without its view hierarchy, then it can BE the scope! (retained fragments, Conductor’s Controller)

Slide 19

Slide 19 text

Navigation • We must know where we are, remember where we have been • This state must be preserved across configuration changes and process death • Things that set out to solve Navigation problem: – Activity record stack – Fragment backstack – square/flow 0.8 – lyft/scoop – square/flow 1.0-alpha3 – terrakok/Cicerone (no backstack, only command queue) – bluelinelabs/Conductor – zhuinden/simple-stack – wealthfront/magellan (don’t use it – does NOT preserve state across process death!!!)

Slide 20

Slide 20 text

Checklist for what a backstack should be able to do • Handling state persistence across config change / process death • Should receive both the previous and the new state on state change • Animations are asynchronous – operations must be enqueued • State changer is not always available (after onPause) – operations must be enqueued

Slide 21

Slide 21 text

How do Activities handle navigation? • Intents to start new Activities • Parameters are provided in the extras Bundle, generally as a dynamically typed storage with string keys • Intents have „intent flags” to manipulate „task stack” (CLEAR_TOP, REORDER_TO_FRONT, etc.) • Downsides: – You can’t easily tell what Activities exist in the background – Modifying stack needs tricky combinations of intent flags (no fine-grained control) – No notifications about change (previous state, new state) – Complicated lifecycle if multiple Activities exist

Slide 22

Slide 22 text

--- APP START D/MainActivity: onCreate D/MainActivity: onStart D/MainActivity: onResume D/MainActivity: onPostResume --- START SECOND ACTIVITY in onPostResume() D/MainActivity: onPause D/SecondActivity: onCreate D/SecondActivity: onStart D/SecondActivity: onResume D/SecondActivity: onPostResume D/MainActivity: onSaveInstanceState D/MainActivity: onStop --- FINISH SECOND ACTIVITY D/SecondActivity: onPause D/MainActivity: onStart D/MainActivity: onResume D/MainActivity: onPostResume D/SecondActivity: onStop D/SecondActivity: onDestroy

Slide 23

Slide 23 text

How are Fragments generally used for navigation? • FragmentTransactions (begin/commit) • Tutorials typically show „replace()” in conjunction with „addToBackStack()” • The backstack stores transactions (operations) with a tag to pop to (inclusive/exclusive) • (Parameters are also provided as Bundle, called arguments) • Downsides: – onBackstackChanged() provides change notification, but does not provide previous and new states (also it’s kinda random) – Stack stores operations instead of active fragments, so asymmetric navigation is super-difficult – commit() runs transaction on the NEXT event loop (what about onPause? „Cannot perform after onSaveInstanceState()”)

Slide 24

Slide 24 text

Principle of Flow (and its variants) • „Flow” is a custom backstack to store current state and history • Content of the backstack is saved to and restored from Bundle (for process death) as Parcelables • State is represented as immutable parcelable value objects, called Keys • Keys contain all necessary data in order to set up the initial state (like Intent extras), but as typed values of the class • List of previous / new keys are both available („Traversal ”,…)

Slide 25

Slide 25 text

@AutoValue public abstract class TaskDetailKey implements Key, Parcelable { public abstract String taskId(); // <- instead of static final TASK_ID = „TASK_ID”; public static TaskDetailKey create(String taskId) { return new AutoValue_TaskDetailKey(R.layout.path_taskdetail, taskId); } @Override public int menu() { return R.menu.taskdetail_fragment_menu; } @Override public boolean isFabVisible() { return true; } @Override public View.OnClickListener fabClickListener(View view) { return v -> { ((TaskDetailView)view).editTask(); }; } @Override public int fabDrawableIcon() { return R.drawable.ic_edit; } }

Slide 26

Slide 26 text

@PaperParcel data class TaskDetailKey(val taskId: String) : Key, PaperParcelable { override fun layout() = R.layout.task_detail override fun menu() = R.menu.taskdetail_fragment_menu override fun isFabVisible() = true override fun fabClickListener(view: View) { return View.OnClickListener { v -> (view as SecondView).editTask() } } override fun fabDrawableIcon() = R.drawable.ic_edit companion object { @JvmField val CREATOR = PaperParcelTaskDetailKey.CREATOR } }

Slide 27

Slide 27 text

public void setupViewsForKey(Key key, View newView) { if(key.shouldShowUp()) { setDrawerLockMode(LOCK_MODE_LOCKED_CLOSED, GravityCompat.START); MainActivity.get(getContext()).getSupportActionBar() .setDisplayHomeAsUpEnabled(true); drawerToggle.setDrawerIndicatorEnabled(false); } else { setDrawerLockMode(LOCK_MODE_UNLOCKED, GravityCompat.START); MainActivity.get(getContext()).getSupportActionBar() .setDisplayHomeAsUpEnabled(false); drawerToggle.setDrawerIndicatorEnabled(true); } drawerToggle.syncState(); setCheckedItem(key.navigationViewId()); MainActivity.get(getContext()).supportInvalidateOptionsMenu(); if(key.isFabVisible()) { fabAddTask.setVisibility(View.VISIBLE); } else { fabAddTask.setVisibility(View.GONE); } fabAddTask.setOnClickListener(key.fabClickListener(newView)); if(key.fabDrawableIcon() != 0) { fabAddTask.setImageResource(key.fabDrawableIcon()); } } Example: set up view by key

Slide 28

Slide 28 text

Displaying the View for a given Key • Our Key specifies what we want to show • We need to handle the events of the views (clicks, text changes, etc.) • For that, we need a „ViewController” (which can be used inside an Activity, so not an Activity) • Possible options: – Custom ViewGroup – Fragment – lyft/scoop’s ViewController – square/coordinators’s Coordinator – bluelinelabs/Conductor’s Controller

Slide 29

Slide 29 text

Creating a Custom Viewgroup

Slide 30

Slide 30 text

public class FirstView extends RelativeLayout { /* constructors call init(); */ Backstack backstack; FirstKey firstKey; private void init(Context context) { if(!isInEditMode()) { backstack = BackstackService.get(context); firstKey = Backstack.getKey(context); } } @OnClick(R.id.first_button) public void clickButton(View view) { backstack.goTo(SecondKey.create()); } @Override protected void onFinishInflate() { super.onFinishInflate(); ButterKnife.bind(this); } /* onAttachedToWindow, onDetachedFromWindow */ }

Slide 31

Slide 31 text

Step-by-step handling view navigation (Simple-Stack) @BindView(R.id.root) RelativeLayout root; @Override public void handleStateChange(StateChange stateChange, Callback completionCallback) { if(stateChange.topNewState().equals(stateChange.topPreviousState())) { completionCallback.stateChangeComplete(); return; } backstackDelegate.persistViewToState(root.getChildAt(0)); root.removeAllViews(); Key newKey = stateChange.topNewState(); Context newContext = stateChange.createContext(this, newKey); View view = LayoutInflater.from(newContext) .inflate(newKey.layout(), root, false); backstackDelegate.restoreViewFromState(view); root.addView(view); completionCallback.stateChangeComplete(); } // + lifecycle integration callbacks! ( onCreate(), onPostResume(), onPause(), onRetainCustomNonConfigurationInstance(), onDestroy() )

Slide 32

Slide 32 text

Same thing using library defaults Navigator.install(this, root, HistoryBuilder.single(FirstKey.create())); Or showing off some configuration builders... Navigator.configure() .setStateChanger(DefaultStateChanger .configure() .create(this, root)) .install(this, root, HistoryBuilder.single(FirstKey.create()));

Slide 33

Slide 33 text

Navigation using the backstack backstack.goTo(TaskDetailsKey.create(taskId)); backstack.goBack(); backstack.setHistory( HistoryBuilder.from(backstack) .removeLast() .add(TaskDetailsKey.create(taskId)) .build(), StateChange.REPLACE); backstack.setHistory( HistoryBuilder.single(TasksKey.create()), StateChange.BACKWARD); backstack.setHistory( HistoryBuilder.from( TasksKey.create(), TaskDetailKey.create(taskId)), StateChange.FORWARD);

Slide 34

Slide 34 text

But what about Fragments? • Fragments are also ViewControllers • Activity provides them with lifecycle integration out of the box • FragmentManager keeps track of them and their state transitions • All added fragments are recreated after process death by super.onCreate() in Activity • (Supports nesting out of the box... with caveats)

Slide 35

Slide 35 text

Fragment Ops beyond „replace” • Other useful operators of FragmentTransaction: – Add/remove: • Create fragment and its view hierarchy • Destroy view hierarchy, and fragment as well – Attach/detach: • Restore view state, (re-)create view hierarchy • preserve view state, but destroy view hierarchy – commitNow(): • Execute the fragment transaction synchronously • Note: this method cannot be used alongside addToBackStack() • Using these operators, we can combine this with a custom backstack, by keeping the fragments and their state alive, but only the currently visible view hierarchy.

Slide 36

Slide 36 text

Key for the Fragment public abstract class BaseKey implements Key { @Override public String getFragmentTag() { return toString(); } @Override public final BaseFragment newFragment() { BaseFragment fragment = createFragment(); Bundle bundle = fragment.getArguments(); if (bundle == null) { bundle = new Bundle(); } bundle.putParcelable("KEY", this); // => T getKey() { … } fragment.setArguments(bundle); return fragment; } protected abstract BaseFragment createFragment(); }

Slide 37

Slide 37 text

Handling state change with Fragments • Remove all Fragments that were in previous state, but are no longer in the new state (if they are still in new state, then just detach them) • Create and add all fragments that are in the new state and not yet added • In the new state, if the current top already exists but is detached, then attach it, if it doesn’t exist, then create it and add it (and detach all other non-top fragments) • Commit transaction now

Slide 38

Slide 38 text

public class FragmentStateChanger { public void handleStateChange(StateChange stateChange) { FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); for(Object _oldKey : stateChange.getPreviousState()) { Key oldKey = (Key) _oldKey; Fragment fragment = fragmentManager.findFragmentByTag(oldKey.getFragmentTag()); if(fragment != null) { if(!stateChange.getNewState().contains(oldKey)) { fragmentTransaction.remove(fragment); } else if(!fragment.isDetached()) { fragmentTransaction.detach(fragment); } } } for(Object _newKey : stateChange.getNewState()) { Key newKey = (Key) _newKey; Fragment fragment = fragmentManager.findFragmentByTag(newKey.getFragmentTag()); if(newKey.equals(stateChange.topNewState())) { if(fragment != null && fragment.isDetached()) { fragmentTransaction.attach(fragment); } else { fragment = newKey.createFragment(); fragmentTransaction.add(containerId, fragment, newKey.getFragmentTag()); } } else if(fragment != null && !fragment.isDetached()) { fragmentTransaction.detach(fragment); } } fragmentTransaction.commitNow(); } }

Slide 39

Slide 39 text

Navigation using the backstack (with fragments) backstack.goTo(TaskDetailsKey.create(taskId)); backstack.goBack(); backstack.setHistory( HistoryBuilder.from(backstack) .removeLast() .add(TaskDetailsKey.create(taskId)) .build(), StateChange.REPLACE); backstack.setHistory( HistoryBuilder.single(TasksKey.create()), StateChange.BACKWARD); backstack.setHistory( HistoryBuilder.from( TasksKey.create(), TaskDetailKey.create(taskId)), StateChange.FORWARD);

Slide 40

Slide 40 text

DEMO Navigation-Example

Slide 41

Slide 41 text

Additional resources Advocating against Android Fragments Simpler Apps with Flow and Mortar Michael Yotive: State of Fragments in 2017 Simplified Fragment Navigation using a custom backstack

Slide 42

Slide 42 text

Thank you for your attention! Q/A?