Главная » Хабрахабр » Есть ли жизнь без архитектуры?

Есть ли жизнь без архитектуры?

0. Основная часть кода большинства современных приложений наверняка была написана ещё во времена Android 4. Поэтому очень важно иметь архитектуру, которая будет оставаться гибкой не только к функциональным изменениям, но и готова к новым веяниям, технологиям и инструментам. Приложения пережили время ContentProvider, RoboSpice, различных библиотек и архитектурных подходов.

В этой статье я хотел бы рассказать об архитектуре приложения IFunny, о принципах, которых мы придерживаемся, и о том, как решаются основные проблемы, возникающие в процессе разработки.

Начнём с моментов, которые я считаю основополагающими при разработке:

  • говорить внутри команды на одном языке. Каждый новый разработчик имеет своё видение архитектуры и может вносить энтропию в существующий код. Хотелось бы, чтобы был базовый паттерн для построения отдельных независимых компонентов приложения;
  • отсутствие глобальных абстракций. В то же время не хочется загонять себя в рамки и реализовывать каждый компонент так, как удобнее, а не как это диктует архитектура приложения. Архитектура должна работать на разработчика, а не наоборот;
  • переиспользование компонентов: возможность максимально просто использовать существующий код;
  • обработка поворота экрана. Одна из главных проблем приложения — это восстановление экрана после поворота или пересоздания Activity/Fragment. До текущего момента мы складывали все данные в Bundle на onSaveInstansState/onRestoreInstanceState;
  • корректная обработка жизненного цикла приложения;
  • однонаправленность потоков данных: очевидность порядка обработки данных внутри приложения.

Теперь давайте по порядку о том, к чему мы пришли и как решали каждую проблему.

В небольших приложениях это довольно удобный паттерн, не требующий сильных абстракций, и этот паттерн изначально диктовался платформой. Изначально при разработке приложения было некое подобие MVC, где контроллером служили Activity/Fragment.

Каждый новый функционал каким-либо образом основывается на состоянии текущего кода, и сложно не продолжать добавлять код в эти классы.
Но с течением времени Activity/Fragment вырастают до нечитаемых размеров (наш рекорд — 3 тысячи строк кода в одном из Fragments).

Мы пришли к тому, что весь экран нужно дробить на независимые составляющие, и выделили отдельную сущность для этого:

ViewController.java

public abstract class ViewController<T extends ViewModel, D> { public abstract void attach(ViewModelContainer<T> container, @Nullable D data); public abstract void detach();
}

ViewModelContainer.java

public interface ViewModelContainer<T extends ViewModel> extends LifecycleOwner { View getView(); T getViewModel();
}

Теперь Fragment выглядит вот так:

ChatFragment.java

public class ChatFragment extends TrackedFragmentSubscriber implements ViewModelContainer<ChatViewModel>, IMessengerFragment @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); mChatViewController.attach(this); mSendMessageViewController.attach(this); mChatToolbarViewController.attach(this); mMessagesPaginationController.attach(this); mUnreadMessagesViewController.attach(this); mTimeInfoViewController.attach(this); mUploadFileProgressViewController.attach(this); } @Override public void onDestroyView() { mUploadFileProgressViewController.detach(); mTimeInfoViewController.detach(); mUnreadMessagesViewController.detach(); mMessagesPaginationController.detach(); mChatToolbarViewController.detach(); mSendMessageViewController.detach(); mChatViewController.detach(); super.onDestroyView(); } @Override public ChatViewModel getViewModel() { return ViewModelProviders .of(this, mViewModelFactory) .get(ChatViewModel.class); }
}

Такой подход даёт сразу множество плюсов:

  • переиспользование компонентов;
    К примеру, есть несколько экранов, на которых используется строка поиска:

Чтобы добавить подобное поведение, нужно всего лишь прописать в коде:

@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); mSearchFieldViewController.attach(this);
}

