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

Original author: Pierre-Yves Ricau
  • Translation
Недавно я выступал на конференции 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'а и анимированных переходов между экранами нам нужны только представления.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 15

    +2
    Давайте не будем писать 100500 фрагментов, давайте лучше напишем 100500 кастомных лэйаутов.
    На сколько легко будет передать ItemListView из примера, если потребуется перейти со старого ListView на новый RecyclerView?
    getChildAt(0), getChildAt(1) это шедевр. Дотаточно по неосторожности добавить в xml разметку еще один элемент что бы код сломался.
    onFinishInflate(), listViewAttached() — те же самые колбэки жизненного цикла, только уже у View. Попробуйте нарисовать самостоятельно такой же граф жизненного цикла для View, как приводится для фрагментов, проще он будет не намного.
    Что если мой ListFragment использует разные лэйауты для sw-600dp и sw-700dp, но по коду ничем не отличается? Сделать классы ItemListView600 и ItemListView700?
    При всем моем восхищении чуваками из squareup, спасибо, но нет.
      +2
      Что если мой ListFragment использует разные лэйауты для sw-600dp и sw-700dp, но по коду ничем не отличается?

      Просто создать разные разметки так же как и для фрагментов.
        +1
        getChildAt(0), getChildAt(1) это шедевр.

        В статье всё-таки приводится пример, а не production-code. Да, такой код легко ломается, лучше искать view через findViewById().
          +1
          На сколько легко будет передать ItemListView...

          Извините, немного не понял вас. Куда его нужно передавать?

          … если потребуется перейти со старого ListView на новый RecyclerView?

          Перейти с ListView на RecyclerView будет в любом случае непросто, вне зависимости от того, используете вы фрагменты или нет. Опять-таки, уточните, пожалуйста, что вы имели в виду.

          Попробуйте нарисовать самостоятельно такой же граф жизненного цикла для View, как приводится для фрагментов, проще он будет не намного.

          Тот граф, что я нашёл, выглядит гораздо проще чем у фрагментов. Покажете более полный?
            +1
            Перейти с ListView на RecyclerView будет в любом случае непросто, вне зависимости от того, используете вы фрагменты или нет

            Смотрите, если я использую фрагмент, то при переходе к RecyclerView поменяется layout-файл фрагмента и внутри фрагмента поменяется всё, что касается ListView. У нас появится новый адаптер, новый RecyclerView и новый OnClick-колбэк. Но это всё будет внутри фрагмента. Все, кто использовал мой фрагмент вообще не заметят никакой разницы при переходе на RV.
            В статье же, автор то ли схитрил, то ли поленился показать как заполняется MyListAdapter. Если MyListAdapter выходит за рамки ItemListView, то при переходе да RV придется заменить и его адаптер во всех контейнерах которые его используют. Если же данные в ItemListView попадают через его гипотетический метод ItemListView.showData(items), а оттуда уже в адаптер, то уже не все так плохо. Правда все равно остается вероятность, что кто-то не воспользуется вашим контрактным методом showData(), а установит в ItemListView свой адаптер.
            Точно такая же история с OnItemClickListener. В статье автор взял и просто напрямую написал внутри своей ItemListView MainActivity activity = (MainActivity) getContext(). Это что, опять "пример, а не production-code"? По хорошему или родительский контейнер должен установить в ItemListView колбэк на клик (и тогда придется переписывать все контейнеры при миграции на RecyclerView.OnClickLisetener()) или ItemListView должен дергать у контейнера onItemClicked, который, разумеется должен быть скрыт за интерфейсом как это делается для фрагментов. Да, наличие презентеров никак эту проблему не решает, т.к. все колбэки просто переносятся из View в Presenter.
            Я надеюсь, я не сумбурно объяснил свои опасения
              0
              Да, спасибо за разъяснения, опасения и правда оправданные. Я вот сейчас посмотрел пример от Square, который использует Mortar, и в нём часть ваших претензий учтена: адаптер создаётся внутри ChatListView, а наружу торчит метод showConversations(), который принимает список чатов, и прокидывает их в адаптер. Ничто не мешает, правда, в текущей реализации установить свой адаптер, но можно эту ситуацию обойти как-нибудь вот так:

              @Override
              public final void setAdapter(ListAdapter adapter) {
                  throw new UnsupportedOperationException("Use ChatListView#showConversations(List<Chat> chats) instead");
              }

              OnItemClickListener в примере тоже устанавливается через ChatListView.

              Я статью перевёл не для того, чтобы оповестить всех о новой серебряной пуле, а чтобы показать на возможные альтернативы, которые тоже могут быть не лишены недостатков (сам я Flow/Mortar, признаюсь, пока что не использовал). В той же статье от BigNerdRanch, которую упоминали ниже, жаловались на большое количество boilerplate, которое получается при их использовании.
                0
                Вообще, чтобы совсем уж абстрагироваться, ItemListView должен не наследоваться от ListView напрямую, а реализовывать какой-нибудь интерфейс IItemListView, и содержать ListView внутри. Тогда клиенты не будут знать о его реализации ничего, и замена ListView на RecyclerView пройдёт относительно безболезненно.
                P.S. Написал и сам тут же понял, какую глупость сморозил. Это же view, нам его потом инфлейтить нужно, поэтому такой финт не пройдёт.
                  +1
                  Ага, а потом напишем в нем onCreateView(), и получим фрагмент :)
                +1
                Это односторонний взгляд. Недавно был случай — попросили вместо фрагмента показать диалог, так вот это был головняк еще тот.
                Flow — библиотека на любителя, но это не значит, что фрагменты хороши.
                В другом UI фреймворке я бы просто сделал нужные компоненты и повторно их использовал как мне захочется, а в андроиде куча состояния, проверок, компонентов с разным жизненным циклом.
                0
                К картинке-графу жизненного цикла фрагмента тоже большая претензия, т.к. там намешано всё, кони, люди… Уже не раз обсуждалось, что onSaveInstanceState/onResoreInstanceState это не методы жизненного цикла, они могут быть вызваны совершенно в разных ситуациях и в разных местах жизненного цикла. Всё что от вас требуется — это сохранять состояния и возвращать состояние в них. У View между прочим тоже есть onSaveInstanceState() и onResoreInstanceState(), почему их нет на том графе, что Вы нашли, непонятно.
                onPrepareOptionsMenu, onCreateOptionsMenu это вообще шаблонные методы и непонятно, почему эти 2 метода попали в этот граф, а десяток других шаблонных методов не попали.

                Еще навскидку, в графе лайфцикла View не преведены onFinishTemporaryDetach(), onStartTemporaryDetach(), onVisibilityChanged(), onFinishInflate(), вероятно еще что-то
              0
              Тем, кто интересуется темой Mortar & Flow рекомендую изучить статью ребят из BigNerdRanch. Правда код примеров в ней еще год назад уже был устаревшим, поэтому детальный пример от Square.
              Так же на Droidcon Berlin выступал Торбен Примке с этой темой: его доклад и сэмпл. И пример использования Mortar-Flow c Dagger2
                +1
                А кто-нибудь знает пример какого-либо приличного приложения, написанного на Mortar+Flow? Может кто-нибудь сам использует?
                  0
                  упомянутый мной Торбен Примке в своем докладе говорит о том, что они свои приложения пишут на нем.
                  Я в свои сторонних проектах тоже использую
                  +1
                  Все эти фрагменты, которые наполовину View, наполовину Activity появились не просто так. При переиспользовании одних и тех же View в разных activity приходилось постоянно дублировать прокидывание разных колбэков жизненного цикла вьюшкам. Посмотрите на класс Webview, который появился гораздо раньше фрагментов и увидите у него webview.onPause(), webview.onResume().
                  Представьте, что вы написали свой видеоплеер MyVideoPlayer и добавили его в Activity1. Теперь у вас появилось требование останавливать видео, если экран с видео перекрывается другим экраном. в Activity1.onStop() я напишу myVideoPlayer.stop(). Потом у меня появляется Activity2 с таким же плеером и таким же требованием. Опять переопределять onStop()? Конечно нет. Создавать общую ViedeoPlayerActivity? Ну можно, если Activity1 и Activity2 это позволяют (все помнят, что в java нет множественного наследования). Я могу создать VideoFragment и это решит все мои проблемы. Как это будут решать ребята из squareup?
                    0
                    а в чем разница такого же синглтона для того чтобы менеджить колбэки и запросы с интернета? тот же service или application? переворот экрана всегда была головной болью что Activity, что Fragment, что custom view жизненный цикл ведь никто не отменял

                    Only users with full accounts can post comments. Log in, please.