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

в 5:01, , рубрики: android, android fragments, dagger, fragments lifecycle, square flow, square mortar, Проектирование и рефакторинг, разработка мобильных приложений, Разработка под android

Недавно я выступал на конференции Droidcon в Париже с докладом (оригинал на французском), в котором рассматривал проблемы, возникшие у нас в Square при работе с фрагментами и возможности полного отказа от фрагментов.

В 2011-м мы решили использовать фрагменты по следующим причинам:

  • Тогда мы ещё не поддерживали планшеты, но знали, что когда-нибудь будем. Фрагменты помогают создавать адаптивный пользовательский интерфейс, и потому казались хорошим выбором.
  • Фрагменты являются контроллерами представлений, они содержат куски бизнес-логики, которые могут быть протестированы.
  • API фрагментов предоставляет возможность работы с backstack'ом (в общих чертах это выглядит так же, как и работа со стэком Activity, но при этом вы остаётесь в рамках одной Activity).
  • Так как фрагменты построены на обычных представлениях (views), а представления могут быть анимированы средствами Android-фреймворка, то фрагменты могли в теории дать нам возможность использовать более интересные переходы между экранами.
  • Google рекомендовал фрагменты к использованию, а мы хотели сделать наш код как можно более стандартным.

С 2011-го года много воды утекло, и мы нашли варианты получше.

Чего ваши родители никогда не говорили вам о фрагментах

Жизненный цикл

Context в Android является божественным объектом, а Activity — это Context с дополнительным жизненным циклом. Божество с жизненным циклом? Ироничненько. Фрагменты в божественный пантеон не входят, но они с лихвой компенсируют этот недостаток очень сложным жизненным циклом.

Стив Помрой (Steve Pomeroy) сделал диаграмму всех переходов в жизненном цикле фрагмента, и особого оптимизма она не внушает:

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

Сделано Стивом Помроем, слегка изменено с целью удалить жизненный цикл 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) приложить к посту следующее изображение, так что не обессудьте:

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

После нескольких лет глубокого анализа я пришёл к тому, что количество 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.

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

Презентер осуществляет высокоуровневую манипуляцию представлением:

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'а и анимированных переходов между экранами нам нужны только представления.

Автор: artemgapchenko

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js