В статье рассматривается стандартная архитектура мобильного приложения.

Мотивация

  1. Снижение требований к квалификации программистов.

  2. Повышение квалификации программистов.

Requirements

  1. Переиспользование архитектуры.

  2. Переиспользование классов (кода).

  3. Снижение количества архитектурных ошибок (которые намного дороже в исправлении, чем синтаксические).

  4. Совместимость между разработчиками на одном проекте. Например, если проект писал один разработчик, а поддерживает его другой, то последний не должен испытывать дискомфорт.

  5. Тестопригодность.

  6. Упрощение оценки новых проектов.

Слои приложения

Представление Бизнес-логика Сервисы Данные
Примеры
  • IView.
  • UIViewController.
  • Таблицы, ячейки таблиц.
  • Модели вида (view models)
  • Пользовательские сценарии.
  • Правила, процедуры, формулы расчета.
  • Swagger.
  • Интерфейс к данным (CoreDate, SQLite).
  • Принтеры.
  • Файловая система.
  • Модель предметной области.
  • Модель CoreData.
Ответственность
  • Ввод-вывод данных на интерфейс.
  • Валидация ввода.
  • Ничего не знает о других контроллерах и сервисах.
  • Ничего не знает о модели CoreData.
  • Выполнение пользовательских сценариев.
  • Обновление данных в слое представления.
  • Взаимодействие с сервисами внешними, по отношению к бизнес-логике.
  • Ничего не знает о представлении.
  • Хранение данных.
  • Ничего не знает о представлении и других сервисах.
Reference design (implementation)
  • LoginVC.
  • LoginScenario
  • BackendService

Какие есть недоразумения

Модель вида — модели предметной области — модели CoreData (NSManagedObject) — модель Swagger (Backend?).

В каком случае какую модель применять? В какой момент происходит преобразование из одной модели в другую? Кто ответственный за преобразование?

UIViewController

UIViewController должен иметь зависимость только от соответствующего ему делегата и модели вида. Он не должен иметь зависимостей от других контроллеров, сервисов приложения. Например:

SignInVC зависит от SignInVCDelegate.

SignInVC зависит от SignInVCVM (view-model).

Важно: вью контроллер не должен зависеть от источника получения данных - во вью контроллер приходит готовая модель вида.

Model

Модель должна быть “глупой”. Модель может перерабатывать “сырые” данные в данные для отображения, но не может выступать, например, делегатом для источника сырых данных. Операции модели должны быть замкнутые - класс модели должен оставаться классом модели. Не делегатом, не обзервером - только моделью.

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

Scenario

Сценарий — реализует пользовательские сценарии (use cases).

Реализует шаблон “Посредник” (Mediator) [Эрих Гамма].

Сценарии:

  • главный — объединяет в себя все вторичные сценарии, реагирует на “внешние раздражители” (события appDelegate).

  • вторичные (SignIn, SignUp, CreateProject, ContactSupport)

У каждого ViewController есть ассоциированный с ним ViewControllerDelegate. Например, у SignUpVC есть SignUpVCDelegate. Через этот делегат ViewController сообщает клиентам класса о самых важных событиях с точки зрения пользовательского сценария. Например:

  • signUpButtonPressed;

  • signUpCancelled;

  • termsOfServiceButtonPressed;

Делегат не должен реализовывать второстепенные события, не влияющие на пользовательский сценарий, например:

  • ошибка валидации;

  • второстепенное состояние вида (аккардеон развернулся);

Ответственность:

  • реализация пользовательских сценариев;

  • взаимодействие между UIViewControllers;

  • интеграция с сервисами.

В каких отношениях находятся главный сценарий и второстепенные? Главный сценарий запускает второстепенные. Например, главный сценарий запускает второстепенный сценарий регистрации. Главный сценарий реагирует только на существенные события второстепенного, например, didSignUpFinish, didCancelSignup.

Эскиз реализации:

// AppDelegate.m

AppScenario *_appScenario = nil;

    -(void)didFinishLaunchWithOptions {
    _appScenario = [Scenario scenarioWithRootViewController:vc];
        # может AppScenario будет сам создавать root vc.
      [_appScenario start];
      }

    -(void)willEnterBackround {
    [_appScenario onEnterBackground];
    }

    -(void)didReceiveNotification:n {
   [_appScenario onPushNotification:n];
    }
// AppScenario.m

-(void)start {
    s = [self state];
    if (s == nil) {
      [self startSignInScenario];
    } else {
      [self startDashboardScenario];
    }
}

#pragma mark - SignInScenarioDelegate

-(void)didCompleteScenario:(SignUpScenario *)scenario {
    [self dismissVC];
    [self startProfileUpdateScenario];
}

Пример вторичного сценария SignScenario:

// SignInScenario.m

-(void)startWithRootVC:r {
    SignInVC *_signInVC = [SignInVC signInVCWithDelegate:self]; # но по-хорошему нужна фабрика.
   id<SignInViewModel> *vm = .;
    [_signInVC setViewModel:vm];
    [r presentViewController:signInVC];
}

