Сейчас набирает обороты библиотека Conductor, однако в сети не очень много информации по её использованию, а из официальных источников доступны только примеры. Эта статья призвана дать вводный курс по Conductor и убрать некоторые грабли с вашего пути. Статья рассчитана на тех кто уже имеет некоторый опыт в разработке Android.
Conductor позиционируется как замена стандартным фрагментам. Основная идея обернуть View и дать доступ к методам жизненного цикла. Conductor имеет свой жизненный цикл, который сильно проще чем у фрагметов, но и в нём есть свои хитрости (об этом дальше).
Основные преимущества, которые даёт Conductor:
- Упрощение кода
- Транзакции выполняются мгновенно
- Возможность построить приложение на одной Activity
- Не ограничивает в выборе архитектуры приложения
- Легко встраиваемые анимации
- Отсутствие необходимости сохранять состояния между изменениями конфигураций
Так же в коробке вы получите:
- Работа с бэкстеком
- Стандартные коллбеки активити легко доступны
- Несколько стандартных анимаций
- Привязка жизненного цикла к RxJava
- Быстрая интеграция с ViewPager
Далее мы с вами разберём несколько типовых кейсов использования, которые встречаются почти во всех приложения и попробуем разобраться в жизненном цикле контроллера.
Часть 1
Начнём мы с простого примера, даже ещё меньшего, чем на официальном сайте. Перед прочтением статьи настоятельно рекомендуется ознакомиться, страничка не большая.
//File: MainActivity.java
public class MainActivity extends AppCompatActivity {
private Router router;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ViewGroup container = (ViewGroup) findViewById(R.id.controller_container);
router = Conductor.attachRouter(this, container, savedInstanceState);
if (!router.hasRootController()) {
router.setRoot(RouterTransaction.with(new HomeController()));
}
}
}
//File: HomeController.java
public class HomeController extends Controller {
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
return inflater.inflate(R.layout.controller_home, container, false);
}
}
При запуске мы получим экран на котором будет отображён лэйаут controller_home
. Не слишком много, но давайте разберёмся что здесь происходит.
Код контроллера предельно прост и создаёт View. Но инициализация несколько сложнее и даже содержит условие. При создании активити Conductor::attachRouter
привязывает роутер к нашей активити и к её жизненному циклу. Обратим внимание на то, что помимо контекста и контейнера мы передаём ещё сохранённый стейт — всё правильно, роутер сохраняет все контроллеры в своём стеке и их состояние. Поэтому если активити не была создана заново, а была восстановлена, то и установка корневого контроллера не требуется, т.к. он был воссоздан из сохранённого состояния.
Ничего не мешает нам убрать эту проверку, но тогда каждый раз при создании активити у нас будет создаваться новый контроллер и мы потеряем все сохранённые данные.
Часть 2
Посмотрим глубже на то, как и где сохраняются состояния в Conductor.
Для этого нам придётся немного усложнить наш пример. Не будем тратить время зря и заодно сделаем, то, что должен сделать каждый в своей жизни — вырастим дерево. Пусть оно вырастает по тапу.
//File: HomeController.java
public class HomeController extends Controller {
private View tree;
@NonNull
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
View view = inflater.inflate(R.layout.controller_home, container, false);
tree = view.findViewById(R.id.tree);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
tree.setVisibility(View.VISIBLE);
}
});
return view;
}
@Override
protected void onDestroyView(@NonNull View view) {
super.onDestroyView(view);
tree = null;
}
}
Пока что не произошло ничего сверхъестественного, но обратите внимание, что мы сбрасываем ссылку на изображение дерева в onDestroyView
, чтобы наш вид мог быть собран сборщиком мусора, когда контроллер убран с экрана.
Теперь посмотрим, что будет, если мы захотим посмотреть на дерево с другой стороны. Давайте сменим ориентацию экрана.
К великому сожалению, дерево пропало. Что же мы можем сделать, чтобы наши труды не пропали зря?
Жизненный цикл контроллеров привязан к фрагменту в активити. Фрагмент создаётся внутри функции Conductor::attachRouter
и все вызовы методов жизненного цикла привязаны к нему и к активити, внутри которой была произведена инициализация. У фрагмента выставлено свойство setRetainInstance(true)
, что позволяет ему, а следовательно и всем контроллерам, переживать изменения конфигурации. Так что единственное, что от нас потребуется — это сохранить наше состояние в переменную в контроллере.
//File: HomeController.java
public class HomeController extends Controller {
private boolean isGrown = false;
private View tree;
@NonNull
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
View view = inflater.inflate(R.layout.controller_home, container, false);
tree = view.findViewById(R.id.tree);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
isGrown = true;
update();
}
});
return view;
}
@Override
protected void onAttach(@NonNull View view) {
super.onAttach(view);
update();
}
private void update() {
tree.setVisibility(isGrown ? View.VISIBLE : View.GONE);
}
@Override
protected void onDestroyView(@NonNull View view) {
super.onDestroyView(view);
tree = null;
}
}
Мы добавили переменную isGrown
, хранящую состояние нашего контроллера и метод, который обновляет состояния изображения дерева. Опять же — никаких сложностей.
Смотрим на результат.
При изменении конфигурации уничтожается активити, но не уничтожается фрагмент. Но что, если наша активити всё же будет уничтожена системой и не в процессе изменения конфигурации? Поставим в настройках галочку Don’t keep activities и посмотрим, что произойдёт.
Чуда не произошло и мы потеряли все наши данные. Чтобы разрешить эту проблему, Кондуктор дублирует методы onSaveInstanceState
и onRestoreInstanceState
. Давайте реализуем их и убедимся, что всё работает.
//File: HomeController.java
...
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean("isGrown", isGrown);
}
@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
isGrown = savedInstanceState.getBoolean("isGrown");
}
Ура! Теперь когда наше дерево в целости и сохранности, можно перейти к чему-то более сложному.
Часть 3
Давайте узнаем, сколько шишек может принести наше дерево. Для этого создадим контроллер, который принимает на вход количество шишек и отображает их.
//File: ConeController.java
public class ConeController extends Controller {
private int conesCount = 0;
private TextView textField;
public ConeController(int conesCount) {
this.conesCount = conesCount;
}
@NonNull
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
View view = inflater.inflate(R.layout.controller_cone, container, false);
textField = (TextView) view.findViewById(R.id.textField);
return view;
}
@Override
protected void onAttach(@NonNull View view) {
super.onAttach(view);
textField.setText("Cones: " + conesCount);
}
@Override
protected void onDestroyView(@NonNull View view) {
super.onDestroyView(view);
textField = null;
}
}
Но данный код не скомпилируется. И будет ругаться на отсутствие ещё одного конструктора — посмотрим зачем он нужен.
Так же как и с Фрагментами здесь есть своя хитрость. Если, в случае с фрагментами, через рефлексию будет вызван конструктор без параметров, то здесь будет вызван конструктор с параметром Bundle.
Если мы просто добавим конструктор, то. в случае уничтожения нашего контроллера, Conductor его пересоздаст, и мы потеряем информацию о наших шишках. Чтобы этого избежать нужно записать наши данные в args
. Args
сохраняются кондуктором при уничтожении контроллера.
//File: ConeController.java
public ConeController(int conesCount) {
this.conesCount = conesCount;
getArgs().putInt("conesCount", conesCount);
}
public ConeController(@Nullable Bundle args) {
super(args);
conesCount = args.getInt("conesCount");
}
Более изящный вариант с BundleBuilder
— можно посмотреть в примерах.
Осталось добавить вызов нашего контроллера и хранение количества шишек.
//File: HomeController.java
public class HomeController extends Controller {
private boolean isGrown = false;
private int conesCount = 42;
private View tree;
@NonNull
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
View view = inflater.inflate(R.layout.controller_home, container, false);
tree = view.findViewById(R.id.tree);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (isGrown) {
getRouter().pushController(RouterTransaction.with(new ConeController(conesCount)));
} else {
isGrown = true;
update();
}
}
});
return view;
}
}
При первом тапе мы выращиваем дерево, при последующих показываем количество шишек на нём. Наконец, переход на другой контроллер осуществляется простым вызовом pushController
у роутера, что добавляет наш контроллер в бэкстек и показывает его на экране.
Всё прекрасно, но без перезапуска приложения мы не можем вернуться назад — давайте исправим это.
//File: MainActivity.java
@Override
public void onBackPressed() {
if (!router.handleBack()) {
super.onBackPressed();
}
}
Данный код будет работать без проблем в нашем приложении. Однако давайте внимательнее посмотрим на внутренности onBackPressed
.
Допустим, мы не хотим, чтобы по последнему переходу назад приложение скрывалось. Первое что хочется сделать это оставить только вызов router.handleBack()
. Однако дело в том что когда handleBack
возвращает false это не значит, что не было сделано никакой работы — это значит что последний контроллер был уничтожен и существование активити (или любого другого контейнера, содержащего роутер) нужно прекратить. Стоит обратить внимание на то, что при удалении корневого контроллера его вид не удаляется со сцены. Это оставлено для того, чтобы при закрытии нашей активити мы могли наблюдать “качественную” анимацию закрытия.
Отсюда следует правило, что всегда, если handleBack
вернул false, хозяин этого роутера должен быть уничтожен.
Данное поведение можно изменить вызвав метод setPopsLastView
с параметром false
.
Часть 4
Теперь настала пора собирать плоды. Часто возникает задача передать данные обратно в предыдущий контроллер. Попробуем собрать несколько шишек с нашего дерева.
Мы могли бы просто передать слушатель в наш контроллер, однако экосистема Android говорит своё нет. Что если контроллер будет уничтожен? Мы не сможем просто так восстановить ссылку на наш слушатель. Самый простой способ решить эту проблему это воспользоваться методом setTargetController
который позволяет запомнить ссылку на другой контроллер и сам восстановит её при пересоздании контроллера.
Хорошим тоном будет не указывать тип контроллера напрямую, а указать лишь интерфейс который он должен наследовать.
//File: ConeController.java
public class ConeController extends Controller {
private int conesCount = 0;
private TextView textField;
public <T extends Controller & ConeListener> ConeController(int conesCount, T listener) {
this.conesCount = conesCount;
getArgs().putInt("conesCount", conesCount);
setTargetController(listener);
}
public ConeController(@Nullable Bundle args) {
super(args);
conesCount = args.getInt("conesCount");
}
@NonNull
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
View view = inflater.inflate(R.layout.controller_cone, container, false);
textField = (TextView) view.findViewById(R.id.textField);
view.findViewById(R.id.collectConeButton)
.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (getTargetController() != null) {
conesCount--;
getArgs().putInt("conesCount", conesCount);
update();
}
}
});
return view;
}
@Override
protected void onAttach(@NonNull View view) {
super.onAttach(view);
update();
}
@Override
public boolean handleBack() {
((ConeListener) getTargetController()).conesLeft(conesCount);
return super.handleBack();
}
private void update() {
textField.setText("Cones: " + conesCount);
}
@Override
protected void onDestroyView(@NonNull View view) {
super.onDestroyView(view);
textField = null;
}
public interface ConeListener {
void conesLeft(int count);
}
}
//HomeController.java
public class HomeController extends Controller implements ConeController.ConeListener {
....
@NonNull
@Override
protected View onCreateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) {
View view = inflater.inflate(R.layout.controller_home, container, false);
tree = view.findViewById(R.id.tree);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (isGrown) {
getRouter().pushController(RouterTransaction.with(new ConeController(conesCount, HomeController.this)));
} else {
isGrown = true;
update();
}
}
});
return view;
}
@Override
public void conesLeft(int count) {
conesCount = count;
}
В конструктор мы передали параметр который должен быть наследником Controller
и реализовывать интерфейс нашего слушателя. Запомнили его с помощью вызова метода setTargetController
.
При уходе с контроллера мы обновляем количество шишек в HomeController
вызовом conesLeft(...)
у слушателя.
И результат!
Часть 5
Осталось навести красоту. Лёгким движением руки добавляются анимации перехода для появления и скрытия контроллера. В коробке есть базовый набор анимаций — но также без труда можно реализовать свои собственные, переопределив ControllerChangeHandler
или унаследованные от него классы.
//File: MainActivity.java
getRouter().pushController(RouterTransaction.with(
new ConeController(conesCount, HomeController.this))
.popChangeHandler(new FadeChangeHandler())
.pushChangeHandler(new FadeChangeHandler())
);
Часть 6
В Conductor
есть ещё несколько методов жизненного цикла, кроме тех, что указаны на диаграмме в официальной документации, которые могут быть полезны, когда захочется реализовать какое-либо необычное поведение. Давайте взглянем на них.
onChangeStarted — вызывается перед началом анимации
onChangeEnded — вызывается по завершению анимации
onAttach — вызывается при показе контроллера на экране
onDetach — вызывается при удалении контроллера с экрана
onDestroyView — вызывается при уничтожении вида привязанного к контроллеру
onCreateView — вызывается при создании вида для контроллера
onDestroy — вызывается перед тем как контроллер будет уничтожен
Описанные ниже методы так же участвуют в жизненном цикле, но по сути дублируют вызовы соответствующих методов у классов View
и Activity
:
onSaveViewState
onRestoreViewState
onSaveInstanceState
onRestoreInstanceState
При построении жизненного цикла, оказалось всё не так радужно как на официальной странице. Для контроллеров в итоге характерно два жизненных цикла. Один — собственный цикл который работает при переходе между контроллерами и второй цикл, который работает при уничтожении и создании активити. Они отличаются и именно поэтому диаграмма так разрослась.
Парные методы выделены цветом. Действия выделенные жирными стрелками инициируются пользователем. Также обратим внимание на то, что автоматически могут быть вызваны только конструкторы без параметра или с параметром Bundle
. Любой другой конструктор может быть вызван только нами вручную.
За помощь в подготовке спасибо Владимиру Фарафонову.