Разработка масштабируемых приложений требует учета многих факторов, таких как изменения бизнес-логики, взаимодействие с внешними сервисами и т.д. В результате приложение может стать неустойчивым и сложным в поддержке.

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

Проблема: Пример 1. изменение API native

На Android существует возможность устанавливать пакеты. Например, это может быть APK. Для того, чтобы воспользоваться данным механизмом нужно запросить REQUEST_INSTALL_PACKAGES у пользователя.

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

Допустим, вы использовали какой-то пакет с pub.dev для того, чтобы пользоваться данным нативным кодом. Вызов в вашем коде выглядел примерно так:

import 'package:apk_installer/apk_installer.dart';

class SomeBussinessLogic {
	Future<void> installPackage(String apkURL) {
		ApkInstaller.install(apkURL); // Вызываем пакет который вы нашли на pub.dev
	}
}

Но есть проблема: начиная с android API level 29 данное разрешение становиться Deprecated, и вас как разработчика начинают обязывать использовать новый нативный API PackageInstaller.

Теперь нативный код для Android API level выглядит следующим образом:

private suspend fun installCoroutine(apkUri: Uri) =
    withContext(Dispatchers.IO) {
      resolver.openInputStream(apkUri)?.use { apkStream ->
        val length = DocumentFile.fromSingleUri(getApplication(), apkUri)?.length() ?: -1
        val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
        val sessionId = installer.createSession(params)
        val session = installer.openSession(sessionId)

        session.openWrite(NAME, 0, length).use { sessionStream ->
          apkStream.copyTo(sessionStream)
          session.fsync(sessionStream)
        }

        val intent = Intent(getApplication(), InstallReceiver::class.java)
        val pi = PendingIntent.getBroadcast(
          getApplication(),
          PI_INSTALL,
          intent,
          PendingIntent.FLAG_UPDATE_CURRENT
        )

        session.commit(pi.intentSender)
        session.close()
      }
    }

Для того, чтобы поддержать установку пакетов программным образом на новых версиях Android вы идёте смотреть новую версию пакета apk_installer который вы использовали раньше, но как часто это бывает разработчик ещё не поддержал новые функции или вообще забросил пакет. Знакомо? Думаю, большинство из вас сталкивались с подобной ситуацией.

Вы начинаете расстроенный искать другие пакеты, поддерживающие новый API, и видете что находите пакет package_installer, который поддерживает только новый API.

Изображение 1
Изображение 1

Для того, чтобы проверить Android API level вам нужен теперь ещё один плагин. Итоговый код будет выглядеть так:

import 'package:apk_installer/apk_installer.dart';
import 'package:package_installer/package_installer.dart';
import 'package:device_info_plus/device_info_plus.dart';

class SomeBussinessLogic {
	Future<void> installPackage(String apkURL) {
		DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
		int androidInfo = await deviceInfo.androidInfo.version.baseOS.toInt();
		
		if (androidInfo >= 29) {
			PackageInstaller.install(apkURL);
		} else {
			ApkInstaller.install(ApkURL);
	  }
	}
}

В данном примере ветвление и кол-во вызовов не большое. Давайте представим что данный API менялся не один раз, а каждые 1-2 новые версии операционной системы. Плюс ещё ваше приложение поддерживает несколько операционных систем. Ваш метод installPackage() превратиться в не поддерживаемую лапшу.

Видя данный код я задаюсь сразу закономерным вопросом “Почему в бизнес логики появился данный код? Кажется это вообще не часть бизнес знания!”.

Проблема: Пример 2. замена

Расскажу реальный кейс из личной практики. Существует приложение которое на старте начало использовать firebase_analytics для сбора продуктовой аналитики. Через месяц пришёл менеджер с и говорит “выпиливай свой firebase, мне нужна AppMetrica”. Скорее всего интеграция firebase у вас была как-то так:

import 'package:firebase_analytics/firebase_analytics.dart';

class SomeWidgetState extends State<SomeWidget> {
	...
	@override
	void initState() {
		FirebaseAnalytics.instance.logEvent(name: 'name', parameters: {});
	}
}

