Pull to refresh
0
FUNCORP
Разработка развлекательных сервисов

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

Reading time10 min
Views9.3K

Основная часть кода большинства современных приложений наверняка была написана ещё во времена Android 4.0. Приложения пережили время 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 {
    @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 пересоздаётся, подписывается на данные и ему приходит последнее состояние.


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.


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


Все сетевые запросы происходят из контекста классов, которые не имеют ссылок на 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


Tags:
Hubs:
+24
Comments10

Articles

Change theme settings

Information

Website
funcorp.dev
Registered
Founded
Employees
101–200 employees
Location
Кипр
Representative
ulanana