Совпадает только отображение: Или, например, поисковая выдача с возможностью множественного выбора, при этом сами типы данных, источники этих данных, навигация и стратегии, кеширование совершенно разные.

  • тестируемость. Нет необходимости создавать Fragment/Activity, чтобы протестировать поведение отдельного экрана;
  • модульность. Отдельные части приложения (UI или обработка данных) могут разрабатываться без привязки друг к другу;
  • но в тоже время не добавляются никакие ограничения для разработчиков и в каждом отдельном компоненте можно использовать свой архитектурный подход (MVC, MVI, MVVM или любую другую MVX). Эта абстракция лишь отделяет нас от компонентов Android и задаёт общий стиль для написания кода;

Нужно где-то хранить состояния экранов и переживать пересоздание Activity/Fragment. Затем необходимо организовать структуру данных.

Почему хранение данных в Bundle нас не устраивает:

  • слишком много бойлерплейт-кода;
  • жизненный цикл фрагментов и порядок вызова методов довольно сложен. Сохранение состояний View и данных в них неочевидно;

Один из множества нюансов

Таким образом Activity восстанавливает состояние своих View:

protected void onRestoreInstanceState(Bundle savedInstanceState) { if (mWindow != null) { Bundle windowState = savedInstanceState.getBundle(WINDOW_HIERARCHY_TAG); if (windowState != null) { mWindow.restoreHierarchyState(windowState); } }
}

И если внутри переопределённого onRestoreInstanceState обновлять адаптер RecycleView, то восстановленный по умолчанию скролл будет сбрасываться;

  • для всех тяжёлых данных приходится организовывать хранение в базе данных, иначе можно схватить TooLargeTransactionException.

Эти объекты живут во FragmentManager в виде непересоздаваемых Fragments. Мы решили использовать retain fragment, а именно удобную обёртку для них от Google в виде ViewModel.

Этот объект переживает пересоздание Activity и FragmentManager в области памяти за пределами FragmentManager, в объекте, называемом ActivityClientRecord. Как это работает
FragmentManager такие объекты хранит в отдельном поле во FragmentManagerNonConfig. Но он способен восстановиться только при повороте экрана. Этот объект формируется при Activity.onDestroy и восстанавливает состояние на Activity.attach. если система «прибила» Activity, то ничего сохранено не будет. Т.е.

Также ему необходима View, чтобы отображать в ней данные. Каждому ViewController необходима своя ViewModel, в которой будет находиться его состояние. Эти данные передаются через ViewModelContainer, который реализуется Activity или Fragment.

На самом деле, в рамках этой задачи можно использовать несколько вариантов. Теперь необходимо организовать потоки передачи данных и состояний между компонентами. Например, неплохим решением является использование Rx для взаимодействия между ViewController и ViewModel.
Мы решили попробовать использовать LiveData для этих целей.
LiveData — это некое подобие потоков в Rx без множества операторов (операторов и правда не хватает, поэтому приходится использовать и LiveData и Rx бок о бок), но с возможностью кеширования данных и обработкой жизненного цикла приложения.

При этом обработка данных происходит за её пределами. В общем случае все данные лежат внутри ViewModel. При повороте экрана ViewController пересоздаётся, подписывается на данные и ему приходит последнее состояние. ViewController просто инициирует события и ждёт данные через observer на ViewModel.
Внутри ViewModel лежат необходимые объекты LiveData, которые кешируют все эти состояния.

ChatViewModel.java