Допустим, приложение отслеживало 50 событий. Теперь вам нужно в 50 местах изменить вызов Firebase на AppMetrica. Давайте рассмотрим проблемы, которые могут появиться:

  • Что будет если менеджер захочет добавить ещё один сервис аналитики параллельно AppMetrica?

  • Что будет если нужно будет добавить во всех методах logEvent() обработку ошибки при помощи catchError()?

  • Что будет если пакет firebase_analytics вам больше не будет подходить?

Данные примеры, показывающие, проблемы с которыми вы сталкивались при разработке приложений возможно загоняли вас в ступор. Основные проблемы расширяемость и гибкость.

Решение

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

Изображение 1
Изображение 1

Давайте рассмотрим данное изображение. Наверняка все видели данный круг в разных видах. Давайте упростим данную схему для простоты понимания.

Изображение 2
Изображение 2

Сразу бросается в глаза, что сервисы выполняют роль прослойки между внешним миром/внешними зависимостями и бизнес логикой.

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

Сервисы в чистой архитектуре имеют свои особенности:

  • Сервисы ничего не знают о внешней среде, такой как базы данных, веб-серверы и т.д.

  • Сервисы не зависят от фреймворков, библиотек и других внешних ресурсов, что делает их легко переносимыми и тестируемыми.

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

  • Сервисы предоставляют интерфейс исходя из потребностей потребителя, а не исходя из знания как устроен сервис внутри.

Давайте рассмотрим несколько основных примеров.

Решение: Пример 1. Web

При взаимодействии с вашим или другими серверами все запросы и сам HTTP клиент имеет смысл обернуть в сервисы.

Почему стоит оборачивать HTTP клиент в сервис?

Как и в примере с “Проблема: Пример 1. изменение API native” со временем ваш HTTP клиент может изменить API или выбранная вами библиотека может вас не устраивать со временем, что заставит вас при переходе менять все вызовы HTTP клиента.

Давайте рассмотрим пример когда вы использовали стандартный пакет http в своём приложении. Приложение начало расти и развиваться и вам стало не достаточно данного пакета и вы решили перейти на пакет dio.

Как выглядит работа с http:

import 'package:http/http.dart' as http;

void createWaitList() {
	var client = http.Client();
	try {
	  var response = await client.post(
	      Uri.https('example.com', 'whatsit/create'),
	      body: {'name': 'doodle', 'color': 'blue'});
	  var decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
	  var uri = Uri.parse(decodedResponse['uri'] as String);
	  print(await client.get(uri));
	} finally {
	  client.close();
	}
}

Метод createWaitList() знает слишком много о том как устроена библиотека http, вам придётся в каждом методе работы с сетью заменять http.Client() и await client.post() на вызовы новой библиотеки. Не гибко, не масштабируемо и не расширяемо.

Как выглядит ваш код если обернуть HTTP клиент в сервис:

import 'http_service/http.dart';

class WaitListAPI {
	IHTTPService _httpService;

	WhaitlistAPI(this._httpService);

	void createWaitList() {
		  var response = await _httpService.post(
		      Uri.https('example.com', 'whatsit/create'),
		      body: {'name': 'doodle', 'color': 'blue'});
		  var decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
		  var uri = Uri.parse(decodedResponse['uri'] as String);
		  print(await client.get(uri));
	}
}

Изменилось всего 4 строчки которые выделены жирным, но теперь мы сделали наш код независимым от библиотеки http и в любое время можем безболезненно мигрировать на любую другую библиотеку.

Как будет выглядеть код HTTPService:

import 'package:dio/dio.dart';

abstract class IHTTPService {
	Future<Response> get();
	Future<Response> post();
}

class HTTPService implements IHTTPService {
		final Dio _dio;

		HTTPService(this._dio);

		Future<Response> get(Uri uri) {
			return _dio.get(uri);
		}

		Future<Response> post(Uri uri, Map body) {
			return _dio.post(uri, body: body);
		}
}

Почему стоит оборачивать все запросы в сервис?

Запросы в себе хранят данные об имплементации вашего бэкенда. При изменении протоколов или принципов взаимодействия с бэкендом вы столкнётесь с проблемой, что заставит вас изменять логику вашей бизнес логики.

Давайте рассмотрим пример с изменением протоколов взаимодействия с бэкендом. У вас есть запрос на получение списка курса валют. Данный список вам требуется на нескольких экранах.

Пример ответа в формате JSON:

[
	"Рубли/Доллары": 35.82,
	"Рубли/Евро": 45.0,
	"Доллары/Франки": 30.3,
]