-(void)completeScenario {
     [self dismissAllViewControllers];
     [self.delegate didCompleteSignInScenario:self];
}

#pragma mark - SignInScenarioDelegate

-(void)didSignInPressedSignInVC:(*)vc signInInfo:signInInfo {
    [vc showProgressAnimation];
    [BackendService loginWithUser:signInInfo completion: (^)(NSError *e) {
       [vc stopProgressAnimation];
       if (e) {
           [vc presentError:e];
      } else {
         [self completeScenario];
   }
}

-(void)didSignUpPressedSignInVC:(*)vc {
    SignUpVC *_signUpVC = [SignUpVC signUpVCWithDelegate:self]; # но по-хорошему нужна фабрика.
    [_signInVC pushViewController:_signUpVC]
}

-(void)didTermsOfServicesPressedSignInVC:(*)vc {

}

Сервисы

Каждый сторонний сервис должен быть выполнен в виде Service-класса, в котором сам сервис возвращается через абстрактный интерфейс. Ни в коем случае приложение не должно зависеть от конкретной реализации. См. Factory Method pattern.

Например, логгер событий должен называется LoggingService, в котором метод defaultService возвращает абстрактный интерфейс. А уже конкретная реализация зависит от, например, Google Analytics или Firebase.

Особенности Android-реализации

На Android в данный момент применяется архитектура MVP, но для всех новых проектов необходимо применять MVVM с использованием языка Kotlin, Architecture Components, Navigation Components.

Паттерн Model-View-Presenter является одним из шаблонов, используемых для проектирования архитектуры мобильного приложения. Model-View-Presenter имеет три слоя:

  • Model — представляет собой интерфейс, отвечающий за управление данными (включая кэширование данных и управление базами данных) приложения и “хранение” его бизнес-логики. В Android приложении роль Model часто выполняет REST API или база данных API.

  • Presenter — выступает посредником между Model и View и отвечает за обновление представления, реагируя на взаимодействие пользователей с обновлением модели. Вся логика представления находится в Presenter, который также контролирует Model и общается с View, что позволяет обновлять конкретный View, когда это нужно.

  • View — отвечает только за представление данных в виде, определяемым Presenter. Представление может быть реализовано с помощью любого Android виджета или всего, что может выполнять такие операции как показ ProgressBar, обновление TextView и заполнение RecyclerView.

Для View и Presenter создается базовый интерфейс и некая, базовая реализация некоторых часто используемых методов.

Как выглядит базовый интерфейс View:

public interface BaseView {

void showErrorMessage(String message);
void hideLoadingIndicator();
void showLoadingIndicator();
void showLoadingIndicator(String message);

}

Базовый интерфейс Presenter:

public interface BasePresenter {

/**
 * Binds presenter with a view when resumed. The Presenter will perform initialization here.

 * @param view the view associated with this presenter*/
void takeView(T view);

/**

 * Drops the reference to the view when destroyed*/

void dropView();

}

Пример реализации базового View:

public class BaseFragmentView extends Fragment implements BaseView {

protected View loadingDialog;
protected TextView loadingMessage;

@Override
@CallSuper
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
    initLoadingDialog(view);

}

protected void initLoadingDialog(View view) {

    loadingDialog = View.inflate(getActivity(), R.layout.lightning_alert_dialog, null);
    loadingDialog.setVisibility(View.GONE);
    ((ViewGroup)getActivity().findViewById(android.R.id.content)).addView(loadingDialog);
    loadingMessage = loadingDialog.findViewById(R.id.loading_message);

}

@Override
public boolean isActive() {
    return isAdded();

}

@Override
public void showErrorMessage(String message) {
    
    AlertDialog.Builder builder;
    builder = new AlertDialog.Builder(getActivity());
    builder.setMessage(message)
        .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int which) {
        }
            }).show();
}

@Override
public void hideLoadingIndicator() {

    if (loadingDialog == null)
        return;

    loadingDialog.animate().alpha(0).setListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);
            loadingDialog.setVisibility(View.GONE);
            if(getActivity() != null)
                getActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
        }
    });
}

@Override

public void showLoadingIndicator() {

    if (loadingDialog.getVisibility() == View.VISIBLE)
        return;
    loadingDialog.setVisibility(View.VISIBLE);
    loadingDialog.setAlpha(0);
    loadingDialog.animate().alpha(1).setListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);
            loadingDialog.setVisibility(View.VISIBLE);
            if(getActivity() != null)
                getActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
                    WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
        }
    });
    setLoadingDialogText(getDefaultDialogText());
}

protected String getDefaultDialogText() {
    return getString(R.string.loading) + "...";
}

@Override
public void showLoadingIndicator(String message) {
    showLoadingIndicator();
    setLoadingDialogText(message);
}

private void setLoadingDialogText(String message) {
    loadingMessage.setText(message);
} }

Интерфейс взаимодействия View и Presenter описывается в Contract файле. Пример такого контракта:

interface SignInContract {

interface View : BaseView {
    fun showEmailError(error: Int)
    fun showPasswordError(error: Int)
    fun showLoginFormError(error: Int)
    fun startLocationListActivity()
    fun startSignInWithAppleActivity()
    fun startResetPasswordActivity()
    fun startCreateAccountActivity()
    fun showHelp(url: String?)
    fun openDeviceDetails(physicalID: String?)
    fun openDeviceDetails(deviceModel: DeviceModel?)
    fun showUnavailableView()

}

interface Presenter :
        BasePresenter<View> {

    fun onSignInClicked(email: String, password: String)
    fun onGoogleSignInClicked()
    fun onAppleSignInClicked()
    fun onCancelAccountDataDialog()
    fun onRetryLoadAccountData()
    fun onForgotPasswordClicked()
    fun onCreateAccountClicked()
    fun onHelpClicked()
    fun onGoogleSignInResult(task: Task<GoogleSignInAccount>)
} }

Все зависимости на сервисы (APIService, UserDataRepository) инжектятся с помощью Dagger2 в Presenter. Важный момент заключается в том, что Presenter зависит не от конкретной реализации сервиса, а от его интерфейса.

Таким образом мы сохраняем возможность в дальнейшем подменить реализацию или заменить ее моком в тесте.

Шаблон управляющего и операционного автоматов (тестопригодность)

Mocking, симулирование

Задача: тестировать и разрабатывать приложение в отсутствие сервера или какой-либо библиотеки.

Частенько бывает, что заказчик не предоставил сервер для разработки и интеграционного тестирования. Это не должно стать блокирующим фактором для разработки остальной части системы.

Нужно замокать (симулировать) результат работы сервера в одном из слоев приложения:

  • UI — проще всего замокать, но много потом переделывать.

  • Модели данных.

  • Бизнес-логика.

  • Сервисы.

  • HTTP-протокол — сложнее всего замокать, но меньше потом переделывать.

Следует помнить, что мок или симулятор — временная мера.

Как определить, на каком уровне замоканы ответы от сервера? В каком слое приложения присутствуют признаки мока, тот слой и замокан. Например, если в UIViewController присутствуют подставленные (статические, тестовые) значения от сервера, то значит UIViewController замокан и его потребуется переписать, когда появится реальный сервер. Также придется переписать и более нижние слои приложения. Мы не может адекватно тестировать тот слой приложения, который замок. Если присутствуют признаки мока в бизнес-логике, то тестирование бизнес-логики в этом случае не дает объективных результатов. Речь идет не об объективности тестирования всего приложения, а только замоканного слоя.

Чтобы увеличить объективное покрытие приложения тестами, нужно вымещать моки в нижние слои приложения: UI → Бизнес-логика → Сервисы → HTTP-клиент.

Всегда существует риск, что реальная модель запроса или ответа сервера, будет отличаться от специфицируемой или ожидаемой (в тех случаях, когда формат не специфицирован). Тем не менее, модели вида, модели предметной области, которые происходят из дизайна и требований к приложению, не подвержены значительным изменениям спецификации сервера.

Для того, чтобы сократить количество классов, которые требуют переделывания, нужно вымещать код конвертации из ожидаемых моделей (запрос или ответ сервера) в модели предметной области и модели вида в классы-фабрики. Например, в начале проекта, когда ответ от сервера еще не был специфицирован, то разработчик выдвинул свои предположения и реализовал класс-фабрику DomainModelFactory с методом createConcreteObjectWithServerResponse(expectedRespose). Когда стала известна реальная спецификация сервера, то разработчик заменил реализацию метода createConcreteObjectWithServerResponse, при этом сохранив его интерфейс. Сохранить интерфейс класса — это значит, что никакой клиент этого класса не потребует изменения кода или повторного юнит-тестирования.

Reference:

mocking1

Mocking

Mocking

Контрольные вопросы

Чтобы полученная информация лучше усвоилась, ответьте на следующие контрольные вопросы:

  1. В чем отличие библиотек unittest.mock и Freeze Gun?
  2. В чем отличие pytest и WebTest?
  3. Вам нужно подготавливать тестовые данные в различных состояниях. Какая библиотека решает эту задачу?
  4. Какой сервис позволяет автоматически запускать тесты и проверять исправность продукта?
  5. Почему репозиторий нужно держать всегда в исправном состоянии?
  6. Как проверить качество написанного теста?
  7. Вам нужно изолировать модуль от внешней зависимости, например, перехватить запрос к серверу. Какой библиотекой решить эту задачу?
  8. Зависимость изредка выбрасывает исключение, чем прерывает нормальную работу приложения. Какой библиотекой решить эту проблему?
  9. Можно ли комбинировать упомянутые технологии между собой? Приведите пример.
  10. Нужно протестировать API веб-сервиса. Какими технологиями вы будете это делать?