public class ChatViewModel extends ViewModel { private final MessageRepositoryFacade mMessageRepositoryFacade; private final CurrentChannelProvider mCurrentChannelProvider; private final SendbirdConnectionManager mSendbirdConnectionManager; private final MediatorLiveData<List<MessageModel>> mMessages = new MediatorLiveData<>(); private final MutableLiveData<String> mMessage = new MutableLiveData<>(); @Inject public ChatViewModel(MessageRepositoryFacade messageRepositoryFacade, SendbirdConnectionManager sendbirdConnectionManager, CurrentChannelProvider currentChannelProvider) { mMessageRepositoryFacade = messageRepositoryFacade; mCurrentChannelProvider = currentChannelProvider; mSendbirdConnectionManager = sendbirdConnectionManager; initLiveData(); } public LiveData<List<MessageModel>> getMessages() { return mMessages; } public void writeMessage(String message) { mMessage.postValue(message); } public void sendMessage() { // ... } private void initLiveData() { LiveData<List<MessageModel>> messages = Transformations.switchMap(mCurrentChannelProvider.getCurrentChannel(), input -> { if (!Resource.isDataNotNull(input)) { return AbsentLiveData.create(); } return mMessageRepositoryFacade.getMessagesList(input.data.mUrl); }); mMessages.addSource(messages, mMessages::setValue); mMessages.addSource(mSendbirdConnectionManager.getConnectionStateLiveData(), connectionState -> { if (connectionState == null) { return; } switch (connectionState) { case OPEN: // ... break; case CLOSED: // ... break; } }); }
}

Все поля у отображения прописываются в его наследнике. Для инициализации View мы используем ButterKnife и подход ViewHolder, чтобы избавиться от нуллабельности инициализированных View.
Каждый ViewController имеет свой ViewHolder, который инициализируется на вызов attach, при detach ViewHolder зануляется.

ViewHolder.java

public class ViewHolder { private final Unbinder mUnbinder; private final View mView; public ViewHolder(View view) { mView = view; mUnbinder = ButterKnife.bind(this, view); } public void unbind() { mUnbinder.unbind(); } public View getView() { return mView; } }

Далее описываем контроллеры для нашего экрана:

SendMessageViewController.java

@ActivityScope
public class SendMessageViewController extends SimpleViewController<ChatViewModel> { @Nullable private ViewHolder mViewHolder; @Nullable private ChatViewModel mChatViewModel; @Inject public SendMessageViewController() {} @Override public void attach(ViewModelContainer<ChatViewModel> container) { mViewHolder = new ViewHolder(container.getView()); mChatViewModel = container.getViewModel(); mViewHolder.mSendMessageButton.setOnClickListener(v -> mChatViewModel.sendMessage()); mViewHolder.mChatTextEdit.addTextChangedListener(new SimpleTextWatcher() { @Override public void afterTextChanged(Editable s) { mChatViewModel.setMessage(s.toString()); } }); } @Override public void detach() { ViewHolderUtil.unbind(mViewHolder); mChatViewModel = null; mViewHolder = null; } public class ChatViewHolder extends ViewHolder { @BindView(R.id.message_edit_text) EmojiconEditText mChatTextEdit; @BindView(R.id.send_message_button) ImageView mSendMessageButton; @BindView(R.id.message_list) RecyclerView mRecyclerView; @BindView(R.id.send_panel) View mSendPanel; public ViewHolder(View view) { super(view); } }
}

ChatMessagesViewController.java

@ActivityScope
public class ChatMessagesViewController extends SimpleViewController<ChatViewModel> { private final ChatAdapter mChatAdapter; @Nullable private ChatViewModel mChatViewModel; @Nullable private ViewHolder mViewHolder; @Inject public ChatMessagesViewController(ChatAdapter chatAdapter) { mChatAdapter = chatAdapter; } @Override public void attach(ViewModelContainer<ChatViewModel> container) { mChatViewModel = container.getViewModel(); mViewHolder = new ViewHolder(container.getView()); mViewHolder.mRecyclerView.setAdapter(mChatAdapter); mChatViewModel.getMessages().observe(container, data -> mChatAdapter.updateMessages(data)); } @Override public void detach() { ViewHolderUtil.unbind(mViewHolder); mViewHolder = null; mChatViewModel = null; } public class SendMessageViewHolder extends ViewHolder { @BindView(R.id.message_list) RecyclerView mRecyclerView; public ViewHolder(View view) { super(view); LinearLayoutManager linearLayoutManager = new LinearLayoutManager(view.getContext()); linearLayoutManager.setReverseLayout(true); linearLayoutManager.setStackFromEnd(true); mRecyclerView.setLayoutManager(linearLayoutManager); } }
}

За счёт логики LiveData наш список не обновляется между onStop и onStart, так как в это время LiveData неактивна, но новые сообщения по-прежнему могут приходить через пуши.

Что я имею в виду, говоря про порядок вызовов?
К примеру, возьмём MVP.
Подразумевается, что Presenter и View имеют ссылки друг на друга. Это позволяет инкапсулировать реализацию хранения данных и также делает очевидным порядок вызовов между классами. Он их как-то обрабатывает и отдаёт результаты обратно. View пробрасывает пользовательские события в Presenter. Так как оба объекта имеют явные ссылки друг на друга (и интерфейсы не разрывают эту связь, а только немного абстрагируют её), вызовы идут в обе стороны и начинается спор о том, насколько View должна быть пассивной; что пробрасывать, а что обрабатывать самой, и т.д. При таком взаимодействии нет чёткости в потоках данных. Также в связи с этим часто начинаются гонки за Presenter.
и т.п.

Но кеширование происходит асинхронно, и пользовательский отклик никак от этого не зависит, так как сразу после получения они постятся в LiveData. В нашем случае очевидно, что пользовательские данные также кешируются и в базу данных.

Как это всё дружит с многопоточностью, сетевыми вызовами?

Отображение получает эти данные через observer или любой другой listener. Все сетевые запросы происходят из контекста классов, которые не имеют ссылок на Activity или Fragment, данные из запросов обрабатываются на глобальных классах, также находящихся в скоупе Application. При пересоздании отображения результаты этих операций остаются в памяти, и это не приводит к каким-либо утечкам. Если это делается через LiveData, то мы не будем обновлять наше отображение между onPause и onStart.
Тяжелые операции, связанные только с отображением (забрать данные из БД, задекодить изображение, записать в файл) происходят из контекста ViewModel и постятся либо через Rx, либо через LiveData.

Если говорить о минусах LiveData и ViewModel, то можно выделить следующие моменты:

  • LiveData активна только между onStart и onStop, то есть срабатывает после onSaveInstanceState, и после этого нужно быть внимательными к взаимодействию с FragmenManager;
  • недостаток операторов для работы с LiveData, а без Rx она довольна ограничена;
  • ViewModel не переживает пересоздание Activity, если его убила система (Don’t keep activities), а значит, какую-то часть важных данных нельзя кешировать только в LiveData;
  • ViewModel наследует все проблемы nested fragments, связанные с пересозданием.

Вывод

И неважно, как это называется, — MVP, MVC или MVVM — главное понимать, зачем вам это и какие проблемы поможет решить. На самом деле всё, что написано в статье, кажется довольно примитивным и очевидным, но мы считаем принцип Keep It Simple, Stupid одним из главных в разработке, ведь следуя простейшим архитектурным принципам можно решить большинство технических проблем, с которыми сталкивается любой разработчик при написании приложения.

https://developer.android.com/topic/libraries/architecture/guide.html
https://en.wikipedia.org/wiki/KISS_principle
https://www.androiddesignpatterns.com/2013/08/fragment-transaction-commit-state-loss.html
https://android.jlelse.eu/android-architecture-components-viewmodel-e74faddf5b94
http://hannesdorfmann.com/android/arch-components-purist


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

Вышла Oracle Database 18c XE

Можно открывать шампанское и закатывать вечеринку — спустя более, чем 7 лет с момента выпуска предыдущего релиза, для скачивания наконец доступна свежайшая Oracle Database 18c XE. Свершилось! Пока только для Linux x64, но версии для других платформ, также как и ...

Palm возрождается из пепла с новым гаджетом

Ее разработки служили примером для других компаний, в свое время поклонников продукции Palm было не счесть. Компания Palm навсегда вписана в историю современного «железа». Но устройства компании и ее программное обеспечение до сих пор популярны у гиков. Как и многим ...