Недавно я выступал на конференции Droidcon в Париже с докладом (оригинал на французском), в котором рассматривал проблемы, возникшие у нас в Square при работе с фрагментами и возможности полного отказа от фрагментов.
В 2011-м мы решили использовать фрагменты по следующим причинам:
- Тогда мы ещё не поддерживали планшеты, но знали, что когда-нибудь будем. Фрагменты помогают создавать адаптивный пользовательский интерфейс, и потому казались хорошим выбором.
- Фрагменты являются контроллерами представлений, они содержат куски бизнес-логики, которые могут быть протестированы.
- API фрагментов предоставляет возможность работы с backstack’ом (в общих чертах это выглядит так же, как и работа со стэком
Activity
, но при этом вы остаётесь в рамках однойActivity
). - Так как фрагменты построены на обычных представлениях (views), а представления могут быть анимированы средствами Android-фреймворка, то фрагменты могли в теории дать нам возможность использовать более интересные переходы между экранами.
- Google рекомендовал фрагменты к использованию, а мы хотели сделать наш код как можно более стандартным.
С 2011-го года много воды утекло, и мы нашли варианты получше.
Чего ваши родители никогда не говорили вам о фрагментах
Жизненный цикл
Context
в Android является божественным объектом, а Activity
— это Context
с дополнительным жизненным циклом. Божество с жизненным циклом? Ироничненько. Фрагменты в божественный пантеон не входят, но они с лихвой компенсируют этот недостаток очень сложным жизненным циклом.
Стив Помрой (Steve Pomeroy) сделал диаграмму всех переходов в жизненном цикле фрагмента, и особого оптимизма она не внушает:
Сделано Стивом Помроем, слегка изменено с целью удалить жизненный цикл Activity и выложено под лицензией CC BY-SA 4.0.
Жизненный цикл ставит перед вами множество интереснейших вопросов. Что можно, а что нельзя делать в каждой упомянутой выше функции обратного вызова? Они вызываются синхронно, или по очереди? Если по очереди, то в каком порядке?
Отладка затрудняется
Когда в вашу программу закрадывается ошибка, вы берёте отладчик и исполняете код инструкция за инструкцией, чтобы понять, что же происходит. И всё прекрасно, пока вы не дошли до класса FragmentManagerImpl
. Осторожно, мина!
С этим кодом довольно сложно разобраться, что затрудняет процесс поиска ошибок в вашем приложении:
switch (f.mState) { case Fragment.INITIALIZING: if (f.mSavedFragmentState != null) { f.mSavedViewState = f.mSavedFragmentState.getSparseParcelableArray( FragmentManagerImpl.VIEW_STATE_TAG); f.mTarget = getFragment(f.mSavedFragmentState, FragmentManagerImpl.TARGET_STATE_TAG); if (f.mTarget != null) { f.mTargetRequestCode = f.mSavedFragmentState.getInt( FragmentManagerImpl.TARGET_REQUEST_CODE_STATE_TAG, 0); } f.mUserVisibleHint = f.mSavedFragmentState.getBoolean( FragmentManagerImpl.USER_VISIBLE_HINT_TAG, true); if (!f.mUserVisibleHint) { f.mDeferStart = true; if (newState > Fragment.STOPPED) { newState = Fragment.STOPPED; } } } // ... }
Если вы хоть раз обнаруживали, что у вас на руках созданный заново после поворота экрана и не присоединённый к Activity
фрагмент, то вы понимаете о чём я говорю (и ради всего святого, не испытывайте судьбу и не упоминайте при мне про вложенные фрагменты).
Закон обязывает меня (по крайней мере я читал об этом на Coding Horror) приложить к посту следующее изображение, так что не обессудьте:
После нескольких лет глубокого анализа я пришёл к тому, что количество WTF в минуту при отладке Android-приложения равно 2fragment_count.
Магия создания фрагментов
Фрагмент может быть создан вами или классом FragmentManager
. Взгляните на следующий код, всё просто и понятно, да?
DialogFragment dialogFragment = new DialogFragment() { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { ... } }; dialogFragment.show(fragmentManager, tag);
Однако, когда происходит восстановление состояния Activity
, FragmentManager
может попытаться создать фрагмент заново через рефлексию. Так как мы наверху создали анонимный класс, в его конструкторе есть скрытый аргумент, ссылающийся на внешний класс. Бамс:
android.support.v4.app.Fragment$InstantiationException: Unable to instantiate fragment com.squareup.MyActivity$1: make sure class name exists, is public, and has an empty constructor that is public
Что мы поняли, поработав со фрагментами
Несмотря на все проблемы фрагментов, из работы с ними можно вынести бесценные уроки, которые мы будем применять при создании своих приложений:
- Нет никакой необходимости создавать одну
Activity
для каждого экрана. Мы можем разнести наш интерфейс по отдельным виджетам и компоновать их как нам нужно. Это упрощает анимации интерфейса и жизненный цикл. Мы также можем разделить наши виджеты на классы-представления и классы-контроллеры. - Backstack не является чем-то, имеющим отношение к нескольким
Activity
; можно спокойно создать его и внутри одной-единственнойActivity
. - Не нужно создавать новые API; всё, что нам нужно (
Activity
,Views
,Layout Inflaters
), было в Android с самого начала.
Адаптивный интерфейс: фрагменты против представлений
Фрагменты
Давайте посмотрим на простой пример с фрагментами: интерфейс, состоящий из списка и детализированного представления каждого элемента списка.
HeadlinesFragment
представляет из себя список элементов:
public class HeadlinesFragment extends ListFragment { OnHeadlineSelectedListener mCallback; public interface OnHeadlineSelectedListener { void onArticleSelected(int position); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setListAdapter( new ArrayAdapter<String>(getActivity(), R.layout.fragment_list, Ipsum.Headlines)); } @Override public void onAttach(Activity activity) { super.onAttach(activity); mCallback = (OnHeadlineSelectedListener) activity; } @Override public void onListItemClick(ListView l, View v, int position, long id) { mCallback.onArticleSelected(position); getListView().setItemChecked(position, true); } }
Переходим к более интересному: ListFragmentActivity
должна разбираться с тем, показывать ли детали на том же экране что и список, или нет:
public class ListFragmentActivity extends Activity implements HeadlinesFragment.OnHeadlineSelectedListener { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.news_articles); if (findViewById(R.id.fragment_container) != null) { if (savedInstanceState != null) { return; } HeadlinesFragment firstFragment = new HeadlinesFragment(); firstFragment.setArguments(getIntent().getExtras()); getFragmentManager() .beginTransaction() .add(R.id.fragment_container, firstFragment) .commit(); } } public void onArticleSelected(int position) { ArticleFragment articleFrag = (ArticleFragment) getFragmentManager() .findFragmentById(R.id.article_fragment); if (articleFrag != null) { articleFrag.updateArticleView(position); } else { ArticleFragment newFragment = new ArticleFragment(); Bundle args = new Bundle(); args.putInt(ArticleFragment.ARG_POSITION, position); newFragment.setArguments(args); getFragmentManager() .beginTransaction() .replace(R.id.fragment_container, newFragment) .addToBackStack(null) .commit(); } } }
Представления
Давайте перепишем этот код, используя самописные представления. Во-первых, мы введём понятие контейнера (Container
), который может показать элемент списка, а также обрабатывает нажатия назад:
public interface Container { void showItem(String item); boolean onBackPressed(); }
Activity
знает, что у неё всегда есть на руках контейнер, и просто делегирует ему нужную работу:
public class MainActivity extends Activity { private Container container; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main_activity); container = (Container) findViewById(R.id.container); } public Container getContainer() { return container; } @Override public void onBackPressed() { boolean handled = container.onBackPressed(); if (!handled) { finish(); } } }
Реализация списка тоже является довольно тривиальной:
public class ItemListView extends ListView { public ItemListView(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected void onFinishInflate() { super.onFinishInflate(); final MyListAdapter adapter = new MyListAdapter(); setAdapter(adapter); setOnItemClickListener(new OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { String item = adapter.getItem(position); MainActivity activity = (MainActivity) getContext(); Container container = activity.getContainer(); container.showItem(item); } }); } }
Переходим к интересному: загрузке разных разметок в зависимости от классификаторов ресурсов:
res/layout/main_activity.xml
<com.squareup.view.SinglePaneContainer xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:id="@+id/container" > <com.squareup.view.ItemListView android:layout_width="match_parent" android:layout_height="match_parent" /> </com.squareup.view.SinglePaneContainer>
res/layout-land/main_activity.xml
<com.squareup.view.DualPaneContainer xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" android:id="@+id/container" > <com.squareup.view.ItemListView android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="0.2" /> <include layout="@layout/detail" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="0.8" /> </com.squareup.view.DualPaneContainer>
Реализация этих контейнеров:
public class DualPaneContainer extends LinearLayout implements Container { private MyDetailView detailView; public DualPaneContainer(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected void onFinishInflate() { super.onFinishInflate(); detailView = (MyDetailView) getChildAt(1); } public boolean onBackPressed() { return false; } @Override public void showItem(String item) { detailView.setItem(item); } }
public class SinglePaneContainer extends FrameLayout implements Container { private ItemListView listView; public SinglePaneContainer(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected void onFinishInflate() { super.onFinishInflate(); listView = (ItemListView) getChildAt(0); } public boolean onBackPressed() { if (!listViewAttached()) { removeViewAt(0); addView(listView); return true; } return false; } @Override public void showItem(String item) { if (listViewAttached()) { removeViewAt(0); View.inflate(getContext(), R.layout.detail, this); } MyDetailView detailView = (MyDetailView) getChildAt(0); detailView.setItem(item); } private boolean listViewAttached() { return listView.getParent() != null; } }
В дальнейшем эти контейнеры можно будет абстрагировать и построить всё приложение на подобных принципах. В результате нам не только не нужны будут фрагменты, но и код получится более доступным для восприятия.
Представления и презентеры
Самописные представления — это хорошо, но хочется большего: хочется выделить бизнес-логику в отдельные контроллеры. Мы будем называть подобные контроллеры презентерами (Presenters
). Введение презентеров позволит нам сделать код более читабельным и упростит дальнейшее тестирование:
public class MyDetailView extends LinearLayout { TextView textView; DetailPresenter presenter; public MyDetailView(Context context, AttributeSet attrs) { super(context, attrs); presenter = new DetailPresenter(); } @Override protected void onFinishInflate() { super.onFinishInflate(); presenter.setView(this); textView = (TextView) findViewById(R.id.text); findViewById(R.id.button).setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { presenter.buttonClicked(); } }); } public void setItem(String item) { textView.setText(item); } }
Давайте посмотрим на код, взятый с экрана редактирования скидок приложения Square Register.
Презентер осуществляет высокоуровневую манипуляцию представлением:
class EditDiscountPresenter { // ... public void saveDiscount() { EditDiscountView view = getView(); String name = view.getName(); if (isBlank(name)) { view.showNameRequiredWarning(); return; } if (isNewDiscount()) { createNewDiscountAsync(name, view.getAmount(), view.isPercentage()); } else { updateNewDiscountAsync(discountId, name, view.getAmount(), view.isPercentage()); } close(); } }
Тестировать этот презентер очень просто:
@Test public void cannot_save_discount_with_empty_name() { startEditingLoadedPercentageDiscount(); when(view.getName()).thenReturn(""); presenter.saveDiscount(); verify(view).showNameRequiredWarning(); assertThat(isSavingInBackground()).isFalse(); }
Управление backstack’ом
Мы написали библиотеку Flow, чтобы упростить себе работу с backstack’ом, а Ray Rayan написал о ней очень хорошую статью. Не вдаваясь особо в подробности, скажу, что код получился довольно простым, так как асинхронные транзакции больше не нужны.
Я глубоко завяз в спагетти из фрагментов, что мне делать?
Вынесите из фрагментов всё, что можно. Код, относящийся к интерфейсу, должен уйти в ваши собственные представления, а бизнес-логику нужно убрать в презентеры, которые знают, как работать с вашими представлениями. После этого у вас останется почти пустой фрагмент, создающий ваши собственные представления (а те, в свою очередь, знают как и с какими презентерами себя связать):
public class DetailFragment extends Fragment { @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { return inflater.inflate(R.layout.my_detail_view, container, false); } }
Всё, фрагмент можно удалить.
Мигрирование с фрагментов было не простым, но мы прошли его — благодаря отличной работе Dimitris Koutsogiorgas и Ray Ryan.
А для чего нужны Dagger и Mortar?
Обе эти библиотеки перпендикулярны фрагментами: их можно использовать как с фрагментами, так и без оных.
Dagger позволяет вам спроектировать ваше приложение в виде графа несвязных компонентов. Dagger берёт на себя работу по связыванию компонентов друг с другом, упрощая таким образом извлечение зависимостей и написание классов с единственной обязанностью.
Mortar работает поверх Dagger’а, и у него есть два важных преимущества:
- Он предоставляет инжектированным компонентам функции обратного вызова, привязанные к жизненному циклу Android-приложения. Таким образом, вы можете написать презентер, который будет синглетоном, не будет разрушаться при повороте экрана, но при этом сможет сохранить своё состояние в
Bundle
, чтобы пережить смерть процесса. - Он управляет подграфами Dagger’а, и позволяет привязывать их к жизненному циклу
Activity
. Таким образом вы можете создавать области видимости: как только представление появляется на экране, Dagger/Mortar создают его презентер и остальные зависимости. Когда представление уходит с экрана, вы уничтожаете эту область видимости (содержащую презентер и зависимости) и сборщик мусора принимается за дело.
Заключение
Мы интенсивно использовали фрагменты, но со временем передумали и избавились от них:
- Большинство наиболее сложных падений наших приложений были связаны с жизненным циклом фрагментов.
- Для создания адаптивного интерфейса, backstack’а и анимированных переходов между экранами нам нужны только представления.
ссылка на оригинал статьи https://habrahabr.ru/post/277289/
Добавить комментарий