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

Мотивация

  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-реализации

Задача: Отделить бизнес логику от Activity(Screens).

Activity могут управлять: вводом информации, инициализацией view компонентов, остальная логика отделяется от данных классов с целью убрать зависимость между Activity и остальными компонентами.

Activity получив данные с view компонентов переносит верификацию на уровень моделей. Создаются сценарии которые управляют бизнес логикой, к каждому сценарию привязывается определенный view(Activity, Fragment, FragmentActivity), сценарии определяют момент когда Activity необходимо получить например структуру данных, за которую отвечают Model View, сценарий обратится к Model View, а Model View в свою очередь вызовет класс Service (CRUD) который управляет запросами вида:

  • Get data

  • Set data

  • Update data

  • Delete data

После получения результата от сервера, или же от базы данных Service вернет результат Model View, который в свою очередь построит необходимую структуру данных исходя из данных результатов.Таким образом Activity, или же другой view не привязан к логике приложения, что в будущем упростит доработку модулей или же их модификацию.

Далее приводится пример данной реализации на реальном проекте:

1) Создается главный сценарий который управляет всеми сценариями, отвечает за их запуск:

public class MainScenario extends Application implements Scenario {
   private static Context context;
   private static MainScenario ourInstance;
   private HomeScenario homeScenario;
   public static MainScenario getInstance() {
       if(ourInstance == null){
           synchronized (MainScenario.class){
               if(ourInstance == null){
                   ourInstance = new MainScenario();
               }
           }
       }
       return ourInstance;
   }
   @Override
   public void onCreate() {
       super.onCreate();
       context = getApplicationContext();
   }
   @Override
   public void createScenario() {
       homeScenario = new HomeScenario();
   }
   @Override
   public void startScenario() {
       createScenario();
       homeScenario.startScenario();
   }
   @Override
   public void endScenario() {
   }
   public static Context getContext() {
       return context;
   }
}

Поведение сценариев описывает интерфейс так как каждый сценарий должен создаться, запустится, завершится:

Пример интерфейса:

public interface Scenario {
   void createScenario();
   void startScenario();
   void endScenario();
}

2) Главный сценарий определяет какой сценарий запускать, например, если пользователь залогинен запускается сценарий Home:

public class HomeScenario implements Scenario, Serializable, HomeObserver {
   @Override
   public void createScenario() {
       Intent intent = new Intent(MainScenario.getContext(), HomeActivity.class);
       MainScenario.getContext().startActivity(ObserverHolder.getInstance().createIntentWithObserver(intent, this));
   }

   @Override
   public void startScenario() {
       createScenario();
   }
   @Override
   public void endScenario() {
   }
   @Override
   public void setContext(Context context) {
   }
}

3) Home сценарий запускает Activity в данном случае HomeActivity:

public class HomeActivity extends ActionBarActivity implements UI {

   private HomeObserver homeObserver;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_home);
       initObserver();
       homeObserver.setContext(this);
     }
Home Activity инициализирует Observer в методе 
@Override
public void initObserver() {
   Intent intent = getIntent();
   String observerId = intent.getStringExtra(Const.OBSERVER_EXTRA_KEY);
   homeObserver = (HomeObserver) ObserverHolder.getInstance().getObserverById(observerId);
   ObserverHolder.getInstance().removeObserverById(observerId);
}

С помощью его мы получаем необходимый сценарий.

Данный метод описан в интерфейсе

public interface UI {
   void initObserver();
}

HomeScenario в свою очередь реализует интерфейс HomeObserver ссылка которого создается в HomeActivity

private HomeObserver homeObserver;

С помощью ссылки мы можем вызвать метод из Home сценария и передать в него Context необходимый для работы с view компонентами нашей Activity.

В качестве хранилища observice выступает public class ObserverHolder implements Serializable {

private static ObserverHolder ourInstance;
   private static HashMap<String, Object> observerList;

   private ObserverHolder() {
       observerList = new HashMap<>();
   }

   public static ObserverHolder getInstance() {
       if(ourInstance == null){
           synchronized (ObserverHolder.class){
               if(ourInstance == null){
                   ourInstance = new ObserverHolder();
               }
           }
       }
       return ourInstance;
   }

   public String addObserver(Object scenario) {
       String unicID = UUID.randomUUID().toString();
       observerList.put(unicID, scenario);
       return unicID;
   }

   public void removeObserverById (String observerById){
       observerList.remove(observerById);
   }

   public Object getObserverById(String idObserver){
       return observerList.get(idObserver);
   }

   public Intent createIntentWithObserver(Intent intent, Object observer) {
       intent.putExtra(Const.OBSERVER_EXTRA_KEY, ObserverHolder.getInstance().addObserver(this));
       intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
       return intent;
   }
}

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

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

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

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

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

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

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

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

  • Сервисы.

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

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

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

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

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

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

Reference:

mocking1

Mocking

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

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

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