Аргументы против использования фрагментов в Android

от автора

Недавно я выступал на конференции 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *