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

7wb_h-peunvu4kahir2yez8zzmq.jpeg

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

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

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


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

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


065d3b2b0933ed85f640855a195c6879.gif

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

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

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


ViewController.java
public abstract class ViewController {
    public abstract void attach(ViewModelContainer container, @Nullable D data);
    public abstract void detach();
}


ViewModelContainer.java
public interface ViewModelContainer extends LifecycleOwner {
    View getView();
    T getViewModel();
}

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


ChatFragment.java
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);
    }
}

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


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

l-wxewb73tei7pca1f7h9ow9ois.gifnuj7anonqe_8oui4oxbpuio7auy.gifjs2wqrimt5dekiqlnfvnbejhnhc.gif

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

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

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

5xnfl5cvt8trxp96nleoynovwio.jpegvvmr6k-j6eq6s03kl112ttmxyeo.jpeg


  • тестируемость. Нет необходимости создавать 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 пересоздаётся, подписывается на данные и ему приходит последнее состояние.


ChatViewModel.java
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 зануляется. Все поля у отображения прописываются в его наследнике.


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 {
    @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);
        }
    }
}


ChatMessagesViewController.java
@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 неактивна, но новые сообщения по-прежнему могут приходить через пуши.


4mnf5imxawm7mszgaxnkcj3rhlo.gif

Это позволяет инкапсулировать реализацию хранения данных и также делает очевидным порядок вызовов между классами. Что я имею в виду, говоря про порядок вызовов?
К примеру, возьмём 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


© Habrahabr.ru