При первом знакомстве с Single Activity Architecture у меня возникало много вопросов: “Как можно управлять моментом добавления и удаления фрагментов?”, “Как фрагменту удерживать нажатие кнопки назад?”, “Возможно ли запускать фрагмент на результат?”, ”Как понять когда пользователь вернулся на фрагмент?” и тд.
Первый вопрос является почти тривиальным. Можно создать единый класс навигации, в который передавать менеджер фрагментов и использовать по функции перехода на экран.
Второй вопрос тоже частично решается оповещением класса навигации о том, что произошло нажатие на кнопку назад. Но в этом случае навигатор начинает являться чем-то большим, чем просто хранителем путей, в нем появляется логика, которая, на мой взгляд, абсолютно не оправдана. Но кто-то в системе должен обрабатывать движения вперед и назад?
С возвращением пользователя на фрагмент тоже есть некоторые сложности. Одним из самых критичных, на мой взгляд, это повторный вызов onCreateView. Как мы все знаем, там появляется пользовательское представление в виде View. Думаю также, ни для кого не секрет, что эта операция является довольно прожорливой.
По итогу получается класс с большим количеством логики переходов, созданием фрагментов различного рода, сомнительными вставками “очень полезной функциональности” в методы обработки перехода назад (если пользователь добавил что-то на предыдущем экране нужно, это добавить в список). По моему мнению, это не совсем то, что требуется от класса, который отвечает за навигацию внутри приложения. Разумное решение — это делегировать часть функционала другим частям системы. Таким образом, в моей программе появилась сущность стека фрагментов.
Требования к стеку фрагментов почти тривиальны: добавить фрагмент, перейти назад, перейти до, — за исключением некоторых нюансов. Для меня, как для проектировщика, основной проблемой стал жизненный цикл добавляемых / удаляемых фрагментов.Также некой проблемой было завершение фрагмента с результатом и отправки результата его потребителю. Благо решение нашлось довольно быстро. Внутренней логической структурой я выбрал немного усовершенствованный стек: слоеный пирог. Идея заключается в том, что слои укладываются на корж. Коржом нашего абстрактного пирога можно считать точку входа в приложение (главный фрагмент, домашняя страница и т.д.). Слои же в свою очередь имеют следующие свойства:
- При добавлении слоя в первую очередь он создается. Затем бережно укладывается на корж или слой, который находится сверху.
- При снятии слоя нижний слой становится виден, а снимаемый слой выбрасывается. И правда, зачем нам слой, измазанный в креме?
Если отойти от сладкого примера, то добавление — это транзакция, состоящая из скрытия предыдущего фрагмента и добавления нового. Также в эту операцию я добавил оповещение скрываемого фрагмента о том, что пользователь с него ушел, и ограничение на размер стека. Операция удаления является более витиеватой, поэтому обо всем более подробно.
Логику, которая отвечает за отправку результата из фрагмента поставщика к фрагменту, разумно вынести в отдельный класс. Например, воображаемый экран добавления записи в ежедневник пользователя мог бы возвращать добавленную запись для последующей ее обработки вызываемому блоку программы. Это некий аналог onActivityResult.
Если представить все вышесказанное на схеме, то она будет выглядеть следующим образом
Результативные фрагменты
Для обеспечения результативности я создал отдельный класс ResultUtils и интерфейс ResultableFragment.
Потребителем может являться любой фрагмент, который расширяет интерфейс ResultableFragment. Данный интерфейс состоит из одной функции void onFragmentResult(final int requestCode, final int resultCode, final Bundle data). Данная функция является аналогом onActivityResult.
public interface ResultableFragment {
void onFragmentResult(final int requestCode, final int resultCode, final Bundle data);
}
Реализация класса ResultUtils представляет из себя набор следующих методов:
- void addPromise(final Fragment currentFragment, final Fragment targetFragment, final int requestCode) — данный метод создает некие обязательства (по ключу requestCode) от целевого фрагмента к текущему. Тут текущим фрагментом является то, откуда пользователь переходит, а целевым — то, куда он хочет перейти. Система обязательств представляет из себя HashMap<Integer, Integer> в котором ключ — это hash текущего фрагмента, а значение requestCode.
- void sendResultIfPossible(final Pair<Fragment, Fragment> fromToFragmentPair) — служит для вызова метода onFragmentResult и передачи соответствующих параметров. Почему по возможности? Потому что не каждый фрагмент хочет отправлять или получать результат. При успешной отправке результата обязательства можно считать выполненными, и они удаляются из структуры.
- void setResult (final Fragment fragment, final Bundle data, final int resultCode) — предназначен для установки результата во фрагмент. Результат, как и ключ, хранится в аргументах данного фрагмента.
- void onBackPressed(final Pair<Fragment, Fragment> fromToFragmentPair) — используется для обработки кнопки назад, устанавливает результат “фрагмент закрыт” с пустыми данными.
Дополнительные методы жизненного цикла
Также мне потребовался интерфейс, который объединяет все фрагменты, подчиняющиеся новому жизненному циклу. Данный интерфейс я назвал LifeBoundFragment. Туда включены следующие методы:
- onUserLeaveScreen — вызывается, когда пользователь покидает экран;
- onUserBack — вызывается, когда пользователь возвращается на экран.
public interface LifeBoundFragment {
void onUserLeaveScreen();
void onUserBack();
}
Стек
Прорабатывая внешний интерфейс стека, я выделил следующие основные функции:
- pushEntryPoint(Fragment home) — данный метод предназначен для добавления точки входа в стек. В моем случае это домашний фрагмент (тот фрагмент, находясь на котором по нажатию кнопки назад, пользователь покидает приложение).
- push(Fragment target) — добавление нового фрагмента в стек.
- push(final T target, final int requestCode) — добавление нового фрагмента в стек с запросом некоего результата.
- popToTarget(Class target) — спускаться вниз до тех пор, пока не встретим запрашиваемый фрагмент. Если такой фрагмент не найден, то останавливаем спуск на нашей точке входа.
- pop — это непосредственно переход назад.
- boolean handleBackPressed — данный метод передается из активити в стек по событию onBackPressed. Возвращает true, если стек может самостоятельно обработать нажатие назад. В противном случае false.
- onActivityPause, onActivityResume — методы жизненного цикла активити. Данные методы вызывают соответствующие методы LifeBoundFragment для оповещения, что пользователь покинул/вернулся на текущий экран.
Сам стек я организовал на структуре LinkedList. Наиболее интересными, с моей точки зрения, являются методы: push(final T target, final int requestCode), pop() и popToTarget(Class target).
Метод push(final T target, final int requestCode)
Как упоминалось ранее, данный метод добавляет новый фрагмент на экран, скрывает предыдущий и добавляет в новый ключи. Для того чтобы скрыть внутреннюю реализацию, я создал приватный метод pushFragment, который отвечал за всю логику добавления и удаления фрагмента. Метод pushFragment возвращает Pair<Fragment, Fragment>. Это по сути направление движения, где ключ это фрагмент с которого пользователь переходит, а значение куда. По задумке при добавлении фрагмента мы должны оповестить фрагмент, который скрывается, о том, что пользователь с него уходит. Для этого достаточно убедиться, что скрываемый фрагмент расширяет интерфейс LifeBoundFragment, и отправить событие onUserLeaveScreen.
Также в этом методе стоит добавить обязательства через класс утилит ResultUtils, используя метод addPromise.
@Override
public void push(final Fragment target, final int resultCode) {
final Pair<Fragment, Fragment> fromToFragmentPair = pushFragment(target);
callPauseIfPossible(fromToFragmentPair.first);
mResultUtils.addPromise(fromToFragmentPair.first,
fromToFragmentPair.second, resultCode);
}
Наиболее интересным тут является метод pushFragment:
private Pair<Fragment, Fragment> pushFragment(final Fragment navigationTargetFragment) {
final FragmentTransaction fragmentTransaction =
mFragmentManager.beginTransaction();
if (mStackLinkedList.size() >= STACK_SIZE) {
final Fragment outOfStackFragment = mStackLinkedList.remove(1);
fragmentTransaction.remove(outOfStackFragment);
}
final Fragment leaveFragment = mStackLinkedList.getLast();
fragmentTransaction.hide(leaveFragment);
mStackLinkedList.add(navigationTargetFragment);
fragmentTransaction.add(R.id.fragmentContainer,
navigationTargetFragment);
fragmentTransaction.commit();
return new Pair<>(leaveFragment, navigationTargetFragment);
}
В данном методе происходит вся основная манипуляция со стеком, скрытие предыдущего фрагмента и ограничение на кол-во элементов стека.
Метод pop()
Метод pop() также является неким собирательным методом.
Особенностью этого метода является вызов sendResultIfPossible класса ResultUtils.
@Override
public void pop() {
final Pair<Fragment, Fragment> fromToFragmentPair = popFragment();
mResultUtils.sendResultIfPossible(fromToFragmentPair);
}
Основная логика метода popFragment вполне предсказуема. Так что особо на ней задерживаться смысла я не вижу.
private Pair<Fragment, Fragment> popFragment() {
final Fragment leaveFragment = mStackLinkedList.removeLast();
final Fragment targetFragment = mStackLinkedList.getLast();
final FragmentTransaction fragmentTransaction =
mFragmentManager.beginTransaction();
fragmentTransaction.remove(leaveFragment);
callResumeIfPossible(targetFragment);
fragmentTransaction.show(targetFragment);
fragmentTransaction.commit();
return new Pair<>(leaveFragment, targetFragment);
}
Метод popToTarget
Данный метод, по моему мнению, является самым интересным. Он сочетает в себе практически все.
Когда я начал разрабатывать функционал класса ResultUtils, одним моим внутренним ограничением было то, что результат при переходе назад передается по цепочке. Исходя из этого ограничения, метод onFragmentResult будет вызываться по цепочке до тех пор, пока не наткнется на корневой вызов. Фрагменты, находящиеся посередине цепочками, я начал называть транзитными. Действительно, они получают вызов onFragmentResult, в котором могут установить результат для следующего фрагмента цепи.
@Override
public void popToTarget(final Class<? extends Fragment> target) {
final FragmentTransaction fragmentTransaction =
mFragmentManager.beginTransaction();
final Iterator<Fragment> iterator =
mStackLinkedList.descendingIterator();
Pair<Fragment, Fragment> fromTransactionFragmentToCurrent = null;
while (iterator.hasNext()) {
final Fragment targetFragment = iterator.next();
if (targetFragment.getClass() == target) {
break;
}
fragmentTransaction.remove(targetFragment);
iterator.remove();
fromTransactionFragmentToCurrent =
new Pair<>(targetFragment, mStackLinkedList.getLast());
if (mStackLinkedList.getLast().getClass() != target) {
mResultUtils.sendResultIfPossible(
fromTransactionFragmentToCurrent);
}
}
final Fragment frontFragment = mStackLinkedList.getLast();
callResumeIfPossible(frontFragment);
mResultUtils.sendResultIfPossible(fromTransactionFragmentToCurrent);
fragmentTransaction.show(frontFragment);
fragmentTransaction.commit();
}
В заключении
По моему мнению получилась гибкая, простая и надежная система управления фрагментами. В данный момент мне удалось успешно применить этот подход в ряде проектов, в которых я участвовал. Из минусов с которыми я столкнулся при использовании этого подхода это leanback (Android TV), но отчасти сама система не располагает к Single Activity Architecture. Далее я планирую придумать механизм хранения/восстановления истории, запуск приложения с заданной историей (будет полезно при push нотификации). Спасибо за внимание!
Комментарии (5)
gkislin
13.02.2018 15:10При первом знакомстве с Single Activity Architecture у меня возникало много вопросов
Было бы хорошо сделать некоторую вводную: что это такое, где применяется… Полагаю (на примере себя) что не все знакомы с этим чудом.
fishbone
13.02.2018 19:25Судя по содержанию статьи, имеется в виду ситуация, когда один Activity управляет всеми представлениями приложения. Я бы крайне не рекомендовал вам погружаться в эту тему. Это значительно усложняет код уровня представления и накладывает кучу ограничений и обязательств, которые обычно берет на себя SDK при смене Activity. Я, как раз, на текущем проекте пытаюсь избавиться от этого подхода, но пока не очень успешно. Сказывается малый опыт. Но одно я уже усвоил: лучше использовать общепринятые модели архитектуры и тот, кто будет дорабатывать проект после тебя, скажет тебе спасибо. :)
serarhi
13.02.2018 23:13Работал когда-то с Single Activity Architecture, очень рад, что ушел от этого. Сейчас использую множественные Activity, по принципу: одно активити решает одну конкретную задачу. Жизнь стала легкой, а приложения простыми до тривиальности.
Я вовсе не против фрагментов, они полезны, например в ViewPager или при компоновке сложного UI. Но на мой взгляд, любую вещь стоит применять только тогда, когда она действительно нужна.
hakandr
А как будет со стеком при смене конфигурации или если процесс программы будет уничтожен операционной системой при нехватки памяти?
morkovkin Автор
При повороте экрана история сохраниться, потому что удерживается классом стека. При уничтожении приложения восстановление истории экранов пока в перспективе, потому что пока не сталкивался с задачами когда это требуется.