
Основная часть кода большинства современных приложений наверняка была написана ещё во времена Android 4.0. Приложения пережили время ContentProvider, RoboSpice, различных библиотек и архитектурных подходов. Поэтому очень важно иметь архитектуру, которая будет оставаться гибкой не только к функциональным изменениям, но и готова к новым веяниям, технологиям и инструментам.
В этой статье я хотел бы рассказать об архитектуре приложения IFunny, о принципах, которых мы придерживаемся, и о том, как решаются основные проблемы, возникающие в процессе разработки.
Начнём с моментов, которые я считаю основополагающими при разработке:
- говорить внутри команды на одном языке. Каждый новый разработчик имеет своё видение архитектуры и может вносить энтропию в существующий код. Хотелось бы, чтобы был базовый паттерн для построения отдельных независимых компонентов приложения;
- отсутствие глобальных абстракций. В то же время не хочется загонять себя в рамки и реализовывать каждый компонент так, как удобнее, а не как это диктует архитектура приложения. Архитектура должна работать на разработчика, а не наоборот;
- переиспользование компонентов: возможность максимально просто использовать существующий код;
- обработка поворота экрана. Одна из главных проблем приложения — это восстановление экрана после поворота или пересоздания Activity/Fragment. До текущего момента мы складывали все данные в Bundle на onSaveInstansState/onRestoreInstanceState;
- корректная обработка жизненного цикла приложения;
- однонаправленность потоков данных: очевидность порядка обработки данных внутри приложения.
Теперь давайте по порядку о том, к чему мы пришли и как решали каждую проблему.

Изначально при разработке приложения было некое подобие MVC, где контроллером служили Activity/Fragment. В небольших приложениях это довольно удобный паттерн, не требующий сильных абстракций, и этот паттерн изначально диктовался платформой.
Но с течением времени Activity/Fragment вырастают до нечитаемых размеров (наш рекорд — 3 тысячи строк кода в одном из Fragments). Каждый новый функционал каким-либо образом основывается на состоянии текущего кода, и сложно не продолжать добавлять код в эти классы.
Мы пришли к тому, что весь экран нужно дробить на независимые составляющие, и выделили отдельную сущность для этого:
public abstract class ViewController<T extends ViewModel, D> { public abstract void attach(ViewModelContainer<T> container, @Nullable D data); public abstract void detach(); }
public interface ViewModelContainer<T extends ViewModel> extends LifecycleOwner { View getView(); T getViewModel(); }
Теперь Fragment выглядит вот так:
public class ChatFragment extends TrackedFragmentSubscriber implements ViewModelContainer<ChatViewModel>, IMessengerFragment { @Inject ChatMessagesViewController mChatViewController; @Inject TimeInfoViewController mTimeInfoViewController; @Inject ChatToolbarViewController mChatToolbarViewController; @Inject SendMessageViewController mSendMessageViewController; @Inject MessagesPaginationController mMessagesPaginationController; @Inject ViewModelProvider.Factory mViewModelFactory; @Inject UnreadMessagesViewController mUnreadMessagesViewController; @Inject UploadFileProgressViewController mUploadFileProgressViewController; @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.face_to_face_chat, container, false); } @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.
Мы решили использовать retain fragment, а именно удобную обёртку для них от Google в виде ViewModel. Эти объекты живут во FragmentManager в виде непересоздаваемых Fragments.
Как это работает
FragmentManager такие объекты хранит в отдельном поле во FragmentManagerNonConfig. Этот объект переживает пересоздание Activity и FragmentManager в области памяти за пределами FragmentManager, в объекте, называемом ActivityClientRecord. Этот объект формируется при Activity.onDestroy и восстанавливает состояние на Activity.attach. Но он способен восстановиться только при повороте экрана. Т.е. если система «прибила» Activity, то ничего сохранено не будет.
Каждому ViewController необходима своя ViewModel, в которой будет находиться его состояние. Также ему необходима View, чтобы отображать в ней данные. Эти данные передаются через ViewModelContainer, который реализуется Activity или Fragment.
Теперь необходимо организовать потоки передачи данных и состояний между компонентами. На самом деле, в рамках этой задачи можно использовать несколько вариантов. Например, неплохим решением является использование Rx для взаимодействия между ViewController и ViewModel.
Мы решили попробовать использовать LiveData для этих целей.
LiveData — это некое подобие потоков в Rx без множества операторов (операторов и правда не хватает, поэтому приходится использовать и LiveData и Rx бок о бок), но с возможностью кеширования данных и обработкой жизненного цикла приложения.
В общем случае все данные лежат внутри ViewModel. При этом обработка данных происходит за её пределами. ViewController просто инициирует события и ждёт данные через observer на ViewModel.
Внутри ViewModel лежат необходимые объекты LiveData, которые кешируют все эти состояния. При повороте экрана ViewController пересоздаётся, подписывается на данные и ему приходит последнее состояние.
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 зануляется. Все поля у отображения прописываются в его наследнике.
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; } }
Далее описываем контроллеры для нашего экрана:
@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); } } }
@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.
Как это всё дружит с многопоточностью, сетевыми вызовами?
Все сетевые запросы происходят из контекста классов, которые не имеют ссылок на Activity или Fragment, данные из запросов обрабатываются на глобальных классах, также находящихся в скоупе Application. Отображение получает эти данные через observer или любой другой listener. Если это делается через 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, связанные с пересозданием.
Вывод
На самом деле всё, что написано в статье, кажется довольно примитивным и очевидным, но мы считаем принцип Keep It Simple, Stupid одним из главных в разработке, ведь следуя простейшим архитектурным принципам можно решить большинство технических проблем, с которыми сталкивается любой разработчик при написании приложения. И неважно, как это называется, — MVP, MVC или MVVM — главное понимать, зачем вам это и какие проблемы поможет решить.
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