Ваш сервис выглядит так:

import 'package:some_parser/parse.dart';

abstract class IExcahngeRateAPI {
	Future<Map<String, double>> getExchangeRate();
}

class ExcahngeRateAPI implements IExcahngeRateAPI {
	final IHttpService _httpService;

	ExcahngeRateAPI(this._httpService);

	@ovveride
	Future<Map<String, double>> getExchangeRate() async {
 		final response = await _httpService.get('/exchange-rate');
		return json.decode(response);
	}	
}

Вызов вашего сервиса выглядит так:

class SomeBussinessLogic {
	final IExcahngeRateAPI _excahngeRateAPI;
	final IDataBaseService _dataBaseService;

	SomeBussinessLogic(this._excahngeRateAPI);

	Future<void> getExchangeDataAndSave() async {
 		final excahgeRate = await _excahngeRateAPI.getExchangeRate();
 		await _dataBaseService.saveExchangeRate(excahgeRate);
	}
}

Ок, супер. Теперь ваша бизнес логика знает что есть 2 сервиса ответственные за получение и сохранение данных. Сама бизнес логика ничего не знает о том как работает ваш сервер и о работе базы данных.

Приходит менеджер через 2 недели и просить расширить данную функцию.

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

{
	"rates": [
		"RUB-USD": 35.82,
		"RUB-EUR": 45.0,
	],
	"data": {
		"RUB-USD": {
			"translate": "Рубли/Доллары",
			"icon": "https:exch.rate/RUB-USD.png"
		},
		"RUB-EUR": {
			"translate": "Рубли/Евро",
			"icon": "https:exch.rate/RUB-EUR.png"
		}
	}
}

Исходя из нового протокола бэкенда в текущий момент вам потребуется изменить только сервис.

Как будет выглядеть сервис:

import 'package:some_parser/parse.dart';

class Currency {
	String? translate;
	String? icon;
	double rate;
}

abstract class IExcahngeRateAPI {
	Future<List<Currency>> getExchangeRate();
}

class ExcahngeRateAPI implements IExcahngeRateAPI {
	final IHttpService _httpService;

	ExcahngeRateAPI(this._httpService);

	@ovveride
	Future<List<Currency>> getExchangeRate() async {
 		final response = await _httpService.get('/exchange-rate');
		
		final Map<String, dynamic> parsedJson = json.decode(response);
	  final List<Currency> currencies = [];
	  final Map<String, dynamic> data = parsedJson['data'];

		parsedJson['rates'].forEach((key, value) {
	    final currencyData = data[key];
	
	    if (currencyData != null) {
	      currencies.add(Currency(
	        translate: currencyData['translate'],
	        icon: currencyData['icon'],
	        rate: value,
	      ));
	    }
	  });

		return currencies;
	}	
}

Как будет выглядеть бизнес логика:

class SomeBussinessLogic {
  final IExcahngeRateAPI _excahngeRateAPI;
  final IDataBaseService _dataBaseService;

  SomeBussinessLogic(this._excahngeRateAPI);

	Future<void> getExchangeDataAndSave() async {
 		final excahgeRate = await _excahngeRateAPI.getExchangeRate();
 		await _dataBaseService.saveExchangeRate(excahgeRate);
	}
}

Ничего вообще не изменилось. Тем самым вы сделали свой код гибким к изменениям, легко масштабируемым и расширяемым.

Решение: Пример 2. devices

Все взаимодействия вашего приложения с hardware или нативными SDK должны быть инкапсулированы в сервисы. Перед нами стоит следующая задача: нужно получить имя устройства. Находим на pub.dev несколько библиотек, которые позволяют получать данные от платформы. Допустим, device_info.

Рассмотрим как будет выглядеть наш сервис:

import 'package:device_info/device_info.dart';

// Интерфейс взаимодействия данного сервиса
abstract class IDeviceInformationService {
  Future<String> getDeviceName();
}

// Имплементация сервиса
class DeviceInformationService implements IDeviceInformationService {
  final _deviceInfoPlugin = DeviceInfoPlugin();

  @override
  Future<String> getDeviceName() async {
    if (Platform.isAndroid) {
      final deviceInformation = await _deviceInfoPlugin.androidInfo;

      return deviceInformation.device;
    } else if (Platform.isIOS) {
      final deviceInformation = await _deviceInfoPlugin.iosInfo;

      return deviceInformation.utsname.machine;
    }

    throw Exception();
  }
}

