Русский
Русский
English
Статистика
Реклама

Multiplatform

Детальный разбор навигации в Flutter

24.07.2020 14:12:14 | Автор: admin

Навигация во Flutter


image


Flutter набирает популярность среди разработчиков. Большенство подходов впостроении приложений уже устоялись иприменяются ежедневно вразработке E-commerce приложений. Тема навигации опускают навторой или третий план. Какой API навигации предоставляет Фреймворк? Какие подходы выработаны? Как использовать эти подходы иначто они годятся?


Введение


Начнём с того, что такое навигация? Навигация это метод который позволяет перемещаться между пользовательским интерфейсом с заданными параметрами.
К примеру в IOS мире организовывает навигацию UIViewController, а в Android Navigation component. А чтопредоставляет Flutter?



Экраны в Flutter называются route. Для перемещениями между route существует класс Navigator который имеющий обширный API для реализации различных видов навигации.



Начнём спростого.Навигация нановый экран(route) вызывается методом push() который принимает всебя один аргумент это Route.


Navigator.push(MaterialPageRoute(builder: (BuildContext context) => MyPage()));

Давайте детальнее разберёмся ввызове метода push:


Navigator Виджет, который управляет навигацией.
Navigator.push() метод который добавляет новый route виерархию виджетов.
MaterialPageRoute() Модальный route, который заменяет весь экран адаптивным кплатформе переходом анимации.
builder обязательный аргумент конструктора MaterialPageRoute, который возвращает пользовательский интерфейс Фреймворк для отрисовки.
[MyPage](https://miro.medium.com/max/664/1Xm96KtLeIAAMtAYWcr1-MA.png)* пользовательский интерфейс реализованный при помощи Stateful/Stateless Widget


Возвращение на предыдущий route


Для возвращения с экрана на предыдущий необходимо использовать метод pop().


Navigator.pop();

Переда данных между экранами


Данные на новый экран передаются через конструктор при создании экрана в builder методе. Этот принцип работает в методах Navigator, где нужно реализовать метод build.


Navigator.push(context, MaterialPageRoute(builder: (context) => MyPage(someData: data)));

В примере продемонстрирована передача данных в класс MyPage (в этом классе хранится пользовательский интерфейс).


Для того чтобы передать данные на предыдущий экран нужно вызвать метод pop() и передать опциональным аргументом туда данные.


Navigator.pop(data);


Состояние виджета Navigator, который вызван внутри одного из видов MaterialApp/CupertinoApp/WidgetsApp. State отвечает за хранение истории навигации и предоставляет API для управления историей.
Базовые методы навигации повторяют структуру данных Stack. В диаграмме можно наблюдать методы и "call flow" NavigatorState.


http://personeltest.ru/aways/habrastorage.org/webt/5w/dg/nb/5wdgnb-tjlngub4c8y4rlpqkeqi.png


Императивный vs Декларативный подход в навигации


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


Давайте на простом примере:


Императивный подход , отвечает на вопрос как?
Пример: Я вижу, что тот угловой столик свободен. Мы пойдём туда и сядем там.


Декларативный подход, отвечает на вопрос что?
Пример: Столик для двоих, пожалуйста.


Для более глубокого понимания разницы советую прочитать эту статью Imperative vs Declarative Programming


Императивная навигация


Вернёмся к реализации навигации. В императивном подходе описывается детали работы в вызывающем коде. В нашем случае это поля Route. В Flutter много типов route, например MaterialPageRoute и CupertinoPageRoute. Например в CupertinoPageRoute задаётся title, или settings.


Пример:


Navigator.push(    context,    CupertinoPageRoute<T>(        title: "Setting",        builder: (BuildContext context) => MyPage(),        settings: RouteSettings(name:"my_page"),    ),);

Этот код и знания о новом route будут хранитьсяв ViewModel/Controller/BLoC/ У этот подхода существует недостаток.


Представим что потребовалось внести изменения в конструкторе в MyPage или в CupertinoPageRoute. Нужно искать каждый вызов метода push в проекте и изменять кодовую базу.


Вывод:


Этот подход не имеет единообразный подход к навигации, и знание о реализации route проникает в бизнес логику.

Декларативная навигация


Принцип декларативной навигации заключается в использовании декларативного подхода в программировании. Давайте разберём на примере.


Пример:


Navigator.pushNamed(context, '/my_page');

Принцип императивной навигации выглядит куда проще. Говорите "Отправь пользователя на экран настроек" передавая путь одним из аргументов навигации.
Для хранении реализации роста в самом Фреймворке предусмотрен механизм у MaterialApp/CupertinoApp/WidgetsApp. Это 2 колбэка onGenerateRoute и onUnknownRoute отвеспющие за хранение деталей реализации.


Пример:


MaterialApp(    onUnknownRoute: (settings) => CupertinoPageRoute(      builder: (context) {                return UndefinedView(name: settings.name);            }    ),  onGenerateRoute: (settings) {    if (settings.name == '\my_page') {      return CupertinoPageRoute(                title: "MyPage",                settings: settings,        builder: (context) => MyPage(),      );    }        // Тут будут описание других роутов  },);

Разберёмся подробнее в реализации:
Метод onGenerateRoute данный метод срабатывает когда был вызван Navigator.pushNamed(). Метод должен вернуть route.
Метод onUnknownRoute срабатывает когда метод onGenerateRoute вернул null. должен вернуть дефолтный route, по аналогии с web сайтами 404 page.


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

Диалоговые и модальные окна


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


Методы для вызова диалоговых и модальных окон:


  • showAboutDialog
  • showBottomSheet
  • showDatePicker
  • showGeneralDialog
  • showMenu
  • showModalBottomSheet
  • showSearch
  • showTimePicker
  • showCupertinoDialog
  • showDialog
  • showLicensePage
  • showCupertinoModalPopup

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


Как работает это под капотом?


Давайте рассмотрим исходный код одного из методов, например showGeneralDialog.


Исходный код:


Future<T> showGeneralDialog<T>({  @required BuildContext context,  @required RoutePageBuilder pageBuilder,  bool barrierDismissible,  String barrierLabel,  Color barrierColor,  Duration transitionDuration,  RouteTransitionsBuilder transitionBuilder,  bool useRootNavigator = true,  RouteSettings routeSettings,}) {  assert(pageBuilder != null);  assert(useRootNavigator != null);  assert(!barrierDismissible || barrierLabel != null);  return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(_DialogRoute<T>(    pageBuilder: pageBuilder,    barrierDismissible: barrierDismissible,    barrierLabel: barrierLabel,    barrierColor: barrierColor,    transitionDuration: transitionDuration,    transitionBuilder: transitionBuilder,    settings: routeSettings,  ));}

Давайте детальнее разберёмся в устройстве этого метода. showGeneralDialog вызывает метод push у NavigatorState с _DialogRoute(). Нижнее подчёркивание обозначает что этот класс приватный и используется только в пределах области видимости в которой сам описан, то есть в пределах этого файла.


Диалоговые и модальные окна которые отображаются при помощи глобальных методов это кастомные route которые реализованы разработчиками Фреймворка.

Типы route в фреймворке


Теперь понятно что"every thins is a route", то есть что связанное с навигацией. Давайте взглянем на то, какие route уже реализованы в Фреймворке.


Два основных route в Flutter это PageRoute и PopupRoute.


PageRoute Модальный route, который заменяет весь экран.
PopupRoute Модальный route, который накладывает виджет поверх текущего route.


Реализации PageRoute:


  • MaterialPageRoute
  • CupertinoPageRoute
  • _SearchPageRoute
  • PageRouteBuilder

Реализации PopupRoute:


  • _ContexMenuRoute
  • _DialogRoute
  • _ModalBottomSheetRoute
  • _CupertinoModalPopupRoute
  • _PopupMenuRoute

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


Вывод:


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

Best practices


В этой части статьи можно задаться вопросом, "а насколько мне всё это нужно?". Ответить на этот вопрос можно легко, если у есть желание сделать масштабируемый API навигации которая в любой момент будет модифицирована под нужды проекта, то это один из вариантов. Если приложение будет в будущем расширяться со сложной логикой.


Начнём с того что мы сделаем некий сервис который будет будет соблюдать следующим аспектам:


  • Декларативный вызов навигации.
  • Отказ от использования BuildContext для навигации (Это критично если сервис навигации будет вызываться в компонентах, в которых нет возможности получить BuildContext).
  • Модульность. Можно вызвать любой route, CupertinoPageRoute, BottomSheetRoute, DialogRoute и т.д.

Для нашего сервиса навигации нам понадобится интерфейс:


abstract class IRouter {  Future<T> routeTo<T extends Object>(RouteBundle bundle);  Future<bool> back<T extends Object>({T data, bool rootNavigator});  GlobalKey<NavigatorState> rootNavigatorKey;}

Разберём методы:
routeTo - выполняет навигацию на новый экран.
back возвращает на предыдущий экран.
rootNavigatorKey GlobalKey умеющий вызывать методы NavigatorState.

После того как мы сделали интерфейс навигации, давайте сделаем реализацию этого интерфейса.


class Router implements IRouter {    @override  GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();    @override  Future<T> routeTo<T>(RouteBundle bundle) async {   // Push logic here  }    @override  Future<bool> back<T>({T data, bool rootNavigator = false}) async {        // Back logic here    }}

Супер, теперь нам нужно реализовать метод routeTo().


@override  Future<T> routeTo<T>(RouteBundle bundle) async {        assert(bundle != null, "The bundle [RouteBundle.bundle] is null");    NavigatorState rootState = rootNavigatorKey.currentState;    assert(rootState != null, 'rootState [NavigatorState] is null');    switch (bundle.route) {      case "/routeExample":        return await rootState.push(          _buildRoute<T>(            bundle: bundle,            child: RouteExample(),          ),        );      case "/my_page":        return await rootState.push(          _buildRoute<T>(            bundle: bundle,            child: MyPage(),          ),        );      default:        throw Exception('Route is not found');    }  }

Данный метод вызывает у root NavigatorState (который описан в WidgetsApp) метод push и конфигурирует его относительно RouteBundle который приходит одним из аргументов в данный метод.


Теперь нужно реализовать класс RouteBundle. Это просто модель, которая хранит в себе набор полей для конфигурации.


enum ContainerType {  /// The parent type is [Scaffold].  ///  /// In IOS route with an iOS transition [CupertinoPageRoute].  /// In Android route with an Android transition [MaterialPageRoute].  ///  scaffold,  /// Used for show child in dialog.  ///  /// Route with [DialogRoute].  dialog,  /// Used for show child in [BottomSheet].  ///  /// Route with [ModalBottomSheetRoute].  bottomSheet,  /// Used for show child only.  /// [AppBar] and other features is not implemented.  window,}class RouteBundle {  /// Creates a bundle that can be used for [Router].  RouteBundle({    this.route,    this.containerType,  });  /// The route for current navigation.  ///  /// See [Routes] for details.  final String route;  /// The current status of this animation.  final ContainerType containerType;}

enum ContainerType тип контейнера, котрый будет задаваться декларативно из вызываемого кода.
RouteBundle класс-холдер данных отвечающих конфигурацию нового route.


Как вы могли заметить у я использовал метод _buildRoute. Именно он отвечает за то, кой тип route будет вызван.


Route<T> _buildRoute<T>({@required RouteBundle bundle, @required Widget child}) {    assert(bundle.containerType != null, "The bundle.containerType [RouteBundle.containerType] is null");    switch (bundle.containerType) {      case ContainerType.scaffold:        return CupertinoPageRoute<T>(          title: bundle.title,          builder: (BuildContext context) => child,          settings: RouteSettings(name: bundle.route),        );      case ContainerType.dialog:        return DialogRoute<T>(          title: '123',          settings: RouteSettings(name: bundle.route),          pageBuilder: (BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {                        return child;                    },        );      case ContainerType.bottomSheet:        return ModalBottomSheetRoute<T>(          settings: RouteSettings(name: bundle.route),          isScrollControlled: true,          builder: (BuildContext context) => child,        );      case ContainerType.window:        return CupertinoPageRoute<T>(          settings: RouteSettings(name: bundle.route),          builder: (BuildContext context) => child,        );      default:        throw Exception('ContainerType is not found');    }  }

Думаю что в этой функции стоит рассказать о ModalBottomSheetRoute и DialogRoute, которые использую. Исходный код этих route позаимствован из раздела Material исходного кода Flutter.


Осталось сделать метод back.


@overrideFuture<bool> back<T>({T data, bool rootNavigator = false}) async {    NavigatorState rootState = rootNavigatorKey.currentState;  return await (rootState).maybePop<T>(data);}

Ну и конечно перед использованием сервиса необходимо передать rootNavigatorKey в App следующим образом:


MaterialApp(    navigatorKey: widget.router.rootNavigatorKey,    home: Home());

Кодовая база для нашего сервиса готова, давайте вызовем наш route. Для этого создадим инстанс нашего сервиса и каким-либо образом "прокинуть" в объект, который будет вызывать этот инстанс, например при помощи Dependency Injection.



router.routeTo(RouteBundle(route: '/my_page', containerType: ContainerType.window));

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


  • Декларативный вызов навигации
  • Отказ от BuildContext по средствам GlobalKey
  • Модульность достигнута возможностью конфигурирования route относительно имени пути и контейнера для View

Итог


В Фреймворке Flutter существуют различные методы для навигации, которые дают преимущества и недостатки.


Ну и конечно полезные ссылки:
Мой телеграм канал
Мои друзья Flutter Dev Podcast
Вакансии Flutter разработчиков

Подробнее..

Архитектурный шаблон MVI в Kotlin Multiplatform. Часть 3 тестирование

27.08.2020 16:05:58 | Автор: admin


Эта статья является заключительной в серии о применении архитектурного шаблона MVI в Kotlin Multiplatform. В предыдущих двух частях (часть 1 и часть 2) мы вспомнили, что такое MVI, создали общий модуль Kittens для загрузки изображений котиков и интегрировали его в iOS- и Android-приложения.

В этой части мы покроем модуль Kittens модульными и интеграционными тестами. Мы узнаем о текущих ограничениях тестирования в Kotlin Multiplatform, разберёмся, как их преодолеть и даже заставить работать в наших интересах.

Обновлённый пример проекта доступен на нашем GitHub.

Пролог


Нет никаких сомнений в том, что тестирование важный этап в разработке программного обеспечения. Конечно, оно замедляет процесс, но при этом:

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

Последний пункт на первый взгляд может показаться недостатком, потому как отнимает время. Однако это делает код более читаемым и приносит пользу в долгосрочной перспективе.

Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. ...[Therefore,] making it easy to read makes it easier to write. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship

Kotlin Multiplatform расширяет возможности тестирования. Эта технология добавляет одну важную особенность: каждый тест автоматически выполняется на всех поддерживаемых платформах. Если поддерживаются, например, только Android и iOS, то количество тестов можно умножить на два. И если в какой-то момент добавляется поддержка ещё одной платформы, то она автоматически становится покрытой тестами.

Тестирование на всех поддерживаемых платформах важно, потому что могут быть различия в поведении кода. Например, у Kotlin/Native особенная модель памяти, Kotlin/JS тоже иногда даёт неожиданные результаты.

Прежде чем идти дальше, стоит упомянуть о некоторых ограничениях тестирования в Kotlin Multiplatform. Самое большое из них это отсутствие какой-либо библиотеки моков для Kotlin/Native и Kotlin/JS. Это может показаться большим недостатком, но я лично считаю это преимуществом. Мне довольно трудно давалось тестирование в Kotlin Multiplatform: приходилось создавать интерфейсы для каждой зависимости и писать их тестовые реализации (fakes). На это уходило много времени, но в какой-то момент я понял, что трата времени на абстракции это инвестиция, которая приводит к более чистому коду.

Я также заметил, что последующие модификации такого кода требуют меньше времени. Почему так? Потому что взаимодействие класса с его зависимостями не прибито гвоздями (моками). В большинстве случаев достаточно просто обновить их тестовые реализации. Нет необходимости углубляться в каждый тестовый метод, чтобы обновить моки. В результате я перестал использовать библиотеки моков даже в стандартной Android-разработке. Я рекомендую прочитать следующую статью: "Mocking is not practical Use fakes" (автор Pravin Sonawane).

План


Давайте вспомним, что у нас есть в модуле Kittens и что нам стоит протестировать.

  • KittenStore основной компонент модуля. Его реализация KittenStoreImpl содержит бОльшую часть бизнес-логики. Это первое, что мы собираемся протестировать.
  • KittenComponent фасад модуля и точка интеграции всех внутренних компонентов. Мы покроем этот компонент интеграционными тестами.
  • KittenView публичный интерфейс, представляющий UI, зависимость KittenComponent.
  • KittenDataSource внутренний интерфейс для доступа к Сети, который имеет платформенно-зависимые реализации для iOS и Android.

Для лучшего понимания структуры модуля приведу его UML-диаграмму:



План следующий:

  • Тестирование KittenStore
    • Создание тестовой реализации KittenStore.Parser
    • Создание тестовой реализации KittenStore.Network
    • Написание модульных тестов для KittenStoreImpl

  • Тестирование KittenComponent
    • Создание тестовой реализации KittenDataSource
    • Создание тестовой реализации KittenView
    • Написание интеграционных тестов для KittenComponent

  • Запуск тестов
  • Выводы


Модульное тестирование KittenStore


Интерфейс KittenStore имеет свой класс реализации KittenStoreImpl. Именно его мы и собираемся тестировать. Он имеет две зависимости (внутренние интерфейсы), определённые прямо в самом классе. Начнём с написания тестовых реализаций для них.

Тестовая реализация KittenStore.Parser


Этот компонент отвечает за сетевые запросы. Вот как выглядит его интерфейс:


TestKittenStoreNetwork имеет хранилище строк (как и настоящий сервер) и может их генерировать. По каждому запросу текущий список строк кодируется в одну строку. Если свойство images равно нулю, то Maybe просто завершится, что должно рассматриваться как ошибка.

Мы также использовали TestScheduler. У этого планировщика есть одна важная функция: он замораживает все поступающие задачи. Таким образом, оператор observeOn, используемый вместе с TestScheduler, будет замораживать нисходящий поток, а также все данные, проходящие через него, прямо как в реальной жизни. Но в то же время многопоточность не будет задействована, что упрощает тестирование и делает его надёжнее.

Кроме того, TestScheduler имеет специальный режим ручной обработки, который позволит нам моделировать сетевые задержки.

Тестовая реализация KittenStore.Parser


Этот компонент отвечает за разбор ответов от сервера. Вот его интерфейс:


Как и в случае с Network, используется TestScheduler для замораживания подписчиков и проверки их совместимости с моделью памяти Kotlin/Native. Ошибки обработки ответов моделируются, если входная строка пуста.

Модульные тесты для KittenStoreImpl


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

Первый шаг создать экземпляры наших тестовых реализаций:


Теперь можно провести несколько тестов. KittenStoreImpl должен загружать изображения сразу после создания. Это означает, что должен быть выполнен сетевой запрос, его ответ должен быть обработан, а состояние должно быть обновлено с новым результатом.


Этапы теста:

  • сгенерировать исходные изображения;
  • создать экземпляр KittenStoreImpl;
  • сгенерировать новые изображения;
  • отправить Intent.Reload;
  • убедиться, что состояние содержит новые изображения.

И наконец давайте проверим следующий сценарий: когда в состоянии установлен флаг isLoading во время загрузки изображений.


Есть две зависимости: KittenDataSource и KittenView. Нам понадобятся тестовые реализации для них, прежде чем мы сможем начать тестирование.

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



Тестовая реализация KittenDataSource


Этот компонент отвечает за сетевые запросы. У него есть отдельные реализации для каждой платформы, и нам нужна ещё одна реализация для тестов. Вот как выглядит интерфейс KittenDataSource:


Как и раньше, мы генерируем разные списки строк, которые кодируются в массив JSON при каждом запросе. Если изображения не сгенерированы или аргументы запроса неверные, Maybe просто завершится без ответа.

Для формирования JSON-массива используется библиотека kotlinx.serialization. Кстати, тестируемый KittenStoreParser использует её же для декодирования.

Тестовая реализация KittenView


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


Нам просто нужно запоминать последнюю принятую модель это позволит проверить правильность отображаемой модели. Мы также можем отправлять события от имени KittenView с помощью метода dispatch(Event), который объявлен в наследуемом классе AbstractMviView.

Интеграционные тесты для KittenComponent


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

Как и раньше, давайте начнём с создания экземпляров зависимостей и инициализации:


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


Этапы:

  • сгенерировать исходные ссылки на изображения;
  • создать и запустить KittenComponent;
  • сгенерировать новые ссылки;
  • отправить Event.RefreshTriggered от имени KittenView;
  • убедиться, что новые ссылки достигли TestKittenView.


Запуск тестов


Чтобы запустить все тесты, нам нужно выполнить следующую Gradle-задачу:

./gradlew :shared:kittens:build

Это скомпилирует модуль и запустит все тесты на всех поддерживаемых платформах: Android и iosx64.

А вот JaCoCo-отчёт о покрытии:



Заключение


В этой статье мы покрыли модуль Kittens модульными и интеграционными тестами. Предложенный дизайн модуля позволил охватить следующие части:

  • KittenStoreImpl содержит бОльшую часть бизнес-логики;
  • KittenStoreNetwork отвечает за сетевые запросы высокого уровня;
  • KittenStoreParser отвечает за разбор сетевых ответов;
  • все преобразования и связи.

Последний пункт очень важен. Охватить егои возможно благодаря особенности MVI. Единственная ответственность представления отображать данные и отправлять события. Все подписки, преобразования и связи выполняются внутри модуля. Таким образом, мы можем покрыть общими тестами всё, кроме непосредственно отображения.

Такие тесты имеют следующие преимущества:

  • не используют платформенные API;
  • выполняются очень быстро;
  • надёжные (не мигают);
  • выполняются на всех поддерживаемых платформах.

Мы также смогли проверить код на совместимость со сложной моделью памяти Kotlin/Native. Это тоже очень важно из-за отсутствия безопасности во время сборки: код просто падает во время выполнения с исключениями, которые трудно отлаживать.

Надеюсь, это поможет вам в ваших проектах. Спасибо, что читали мои статьи! И не забудьте подписаться на меня в Twitter.



Бонусное упражнение


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

Рефакторинг KittenDataSource


В модуле существуют две реализации интерфейса KittenDataSource: одна для Android и одна для iOS. Я уже упоминал, что они отвечают за доступ к сети. Но на самом деле у них есть ещё одна функция: они генерируют URL-адрес для запроса на основе входных аргументов limit и page. В то же время у нас есть класс KittenStoreNetwork, который ничего не делает, кроме делегирования вызова в KittenDataSource.

Задание: переместить логику генерирования URL-запроса из KittenDataSourceImpl (на Android и iOS) в KittenStoreNetwork. Вам нужно изменить интерфейс KittenDataSource следующим образом:



Как только вы это сделаете, вам нужно будет обновить тесты. Единственный класс, к которому вам потребуется прикоснуться, это TestKittenDataSource.

Добавление постраничной загрузки


TheCatAPI поддерживает разбивку на страницы, поэтому мы можем добавить эту функцию для лучшего взаимодействия с пользователем. Вы можете начать с добавления нового события Event.EndReached для KittenView, после чего код перестанет компилироваться. Затем вам нужно будет добавить соответствующий Intent.LoadMore, преобразовать новый Event в Intent и обработать последний в KittenStoreImpl. Вам также потребуется изменить интерфейс KittenStoreImpl.Network следующим образом:



Наконец, вам потребуется обновить некоторые тестовые реализации, исправить один или два существующих теста, а затем написать несколько новых, чтобы покрыть разбивку на страницы.

Подробнее..

Как внедряли Kotlin Multiplatform в Профи

07.05.2021 16:08:17 | Автор: admin

Привет, Хабр! Я Миша Игнатов, тимлид в компании Профи. Моя команда отвечает за клиентские мобильные приложения на Android и iOS. Мы используем Kotlin Multiplatform в production с 2019 года. Расскажу, почему мы выбрали именно эту технологию, как внедряли её, какие ключевые этапы прошли и какие сделали выводы.

Коротко о Kotlin Multiplatform

Kotlin Multiplatform позволяет запускать один и тот же код, написанный на Kotlin, на множестве платформ. В августе 2020 года компания JetBrains представила Kotlin Multiplatform Mobile (КММ) SDK, который помогает упростить использование общего кода на Android и iOS. Цель технологии вынос бизнес-логики. UI-слой остаётся нативным, что хорошо сказывается на опыте пользователя и внешнем виде приложений.

Почему мы выбрали Kotlin Multiplatform

Мы изучали разные кросс-платформенные технологии. Например, React Native и Flutter позволяют писать сразу всё в одном проекте на обе платформы, но ограничивают разработчика языком и набором библиотек. Остановились на Kotlin Multiplatform по трём причинам.

  1. Легко интегрировать

    Общий код, написанный на Kotlin, можно внедрить с минимальными усилиями в готовое приложение. Он компилируется в привычные для платформ библиотеки. Для Android это jar или aar-библиотека, для iOS Universal Framework. Подключение и дальнейшая работа не сильно отличаются от взаимодействия с любой нативной библиотекой.

  2. Синтаксис языка Kotlin близок к Swift

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

  3. Не нужно тратить ресурсы дважды на одну задачу

    Бизнес-логика наших приложений одинаковая. Более 70% кода не связано с платформой, на которой его запускают. Мы запрашиваем данные с сервера, преобразуем их, кешируем и готовим к отображению. Поэтому пишем код в двух проектах, дублируя логику, Android на языке Kotlin и iOS на Swift. Отличия есть только в дизайне из-за разного UX на мобильных платформах и взаимодействия с системой (запросы к различной периферии: камера, геолокация, галерея, уведомления и т.д.).

Как внедряли

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

Шаг 1. Первая строчка в общем коде

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

Обмен данными с сервером у нас реализован на GraphQL. Запрос в коде это multiline строка. Бывает пять строк, а бывает под сотню. Если отправить такой объём, бэкенду придётся тратить время на парсинг структуры. С другой стороны, нужно контролировать запрашиваемые данные во время код-ревью и валидации запросов на проде. Поэтому перед релизом мы обучаем сервер новым запросам. Это позволяет использовать хеши вместо строк.

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

Решили вынести в общий код несколько запросов. Для этого в Android-проекте сделали мультиплатформенный модуль shared. Перенесли в него строки запросов и обернули в классы-синглтоны object, а в клиентских приложениях вызывали методы этих классов. Забавный факт использовать КММ предложил iOS-разработчик.

Первая строчка в общем коде
package ru.profi.shared.queries.client.city/*** Запрос поиска города по [Params.term]*/object GeoSelectorWarpQuery : WarpQuery<Params> {   override val hash: String? = "\$GQLID{c9d4adbb7b9ef49fc044064b9a3e662b}"   override val dirtyQuery = listOf("\$term").let { (term) ->       """       query geoSelector($term: String) {         suggestions: simpleGeoSelector(term: $term, first: 100) {           edges {             node {               name               geoCityId               regionName               hostname               countryId             }           }         }       }       """   }.trimIndent()}
Использование в Android проекте
override fun getQuery() = GeoSelectorWarpQuery.getQuery()
Использование в iOS проекте
import KotlinComponentsstruct GraphQLWarpRequests {    static let GeoSelectorWarpQuery = GeoSelectorWarpQuery()...}let model = GraphQLRequestModel(query: GraphQLWarpRequests.GeoSelectorWarpQuery.getQuery(), variables: variables)

Теперь структуры запросов лежат в одном месте. В следующем релизе общую библиотеку подключили на обеих платформах, и всё работало без проблем. Размер приложения на iOS увеличился всего на 0,8 Мб. Впоследствии вынос запросов в общий код сократил количество подходов к обучению в два раза.

Проблему ручного обучения решили утилитарной библиотекой из нескольких классов, написанных на Kotlin. Она находит в коде необученные запросы, генерирует и отправляет новые хеши через pull request в репозиторий backend. Теперь мы не тратим время на обучение, оно полностью автоматизировано.

На этом шаге мы построили инфраструктуру для общего кода на Kotlin Мultiplatform. Можно переходить к более серьёзным задачам.

Шаг 2. Создаём мультиплатформенный SDK

В один момент компания решила создать свою in-house аналитику на базе Clickhouse. Для этого на стороне backend создали API для приложений. Моей команде оставалось только отправлять события. Чтобы не мешать работе основного функционала и не терять события, если у пользователя нет сети, нужно было научиться кешировать, группировать пачки событий и отправлять их с меньшим приоритетом, чем запросы на основной функционал.

Модуль решили писать в общем коде. Для отправки событий взяли network client ktor. Для работы с сетью он нас полностью устраивал.

Когда сети нет, события надо сохранить до следующего сеанса связи. Для этого выбрали SQLDelight мультиплатформенную библиотеку для нативной базы данных.

Для асинхронных операций использовали kotlinx.coroutines. Для сериализации и десериализации выбрали kotlinx.serialization.

Чтобы повысить надёжность кода, функционал модуля покрыли unit-тестами. Удобно, что их можно запускать на разных платформах.

При интеграции приложения на Android проблем не возникло, но на iOS были падения на старте. В консоли XCode и логах Firebase Crashlytics трассировка стека не сильно приближала нас к причине. Но было ясно, что падает внутри общего кода.

Чтобы получить понятную трассировку стека, мы подключили библиотеку CrashKiOS от студии Touchlab. А при создании корутины добавили CoroutineExceptionHandler, который перехватывает исключения во время их выполнения.

Оказалось, что отправка события происходила после отмены скоупа корутины. Это и приводило к падению. Причина мы неправильно отменяли CoroutineScope в жизненном цикле приложения.

Kotlin Multiplatform позволил объединить в один модуль ответственность за отправку и хранение аналитических событий. В итоге мы построили полноценный SDK в общем коде.

Шаг 3. Переносим бизнес-логику из приложения Android в мультиплатформу

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

В приложении на iOS был такой код в модуле бизнес-логики чатов. Это была наша боль. Добавлять новый функционал становилось всё дороже код написан на Objective-C с устаревшей и сложной архитектурой. Чувствовалось, что разработчики неохотно брали задачи по чатам.

В приложении на Android бизнес-логику чатов недавно уже переписали на Kotlin. Поэтому решили попробовать вынести существующий модуль в общий код и адаптировать его под iOS.

Нам помогли ребята из IceRock.dev. Они уже давно встали на путь мультиплатформы, активно продвигают KMM и развивают сообщество. Вместе мы составили план переезда.

  1. Настроить поддержку Kotlin Multiplatform в gradle-модуле.
    Создать модуль, подключить плагины, настроить sourceSets и зависимости.

  2. Перенести платформенно-независимые классы в commonMain.
    Перенести всё, что не зависит от JVM и Android, в commonMain. Это место для общего кода, в котором нет платформенных зависимостей.

  3. Заменить библиотеки JVM/Android на мультиплатформенные аналоги.
    Перейти с org.json на kotlinx.serialization и с JodaTime на klock. Некоторые части пришлось вынести в платформозависимый код в виде expect/actual.

  4. Перенести в commonMain JVM-зависимый код, который требует изменений.
    Например, заменить JVM IOException на kotlin.Exception, а ConcurrentHashMap на использование Stately.

  5. Перенести в commonMain Android-зависимый код, который требует изменений.
    Единственной зависимостью Android SDK был компонент Service, который работает с WebSocket. Стабильного мультиплатформенного аналога на Kotlin пока нет.

    Мы решили оставить нативные реализации в приложении и подключить их через интерфейс SocketService.

    Интерфейс SocketService
    interface SocketService {    /**     * Присоединиться по сокету к [chatUrl]. Все события из сокета необходимо отдавать в [callback]     */    fun connect(chatUrl: String, callback: (SocketEvent) -> Unit)    /**     * Отсоединиться от текущего подключения по сокету.     */    fun disconnect()    /**     * Отправить сообщение [msg] в текущем подключении по сокету     */    fun send(msg: String)}
    
  6. Сделать модуль API удобным для обеих платформ.
    Так как в iOS невозможно перехватить runtime-исключения из Kotlin, мы решили обрабатывать их внутри SDK и добавить в методы интерфейса callback onError. Поэтому пришлось немного переделать интерфейс взаимодействия с клиентскими приложениями.

При переносе кода в мультиплатформу мы сформировали алгоритм миграции модулей с бизнес-логикой в общий код. Теперь пользуемся им для выноса других модулей.

План от IceRock.dev помог нам двигаться увереннее и быстрее. Мы продолжаем созваниваться и делиться опытом разработки.

Что мы поняли

Kotlin Multiplatform помог сделать единый источник правды для бизнес-логики в клиентских приложениях Профи. UI и UX оставили для пользователя нативными. При грамотном проектировании интерфейса взаимодействия с общим кодом, изменение и расширение бизнес-логики происходят в одном месте, а клиентским приложениям нужно просто поддержать это.

Мы сэкономили ресурсы. При переносе модулей в Kotlin Multiplatform мы ощутили экономию времени на разработке модуль чатов на iOS не пришлось рефакторить. Вместо этого мы перенесли решение из Android-проекта в общий код и адаптировали его для iOS. Это обошлось дешевле, чем писать чаты с нуля.

Разработчики быстро освоились. Для Android-разработчиков оказались в новинку только мультиплатформенные библиотеки и настройка build-скрипта модуля. Остальное привычно и не вызывает сложностей. iOS-разработчикам было легко понять синтаксис языка, но пришлось покопаться в сборке через Gradle. Но сейчас каждый из них уже решил как минимум одну задачу в общем коде.

Главный минус технологии время сборки для iOS. Например, когда мы искали причину падения приложения, пересобирать общий код для iOS приходилось часто. При публикации общих модулей это не ощущается. С выпусками новых версий Kotlin скорость сборки растёт, что добавляет надежд на будущий комфорт разработки.

Иногда мы спотыкались о проблемы по незнанию. Когда мы начинали, информации по внедрению KMM было очень мало, поэтому набивали шишки сами. Сейчас сообщество Kotlin Multiplatform быстро развивается. Появляется всё больше статей и докладов на конференциях и митапах. Есть каналы в Slack и Telegram, библиотеки для Kotlin Multiplatform.

Оно того стоило

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

Сейчас у нас уже 10 общих модулей разной сложности, и мы продолжаем выносить бизнес-логику в общий код. Уверен, что Kotlin Multiplatform Mobile готов к покорению мира разработки мобильных приложений.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru