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

Dart

Как портировать SDK Flutter на ТВ-приставку для разработки и запуска приложений Android TV

15.04.2021 16:11:54 | Автор: admin

Недавно мы успешно портировали фреймворк Flutter на ТВ-приставку c открытой программной платформой RDK. В этой статье расскажем о трудностях, с которыми пришлось столкнуться, и предложим решения для успешного запуска и повышения производительности.

Учитывая, что программный стек RDK или Reference Design Kit сейчас активно используется для разработки OTT-приложений, голосового управления приставками и других продвинутых функций для видео по запросу (VoD), мы хотели разобраться, сможет ли Flutter работать на ТВ-приставке. Оказалось, что да, но, как это обычно бывает, есть нюансы.

Далее мы по шагам распишем процесс портирования и запуска Flutter на встраиваемых Linux-платформах и разберемся, как этот SDK с открытым исходным кодом от Google чувствует себя на железе с ограниченными ресурсами и ARM-процессорами.

Но прежде чем переходить непосредственно к Flutter и его преимуществам скажем пару слов об исходном решении, которое было задействовано на ТВ-приставке. На плате работала связка набор библиотек EFL + протокол Wayland, а рисование примитивов было реализовано из node.js на основе плагинного нативного модуля. Это решение неплохо себя показало с точки зрения производительности при отображении кадров, однако сам EFL отнюдь не самый новый фреймворк для отрисовки. А в режиме выполнения node.js со своим огромным event-loopом казался уже не самой перспективной идеей. В то же время Flutter мог позволить нам задействовать более производительную связку рендеринга.

Для тех, кто не в теме: первую версию этого SDK с открытым кодом Google представил еще шесть лет назад. Тогда этот набор средств разработки годился только для Android. Сейчас на нем можно писать приложения для веба, iOS, Linux и даже Google Fuchsia. :-) Рабочий язык для разработки приложений на Flutter Dart, в свое время он был предложен в качестве альтернативы JavaScript.

Перед нами стоял вопрос: даст ли переход на Flutter какой-то выигрыш по производительности? Ведь подход там совершенно иной, хоть в конечном счете и имеется та же графическая подсистема Wayland + OpenGL. Ну и как там с поддержкой процессоров с neon-инструкциями? Были и другие вопросы, например, нюансы по переносу UI на dart или то, что поддержка Linux находится в стадии альфы-беты.

Сборка Flutter Engine для ТВ-приставок на базе ARM

Итак, начнем. Вначале Futter нужно запустить на чужеродной платформе с Wayland + OpenGL ES. В основе рендеринга у Flutter лежит библиотека Skia, которая прекрасно поддерживает OpenGL ES, поэтому в теории все выглядело хорошо.

При сборке Flutter под наши целевые устройства (три ТВ-приставки с RDK), к нашему удивлению, проблемы возникли только на одной. Не будем с ней сражаться, т.к. из-за старой архитектуре intel x86 она для нас не является приоритетной. Лучше сосредоточимся на оставшихся двух ARM-платформах.

Вот, с какими опциями мы собирали Flutter Engine:

./flutter/tools/gn \      --embedder-for-target \      --target-os linux \      --linux-cpu arm \      --target-sysroot DEVICE_SYSROOT      --disable-desktop-embeddings \      --arm-float-abi hard      --target-toolchain /usr      --target-triple arm-linux-gnueabihf      --runtime-mode debugninja -C out/linux_debug_unopt_arm

Большинство опций понятны: собираем под 32-битный ARM-процессор и Linux, выключая при этом все лишнее через --embedder-for-target --disable-desktop-embeddings.

Для сборки в системе должен быть установлен clang версии 9 и выше, т.е. это стандартный сборочный механизм Flutter, инструментарий кросс-компиляции gcc не пойдет. Самое важное подать корректный target-sysroot устройства с RDK.

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

Теперь можно собрать целевой проект flutter/dart с нашей библиотекой/движком. Это сделать легко:

flutter --local-engine-src-path PATH_TO_BUILDED_ENGINE_src --local-engine=host_debug_unopt build bundle

Важно! Сборка проекта должна происходить не на устройстве с собранной библиотекой, а на хостовой, т.е. x86_64!

Для этого достаточно еще раз пройти путь сборкой gn и ninja только под x86_64! Именно она указывается в параметре host_debug_unopt.

PATH_TO_BUILDED_ENGINE_src это путь, где находится engine/src/out.

За запуск Flutter Engine под системой обычно отвечает embedder, именно он конфигурирует Flutter под целевую систему и дает основные контексты рендеринга библиотеке Skia и Dart-обработчику. Не так давно в состав Flutter добавили linux-embedder, и, в частности, GTK-embedder, так что можно воспользоваться им из коробки. На нашей платформе на момент портирования это был не вариант, нужно было что-то независимое от GTK.

Рассмотрим некоторые особенности реализации, которые пришлось учесть с кастомным эмбеддером (все, кто любит разбирать не нюансы, а исходники целиком, может сразу перейти к проекту нашего форка с доработками на github.com). К тому же по производительности наш вариант немного выигрывал у версии GTK, что было крайне важно для заказчика, и не тянул за собой весь зоопарк GTK-библиотек.

Так что же вообще нужно от эмбеддера для запуска flutter-приложения? Достаточно, чтобы он просто вызывал из библотеки flutter_engine.so

FlutterEngineRun(FLUTTER_ENGINE_VERSION, &config, &args, display /* userdata */, &engine_);

где в качестве параметров идет передача настроек проекта (директория с собранным flutter bundle) FlutterProjectArgs args и аргументов рендеринга FlutterRendererConfig config.

В первой структуре как раз задается путь bundle-пакета, собранного flutter-утилитой, а во второй используются контексты OpenGL .

// пример использования на github.com/DEgITx/flutter_wayland/blob/master/src/flutter_application.cc

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

Проблемы и их решение

Теперь поговорим о нюансах, с которыми мы столкнулись на этапе портирования. А как же без них? Не только ведь библиотеки собирать :-)

1. Краш эмбеддера и замена очередности вызова функций

Первая проблема, с которой мы столкнулись краш эмбеддера под платформой. Казалось бы, инициализация egl-контекста в других приложения происходит нормально, FlutterRendererConfig инициализирован корректно, но нет эмбеддер не заводится. Значит в связке что-то явно не так. Оказалось, eglBindAPI нельзя вызывать перед eglGetDisplay, на котором происходит особая инициализация nexus-драйвера дисплея (у нас платформа базируется на чипе BCM). В обычном Linux это не проблема, но на целевой платформе оказалась иначе.

Корректная инициализация эмбеддера выглядит так:

egl_display_ = eglGetDisplay(display_);if (egl_display_ == EGL_NO_DISPLAY) {  LogLastEGLError();  FL_ERROR("Could not access EGL display.");  return false;}if (eglInitialize(egl_display_, nullptr, nullptr) != EGL_TRUE) {  LogLastEGLError();  FL_ERROR("Could not initialize EGL display.");  return false;}if (eglBindAPI(EGL_OPENGL_ES_API) != EGL_TRUE) {  LogLastEGLError();  FL_ERROR("Could not bind the ES API.");  return false;}

// github.com/DEgITx/flutter_wayland/blob/master/src/wayland_display.cc корректная реализация, т.е. помогла измененная очередность вызова функций.

Теперь, когда нюанс запуска улажен, мы рады увидеть заветное демо-окно приложения на экране :-).

2. Оптимизация производительности

Настало время проверить производительность. И, честно говоря, она нас не сильно порадовала в режиме отладки (debug mode). Что-то работало шустро, что-то наоборот, имело большие просадки по фреймам и тормозило гораздо больше, чем что-то похожее на EFL+Node.js.

Мы немного расстроились и начали копать дальше. В SDK Flutter есть специальный режим компиляции машинного кода AOT, это даже не jit, а именно компиляция в нативный код со всеми сопутствующими оптимизациями, именно это подразумевается под по релиз-версией Flutter. Такой поддержки у нас в эмбеддере пока не было, добавляем.

Необходимы определенные инструкции, поданные аргументами к FlutterEngineRun

// полная реализация github.com/DEgITx/flutter_wayland/blob/master/src/elf.cc

vm_snapshot_instructions_ = dlsym(fd, "_kDartVmSnapshotInstructions");if (vm_snapshot_instructions_ == NULL) {  error_ = strerror(errno);  break;}vm_isolate_snapshot_instructions_ = dlsym(fd, "_kDartIsolateSnapshotInstructions");if (vm_isolate_snapshot_instructions_ == NULL) {  error_ = strerror(errno);  break;}vm_snapshot_data_ = dlsym(fd, "_kDartVmSnapshotData");if (vm_snapshot_data_ == NULL) {  error_ = strerror(errno);  break;}vm_isolate_snapshot_data_ = dlsym(fd, "_kDartIsolateSnapshotData");if (vm_isolate_snapshot_data_ == NULL) {  error_ = strerror(errno);  break;}
if (vm_snapshot_data_ == NULL || vm_snapshot_instructions_ == NULL || vm_isolate_snapshot_data_ == NULL || vm_isolate_snapshot_instructions_ == NULL) {  return false;}*vm_snapshot_data = reinterpret_cast <  const uint8_t * > (vm_snapshot_data_);*vm_snapshot_instructions = reinterpret_cast <  const uint8_t * > (vm_snapshot_instructions_);*vm_isolate_snapshot_data = reinterpret_cast <  const uint8_t * > (vm_isolate_snapshot_data_);*vm_isolate_snapshot_instructions = reinterpret_cast <  const uint8_t * > (vm_isolate_snapshot_instructions_);
FlutterProjectArgs args;// передаем все необходимое в argsargs.vm_snapshot_data = vm_snapshot_data;args.vm_snapshot_instructions = vm_snapshot_instructions;args.isolate_snapshot_data = vm_isolate_snapshot_data;args.isolate_snapshot_instructions = vm_isolate_snapshot_instructions;

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

$HOST_ENGINE/dart-sdk/bin/dart \--disable-dart-dev \$HOST_ENGINE/gen/frontend_server.dart.snapshot \--sdk-root $DEVICE_ENGINE}/flutter_patched_sdk/ \--target=flutter \-Ddart.developer.causal_async_stacks=false \-Ddart.vm.profile=release \-Ddart.vm.product=release \--bytecode-options=source-positions \--aot \--tfa \--packages .packages \--output-dill build/tmp/app.dill \--depfile build/kernel_snapshot.d \package:lib/main.dart$DEVICE_ENGINE/gen_snapshot                               \    --deterministic                                             \    --snapshot_kind=app-aot-elf                                 \    --elf=build/lib/libapp.so                                   \    --no-causal-async-stacks                                    \    --lazy-async-stacks                                         \    build/tmp/app.dill

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

-Ddart.vm.profile=release \
-Ddart.vm.product=release \

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

output-dill нужен для построения нативной библитеки libapp.so.

Самыми важными для нас являются пути $DEVICE_ENGINE и $HOST_ENGINE два собранных движка под целевую (ARM) и хост-системы (x86_64) соответственно. Тут важно ничего не перепутать и убедиться, что libapp.so получается именно 32-битной ARM-версией:

$ file libapp.so libapp.so: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked

Запускаем и-и-и-и... вуаля! все работает!

И работает шустрее значительно! Теперь уже можно говорить о сравнимой производительности и эффективности рендеринга с исходным приложением на базе набора библиотек EFL. Рендеринг работает почти без запинки и почти идеально на простых приложениях.

3. Подключение устройств ввода

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

4. Интерфейс на ТВ-приставке под Linux и Android и как увеличить производительность в 23 раза

Коснемся еще нескольких нюансов производительности, с которыми столкнулись в продуктовом UI-приложении. Нас очень обрадовала идентичность работы UI как на целевом устройстве, так и на Linux и Android. Уже сейчас Flutter может вполне может похвастаться очень гибкой портируемостью.

Еще отметим интересный опыт оптимизации самого dart-приложения под целевую платформу. Нас разочаровала довольно низкая производительность продуктового приложения (в отличии от демок). Мы взяли в руки профайлер и начали копать идовольно быстро обнаружили активное использование функций __brcm_cpu_dcache_flush и khrn_copy_8888_to_tf32 во время анимаций (на платформе используется чип процессора Broadcom/BCM ). Явно происходило какое-то очень жесткое пиксельное программное трансформирование или копирование во время анимаций. В итоге виновник был найден: в одной из панелей был задействован эффект размытия:

//...filter: new ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),//...

Комментирование одного этого эффекта дало увеличение производительности приложения в дватри раза и вывело приложение на стабильные 50-60 fps. Это хороший пример того, как один специфический эффект может уничтожить производительность во всем приложении на Flutter при том, что он был зайствован всего-лишь в одной панели, которая большую часть времени была скрыта.

Итого

В результате мы получили не просто работающее продуктовое приложение, а работающее приложение с качественным фреймрейтом на Flutter на целевом устройстве. Форк и наша версия эмбеддера под RDK и другие платформы на основе Wayland находится тут: github.com/DEgITx/flutter_wayland

Надеемся, опыт нашей команды в разработке и портировании ПО для ТВ-приставок и Smart TV пригодится вам в своих проектах и послужит отправной точкой для портирования Flutter на других устройствах.

[!?] Вопросы и комментарии приветствуются. На них будет отвечать автор статьи Алексей Касьянчук, наш инженер-программист

Подробнее..

Как я хотел поработать нативным Android разработчиком, но устроился Flutter разрабом

25.05.2021 18:15:50 | Автор: admin

Небольшое вступление

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

Ещё в декабре я познакомился с главным программистом IT-компании, которая находится в Сочи.

Я не буду оглашать имя компании в целях корпоративной тайны, это не суть. Компания довольно молодая, и поэтому использует более новые технологии. Я был удивлен, когда мне ответили, что им нужен Flutter разработчик, а не Java/Kotlin.

Так я и познакомился с Flutter.

Первые впечатления

Я был совершенно поражен когда впервые увидел сгенерированный проект мобильного приложения на Flutter. Все совершенно отличалось от обычной нативной разработки под Android.

Первое что бросалось в глаза - это совершенно другой язык, Dart.

Я сразу начал штудировать этот раздел и узнал, что Flutter - это Framework с декларативным стилем написания UI.

Мне никогда не был понятен данный стиль написания кода. Когда-то в прошлом я решил освоить React JS, но не смог его одолеть и забросил (в основном из-за глупости и лени). Зачем вообще декларативный стиль программирования? Есть же интуитивно понятно императивный: создал объект кнопки, добавил в родительский элемент и т.д.

Когда я увлекся Flutter, то осознал и понял главные преимущества такого подхода:

  • Меньше кода

  • Интуитивно понятный

  • Ускоренная разработка

  • Мощность

Возможно это произвучит чересчур громко. Все эти преимущества в той или иной мере правдивы.

Вот так, к примеру, выглядит разметка UI приложения, сгенерированного Android Studio:

Scaffold(      appBar: AppBar(        title: Text("Counter App"),      ),      body: Center(child: Column(        mainAxisAlignment: MainAxisAlignment.center,        children: [      Text("You have pushed the button this many times: "),      SizedBox(height: 10),        Text("$counter",           style: Theme.of(context).textTheme.headline4,          )    ],      ),),  floatingActionButton: FloatingActionButton(        onPressed: () { setState(() => counter = counter + 1); },    child: Icon(Icons.add),      ),);

Первая важная особенность такого кода: большое количество скобок и запятых, как бы смешно это не звучало)))

Вторая: большая гибкость и мощность, вы можно по-разному вкладывать друг в друга элементы и комбинировать их, создавая более сложный UI

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

И наконец, Dart имеет интересную фишку: можно поставить запятую в конце последнего параметра функции или конструктура класса, поэтому вы можете не париться по поводу последней запятой в вашем коде, когда делает разметку UI или передаете параметры функции.

Как вы уже догадались в любой технологии найдется уязвимое место. Какие оптимизации бы не сделал Flutter разработчик, его приложение все равно будет проигрывать в скорости работы приложения, написаного на Java / Kotlin - это 100% очевидно (данная проблема проявляется не во всех ситуациях).

Первое приложение

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

Я начал курить flutter.dev, прочитал довольно много полезных материалов на данную тему.

В результате, я решил использовать обертку sqlite для Android и iOS - sqflite.

Сразу стоит отметить, что подключение большинства библиотек (pub-пакетов) осуществляется через специальный файл pubspec.yaml, в отличие от build.gradle (Android).

Все пакеты Dart (включая подмножество Flutter) располагаются на сайте pub.dev

Как я позже узнал, Flutter позволяет использовать нативный код Android и iOS, что меня очень сильно порадовало.

Дальнейшие разработки

C февраля я был переведен на первый рабочий проект.

Я все больше стал понимать синтаксис языка Dart. Для тех, кто хочет довольно быстро с ним познакомиться покурите Dart Tour

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

Поэтому необходимо использовать либо Thread'ы (Java), либо Coroutines (Kotlin) в нативной разработке под Android

В Flutter это решается довольно просто, использованием асинхронных функций:

fun getArticles() async {  final response = await http.get("https://xxx.ru/rest/getArticles");  final List<Article> articles = decodeArticles(response.body);setState(() {    this.articles = articles;  });}

Возможно не совсем понятно для незнакомых с Flutter и декларативным стилем написания кода, вызов функции setState.

setState является функцией высшего порядка (Dart поддерживает функциональное программирование) и принимает другую функцию, как входной параметр.

Логика setState довольно простая: сначала выполняем функцию, которая была передана в качестве параметра, а затем перерисовываем все компоненты Flutter приложения. (на самом деле не все, Flutter сам решает, что нужно перерисовать, а что нет, дабы обеспечить эффективность работы).

В этом и состоит один из важнейших принципов декларативного подхода Flutter - принципа состояние.

Более подробно о состоянии: flutter.dev

При реализации моего первого рабочего приложения на Flutter я впервые столкнулся с проблемой архитектуры.

По большей части все данные Flutter приложения - это состояние (на момент выполнения приложения несомненно).

И поэтому в проектировании архитектуры Flutter приложения нужно руководствоваться одним из подходов по управления состоянием.

Я выбрал provider и не пожалел об этом. Данный подход довольно простой и изящный.

В апреле мой первый более менее рабочий проект был опубликован в Google Play и Apple Store

Мое личное мнение о Flutter

Я считаю, что Flutter - довольно неплохой кроссплатформенный framework для мобильной разработки, по моему мнению он не уступает своим конкурентам, таким как React Native например.

Большинство коммерческих проектов вполне могут быть реализованы на Flutter.

Основные преимущества Flutter по моему мнению:

  • Довольно мощный UI framework, позволяет сильно кастомизировать внешний вид приложения. Это также является важнейшим преимуществом по отношению к нативной Android разработке, т.к. создание кастомных View и написание дополнительного кода является не одной из самых простых задач;

  • Быстрая разработка - т.к. Flutter является кроссплатформенным инструментом для разработки, вам не нужно писать отдельно код для iOS и Android, что действительно повышает скорость разработки, но не во всех случаях работу самого приложения :)

  • Декларативный стиль обладает некоторыми преимуществами над императивным, как было отмечено выше

  • Функциональность - Flutter имеет огромное количество полезных компонентов, а также pub-пакетов, которые не раз меня выручали). Сейчас Flutter продолжает расти, в марте прошел Flutter Engage 2021

Причины по которым вы не должны использовать Flutter:

  • Высокая производительность - если каждая доля миллисекунды вам дорога, то несомненно в таком случае вам не стоит использовать Flutter

  • Кастомная отрисовка компонентов

  • Какие-либо нестандарные решения

  • Низкоуровневая работа с компонентами мобильной ОС

Заключение

Статья носит субъективное мнение и поэтому я могу ошибаться в некоторых, а может и во всех отношениях.

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

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

Полезные ссылки:

Подробнее..

Повышаем качество кода с Dart Code Metrics

13.04.2021 14:10:30 | Автор: admin

Dart Code Metrics это инструмент статического анализа кода, который позволяет собирать метрики по коду и предоставляет дополнительные правила для анализатора. Основная задача помогать разработчикам следить за качеством кода и улучшать его. В этой статье мы хотим поделиться возможностями инструмента с сообществом. Он помог нам в Wrike решить часть проблем на фронтенде, и, надеемся, поможет и вам.

Инструмент можно запускать из командной строки, подключать в виде плагина к Dart Analysis Server, а также в виде библиотеки. Запуск из командной строки позволяет легко интегрировать инструмент в процесс CI/CD, при этом результат анализа можно получить в одном из форматов: Сonsole, HTML, JSON, CodeClimate, GitHub. Подключение в виде плагина к Analysis Server позволяет получать оперативную обратную связь непосредственно в IDE.

Зачем мы решили создать такой инструмент? В Wrike уже написано примерно 2.5 миллиона строк кода на Dart. При таком размере кодовой базы невольно задумываешься о том, какова цена поддержки такого объема кода, как понять, что пора проводить рефакторинг, и с каких мест его стоит начать.

Вместе с Dart SDK нам поставляется Analyzer, у которого есть встроенный линтер с богатым набором правил. В официальном pub вы также можете встретить рекомендованные наборы правил: pedantic, effective_dart и т.д. Это помогает нам избежать глупых ошибок и поддерживать кодовую базу в той стилистике, которую предлагают авторы языка. Но мы столкнулись с тем, что нам не хватало аналитической информации по коду, а дальше завертелось.

Метрики

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

Сейчас анализатор собирает такие метрики:

  • Cyclomatic Complexity

  • Lines of Executable Code

  • Lines of Code

  • Number of Parameters

  • Number of Methods

  • Maximum Nesting

  • Weight of Class

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

Следующий шаг опираясь на данные нескольких метрик, инструмент находит антипаттерны в кодовой базе. На текущий момент реализовано всего два антипаттерна long-method и long-parameter-list. Мы думаем о том, чтобы расширить этот список.

Если с метриками типа Number of Parameters или Number of Methods достаточно легко разобраться, то как же считается, например, Cyclomatic Complexity?

Цикломатическая сложность части программного кода это количество линейно независимых маршрутов через программный код. Например, если исходный код не содержит никаких точек ветвления или циклов, то сложность равна единице, поскольку есть только единственный маршрут через код. Если код имеет единственный оператор if, содержащий простое условие, то существует два пути через код: первый если условие оператора if имеет значение true, второй если false. Подробнее про цикломатическую сложность можно почитать в статье на Википедии.

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

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

Правила

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

Почему мы решили добавить линтинг в отдельный пакет, а не сделать PR во встроенный анализатор? У нас иначе спроектированы правила наличие расширенной конфигурации, возможность для каждого правила настроить строгость проверки. Например, error будет валить пайплайн, в то время как warning или style нет.

Текущий список правил выглядит так:

Общие

  • avoid-unused-parameters

  • binary-expression-operand-order

  • double-literal-format

  • member-ordering

  • member-ordering-extended

  • newline-before-return

  • no-boolean-literal-compare

  • no-empty-block

  • no-equal-arguments

  • no-equal-then-else

  • no-magic-number

  • no-object-declaration

  • prefer-conditional-expressions

  • prefer-trailing-comma

Специально для использования с библиотекой Intl

  • prefer-intl-name

  • provide-correct-intl-args

Специально под Dart Angular

  • avoid-preserve-whitespace-false

  • component-annotation-arguments-ordering

  • prefer-on-push-cd-strategy

Актуальный список вы всегда можете посмотреть в документации.

Речь идет не только про стилистические правила, но и про те, которые подсвечивают потенциальные ошибки: например, no-equal-then-else, no-equal-arguments и другие.

Часть наших правил основаны на проблемах, с которыми мы сталкиваемся на ревью и которые хотим покрывать автоматически, чтобы иметь возможность сосредотачиваться на самом главном. Другая часть появилась в процессе изучения списка правил таких инструментов, как PVS-Studio, TSLint, ESLint (большое спасибо им за вдохновение).

Рассмотрим подробнее некоторые из них.

Avoid unused parameters. Проверяет наличие неиспользуемых параметров у функций или методов. Наличие неиспользуемого параметра может говорить как о том, что параметр перестал быть нужен в ходе рефакторинга, так и том, что где-то в теле функции или метода используется какая-то переменная вместо него. Если в первой ситуации правило помогает убрать неиспользуемый код, то во второй указывает на возможную ошибку.

Простой пример:

String method(String value) => ;

Здесь параметр value не используется, и для него анализатор выведет сообщение Parameter is unused.

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

Например:

String method(String _) => ;

Prefer trailing comma. Проверяет последнюю запятую для аргументов, параметров, перечислений и коллекций при условии, что они занимают несколько строк.

Например:

void function(String firstArgument, String secondArgument, String thirdArgument) {return;}

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

void function(String firstArgument,String secondArgument,String thirdArgument,) {return;}

Если параметры изначально помещались на одной строке, то анализатор не будет считать это ошибкой:

void function(String arg1, String arg2, String arg3) {return;}

Для правила также можно указать параметр break-on, который включает дополнительную проверку для указанного количества элементов. Например, без параметра break-on пример выше считается корректным. Но если сконфигурировать правило на break-on: 2, то анализатор покажет ошибку для этой функции и предложит добавить запятую.

No equal arguments. Проверяет передачу одного и того же аргумента более одного раза при создании инстанса класса или вызове метода/функции.

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

class User {final String firstName;final String lastName;const User(this.firstName, this.lastName);}User createUser(String lastName) {String firstName = getFirstName();return User(firstName,firstName,);}

Оба поля класса User являются строкам. Легко не заметить, что при его создании передается одна и та же переменная. В таких случаях правило покажет, что переменная firstName передается более одного раза, и это может быть ошибкой.

Member ordering extended. Проверяет порядок членов класса. Это правило получило постфикс extended: у нас уже было правило Member ordering, но оно было не настолько гибким.

Правило принимает конфигурацию порядка по достаточно гибкому шаблону. Он позволяет указывать не только тип члена класса (поле, метод, конструктор и др.), но и такие ключевые слова, как late, const, final или, например, признак nullable.

Конфигурация может быть задана, например, так:

- public-late-final-fields

- private-late-final-fields

- public-nullable-fields

- private-nullable-fields

- named-constructors

- factory-constructors

- getters

- setters

- public-static-methods

- private-static-methods

- protected-methods

- etc.

Или упрощена до:

- fields

- methods

- setters

- getters (или просто **getters-setters** если нет необходимости разделять)

- constructors

Подробнее про возможности правила можно почитать в документации.

Дополнительно правило может требовать сортировку по алфавиту. Для этого в его конфигурацию нужно передать `alphabetize: true`.

Отчеты

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

  • Console

  • HTML

  • JSON

  • CodeClimate

  • GitHub

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

Например, при выполнении команды:

$ dart pub run dart_code_metrics:metrics lib  # or for a Flutter package$ flutter pub run dart_code_metrics:metrics lib

При выполнении команды на кодовой базе Dart Code Metrics мы получили такой отчет в консоль:

Если захотим выбрать другой тип репортера (например, HTML), то нужно выполнить следующую команду:

$ dart pub run dart_code_metrics:metrics lib --reporter=html  # or for a Flutter package$ flutter pub run dart_code_metrics:metrics lib --reporter=html

Сгенерируется отчет в папке metrics. Результирующую папку также можно передать с помощью флага --output-directory или -o:

В отчете можно посмотреть каждый файл отдельно:

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

Если вы используете GitHub Workflows и хотите получить репорт сразу в созданных PR, необходимо добавить в пайплайн новый шаг:

jobs:your_job_name:...steps:...- name: Run Code Metricsrun: dart run bin/metrics.dart --reporter=github lib

Это позволит получить отчеты в таком формате:

Подробную информацию про все типы метрик и способы конфигурации можно посмотреть в документации.

Как подключить

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

Шаг 1: установите пакет как dev-зависимость.

$ dart pub add --dev dart_code_metrics# or for a Flutter package$ flutter pub add --dev dart_code_metrics

ИЛИ

Добавьте пакет вручную в pubspec.yaml.

Важно: если ваш пакет еще не переведен на null safety, используйте версию 2.4.1.

dev_dependencies:dart_code_metrics: ^3.0.0

Запустите команду установки зависимостей.

$ dart pub get# or for a Flutter package$ flutter pub get

Шаг 2: добавьте конфигурацию в analysis_options.yaml.