Пример потребителя данного сервиса:

class SomeBussinessLogic {
	final IDeviceInformationService _deviceInfoService;
	final IHttpService _httpService;

	SomeBussinessLogic(this._deviceInfoService);

	Future<void> sendDeviceNameToServer() async {
		final String deviceName = await _deviceInfoService.getDeviceName();
		await _httpService.post('/device', queryParams: {'name': deviceName});
	}
}

Пример 3. framework

Допустим, вы используете Flutter для написания мультиплатформенного приложения. Фреймфорк в рамках вашего приложения - это тоже сервис, но его ответственность не в том, чтобы ходить в сеть или в нативные API, а в том, чтобы рисовать интерфейс. Большой разницы в этом нет. Давайте рассмотрим самый базовый пример работы с фреймфорком. Flutter из коробки предоставляет некоторые API для работы с нативным API, но вы как программист вызываете непосредственно Flutter.

Один из таких примеров - это работа с Clipboard. Flutter есть встроенная библиотека package:flutter/services.dart которая позволяет работать с ClipboardManager на Android и UIPasteboard на iOS.

Вызов выглядит следующим образом:

Clipboard.getData('');
Clipboard.setData(ClipboardData(text: 'Text'));

Можно подумать, но тут же всё супер просто и прозрачно.

Зачем нам вообще оборачивать данные вызовы в сервис?

Ваше приложение уже успешно работает на Android и iOS. Вы решаете адаптировать уже существующую кодовую базу ещё и под Windows. Но проблема в том, на Windows Flutter не умеет работать с Clipboard.

Screen Shot 2023-04-14 at 10.21.02 PM.png
Screen Shot 2023-04-14 at 10.21.02 PM.png

Как всегда, можно найти библиотеку clipboard на pub.dev которая умеет это делать. В данном кейсе вам нужно будет везде при вызове Clipboard делать такое:

import 'package:clipboard/clipboard.dart';
import 'package:flutter/services.dart';

if (Platform.Windows) {
	Clipboard.setData(ClipboardData(text: 'Text'));
} else {
	FlutterClipboard.copy('Text');
}

Куда лучше сделать это следующим образом:

import 'package:flutter/services.dart';
import 'package:clipboard/clipboard.dart';

abstract class IClipboardService {
  Future<String?> getText();
  Future<void> setText(String text);
}

class ClipboardService implements IClipboardService {
  @override
  Future<String?> getText() async {
		if (Platform.Windows) {
	    return await Clipboard.getData('');
		} else {
			return FlutterClipboard.paste();
		}
  }

  @override
  Future<void> setText(String text) {
		if (Platform.Windows) {
			Clipboard.setData(ClipboardData(text: text));
		} else {
			FlutterClipboard.copy(text);
		}
  }
}

И каждый вызов вашего сервиса будет выглядеть так:

class SomeBusinessLogic {
	final IClipboardService _clipboardService;

	void someMethod() {
		var text;
		text = 'some text'; // например тут мы получаем данные из базы данных
		_clipboardService.setText(text);
	}
}

Теперь при расширении или изменении вашего сервиса IClipboardService ваша бизнес логика вообще ничего не будет знать об этом что позволит вам добиться трёх основных преимуществ использования сервисов: расширяемость и гибкость.

Какие плюсы для бизнеса и программистов

Использование сервисов в мобильных приложениях имеет несколько преимуществ:

  1. Расширяемость: использование сервисов позволяет бизнесу быстро вносить изменения и адаптироваться к потребностям клиентов.

  2. Гибкость: сервисы могут быть переиспользованы в других приложениях и выполнять определенные задачи, что делает их более гибкими.

Если сказать просто и понятно, данный подход позволяет тратить меньше времени при разработке программного обеспечения с течением времени.

