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