Есть ли жизнь без архитектуры?
Основная часть кода большинства современных приложений наверняка была написана ещё во времена Android 4.0. Приложения пережили время ContentProvider, RoboSpice, различных библиотек и архитектурных подходов. Поэтому очень важно иметь архитектуру, которая будет оставаться гибкой не только к функциональным изменениям, но и готова к новым веяниям, технологиям и инструментам.
В этой статье я хотел бы рассказать об архитектуре приложения IFunny, о принципах, которых мы придерживаемся, и о том, как решаются основные проблемы, возникающие в процессе разработки.
Начнём с моментов, которые я считаю основополагающими при разработке:
- говорить внутри команды на одном языке. Каждый новый разработчик имеет своё видение архитектуры и может вносить энтропию в существующий код. Хотелось бы, чтобы был базовый паттерн для построения отдельных независимых компонентов приложения;
- отсутствие глобальных абстракций. В то же время не хочется загонять себя в рамки и реализовывать каждый компонент так, как удобнее, а не как это диктует архитектура приложения. Архитектура должна работать на разработчика, а не наоборот;
- переиспользование компонентов: возможность максимально просто использовать существующий код;
- обработка поворота экрана. Одна из главных проблем приложения — это восстановление экрана после поворота или пересоздания Activity/Fragment. До текущего момента мы складывали все данные в Bundle на onSaveInstansState/onRestoreInstanceState;
- корректная обработка жизненного цикла приложения;
- однонаправленность потоков данных: очевидность порядка обработки данных внутри приложения.
Теперь давайте по порядку о том, к чему мы пришли и как решали каждую проблему.
Изначально при разработке приложения было некое подобие MVC, где контроллером служили Activity/Fragment. В небольших приложениях это довольно удобный паттерн, не требующий сильных абстракций, и этот паттерн изначально диктовался платформой.
Но с течением времени Activity/Fragment вырастают до нечитаемых размеров (наш рекорд — 3 тысячи строк кода в одном из Fragments). Каждый новый функционал каким-либо образом основывается на состоянии текущего кода, и сложно не продолжать добавлять код в эти классы.
Мы пришли к тому, что весь экран нужно дробить на независимые составляющие, и выделили отдельную сущность для этого:
public abstract class ViewController {
public abstract void attach(ViewModelContainer container, @Nullable D data);
public abstract void detach();
}
public interface ViewModelContainer extends LifecycleOwner {
View getView();
T getViewModel();
}
Теперь Fragment выглядит вот так:
public class ChatFragment extends TrackedFragmentSubscriber implements ViewModelContainer, 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> mMessages = new MediatorLiveData<>();
private final MutableLiveData mMessage = new MutableLiveData<>();
@Inject
public ChatViewModel(MessageRepositoryFacade messageRepositoryFacade,
SendbirdConnectionManager sendbirdConnectionManager,
CurrentChannelProvider currentChannelProvider) {
mMessageRepositoryFacade = messageRepositoryFacade;
mCurrentChannelProvider = currentChannelProvider;
mSendbirdConnectionManager = sendbirdConnectionManager;
initLiveData();
}
public LiveData> getMessages() {
return mMessages;
}
public void writeMessage(String message) {
mMessage.postValue(message);
}
public void sendMessage() {
// ...
}
private void initLiveData() {
LiveData> 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 {
@Nullable private ViewHolder mViewHolder;
@Nullable private ChatViewModel mChatViewModel;
@Inject
public SendMessageViewController() {}
@Override
public void attach(ViewModelContainer 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 {
private final ChatAdapter mChatAdapter;
@Nullable private ChatViewModel mChatViewModel;
@Nullable private ViewHolder mViewHolder;
@Inject
public ChatMessagesViewController(ChatAdapter chatAdapter) {
mChatAdapter = chatAdapter;
}
@Override
public void attach(ViewModelContainer 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