Комментарии (4)


  1. PackRuble
    02.10.2023 18:23

    Спасибо за статью, посылы несёте праведные! Хочу обратить внимание на некоторые вещи:

    1. У вас слетели отступы (для dart обычно принят отступ в два пробела)

    2. Чем продиктована необходимость использовать префикс I для именования интерфейсов? Былой разработкой на java?)) Есть мнение, что лучше именовать реализации интерфейсов, добавляя постфикс Impl (вот так: ClipboardServiceImpl -> ClipboardService), тогда ваша бизнес-логика не имеет лишней семантической сложности.

    3. Стоит заметить, что для простых приложений лучше воспользоваться как раз таки if-else реализациями, нежели чем плодить абстракции абстракций интерфейсов :) В данном случае может показаться, что сделать интерфейс для сервиса достаточно просто. Но вспомните реальные кейсы с необходимой реализацией 10-20 методов. Мой вердикт - by design. Внедряем, если есть неотрицательная вероятность "бизнес захочет"

    4. Используйте interface class или даже abstract interface class вместо abstract class, чего стесняться, если dart 3 разрешает


    1. kaparray Автор
      02.10.2023 18:23
      -1

      Добрый день! Спасибо за комментарий. Отвечаю на ваши пункты:
      1) спасибо
      2) Думаю это вопрос "вкусовщины". Я считаю что интерфейс должен быть явно выделен. То есть название должно быть i_name_of_class.dart и имя класса INameOfClass. Это позволяет более явно отделить интерфейсы от реализации.
      3) Моё мнение "всегда нужно исходить из бизнес потребностей".
      4) Согласен. Вы уже мигрировали свой проект на dart 3? Расскажите опыт.


      1. kacetal
        02.10.2023 18:23

        Вообще I это скорее антипатерн. Интерфейс - сущность которую мы декларируем и используем чаще всего в коде. И он должен быть максимально "красивым" и удобным для чтения. Без всяких префиксов, суффиксов и особенностей имплементации в названии. По сути это название абстракции, а уж что вы используете для ее создания интерфейс, тип, абсолютный класс это уже особенности конкретного языка программирования и они не должны торчать наркжу.

        А вот имплементации могут быть максимально "некрасивыми" *Impl, *Test, *Mock итд. Так как мы их видим только при декларации и пользуемся только абстракцией.


      1. PackRuble
        02.10.2023 18:23

        Да, миграция на dart 3 оказалась вполне приятной. Возможно, в силу специфичности моих проектов, но:

        • в приложении Weather Today это выглядело буквально вот так commit. При чём проект не обновлялся полгода и только поэтому я сделал такой непринуждённый обобщённый коммит, обновив сразу все доступные зависимости. Но для dart 3 этого не требовалось. Всё сразу заработало без танцев

        • в пакете weather_pack тоже всё прошло легко. Но проект совсем простой, это плохое сравнение с бизнес кейсами

        • прямо сейчас происходит миграция одного чуть бОльшего приложения чем погодка, с кучей устаревших зависимостей (даже форков для которых нет) и с флагом --no-sound-null-safety в командной строке при запуске/билде. В текущую минуту уже избавились от флага и на стадии "а не накатить ли dart 3", но:

          • если форков нет, нужно самому править пакеты, что занимает много времени

          • лучше потратить время на работу с кодовой базой, которая тоже не ахти (включая архитектуру)

          • в этом проекте нет нужды ни в pattern matching, ни в switch expressions, ни в модификаторах классов

        Плюс в том, что некоторые популярные пакеты уже перешли на новую версию и мы этого можем даже не замечать, если автор указал минорное|патч повышение версии. И также жизнь облегчает то, что pub tool пока что позволяет ставить пакеты даже с ограничением а-ля sdk: '>=2.14.0 <3.0.0':

        Dart’s pub tool allows resolution even when the upper bound is limited to versions below 3.0.0. For example, a package with the following constraint will be allowed to resolve with a Dart 3.x SDK, as pub will re-interpret the upper-constraint <3.0.0 as <4.0.0 when the lower constraint is 2.12 or higher

        Опять же, когда я попробовал switch выражения, то забыл об идиотском обходе используя анонимные функции или "большом разглагольствовании". Когда я попробовал сопоставление (вместе с sealed) - пришлось привыкать к новому синтаксису, но оказалось вполне удобным. Records оказались также весьма кстати. Модификаторы - это для ультра проектов, либо для повышенного удобства использования пакетов (для авторов пакетов). А вот различные виды сопоставлений не могу освоить до сих пор (имеется ввиду, чтобы их восприятие стало для меня родным и удобным). Лично мне сейчас очень не хватает data-классов, метапрограммирования и нормального ide-рефакторинга.