analyzer:  plugins:    - dart_code_metrics dart_code_metrics:  anti-patterns:    - long-method    - long-parameter-list  metrics:    cyclomatic-complexity: 20    lines-of-executable-code: 50    number-of-parameters: 4    maximum-nesting-level: 5  metrics-exclude:    - test/**  rules:    - newline-before-return    - no-boolean-literal-compare    - no-empty-block    - prefer-trailing-comma    - prefer-conditional-expressions    - no-equal-then-else

В этой конфигурации указаны антипаттерны long-method и long-parameter-list, метрики с их пороговыми значениями и правила. Весь доступный список метрик можно посмотреть здесь, а список правил здесь.

Шаг 3: перезагрузите IDE, чтобы анализатор смог обнаружить плагин.

Если вы хотите использовать пакет как CLI, то с документацией по использованию можно ознакомиться здесь.

Заключение

Учитывая популярность Flutter, мы думаем над тем, какие метрики и правила могли бы помочь разработчикам на этом фреймворке. При этом мы открыты к предложениями: если у вас есть идеи правил или метрик, которые могли бы быть полезны разработчикам, пишущим на Dart или Flutter, смело пишите нам или оставляйте issue на GitHub. Мы будем рады добавить их и сделать жизнь разработчиков еще лучше.

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

Подробнее..

Wrike уходит от использования языка Dart. Часть 1

16.04.2021 16:20:16 | Автор: admin

Данной статьёй мы хотим пролить свет на технический стек Wrike: каким он был раньше и каким мы видим его в будущем. Мы расскажем о том, почему пять лет назад мы выбрали язык Dart основным для frontend-разработки нашего продукта и почему сейчас мы решили посмотреть в сторону других языков и фреймворков.

Что такое Wrike?

Для полноценного понимания наших технических решений необходимо рассказать, что такое Wrike как продукт. Wrike это большая SaaS платформа для управления проектами и совместной работы команд. Когда мы говорим большая, имеется в виду не только количество возможностей самого продукта (о которых вы можете почитать здесь), но и кодовая база. За годы своего развития, пока продукт рос и эволюционировал, мы прошли большой путь от:

Wrike, каким он был в 2014Wrike, каким он был в 2014

До:

Wrike 2021Wrike 2021

Столь же стремительно эволюционировали технический стек и команда разработки.

Если постараться рассказать на пальцах, что такое Wrike, то стоит отметить, что в мире управления проектами есть довольно много must have фич, без которых трудно себе представить полноценный продукт на этом рынке:

Gantt Chart, календари, таблицы и это далеко не полный набор возможностей WrikeGantt Chart, календари, таблицы и это далеко не полный набор возможностей Wrike

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

Краткая история технического стека

Wrike появился в 2006, но так далеко мы копать не будем. Историю frontend-разработки нового времени Райка можно условно поделить на несколько этапов, рассматривая последние шесть лет.

JS + EXT

На тот момент (2013-2014) мы уже написали достаточно внушительный объём кода на чистом JS, которому тогда не было альтернатив. В качестве основного движка (или фреймворка, если хотите) мы использовали Ext.js третьей версии. Несмотря на теперешнюю архаичность, вы будете удивлены, но он по-прежнему жив-здоров! На тот момент в нём было достаточно много прорывных возможностей, которые потом, через года, трансформировались в то, к чему мы привыкли сейчас. Например, data stores с некоторой натяжкой можно считать провозвестником привычных нам stores в React.

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

  • строгую типизацию

  • большие возможности из коробки

  • хорошую работу с большими объемами кода (сборка, минимизация и т.д.)

DART. Почему не TypeScript?

2014-2015 года были сложными с точки зрения принятия инженерных решений. Мы оказались перед выбором: использовать TypeScript, который тогда только-только вышел на стабильную версию или взять Dart, который был более зрелым, но менее распространенным. Подробнее вы можете прочесть тут.

Ключевыми моментами в нашем выборе стали:

  • Более строгая типизация. Как показало время, и Dart, и TypeScript двинулись в сторону более строгой системы типов. Dart полностью перешёл на sound систему типов, TypeScript по-прежнему имеет с этим некоторые сложности.

  • Возможности из коробки. Порой third-party libraries могут быть очень полезны, а порой вредны. Одна из проблем современного мира web, и ее TypeScript не решает, это обилие библиотек, которые могут помочь ускорить разработку, но которые при этом нужно выбрать, поддерживать и время от времени обновлять. Шутки про node_modules уже вошли в историю. Dart при этом имеет достаточно богатую встроенную библиотеку, core библиотеки обновляются и поддерживаются самим Google

  • Агрессивный Tree-Shaking. Так как Wrike имеет огромный набор фичей, которые в итоге превращаются в большой объём кода, язык должен был помогать нам не загружать большое количество кода на клиент (см. Minification is not enough, you need tree shaking by Seth Ladd, a также github).

Эти и некоторые другие особенности убедили нас сделать выбор в пользу Dart. И, оглядываясь назад на почти шестилетнюю историю Dart и Wrike, мы видим, что выбор был правильным. Конечно, мы прошли долгий путь от Dart 1.x с его динамической типизацией и интеграцией с Polymer до AngularDart и Dart 2.x. Dart помогал нам год от года растить продукт с инженерной и бизнесовой точки зрения, продвигая компанию и продукт в лидеры рынка Work Management Platforms (Gartner and Forrester ratings).

Текущее состояние

Сейчас мы написали на Dart уже 2.5 миллиона строк кода, а кодовая база состоит из 365 репозиториев. Мы создали большое количество решений для сборки и проверки Dart-кода: например, Dart Code Metrics. Без преувеличения отметим, что Wrike один из самых больших потребителей Dart за пределами Google, что касается его web-ипостаси (появление Flutter способствовало взрывному росту популярности Dart, но пока ещё по большей мере в мире мобильной разработки).

Однако реальность такова, что язык сам по себе не может дать все необходимые инструменты для построения большого web-приложения. Экосистема имеет не менее, а, может, и более важное значение. Системы сборки, подсветки синтаксиса, интернационализации, фреймворки для View без этого невозможно себе представить современную разработку.

Экосистема Dart

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

  • OverReact обёртка над React от Workiva.

  • Flutter for Web популярный кроссплатформенный фреймворк, написанный на Dart, с недавнего времени поддержка web вышла в стабильной версии.

  • AngularDart де-факто стандарт для разработки web-приложений на Dart.

Другие решения возможны, но неудобны либо трудно реализуемы. Таким образом, выбирая Dart для web-разработки, вы вынуждены взять один из этих трёх фреймворков либо писать что-то своё.

Главные причины нашего ухода от разработки на Dart

В предыдущей части мы дали краткий обзор на текущее состояние экосистемы языка Dart, касаясь в основном её frontend-части. Пришло время подойти к одной из ключевых проблем, которая на момент написания этой статьи, к сожалению, не решена: при всём желании невозможно выбрать техническое решение на века. Мир frontend-разработки постоянно движется вперёд, развиваясь, порой, даже быстрее, чем того хотелось бы. Веб-браузеры также развиваются, добавляя новые и новые возможности и расширяя API. Веб-фреймворки, которые, казалось бы, по определению созданы для того, чтобы абстрагироваться от нижележащих слоёв, тоже вынуждены реагировать на это.

Вдобавок к этому существуют и модные течения даже в весьма хаотичном мире фронтенда. Какое-то время назад это был прогрессивный рендеринг (React Fiber, Angular Ivy). Сейчас появляется тенденция в виде отказа от глобальных state managers, для примера можно рассмотреть Effector. GraphQL, Server Side Rendering можно найти достаточно много вещей, которые обязательно должны быть поддержаны в современном веб-фреймворке.

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

И в этом фундаменте есть два составляющих элемента:

  • Код, который ваши инженеры пишут.

  • Код, который ваши инженеры НЕ пишут.

Современная разработка (особенно на фронтенде) щедро сдобрена использованием third-party библиотек и инструментов. Да что там, сейчас можно запустить продукт на рынок, вообще не написав ни строчки кода (так называемый no-code подход)! Тем не менее, код, который вы не написали это, с одной стороны, время, которое вы сэкономили, а с другой риск, который вы берёте на себя.

Разработка крупного продукта это всегда сложный баланс между написанием собственных решений / переиспользованием готовых / взаимодействием с разработчиками сторонних фреймворков. И используемые язык и фреймворк как одни из самых обширных и всепроникающих частей разработки становятся её наиболее уязвимым местом. В былые годы, когда продукты распространялись на дисках и концепция Continuous Delivery ещё не появилась, смена языка или фреймворка могла стоить критически дорого. Сейчас же, особенно с появлением концепции micro frontends, это не только не должно быть трагедией, а, наоборот, служит здоровым механизмом эволюционного развития.

Со всем вышесказанным приходится признать, что мы пришли к точке, где нам приходится пересмотреть свой текущий технический стек как не отвечающий нашим требованиям. Несмотря на то, что язык Dart и его экосистема движутся вперёд (в том числе благодаря взрывному росту популярности Flutter), а язык Dart становится всё лучше и лучше (например, с null safety) один ингредиент всё равно отсутствует web-фреймворк. Да, в самом языке уже есть примитивы, которые позволяют работать с DOM напрямую, но такая разработка может подойти для индивидуальных разработчиков, а не для больших команд.

Под отсутствием web-фреймворка мы имеем в виду, что никакое из существующих решений для языка Dart не обладает четырьмя необходимыми для современного web-фреймворка качествами:

  • Feature richness. Обеспечение работы со всеми (или большинством) возможностей, которые предоставляет современный web.

  • Performance.

  • Поддержка сообщества.

  • Развитие и добавление новых возможностей.

Если более пристально посмотреть на существующие фреймворки для языка Dart, то мы увидим, что:

AngularDart

Де-факто стандарт для веб-приложений. Отвечал почти всем требованиям, но, к сожалению, Google-команда сдвинула приоритет его развития в сторону Flutter. Это следует не только из твиттера Tim Sneath (менеджер Dart & Flutter):

Переписка о судьбе AngularDartПереписка о судьбе AngularDart

Но и из более официальных источников. Также можно прочесть ветку на GitHub. Да, AngularDart по-прежнему на месте, он жив, его можно использовать. Но ему не хватает одного из ключевых элементов: Развитие и добавление новых возможностей.

OverReact

Портированная версия React для Dart. К сожалению, поддержка комьюнити не очень большая, а сам проект разрабатывается в основном компанией Workiva.

Flutter for Web

Именно на этот фреймворк делают ставку инженеры Google. И он действительно движется вперёд взрывными темпами! Но то, что подойдёт небольшим компаниям или при портировании мобильного приложения на web, к сожалению, не подходит для наших задач.

В данный момент для нас есть несколько блокеров, связанных с Flutter Web. Основной это то, что невозможно встроить Flutter-приложение внутрь текущего веб-приложения. Ведь, к сожалению, Flutter нельзя обернуть в веб-компонент. Это очень сильно мешает концепции микро-фронтендов, основная идея которой состоит в том, что всю функциональность мы делим на независимые приложения. Эти приложения деплоятся и разрабатываются разными командами и имеют слабую связанность друг с другом. Если вы хотите узнать больше, на это есть соответствующий баг. Выходом было бы заворачивать микро-фронтенды в iframe, но это сопряжено с рядом трудностей технического характера.

Помимо этого, Flutter пока не имеет ряда немаловажных для современного web возможностей, например SSR или SEO.

Немаловажный аспект связан и со скоростью приложения: пока не совсем ясно, насколько Flutter сможет справиться, допустим, с табличным представлением. Мы проведём это исследование, когда появится возможность встраивать Flutter-приложение внутрь другого.

Таким образом, несмотря на нашу любовь к Dart и годы, которые мы прошли вместе, мы приняли решение двигаться в сторону изменения нашего технического стека, так как основная задача компании обеспечить возможность разработки приложения и через 2 года, и дальше, а с AngularDart мы объективно не можем этого гарантировать.

Важно отметить, что мы не прощаемся: Dart по-прежнему будет большой частью нашей разработки. Но для новых проектов мы теперь будем рассматривать другие возможности. Какие?

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

Подробнее..

Как мы подружили Flutter с CallKit Call Directory

21.04.2021 14:19:32 | Автор: admin

Flutter+CallKitCallDirectory=Love


Привет!


В этом лонгриде я расскажу о том, как мы в Voximplant пришли к реализации собственного Flutter плагина для использования CallKit во Flutter приложении, и в итоге оказались первыми, кто сделал поддержку блокировки/определения номеров через Call Directory для Flutter.


Что такое CallKit


Apple CallKit это фреймворк для интеграции звонков стороннего приложения в систему.


Если звонок из стороннего приложения отображается как нативный, то тут задействован CallKit. Если звонок из стороннего приложения отображается в списке звонков системного приложения Phone тоже CallKit. Сторонние приложения, выступающие в качестве определителя номера CallKit. Звонки из сторонних приложений, которые не могут пробиться через режим Не беспокоить ну вы поняли.



CallKit предоставляет сторонним разработчикам системный UI для отображения звонков



А что с CallKit на Flutter?


CallKit является частью iOS SDK, во Flutter он не представлен, однако доступ к нему из Flutter возможен путём взаимодействия с нативным кодом. Для использования функциональности этого фреймворка потребуется подключить сторонний плагин, инкапсулирующий взаимодействие Flutter с iOS, или реализовывать всё самостоятельно, например, так:



Пример реализации CallKit сервиса для Flutter, где код iOS приложения (platform code) связывает приложение Flutter с системой




Готовые решения с CallKit на Flutter


Итак, нам потребовалось интегрировать наше Flutter приложение для VoIP звонков с системой. Первым делом мы рассмотрели большинство из существующих сторонних решений и на какое-то время воспользовались одним из них. Однако этот и остальные доступные варианты вели по пути наименьшего сопротивления, которому сопутствовали характерные проблемы.


Существующие плагины частично или полностью оборачивали CallKit API в собственный высокоуровневый API. Таким образом терялась гибкость, а некоторые возможности становились недоступными. Из-за собственной реализации архитектуры и интерфейсов такие плагины содержали свои баги. Документация хромала или отсутствовала, а авторы некоторых из них прекратили поддержку почти сразу, что особенно опасно на быстроразвивающемся Flutter.



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


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


Мы задумались о том, чтобы реализовать своё решение с учетом этих недостатков.


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



Наша Реализация


Нам удалось перенести всё CallKit API на Dart с сохранением иерархии классов и механизмов взаимодействия с ними.



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


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


Например, нативное CallKit API CXProviderDelegate.provider(_:execute:) требует синхронно возвращать Bool значение:


optional func provider(_ provider: CXProvider,     execute transaction: CXTransaction) -> Bool

Этот метод вызывается каждый раз, когда нужно обработать новую транзакцию CXTransaction. Можно вернуть true, чтобы обработать транзакцию самостоятельно и уведомить об этом систему. Вернув false, получим дефолтное поведение, при котором для каждого CXAction, содержащегося в транзакции, будет вызван соответствующий метод обработчик в CXProviderDelegate.


Для переноса этого API в плагин требовалось объявить его в Dart коде так, чтобы пользователь мог управлять этим поведением, несмотря на асинхронный характер обмена данными между платформами. Возвращая в нативном коде значение true, мы смогли перенести управление транзакциями в Dart код, где выполняем ручную или автоматическую обработку CXTransaction в зависимости от значения, полученного от пользователя.


Проблемы с асинхронностью возникают и в нативной части. Например, есть iOS фреймворк PushKit, он не является частью CallKit, но часто они используются вместе, так что интеграция с ним была необходима. При получении VoIP пуша требуется немедленно уведомить CallKit о входящем звонке в нативном коде, в противном случае приложение упадет. Для обработки этого нюанса мы решили дать возможность репортить входящие звонки напрямую в CallKit из нативного кода без асинхронного крюка в виде Flutter. В итоге для этой интеграции реализовали несколько хелперов в нативной части плагина (доступны через FlutterCallkitPlugin iOS класс) и несколько на стороне Flutter (доступны через FCXPlugin Dart класс).


Дополнительные возможности плагина мы объявили в его собственном классе, чтобы отделить интерфейс плагина от интерфейса CallKit.

Как зарепортить входящий звонок напрямую в CallKit

При получении VoIP пуша вызывается один из методов PKPushRegistryDelegate.pushRegistry(_: didReceiveIncomingPushWith:). Здесь необходимо создать экземпляр CXProvider и вызвать reportNewIncomingCall для уведомления CallKit о звонке. Так как для дальнейшей работы со звонком необходим тот же экземпляр провайдера, мы добавили метод FlutterCallkitPlugin.reportNewIncomingCallWithUUID с нативной стороны плагина. При его вызове плагин сам зарепортит звонок в CXProvider, а так же вызовет FCXPlugin.didDisplayIncomingCall хендлер на стороне Dart для продолжения работы со звонком.


func pushRegistry(_ registry: PKPushRegistry,                  didReceiveIncomingPushWith payload: PKPushPayload,                  for type: PKPushType,                  completion: @escaping () -> Void) {    // Достаем необходимые данные из пуша    guard let uuidString = payload["UUID"] as? String,        let uuid = UUID(uuidString: uuidString),        let localizedName = payload["identifier"] as? String    else {        return    }    let callUpdate = CXCallUpdate()    callUpdate.localizedCallerName = localizedName    let configuration = CXProviderConfiguration(        localizedName: "ExampleLocalizedName"    )        // Репортим звонок в плагин, а он зарепортит его в CallKit    FlutterCallkitPlugin.sharedInstance.reportNewIncomingCall(        with: uuid,        callUpdate: callUpdate,        providerConfiguration: configuration,        pushProcessingCompletion: completion    )}


Подводя итог: главной фишкой нашего плагина является то, что его использование на Flutter практически не отличается от использования нативного CallKit на iOS.


One more thing


Но оставалось ещё кое-что в Apple CallKit, что мы не реализовали у себя (и не реализовал никто в доступных сторонних решениях). Это поддержка Call Directory App Extension.



Что такое Call Directory


CallKit умеет блокировать и определять номера, доступ к этим возможностям для разработчиков открыт через специальное системное расширение Call Directory. Подробнее про iOS app extensions можно почитать в App Extension Programming Guide.



Call Directory app extension позволяет блокировать и/или идентифицировать номера


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


Например, при получении входящего звонка iOS пытается определить или найти звонящего в списке заблокированных стандартными средствами. Если номер не был найден, система может запросить данные у доступных Call Directory расширений, чтобы так или иначе обработать звонок. В этот момент расширение должно эти номера достать из некого хранилища номеров. Само приложение может заполнять это хранилище номерами из своих баз в любое время. Таким образом, взаимодействия между расширением и приложением нет, обмен данными происходит через общее хранилище.



Пример архитектуры для реализации Call Directory


Примеры с передачей номеров в Call Directory уже есть на хабре: раз и два.


Подробнее про iOS App Extensions: App Extension Programming Guide.



Call Directory Extension на Flutter


Не так давно нам написал пользователь с запросом на добавление поддержки Call Directory. Начав изучать возможность реализации этой фичи, мы выяснили, что сделать Flutter API без необходимости написания пользователями нативного кода не выйдет. Проблема заключается в том, что, как было сказано выше, Call Directory работает в расширении. Оно запускается системой, работает очень короткое время и не зависит от приложения (и в том числе от Flutter). Таким образом, для поддержки этого функционала пользователю плагина так или иначе потребуется реализовать app extension и хранилище данных самостоятельно.



Пример работы с Call Directory во Flutter приложении



Принятое решение


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


Проверив возможность работы такого расширения в связке с Flutter приложением, мы принялись за проектирование. Решение должно было сохранить все Call Directory Manager API, а также требовать от пользователя минимум написания нативного кода и быть удобным для взаимодействия через Flutter.


Так мы сделали версию 1.2.0 с поддержкой Call Directory Extension.



Как мы реализовывали Call Directory для Flutter


Итак, для реализации этого функционала требовалось учесть несколько аспектов:


  • Перенести интерфейс класса CXCallDirectoryManager (CallKit объект позволяющий управлять Call Directory)
  • Решить, что делать с app extension и хранилищем номеров для него
  • Создать удобный способ передачи данных из Dart в натив и обратно для управления списками номеров из Flutter приложения


Перенос интерфейсов CXCallDirectoryManager во Flutter


Код, приведенный в статье, был специально упрощен для облегчения восприятия, полную версию кода можно найти по ссылкам в конце статьи. Для реализации плагина мы использовали Objective-C, так как он был выбран основным в проекте ранее. Интерфейсы CallKit представлены на Swift для простоты.


Интерфейс


Первым делом посмотрим, что конкретно требуется перенести:


extension CXCallDirectoryManager {    public enum EnabledStatus : Int {        case unknown = 0        case disabled = 1        case enabled = 2    }}open class CXCallDirectoryManager : NSObject {    open class var sharedInstance: CXCallDirectoryManager { get }    open func reloadExtension(        withIdentifier identifier: String,        completionHandler completion: ((Error?) -> Void)? = nil    )    open func getEnabledStatusForExtension(        withIdentifier identifier: String,        completionHandler completion: @escaping (CXCallDirectoryManager.EnabledStatus, Error?) -> Void    )    open func openSettings(        completionHandler completion: ((Error?) -> Void)? = nil    )}

Воссоздадим аналог CXCallDirectoryManager.EnabledStatus энама в Dart:


enum FCXCallDirectoryManagerEnabledStatus {  unknown,  disabled,  enabled}

Теперь можно объявить класс и методы. Необходимости в sharedInstance в нашем интерфейсе нет, так что сделаем обычный Dart класс со static методами:


class FCXCallDirectoryManager {  static Future<void> reloadExtension(String extensionIdentifier) async { }  static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(    String extensionIdentifier,  ) async { }  static Future<void> openSettings() async { }}

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


Для API в Dart мы использовали более короткое название без слов-связок (длинное название пришло из objective-C) и заменили completion блок на Future. Future является стандартным механизмом, используемым для получения результата выполнения асинхронных методов в Dart. Мы также возвращаем Future из большинства Dart методов плагина, потому что коммуникация с нативным кодом происходит асинхронно.


Было getEnabledStatusForExtension(withIdentifier:completionHandler:)


Стало Future getEnabledStatus(extensionIdentifier)




Реализация


Для коммуникации между Flutter и iOS будем использовать FlutterMethodChannel.


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



On the Flutter side


Создадим объект MethodChannel:


const MethodChannel _methodChannel =  const MethodChannel('plugins.voximplant.com/flutter_callkit');


On the iOS side


Первым делом iOS класс плагина нужно подписать на протокол FlutterPlugin, чтобы иметь возможность взаимодействовать с Flutter:


@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>@end

При инициализации плагина создадим FlutterMethodChannel с таким же идентификатором, что мы использовали выше:


+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {    FlutterMethodChannel *channel        = [FlutterMethodChannel           methodChannelWithName:@"plugins.voximplant.com/flutter_callkit"          binaryMessenger:[registrar messenger]];    FlutterCallkitPlugin *instance         = [FlutterCallkitPlugin sharedPluginWithRegistrar:registrar];    [registrar addMethodCallDelegate:instance channel:channel];}

Теперь можно использовать этот канал для вызова iOS методов из Flutter.



Рассмотрим подробно реализацию методов в Dart и нативной части плагина на примере getEnabledStatus.



On the Flutter side


Реализация на Dart будет максимально проста и будет заключаться в вызове MethodChannel.invokeMethod с необходимыми аргументами, а также в обработке результата этого вызова.


Про MethodChannel

MethodChannel API позволяет асинхронно получить результат вызова из нативного кода посредством Future, но накладывает ограничения на передаваемые типы данных.




Итак, нам потребуется передать имя метода (его будем использовать в нативном коде для того, чтобы идентифицировать вызов) и аргумент extensionIdentifier в MethodChannel.invokeMethod, а затем преобразовать результат из простейшего типа int в FCXCallDirectoryManagerEnabledStatus. На случай ошибки в нативном коде следует обработать PlatformException.


static Future<FCXCallDirectoryManagerEnabledStatus> getEnabledStatus(  String extensionIdentifier,) async {  try {    // Воспользуемся объектом MethodChannel для вызова    // соответствующего метода в платформенном коде    // с аргументом extensionIdentifier.    int index = await _methodChannel.invokeMethod(      'Plugin.getEnabledStatus',      extensionIdentifier,    );    // Преобразуем результат в энам     // FCXCallDirectoryManagerEnabledStatus    // и вернем его значение пользователю    return FCXCallDirectoryManagerEnabledStatus.values[index];  } on PlatformException catch (e) {    // Если что-то пошло не так, обернем ошибку в собственный тип     // и отдадим пользователю    throw FCXException(e.code, e.message);  }}

Обратите внимание на идентификатор метода который мы использовали:


Plugin.getEnabledStatus


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


getEnabledStatus идентично названию метода во Flutter, а не в iOS (или Android).




On the iOS side


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


Вызовы через FlutterMethodChannel попадают в метод handleMethodCall:result:.


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


- (void)handleMethodCall:(FlutterMethodCall*)call                  result:(FlutterResult)result {    // Вызовы из Flutter можно идентифицировать по названию,    // которое передается в `FlutterMethodCall.method` проперти    if ([@"Plugin.getEnabledStatus" isEqualToString:call.method]) {        // При передаче аргументов с помощью MethodChannel,         // они упаковываются в `FlutterMethodCall.arguments`        // Извлечем extensionIdentifier, который         // мы передали сюда ранее из Flutter кода        NSString *extensionIdentifier = call.arguments;        if (isNull(extensionIdentifier)) {            // Если аргументы не валидны, вернём ошибку через             // `result` обработчик            // Ошибка должна быть упакована в `FlutterError`            // Она вылетит в виде PlatformException в Dart коде            result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);            return;}        // Теперь, когда метод обнаружен,        // а аргументы извлечены и провалидированы,         // можно реализовать саму логику        // Для взаимодействия с этой функциональностью CallKit // потребуется экземпляр CallDirectoryManager        CXCallDirectoryManager *manager             = CXCallDirectoryManager.sharedInstance;        // Вызываем метод CallDirectoryManager        // с требуемой функциональностью        // и ожидаем результата        [manager             getEnabledStatusForExtensionWithIdentifier:extensionIdentifier            completionHandler:^(CXCallDirectoryEnabledStatus status,                                            NSError * _Nullable error) {            // completion с результатом вызова запустился,             // можем пробросить результат в Dart            // предварительно сконвертировав его в подходящие типы,             // так как через MethodChannel можно передавать            // лишь некоторые определенные типы данных.            if (error) {                // Ошибки передаются упакованные в `FlutterError`                result([FlutterError errorFromCallKitError:error]);            } else {                // Номера передаются упакованные в `NSNumber`                // Так как этот энам представлен значениями `NSInteger`,                 // выполним требуемое преобразование                result([self convertEnableStatusToNumber:enabledStatus]);            }}];    }}


По аналогии реализуем оставшиеся два метода FCXCallDirectoryManager



On the Flutter side


static Future<void> reloadExtension(String extensionIdentifier) async {  try {    // Задаем идентификатор, передаем аргумент     // и вызываем платформенный метод    await _methodChannel.invokeMethod(      'Plugin.reloadExtension',      extensionIdentifier,    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}static Future<void> openSettings() async {  try {    // А этот метод не принимает аргументов     await _methodChannel.invokeMethod(      'Plugin.openSettings',    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.reloadExtension" isEqualToString:call.method]) {    NSString *extensionIdentifier = call.arguments;    if (isNull(extensionIdentifier)) {        result([FlutterError errorInvalidArguments:@"extensionIdentifier must not be null"]);        return;    }    CXCallDirectoryManager *manager         = CXCallDirectoryManager.sharedInstance;    [manager         reloadExtensionWithIdentifier:extensionIdentifier        completionHandler:^(NSError * _Nullable error) {        if (error) {            result([FlutterError errorFromCallKitError:error]);        } else {            result(nil);        }    }];}if ([@"Plugin.openSettings" isEqualToString:call.method]) {    if (@available(iOS 13.4, *)) {        CXCallDirectoryManager *manager             = CXCallDirectoryManager.sharedInstance;        [manager             openSettingsWithCompletionHandler:^(NSError * _Nullable error) {            if (error) {                result([FlutterError errorFromCallKitError:error]);            } else {                result(nil);            }        }];    } else {        result([FlutterError errorLowiOSVersionWithMinimal:@"13.4"]);    }}


Готово, CallDirectoryManager реализован и может быть использован.


Подробнее про Platform-Flutter взаимодействие



App Extension и хранилище номеров


Так как из-за нахождения Call Directory в iOS расширении мы не сможем предоставить его реализацию с плагином, а работа с платформенным кодом обычно непривычна для Flutter разработчиков, не знакомых с нативной разработкой, постараемся по максимуму помочь им с помощью Документации!


Реализуем полноценный пример app extension и хранилища и подключим их к example app нашего плагина.


В качестве простейшего варианта хранилища используем UserDefaults, которые обернем в propertyWrapper.


Примерно так выглядит интерфейс нашего хранилища:


// Доступ к хранилищу из iOS приложения@UIApplicationMainfinal class AppDelegate: FlutterAppDelegate {    @UserDefault("blockedNumbers", defaultValue: [])    private var blockedNumbers: [BlockableNumber]    @UserDefault("identifiedNumbers", defaultValue: [])    private var identifiedNumbers: [IdentifiableNumber]}// Доступ к хранилищу из app extensionfinal class CallDirectoryHandler: CXCallDirectoryProvider {    @UserDefault("blockedNumbers", defaultValue: [])    private var blockedNumbers: [BlockableNumber]    @UserDefault("identifiedNumbers", defaultValue: [])    private var identifiedNumbers: [IdentifiableNumber]    @NullableUserDefault("lastUpdate")    private var lastUpdate: Date?}


Код имплементации хранилища:


UserDefaults


Код iOS приложения:


iOS App Delegate


Код iOS расширения:


iOS App Extension


Обратите внимание, что примеры хранилища и расширения это не часть плагина, а часть example приложения, идущего в комплекте с ним.


Передача номеров из Flutter в iOS и обратно


Итак, app extension настроен и связан с хранилищем, необходимые методы CallDirectoryManager реализованы, осталась последняя деталь научиться передавать номера из Flutter в платформенное хранилище или, наоборот, запрашивать номера оттуда.


Наиболее простым вариантом кажется взвалить передачу данных на пользователя плагина, тогда ему придется самостоятельно организовывать MethodChannel или использовать другие сторонние решения по управлению хранилищем. И, безусловно, кому-то это даже подойдет! :) А для остальных сделаем простое и удобное API, чтобы пробрасывать номера прямо через наш фреймворк. Этот функционал будем делать опциональным, чтобы не ограничивать тех, кому удобнее использовать свои способы передачи данных.



Интерфейс


Посмотрим, какие интерфейсы могут понадобиться:


  • Добавление блокируемых/идентифицируемых номеров в хранилище
  • Удаление блокируемых/идентифицируемых номеров из хранилища
  • Запрос блокируемых/идентифицируемых номеров из хранилища


On the Flutter side


Для методов-хелперов мы ранее решили использовать классы плагина FCXPlugin (Flutter) и FlutterCallkitPlugin (iOS). Однако Call Directory является узкоспециализированным функционалом, который используется далеко не в каждом проекте. Поэтому хотелось вынести это в отдельный файл, но оставить доступ через объект класса FCXPlugin, для этого подойдет extension:


extension FCXPlugin_CallDirectoryExtension on FCXPlugin {  Future<List<FCXCallDirectoryPhoneNumber>> getBlockedPhoneNumbers()    async { }  Future<void> addBlockedPhoneNumbers(    List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeBlockedPhoneNumbers(List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeAllBlockedPhoneNumbers() async { }  Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers()    async { }  Future<void> addIdentifiablePhoneNumbers(List<FCXIdentifiablePhoneNumber> numbers,  ) async { }  Future<void> removeIdentifiablePhoneNumbers(List<FCXCallDirectoryPhoneNumber> numbers,  ) async { }  Future<void> removeAllIdentifiablePhoneNumbers() async { }}


On the iOS side


Чтобы со стороны Flutter получить доступ к номерам, которые находятся в неком хранилище на стороне iOS, пользователю плагина нужно будет как-то связать свою базу номеров с плагином. Для этого дадим ему такой интерфейс:



@interface FlutterCallkitPlugin : NSObject<FlutterPlugin>@property(strong, nonatomic, nullable)NSArray<FCXCallDirectoryPhoneNumber *> *(^getBlockedPhoneNumbers)(void);@property(strong, nonatomic, nullable)void(^didAddBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveBlockedPhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveAllBlockedPhoneNumbers)(void);@property(strong, nonatomic, nullable)NSArray<FCXIdentifiablePhoneNumber *> *(^getIdentifiablePhoneNumbers)(void);@property(strong, nonatomic, nullable)void(^didAddIdentifiablePhoneNumbers)(NSArray<FCXIdentifiablePhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveIdentifiablePhoneNumbers)(NSArray<FCXCallDirectoryPhoneNumber *> *numbers);@property(strong, nonatomic, nullable)void(^didRemoveAllIdentifiablePhoneNumbers)(void);@end


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


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


Реализация


Теперь реализуем связь между объявленными методами-хелперами во Flutter и обработчиками в iOS.


Методов много, а работают они +- одинаково, поэтому будем рассматривать два из них с противоположным направлением движения данных.

Get identifiable numbers



On the Flutter side


Future<List<FCXIdentifiablePhoneNumber>> getIdentifiablePhoneNumbers() async {  try {    // Вызываем платформенный метод и сохраняем результат    List<dynamic> numbers = await _methodChannel.invokeMethod(      'Plugin.getIdentifiablePhoneNumbers',    );    // Типизируем результат и возвращаем пользователю    return numbers      .map(        (f) => FCXIdentifiablePhoneNumber(f['number'], label: f['label']))      .toList();  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.getIdentifiablePhoneNumbers" isEqualToString:call.method]) {    if (!self.getIdentifiablePhoneNumbers) {        // Проверяем существует-ли обработчик,        // если нет  возвращаем ошибку        result([FlutterError errorHandlerIsNotRegistered:@"getIdentifiablePhoneNumbers"]);        return;    }    // Используя обработчик, запрашиваем номера у пользователя    NSArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers        = self.getIdentifiablePhoneNumbers();    NSMutableArray<NSDictionary *> *phoneNumbers        = [NSMutableArray arrayWithCapacity:identifiableNumbers.count];    // Оборачиваем каждый номер в словарь,     // чтобы иметь возможность передать их через MethodChannel     for (FCXIdentifiablePhoneNumber *identifiableNumber in identifiableNumbers) {        NSMutableDictionary *dictionary             = [NSMutableDictionary dictionary];        dictionary[@"number"]             = [NSNumber numberWithLongLong:identifiableNumber.number];        dictionary[@"label"]             = identifiableNumber.label;        [phoneNumbers addObject:dictionary];    }    // Отправляем номера во Flutter    result(phoneNumbers);}


Add identifiable numbers



On the Flutter side


Future<void> addIdentifiablePhoneNumbers(  List<FCXIdentifiablePhoneNumber> numbers,) async {  try {    // Готовим номера для передачи через MethodChannel    List<Map> arguments = numbers.map((f) => f._toMap()).toList();    // Отправляем номера в нативный код    await _methodChannel.invokeMethod(      'Plugin.addIdentifiablePhoneNumbers',      arguments    );  } on PlatformException catch (e) {    throw FCXException(e.code, e.message);  }}


On the iOS side


if ([@"Plugin.addIdentifiablePhoneNumbers" isEqualToString:call.method]) {    if (!self.didAddIdentifiablePhoneNumbers) {        // Проверяем существует-ли обработчик,        // если нет  возвращаем ошибку        result([FlutterError errorHandlerIsNotRegistered:@"didAddIdentifiablePhoneNumbers"]);        return;    }    // Достаем переданные в аргументах номера    NSArray<NSDictionary *> *numbers = call.arguments;    if (isNull(numbers)) {        // Проверяем их валидность        result([FlutterError errorInvalidArguments:@"numbers must not be null"]);        return;    }    NSMutableArray<FCXIdentifiablePhoneNumber *> *identifiableNumbers        = [NSMutableArray array];    // Типизируем номера    for (NSDictionary *obj in numbers) {        NSNumber *number = obj[@"number"];        __auto_type identifiableNumber            = [[FCXIdentifiablePhoneNumber alloc] initWithNumber:number.longLongValue                                                                                     label:obj[@"label"]];        [identifiableNumbers addObject:identifiableNumber];    }    // Отдаём типизированные номера в обработчик пользователю    self.didAddIdentifiablePhoneNumbers(identifiableNumbers);    // Сообщаем во Flutter о завершении операции    result(nil);}


Остальные методы реализуются по аналогии, полный код:




Примеры использования


Теперь переместимся на сторону пользователя получившегося плагина и посмотрим, как он может воспользоваться нашими интерфейсами.



Reload extension


Метод reloadExtension(withIdentifier:completionHandler:) используется для перезагрузки расширения Call Directory. Это может потребоваться, например, после добавления новых номеров в хранилище, чтобы они попали в CallKit.


Использование идентично нативному CallKit API: обращаемся к FCXCallDirectoryManager и запрашиваем перезагрузку по заданному extensionIdentifier:


final String _extensionID =  'com.voximplant.flutterCallkit.example.CallDirectoryExtension';Future<void> reloadExtension() async {  await FCXCallDirectoryManager.reloadExtension(_extensionID);}


Get identified numbers



On the Flutter side


Запрашиваем список идентифицируемых номеров через класс плагина из Flutter:


final FCXPlugin _plugin = FCXPlugin();Future<List<FCXIdentifiablePhoneNumber>> getIdentifiedNumbers() async {  return await _plugin.getIdentifiablePhoneNumbers();}


On the iOS side


Добавляем обработчик getIdentifiablePhoneNumbers, который плагин использует для передачи заданных номеров во Flutter. Будем передавать в него номера из нашего хранилища identifiedNumbers:


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance@UserDefault("identifiedNumbers", defaultValue: [])private var identifiedNumbers: [IdentifiableNumber]// Добавляем обработчик событий запроса номеровcallKitPlugin.getIdentifiablePhoneNumbers = { [weak self] in    guard let self = self else { return [] }    // Возвращаем номера из хранилища в обработчик    return self.identifiedNumbers.map {        FCXIdentifiablePhoneNumber(number: $0.number, label: $0.label)    }}


Теперь номера из пользовательского хранилища будут попадать в обработчик, а из него через плагин во Flutter.



Add identified numbers



On the Flutter side


Передаем номера, которые хотим идентифицировать, в объект плагина:


final FCXPlugin _plugin = FCXPlugin();Future<void> addIdentifiedNumber(String number, String id) async {  int num = int.parse(number);  var phone = FCXIdentifiablePhoneNumber(num, label: id);  await _plugin.addIdentifiablePhoneNumbers([phone]);}


On the iOS side


Добавляем обработчик didAddIdentifiablePhoneNumbers, который плагин использует для уведомления платформенного кода о получении новых номеров из Flutter. В обработчике сохраняем полученные номера в хранилище номеров:


private let callKitPlugin = FlutterCallkitPlugin.sharedInstance@UserDefault("identifiedNumbers", defaultValue: [])private var identifiedNumbers: [IdentifiableNumber]// Добавляем обработчик событий добавления номеровcallKitPlugin.didAddIdentifiablePhoneNumbers = { [weak self] numbers in    guard let self = self else { return }    // Сохраняем в хранилище номера, переданные плагином в обработчик    self.identifiedNumbers.append(        contentsOf: numbers.map {            IdentifiableNumber(identifiableNumber: $0)        }    )    // Номера в Call Directory обязательно должны быть отсортированы    self.identifiedNumbers.sort()}


Теперь номера из Flutter будут попадать в плагин, из него в обработчик события, а оттуда в пользовательское хранилище номеров. При следующей перезагрузке Call Directory расширения они станут доступны CallKit для идентификации звонков.


Полные примеры:




Итог


У нас получилось дать возможность использовать CallKit Call Directory из Flutter!


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


Теперь во Flutter можно относительно просто блокировать и/или определять номера с помощью нативного Call Directory.



Пример работы с Call Directory в Flutter приложении с использованием flutter_callkit_voximplant



Результаты:


  • Интерфейс CallDirectoryManager полностью перенесен
  • Добавлен простой способ передачи номеров из Flutter кода в iOS, оставлена возможность использовать собственные решения передачи данных
  • Архитектура решения описана в README с визуальными схемами для лучшего понимания
  • Добавлен полноценный работоспособный example app, использующий всю функциональность Call Directory, реализующий пример платформенных модулей (таких как iOS расширение и хранилище данных)


Полезные ссылки


Source код flutter_callkit на GitHub


Example app код на GitHub


Полная документация по использованию Call Directory с flutter_callkit


CallKit Framework Documentation by Apple


App Extension Programming Guide by Apple


Writing custom platform-specific code by Flutter

Подробнее..

Перевод Углубленный анализ тестирования виджетов во Flutter. Часть I testWidgets() и TestVariant

29.04.2021 20:16:38 | Автор: admin

Перевод подготовлен в рамках онлайн-курса "Flutter Mobile Developer".

Приглашаем всех желающих на бесплатный двухдневный интенсив Создаем приложение на Flutter для Web, iOS и Android. Узнать подробности и зарегистрироваться можно здесь.


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

Кроме того, какое-то время я не писал статьи, поэтому мне показалось отличной идеей вернуться к перу и рассказать, как мои неудачи в определенном деле в конечном счете привели к тому, что оно стало неотъемлемой частью разработки.

Давайте начнем.

Тестирование виджетов что же это такое на самом деле?

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

Может ли взмах крыльев бабочки в Бразилии вызвать торнадо в Техасе?

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

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

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

Основы тестирования

Тесты обычно находятся за пределами удобной и уютной папки lib и располагаются в собственной папке test.

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

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

Если пройтись по верхам, все выглядит довольно просто:

  1. В функции main(), судя по всему, находятся тесты.

  2. Функция testWidgets(), как следует из названия, содержит сам тест.

  3. Внутри функции testWidgets() имеется описание теста и место для написания собственно кода теста.

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

Разбор функции testWidgets()

Начнем с testWidgets а почему бы и нет?

Давайте посмотрим, какие возможности скрывает эта функция.

Пропуск теста целиком

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

То есть тест не может показать неудачные результаты, если вы пропустите его. \_()_/

Добавление тайм-аутов в тест

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

Не вдаваясь в подробности, почему это делается именно таким образом, опишем суть этого действия: initialTimeout основной используемый тайм-аут, который может быть увеличен, но на значение,НЕ ПРЕВШАЮЩЕЕзначение параметра timeout.

Для увеличения тайм-аута мы можем сделать так:

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

Небольшое отступление: изучение функций setUp() и tearDown()

Перед тем как перейти к дальнейшему рассмотрению функции testWidgets(), узнаем побольше об одной полезной особенности среды тестирования Flutter возможности задавать необходимые настройки перед тестированием и выполнять необходимую очистку после завершения тестирования.

Это делается с помощью четырех функций:

setUpAll() иtearDownAll() вызываются один раз до и после выполнения тестов соответственно. setUp() иtearDown() вызываются до и после КАЖДОГО теста. Эти функции помогают с подготовкой и очисткой среды.

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

Обратно к testWidgets(): изучение вариантов тестов

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

Как мастер плохих примеров, предложу следующий: допустим, у нас есть три цвета, в отношении которых мы должны выполнить один и тот же тест. Давайте поместим их в перечисление (enum):

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

Мы видим знакомые нам функции setUp() иtearDown(), хотя и с разными параметрами, и можем выполнить настройку для каждого значения, однако самая важная вещь здесь это get values.

Теперь мы можем добавить значения WidgetColor в вариант теста:

В результате этот вариант может запустить тест для всех значений WidgetColor. Теперь мы можем передать это в наш тест с помощью параметра variant:

При запуске этого теста он будет выполнен три раза для всех значений WidgetColor:

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


Узнать подробнее о курсе "Flutter Mobile Developer".

Участвовать в интенсиве Создаем приложение на Flutter для Web, iOS и Android

Подробнее..

Перевод Углубленный анализ тестирования виджетов во Flutter. Часть II. Классы Finder и WidgetTester

11.05.2021 18:04:27 | Автор: admin

Перевод материала подготовлен в рамках онлайн-курса "Flutter Mobile Developer".

Приглашаем также всех желающих на бесплатный двухдневный интенсив Создаем приложение на Flutter для Web, iOS и Android. На интенсиве узнаем, как именно Flutter позволяет создавать приложения для Web-платформы, и почему теперь это стабильный функционал; как именно работает Web-сборка. Напишем приложение с работой по сети. Подробности и регистрация здесь.


Это продолжение первой части статьи о тестировании виджетов во Flutter.

Продолжим наше изучение процесса тестирования виджетов.

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

Небольшое резюме предыдущей части статьи:

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

  2. Мы сохраняем наши тесты в папке test.

  3. Внутри функции testWidgets() пишем тесты виджетов, и мы подробно рассмотрели состав этой функции.

Продолжим наш анализ.

Как пишется тест виджета?

Тест виджета обычно дает возможность проверить:

  1. Отображаются ли визуальные элементы.

  2. Дает ли взаимодействие с визуальными элементами правильный результат.

Начнем со второй задачи, а первая подтянется сама собой как производная. Для этого мы обычно выполняем следующие шаги в ходе тестирования:

  1. Задаем начальные условия и создаем виджет для тестирования.

  2. Находим визуальные элементы на экране с помощью какого-либо свойства (например, ключа).

  3. Взаимодействуем с элементами (например, кнопкой), используя тот же самый идентификатор.

  4. Убеждаемся, что результаты соответствуют ожидаемым.

Создание виджета для тестирования

Чтобы протестировать виджет, очевидно, нам нужен сам виджет. Давайте рассмотрим тест по умолчанию в папке test:

void main() {  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here    },  );}

Наверняка, вы заметили объект WidgetTester в функции обратного вызова, где мы пишем наш тест. Пришло время применить его.

Чтобы создать новый виджет для тестирования, используем метод pumpWidget():

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),          ),        ),      );    },  );

(Не забудьте про await, иначе тест будет выдавать кучу ошибок.)

Этот метод создает виджет для тестирования.

Более подробно о WidgetTester мы поговорим чуть позже, сначала нам нужно разобраться с другим вопросом.

Объекты-искатели

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

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

Итак, как же найти виджет? Для этого мы используем объект-искатель, класс Finder. (Вы можете искать и элементы, но это другая тема.)

На словах просто, но на деле вам нужно определить что-то уникальное для виджета тип, текст, потомков или предков и т.д.

Давайте рассмотрим широко распространенные и некоторые более специфические способы поиска виджетов:

find.byType()

Давайте в качестве примера рассмотрим поиск виджета Text:

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),            body: Center(              child: Text('Hi there!'),            ),          ),        ),      );      var finder = find.byType(Text);    },  );

Здесь для создания объекта-искателя мы используем предопределенный экземпляр класса CommonFinders под именем find. Функция byType() помогает нам найти ЛЮБОЙ виджет определенного типа. Таким образом, если в дереве виджетов существует два текстовых виджета, будут идентифицированы ОБА. Поэтому, если вы хотите найти определенный виджет Text, подумайте о том, чтобы добавить в него ключ или использовать следующий тип:

find.text()

Чтобы найти конкретный виджет Text, используйте функцию find.text():

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),            body: Center(              child: Text('Hi there!'),            ),          ),        ),      );      var finder = find.text('Hi there!');    },  );

Это также применимо и для любого виджета типа EditableText, например виджета TextField.

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      var controller = TextEditingController.fromValue(TextEditingValue(text: 'Hi there!'));      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),            body: Center(              child: TextField(controller: controller,),            ),          ),        ),      );      var finder = find.text('Hi there!');    },  );

find.byKey()

Один из самых распространенных и простых способов найти виджет это просто добавить в него ключ:

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),            body: Center(              child: Icon(                Icons.add,                key: Key('demoKey'),              ),            ),          ),        ),      );      var finder = find.byKey(Key('demoKey'));    },  );

find.descendant() и find.ancestor()

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

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

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),            body: Center(              key: Key('demoKey'),              child: Icon(Icons.add),            ),          ),        ),      );            var finder = find.descendant(        of: find.byKey(Key('demoKey')),        matching: find.byType(Icon),      );    },  );

Здесь мы указываем, что искомый виджет является потомком виджета Center (для этого используется параметр of) и отвечает свойствам, которые мы снова задаем с помощью объекта-искателя.

Вызов find.ancestor() во многом схож, но роли меняются местами, так как мы пытаемся найти виджет, расположенный выше виджета, определенного с помощью параметра of.

Если бы здесь мы пытались найти виджет Center, мы бы сделали следующее:

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),            body: Center(              key: Key('demoKey'),              child: Icon(Icons.add),            ),          ),        ),      );      var finder = find.ancestor(        of: find.byType(Icon),        matching: find.byKey(Key('demoKey')),      );    },  );

Создание пользовательского объекта-искателя

При использовании функций вида find.xxxx() мы используем предопределенный класс Finder. А если мы хотим использовать собственный способ поиска виджета?

Продолжая череду неудачных примеров, предположим, что нам нужен объект-искатель, который находит все значки, не имеющие ключей. Назовем этот объект BadlyWrittenWidgetFinder.

  1. Сначала дополним классMatchFinder.

class BadlyWrittenWidgetFinder extends MatchFinder {    @override  // TODO: implement description  String get description => throw UnimplementedError();  @override  bool matches(Element candidate) {    // TODO: implement matches    throw UnimplementedError();  }  }

2. С помощью функции matches() мы проверяем, соответствует ли виджет нашим условиям. В нашем случае предстоит проверить, является ли виджет значком и равен ли его ключ значению null:

class BadlyWrittenWidgetFinder extends MatchFinder {  BadlyWrittenWidgetFinder({bool skipOffstage = true})      : super(skipOffstage: skipOffstage);  @override  String get description => 'Finds icons with no key';  @override  bool matches(Element candidate) {    final Widget widget = candidate.widget;    return widget is Icon && widget.key == null;  }}

3. Пользуясь преимуществами расширений, мы можем добавить этот объект-искатель непосредственно в класс CommonFinders (объект find является экземпляром этого класса):

extension BadlyWrittenWidget on CommonFinders {  Finder byBadlyWrittenWidget({bool skipOffstage = true }) => BadlyWrittenWidgetFinder(skipOffstage: skipOffstage);}

4. Благодаря расширениям мы можем обращаться к объекту-искателю так же, как и к любым другим объектам:

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(        MaterialApp(          home: Scaffold(            appBar: AppBar(),            body: Center(              key: Key('demoKey'),              child: Icon(Icons.add),            ),          ),        ),      );      var finder = find.byBadlyWrittenWidget();    },  );

Теперь, когда мы познакомились с объектами-искателями, перейдем к изучению класса WidgetTester.

Все, что нужно знать о WidgetTester

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

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

В тесте виджета функция setState() работает не так, как она обычно работает.

Хотя функция setState()помечает виджет, подлежащий перестраиванию, в реальности она не перестраивает дерево виджетов в тесте. Так как же нам это сделать? Давайте посмотрим на методы pump.

Для чего нужны методы pump

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

Немного о функции pumpWidget()

Как мы видели ранее, функция pumpWidget() использовалась для установки корневого виджета для тестирования. Она вызывает функцию runApp(), используя указанный виджет, и осуществляет внутренний вызов функции pump(). При повторном вызове функция перестраивает все дерево.

Подробнее о функции pump()

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

class CounterWidget extends StatefulWidget {  @override  _CounterWidgetState createState() => _CounterWidgetState();}class _CounterWidgetState extends State<CounterWidget> {  var count = 0;  @override  Widget build(BuildContext context) {    return MaterialApp(      home: Scaffold(        body: Text('$count'),        floatingActionButton: FloatingActionButton(          child: Icon(Icons.add),          onPressed: () {            setState(() {              count++;            });          },        ),      ),    );  }}

Виджет просто хранит значение счетчика и обновляет его при нажатии кнопки FloatingActionButton, как в стандартном приложении-счетчике.

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

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(CounterWidget());      var finder = find.byIcon(Icons.add);      await tester.tap(finder);            // Ignore this line for now      // It just verifies that the value is what we expect it to be      expect(find.text('1'), findsOneWidget);    },  );

А вот и нет:

Причина в том, что мы перестраиваем виджет Text, отображающий счетчик, с помощью функции setState() в виджете, но в данном случае виджет не перестраивается. Нам также необходимо вызвать метод pump():

  testWidgets(    'Test description',    (WidgetTester tester) async {      // Write your test here      await tester.pumpWidget(CounterWidget());      var finder = find.byIcon(Icons.add);      await tester.tap(finder);      await tester.pump();      // Ignore this line for now      // It just verifies that the value is what we expect it to be      expect(find.text('1'), findsOneWidget);    },  );

И мы получаем более приятный результат:

Если вам нужно запланировать отображение кадра через определенное время, в метод pump() также можно передать время тогда будет запланировано перестраивание виджета ПОСЛЕ истечения указанного временного промежутка:

await tester.pump(Duration(seconds: 1));

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

У метода pump есть полезная особенность: вы можете остановить его на нужном этапе перестраивания и визуализации виджета. Для этого необходимо задать параметр EnginePhase данного метода:

enum EnginePhase {  /// The build phase in the widgets library. See [BuildOwner.buildScope].  build,  /// The layout phase in the rendering library. See [PipelineOwner.flushLayout].  layout,  /// The compositing bits update phase in the rendering library. See  /// [PipelineOwner.flushCompositingBits].  compositingBits,  /// The paint phase in the rendering library. See [PipelineOwner.flushPaint].  paint,  /// The compositing phase in the rendering library. See  /// [RenderView.compositeFrame]. This is the phase in which data is sent to  /// the GPU. If semantics are not enabled, then this is the last phase.  composite,  /// The semantics building phase in the rendering library. See  /// [PipelineOwner.flushSemantics].  flushSemantics,  /// The final phase in the rendering library, wherein semantics information is  /// sent to the embedder. See [SemanticsOwner.sendSemanticsUpdate].  sendSemanticsUpdate,}await tester.pump(Duration.zero, EnginePhase.paint);

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

Переходим к pumpAndSettle()

Метод pumpAndSettle() это, по сути, тот же метод pump, но вызываемый до того момента, когда не будет запланировано ни одного нового кадра. Он помогает завершить все анимации.

Он имеет аналогичные параметры (время и этап), а также дополнительный параметр тайм-аут, ограничивающий время вызова данного метода.

await tester.pumpAndSettle(        Duration(milliseconds: 10),        EnginePhase.paint,        Duration(minutes: 1),      );

Взаимодействие со средой

Класс WidgetTester позволяет нам использовать сложные взаимодействия помимо обычных взаимодействий типа поиск + касание. Вот что можно делать с его помощью:

Метод tester.drag() позволяет инициировать перетаскивание из середины виджета, который мы находим с помощью объекта-искателя по определенному смещению. Мы можем задать направление перетаскивания, указав соответствующие смещения по осям X и Y:

      var finder = find.byIcon(Icons.add);      var moveBy = Offset(100, 100);      var slopeX = 1.0;      var slopeY = 1.0;      await tester.drag(finder, moveBy, touchSlopX: slopeX, touchSlopY: slopeY);

Мы также можем инициировать перетаскивание с контролем по времени, используя методtester.timedDrag():

      var finder = find.byIcon(Icons.add);      var moveBy = Offset(100, 100);      var dragDuration = Duration(seconds: 1);      await tester.timedDrag(finder, moveBy, dragDuration);

Чтобы просто перетащить объект из одной позиции на экране в другую, не прибегая к объектам-искателям, используйте метод tester.dragFrom(), который позволяет инициировать перетаскивание из нужной позиции на экране.

      var dragFrom = Offset(250, 300);      var moveBy = Offset(100, 100);      var slopeX = 1.0;      var slopeY = 1.0;      await tester.dragFrom(dragFrom, moveBy, touchSlopX: slopeX, touchSlopY: slopeY);

Также существует вариантэтого метода с контролем по времени tester.timedDragFrom().

      var dragFrom = Offset(250, 300);      var moveBy = Offset(100, 100);      var duration = Duration(seconds: 1);      await tester.timedDragFrom(dragFrom, moveBy, duration);

Примечание. Если вы хотите имитировать смахивание, используйте методtester.fling() вместоtester.drag().

Создание пользовательских жестов

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

Сначала нам нужно инициализировать жест:

      var dragFrom = Offset(250, 300);      var gesture = await tester.startGesture(dragFrom);

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

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

      var dragFrom = Offset(250, 300);      var gesture = await tester.startGesture(dragFrom);            await gesture.moveBy(Offset(50.0, 0));      await gesture.moveBy(Offset(0.0, -50.0));      await gesture.moveBy(Offset(-50.0, 0));      await gesture.moveBy(Offset(0.0, 50.0));            await gesture.up();

При тестировании доступны и другие возможности, такие как получение позиций используемых виджетов, взаимодействие с клавиатурой и прочее. Это, как правило, тривиальные вещи, и я, возможно, расскажу о них в одной из следующих статей (когда я это пишу, на часах уже пять утра может, мое нежелание вдаваться в детали с этим как-то связано, кто знает).

В этой части статьи мы узнали об особенностях работы с классами Finder и WidgetTester. Далее мы завершим наше знакомство с процессом тестирования виджетов и изучим дополнительные варианты тестирования это будет в третьей части статьи.


Подробнее о курсе "Flutter Mobile Developer".

Участвовать в интенсиве Создаем приложение на Flutter для Web, iOS и Android.

Подробнее..

Перевод Flutter 2.2 что нового

09.06.2021 12:21:23 | Автор: admin

Представляем свежий релиз Flutter 2.2, анонсированный на Google I/O. Да, оригинальная статья вышла ещё в мае, но мы считаем, что лучше поздно, чем никогда. Публикуем перевод статьи с комментариями Евгения Сатурова ex-Flutter TeamLead Surf, а ныне DevRel Surf.

Flutter 2.2 лучшая версия Flutter. С последними обновлениями разработчикам станет ещё проще монетизировать приложения через покупки, платежи и рекламу, а также подключить облачные сервисы и API, чтобы расширить функциональность приложений. Благодаря новым инструментам и языковым фичам разработчики смогут избежать целого класса ошибок, повысить производительность приложений и сделать их компактнее.

Основа Flutter 2

За основу Flutter 2.2 взят Flutter 2, который работает не только с мобильными устройствами, но и с вебом, ПК и встраиваемые системами. Он создан специально для мира, где нас окружают компьютеры: множество разных устройств и форм-факторов создают потребность в единообразии интерфейсов.

С помощью Flutter 2.2 все корпорации, стартапы, предприниматели смогут создавать решения высокого качества, способные по-максимуму раскрыть потенциал целевого рынка. Единственным ограничением станет креативность а не целевая платформа.

Flutter стал самым популярным фреймворком для кроссплатформенной разработки.

Рост Flutter отметили в недавнем исследовании мобильной разработки. Компания SlashData провела анализ и выпустила статью Mobile Developer Population Forecast 2021, согласно которой Flutter стал самым популярным фреймворком в кроссплатформенной разработке: его выбирают 45% разработчиков. Рост между Q1 2020 и Q1 2021 составил 47%. Интерес к Flutter продолжает расти: за последние30 дней одно из восьми приложений, загруженных в Play Store, написано на Flutter.

Комментарий Жени Сатурова

Это не удивляет нас в Surf. Нашей Flutter-компетенции уже больше двух лет. За это время мы стали свидетелями того, как мобильная индустрия в России и за её пределами совершила кульбит.

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

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

На I/O мы рассказали вам, что только в Play Store загружено более 200 тысяч приложений, написанных на Flutter. Эти приложения пишут очень серьёзные компании. Например:

  • Tencent их мессенджером WeChat пользуется более 1,2 миллиарда пользователей iOS и Android.

  • ByteDance создатели TikTok, которые уже написали 70 разных приложений на Flutter.

  • Другие компании, в том числе BMW, SHEIN, Grab и DiDi.

Конечно, Flutter используют не только крупные корпорации. Некоторые новаторские приложения делают компании, названия которых вы, возможно, и не слышали раньше. Например:

  • Wombo вирусное приложение с поющими селфи.

  • Fastly приложение для интервального голодания.

  • Kite красивое приложение для инвестиций и трейдинга.

Flutter 2.2

Основная цель релиза Flutter 2.2 улучшить процесс разработки, чтобы вы могли писать более надёжные и производительные приложения для клиентов.

Sound null safety теперь по умолчанию работает для новых проектов. Null safety защищает от ошибок типа null reference exception, так как даёт разработчикам возможность указать non-nullable типы в своём коде. А раз Dart абсолютно непротиворечив (sound), компилятор может не проверять на null во время выполнения. В результате производительность приложения повышается. Наша экосистема отреагировала быстро: около 5 000 пакетов уже обновлены и поддерживают null safety.

Комментарий Жени Сатурова

Жить в эпоху перемен всегда непросто, но весело. То же самое я могу сказать про переезд на Flutter 2. Для некоторых особенно масштабных наших проектов это стало настоящим испытанием. Процесс переезда продолжается до сих пор.

Хорошая новость заключается в том, что столь масштабных изменений во фреймворке и языке Dart в ближайшие годы не предвидится. Шутка ли к внедрению null safety в прод шли больше двух лет. Чтобы облегчить переезд коллегам из других компаний, мы оперативно обновили все наши пакеты, входящие в состав репозитория SurfGear.

Кроме того, в этом релизе есть множество возможностей повысить производительность:

  • Для веб-приложений фоновое кэширование с помощью сервисных работников.

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

  • Для iOS мы разработали инструменты предварительной компиляции шейдеров.

Благодаря им удастся устранить или сократить лаги при первом запуске.

Комментарий Жени Сатурова

Многие ждали, что возможность прекомпиляции шейдеров для Metal поступит в stable уже в этом релизе, но увы. В утешение могу посоветовать попробовать собрать проект из dev-канала. Обещаю, вы непременно заметите разницу в FPS на iOS в анимациях и переходах.

Ещё мы добавили поддержку сторонних инструментов и расширений и несколько фич в DevTools, чтобы вам стало понятнее, как распределяется память в вашем приложении.

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

Мы не только совершенствуем сам Flutter. Вместе с другими командами Google мы работаем над интеграцией Flutter в более масштабный стек технологий компании. В частности мы продолжаем разработку надёжных сервисов, с помощью которых разработчики смогут с умом монетизировать приложения. В этом релизе мы дополнили новый ads SDK: добавили в него null safety и поддержку адаптивных баннеров. Ещё мы добавили новый плагин для оплаты, который написали в сотрудничестве с командой Google Pay. С его помощью можно провести оплату за материальный товар как на iOS, так и на Android. Также мы обновили свой плагин для оплаты из приложения и соответствующую статью на codelab.

Комментарий Жени Сатурова

Радует, что экосистема вокруг Flutter развивается в правильном направлении. Не так много осталось незакрытых официальными плагинами вопросов. Возможность процессинга платежей через Google Pay существовала уже давно, но официальная реализация это гарантия качества решения. В нашем последнем релизе The Hole мы внедрили in_app_purchase ещё до выхода пакета в стабильный релиз. Надеемся, что теперь работать с ним станет ещё приятнее.

Так как Dart секретный ингредиент в составе Flutter, в этом релизе мы обновили и его. В Dart 2.13 мы расширили возможность интеграции нативного кода, добавив в FFI поддержку массивов и упакованных структур. В том числе появилась поддержка псевдонимов типов, с которыми код станет читабельнее, а некоторые сценарии рефакторинга получится осуществить менее болезненно. Мы продолжаем добавлять новые возможности интеграции с более широкой экосистемой через GitHub Action для Dart и Docker Official Image. Последний проходит тщательную проверку и оптимально подходит для внедрения бизнес-логики в облачную среду.

Не просто проект Google

Несмотря на то, что Google остаётся основным контрибьютором в проекты Flutter, нас радует, что экосистема Flutter развивается и за пределами компании.

За последние месяцы больше всего роста было связано с тем, что всё больше платформ и операционных систем стали доступны для Flutter. На Flutter Engage мы объявили, что Toyota собирается использовать Flutter в следующем поколении информационно-развлекательных систем своих автомобилей. А месяц назад Canonical отправили в свободное плаванье первый релиз Ubuntu с поддержкой Flutter, в том числе интеграцией со Snap и поддержкой Wayland.

Рост экосистемы отлично демонстрируют два наших новых партнёра. Samsung портирует Flutter на Tizen и оставляет репозиторий опенсорсным, чтобы остальные тоже могли в него контрибьютить. А Sony руководит разработкой решения для Linux на встраиваемых системах.

Дизайнеры тоже выигрывают от того, что проект опенсорсный: так, Adobe анонсировали, что обновили плагин XD to Flutter. Adobe XD даёт дизайнерам отличную возможность экспериментировать и копировать свои предыдущие работы. С улучшенной поддержкой Flutter дизайнеры и разработчики могут вместе работать над материалами а значит, ещё быстрее выводить классные идеи в продакшн.

И наконец, с нами продолжает сотрудничать Microsoft. Команда Surface разрабатывает с помощью Flutter решения для складных устройств. А на этой неделе в альфа канале появится поддержка Flutter для приложений на UWP, разработанных для Windows 10. Мы очень рады, что всё больше мобильных, десктопных, веб- и других приложений используют способность Flutter подстраиваться под разные платформы.

Комментарий Жени Сатурова

Дух open-source разработки захватывает кроссконтинентальные корпорации. Очень радует тенденция открытости и поддержки, даже когда речь идёт о взаимодействиях таких монстров, как Samsung, Google и Microsoft. Пока разработки ведутся в независимых репозиториях и никакого официального отношения к Flutter не имеют, но существует надежда, что скоро список поддерживаемых платформ расширится в полтора раза.

Создаём отличный пользовательский опыт

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

Нам нравится смотреть, какое применение вы находите для Flutter. Один из примеров проект от US Veterans Administration. В видео о том, как приложение на Flutter помогаетсолдатам с реабилитацией от посттравматического стрессового расстройства.

Мы проведём множество разных воркшопов, презентаций, посвящённых Flutter и ответим на возникшие у вас вопросы на Google I/O. А пока не забудьте попробовать нашу веб-фотобудку, написанную во Flutter: в ней можно сделать селфи с нашим талисманом Dash и её друзьями!

Подробнее..

Вызов кода Go из Dart с использованием cgo и Dart FFI на простом примере

09.06.2021 16:12:52 | Автор: admin

Ключевой мотивацией для написания данной статьи является факт сильного недостатка информации (особенно в русскоязычном сообществе) по использованию cgo и Dart FFI для вызова Go кода из языка Dart.

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

В случае если можно можно избежать экспорта go кода в Dart (например экспортировать готовую c библиотеку), то лучше воспользоваться такой возможностью и не использовать cgo. Однако, могут возникать случаи, когда перегонка go в dart кода является оптимальным решением (например вы уже знакомы с Go и Dart, и не хотите писать код на C, в таком случае есть смысл задуматься об использованием cgo и Dart FFI).

В данной статье на простом примере будет показано как можно вызвать код Go из языка Dart (например в приложениях на Flutter).

Что должно быть установлено:

  • Go

  • Dart

  • Текстовый редактор/IDE (я буду использовать VSCode, так как это самая популярная среда среди Dart и Go сообщества, так же будут установлены специальные плагины для поддержки языков Go и Flutter)

Шаг 1 - Создаем пустое консольное приложение на Dart

Вызываем Command Palette клавишей F1 и создаем новый проект на Dart, выбираем опцию Console Application (данный формат использован для примера, далее код на cgo можно будет использовать в том числе из Flutter проектов или других форматов приложений на Dart).

Назвать приложение можно в целом как угодно, я выбрал название cgo_dartffi_helloworld, исключительно для тестового примера. (Нам потребуется именно директория с проектом на Dart, так как мы будем добавлять ffi в pubspec.yaml файл).

Прожигаем кнопку создать и переходим в директорию со новоиспеченным проектом.

Шаг 2 - Добавляем ffi в yaml файл

Далее нам необходимо добавить ffi в yaml файл для возможности использования go кода из dart.

name: cgo_dartffi_helloworlddescription: A sample command-line application.version: 1.0.0environment:  sdk: '>=2.12.0 <3.0.0'dependencies:  path: ^1.8.0  ffi: ^0.1.3dev_dependencies:  pedantic: ^1.10.0  test: ^1.16.0

Шаг 3 - Создаем .go файл содержащий экспортируемую функцию

Далее необходимо создать файл на go, (в например в руте директории с проектом, например lib.go) который будет содержать функцию для экспорта в Dart. В данном примере эта функция - HelloFromGo().

// filename: lib.gopackage mainimport "C"//export HelloFromGofunc HelloFromGo() *C.char {message := "Hello to dart lang from go"return C.CString(message)}func main() {}

Стоит быть крайне аккуратными при написании кода cgo так как большая часть инструментов, включая сборщик мусора перестают работать. В cgo комментарии имеют значение (да, это странно), именно с помощью комментариев можно обозначить функцию которую необходимо экспортировать (используя слово export). Более подробно данные нюансы описаны на официальной странице cgo https://golang.org/cmd/cgo/, ну а мы вернемся к практической стороне вопроса.

Шаг 4 - Собираем динамическую библиотеку из go файла

Далее необходимо открыть терминал и запустить там следующую команду:

go build -buildmode=c-shared -o lib.a lib.go

Данная команда создаст файл lib.a (который и представляет из себя динамическую c библиотеку). Даже для такого небольшого файлика время сборки заставляет ужаснуться (аж целых несколько секунд, в отличии от моментальных сборок на go, еще один из плюсов go, который теряется при использовании cgo).

Шаг 5 - Проверяем наличие необходимых файлов

На данном этапе наша директория должна выглядеть примерно следующим образом:

Она должна содержать следующие файлы:

  • Измененный pubspec.yaml файл

  • lib.h, lib.a файлы созданные из файла lib.go

  • директорию bin с дефолтным файлом библиотеки dart (туда мы сейчас и отправимся)

Шаг 6 - Прописываем биндинги на cgo функцию в Dart коде

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

  • 6.1 - удаляем всё содержимое файла bin/cgo_dartffi_helloworld.dart и начинаем там писать с чистого листа

  • 6.2 - импортируем необходимые библиотеки (для нас это ffi и utf8 для передачи текста)

import 'dart:ffi' as ffi;import 'package:ffi/src/utf8.dart';
  • 6.3 - открываем динамическую библиотеку

final dylib = ffi.DynamicLibrary.open('lib.a');
  • 6.4 - привязываем нашу функции к функции в dart

typedef HelloFromGo = ffi.Pointer<Utf8> Function();typedef HelloFromGoFunc = ffi.Pointer<Utf8> Function();final HelloFromGo _finalFunction = dylib    .lookup<ffi.NativeFunction<HelloFromGoFunc>>('HelloFromGo')    .asFunction();
  • 6.5 - создаем метод который проверит вызов нашей функции (обратите внимание, метод .toDartString переводит стринг из формата C в формат Dart):

void main() {  print(_finalFunction().toDartString());}

Таким образом мы создали функцию на go, которая передает string в язык Dart.

Далее при написании своих функций следует учитывать, что форматы данных в языках Go, C и Dart могут отличаться (и зачастую так происходит), что приводит к необходимости использовать различные конвертации на стороне go/dart кода, более подробно можно ознакомиться по следующим ссылкам:

Полный код на Dart:

import 'dart:ffi' as ffi;import 'package:ffi/src/utf8.dart';final dylib = ffi.DynamicLibrary.open('lib.a');typedef HelloFromGo = ffi.Pointer<Utf8> Function();typedef HelloFromGoFunc = ffi.Pointer<Utf8> Function();final HelloFromGo _finalFunction = dylib    .lookup<ffi.NativeFunction<HelloFromGoFunc>>('HelloFromGo')    .asFunction();void main() {  print(_finalFunction().toDartString());}

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

typedef GetHash = Pointer<Utf8> Function(Pointer<Utf8> str);typedef GetHashFunc = Pointer<Utf8> Function(Pointer<Utf8> str);final GetHash _getHashGoFunction =    _lib.lookup<NativeFunction<GetHashFunc>>('GetHash').asFunction();

Главное помнить, что необходимо проверять форматы передаваемых данных.

Подробнее..
Категории: C , Go , Dart , Flutter , Golang , Cgo , Ffi

Как написать и опубликовать идеальный пакет для Flutter

26.03.2021 16:04:17 | Автор: admin

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

С чего начинается путь велосипедных дел мастера во Flutter?

Краткий ответ - с pub.dev. Более длинный - с ознакомления с документацией. Кстати - вот она. Начнем с самого начала - во Flutter / Dart пакеты разделяются на два типа:

  1. Собственно - пакет (Dart package)

  2. Плагин (Plugin package)

Отличия у них такие: простые пакеты содержат только Dart-код и могут содержать зависимости от Flutter. Плагины - это пакеты, имеющие связь с нативным кодом. Это может быть Java / Kotlin, Objective-C / Swift и с недавних пор, пожалуй, сюда можно отнести и C++ / С / etc, так как Flutter официально ступил на земли десктопов. Есть еще такие вариации, когда ты не используешь платформенный код как таковой, но при этом используешь те же плюсы через FFI. Это можно отнести, скорее всего - к плагинам. Но на самом деле в разрезе данной статьи это не играет большой роли. Далее - оба этих типа будут называться одним словом - пакет, без разделения на подтипы.

Небольшое интро провели - идем дальше. Как советует та же дока, чтобы начать писать пакет необходимо выполнить следующую команду:

flutter create --template=package my_package_name# orflutter create --template=plugin my_plugin_name

Я же воспользуюсь возможностями IDE, поэтому тем, кто сидит на Android Studio / IDEA, можно сделать следующее:

  1. Создаем новый проект

  1. Выбираем Flutter в качестве основы (предварительно - вы должны установить Flutter + Dart плагины

  1. Выбираем тип проекта - Plugin / Package (остальные свойства выбираем исходя из своих задач)

Отлично. Проект создан, что дальше?

Я написал пакет - как его опубликовать?

Не торопись, ковбой! Прежде чем публиковать пакет, стоит помнить о следующих вещах:

  • Твой опубликованный пакет будет таковым навсегда (пока существует pub.dev)

  • Твой пакет должен соответствовать хотя-бы каким-то минимальным требованиям к качеству кода

  • Для каждой новой версии пакета необходимо указывать изменения в файле CHANGELOG.md

  • Прежде чем публиковаться - необходимо заботливо положить в корень твоего проекта файлик LICENCE с лицензией, согласно которой он будет доступен

  • pubspec.yaml в твоем проекте должен содержать обязательные поля, содержащие информацию о твоем проекте

  • Все зависимости твоего пакета должны быть опубликованы на pub.dev

Давай пройдемся по всем этим пунктам не по порядку.

Качество кода

Все опубликованные пакеты автоматически оцениваются по нескольким критериям качества кода. Рассмотрим их подробнее:

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

Тут все просто - используем /// для всего, что будет доступно пользователям твоей прекрасной библиотеки. Например, вот так:

/// Describes a one cell of animated text:  /// We change "100" to "250"  /// Then, we have 3 animated tokens in not reversed flow:  ///  1th  2th 3th  /// | 2 | 5 | _ |  /// | 1 | 0 | 0 |  /// | _ | _ | _ |  class AnimatedToken {    AnimatedToken({    @required this.top,    @required this.center,    @required this.bottom,    @required this.direction,    @required this.topSize,    @required this.centerSize,    @required this.bottomSize,    this.axisY,    this.axisYOld,    this.axisX,    this.axisXTween,    this.opacity,    this.opacityOld,    });      /// | top |    /// | center |  /// | bottom |  final String top;      /// | top |    /// | center |  /// | bottom |  final String center;      /// | top |    /// | center |  /// | bottom |  final String bottom;      /// Describes in which direction this token will move    final Direction direction;      /// Size of top letter    final Size topSize;      /// Size of center letter    final Size centerSize;      /// Size of bottom letter    final Size bottomSize;      /// Animation in Y axis for new letter    Animation<double> axisY;      /// Animation in Y axis for old letter    Animation<double> axisYOld;      /// Animation in X axis for the same letter (old == new)    Animation<double> axisX;      Tween<double> axisXTween;      /// If token is Direction.bottom - opacity ween will be from    /// If Direction.top - 0 -> 1  Animation<double> opacity;      /// If token is Direction.bottom - opacity ween will be from    /// If Direction.top - 0 -> 1  Animation<double> opacityOld;      @override    String toString() => '''AnimatedToken {   top: $top -> $topSize   center: $center -> $centerSize   bottom: $bottom -> $bottomSize   direction: $direction   }''';  }

Не буду говорить, что такая практика позволяет и самому, спустя какое-то время, понимать что тут к чему, но она помогает и юзерам твоего пакета. Например, в той же IDEA / AS есть возможность отображения комментариев к коду по наведению курсора (прямо как в VSCode).

Желательно использовать dart fmt - форматтер кода, настроенный в соответствии с рекомендуемыми параметрами

Тут все довольно просто. Используем зависимость pedantic или effective_dart (лично я предпочитаю pedantic, т.к. он более строгий из коробки). Затем создаем файл analysis_options.yaml и используем в нем нашу зависимость:

include: package:pedantic/analysis_options.yaml

Если есть личные предпочтения в том, как должен выглядеть код, то можно дополнять / переопределять правила линтера. В этом помогут этот и этот ресурсы. К слову, кастомизировать можно не только правила линтера, но и общие правила языка (с некоторыми оговорками). Делается это через манипуляции в блоке:

include: package:pedantic/analysis_options.yaml    analyzer:    strong-mode:      implicit-dynamic: false      implicit-casts: false    errors:      todo: ignore      mixin_inherits_from_not_object: ignore      sdk_version_async_exported_from_core: ignore      missing_required_param: error      division_optimization: error      must_call_super: error      always_put_required_named_parameters_first: error      avoid_positional_boolean_parameters: error      unnecessary_await_in_return: error      invalid_use_of_protected_member: error      # ...  linter:    rules:    # ...

Вот тут есть весь перечень возможных ошибок / ситуаций, которыми можно управлять. Можно настроить все так, словно ты настоящий маньяк - мне нравится возможность сделать некоторые warning'и ошибками, и не позволять запускать проект в принципе, к примеру, при наличии в коде обращений к @protected полям и методам.

После всего этого твой код, скорее всего, засияет - ты увидишь все проблемы, которые стоит исправить заблаговременно. Плюс - можно настроить IDE на полное автоформатирование кода и перестать беспокоиться, что где-то случайно поставил два пробела вместо одного или фигурная скобка висит не на той строке. Для этого нужно всего-то сделать это:

Работать после таких манипуляций намного приятнее.

Полный список параметров, из которых формируется оценка твоего пакета, показан ниже:

1. Сопроводительные файлы

2. Документирование кода. Важный момент в этом пункте связан с проектом-примером, который следует располагать в папке example твоего пакета и отражать в этом примере то, как именно следует пользоваться твоим пакетом

3. Поддержка всех платформ. С этим тоже могут быть проблемы, например - некоторые части стандартной библиотеки не могут быть использованы в Web - поэтому получить все возможные баллы для некоторых пакетов просто невозможно. Также, из интересного - после релиза Flutter 2 появилась поддержка десктопных платформ, и для таких пакетов теперь есть пометка об их поддержке. А также, такие пакеты стоит писать сразу с null-safety (при Dart >= 2.12)

4. Прохождение форматтера

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

Changelog

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

## [1.1.0] - Add opacity sub-animation for tokens and curves manipulation  ## [1.0.1] - Add demo gif and update readme  ## [1.0.0] - First release

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

License

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

Pubspec

Можно ознакомиться с документацией к данному конфигу или просто посмотреть на пример (в нем отражены необходимые для публикации поля):

name: anitex  description: Anitex is a implicitly animated text widget, which animates on passed text changes  version: 1.2.0    repository: https://github.com/alphamikle/anitex  homepage: https://github.com/alphamikle/anitex    environment:    sdk: ">=2.7.0 <3.0.0"    flutter: ">=1.17.0 <2.0.0"    dependencies:    flutter:    sdk: flutter    dev_dependencies:    flutter_test:    sdk: flutter    pedantic: ^1.9.2    flutter:

Процесс публикации

Сделать это можно таким образом:

pub publish

Результатом выполнения команды будет вывод краткого отчета, например такого:

......Package validation found the following potential issue:* ./CHANGELOG.md doesn't mention current version (2.0.0).  Consider updating it with notes on this version prior to publication.Publishing is forever; packages cannot be unpublished.Policy details are available at https://pub.dev/policyPackage has 1 warning.. Do you want to publish anitex 2.0.0 (y/N)? 

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

pub publish --dry-run

Она покажет те же самые проблемы, либо их отсутствие, как тут:

......Package has 0 warnings.

И когда ты увидишь заветные 0 warnings - значит можно публиковать пакет. Какие еще есть нюансы? Нужно зарегистрироваться на том же pub.dev (с помощью аккаунта Google). А при выполнении команды публикации тебе будет предложено авторизоваться уже в консоли.

Это навечно

Даже если никто и никогда не воспользуется твоим пакетом (надеюсь, что все будет не так), гугл не позволит выполнить операцию, непосредственно, удаления твоего пакета из pub.dev (может только через поддержку, но я не пробовал). Однако, если ты понял, что совершил ошибку - то ты можешь пометить свой пакет как "Неподдерживаемый". У него появится яркая плашка, которая будет говорить всем твоим потенциальным фанатам, что этот продукт деятельности твоего ума больше не будет развиваться.

Можно зайти еще дальше и сделать пакет Unlisted - он выпадет из обычного поиска, но по прежнему будет доступен при расширенном поиске - это нечто среднее между приватным и публичным пакетом.

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

Что еще?

Пакет ты опубликовал - собрал 110 или 130 баллов, но его никто не использует... Тут начинается самое интересное - продвижение. Можно писать статьи, приводя расширенные примеры использования твоего пакета и рассказывая в деталях, почему он лучше другого очень похожего решения. На ресурсе на букву M можно встретить множество статей подобного плана. Можно начать, хотя бы, с коллег или, если есть уверенность в себе и своем решении - использовать его в рабочем проекте. После достижения хотя бы какой-то известности можно попытать удачу и податься, например - сюда. Это коллекция интересных open source решений для Flutter, и там может оказаться и твой прекрасный пакет!

Выводы

Их не особо много - процесс публикации библиотек в экосистеме Flutter выглядит довольно простым, а сама идея делиться своими наработками с сообществом очень благородна, и, как по мне - обязательна, просто потому что каждый разработчик пользовался результатом умственного труда других разработчиков и будет весьма справедливым - внести и свою лепту. К тому же, это полезно и тебе, дорогой друг - новые знакомства из open source комьюнити, новые возможности в поиске работы (многие HR'ы ищут разрабов уже и на GitHub), да и просто развитие себя, как технического специалиста.

Подробнее..

Storybook Flutter storybook_flutter

29.03.2021 18:20:06 | Автор: admin

Всем привет! В этой статье я буду бессовестно пиарить рассказывать о своей библиотеке для Flutter'а, которая позволяет создавать истории из изолированных виджетов и/или экранов. Что-то типа Storybook из мира React. Собственно, она так и называется: storybook_flutter.

Зачем она нужна?

Во-первых, быстрее делать UI. Конечно, у Flutter'а и так есть hot reload, но если виджет зарыт где-то в дебрях приложения, то до него еще надо добраться. А если этот виджет показывается только при определенных условиях, то эти условия надо воспроизвести. Кроме того, hot reload работает не во всех случаях. Поэтому удобнее изолировать виджет, вынести его в отдельную историю, и работать с этой историей. При этом вам придется задуматься над тем, как бы убрать лишние зависимости из этого виджета, так что код в итоге будет чище.

Во-вторых, демонстрация виджетов/экранов. Например, мы делаем свою дизайн-библиотеку для Flutter'а, и в документацию мы бы хотели встраивать интерактивную песочницу с виджетами, тем более, что Flutter for Web уже в стабильной ветке.

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

Может, взять что-то готовое?

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

Как она выглядит?

Как-то так:

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

Что она умеет?

  • Навигация по историям с разбивкой по категориям.

  • Параметры (knobs) виджетов.

  • Переключатель светлой/темной темы.

  • Показ истории в отдельном окне без всех элементов интерфейса (в вебе удобно встраивать такую полноэкранную историю в iframe).

  • Кастомизация.

  • Различные рамки (спасибо пакету device_frame) в превью версии.

  • Плагины тоже в превью.

Как с ней работать?

Добавляем в pubspec.yaml(я использую превью-версию, плагины и рамки пока есть только в ней):

storybook_flutter: ^0.5.0-dev.0

Создаем историю (хорошо звучит). В самом простом случае будет что-то такое:

import 'package:flutter/material.dart';import 'package:storybook_flutter/storybook_flutter.dart';void main() => runApp(const MyApp());class MyApp extends StatelessWidget {  const MyApp({Key? key}) : super(key: key);  @override  Widget build(BuildContext context) => Storybook(        children: [          Story.simple(            name: 'Button',            child: ElevatedButton(              onPressed: () {},              child: const Text('Push me'),            ),          ),        ],      );}

Запускаем, смотрим, радуемся:

Добавим несколько параметров. Для этого меняем simple конструктор на обычный, и используем builder вместо child:

Story(  name: 'Button',  builder: (context, k) => ElevatedButton(    onPressed:        k.boolean(label: 'Enabled', initial: true) ? () {} : null,    child: Text(k.text(label: 'Text', initial: 'Push me')),  ),),

Запускаем, радуемся еще больше:

Если надо добавить секцию, просто добавляем параметр section:

Story(  name: 'Button',  section: 'Buttons',  builder: (context, k) => ElevatedButton(    onPressed:        k.boolean(label: 'Enabled', initial: true) ? () {} : null,    child: Text(k.text(label: 'Text', initial: 'Push me')),  ),),

Все истории с одинаковым параметром section будут автоматически сгруппированы.

Как кастомизировать?

У каждой Story есть параметры paddingи background отвечающие, как ни странно, за отступы и фоновый цвет каждой истории:

Story(  name: 'Button',  section: 'Buttons',  padding: const EdgeInsets.all(8),  background: Colors.red,  builder: (context, k) => ElevatedButton(    onPressed:        k.boolean(label: 'Enabled', initial: true) ? () {} : null,    child: Text(k.text(label: 'Text', initial: 'Push me')),  ),),

Но это слишком просто. Гораздо интереснее использовать параметр wrapperBuilder у Story, который позволяет обернуть каждую историю в кастомный виджет:

Story(  name: 'Button',  section: 'Buttons',  wrapperBuilder: (context, story, child) => Container(    decoration: BoxDecoration(border: Border.all()),    margin: const EdgeInsets.all(16),    child: Center(child: child),  ),  builder: (context, k) => ElevatedButton(    onPressed:        k.boolean(label: 'Enabled', initial: true) ? () {} : null,    child: Text(k.text(label: 'Text', initial: 'Push me')),  ),),

Этот же билдер можно передать в качестве параметра storyWrapperBuilder в Storybook, тогда каждая история будет обернута в этот виджет.

Нужно больше кастомизаций!

Если же одних билдеров, врапперов и параметров недостаточно, можно взять CustomStorybook и сделать все своими руками:

class MyApp extends StatelessWidget {  const MyApp({Key? key}) : super(key: key);  @override  Widget build(BuildContext context) {    final decoration = BoxDecoration(      border: Border(        right: BorderSide(color: Theme.of(context).dividerColor),        left: BorderSide(color: Theme.of(context).dividerColor),      ),      color: Theme.of(context).cardColor,    );    return MaterialApp(      debugShowCheckedModeBanner: false,      home: Scaffold(        body: CustomStorybook(          builder: (context) => Row(            children: [              Container(                width: 200,                decoration: decoration,                child: const Contents(),              ),              const Expanded(child: CurrentStory()),              Container(                width: 200,                decoration: decoration,                child: const KnobPanel(),              ),            ],          ),          children: [            Story(              name: 'Button',              builder: (context, k) => ElevatedButton(                onPressed:                    k.boolean(label: 'Enabled', initial: true) ? () {} : null,                child: Text(k.text(label: 'Text', initial: 'Push me')),              ),            )          ],        ),      ),    );  }}

При этом вы можете использовать встроенные виджеты Contents, CurrentStory и KnobPanel (думаю, вы догадались, что они делают). Получим вот такую минималистичную картину:

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

Что там с плагинами?

Как я уже говорил, в превью версии появилась поддержка плагинов и первый 1st party плагин: DeviceFramePlugin:

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

Про создание плагинов, если будет интересно, расскажу в другой статье.

Какие платформы поддерживаются?

Никакой особой магии под капотом нет, так что теоретически должно работать на всех платформах, которые поддерживаются Flutter'ом. Я проверял на Android, iOS, Web и macOS.

Что дальше?

Дальше в планах устаканить API плагинов, подумать, какие плагины еще нужны из коробки (ну и написать их).

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


На этом все. Буду рад замечаниям, предложениям и баг-репортам (ну и лайкам/звездочкам, конечно же, чего уж греха таить).

Подробнее..

Миграция мобильного приложения на FLUTTER 2

23.04.2021 16:06:54 | Автор: admin

3 марта 2021 года разработчики Google представили Flutter 2. Что появилось в новой версии языка Dart? Как теперь быть с разработкой и поддержкой приложений, созданных с использованием Flutter предыдущих версий? И, самое главное, насколько сложно будет мигрировать на версию 2? В этой статье подробно опишем опыт миграции приложения на новую версию Flutter и проблемы, которые могут возникнуть в процессе миграции.

Кто такой и зачем нужен Flutter?

Для тех, кто набрел на статью случайно и понятия не имеет, что такое Flutter это технология от Google для разработки кроссплатформенных мобильных приложений - да, да приложения будут работать и на Android, и на iOS устройствах. Flutter активно завоевывает сердца разработчиков и очень быстро идет от только мобильной разработки к Web и Desktop. Он достаточно прост в освоении, позволяет отказаться от одновременной разработки двух приложений для Android и iOS, он показывает высокую производительность и значительно ускоряет разработку приложений. Ну не прелесть ли? Первая версия была представлена в начале 2018, а спустя два года мы уже видим Flutter 2 с весьма серьезными доработками и нововведениями.

Что появилось во Flutter 2?

Наиболее громкие изменения:

  • Новая версия языка Dart 2.12 c Sound null safety;

  • Выход Flutter for web;

  • Большой шаг к мультиплатформенности с Flutter for desktop.

Разработчики обещают, что миграция существующих решений на Flutter 2 должна пройти просто и быстро, есть гайды по миграции с примерами простых приложений, но можем ли мы им доверять, имея на руках приложение с множеством экранов, общающееся с сервером по api и использующее внешние библиотеки?

Если изменения по части Web и Desktop не затрагивают существующие приложения, то sound null safety может потребовать доработок. Почему и зачем sound null safety вообще нужна? Sound null safety - это фича, которая появилась в новой версии языка Dart 2.12, вышедшей вместе с Flutter 2.0. На просторах сети уже довольно много сказано о нововведениях и о null safety, поэтому очень подробно на этом останавливаться не будем. Но для целостности картины происходящего немного все же нужно сказать.

До появления Sound null safety все переменные в языке Dart могли принимать значение null. Если разработчик забывал добавить проверку на null перед использованием переменной, то во время работы приложения внезапно можно было получить экран со следующим содержанием:

Не самые приятные события. Разработчики понимают, что такую проверку легко забыть сделать, и, если вместо ссылки на экземпляр определенного типа мы получаем null, который ничего не знает о методах и состояниях экземпляра, мы имеем красный экран смерти с NoSuchMethodError.

Решить эту проблему призван Sound null safety, основные принципы которого:

  • Безопасность кода по умолчанию - все переменные, которые мы создаем, по умолчанию будут non-nullable, пока мы не разрешим им другого поведения.

  • Простота написания кода - с этим все понятно: не хотелось бы в обмен на безопасность получать сложности в написании и понимании кода.

  • Непротиворечивость кода - если мы определяем какую-то переменную как переменную non-nullable типа, то она абсолютно точно никогда не будет равна null. Как сказал Евгений Сатуров в своем подкасте, это самый главный принцип Sound null safety, и загадочное sound переводится именно как непротиворечивость.

Итак, в языке Dart иерархия типов претерпевает некоторые изменения:

Null больше не является подтипом для всех типов, а существует по соседству с ними. Если у нас есть переменная типа String, то она всегда будет содержать строку. Но на практике, конечно, случаются ситуации, когда нужно использовать null в качестве значения для переменной.

В таком случае можно воспользоваться соседом-компаньоном типа, допускающим значение null. В нашем случае этот тип - String?. Мы как будто заранее заставляем себя и других разработчиков сомневаться в том что в этой переменной будет строка: может, будет, а может, и нет.

Ниже пример использования этого типа в деле приготовления бургеров:

makeBurger(String burger, [String? meat]) {  if (meat != null) {    print('$burger with $meat');  } else {      print('Vegan $burger'); }}

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

Более подробно о новой иерархии типов можно почитать в официальной документации здесь.

В изменениях типов по умолчанию кроются сложности и страхи миграции проекта на Flutter 2. Второй момент, вызывающий опасения, это сторонние пакеты из pub.dev. На момент написания статьи более 85% пакетов из топ-250 на pub.dev уже поддерживают null safety. Никто, конечно, не застрахован от того, что нужный пакет и вовсе больше не поддерживается, поэтому перед использованием стоит проверить нужный пакет на pub.dev.

Подготовка к миграции кода

Начнем переезжать на Flutter 2, подсматривая в официальный гайд с использованием мигратора.

Первым делом, конечно же, обновляем Dart и Flutter до последних версий с помощью команды:

flutter upgrade

Вместе с Flutterом обновляется и Dart до версии 2.12.

Помимо SDK, нужно обновить плагины Flutter и Dart, сделать это можно в среде разработки. В AndroidStudio откроем Settings->Plugins. Там нас уже ждут кнопочки Update. Для применения апдейта обязательно потребуется перезапуск среды.

Далее проверяем, что все пакеты, которые мы используем в своем проекте, обновлены до версии с поддержкой null safety. Сделать это можно с помощью команды:

dart pub outdated --mode=null-safety

Выполнять ее нужно из директории проекта, оттуда, где лежит файл pubspec.yaml, который как раз анализируется на присутствие устаревших пакетов. Если такие пакеты в проекте есть, в консоли будет подсказка с информацией, кому из них требуется обновление, с какой версии пакета начата поддержка null safety, и какая версия наиболее свежая. У нас целых 5 таких пакетов.

Здесь Dart подсказывает нам, что для автоматической смены версий пакетов можно воспользоваться командой:

dart pub upgrade --null-safety

Пробуем, не получается

Кажется, пакет device_id все еще не может в null safety. На pub.dev выясняется, что все еще хуже: обновляли его последний раз в апреле 2019. Но есть и хорошие новости, этот пакет в проекте используется только в одном месте при формировании http запросов к серверу для определения ID устройства. Уходит некоторое время на поиски и тестирование альтернативы, удается найти null safety пакет, в котором есть методы для определения ID устройства, - platform_device_id. Если бы такой альтернативы не нашлось, пришлось бы делать форк и допиливать пакет самостоятельно. Добавляем platform_device_id актуальной версии в pubspec.yaml вместо device_id. Пробуем выполнить апгрейд пакетов еще раз.

Теперь все сработало отлично, пакеты обновлены!

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

dart pub get
dart pub upgrade

Результат будет таким же.

Сразу переходить к миграции кода в нашем случае не получается: в проекте появились ошибки. Методы post() и get() пакета http в новой версии поменяли тип аргумента uri, вместо String теперь нужен Uri. Эта проблема тоже довольно быстро решается с помощью метода Uri.parse().

После обновления SDK, плагинов и пакетов проект все еще собирается и работает, но остается самый главный шаг - миграция кода.

Миграция кода

Ее можно осуществить вручную или воспользоваться мигратором, вызвав команду:

dart migrate

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

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

Рассмотрим более подробно предлагаемые изменения. Где-то мигратор добавил ? к типу поля или переменной, сделав его nullable.

Появились комментарии /* no valid migration */ как в примере к строчке, где метод возвращает null, а принимающая сторона этого не ждет.

В окошке справа от кода можно найти подробности о причинах внесения тех или иных изменений, например, такие причины для приведения к nullable поля title:

  • поле не является final, значит его значение может измениться после определения в конструкторе;

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

Здесь же можно воспользоваться кнопочками Add, чтобы добавить к типу String /*!*/, сообщив мигратору, что мы уверены, что это поле должно быть non-nullable, перезапустить миграцию и понаблюдать, как меняется код, использующий это поле. Вот, например, при передаче meter.customName в конструктор ButtonItem появился !.

Пробежавшись по коду, можно заметить, что везде, где есть использование nullable, но требуется non-nullable переменная, мигратор добавил оператор !. Он уговаривает метод или выражение, которое ждет только non-nullable, взять из наших рук nullable. Оператор ! относится к null-aware операторам, таким как ?., ??, !. (подробно про их использование можно почитать здесь).

ComboMeal(Drink? drink) {  drink!.addIce(); //приложение упадет}ComboMeal(null);

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

Вместо ! подойдет простая проверка на null, но не всегда. Можно встретить, например, вот такую ситуацию с полем meters:

Проверка есть, но мигратор все равно добавляет !.

Оказывается, проверка на null перед использованием перетаскивает на светлую non nullable сторону только локальные переменные и не работает с полями класса. На простых примерах это выглядит так:

ComboMeal(Drink drink) {  if (drink.bestTemperature != null) {    keepTemperature(drink.bestTemperature); // ошибка компиляции  }}ComboMeal(Drink drink) {  int? bestTemperature = drink.bestTemperature;  if (bestTemperature!= null) {    keepTemperature(bestTemperature); // null safety  }}

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

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

ComboMeal(Drink? drink) {  drink?.addIce(); // addIce не вызывается}...ComboMeal(null);

Еще обойти опасный ! там, где это возможно, хорошо помогает оператор ??, позволяющий заменить возможный null на значение по умолчанию.

ComboMeal(Drink? drink) {  keepTemperature(drink.bestTemperature ?? 70);}

Перед некоторыми полями классов мигратор добавил слово late.

Ключевое слово late позволяет обойти использование nullable типов для полей класса, когда они не инициализируется сразу, но и значение null не является для них необходимым. В примере ниже получим ошибку компиляции, так как поле burgerName не может быть nullable и не инициализируется сразу либо в конструкторе.

class ComboMeal {  String burgerName; // ошибка компиляции    void comboWithCheeseburger() {    burgerName = 'Сheeseburger';  }    void comboWithChickenBurger() {    burgerName = 'Chicken burger';  }    getComboMealName() {    return 'ComboMeal with ' + burgerName;}}

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

class ComboMeal {  String? burgerName;    void comboWithCheeseburger() {    burgerName = 'Сheeseburger';  }    void comboWithChickenBurger() {    burgerName = 'Chicken burger';  }    getComboMealName() {    return 'Combo meal with ' + burgerName!;}}

Ключевое слово late позволяет избежать необходимости делать это поле nullable.

class ComboMeal {  late String burgerName; //null safety    void comboWithCheeseburger() {    burgerName = 'Сheeseburger';  }    void comboWithChickenBurger() {    burgerName = 'Chicken burger';  }    getComboMealName() {    return 'Combo Meal with ' + burgerName;  }}ComboMeal comboMeal = ComboMeal();comboMeal.comboWithCheeseburger();print(comboMeal.getComboMealName());

Здесь стоит обратить внимание на то, что такая инициализация все равно не является абсолютно безопасной. Если поменять местами последние две строки кода из примера выше, то ошибки компиляции не будет, но произойдет ошибка LateInitializationError во время работы приложения, так как поле burgerName не было проинициализировано.

Еще одним применением ключевого слова late является ленивая инициализация полей класса.

class ComboMeal {  late String burgerName = _getSurpriseName();}

В нашем случае поле burgerName будет инициализировано не при создании экземпляра класса ComboMeal, а при первом обращении к нему. Такой прием может быть полезен, когда мы уверены в том, что инициализация потребует много ресурсов, но не обязательно будет вызвана.

Автоматическая миграция

Насколько долго придется дорабатывать код после миграции? В нашем проекте около 1200 строк кода. Я потратила на внесение изменений немного времени - около одного рабочего дня. Предлагаю оставить этап несогласия с мигратором и правок за кадром и нажать кнопку Apply Migration, чтобы понять, насколько работоспособен код после автоматической миграции.

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

В первую очередь, видим ошибки там, где были комментарии /* no valid migration */. Это те места, где в методах возвращается или передается null. Правим.

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

List<String> words = List<String>();

Это решение было принято разработчиками из-за того, что конструктор по умолчанию создает список определенного размера, но не инициализирует его элементы, то есть все они имеют значение null. Такой конструктор теперь нельзя применить даже для списка, допускающего содержание nullable элементов. Для разрешения этой проблемы в зависимости от ситуации можно воспользоваться инструментами создания фиксированного или нефиксированного списка List.empty(), List.generate(), List.fill(), []. Правим места с использованием конструктора по умолчанию.

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

На этом миграцию приложения можно считать успешной, но с небольшими оговорками.

Впечатления от миграции

Автоматическая миграция не отдает 100% рабочего кода. Но, справедливости ради, стоит признать - так получается из-за того, что исходный код не обеспечивает null safety. Возможно, если провести какую-то предварительную подготовку, то таких проблем не будет. С другой стороны, правки после миграции были не сложными, заняли не так уж много времени, и вдобавок список ошибок акцентирует внимание на проблемах, которые можно было и не заметить при подготовке.

Выбор мигратора делать переменную/поле nullable или non-nullable, судя по всему, полностью зависит от их использования в коде. Это хорошо прослеживается в окошке с причинами добавления nullable к типу в предложениях по миграции. Понятно, что нельзя полностью уйти от использования null. Например, при получении данных от сервера и декодировании json-ответов в классы невозможно гарантировать, что все поля будут заполнены, как мы ожидаем. Мигратор, конечно, сам не догадается о контексте и не сделает все поля response-класса nullable. Разработчику придется уделить время на доработку, чтобы получить хороший код.

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

Но все это опять же легко поправилось.

Документация по null-safety языка Dart и вообще вся документация Dart и Flutter отлично написана, в ней можно найти ответы на большинство возникших вопросов, связанных с работой nullable или non-nullable. Ну и конечно, когда при написании нового кода на Dart 2.12 встает вопрос - делать переменную nullable или non-nullable, лучше выбирать non-nullable и работать с этой концепцией пока не станет очевидно, что без nullable не обойтись.

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

P.S. Об Инфосфере

В завершение стоит немного рассказать о мобильном приложение, которое было героем этой статьи и переживало миграцию на Flutter 2. Оно является частью платформы Инфосфера, которая разработана центром проектирования программного обеспечения компании Миландр.

Инфосфера представляет собой целый комплекс приложений для мониторинга и управлениями различными умными устройствами. Сейчас наша платформа используется в разных отраслях промышленности и городской среды: ЖКХ, энергетике, спортивно-развлекательных комплексах. Само мобильное приложение предоставляет пользователю информацию о его устройствах и позволяет управлять ими удаленно.

Подробнее..

Основы Flutter для начинающих (Часть I)

30.05.2021 12:21:38 | Автор: admin

Вступление

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

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

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

Результатом нашей работы будет небольшое Flutter приложение, которое будет брать данные из JSONPlaceholder.

Первый шаг - Настройка и установка компонентов

Ну что ж, приступим.

Переходим на страницу установки: Install - Flutter и загружаем Flutter для своей платформы

Затем устанавливаем редактор или IDE по инструкции Set up an editor

Я буду использовать Android Studio IDE от Google.

Для разработки на Android Studio нужно установить Flutter плагин (в инструкции Set up an editor, описано как это сделать).

Второй шаг - Создание проекта

Выбираем Flutter Application

Далее указываем название приложения (имя Flutter приложения должно иметь нижний регистр, отдельные слова могут разделяться нижним подчеркиванием).

Затем указываем package name (используется для того, чтобы идентифицировать наше приложение среди других в Google Play или Apple Store, его впоследствии можно будет изменить, более подробно об Android Application ID или об Apple App ID):

Нажимаем Finish.

Третий шаг - создание первоначальной структуры приложения

Очищаем main.dart файл от ненужного кода:

import 'package:flutter/material.dart';// main() является главной функцией с которой начинается // выполнение приложения// возвращает виджет приложенияvoid main() => runApp(MyApp());// В Flutter все является виджетом (кнопки,списки, текст и т.д.)// виджет - это отдельный компонент, который может быть отрисован// на экране (не путать с Android виджетами)// Наиболее простые виджеты наследуются от StatelessWidget класса// и не имеют состоянияclass MyApp extends StatelessWidget {// функция build отвечает за построение иерархии виджетов  @override  Widget build(BuildContext context) {// виджет MaterialApp - главный виджет приложения, который  // позволяет настроить тему и использовать    // Material Design для разработки.    return MaterialApp(    // заголовок приложения      // обычно виден, когда мы сворачиваем приложение      title: 'Json Placeholder App',      // настройка темы, мы ещё вернёмся к этому      theme: ThemeData(        primarySwatch: Colors.blue,      ),      // указываем исходную страницу, которую мы создадим позже      home: HomePage(),    );  }}

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

Называем его pages:

Затем создаем в пакете файл home_page.dart:

И реализуем нашу первую страницу:

import 'package:flutter/material.dart';// StatefulWidget имеет состояние, с которым// позже мы будем работать через функцию// setState(VoidCallback fn);// обратите внимание setState принимает другую функциюclass HomePage extends StatefulWidget {  // StatefulWidget должен возвращать класс,  // которые наследуется от State  @override  _HomePageState createState() => _HomePageState();}// В треугольных скобках мы указываем наш StatefulWidget // для которого будет создано состояние// нижнее подчеркивание _ используется для того, // чтобы скрыть доступ к _HomePageState  из других файлов// нижнее подчеркивание аналогия private в Java / Kotlinclass _HomePageState extends State<HomePage> {    // функция buil, как мы уже отметили, строит  // иерархию наших любимых виджетов  @override  Widget build(BuildContext context) {    // В большинстве случаев Scaffold используется,    // как корневой виджет для страницы или экрана    // Scaffold позволяет вам указать AppBar, BottomNavigationBar,    // Drawer, FloatingActionButton и другие не менее важные    // компоненты (виджеты).    return Scaffold(      // мы создаем AppBar с текстом "Home Page"      appBar: AppBar(title: Text("Home page")),      // указываем текст в качестве тела Scaffold      // текст предварительно вложен в Center виджет,      // чтобы выровнять его по центру        body: Center(        child: Text(          "Hello, JSON Placeholder!!!",          // Также выравниваем текст внутри самого виджета Text          textAlign: TextAlign.center,          // Theme.of(context) позволяет получить доступ к           // текущему ThemeData, который был указан в MaterialApp          // После получения ThemeData мы можем использовать          // различные его стили (например headline3, как здесь)          style: Theme.of(context).textTheme.headline3,        )      )    );  }  }

Обратите внимание на мощь Flutter - мы можем вкладывать различные виджеты друг в друга, комбинировать их и создавать более сложные структуры

Четвертый шаг - запуск

Ну что ж, пора испытать наше приложение.

Не забудьте импортировать HomePage в main файл:

import 'pages/home_page.dart';

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

import 'package:json_placeholder_app/pages/home_page.dart';

По моему небольшому опыту могут возникнуть проблемы, когда вы захотите поменять имя приложения в pubspec.yaml файле (pubspec.yaml находиться в корневой директории проекта):

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

Переходим к запуску, выбираем устройство на котором будет выполняться приложение (в данном случае я использую реальное устройство, мой Honor 30i), и нажимаем Run:

Та дам!

Если вас раздражает надпись DEBUG в правом верхнем углу, то её можно убрать:

import 'package:flutter/material.dart';// main() является главной функцией с которой начинается // выполнение приложения// возвращает виджет приложенияvoid main() => runApp(MyApp());// В Flutter все является виджетом (кнопки,списки, текст и т.д.)// виджет - это отдельный компонент, который может быть отрисован// на экране (не путайте с Android виджетами)// Наиболее простые виджеты наследуются от StatelessWidget класса// и не имеют состоянияclass MyApp extends StatelessWidget {// функция build отвечает за построение иерархии виджетов  @override  Widget build(BuildContext context) {    // виджет MaterialApp - главный виджет приложения, который    // позволяет настроить тему и использовать    // Material Design для разработки.    return MaterialApp(      // заголовок приложения      // обычно виден, когда мы сворачиваем приложение      title: 'Json Placeholder App',      // убираем баннер      debugShowCheckedModeBanner: false,      // настройка темы, мы ещё вернёмся к этому      theme: ThemeData(        primarySwatch: Colors.blue,      ),      // указываем исходную страницу, которую мы создадим позже      home: HomePage(),    );  }}

Также обратите внимание, когда вы запустите приложение, вы можете использовать hot reload:

Hot Reload позволяет буквально за 2-5 секунд внести изменения, когда ваше приложение выполняется.

Это довольно приятная опция, которая ускорит вашу разработку.

При каждом вызове Hot Reload происходит перезапуск build функции. (вся иерархия виджетов перестраивается)

Будьте внимательны: не во всех ситуциях Hot Reload срабатывает и изменения отражаются в приложении, поэтому в таких ситуациях нужно перезапускать приложение полностью.

Также есть довольно интересный факт: размер отладочного приложения на Flutter с одним экраном, которое мы только что создали:

Этого бояться не стоит, т.к. release Flutter приложения будет весить гораздо меньше.

Отладочное приложение содержит много дополнительной информации, а также к этому добавляется поддержка Hot Reload.

Четвертый шаг - использование состояния

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

Ну что ж попробуем реализовать небольшую анимацию которая будет запускаться по кнопки:

import 'package:flutter/material.dart';// StatefulWidget имеет состояние, с которым// позже мы будем работать через функцию// setState(VoidCallback fn);// обратите внимание setState принимает другую функциюclass HomePage extends StatefulWidget {  // StatefulWidget должен возвращать класс,  // которые наследуется от State  @override  _HomePageState createState() => _HomePageState();}// В треугольных скобках мы указываем наш StatefulWidget// для которого будет создано состояние// нижнее подчеркивание используется для того, чтобы // скрыть доступ к _HomePageState из других файлов// нижнее подчеркивание - аналогия private в Java / Kotlinclass _HomePageState extends State<HomePage> {  // добавим переменную, которая будет нашим состоянием  // т.к. _counter мы будем использовать только внутри нашего  // класса, то сделаем его недоступным для других классов  // _counter будет хранить значение счетчика  var _counter = 0;  // build как мы уже отметили, строит  // иерархию наших любимых виджетов  @override  Widget build(BuildContext context) {    // В большинстве случаев Scaffold используется    // как корневой виджет для страницы или экрана    // Scaffold позволяет вам указать AppBar, BottomNavigationBar,    // Drawer, FloatingActionButton и другие не менее важные    // компноненты (виджеты).    return Scaffold(      // мы создаем AppBar с текстом "Home page"      appBar: AppBar(title: Text("Home page")),      // указываем текст в качестве тела Scaffold      // текст предварительно вложен в Center виджет,      // чтобы выровнять его по центру      body: Center(        // добавляем AnimatedSwitcher, который и будет управлять        // нашей анимацией        child: AnimatedSwitcher(          // обратите внимание: const указывает          // на то, что нам известно значение Duration во время          // компиляции и мы не будем его менять во время выполнения          // класс Duration позволяет указать задержку в разных          // единицах измерения (секунды, миллисекунды и т.д.)          duration: const Duration(milliseconds: 900),          // AnimatedSwitcher создает reverse эффект,          // то  есть эффект возврата анимации к первоначальному          // состоянию, что выглядит не всегда красиво,          // поэтому я указал reverseDuration в 0          // вы можете поэкспериментировать с этим значением          reverseDuration: const Duration(milliseconds: 0),          child: Text(            // вывод значения счетчика            // при каждой перерисовки виджетов _counter             // увеличивается на единицу            "$_counter",            // здесь самое интересное            // когда мы изменяем значение _counter            // и вызываем функцию setState, компоненты            // перерисовываются и AnimatedSwitcher сравнивает            // предыдущий key своего дочернего виджета с текущим,            // если они не совпадают, то вопроизводит анимацию            key: ValueKey<int>(_counter),            // Также выравниваем текст внутри самого виджета Text            textAlign: TextAlign.center,            // Theme.of(context) позволяет получить доступ к            // текущему ThemeData, который мы указали в MaterialApp            // После получения ThemeData мы можем использовать            // различные его стили (например headline3, как здесь)            style: Theme.of(context).textTheme.headline3,          ),        )      ),      // добавляем кнопку      // FloatingActionButton - круглая кнопка в правом нижнем углу      floatingActionButton: FloatingActionButton(        // указываем иконку        // Flutter предлагает нам большой спектр встроенных иконок        child: Icon(Icons.animation),        onPressed: () {          // наконец то мы дошли до функции setState          // которая даст сигнал, что пора перерисовывать           // наши виджеты.           // здесь мы просто увеличиваем наш счетчик          setState(() {            _counter++;          });        },      ),    );  }}

Выполняем приложение:

Та дам! Выглядит здорово!

Заключение

Статья получилась достаточно информативной и по моему мнению полезной для новичков.

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

Примерный план:

1) Часть 1 (текущая статья) - введение в разработку, первое приложение, понятие состояния;

2) Часть 2 - BottomNavigationBar и Navigator;

3) Часть 3 - MVC. Мы будем использовать именно этот паттерн, как один из самый простых;

4) Часть 4 - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

5) Часть 5 - Работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

6) Часть 6 - Создание своей темы, добавление кастомных шрифтов и анимации;

7) Часть 7 - Немного о тестировании;

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

Все пожелания в комментариях)

До скорой встречи!

Подробнее..

Основы Flutter для начинающих (Часть II)

31.05.2021 18:12:13 | Автор: admin

Вступление

Добрый денек!

Мы продолжаем изучать Flutter.

И в этой статье мы познакомимся с файлом pubspec.yaml, а также поработаем с Flutter в командной строке.

Ну что ж, приступим!

Наш план
  • Часть 1 - введение в разработку, первое приложение, понятие состояния;

  • Часть 2 (текущая статья) - файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3 - BottomNavigationBar и Navigator;

  • Часть 4 - MVC. Мы будем использовать именно этот паттерн, как один из самый простых;

  • Часть 5 - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6 - Работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 7 - Создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 8 - Немного о тестировании;

Файл pubspec.yaml

Расширение .yaml указывает на то, что мы используем YAML формат данных (более подробнее в Википедии).

Это довольно простой формат, ориентированный на удобство представления данных.

pubspec.yaml находится в корневой директории проекта и служит для общей настройки, добавления зависимостей, шрифтов, картинок в ваш проект.

Немного об организации файлов:

  • .dart-tool содержит информацию для Dart Tools (набор различных утилит для работы с кодом Dart)

  • .idea была создана самой Android Studio и хранит настройки проекта

  • build содержит файлы сборки, в том числе и наш release apk

  • ios папка содержит нативный код iOS и предназначена для отдельной настройки iOS приложения, а также его публикации через XCode

  • android папка содержит нативный код Android и предназначена для отдельной настройки Android приложения

  • lib содержит непосредственно наш код на Dart

  • test предназначена для тестов

    Далее идет несколько файлов:

  • README.md и .gitignore - это файлы Git

  • о pubspec.yaml мы говорили выше, а pubspec.lock содержит информацию о версиях наших pub-пакетов.

  • .metadata содержит необходимую информацию для обновления Flutter

  • .packages дополнительная информация о пакетах

Рассмотрим минимальный pubspec.yaml:

# имя Flutter приложения# обычно данное имя используется в качестве# названия pub-пакета. Это важно лишь в том случае,# если вы разрабатываете свой pub-пакет и собираетесь# выложить его в общий доступ# как я уже отметил имя Android и iOS приложения впоследствии# можно будет изменить отдельно для каждой из платформname: json_placeholder_app# краткое описание на английскомdescription: json_placeholder_app is an demo application# в данном случае мы не собираемся# опубликовывать pub-пакет и поэтому# запрещаем команду flutter publishpublish_to: 'none' # версия Android и iOS приложения# состоит из 2 частей, разделенных знаком плюса# первая часть - это имя версии, которое будет# видно для пользователей, например 1.1.5# вторая часть позволяет Google Play и Apple Store# отличать разные версии нашего приложения (например: 5)version: 1.0.0+1# версия Dart SDKenvironment:  sdk: ">=2.7.0 <3.0.0"# блок зависимостейdependencies:  flutter:    sdk: flutter  # использование иконок для Cupertino компонентов  # Cupertino компоненты - это компоненты в стили iOS  # В данном приложении мы не будем использовать их и поэтому  # удалим ненужный pub-пакет  #cupertino_icons: ^1.0.2# зависимости для разработки# в данном случае подключено тестированиеdev_dependencies:  flutter_test:    sdk: flutter# в данной секции вы можете подключить шрифты и assets файлы# об этом мы поговорим позжеflutter:  # указываем, что мы используем MaterialApp иконки и наше  # приложение соответствует Material Design  uses-material-design: true

Немного о pub-пакетах

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

Все pub-пакеты делятся на собственно пакеты и плагины.

В чем же отличие пакета от плагина?

Пакет - это код на Dart с pubspec.yaml файлом, а плагин - подвид пакета, который содержит нативный код какой-либо платформы.

Например плагин camera позволяет получить доступ к камере на Android и iOS устройствах и содержит нативный код отдельно для Android (папка android) и отдельно для iOS (папка ios)

Добавление зависимостей

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

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

# имя Flutter приложения# обычно данное имя используется в качестве# названия pub-пакета. Это важно лишь в том случае,# если вы разрабатываете свой pub-пакет и собираетесь# выложить его в общий доступ# как я уже отметил имя Android и iOS приложения впоследствии# можно будет изменить отдельно для каждой из платформname: json_placeholder_app# краткое описание на английскомdescription: json_placeholder_app is an demo application# в данном случае мы не собираемся# опубликовывать pub-пакет и поэтому# запрещаем команду flutter publishpublish_to: 'none' # версия Android и iOS приложения# состоит из 2 частей, разделенных плюсом# первая часть - это имя версии, которое будет# видно для пользователей, например 1.1.5# вторая часть позволяет Google Play и Apple Store# отличать разные версии нашего приложения (например: 5)version: 1.0.0+1# версия Dart SDKenvironment:  sdk: ">=2.7.0 <3.0.0"  # блок зависимостейdependencies:  flutter:    sdk: flutter      # подключение необходимых pub-пакетов    # используется для произвольного размещения  # компонентов в виде сетки  flutter_staggered_grid_view: ^0.4.0    # мы будем использовать MVC паттерн  mvc_pattern: ^7.0.0    # большая часть данных будет браться из сети,  # поэтому мы будем использовать http для  # осуществления наших запросов  http: ^0.13.3    # зависимости для разработки# в данном случае подключено тестированиеdev_dependencies:  flutter_test:    sdk: flutter# в данной секции вы можете подключить шрифты и assets файлы# об этом мы поговорим позжеflutter:  # указываем, что мы используем MaterialApp иконки и наше  # приложение соответствует Material Design  uses-material-design: true

Пока на этом все!

Flutter в командной строке

Для начала нужно запустить командную строку или терминал.

У меня Debian 10, поэтому я буду использовать терминал.

Теперь надо определить местоположения главного flutter скрипта.

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

В противном случае вам нужно прописать полный путь к Flutter:

В директории Flutter есть папка bin, в которой лежит главный скрипт - flutter.

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

Ну что ж давайте пройдемся по основным командам.

Создание проекта

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

# также перед созданием проекта можно отключить web поддержку# с помощью команды: flutter config --no-enable-webflutter create new_flutter_app

Результат:

Установка зависимостей

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

flutter pub get

Вы также можете использовать встроенный терминал в Android Studio:

Получение доступных устройств

flutter devices

Результат:

Здесь мы видем мой Honor и Chrome браузер (т.к. включена web поддержка)

Запуск

Для запуска нужно указать устройство через параметр -d

flutter run -d JYXNW20805003141

Результат:

Получение скрина

Теперь когда мы запустили наше приложение, мы можем получить скрин с экрана нашего устройства c помощью команды:

# -d указываем устройство# -o путь в файлу, куда будет сохранен наш скринflutter screenshot -d JYXNW20805003141 -o ~/Downloads/screen_1.png

Результат:

Скрин:

Сборка релиза

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

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

Для создания release apk выполните:

flutter build apk --release

Результат:

В данном случае мы имеем неподписанный apk с набором всех архитектур (armeabi-v7a, arm64-v8a и 86_64).

Лучшим вариантом является использование опции --split-per-abi для разделения архитектур по разным файлам:

flutter build apk --split-per-abi

Результат:

Допольнительные команды

Определение версии Flutter:

flutter --version

Обновление Flutter:

flutter upgrade

Чтобы получить справку по какой-либо команде нужно использовать --help опцию:

flutter create --help

Результат:

Заключение

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

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

Не забывайте оставлять пожелания в комментах)))

Далее переходим к навигации.

Подробнее..

Основы Flutter для начинающих (Часть III)

02.06.2021 16:12:10 | Автор: admin

Поздравляю, по крайней мере, всех живущих в Сибири с наступлением лета!)))

Сегодня довольно непростая тема - навигация.

Мы рассмотрим как устроена навигация в Flutter, что вообще нужно чтобы перейти с одного экраны на другой и конечно же не забудем о передачи аргументов между экранами.

И напоследок весьма распространенный use case: создание BottomNavigationBar.

'Ну что ж не будем терять ни минуты, начинаем!

Наш план
  • Часть 1- введение в разработку, первое приложение, понятие состояния;

  • Часть 2 - файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3 (текущая статья) - BottomNavigationBar и Navigator;

  • Часть 4 - MVC. Мы будем использовать именно этот паттерн, как один из самый простых;

  • Часть 5 - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6 - Работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 7 - Создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 8 - Немного о тестировании;

Navigator и стэк навигации

Flutter довольно прост в плане навигации, здесь нет фрагментов и Activity.

Все довольно просто: каждая страница это виджет, который называется Route.

Навигация осуществляется через объект Navigator:

// Navigator.of(context) получает состояние Navigator// виджета: NavigatorState, которое имеет push и pop методы// push помещает новую страницу на вершину стека Navigator// pop наоборот удаляет текущую страницу из вершины стэка// MaterialPageRoute в основном используется для создания// анимации между экранамиNavigator.of(context).push(MaterialPageRoute(builder: (context) => OurPage()));

Рассмотрим стэк Navigator'a на конкретном примере.

У нас есть два экрана: список книг и информация о книге.

Первый экран, который появится при запуске приложения - это список книг:

Затем мы переходим на страницу с информацией об одной из книг:

В этот момент наша новая страница находится на вершине стэка и поэтому мы не имеем доступа к списку книг.

Далее мы нажимаем кнопку Back или Up (стрелка в левом верхнем углу) и снова возвращаемся к первоначальному состоянию:

В первом случае нужно использовать push(route), во втором pop() метод.

Переходим непосредственно к практике!

Создание навигации между двумя экранами

Сделаем небольшой список персонажей из сериала My Little Pony с переходом на страницу описания каждого персонажа.

Для начала создадим новую страницу в папке pages:

Затем напишем немного кода:

import 'package:flutter/material.dart';// класс пони, который будет хранить имя и описание, а также idclass Pony {  final int id;  final String name;  final String desc;  Pony(this.id, this.name, this.desc);}// создаем список пони// final указывает на то, что мы больше// никогда не сможем присвоить имени ponies// другой список поняшекfinal List<Pony> ponies = [  Pony(      0,      "Twillight Sparkle",      "Twilight Sparkle is the central main character of My Little Pony Friendship is Magic. She is a female unicorn pony who transforms into an Alicorn and becomes a princess in Magical Mystery Cure"  ),  Pony(      1,      "Starlight Glimmer",      "Starlight Glimmer is a female unicorn pony and recurring character, initially an antagonist but later a protagonist, in the series. She first possibly appears in My Little Pony: Friends Forever Issue and first explicitly appears in the season five premiere."  ),  Pony(      2,      "Applejack",      "Applejack is a female Earth pony and one of the main characters of My Little Pony Friendship is Magic. She lives and works at Sweet Apple Acres with her grandmother Granny Smith, her older brother Big McIntosh, her younger sister Apple Bloom, and her dog Winona. She represents the element of honesty."  ),  Pony(      3,      "Pinkie Pie",      "Pinkie Pie, full name Pinkamena Diane Pie,[note 2] is a female Earth pony and one of the main characters of My Little Pony Friendship is Magic. She is an energetic and sociable baker at Sugarcube Corner, where she lives on the second floor with her toothless pet alligator Gummy, and she represents the element of laughter."  ),  Pony(      4,      "Fluttershy",      "Fluttershy is a female Pegasus pony and one of the main characters of My Little Pony Friendship is Magic. She lives in a small cottage near the Everfree Forest and takes care of animals, the most prominent of her charges being Angel the bunny. She represents the element of kindness."  ),];// PonyListPage не будет иметь состояния,// т.к. этот пример создан только для демонстрации// навигации в действииclass PonyListPage extends StatelessWidget {    // build как мы уже отметили, строит  // иерархию наших любимых виджетов  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(title: Text("Pony List Page")),      // зададим небольшие отступы для списка      body: Padding(        // объект EdgeInsets хранит четыре важные double переменные:        // left, top, right, bottom - отступ слева, сверху, справа и снизу        // EdgeInsets.all(10) - задает одинаковый отступ со всех сторон        // EdgeInsets.only(left: 10, right: 15) - задает отступ для        // определенной стороны или сторон        // EdgeInsets.symmetric - позволяет указать одинаковые        // отступы по горизонтали (left и right) и по вертикали (top и bottom)        padding: EdgeInsets.symmetric(vertical: 15, horizontal: 10),        // создаем наш список          child: ListView(            // map принимает другую функцию, которая            // будет выполняться над каждым элементом            // списка и возвращать новый элемент (виджет Material).            // Результатом map является новый список            // с новыми элементами, в данном случае            // это Material виджеты            children: ponies.map<Widget>((pony) {              // Material используется для того,              // чтобы указать цвет элементу списка              // и применить ripple эффект при нажатии на него              return Material(                color: Colors.pinkAccent,                // InkWell позволяет отслеживать                // различные события, например: нажатие                child: InkWell(                  // splashColor - цвет ripple эффекта                  splashColor: Colors.pink,                  // нажатие на элемент списка                  onTap: () {                    // добавим немного позже                  },                  // далее указываем в качестве                  // элемента Container с вложенным Text                  // Container позволяет указать внутренние (padding)                  // и внешние отступы (margin),                  // а также тень, закругление углов,                  // цвет и размеры вложенного виджета                  child: Container(                      padding: EdgeInsets.all(15),                      child: Text(                          pony.name,                          style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.white)                      )                  ),                ),              );              // map возвращает Iterable объект, который необходимо              // преобразовать в список с помощью toList() функции            }).toList(),          )      ),    );  }}

Обратите внимание, что в Dart можно определять переменные вне классов, как в нашем случае со списком.

Теперь переходим к созданию PonyDetailPage:

import 'package:flutter/material.dart';import 'pony_list_page.dart';// также, как и PonyListPage наша страница// не будет иметь состоянияclass PonyDetailPage extends StatelessWidget {  // в качестве параметра мы будет получать id пони  final int ponyId;  // конструктор PonyDetailPage принимает ponyId,  // который будет присвоен нашему ранее  // объявленному полю  PonyDetailPage(this.ponyId);  @override  Widget build(BuildContext context) {    // получаем пони по его id    // обратите внимание: мы импортируем ponies     // из файла pony_list_page.dart    final pony = ponies[ponyId];    return Scaffold(      appBar: AppBar(        title: Text("Pony Detail Page"),      ),      body: Padding(        // указываем отступ для контента        padding: EdgeInsets.all(15),        // Column размещает дочерние виджеты в виде колонки        // crossAxisAlignment - выравнивание по ширине (колонка) или        // по высоте (строка)        // mainAxisAlignment работает наоборот        // в данном случае мы растягиваем дочерние элементы        // на всю ширину колонки        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            Container(                padding: EdgeInsets.all(10),                // вы не можете указать color для Container,                // т.к. свойство decoration было определено                // color: Colors.pinkAccent,                                // BoxDecoration имеет дополнительные свойства,                // посравнению с Container,                // такие как: gradient, borderRadius, border, shape                // и boxShadow                // здесь мы задаем радиус закругления левого и правого                // верхних углов                decoration: BoxDecoration(                  borderRadius: BorderRadius.only(                      topLeft: Radius.circular(15),                      topRight: Radius.circular(15)                  ),         // цвет Container'а мы указываем в BoxDecoration                  color: Colors.pinkAccent,                ),                child: Text(                    // указываем имя pony                    pony.name,                    style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.white),                )            ),            Container(                padding: EdgeInsets.all(10),                child: Text(                    // указываем описание pony                    pony.desc,                    style: Theme.of(context).textTheme.bodyText1                )            )          ],        ),      )    );  }}

Осталось только организовать саму навигацию.

Добавьте следующий код в PonyListPage:

// нажатие на элемент спискаonTap: () {  // Здесь мы используем сокращенную форму:  // Navigator.of(context).push(route)  // PonyDetailPage принимает pony id,  // который мы и передалиNavigator.push(context, MaterialPageRoute(  builder: (context) => PonyDetailPage(pony.id)  ));},

Также не забудем заменить домашнюю страницу:

@override  Widget build(BuildContext context) {    // виджет MaterialApp - главный виджет приложения, который    // позволяет настроить тему и использовать    // Material Design для разработки.    return MaterialApp(      // заголовок приложения      // обычно виден, когда мы сворачиваем приложение      title: 'Json Placeholder App',      // убираем баннер      debugShowCheckedModeBanner: false,      // настройка темы, мы ещё вернёмся к этому      theme: ThemeData(        primarySwatch: Colors.blue,      ),      // теперь у нас домашная страница - PonyListPage      home: PonyListPage(),    );  }

Запуск

Теперь кликаем на любой элемент:

Та дам! Мы также можем вернуться обратно, если нажмем кнопку Back или стрелку в левом верхнем углу.

Также можно реализовать свою логику обратного перехода:

// Получаем NavigatorState и уничтожает последний элемент // из стэка навигации (PonyDetailPage)// мы можем передать второй аргумент, если хотим вернуть результатNavigator.pop(context, result)

Пока на этом все. О навигации можно написать целый цикл статей.

Ещё пара слов о нововведениях: появился новый Navigator API 2.0, о котором есть довольно хорошая статья.

Мы останавливаться не будем и переходим к BottomNavigationBar.

BottomNavigationBar и свои Navigator'ы

Я 100% уверен, что вы встречали нижнее меню, по которому можно переходить на различные экраны:

Здесь вы можете увидеть четыре элемента меню (домашная страница, поиск, уведомления и сообщения).

Давайте реализуем что-нибудь похожее.

Сначала создадим новую папку models, а в ней файл tab.dart:

Затем создадим класс Tab и перечисление TabItem:

import 'package:flutter/material.dart';// будет хранить основную информацию // об элементах менюclass MyTab {  final String name;  final MaterialColor color;  final IconData icon;  const MyTab({this.name, this.color, this.icon});}// пригодиться для определения // выбранного элемента меню// у нас будет три пункта меню и три страницы:// посты, альбомы и заданияenum TabItem { POSTS, ALBUMS, TODOS }

Переходим к более сложной части, реализации главной страницы:

import 'package:flutter/material.dart';import "../models/tab.dart";// Наша главная страница будет содержать состояниеclass HomePage extends StatefulWidget {  @override  _HomePageState createState() => _HomePageState();}class _HomePageState extends State<HomePage> {  // GlobalKey будет хранить уникальный ключ,  // по которому мы сможем получить доступ  // к виджетам, которые уже находяться в иерархии  // NavigatorState - состояние Navigator виджета  final _navigatorKeys = {    TabItem.POSTS: GlobalKey<NavigatorState>(),    TabItem.ALBUMS: GlobalKey<NavigatorState>(),    TabItem.TODOS: GlobalKey<NavigatorState>(),  };  // текущий выбранный элемент  var _currentTab = TabItem.POSTS;  // выбор элемента меню  void _selectTab(TabItem tabItem) {    setState(() => _currentTab = tabItem);  }  @override  Widget build(BuildContext context) {    // WillPopScope переопределяет поведения    // нажатия кнопки Back    return WillPopScope(      // логика обработки кнопки back может быть разной      // здесь реализована следующая логика:      // когда мы находимся на первом пункте меню (посты)      // и нажимаем кнопку Back, то сразу выходим из приложения      // в противном случае выбранный элемент меню переключается      // на предыдущий: c заданий на альбомы, с альбомов на посты,      // и после этого только выходим из приложения      onWillPop: () async {          if (_currentTab != TabItem.POSTS) {            if (_currentTab == TabItem.TODOS) {              _selectTab(TabItem.ALBUMS);            } else {              _selectTab(TabItem.POSTS);            }            return false;          } else {            return true;          }      },      child: Scaffold(        // Stack размещает один элемент над другим        // Проще говоря, каждый экран будет находится        // поверх другого, мы будем только переключаться между ними        body: Stack(children: <Widget>[          _buildOffstageNavigator(TabItem.POSTS),          _buildOffstageNavigator(TabItem.ALBUMS),          _buildOffstageNavigator(TabItem.TODOS),        ]),        // MyBottomNavigation мы создадим позже        bottomNavigationBar: MyBottomNavigation(          currentTab: _currentTab,          onSelectTab: _selectTab,        ),      ),);  }  // Создание одного из экранов - посты, альбомы или задания  Widget _buildOffstageNavigator(TabItem tabItem) {    return Offstage(      // Offstage работает следующим образом:      // если это не текущий выбранный элемент      // в нижнем меню, то мы его скрываем      offstage: _currentTab != tabItem,      // TabNavigator мы создадим позже      child: TabNavigator(        navigatorKey: _navigatorKeys[tabItem],        tabItem: tabItem,      ),    );  }}

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

Далее с помощью виджета Offstage мы показывает только тот экран, который был выбран.

Также мы переопределили нажатие на кнопку back - WillPopScope.

Теперь создадим нижнее меню в новом файле bottom_navigation.dart:

import 'package:flutter/material.dart';import '../models/tab.dart';// создаем три пункта меню// const обозначает, что tabs является // постоянной ссылкой и мы больше// ничего не сможем ей присвоить,// иначе говоря, она определена во время компиляцииconst Map<TabItem, MyTab> tabs = {  TabItem.POSTS : const MyTab(name: "Posts", color: Colors.red, icon: Icons.layers),  TabItem.ALBUMS : const MyTab(name: "Albums", color: Colors.blue, icon: Icons.image),  TabItem.TODOS : const MyTab(name: "Todos", color: Colors.green, icon: Icons.edit)};class MyBottomNavigation extends StatelessWidget {  // MyBottomNavigation принимает функцию onSelectTab  // и текущую выбранную вкладку  MyBottomNavigation({this.currentTab, this.onSelectTab});  final TabItem currentTab;  // ValueChanged<TabItem> - функциональный тип,  // то есть onSelectTab является ссылкой на функцию,  // которая принимает TabItem объект  final ValueChanged<TabItem> onSelectTab;  @override  Widget build(BuildContext context) {    // Используем встроенный виджет BottomNavigationBar для    // реализации нижнего меню    return BottomNavigationBar(        selectedItemColor: _colorTabMatching(currentTab),        selectedFontSize: 13,        unselectedItemColor: Colors.grey,        type: BottomNavigationBarType.fixed,        currentIndex: currentTab.index,        // пункты меню        items: [          _buildItem(TabItem.POSTS),          _buildItem(TabItem.ALBUMS),          _buildItem(TabItem.TODOS),        ],        // обработка нажатия на пункт меню        // здесь мы делаем вызов функции onSelectTab,        // которую мы получили через конструктор        onTap: (index) => onSelectTab(            TabItem.values[index]        )    );  }  // построение пункта меню  BottomNavigationBarItem _buildItem(TabItem item) {    return BottomNavigationBarItem(        // указываем иконку        icon: Icon(          _iconTabMatching(item),          color: _colorTabMatching(item),        ),        // указываем метку или название        label: tabs[item].name,    );  }  // получаем иконку элемента  IconData _iconTabMatching(TabItem item) => tabs[item].icon;  // получаем цвет элемента  Color _colorTabMatching(TabItem item) {    return currentTab == item ? tabs[item].color : Colors.grey;  }}

И реализуем TabNavigator (tab_navigator.dart):

import 'package:flutter/material.dart';import '../models/tab.dart';import 'pony_list_page.dart';class TabNavigator extends StatelessWidget {  // TabNavigator принимает:  // navigatorKey - уникальный ключ для NavigatorState  // tabItem - текущий пункт меню  TabNavigator({this.navigatorKey, this.tabItem});  final GlobalKey<NavigatorState> navigatorKey;  final TabItem tabItem;  @override  Widget build(BuildContext context) {    // наконец-то мы дошли до этого момента    // здесь мы присваиваем navigatorKey     // только, что созданному Navigator'у    // navigatorKey, как уже было отмечено является ключом,    // по которому мы получаем доступ к состоянию    // Navigator'a, вот и все!    return Navigator(      key: navigatorKey,      // Navigator имеет параметр initialRoute,      // который указывает начальную страницу и является      // всего лишь строкой.      // Мы не будем вдаваться в подробности, но отметим,      // что по умолчанию initialRoute равен /      // initialRoute: "/",            // Navigator может сам построить наши страницы или      // мы можем переопределить метод onGenerateRoute      onGenerateRoute: (routeSettings) {        // сначала определяем текущую страницу        Widget currentPage;        if (tabItem == TabItem.POSTS) {          // пока мы будем использовать PonyListPage          currentPage = PonyListPage();        } else if (tabItem == TabItem.POSTS) {          currentPage = PonyListPage();        } else {          currentPage = PonyListPage();        }        // строим Route (страница или экран)        return MaterialPageRoute(builder: (context) => currentPage,);      },    );  }}

Также не забудьте заменить домашнюю страницу в main.dart файле:

return MaterialApp(   //...   // Наша главная страница с нижнем меню   home: HomePage(),);

Осталось только импортировать нужные классы в home_page.dart и вуаля:

Также хорошей практикой является правильная организация кода, поэтому в папке pages создадим новую папку home и перетащим туда два наших файлика:

И напоследок сделаем три страницы заглушки: PostListPage, AlbumListPage и TodoListPage:

import 'package:flutter/cupertino.dart';import 'package:flutter/material.dart';// Здесь все довольно очевидноclass PostListPage extends StatefulWidget {  @override  _PostListPageState createState() => _PostListPageState();}class _PostListPageState extends State<PostListPage> {    @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post List Page"),      ),      body: Container()    );  }}

Та же структура и для двух остальных.

После этого укажим их в TabNavigator'e:

onGenerateRoute: (routeSettings) {  // сначала определяем текущую страницу  Widget currentPage;  if (tabItem == TabItem.POSTS) {  // указываем соответствующие страницы    currentPage = PostListPage();  } else if (tabItem == TabItem.ALBUMS) {    currentPage = AlbumListPage();  } else {    currentPage = TodoListPage();  }   // строим Route (страница или экран)   return MaterialPageRoute(builder: (context) => currentPage);},

Заключение

Поздравляю вас!

Искренне рад и благодарен вам за хорошие отзывы и за поддержку!

Полезные ссылки:

До скорой встречи!

Подробнее..

Основы Flutter для начинающих (Часть V)

04.06.2021 10:04:57 | Автор: admin

Наконец-то мы добрались до одной из самых важных тем, без которой идти дальше нет смысла.

План довольно простой: нам предстоит познакомиться с клиент-серверной архитектурой и реализовать получение списка постов.

В конце мы правильно организуем файлы наших страниц и вынесем элемент списка в отдельный файл.

Полетели!

Наш план
  • Часть 1- введение в разработку, первое приложение, понятие состояния;

  • Часть 2- файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3- BottomNavigationBar и Navigator;

  • Часть 4 - MVC. Мы будем использовать именно этот паттерн, как один из самых простых;

  • Часть 5 (текущая статья) - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6 - работа с формами, текстовые поля и создание поста.

  • Часть 7 - работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 9 - немного о тестировании;

Client и Server

Модель Client / Server лежит в основе всего Интернета и является наиболее распространенной.

В чем её суть?

Сначала разберемся что такое клиент и сервер:

  • Клиент - пользовательское устройство, которое отправляет запросы за сервер и получает ответы. Это может быть смартфон, компьютер или MacBook.

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

Вся модель сводиться к примитивному принципу: клиент отправил запрос, сервер принял его, обработал и передал ответ клиенту.

Для организации взаимодействия сервера и клиента используются специальные протоколы. На текущий момент одним из самых распространенных протоколов в сети Интернет является http / https (s означает защищенный, secure).

http / https позволяет передавать почти все известные форматы данных: картинки, видео, текст.

Мы будем работать с JSON форматом.

JSON - простой и понятный формат данных, а главное легковесный, т.к. передается только текст.

Пример JSON:

[  {    "userId": 1,    "id": 1,    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"  },  {    "userId": 1,    "id": 2,    "title": "qui est esse",    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"  },  ...]  

Здесь массив постов, который мы будем получать от сервера.

Обратите внимание: квадратные скобки указывает на массив данных, а фигурные на отдельный объект.

JSON позволяет создавать глубокую вложенность объектов и массивов:

{  "total_items" : 1  "result" : [  {  "id" : 1,  "name" : "Twillight Sparkle",  "pony_type" : "alicorn",  "friends" : [  "Starlight Glimmer", "Applejack", "Rarity", "Spike"  ]}  ]}

Понятие запроса

Для обмена данными клиент должен отправлять запросы на сервер.

Т.к. интернет в большинстве случаев использует http / https то запросы называются HTTP запросами.

Структура HTTP запроса:

  • URL - уникальный адрес в Интернете, который идентифицирует сервер и его конкретный ресурс, данные которого мы собираемся получить. В нашем случае URL выглядит следующим образом: https://jsonplaceholder.typicode.com/posts. (об структуре самого URL'а можно почитать в Википедии)

  • Метод, который определяет типа запроса. GET используется только для получения данных, POST позволяет клиенту добавить свои данные на сервер, DELETE - удалить их, PUT - изменить.

  • Данные запроса обычно называются телом запроса и используются совместно с POST, PUT и DELETE методами. Для GET метода в основном используются параметры самого URL'а. Выглядит это следующим образом: https://jsonplaceholder.typicode.com/posts/1 (здесь мы обращаемся к конкретному посту по его id = 1)

Запрос и вывод списка постов

Мы будем использовать довольно мощный и простой пакет http для отправки запросов на сервер.

Сначала убедимся, что мы указали его в pubspec.yaml файле:

# блок зависимостейdependencies:  flutter:    sdk: flutter  # подключение необходимых pub-пакетов  # используется для произвольного размещения  # компонентов в виде сетки  flutter_staggered_grid_view: ^0.4.0  # мы будем использовать MVC паттерн  mvc_pattern: ^7.0.0  # http предоставляет удобный интерфейс для создания# запросов и обработки ошибок  http: ^0.13.3

Переходим к созданию классов модели.

Для этого создайте файл post.dart в папке models:

// сначала создаем объект самого постаclass Post {  // все поля являются private  // это сделано для инкапсуляции данных  final int _userId;  final int _id;  final String _title;  final String _body;    // создаем getters для наших полей  // дабы только мы могли читать их  int get userId => _userId;  int get id => _id;  String get title => _title;  String get body => _body;  // Dart позволяет создавать конструкторы с разными именами  // В данном случае Post.fromJson(json) - это конструктор  // здесь мы принимаем JSON объект поста и извлекаем его поля  // обратите внимание, что dynamic переменная   // может иметь разные типы: String, int, double и т.д.  Post.fromJson(Map<String, dynamic> json) :    this._userId = json["userId"],    this._id = json["id"],    this._title = json["title"],    this._body = json["body"];}// PostList являются оберткой для массива постовclass PostList {  final List<Post> posts = [];  PostList.fromJson(List<dynamic> jsonItems) {    for (var jsonItem in jsonItems) {      posts.add(Post.fromJson(jsonItem));    }  }}// наше представление будет получать объекты// этого класса и определять конкретный его// подтипabstract class PostResult {}// указывает на успешный запросclass PostResultSuccess extends PostResult {  final PostList postList;  PostResultSuccess(this.postList);}// произошла ошибкаclass PostResultFailure extends PostResult {  final String error;  PostResultFailure(this.error);}// загрузка данныхclass PostResultLoading extends PostResult {  PostResultLoading();}

Одной из наиболее неприятных проблем является несоответствие типов.

Если взглянуть на JSON объект поста:

{  "userId": 1,  "id": 1,  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}

То можно заметить, что userId и id являются целыми числами, а title и body строками, поэтому в конструкторе Post.fromJson(json) мы не замарачиваемся с привидением типов.

Пришло время создать Repository класс.

Для этого создадим новую папку data и в нем файл repository.dart:

import 'dart:convert';// импортируем http пакетimport 'package:http/http.dart' as http;import 'package:json_placeholder_app/models/post.dart';// мы ещё не раз будем использовать // константу SERVERconst String SERVER = "https://jsonplaceholder.typicode.com";class Repository {  // обработку ошибок мы сделаем в контроллере  // мы возвращаем Future объект, потому что  // fetchPhotos асинхронная функция  // асинхронные функции не блокируют UI  Future<PostList> fetchPosts() async {    // сначала создаем URL, по которому    // мы будем делать запрос    final url = Uri.parse("$SERVER/posts");    // делаем GET запрос    final response = await http.get(url);// проверяем статус ответаif (response.statusCode == 200) {  // если все ок то возвращаем посты  // json.decode парсит ответ   return PostList.fromJson(json.decode(response.body));} else {  // в противном случае говорим об ошибке  throw Exception("failed request");}  }}

Вы скажите: мы могли все запихнуть в контроллер, зачем создавать ещё один класс?

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

К тому же это не очень гибко. Вдруг нам нужно будет поменять URL адрес сервера.

Реализуем PostController:

import '../data/repository.dart';import '../models/post.dart';import 'package:mvc_pattern/mvc_pattern.dart';class PostController extends ControllerMVC {  // создаем наш репозиторий  final Repository repo = new Repository();  // конструктор нашего контроллера  PostController();    // первоначальное состояние - загрузка данных  PostResult currentState = PostResultLoading();  void init() async {    try {      // получаем данные из репозитория      final postList = await repo.fetchPosts();      // если все ок то обновляем состояние на успешное      setState(() => currentState = PostResultSuccess(postList));    } catch (error) {      // в противном случае произошла ошибка      setState(() => currentState = PostResultFailure("Нет интернета"));    }  }}

Заключительная часть: подключим наш контроллер к представлению и выведем посты:

import 'package:flutter/material.dart';import '../controllers/post_controller.dart';import '../models/post.dart';import 'package:mvc_pattern/mvc_pattern.dart';class PostListPage extends StatefulWidget {  @override  _PostListPageState createState() => _PostListPageState();}// не забываем расширяться от StateMVCclass _PostListPageState extends StateMVC {  // ссылка на наш контроллер  PostController _controller;  // передаем наш контроллер StateMVC конструктору и  // получаем на него ссылку  _PostListPageState() : super(PostController()) {    _controller = controller as PostController;  }  // после инициализации состояния  // мы запрашивает данные у сервера  @override  void initState() {    super.initState();    _controller.init();  }  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post List Page"),      ),      body: _buildContent()    );  }  Widget _buildContent() {    // первым делом получаем текущее состояние    final state = _controller.currentState;    if (state is PostResultLoading) {      // загрузка      return Center(        child: CircularProgressIndicator(),      );    } else if (state is PostResultFailure) {      // ошибка      return Center(        child: Text(          state.error,          textAlign: TextAlign.center,          style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.red)        ),      );    } else {      // отображаем список постов      final posts = (state as PostResultSuccess).postList.posts;      return Padding(        padding: EdgeInsets.all(10),        // ListView.builder создает элемент списка        // только когда он видим на экране        child: ListView.builder(          itemCount: posts.length,          itemBuilder: (context, index) {            return _buildPostItem(posts[index]);          },        ),      );    }  }  // элемент списка   Widget _buildPostItem(Post post) {    return Container(        decoration: BoxDecoration(            borderRadius: BorderRadius.all(Radius.circular(15)),            border: Border.all(color: Colors.grey.withOpacity(0.5), width: 0.3)        ),        margin: EdgeInsets.only(bottom: 10),        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            Container(              decoration: BoxDecoration(                borderRadius: BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),                color: Theme.of(context).primaryColor,              ),              padding: EdgeInsets.all(10),              child: Text(                post.title,                textAlign: TextAlign.left,                style: Theme.of(context).textTheme.headline5.copyWith(color: Colors.white),),            ),            Container(              child: Text(                post.body,                style: Theme.of(context).textTheme.bodyText2,              ),              padding: EdgeInsets.all(10),            ),          ],        )    );  }}

Не пугайтесь если слишком много кода.

Все сразу освоить невозможно, поэтому не спешите)

Запуск

Попробуем запустить:

Вуаля! Теперь отключим интернет:

Все работает!

Небольшая заметка

Одним из важных принципов программирования является стремление к минимизации кода и его упрощению.

Файл post_list_page.dart содержит всего 110 строк кода, это не проблема. Но если бы он был в 10 или даже в 20 раз больше!

Какой ужас был бы на глазах у того, кто взглянул бы на него.

Лучшей практикой считается выносить повторяющие фрагменты кода в отдельные файлы.

Давайте попробуем вынести функцию Widget _buildItem(post) в другой файл.

Для этого создадим для каждой группы страниц свою папку:

Затем в папке post создадим новый файл post_list_item.dart:

import 'package:flutter/material.dart';import '../../models/post.dart';// элемент спискаclass PostListItem extends StatelessWidget {    final Post post;    // элемент списка отображает один пост  PostListItem(this.post);    Widget build(BuildContext context) {    return Container(        decoration: BoxDecoration(            borderRadius: BorderRadius.all(Radius.circular(15)),            border: Border.all(color: Colors.grey.withOpacity(0.5), width: 0.3)        ),        margin: EdgeInsets.only(bottom: 10),        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            Container(              decoration: BoxDecoration(                borderRadius: BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),                color: Theme.of(context).primaryColor,              ),              padding: EdgeInsets.all(10),              child: Text(                post.title,                textAlign: TextAlign.left,                style: Theme.of(context).textTheme.headline5.copyWith(color: Colors.white),),            ),            Container(              child: Text(                post.body,                style: Theme.of(context).textTheme.bodyText2,              ),              padding: EdgeInsets.all(10),            ),          ],        )    );  }}

Не забудьте удалить ненужный код из post_list_page.dart:

import 'package:flutter/material.dart';import '../../controllers/post_controller.dart';import '../../models/post.dart';import 'post_list_item.dart';import 'package:mvc_pattern/mvc_pattern.dart';class PostListPage extends StatefulWidget {  @override  _PostListPageState createState() => _PostListPageState();}// не забываем расширяться от StateMVCclass _PostListPageState extends StateMVC {  // ссылка на наш контроллер  PostController _controller;  // передаем наш контроллер StateMVC конструктору и  // получаем на него ссылку  _PostListPageState() : super(PostController()) {    _controller = controller as PostController;  }  // после инициализации состояние  // мы запрашивает данные у сервера  @override  void initState() {    super.initState();    _controller.init();  }  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post List Page"),      ),      body: _buildContent()    );  }  Widget _buildContent() {    // первым делом получаем текущее состояние    final state = _controller.currentState;    if (state is PostResultLoading) {      // загрузка      return Center(        child: CircularProgressIndicator(),      );    } else if (state is PostResultFailure) {      // ошибка      return Center(        child: Text(          state.error,          textAlign: TextAlign.center,          style: Theme.of(context).textTheme.headline4.copyWith(color: Colors.red)        ),      );    } else {      // отображаем список постов      final posts = (state as PostResultSuccess).postList.posts;      return Padding(        padding: EdgeInsets.all(10),        // ListView.builder создает элемент списка        // только когда он видим на экране        child: ListView.builder(          itemCount: posts.length,          itemBuilder: (context, index) {            // мы вынесли элемент списка в            // отдельный виджет            return PostListItem(posts[index]);          },        ),      );    }  }  }

Заключение

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

Я постарался кратко рассказать и показать на наглядном примере работу с сетью.

Надеюсь моя статья принесла вам пользу)

Ссылка на Github

Всем хорошего кода!

Подробнее..

Основы Flutter для начинающих (Часть VI)

05.06.2021 14:06:41 | Автор: admin

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

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

И сегодня мы постараемся разобраться с этой темой на небольшом примере.

Ну что ж, погнали!

Наш план
  • Часть 1- введение в разработку, первое приложение, понятие состояния;

  • Часть 2- файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3- BottomNavigationBar и Navigator;

  • Часть 4- MVC. Мы будем использовать именно этот паттерн, как один из самых простых;

  • Часть 5 - http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6 (текущая статья) - работа с формами, текстовые поля и создание поста.

  • Часть 7 - работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 9 - немного о тестировании;

Создание формы: добавление поста

Для начала добавим на нашу страницу HomePage кнопку по которой мы будем добавлять новый пост:

@overrideWidget build(BuildContext context) {  return Scaffold(    appBar: AppBar(      title: Text("Post List Page"),    ),    body: _buildContent(),    // в первой части мы уже рассматривали FloatingActionButton    floatingActionButton: FloatingActionButton(      child: Icon(Icons.add),      onPressed: () {      },    ),  );}

Далее создадим новую страницу в файле post_add_page.dart:

import 'package:flutter/material.dart';class PostDetailPage extends StatefulWidget {  @override  _PostDetailPageState createState() => _PostDetailPageState();}class _PostDetailPageState extends State<PostDetailPage> {    // TextEditingController'ы позволят нам получить текст из полей формы  final TextEditingController titleController = TextEditingController();  final TextEditingController contentController = TextEditingController();    // _formKey пригодится нам для валидации  final _formKey = GlobalKey<FormState>();    @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post Add Page"),        actions: [          // пункт меню в AppBar          IconButton(            icon: Icon(Icons.check),            onPressed: () {              // сначала запускаем валидацию формы              if (_formKey.currentState!.validate()) {                // здесь мы будем делать запроc на сервер              }            },          )        ],      ),      body: Padding(        padding: EdgeInsets.all(15),        child: _buildContent(),      ),    );  }  Widget _buildContent() {    // построение формы    return Form(      key: _formKey,      // у нас будет два поля      child: Column(        children: [          // поля для ввода заголовка          TextFormField(            // указываем для поля границу,            // иконку и подсказку (hint)            decoration: InputDecoration(                border: OutlineInputBorder(),                prefixIcon: Icon(Icons.face),                hintText: "Заголовок"            ),            // не забываем указать TextEditingController            controller: titleController,            // параметр validator - функция которая,            // должна возвращать null при успешной проверки            // или строку при неудачной            validator: (value) {              // здесь мы для наглядности добавили 2 проверки              if (value == null || value.isEmpty) {                return "Заголовок пустой";              }              if (value.length < 3) {                return "Заголовок должен быть не короче 3 символов";              }              return null;            },          ),          // небольшой отступ между полями          SizedBox(height: 10),          // Expanded означает, что мы должны          // расширить наше поле на все доступное пространство          Expanded(            child: TextFormField(              // maxLines: null и expands: true               // указаны для расширения поля на все доступное пространство              maxLines: null,              expands: true,              textAlignVertical: TextAlignVertical.top,              decoration: InputDecoration(                  border: OutlineInputBorder(),                  hintText: "Содержание",              ),              // не забываем указать TextEditingController              controller: contentController,              // также добавляем проверку поля              validator: (value) {                if (value == null || value.isEmpty) {                  return "Содержание пустое";                }                return null;              },            ),          )        ],      ),    );  }}

Не забудьте добавить переход на страницу формы:

floatingActionButton: FloatingActionButton(   child: Icon(Icons.add),   onPressed: () {      Navigator.push(context, MaterialPageRoute(         builder: (context) => PostDetailPage()      ));   },),

Запускаем и нажимаем на кнопку:

Вуаля! Форма работает.

Небольшая заметка

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

Поэтому для 100%-ной работы коды постарайтесь использовать схожие версии Flutter и Dart с моими:

  • Flutter 2.0.6

  • Dart SDK version: 2.12.3

Также в комментах я обратил внимание на null safety. Это очень важно, я позабыл об этом и это мой косяк.

Я уже добавил в приложение поддержку null safety. Вы наверно обратили внимание на восклицательный знак:

// ! указывает на то, что мы 100% уверены// что currentState не содержит null значение_formKey.currentState!.validate()

О null safety и о её поддержи в Dart можно сделать целый цикл статей, а возможно и написать целую книгу.

Мы задерживаться не будем и переходим к созданию POST запроса.

POST запрос для добавления данных на сервер

POST, как уже было отмечено, является одним из HTTP методов и служит для добавления новых данных на сервер.

Для начала добавим модель для нашего результата и изменим немного класс Post:

class Post {  // все поля являются private  // это сделано для инкапсуляции данных  final int? _userId;  final int? _id;  final String? _title;  final String? _body;  // создаем getters для наших полей  // дабы только мы могли читать их  int? get userId => _userId;  int? get id => _id;  String? get title => _title;  String? get body => _body;  // добавим новый конструктор для поста  Post(this._userId, this._id, this._title, this._body);  // toJson() превращает Post в строку JSON  String toJson() {    return json.encode({      "title": _title,      "content": _body    });  }  // Dart позволяет создавать конструкторы с разными именами  // В данном случае Post.fromJson(json) - это конструктор  // здесь мы принимаем объект поста и получаем его поля  // обратите внимание, что dynamic переменная  // может иметь разные типы: String, int, double и т.д.  Post.fromJson(Map<String, dynamic> json) :    this._userId = json["userId"],    this._id = json["id"],    this._title = json["title"],    this._body = json["body"];}// у нас будут только два состоянияabstract class PostAdd {}// успешное добавлениеclass PostAddSuccess extends PostAdd {}// ошибкаclass PostAddFailure extends PostAdd {}

Затем создадим новый метод в нашем Repository:

// добавление поста на серверFuture<PostAdd> addPost(Post post) async {  final url = Uri.parse("$SERVER/posts");  // делаем POST запрос, в качестве тела  // указываем JSON строку нового поста  final response = await http.post(url, body: post.toJson());  // если пост был успешно добавлен  if (response.statusCode == 201) {    // говорим, что все ок    return PostAddSuccess();  } else {    // иначе ошибка    return PostAddFailure();  }}

Далее добавим немного кода в PostController:

// добавление поста// функция addPost будет принимать callback,// через который мы будет получать результатvoid addPost(Post post, void Function(PostAdd) callback) async {  try {    final result = await repo.addPost(post);    // сервер вернул результат    callback(result);  } catch (error) {    // произошла ошибка    callback(PostAddFailure());  }}

Ну что ж пора нам вернуться к нашему представлению PostAddPage:

class PostDetailPage extends StatefulWidget {  @override  _PostDetailPageState createState() => _PostDetailPageState();}// не забываем поменять на StateMVCclass _PostDetailPageState extends StateMVC {  // _controller может быть null  PostController? _controller;  // получаем PostController  _PostDetailPageState() : super(PostController()) {    _controller = controller as PostController;  }  // TextEditingController'ы позволят нам получить текст из полей формы  final TextEditingController titleController = TextEditingController();  final TextEditingController contentController = TextEditingController();  // _formKey нужен для валидации формы  final _formKey = GlobalKey<FormState>();  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: Text("Post Add Page"),        actions: [          // пункт меню в AppBar          IconButton(            icon: Icon(Icons.check),            onPressed: () {              // сначала запускаем валидацию формы              if (_formKey.currentState!.validate()) {                // создаем пост                // получаем текст через TextEditingController'ы                final post = Post(                  -1, -1, titleController.text, contentController.text                );                // добавляем пост                _controller!.addPost(post, (status) {                  if (status is PostAddSuccess) {                    // если все успешно то возвращаемя                    // на предыдущую страницу и возвращаем                    // результат                    Navigator.pop(context, status);                  } else {                    // в противном случае сообщаем об ошибке                    // SnackBar - всплывающее сообщение                    ScaffoldMessenger.of(context).showSnackBar(                      SnackBar(content: Text("Произошла ошибка при добавлении поста"))                    );                  }                });              }            },          )        ],      ),      body: Padding(        padding: EdgeInsets.all(15),        child: _buildContent(),      ),    );  }  Widget _buildContent() {    // построение формы    return Form(      key: _formKey,      // у нас будет два поля      child: Column(        children: [          // поля для ввода заголовка          TextFormField(            // указываем для поля границу,            // иконку и подсказку (hint)            decoration: InputDecoration(                border: OutlineInputBorder(),                prefixIcon: Icon(Icons.face),                hintText: "Заголовок"            ),            // указываем TextEditingController            controller: titleController,            // параметр validator - функция которая,            // должна возвращать null при успешной проверки            // и строку при неудачной            validator: (value) {              // здесь мы для наглядности добавили 2 проверки              if (value == null || value.isEmpty) {                return "Заголовок пустой";              }              if (value.length < 3) {                return "Заголовок должен быть не короче 3 символов";              }              return null;            },          ),          // небольшой отступ между полями          SizedBox(height: 10),          // Expanded означает, что мы должны          // расширить наше поле на все доступное пространство          Expanded(            child: TextFormField(              // maxLines: null и expands: true              // указаны для расширения поля              maxLines: null,              expands: true,              textAlignVertical: TextAlignVertical.top,              decoration: InputDecoration(                  border: OutlineInputBorder(),                  hintText: "Содержание",              ),              // указываем TextEditingController              controller: contentController,              // также добавляем проверку поля              validator: (value) {                if (value == null || value.isEmpty) {                  return "Содержание пустое";                }                return null;              },            ),          )        ],      ),    );  }}

Логика работы следующая:

  1. мы нажаем добавить новый пост

  2. открывается окно с формой, вводим данные

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

Заключительный момент, добавим обработку результата в PostListPage:

floatingActionButton: FloatingActionButton(  child: Icon(Icons.add),  onPressed: () {    // then возвращает объект Future    // на который мы подписываемся и ждем результата    Navigator.push(context, MaterialPageRoute(      builder: (context) => PostDetailPage()    )).then((value) {      if (value is PostAddSuccess) {        // SnackBar - всплывающее сообщение        ScaffoldMessenger.of(context).showSnackBar(         SnackBar(content: Text("Пост был успешно добавлен"))        );      }    });  },),

Теперь тестируем:

К сожалению JSONPlaceholder на самом деле не добавляет пост и поэтому мы не сможем его увидеть среди прочих постов.

Заключение

Я надеюсь, что убедил вас в том, что работа с формами на Flutter очень проста и не требует почти никаких усилий.

Большая часть кода - это создание POST запроса на сервер и обработка ошибок.

Полезные ссылки

Всем хорошего кода)

Подробнее..

Основы Flutter для начинающих (Часть IX)

11.06.2021 16:11:37 | Автор: admin

Flutter позволяет вам писать простые и понятные тесты для разных частей приложения.

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

Также мы попробуем использовать библиотеку Mockito, которая позволяет создавать фейковые реализации.

Ну что ж, приступаем к тестированию!

Наш план
  • Часть 1- введение в разработку, первое приложение, понятие состояния;

  • Часть 2- файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3- BottomNavigationBar и Navigator;

  • Часть 4- MVC. Мы будем использовать именно этот паттерн, как один из самых простых;

  • Часть 5- http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6- работа с формами, текстовые поля и создание поста.

  • Часть 7- работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 9 (текущая статья) - немного о тестировании;

Добавления необходимых зависимостей

Нам понадобиться два дополнительных пакета mockito и build_runner, поэтому добавим их:

# зависимости для разработки# в данном случае подключено тестированиеdev_dependencies:  flutter_test:    sdk: flutter  mockito: ^5.0.10  build_runner: ^2.0.4

Теперь мы можем приступать к тестированию

Пишем первый тест

В качестве объекта тестирования будет небольшой класс Stack:

class Stack<T> {  final stack = <T>[];    void push(T t) {    stack.add(t);  }    T? pop() {    if (isEmpty) {      return null;    }    return stack.removeLast();  }    bool get isEmpty => stack.isEmpty; }

Обратите внимание: класс Stack является обобщенным.

В корневой директории нашего проекта есть папка test, которая предназначена для тестов.

Создадим в ней новый файл stack_test.dart:

import 'package:flutter_test/flutter_test.dart';import 'package:json_placeholder_app/helpers/stack.dart';void main() {  // группа тестов  group("Stack", () {    // первый тест на пустой стек    test("Stack should be empty", () {      // expect принимает текущее значение       // и сравнивает его с правильным      // если значения не совпадают, тест не пройден      expect(Stack().isEmpty, true);    });    test("Stack shouldn't be empty", () {      final stack = Stack<int>();      stack.push(5);      expect(stack.isEmpty, false);    });    test("Stack should be popped", () {      final stack = Stack<int>();      stack.push(5);      expect(stack.pop(), 5);    });    test("Stack should be work correctly", () {      final stack = Stack<int>();      stack.push(1);      stack.push(2);      stack.push(5);      expect(stack.pop(), 5);      expect(stack.pop(), 2);      expect(stack.isEmpty, false);    });  });}

Довольно просто! Не правда ли?

На самом деле, это один из типов тестирования, который называется unit (модульное).

Также Flutter поддерживает:

  • Widget тестирование

  • Интеграционное тестирование

В данной статье мы рассмотрим только unit тестирование.

Давайте выполним наши тесты командой flutter test test/stack_test.dart:

Успешно!

Тестируем получение постов

Сначала видоизменим метод fetchPosts:

Future<PostList> fetchPosts({http.Client? client}) async {  // сначала создаем URL, по которому  // мы будем делать запрос  final url = Uri.parse("$SERVER/posts");  // делаем GET запрос  final response =  (client == null) ? await http.get(url) : await client.get(url);  // проверяем статус ответа  if (response.statusCode == 200) {    // если все ок то возвращаем посты    // json.decode парсит ответ    return PostList.fromJson(json.decode(response.body));  } else {    // в противном случае вызываем исключение    throw Exception("failed request");  }}

Теперь переходим к написанию самого теста.

Мы будем использовать mockito для создания фейкового http.Client'а

Создадим файл post_test.dart в папке tests:

import 'package:flutter_test/flutter_test.dart';import 'package:http/http.dart' as http;import 'package:json_placeholder_app/data/repository.dart';import 'package:json_placeholder_app/models/post.dart';import 'package:mockito/annotations.dart';import 'package:mockito/mockito.dart';// данный файл будет сгенерированimport 'post_test.mocks.dart';// аннотация mockito@GenerateMocks([http.Client])void main() {  // создаем наш репозиторий  final repo = Repository();  group("fetchPosts", () {      test('returns posts if the http call completes successfully', () async {        // создаем фейковый клиент        final client = MockClient();        // ответ на запрос        when(client.get(Uri.parse('https://jsonplaceholder.typicode.com/posts')))            .thenAnswer((_) async => http.Response('[{"userId": 1, "id": 2, "title": "Title", "content": "Content"}]', 200));        // проверяем корректность работы fetchPosts        // при удачном выполнении        final postList = await repo.fetchPosts(client: client);        expect(postList, isA<PostList>());        expect(postList.posts.length, 1);        expect(postList.posts.first.title, "Title");      });      test('throws an exception if the http call completes with an error', () {        final client = MockClient();        // генерация ошибки        when(client.get(Uri.parse('https://jsonplaceholder.typicode.com/posts')))            .thenAnswer((_) async => http.Response('Not Found', 404));        // проверка на исключение        expect(repo.fetchPosts(client: client), throwsException);      });  });}

Перед запуском теста необходимо сгенерировать post_test.mocks.dart файл:

flutter pub run build_runner build

После этого выполняем наши тесты командой flutter test test/post_test.dart:

Вуаля!

Заключение

Мы разобрали один из самых простых и известных типов тестирования - unit (модульное).

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

Полезные ссылки:

Всем хорошего кода!

Подробнее..

Flutter слоёный пирог с интересной начинкой. Графика

27.03.2021 10:18:41 | Автор: admin

Write once, run anywhere

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

В 1995 году, когда компания Sun провозгласила этот лозунг, ещё никто не знал что такое смартфон, а web-технологии только только начинали покорять мир. Похоже, с тех пор, многое изменилось. Количество IT-продуктов, как и их пользователей, росло огромными темпами, соответственно выросло количество технологий, инструментов и языков разработки. Акцент взаимодействия конечных пользователей с приложениями сместился в сторону web и мобильных платформ, а Android и iOS практически единолично поделили между собой мир мобильных устройств. Тем временем, можно определённо сказать, что лозунг "напиши один раз и запускай везде" приобрёл еще большую актуальность.

C точки зрения бизнеса, единая кодовая база, на которой можно собрать приложение с минимальными изменениями или дополнениями для различных платформ, звучит соблазнительно. Важную роль сегодня играет UI/UX, возможности для реализации которых, нам предоставляют web и мобильные платформы. И если, в плане мультиплатформенности бизнес-логики, к Java вопросов нет, то, например, проSwingдумаю можно не обсуждать. Перед техническим погружением в реализацию механизмов отрисовки во Flutter, разрешите представить своё мнение о текущем состоянии мультиплатформы.

To Web or not to Web

Web-технологии не стоят на месте -HTML5,WebGL,WebRTC,WebAssembly,ProgressiveWebApp. Казалось бы, идеальный кандидат для кроссплатформы, свиртуальной машиной (браузером), которая присутствует почти в каждом девайсе пользователя, от компьютера и телефона до часов и телевизора. Много внимания уделяется Responsive design в вебе, который помогает справиться с различными размерами экранов, а PWA делает web-приложения всё ближе к полноценным нативным приложениям.

Для мобильных платформ предлагаются "промежуточные" решения, например, обернуть WebView в нативный код, чтобы притвориться нормальным приложением. PhoneGap/Cardova,Ionicи многие другие из этой группы, как говорится, - Добро пожаловать, web-разрабочики, в мир мобильных приложений! Есть желание использовать возможности платформы, которых пока нет в реализации браузера? не беда, подключаем плагины. Такой подход существует уже с 2005 года, но, по-видимому, не всё так гладко.

Идея! - сказал кто-то в Facebook, - Берём полюбившийся сообществу веб-разработчиков ReactJS, а чтобы пользовательский опыт не сильно страдал, будем использовать "родные" для платформы интерфейсы взаимодействия с пользователем - на сцене появляется React Native.Xamarin c C#, KMM с Kotlin, по-моему, можно причислить к этой же когорте. Как бы там ни было - React Native, с Javascript и часто обсуждаемым "мостом", занял вполне достойное место в ряду кроссплатформенных решений.

Fresh start

В 2014 году, тихо и без пафоса, стартовал проект Sky, а через какое-то время начала появляется первая публичная информация. В 2018 году, уже под именем Flutter, проект достаточно громко заявил о себе выпуском первой стабильной версии. К тому времени, у меня уже был опыт использования ReactNative, PhoneGap/Ionic и PWA - и вроде всё работает, бизнес-задачи решаются, но, где-то внутри, было труднообъяснимое чувство недосказанности по этим инструментам.

Когда я впервые услышал о Flutter, первая мысль - очередная попытка "поженить" web и mobile, но, после более глубокого ознакомления с работой фреймворка, понял, что ошибался, понял, что меня не устраивало в предыдущих решениях - вот эти попытки, связать по живому mobile и web, как Франкенштейна.

Flutter выглядел одновременно новым, и в тоже время знакомым решением. Новым - потому что увидел смелый шаг с отказом от полумер, в попытке "угодить и этим и этим", знакомым - поскольку, при изучении фреймворка, часто наталкивался на известные и хорошо себя показавшие подходы из веб-технологий, что не удивительно, ведь вдохновителями Flutter были и остаются Eric Siedel и Ian Hickson, которые принимали непосредственное участие в разработке Chrome(WebKit, Blink), HTML5 и других web-технологий, а разработчик Dart, принимал активное участие в разработке Javascript V8. Мне кажется, эти люди просто решили сделать шаг вперед, без оглядки на взаимные ограничения накладываемые web и mobile. В общем, просто настало время, появились люди и поддержка компании Google))

Хорошая это была идея или плохая, покажет время. А пока, при упоминании Flutter, число классических мнений: кладбище проектов google и зачем с нуля изобретать очередной велосипед уменьшается пропорционально росту количеству приложений на флаттер, вакансий и популярности проекта на GitHub, а появление SwiftUI и Jetpack Compose (и более экзотических) подтверждает жизнеспособность декларативного подхода при определении UI в коде.

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

Идея использования слоёв в IT-архитектуре, не нова, взять ту же Clean Architecture, которая позволяет нам разделить ответственности и взаимодействие между слоями. Flutter с самого начала своей истории активно использует этот подход. В первом приближении Flutter UI Toolkit делится на 3 слоя:

Application Code - код написанный разработчиком Flutter

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

Flutter Engine - платформонезависимая часть Flutter, написанная на C/C++, фактически представляет из себя динамическую библиотеку, подключаемую при старте приложения. Поставляется в скомпилированном виде на машине разработчика при установке Flutter. Содержит виртуальную машину Dart, библиотеку Skia, код рендеринга, код взаимодействия с нижнем уровнем платформы.

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

Framework

Это первый слой, с которым начинает "общаться" Flutter разработчик. А если быть более точным, и принять во внимание отличие понятий "библиотека" и "фреймворк", Flutter Framework, управляет написанным нами кодом. На языке Dart, создается декларативное описание интерфейса в виде древовидной компоновки объектов Widget, и уже сам фреймворк вызывает методы отрисовки или перестроения дерева виджетов.

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

Фреймворк также внутри разделен на слои, каждый со своей зоной ответственности.

  • Уровень виджетов - использует программист Flutter при разработке(чуть ниже есть уровень Elements, его пропустим, полное описание принципа построения виджетов будет дано по ссылке ниже)

  • Уровень рендеринга - сам фреймворк, под капотом, использует так называемые RenderObject, которые формируются из дерева виджетов

  • Уровень отрисовки - здесь на основе дерева RenderObject формируются команды отрисовки для передачи в Engine.

Для исследования, возьмем простейший пример, состоящий из нескольких виджетов с простой конфигурацией.

box_example.dart
import 'package:flutter/material.dart';void main() {  runApp(MyApp());}class MyApp extends StatelessWidget {  @override  Widget build(BuildContext context) {    return Center(      child: Container(        width: 200,        height: 200,        decoration: BoxDecoration(          border: Border.all(color: Colors.blueAccent),          borderRadius: const BorderRadius.all(Radius.circular(30)),        ),        padding: EdgeInsets.all(30),        child: Container(          color: Colors.redAccent,        ),      ),    );  }}
Результат работы box_example.dartРезультат работы box_example.dart

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

После запуска приложения происходит инициализация так называемых Bindings, которые связывают фреймворк с нижним уровнем Engine.

SchedulerBinding Отвечает, в том числе, за обработку и формирование вызовов сигнализирующих о текущем статусе построения фрейма (BeginFrame, DrawFrame)

WidgetsBinding отвечает за перестроение дерева виджетов, построение elements и RenderObejct

RenderBinding После построения дерева элементов, RenderObject и их взаимосвязей, в работу включается слой фреймворка Rendering, который выполняет несколько этапов

  • Layout - производится расчет размеров и положения всех RenderObject

  • CompositingBits - обновляется информация об объетках использующих композицию

  • Paint - отрисовка экрана. Здесь задействована Skia, через использование PictureLayer, PictureRecorder и Canvas, RenderObjects отрисовываютс себя с помощью этих элементов, но, на самом деле фактически просто формируется список команд отрисовки и применения слоёв.

  • Compositing - создается и строится сцена Scene с учетом всех слоёв их композиции и команд отрисовки на этих слоях.

 Подготовка сцены перед передачей на отрисовку в Engine Подготовка сцены перед передачей на отрисовку в Engine

Flutter Engine

После того как сцена(Scene) со всеми командами отрисовки подготовлена, она передается на уровень Engine с помощью метода render. Затем из сцены извлекается LayerTree, производятся необходимые предварительные действия и проверка дерева. На данном этапе, работа в потоке UI завершается, а LayerTree передается на обработку в Raster поток.

При запуске Flutter приложения, инициализируются четыре потока
UI Thread - основная программа пользователя и код фреймворка на Dart
Raster Thread - поток работы с OpenGL и отрисовкой на GPU
IO Thread - вспомогательный поток для Raster thread, обработка assets
Platform Thread - платформозависимый код приложения

Процессом непосредственной отрисовки на экран устройства управляет класс Rasterizer. Метод Draw этого класса запускается в отельном потоке Raster. Сначала в работу вступает композитор Flow, он разворачивает дерево и подготавливает слои дерева (LayerTree:Preroll) к отрисовке, а после выполняет команды отрисовки Skia, которые находятся в дереве (LayerTree:paint), замечу, в данном случае непосредственно отрисовки на экране не происходит, по сути собирается очередь из так называемых объектов операций Skia (см. addDrawOp)

В методе SkCanvas:Flush производится выполнение операций Skia, из очереди, которая была подготовлена на предыдущем этапе, эти операции выполняют непосредственную отрисовку в OpenGL буфер. После отрисовки вызывается метод платформы SwapBuffer, изображение появляется на экране, а цикл рендеринга фрейма в потоке GPU завершается.

По временному графику видно, что время подготовки сцены в UI потоке и её последующей композиции в Raster потоке мало, по сравнению с временем выполнения непосредственной отрисовки в SkCanvas:Flush. В нашем случае простой сцены это в пределах нормы, но при сложной сцене время отрисовки может значительно возрасти. Как правило, это связно с подготовкой программ для графического процессора по каждой из операций Skia. Обратите внимание на пару GrGlProgram в операциях отрисовки прямоугольников из нашего примера - FillRectOp на графике, это как раз место где генерируются и компилируются программы для GPU.

Shaders

Шейдер(англ.shaderзатеняющий)компьютерная программа, предназначенная для исполненияпроцессорами видеокарты (GPU). Шейдеры составляются на одном из специализированныхязыков программирования икомпилируютсявинструкциидля ЦП.

Skia, как и большинство программ использующих OpenGL/Metal при рендеринг, не использует команды отрисовки отдельных точек, линий, полигонов и их закрашивание. Для отрисовки элемента сначала создаются буферы с вершинами и их координатами(vertex), а также генерируются так называемые шейдеры. Шейдеры - по сути программы, которые работают на процессорах GPU, и параллельно обрабатывают координаты и цвета точек, которые им были предварительно переданы через буферы. В Skia, для рендеринга элементов используются 2 типа шейдеров - это вершинные(vertex) и пиксельные(fragment). Вершинные работают с координатами(например могут применять матрицы трансформации, проекции), пиксельные управляют цветом каждого пикселя перед выдачей на экран устройства.

Существуют несколько вариантов языков описания шейдеров, в большинстве своём, это Си-подобные языки (Metal например использует спецификацию С++14). Чтобы абстрагироваться от разных вариантов языков, Skia, под капотом, использует свой формат SkSL, который фактически унаследован от OpenGL GLSL.

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

Вершинный и пиксельный шейдеры сгенерированые Skia для отрисовки квадрата
#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;in float2 position;in half4 color;noperspective out half4 vcolor_Stage0;void main() {// Primitive Processor QuadPerEdgeAAGeometryProcessorvcolor_Stage0 = color;sk_Position = float4(position.x , position.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requirenoperspective in half4 vcolor_Stage0;out half4 sk_FragColor;void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, QuadPerEdgeAAGeometryProcessoroutputColor_Stage0 = vcolor_Stage0;outputCoverage_Stage0 = half4(1);}{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * outputCoverage_Stage0;}}
Вершинный и пиксельный шейдеры сгенерированные Skia для отрисовки рамки
#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;in float2 position;in half4 color;noperspective out half4 vcolor_Stage0;void main() {// Primitive Processor QuadPerEdgeAAGeometryProcessorvcolor_Stage0 = color;sk_Position = float4(position.x , position.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 uinnerRect_Stage1_c0;uniform half2 uradiusPlusHalf_Stage1_c0;uniform float4 uinnerRect_Stage1_c0_c0;uniform half2 uradiusPlusHalf_Stage1_c0_c0;noperspective in half4 vcolor_Stage0;out half4 sk_FragColor;half4 CircularRRect_Stage1_c0_c0(half4 _input) {float2 dxy0 = uinnerRect_Stage1_c0_c0.LT - sk_FragCoord.xy;float2 dxy1 = sk_FragCoord.xy - uinnerRect_Stage1_c0_c0.RB;float2 dxy = max(max(dxy0, dxy1), 0.0);half alpha = half(saturate(uradiusPlusHalf_Stage1_c0_c0.x - length(dxy)));alpha = 1.0 - alpha;return _input * alpha;}inline half4 CircularRRect_Stage1_c0(half4 _input) {float2 dxy0 = uinnerRect_Stage1_c0.LT - sk_FragCoord.xy;float2 dxy1 = sk_FragCoord.xy - uinnerRect_Stage1_c0.RB;float2 dxy = max(max(dxy0, dxy1), 0.0);half alpha = half(saturate(uradiusPlusHalf_Stage1_c0.x - length(dxy)));return CircularRRect_Stage1_c0_c0(_input) * alpha;}void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, QuadPerEdgeAAGeometryProcessoroutputColor_Stage0 = vcolor_Stage0;outputCoverage_Stage0 = half4(1);}half4 output_Stage1;output_Stage1 = CircularRRect_Stage1_c0(outputCoverage_Stage0);{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * output_Stage1;}}

Т.е. в нашем примере сгенерировано всего 2 шейдера, но если мы возьмем классический пример приложения каунтера во Flutter:

шейдеров гораздо больше (с учётом эффекта при нажатии кнопки)

Шейдеры классического примера с каунтером
#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;in float2 inPosition;in half4 inColor;in half3 inShadowParams;noperspective out half3 vinShadowParams_Stage0;noperspective out half4 vinColor_Stage0;void main() {// Primitive Processor RRectShadowvinShadowParams_Stage0 = inShadowParams;vinColor_Stage0 = inColor;float2 _tmp_0_inPosition = inPosition;sk_Position = float4(_tmp_0_inPosition.x , _tmp_0_inPosition.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requireuniform sampler2D uTextureSampler_0_Stage0;noperspective in half3 vinShadowParams_Stage0;noperspective in half4 vinColor_Stage0;out half4 sk_FragColor;void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, RRectShadowhalf3 shadowParams;shadowParams = vinShadowParams_Stage0;outputColor_Stage0 = vinColor_Stage0;half d = length(shadowParams.xy);float2 uv = float2(shadowParams.z * (1.0 - d), 0.5);half factor = sample(uTextureSampler_0_Stage0, uv).000r.a;outputCoverage_Stage0 = half4(factor);}{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * outputCoverage_Stage0;}}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;in float2 position;in half4 color;noperspective out half4 vcolor_Stage0;void main() {// Primitive Processor QuadPerEdgeAAGeometryProcessorvcolor_Stage0 = color;sk_Position = float4(position.x , position.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requirenoperspective in half4 vcolor_Stage0;out half4 sk_FragColor;void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, QuadPerEdgeAAGeometryProcessoroutputColor_Stage0 = vcolor_Stage0;outputCoverage_Stage0 = half4(1);}{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * outputCoverage_Stage0;}}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;uniform float3x3 ulocalMatrix_Stage0;in float2 inPosition;in half4 inColor;in float4 inCircleEdge;noperspective out float4 vinCircleEdge_Stage0;noperspective out half4 vinColor_Stage0;void main() {// Primitive Processor CircleGeometryProcessorvinCircleEdge_Stage0 = inCircleEdge;vinColor_Stage0 = inColor;float2 _tmp_0_inPosition = inPosition;float2 _tmp_1_inPosition = (ulocalMatrix_Stage0 * inPosition.xy1).xy;sk_Position = float4(_tmp_0_inPosition.x , _tmp_0_inPosition.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requirenoperspective in float4 vinCircleEdge_Stage0;noperspective in half4 vinColor_Stage0;out half4 sk_FragColor;void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, CircleGeometryProcessorfloat4 circleEdge;circleEdge = vinCircleEdge_Stage0;outputColor_Stage0 = vinColor_Stage0;float d = length(circleEdge.xy);half distanceToOuterEdge = half(circleEdge.z * (1.0 - d));half edgeAlpha = saturate(distanceToOuterEdge);outputCoverage_Stage0 = half4(edgeAlpha);}{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * outputCoverage_Stage0;}}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;in float2 position;in half4 inColor;noperspective out half4 vcolor_Stage0;void main() {// Primitive Processor VerticesGPhalf4 color = inColor;color = color.bgra;color = color;color = half4(color.rgb * color.a, color.a);vcolor_Stage0 = color;float2 _tmp_0_position = position;sk_Position = float4(_tmp_0_position.x , _tmp_0_position.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requireuniform half4 ucolor_Stage1_c0_c0;noperspective in half4 vcolor_Stage0;out half4 sk_FragColor;half4 ConstColorProcessor_Stage1_c0_c0(half4 _input) {return ucolor_Stage1_c0_c0;}half4 BlurredEdgeFragmentProcessor_Stage1_c0_c1(half4 _input) {half inputAlpha = _input.w;half factor = 1.0 - inputAlpha;@switch (0) {case 0:        factor = exp((-factor * factor) * 4.0) - 0.017999999225139618;break;case 1:        factor = smoothstep(1.0, 0.0, factor);break;}return half4(factor);}inline half4 Blend_Stage1_c0(half4 _input) {// Blend mode: Modulate (SkMode behavior)return blend_modulate(ConstColorProcessor_Stage1_c0_c0(half4(1)), BlurredEdgeFragmentProcessor_Stage1_c0_c1(_input));}void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, VerticesGPoutputColor_Stage0 = vcolor_Stage0;outputCoverage_Stage0 = half4(1);}half4 output_Stage1;output_Stage1 = Blend_Stage1_c0(outputColor_Stage0);{// Xfer Processor: Porter Duffsk_FragColor = output_Stage1 * outputCoverage_Stage0;}}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;uniform float2 uAtlasSizeInv_Stage0;in float2 inPosition;in half4 inColor;in ushort2 inTextureCoords;noperspective out float2 vTextureCoords_Stage0;noperspective out float vTexIndex_Stage0;noperspective out half4 vinColor_Stage0;void main() {// Primitive Processor Textureint texIdx = 0;float2 unormTexCoords = float2(inTextureCoords.x, inTextureCoords.y);vTextureCoords_Stage0 = unormTexCoords * uAtlasSizeInv_Stage0;vTexIndex_Stage0 = float(texIdx);vinColor_Stage0 = inColor;float2 _tmp_0_inPosition = inPosition;sk_Position = float4(inPosition.x , inPosition.y, 0, 1);}}#extension GL_NV_shader_noperspective_interpolation: requireuniform sampler2D uTextureSampler_0_Stage0;noperspective in float2 vTextureCoords_Stage0;noperspective in float vTexIndex_Stage0;noperspective in half4 vinColor_Stage0;out half4 sk_FragColor;void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, TextureoutputColor_Stage0 = vinColor_Stage0;half4 texColor;{texColor = sample(uTextureSampler_0_Stage0, vTextureCoords_Stage0).rrrr;}outputCoverage_Stage0 = texColor;}{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * outputCoverage_Stage0;}}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;uniform float4 uviewMatrix_Stage0;in float2 position;in half4 inColor;noperspective out half4 vcolor_Stage0;void main() {// Primitive Processor VerticesGPhalf4 color = inColor;color = color.bgra;color = color;color = half4(color.rgb * color.a, color.a);vcolor_Stage0 = color;float2 _tmp_0_position = uviewMatrix_Stage0.xz * position + uviewMatrix_Stage0.yw;sk_Position = float4(_tmp_0_position.x , _tmp_0_position.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requireuniform half4 ucolor_Stage1_c0_c0;noperspective in half4 vcolor_Stage0;out half4 sk_FragColor;half4 ConstColorProcessor_Stage1_c0_c0(half4 _input) {return ucolor_Stage1_c0_c0;}half4 BlurredEdgeFragmentProcessor_Stage1_c0_c1(half4 _input) {half inputAlpha = _input.w;half factor = 1.0 - inputAlpha;@switch (0) {case 0:        factor = exp((-factor * factor) * 4.0) - 0.017999999225139618;break;case 1:        factor = smoothstep(1.0, 0.0, factor);break;}return half4(factor);}inline half4 Blend_Stage1_c0(half4 _input) {// Blend mode: Modulate (SkMode behavior)return blend_modulate(ConstColorProcessor_Stage1_c0_c0(half4(1)), BlurredEdgeFragmentProcessor_Stage1_c0_c1(_input));}void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, VerticesGPoutputColor_Stage0 = vcolor_Stage0;outputCoverage_Stage0 = half4(1);}half4 output_Stage1;output_Stage1 = Blend_Stage1_c0(outputColor_Stage0);{// Xfer Processor: Porter Duffsk_FragColor = output_Stage1 * outputCoverage_Stage0;}}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;uniform float3x3 ulocalMatrix_Stage0;in float2 inPosition;in half4 inColor;in float4 inCircleEdge;noperspective out float4 vinCircleEdge_Stage0;noperspective out half4 vinColor_Stage0;void main() {// Primitive Processor CircleGeometryProcessorvinCircleEdge_Stage0 = inCircleEdge;vinColor_Stage0 = inColor;float2 _tmp_0_inPosition = inPosition;float2 _tmp_1_inPosition = (ulocalMatrix_Stage0 * inPosition.xy1).xy;sk_Position = float4(_tmp_0_inPosition.x , _tmp_0_inPosition.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requireuniform float3x3 umatrix_Stage1_c0_c0_c0;uniform sampler2D uTextureSampler_0_Stage1;noperspective in float4 vinCircleEdge_Stage0;noperspective in half4 vinColor_Stage0;out half4 sk_FragColor;half4 TextureEffect_Stage1_c0_c0_c0_c0(half4 _input, float2 _coords) {return sample(uTextureSampler_0_Stage1, _coords).000r;}half4 MatrixEffect_Stage1_c0_c0_c0(half4 _input, float2 _coords) {return TextureEffect_Stage1_c0_c0_c0_c0(_input, ((umatrix_Stage1_c0_c0_c0) * _coords.xy1).xy);}half4 DeviceSpaceEffect_Stage1_c0_c0(half4 _input) {return MatrixEffect_Stage1_c0_c0_c0(_input, sk_FragCoord.xy);}inline half4 Blend_Stage1_c0(half4 _input) {// Blend mode: DstIn (Compose-One behavior)return blend_dst_in(DeviceSpaceEffect_Stage1_c0_c0(half4(1)), _input);}void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, CircleGeometryProcessorfloat4 circleEdge;circleEdge = vinCircleEdge_Stage0;outputColor_Stage0 = vinColor_Stage0;float d = length(circleEdge.xy);half distanceToOuterEdge = half(circleEdge.z * (1.0 - d));half edgeAlpha = saturate(distanceToOuterEdge);outputCoverage_Stage0 = half4(edgeAlpha);}half4 output_Stage1;output_Stage1 = Blend_Stage1_c0(outputCoverage_Stage0);{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * output_Stage1;}}#extension GL_NV_shader_noperspective_interpolation: requireuniform float4 sk_RTAdjust;in float4 radii_selector;in float4 corner_and_radius_outsets;in float4 aa_bloat_and_coverage;in float4 skew;in float2 translate;in float4 radii_x;in float4 radii_y;in half4 color;noperspective out half4 vcolor_Stage0;noperspective out float2 varccoord_Stage0;void main() {// Primitive Processor GrFillRRectOp::Processorvcolor_Stage0 = color;float2 corner = corner_and_radius_outsets.xy;float2 radius_outset = corner_and_radius_outsets.zw;float2 aa_bloat_direction = aa_bloat_and_coverage.xy;float coverage = aa_bloat_and_coverage.z;float is_linear_coverage = aa_bloat_and_coverage.w;float2 pixellength = inversesqrt(float2(dot(skew.xz, skew.xz), dot(skew.yw, skew.yw)));float4 normalized_axis_dirs = skew * pixellength.xyxy;float2 axiswidths = (abs(normalized_axis_dirs.xy) + abs(normalized_axis_dirs.zw));float2 aa_bloatradius = axiswidths * pixellength * .5;float4 radii_and_neighbors = radii_selector* float4x4(radii_x, radii_y, radii_x.yxwz, radii_y.wzyx);float2 radii = radii_and_neighbors.xy;float2 neighbor_radii = radii_and_neighbors.zw;if (any(greaterThan(aa_bloatradius, float2(1)))) {corner = max(abs(corner), aa_bloatradius) * sign(corner);coverage /= max(aa_bloatradius.x, 1) * max(aa_bloatradius.y, 1);radii = float2(0);}if (any(lessThan(radii, aa_bloatradius * 1.25))) {radii = aa_bloatradius;radius_outset = floor(abs(radius_outset)) * radius_outset;is_linear_coverage = 1;}else {radii = clamp(radii, pixellength, 2 - pixellength);neighbor_radii = clamp(neighbor_radii, pixellength, 2 - pixellength);float2 spacing = 2 - radii - neighbor_radii;float2 extra_pad = max(pixellength * .0625 - spacing, float2(0));radii -= extra_pad * .5;}float2 aa_outset = aa_bloat_direction.xy * aa_bloatradius;float2 vertexpos = corner + radius_outset * radii + aa_outset;float2x2 skewmatrix = float2x2(skew.xy, skew.zw);float2 devcoord = vertexpos * skewmatrix + translate;if (0 != is_linear_coverage) {varccoord_Stage0.xy = float2(0, coverage);}else {float2 arccoord = 1 - abs(radius_outset) + aa_outset/radii * corner;varccoord_Stage0.xy = float2(arccoord.x+1, arccoord.y);}sk_Position = float4(devcoord.x , devcoord.y, 0, 1);}#extension GL_NV_shader_noperspective_interpolation: requireuniform float3x3 umatrix_Stage1_c0_c0_c0;uniform sampler2D uTextureSampler_0_Stage1;noperspective in half4 vcolor_Stage0;noperspective in float2 varccoord_Stage0;out half4 sk_FragColor;half4 TextureEffect_Stage1_c0_c0_c0_c0(half4 _input, float2 _coords) {return sample(uTextureSampler_0_Stage1, _coords).000r;}half4 MatrixEffect_Stage1_c0_c0_c0(half4 _input, float2 _coords) {return TextureEffect_Stage1_c0_c0_c0_c0(_input, ((umatrix_Stage1_c0_c0_c0) * _coords.xy1).xy);}half4 DeviceSpaceEffect_Stage1_c0_c0(half4 _input) {return MatrixEffect_Stage1_c0_c0_c0(_input, sk_FragCoord.xy);}inline half4 Blend_Stage1_c0(half4 _input) {// Blend mode: DstIn (Compose-One behavior)return blend_dst_in(DeviceSpaceEffect_Stage1_c0_c0(half4(1)), _input);}void main() {half4 outputColor_Stage0;half4 outputCoverage_Stage0;{// Stage 0, GrFillRRectOp::ProcessoroutputColor_Stage0 = vcolor_Stage0;float x_plus_1=varccoord_Stage0.x, y=varccoord_Stage0.y;half coverage;if (0 == x_plus_1) {coverage = half(y);}else {float fn = x_plus_1 * (x_plus_1 - 2);fn = fma(y,y, fn);float fnwidth = fwidth(fn);half d = half(fn/fnwidth);coverage = clamp(.5 - d, 0, 1);}outputCoverage_Stage0 = half4(coverage);}half4 output_Stage1;output_Stage1 = Blend_Stage1_c0(outputCoverage_Stage0);{// Xfer Processor: Porter Duffsk_FragColor = outputColor_Stage0 * output_Stage1;}}

Shader compilation jank

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

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

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

Можно ли предварительно скомпилировать все возможные шейдеры, перед запуском программы?

Шейдеры в Skia генерируются в зависимости от конфигурации операции - например, закрашенный прямоугольник или только рамка, стиль отрисовки, применение матриц трансформаций - соответственно это порождает много возможных вариантов для каждого примитива (насколько много, на текущий момент, я сказать не могу, предположительно, с учетом всех примитивов, речь идет о сотнях)

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

Для решения проблемы компиляции большого количества шейдеров в OpenGL, команда Flutter и Skia, реализовала метод предварительной записи шейдеров - так называемый "прогрев шейдеров". Суть метода такова - на машине разработчика запускается приложение с ключом, который позволяет сохранять используемые в Skia шейдеры. После запуска приложения, мы пробуем все варианты взаимодействия с интерфейсом, при этом шейдеры запоминаются и сохраняются на диск. Приложение собирается с этими шейдерами, и при старте на устройстве пользователя, они считываются и компилируются перед запуском основного кода.

Сам формат в котором Flutter сохраняет шейдеры закодирован с помощью base64, поэтому для удобства анализа я написал небольшую программу, которая может декодировать в читабельный вид. Ссылка на программу здесь. Плагин VSCode для подсветки синтаксиса здесь.

Metal

Выход metal и добровольно-принудительный переход на эту библиотеку в приложениях iOS, обострил проблему компиляции с новой силой. Дело в том, что предлагаемый метод "прогрева шейдеров", был реализован только на уровне OpenGL. Кроме того, текущий подход при компиляции шейдеров Metal MLSL, в некоторых случаях занимает больше времени, чем, при равных условиях, компиляция шейдеров OpenGL GLSL, что также повлияло на юзер экспириенс и справедливые возмущения разработчиков и бизнеса.

Подход к обработке скомпилированных объектов и их применении в пайплайне рендеринга Metal, немного отличаются от подхода в OpenGL, но, в любом случае методы предварительной компиляции описаны и доступны для использования. Также осенью прошлого года, Apple на WWDC2020 провела презентацию по использованию прекомпиляции и бинарных файлов при построении пайплайна GPU.

По-моему мнению, эта проблема "первых кадров" лежит не в технической, а организационной плоскости, и команда Flutter не придавала должного значения к анализу данной проблемы, а сама проблема решалась без привлечения специалистов по Metal. И только накал страстей и давление сообщества, помогло сдвинуть дело с мертвой точки, и последние недели на GitHub Skia, стали появляется изменения связанные с оптимизацией бинарных архивов Metal и подготовкой библиотек шейдеров.

Также необходимо упомянуть, что команда Flutter рассматривает вариант "прогрева шейдеров" как временный workaround. Конечная цель - полностью избавить разработчика от переживаний и дополнительных действий связанных с "прогревом" шейдеров.

Futter For Web

Буквально пара слов про рендеринг на Canvas в вебе. Для рендеринга, HTML элемент сanvas, используется только для инициализации WebGL (по сути, калька с OpenGLES). Skia, как известно, хорошо работает с OpenGL. Поэтому фактически библиотека Skia написанная на C++, компилируется в WebAssembly с помощью Emscripten + добавляются некоторые байндинги и подключается к проекту.

Show must go on

Как видно Flutter пытается откусить достаточно жирный кусок у платформы. От платформы по-сути требуется только поверхность для рисования, и некоторые системные вещи. Флаттер уже стабильно поддерживает Android/iOS/Linux/Windows.

Конечно, такой кардинальный подход, который выбрала Flutter команда, не обходится без трудностей, но куда ж без них, без трудностей))

Sources

Flutter architectural overview
Inside Flutter
Skia

Подробнее..

GRPC Dart, Сервис Клиент, напишем

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

Привет! Меня зовуте Андрей и я работаю разработчиком Flutter.

Написание материала вызвано желанием показать пример создания сервиса c использованием технологии gRPC в экосистеме Dart и, соответственно, Flutter. Желание периодически возникает, когда приходится испытывать "боль", при переключении на проекты, в которых до сих пор применяется REST + JSON.

Планирую сделать серию из 3-4 статей.

Кратко о gRPC

gRPC (Remote Procedure Calls от Гугл) технология для создания информационных систем (сервисов и клиентских приложений).

Для сериализации данных и их передачи по сети, как правило, в связке с gRPC используется Protocol Buffers (Protobuf).

Protobuf применяется и как IDL (Interface Definition Language) для описания типов данных и вызываемых процедур.

Технология gRPC является достойной альтернативой широко распространённым подходам, при которых сетевые вызовы используют HTTP методы, а обмен данными происходит в формате JSON или XML.

Основные преимущества gRPC это:

  1. HTTP/2 в качестве транспорта

  2. Отсутствие привязок к HTTP-методам при взаимодействии компонентов системы

  3. Возможность использования Protocol Buffers (Protobuf) для сериализации / десериализации данных и их передачи по сети

  4. Protobuf IDL удобен для описания системы

  5. Нет необходимости вручную писать модели, сериализацию / десериализацию данных, интерфейсы вызовов процедур. Применяется кодогенерация для популярных языков программирования, в том числе и для Dart

Упрощённый пример gRPC системыУпрощённый пример gRPC системы

Пример написания сервиса и клиента

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

Подготовка среды разработки

Если на машине нет Dart SDK его нужно установить. Пример команды установки для Mac brew install dart, для Ubuntu 20.4 sudo apt install dart.

Проверить, что Dart успешно установлен dart --version.

Установить protobuf (пример для Mac brew install protobuf, пример для Ubuntu 20.4 sudo apt install -y protobuf-compiler).

Проверить, что все прошло успешно protoc --version.

Установить плагин для кодогенерации .proto файлов, описывающих систему, в Dart:

dart pub global activate protoc_plugin.

Pub устанавливает утилиты в $HOME/.pub-cache/bin.

Чтобы плагин был доступен из любой директории в вашем терминале, добавьте в его конфигурационный файл (.bashrc, .bash_profile, .zshrc и т.п.) строчку export PATH="$PATH":"$HOME/.pub-cache/bin" и перезагрузите терминал (или выполните команду source на обновленный файл).

Подготовка проекта

В качестве примера давайте сделаем сервис, который будет задавать "Клиентам" вопросы и получать от них ответы. Название пусть будет "Umka".

В выбранной папке создаем проект:

dart create umka

Перейдя в папку проекта добавим директорию protos/ и в неё файл umka.proto, в котором мы и опишем нашу систему:

mkdir protos && touch protos/umka.proto

Для исходного кода сделаем папку lib/ с файлами service.dart и client.dart:

mkdir lib && touch lib/service.dart lib/client.dart

Создадим также папку для сгенерированного кода:

mkdir lib/generated

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

Добавление зависимостей

В качестве сторонней библиотеки нам пока потребуется только grpc. Остальные зависимости она "подтянет" сама.

Добавим её в pubspec.yaml и удалим из файла все лишнее:

Для загрузки из pub.dev репозитория библиотеки и её зависимостей в папке проекта выполним команду: dart pub get

Описание системы в с помощью IDL proto3

Опишем наш сервис Umka следующим образом:

Кому лень печатать, прогуляйтесь по ссылке на код.

В первой строчке обязательно нужно указать версию IDL syntax="proto3";.

Строки с 3 по 24 содержат описание типов передаваемых данных:

  • Ученик

  • Вопрос

  • Ответ

  • Оценка

Обратите внимание, что записи подобные string text = 2; выглядят как присваивание значения, но на самом деле это номера полей, которые используются для их идентификации в бинарном потоке данных при сериализации / десериализации.

Типы выглядят как в привычных языках программирования:

  • встроенные (Scalar Value Types) int32 и string

  • созданные Student, Question

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

  • получить вопрос

  • отправить ответ

Структура записи вызова rpc sendAnswer(Answer) returns(Evaluation) {} следующая:

  • sendAnswer - название удаленного вызова

  • Answer - тип запроса

  • Evaluation - тип ответа

Генерируем код сервиса на основе его описания в umka.proto

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

protoc -I protos/ protos/umka.proto --dart_out=grpc:lib/generated

Разберем команду:

  • protoc утилита генерации (мы установили ее ранее)

  • -I protos/ указание расположения файлов .proto

  • protos/umka.proto файл описания сервиса

  • --dart_out=grpc:lib/generated grpc - указание плагина, lib/generated - директория для сгенерированного кода

В результате её выполнения в проекте появится 4 новых файла:

Это основа нашего сервиса.

Эмуляция работы с данными

Добавим в корень проекта папку с файлом db/questions_db.json со списком вопросов:

[    {        "id": 0,        "text": "7 x 5 = ?"    },    {        "id": 1,        "text": "12 x 13 = ?"    },    {        "id": 3,        "text": "2 ** 5 = ?"    },    {        "id": 4,        "text": "2 ** 10 = ?"    },    {        "id": 5,        "text": "2 ** 11 = ?"    }]

В папку lib добавим файл lib/questions_db_driver.dart с кодом для получения списка вопросов из нашей импровизированной базы данных:

import 'dart:io';import 'dart:convert';import 'generated/umka.pb.dart';final List<Question> questionsDb = _readDb();List<Question> _readDb() {  final jsonString = File('data/questions_db.json').readAsStringSync();  final List db = jsonDecode(jsonString);  return db      .map((entry) => Question()        ..id = entry['id']        ..text = entry['text'])      .toList();}

Пишем код для сервера

В файле lib/service.dart создадим класс UmkaService, расширив UmkaServiceBase, находящийся в сгенерированном файле lib/generated/umka.pbgrpc.dart:

class UmkaService extends UmkaServiceBase {}

Добавим реализацию одного из двух обязательных методов абстрактного родительского класса getQuestion, а для второго sendAnswer оставим пока заглушку TODO:

@overrideFuture<Question> getQuestion(ServiceCall call, Student request) async {  print('Received question request from: $request');  return questionsDb[Random().nextInt(questionsDb.length)];}@overrideFuture<Evaluation> sendAnswer(ServiceCall call, Answer request) {  // TODO: implement sendAnswer  throw UnimplementedError();}

Я намеренно оставил имя второго параметра обоих вызовов request - каждый удаленный вызов должен содержать объект запроса, соответствующий типу, описанному в файле umka.proto.

В этот же файл lib/service.dart добавим код запуска нашего сервиса на сервере:

class Server {  Future<void> run() async {    final server = grpc.Server([UmkaService()]);    await server.serve(port: 5555);    print('Serving on the port: ${server.port}');  }}Future<void> main() async {  await Server().run();}

Теперь наш сервис готов "служить клиентам на 5555 порту", отвечая пока только на один вызов getQuestion.

код сервиса

Пишем код клиентского приложения для терминала

Файл lib/client.dart

import 'package:grpc/grpc.dart';import 'generated/umka.pbgrpc.dart';class UmkaTerminalClient {  late final ClientChannel channel;  late final UmkaClient stub;  UmkaTerminalClient() {    channel = ClientChannel(      '127.0.0.1',      port: 5555,      options: ChannelOptions(credentials: ChannelCredentials.insecure()),    );    stub = UmkaClient(channel);  }  Future<Question> getQuestion(Student student) async {    final question = await stub.getQuestion(student);    print('Received question: $question');    return question;  }  Future<void> callService(Student student) async {    await getQuestion(student);    await channel.shutdown();  }}Future<void> main(List<String> args) async {  final clientApp = UmkaTerminalClient();  final student = Student()    ..id = 42    ..name = 'Alice Bobich';  await clientApp.callService(student);}

ClientChannel channel; является абстракцией сетевых вызовов по протоколу HTTP/2. Можно представить его как канал к виртуальному "gRPC endpoint".

stub - экземпляр "клиента" любезно сгенерированного нам утилитой protoc. Вызовы его методов фактически и являются RPC - удалёнными вызовами процедур.

В конструкторе мы инициализируем channel передав ему адрес localhost (для запуска локально), произвольный порт, и отключаем для простоты демонстрации "секьюрность".

Далее инициализируем stub передав ему созданный channel.

Метод запроса случайного вопроса getQuestion очень прост - вызываем соответствующий метод у нашего экземпляра stub, ждём пока вопрос не "прилетит", печатаем его и "возвращаем".

Метод callService в классе UmkaTerminalClient присутствует для демонстрации работы.

Также для запуска примера в файл client.dart добавлен метод main в котором "создаётся студент" и от его имени запрашивается вопрос у нашего сервиса./

Запускаем сервис

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

dart lib/service.dart

Стартуем клиентское приложение

Командой dart lib/client.dart в другом окне терминала из папки проекта запустим нашего "клиента", который создаст канал, установит соединение с сервисом, запросит случайный вопрос, получит его и разорвёт соединение, заглушив канал.

Заключение

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

  • Подготовили среду разработки

  • Создали Dart проект

  • Добавили все необходимые зависимости

  • Описали нашу систему с помощью IDL proto3

  • Сгенерировали базовый Dart код системы утилитой protoc

  • Добавили "базу вопросов" и код для чтения из неё

  • Написали код для запуска сервиса на сервере

  • Создали терминального "клиента"

  • Запустили сервис на локальной машине и обратились к нему получив запрошенные данные

Далее можно "получать удовольствие" развивая наш сервис и клиентское приложение.

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

До встречи в следующей части!

Подробнее..
Категории: Dart , Flutter , Protobuf , Grpc , Дарт , Флаттер

Категории

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

  • Имя: Макс
    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