Или же «Как получить работу на Flutter без опыта?»
Мудрый не тот, кто знает ответы на все вопросы, а тот, кто знает вопросы, на которые нет ответов. — Урсула Крёбер Ле Гуин
Приветствую читателей Хабр!
Меня зовут Денис, я являюсь Cross-Platform Developer и использую фреймворк Flutter уже больше 20 лет, а мой общий опыт в программировании достигает 4-ёх лет. Коммерческий опыт так же присутствует.
Относительно недавно передо мной встала цель — получить работу в сфере IT, а именно в Cross-Platform отделе одной крупной компании. Для изучения был выбран фреймворк Flutter.
Пришлось собирать информацию про современную разработку по крупицам по всему интернету, что заняло нереальное количество времени — около 200 часов.
Сегодня я хочу поделиться с Вами этими знаниями. Также приправлю их полезной информацией и опытом, которе я получил уже работая в компании.
В статье будет материал, который должен знать каждый программист, независимо от направления или языка, однако также будет много материала, особенно полезного Flutter разработчикам. В этой статье не будет таких базовых вещей, как циклы, работа со структурами данных и так далее.
Содержание
Статью разделю на 4 основных главы:
-
Архитектура
-
Dart
-
Flutter
-
Углубленность для повышения уровня
Первый раздел рекомендую к прочтению, даже если Вы никогда не слышали про Flutter и Dart — там собрана общая информация, необходимая любому программисту.
Во втором и третьем разделах затрону особенности языка Dart и фреймворка Flutter — здесь также будет много полезной для всех информации, однако большинство строк будет посвящено именно Dart и Flutter.
В четвёртом разделе затрону некоторые интересные механики и дополнительные пакеты, которые помогут Вам убедиться, что после прочтения Вы действительно много знаете про современную разработку проектов любой сложности, а также не испытаете удивления, при получении вопроса про Сетевое взаимодействие, Протоколы для общения в сети и что вообще значите Десериализовать полученные данные.
Ну что, погнали?
Архитектура
В этом разделе речь пойдёт о Clean Architecture (Чистая Архитектура Роберта Мартина), основных современных архитектурных решениях — MV(x), Multimodularity (Многомодульная архитектура), принципах SOLID, GRASP и многом другом.
Начнём сразу с мастодонтов Архитектуры любого проекта:
ООП
ООП, или же Объектно-Ориентированное Программирование — концепция разработки, которая представляет абсолютно всё в виде классов и их объектов. Деревья — класс, а сосна, дуб, ёлка в конце концов — его объекты. У каждого из этих деревьев есть одна способность — рости. Рост — это метод (или же функция) класса Дерево.
К слову, классы имеют множество разных названий на разговорном сленге, поэтому не пугайся, если услышишь Сущности, Модели, Entities — это всё — другие названия классов, только чуть более специализированные.
Разберём 4 основных принципа ООП:
-
Абстракция — программисту должна быть представлена абстракция (абстрактный класс или интерфейс) без сложной кодовой реализации, чтобы каждый раз не приходилось лезть в сотни строк кода и разбираться, как же блин работает этот ServiceLocator?..Также здесь стоит знать про абстрагирование — это как в физике, когда мы пренебрегаем большинством малозначных для нас физических активностей — например, скоростью ветра при расчёте скорости пули или тепловоза на небольшом расстоянии. Так же и здесь — нам абсолютно не важно, сколько листиков у нашего дерева, если того не требует Техническое задание, поэтому и такое свойство в классе Дерево мы не создаём.
-
Инкапсуляция — помещаем данные об объекте в специальную капсулу — класс. Здесь стоит также рассказать и про дополнение к инкапсуляции — Скрытие. Мы можем использовать специальные модификаторы доступа для свойств класса, чтобы сделать из невидимыми вне этого класса или вне его классов наследников. В Dart мы имеем 2 модификатора доступа:
1. Public — по умолчанию, доступ к свойству отовсюду, где есть доступ к классу.
2. Private — переменная или метод начинаются с символа _ (нижнее подчёркивание). Такое свойство не будет видно за пределами этого класса.
Стоит отметить одну особенность Dart. Даже, если свойство помечено как Private, в этом же файле доступ к нему будет везде. Это противоречит концепции Инкапсуляции, поэтому мы должны это исправить. Для этого каждую модель (каждый класс) необходимо выносить в отдельный файл. -
Наследование — при создании нового класса мы можем унаследовать существующий класс, при этом весь его функционал окажется в классе-наследнике. Это значительно сокращает количество кода, который программисту необходимо переписать заново. Такой код называется переиспользуемым.
Существует 4 вида наследования :
1. Одиночное наследование — наследование одного класса, от другого: класс B наследуется от класса A и на этом дерево наследования заканчивается.
2. Многоуровневое наследование — наследование одного класса, что похоже на лесенку: класс B наследует класс A, а класс C наследует класс B …
3. Иерархическое наследование — наследование одного класса, что похоже на дерево (структура данных): классы B и C наследуются от класса A
4. Множественное наследование — наследование множества классов одновременно. Это вызывает множество проблем, таких, как «Алмазная проблема», поэтому в современных языках программирования стараются не допускать этот тип наследования. В Dart нечто похожее на множественное наследование можно сделать с помощью имплементации множества абстрактных классов (или же интерфейсов) или же с помощью добавления Mixins (примесей), но о них попозже. К слову, в Dart можно имплементировать неограниченное количество классов, однако нужно следить, чтобы отличалась их сигнатура, иначе возникнет ошибка компиляции. -
Полиморфизм — если, при наследовании классов, классу-наследнику необходимо модифицировать один из методов, то для этого можно использовать переопределение — изменение работы метода класса с сохранением его сигнатуры (передаваемых аргументов и названия метода). В Dart это делается с помощью аннотации @override. Если же мы хотим изменить сигнатуру метода (а именно тип передаваемых аргументов), то для этого используется перегрузка, однако в Dart перегрузки нет. Эту проблему решают именованные конструкторы, однако об этом позже..
Однако наследование имеет и свои недостатки: сильная связь между родителем и наследником, изменение базового класса обычно требует корректировки наследников, усложнение читабильности кода при глубоком уровне наследования, а так же замедление выполнения пода, поскольку наследование тожеже требует небольшой времязатраты.
С ООП покончено, двигаемся дальше…
SOLID
SOLID — набор принципов, которые следует соблюдать, чтобы код оставался легкочитаемым, легкоизменяемым и масштабиремым. Также соблюдение этих принципов позволяет новичкам быстрее входить на проект.
Заглавные буквы каждого принципа соответствуют заглавной букве SOLID. Удобнее всего понимать эти принципы на примере ООП (Объектно-Ориентированное Программирование), поэтому часто буду затрагивать сущности, объекты и классы. Давайте разберём каждый принцип чуть подробнее:
-
Single Responsibility Principle (принцип Единоответствия) — у каждой сущности должна быть всего одна причина для изменения, или же каждая сущность должна отвечать только за одно действие. Мы должны применять принцип Декомпозиции — разделение больших сущностей на маленькие, где у каждой — своя зона ответственности.
В программировании есть такое понятие — GodObject (Божественный объект) — объект, который может делать всё. Так вот, соблюдая принцип Единоответствия мы должны избегать таких объектов.
Один объект — одна задача: класс для работы с файлом — чтение и запись в файл / из файла, класс для работы с базой данных — чтение и запись в таблицу / из таблицы базы данных. -
Open-Closed Principle (принцип Открытости/Закрытости) — написанный ранее код должен быть закрыт для изменения, но открыт для модификации. Проще говоря, если необходимо доработать уже написанный ранее код, то внести изменения необходимо так, чтобы этот код мог работать так же, как и до внесения изменений во всех, требуемым до этого, случаях.
-
Liskov Substitution Principle (принцип подстановки Лисков) — класс-наследник должен дополнять функционал родительского класса, а не изменять его. Объяснение здесь похоже на принцип Открытости/Закрытости — если мы наследуем класс, то это нужно сделать так, чтобы класс-наследник мог использоваться во всех тех же местах, где используется родительский класс.
-
Interface Segrigation Principle (принцип разделения нтерфейсов) — класс, имплементирующий (реализующий) интерфейс, должен корректно реализовать все его методы, нигде не выбрасывая исключения о нереализованном методе. В случае возникновения у имплементируемого интерфейса методов, бесполезных для класса-реализатора, необходимо разделить этот интерфейс на более мелкие.
-
Dependency Inversion Principle (принцип инверсии зависимостей) — высокоуровневые сущности не должны зависеть от более низкоуровневых. Тоесть экран не должен зависеть от кнопки на нём, магазин не должен зависеть от способа оплаты товаров, а бензоколонка — от типа топлива. Для решения такой проблемы мы используем абстракцию.
Например, в случае с магазином и способом оплаты:
Необходимо создать абстрактный класс PaymentMethod, реализовывать который будут, например, классы CashPayment и BankCardPayment.
В параметрах класса магазина необходимо использовать именно абстрактный класс PaymentMethod. За счёт такой хитрости, в магазине мы сможем использовать и CashPayment, и BankCardPayment. Тоесть, таким образом, мы убрали зависимость высокоуровневого класса Shop от конкретной реализации способа оплаты в нём, и теперь можем использовать как наличку, так и банковские карты.
Вот и всё по SOLID. Совсем не сложно, верно?..
(Ну а если сложно, то перечитай всё выше сказанное ещё раз и попробуй представить примеры на каждый принцип)
Clean Architecture
100% Вы уже видели вот эту красивую круговую схему:
Так вот, ЗАБУДЬТЕ ЕЁ! Прямо сейчас, я серьёзно!..
Она достаточно трудна для понимания новичков. Я не раз сталкивался со случаем, когда новички не правильно воспринимали эту схему и потом строили неправильные связи и зависимости в проекте.
Ниже я представлю максимально простую схему, где, соблюдая Чистую архитектуру, реализовано одно из базовых действий любого проекта — аутентификация.
Эта схема актуальна для любого Front-End фреймворка, включая мобильные, Веб и кросс-платформенные. Для Back-End схема будет такой-же однако там немного другие участники процесса.
Давайте разберём эту схему подробнее:
-
3 слоя — Presentation (взаимодействие с пользователем), Domain (незаменимое сердце проекта с бизнес-логикой и моделями), Data (реализация взаимодействия со сторонними API, базами данных, как локальными, так и удалёнными, и так далее)
-
Элементы — UI (пользовательский интерфейс), BloC (контроллер состояния, то есть вся логика обработки пользовательского интерфейса), UseCase (конкретное действие в системе, которое связывает логику страницы и репозиторий), Repo (абстракция репозитория, которая нужна для реализации 5-ого принципа SOLID (Dependency Inversion Principle)), Repo_impl (имплементация (реализация) репозитория, где происходят какие-либо действия), Provider (сущность, где происходит взаимодействие с Trird Praty API) и Mapper (класс для сериализации и десериализации приходящих и уходящих аргументов запросов)
Работает это всё следующим образом: при нажатии на кнопку на стороне UI запрос передаётся в BloC, где уже вызывается UseCase, который вызывает метод репозитория, где уже запрос мапируется и попадает в Provider, откуда отправляется запрос вместе с аутентификационными данными пользователя на сервер.
В конце главы подведу итог: Domain — единственная неизменяемая часть проекта. Presentation и Data мы можем изменить в любой момент, благодаря чему у нас появляется возможность легко изменить интерфейс (Presentation) или, например, используемую базу данных (Data). Удобно? — Удобно!
IoC — Inversion of Control
Принцип инверсии управления, который повышает тестируемость, а так же облегчает изменяемость, масштабируемость и поддерживаемость программного продукта
Существует 3 паттерна:
-
Dependancy Injection (внедрение зависимостей) — зависимости напрямую передаются в объект, вместо того, чтобы создаваться внутри него.
Существует несколько видов, например внедрение зависимостей через конструктор: если для создания объекта A нужен другой объект B (то есть объект A зависит от объекта B), то в конструктор объекта A мы передаём не свойства для конструктора объекта B, а уже готовый объект B. Внедрять зависимости также можно с помощью сеттеров и публичных полей. -
ServiceLocator — паттерн, при использовании которого мы создам хранилище (этакую глобальную переменную) для всех сервисов, доступных в приложении (например UseCases и Repositories из главы про Чистую архитектуру), а потом из любого, нужного нам места, мы можем вытянуть требуемый сервис и использовать его.
-
Factory (фабричный метод) — создание фабричного конструктора, который будет создавать тот или иной вариант объекта, в зависимости от переданных в него данных.
Также стоит отметить такую сущность, как IoC-контейнер. Область в памяти или коде, где располагается реализация паттернов Inversion of Control.
MV(x) паттерны разработки архитектуры
Паттерны, предназначеные для написания контролируемой и легкоподдерживаемой архитектуры.
Существует 4 MV(x) паттерна:
-
MVC (Model — View — Controller)
MVC подразумевает разделение интерфейса и бизнес-логики, где View — пользовательский интерфейс, а Model — бизнес-логика. Controller служит для связывания этих двух слоёв. То есть, при нажатии на кнопку в слое View, Controller отправляет сигнал в Model, где происходят изменения, после чего Controller отправляет сигнал во View, где перерисовывается страничка с обновлённой информацией.
-
MVP (Model — View — Presenter)
Очень похож на MVC, однако есть одно существенное отличие: View имеет абстрактную надстройку — абстрактный класс или интерфейс, с которым уже общается не Controller, а Presenter. Это помогает облегчить тестирование, а также Presenter можно переиспользовать, ведь с одним Presenter можно использовать множество интерфейсов.
-
MVVM (Model — View — ViewModel)
По схеме можно заметить, что MVVM не отличим от MVC, тогда зачем же он вообще нам нужен? Дьявол кроется в деталях!
Здесь у нас нет физического блока управления, такого как Controller, а ViewModel — лишь абстракция, которое представляет собой DataBinding (двустороннее связывание). Если когда-то то работали с фреймворком Vue, то точно знаете, что такое реактивность, ведь это его главная особенность, а вот реактивность уже полностью представляет собой архитектуру MVVM.
Если не работали с Vue, то сейчас всё расскажу. Двустороннее связывание работает следующим образом: когда мы изменили переменную во View — сразу же изменилось значение переменной в Model. На примере сейчас всё станет ещё более понятно: как только Вы внесли изменение в поле ввода на экране — сразу же изменилось значение связанной с ним переменной.
Это достигается с помощью механизма прослушивания и обработки событий (Events Handling).
-
MVI (Model — View — Intent)
Здесь не буду давать никакой схемы, посколько она будет кристально повторять схему MVVM, только на место ViewModel необходимо поставить Intent. Отличается от MVVM только тем, что при работе с MVVM мы можем изменять каждую переменную в Model по отдельности, а в MVI — мы обязаны перезаписать весь State, то есть всё состояние — все переменные, связанные с этим объектом в Model. В таком случае используются методы копирования с внесением изменения только в требуемые поля.
Вот и всё, с MV(x) архитектурами разобрались, !(можно выдохнуть).
Multimodularity (Многомодульность)
Суть многомодульной архитектуры — разделение проекта на отдельные модули, где у каждого своя зона ответственности. Например, отдельными модулями могут быть слои чистой архитектуры + отдельный модуль с переиспользуемой бизнес-логикой, например какими-то константами, утилитами или локализацией, а так же модуль с переиспользуемыми элементами дизайна, которые будут отображаться сразу на нескольких экранах. Также можно вынести навигацию в отдельный модуль.
На примере Flutter в каждом модуле будут свои файлы конфигурации pubspec.yaml и pubspec.lock.
Такое решение позволяет переиспользовать отдельные, полностью готовые к работе модули в разных проектах. Также многомодульность ускоряет время сборки проекта, поскольку модули собираются независимо друг от друга, что позволяет не пересобирать модули, в которые небыли внесены изменения.
GRASP принципы
Ух, тут весело…
Их целых 9 штук! ОДНАКО, есть одно НО…
GRASP — расширенная версия принципов SOLID, поэтому, если Вы уже выучили SOLID — считайте, что вы почти что уже знаете GRASP.
На практике из GRASP обычно требуются 2 самых основных принципа, давайте разбираться:
-
Low Cupling (низкое связывание)
Принцип говорит, что класс должен быть максимально независимым, то есть иметь слабые связи с другими классами. -
High Cohesion (высокая зацепленность)
Класс должен иметь минимальную зону ответственность, то есть иметь минимальный функционал. Он должен сильно цепляться за какое-то узкое действие, не выполняя множество работы (аналог принципа S из SOLID) — отсюда и название.
Совершенно не сложно, правда?
Design Patterns (Паттерны проектирования)
Владение паттернами проектирования облегчает построение архитектуры проекта. Код перестаёт казаться каким-то магическим, Вы сразу понимаете, почему тут используется абстракция, тут передаётся функция, а здесь Collback.
Всего этих паттернов существует целых 22, однако на практике чаще всего используются 12 основных (а то и меньше, но знать нужно).
Для начала разделим паттерны на 3 основные группы:
-
Порождающие паттерны проектирования — отвечают за безопасное создание объектов: Singleton, Factory, Abstract Factory, Builder.
-
Структурные паттерны проектирования — отвечают за создание иерархий классов и их связь: Adapter, Bridge, Decorator, Facade.
-
Поведенческие паттерны проектирование — отвечают за эффективное взаимодействие между объектами: Command, Iterator, Observer, Strategy.
А сейчас разберём их подробнее в вышеперечисленном порядке:
-
Singleton (Одиночка) — создание всего одного объекта на основе конкретного класса. Примером может стать создание какого-то сервиса, например FileWriteService, или объекта класса ServiceLocator, о котором говорили выше.
-
Factory (Фабрика) — создание фабричного конструктора, который возвращает один из нескольких вариантов объекта, исходя из передаваемых данных. Например класс Furniture, который с помощью фабричного конструктора создаёт объекты Table, Chair, Bad.
-
Abstract Factory (Абстрактная фабрика) — абстрактная надстройка над обычной фабрикой. Если опять же обратиться к примеру с классом Furniture, то c здесь мы ещё сможем создать абстракцию, которая позволяет выбрать, например, стиль мебели: Venetian, Roman, Gothic.
-
Builder (Строитель) — использование конструктора с большим количеством входных параметров. Например, класс House, в конструктор которого мы можем передать следующие аргументы: Door, Window, WindowsAmount, RoofType, FloorAmount…
-
Adapter (Адаптер) — как и следует из названия — это абстракция, которая позволяет связать (дать возможность взаимодействовать) два, несвязуемых до этого, класса.
-
Bridge (Мост) — разделение сущность на абстракцию и её реализацию. То есть создаётся абстрактный класс или интерфейс с описанием сущности, а потом класс, который имплементирует эту абстракцию. Именно этот паттерн используется, когда мы реализуем D принцип SOLID.
-
Decorator (Декоратор) — оборачивание функционала или сущности в код, который расширяет его функционал, то есть расширение функционала сущности, которая оборачивается. Декораторы активно используются в языке программирования Python.
-
Facade (Фасад) — использование абстракции для работы с большим количеством сущностей.
-
Command (Команда) — метод вызывается в одном классе, а его реализация находится в другом классе, то есть мы командуем другому классу выполнить какое-то действие. Самым простым примером этого паттерна будет взаимодействие между слоями Чистой архитектуры, когда по кнопке со слоя Presentation выполняется UseCase в Domain, а потом Repository и Provider из Data.
-
Iterator (Итератор) — обход коллекций элемент за элементом. Используется, например, при обходе массива или списка с помощью цикла.
-
Observer (Наблюдатель) — использование механизма подписки и реагирования на событие. Используется при работе с WebSocket или же Stream во Flutter, а так же при любом отслеживании какого-либо события.
-
Strategy (Стратегия) — передача конкретной реализации метода внутрь класса, то есть действия объектов класса зависят от переданного метода. Примером может стать свойство onTap, которое присуще каждой кнопке и в которое мы передаём вырываемую при нажатии на кнопку функцию во Flutter.
Всё… Да… Немного совсем…
Композиция VS Агрегация
Совершенно несложная глава, где стоит запомнить только разницу между понятиями и примеры использования.
Композиция — при создании объекта мы создаём все его зависимости (необходимые для его работы объекты других классов) внутри его конструктора.
Агрегация — при создании объекта мы передаём в конструктор уже созданные заранее зависимости.
Агрегация значительно повышает тестируемость этого класса, так как все зависимости мы можем просто передавать извне.
Поздравляю, мы завершили самый сложный раздел. На его полное изучение по крупицам у меня ушло около месяца. Однако без этих знаний точно не получится получить работу программистом где-то не в «Шарашкиной конторе».
Dart
Это глава будет посвящена всем особенностям и тонкостям языка программирования Dart. Обсудим типы данных и разновидности коллекций, асинхронность и многопоточность, Null-Safety, а также Dart VM(Virtual Machine).
Let’s go …
Особенности Dart
Dart — статический, строготипизированный, объектно-ориентированный язык программирования, разработанный компанией Google в 2011 году. Изначально Dart позиционировался в качестве замены языку JavaScript, а позже в дополнение в Dart был создан кросс-платформенный фреймворк Flutter, который позволил удобно создавать приложения под огромное количество платформ одновременно, однако о звезде сегодняшнего шоу поговорим в следующей главе.
Типы данных в Dart
В Dart есть интересная особенность — в нём нет ни одного простого типа данных, таких как, например, в C. Все типы данных представлены в виде наследуемых друг от друга классов. Такая система используется по двум причинам:
-
Dart — кроссплатформенный язык программирования, а типы данных на разных платформах могут отличаться по размеру, поэтому, при переводе классового типа данных в тип данных определённой платформы получается установить используемый на платформе размер.
-
У классовых типов данных, в отличии от простых, есть методы, которые можно использовать. Например: toString().
Как было сказано выше — типы данных наследуются друг от друга, а значит они имеют какую-то иерархию наследования. Давайте разберём её подробно.
Как видно из схемы — все типы данных наследуются от Object? (Null-Safety Object). Что такое Null-Safety — разберём немного позже. От него наследуются базовый тип данных в Dart, от которого уже наследуются все остальные привычные нам типы данных — Object. Именно он имеет стандартные методы, по типу toString(). Также в этот тип данных можно записать переменную любого типа и даже изменять тип переменной позже, однако такая реализация накладывает на нас некоторые ограничения, о которых поговорим в следующей главе.
Однако от Object? также наследуется и Null. Что такое тип даных Null? Очень просто! Это тип данных, значение переменной с которым в любом случае будет равно Null.
От Object наследуются Iterable — итерируемые коллекции, то есть коллекции, элементы которых можно обойти, и num — любое число, включая целые числа и числа с плавающей точкой.
От Iterable уже наследуются конкретные коллекционные типы данных, такие как List — список, или же массив на языке других языков программирования. Язык языков…
А от num наследуются все числовые типы данных: double — числа с плавающей запятой двойной точности, и int — целые числа.
В итоге всей этой цепочки наследования всё сводится к Never — тип данных, который присваивается методам или функциям, результатом которых в любом случае будет выброс Exeption (Исключения).
Кроме типов данных, указанных на этой сокращённой схеме, в Dart есть ещё несколько:
-
String — строка с текстом, или же стандартная фраза
-
Symbol — один символ, аналог char из низкоуровневых языков программирования
-
Rune — один символ в кодировке UTF-16, может представить любой символ Unicode
-
bool — булевый тип данных, переменная которого может иметь только 2 значение — true (в случае верного результата) и false (в случае неверности)
-
Function — стандартная функция, записанная в переменную
-
Set — математическое множество. Отличается от List тем, что элементы в нём не могут повторяться. Set в Dart имеет сразу 3 реализации на выбор пользователя:
1. HashSet — множество по умолчанию. Не гарантирует сохранение порядка добавления элементов. Реализовано на Хеш-Таблице
2. LinkedHashSet — гарантирует сохранение порядка добавления элементов, благодаря реализации на Связном списке и Хеш-Таблице
3. SplayTreeSet — множество упорядоченных объектов, то есть объекты автоматически сортируются в порядке возрастания. Достичь такого результата получилось благодаря реализации на самобалансирующемся бинарном дереве
Реализация выбирается программистом в зависимости от требований от этого множества, основываясь на выполняемых с ним операциями. Скорость их выполнения в корне зависит от структуры данных, на которой реализован тот или иной вид Set -
Map — хеш-таблица или же набор пар ключ — значение. Каждому ключу выбранного типа данных соответствует значение выбранного типа данных
С типами данных в Dart разобрались…
Ключевые слова в Dart
Раз уж затронули типы данных, то самое время поговорить и о ключевых словах — это механизм управления ограничениями и разрешениями той или иной переменной, свойства или метода. Их относительно немало, однако разберём только самые часто используемые:
-
var — переменная без явной установки типа данных. Тип данных устанавливается анализатором кода при присваивании переменной какого-либо значения. Тип такой переменной позже изменить нельзя.
-
dynamic — переменная, тип данных которой может быть изменён. Стоп, так ведь то же самое можно сделать, просто использовав тип данных Object, тогда зачем нам dynamic? А вот и те самые ограничения Object — при использовании переменной с типом данных Object каждый раз приходится делать явное приведение типа данных (variable as String). dynamic позволяет избежать этих мучений
-
const — переменное, значение которой изменить нельзя. Значение такое переменной устанавливается на этапе компиляции, поэтому, при инициализации мы должны установить конкретное значение.
-
final — переменная, значение которой нельзя изменить. Значение устанавливается в момент выполнения, то есть при запуске программы. В такую переменную, в отличии от const, можно записать, например, текущую дату с помощью DateTime.now(), или же свойство какого-то объекта.
Есть небольшое пояснение — нельзя изменить значения переменных, например int, double, String, List, однако у ссылочных типов данных (которые имеют внутреннюю коллекцию — String, List) можно изменить внутреннее состояние, тоесть элементы этих самых коллекций.
Ещё раз: саму коллекцию полностью изменить нельзя, однако изменять внутренние элементы можно -
late — переменная, значение которой будет присвоено позже. Если попытаться использовать такую переменную до присвоения значения — выскочит исключение
Такой подход также можно использовать для инициализации только при использовании пользователем — этот метод называется Lazy Initialization или же Ленивая инициализация -
covariant — тип данных переменной может быть изменён на более узкий, то есть может быть приведён к родительскому типу данных
-
typedef — позволяет создать свой тип данных на основе существующего. Позволяет сократить длинные названия типов данных для улучшения читаемости кода, а так же используется в случае, если известно, что скоро придётся изменить тип данных в предстоящем к написанию большом участке кода. Можно создать свой тип на основе временного, использовать в написании его, а потом просто заменить временный тип на требуемый. В таком случае изменения придётся внести лишь в одном месте, а не в условной тысяче строк
-
abstract — поле класса, которое обязательно нужно переопределить при наследовании
-
static — поле класса, которое принадлежит самому классу, а не его объекту. Обратиться к такому полу можно только через название класса: ClassName.property
Абсолютно все объекты этого класса будут иметь одинаковые статические поля -
required — значение поля обязательно должно быть передано при инициализации объекта класса
И с этим закончили. Так скоро весь Dart изучим…
Операторы Dart
Перед изучением темы с Null-Safety стоит узнать, какие же операторы существуют в Dart
Операторы созданы с целью улучшения читабильности кода, а также для ускорения кодописания. Ну а без условных операторов написать что-то стоящее вообще не получится
Условных операторов не так много, давайте разберём их:
-
Конструкция if — else if — else
Стандартный условный оператор, как и во всех остальных языках программирования — не о чем говорить -
Тернарный оператор (?) :
A > B ? A : B;
Тут уже интереснее. По факту — заменяет конструкцию if — else на её однострочное представление. Вышеуказанный пример трактуется следующим образом: Если A > B, то вернуть A, иначе вернуть B. Всё очень просто
Теперь остальные операторы Dart:
-
Cascade (..) — каскадный оператор:
var person = Person() ..name = 'Alice' ..age = 30;
Выполняет несколько действий с переменной, к которой уже обратились. В примере создаётся новая переменная и сразу же ей присваивается name и age. Таким же методом можно вызывать несколько методов объекта подряд -
Spread (…) — оператор распространения:
var combinedList = [...list1, ...list2];
Распаковывает коллекции в строку или список. В примере создастся новый список, в который будут входить все элементы списков list1 и list2
Null-Safety и его операторы
Технология Null-Safety была добавлена относительно недавно и сильно перевернула мышление программистов в Dart. Многие до сих пор не могут привыкнуть к этому нововведению, однако, поверьте мне, оно приносит оооочень много удобств и синтаксического сахара.
Null-Safety позволяет присвоить любой переменное значение Null, то есть отсутствие значения. Такие переменные помечаются знаком ? после объявления типа данных, например: String?, int?
Если попытаться вызвать метод у переменной, значение которой равно null, то в результате мы в любом случае получим null. На такие случаи очень любит жаловаться компилятор и выдавать Warnings. Чтобы избежать предупреждений был придуман оператор ! — Bang. Он утверждает компилятору, что в этом месте значение переменной точно будет отличным от null. Если значение такой переменной всё же будет null — мы получим ошибку компиляции.
Кроме этих операторов обновление Null-Safety добавило ещё несколько:
-
null-awear (??) :
var result = someValue ?? defaultValue;
Возвращает левые операнд, если он не равен null, иначе возвращает правый операнд -
null-aware assignment (??=)
В случае, если левый операнд равен null, устанавливает в него значение правого операнда -
conditional cascade (?..)
Работает так же, как и каскадный оператор, однако только в случае, если операнд не равен null -
conditional spread (?…)
Работает так же, как и оператор распространения, однако только в случае, если операнд не равен null. В противном случае вернёт пустую строку -
null-aware index (?[i] ?? defaultValue)
Возвращает элемент списка, в случае, если он не равен null. В противном случае возвращает правый операнд
Разберитесь с Null-Safety чуть подробнее и осознание, что это нереально удобно, прийдёт само
Generic
После нестандартной и немного запутанной темы с Null-Safety давайте разберём довольно простой и понятный механизм — Generic
Сталкивались с моментом, когда пишете метод для String, потом понимаете, что нужен точно такой же метод, но для int? Например, каждый узел дерева может иметь данные типа String, а бывают деревья и с int, и с double. Тогда, получается, нужно писать несколько деревьев под каждый тип данных? Нет! Эту проблему помогает решать Generic
Выглядит это следующим образом:
class BinaryTreeNode<T> { T data; BinaryTreeNode<T> left; BinaryTreeNode<T> right; }
В выше указанном коде мы создаём тот самый узел бинарного дерева, где, в качестве типа данных для содержащейся информации, устанавливаем этакий тип данных T, который будет определён при создании этого дерева.
BinaryTreeNode<int> root = BinaryTreeNode(5);
А вот так будет выглядеть инициализация такого дерева для типа данных int
BinaryTreeNode<String> root = BinaryTreeNode(5);
А вот так для String
А теперь интересный момент. Особо догадливые и находчивые уже побежали писать гневные комментарии по типу: «Здесь же можно просто использовать dynamic!». А вот и нет — нельзя.
Объясняю: при использовании Generic мы сохраняем строгую типизацию, ведь тип данных строго определяется в момент создания и не может быть изменён, в отличии от dynamic. Таким образом мы не теряем контроль над типизацией и не превращаемся в JavaScript…
Я же говорил, всё максимально просто и легко
Конструкторы классов в Dart
Ещё одна абсолютно не сложная тема. Необходимо запомнить всего 4 типа конструкторов и всё:
-
Default Сonstruсtor — стандартный конструктор, который просто возвращает новый объект класса
-
Named Сonstructor — тот самый именной конструктор.
Создан для решения проблемы отсутствия перегрузки методов в Dart. К конструктору добавляется имя и указываются другие аргументы, благодаря чему мы можем создавать разные объекты на основе одного класса, в зависимости от имени конструктора и передаваемых параметров, то есть использовать разные значения по умолчанию, исходя из переданных аргументов -
Factory Constructor — фабричный конструктор.
Внутри себя позволяет использовать условные операторы, благодаря чему может не создать новый объект, а вернуть существующий, например из кеша, если такой уже существует
Именно тут, кстати, реализуется Factory паттерн проектирования -
Пересылающий конструктор
constructorName : this.nextConstructorName
Вместо себя пересылает создание объекта на другой конструктор
Конструкторы кончились…
Mixins (Примеси)
Расширяют функционал класса. Можно добавлять как свойства, так и методы.
Создаются с помощью ключевого слова mixin.
mixin Printable { void printData() { print('Printing data...'); } }
Подмешиваются с помощью ключевого слова with.
class Person with Printable {}
Теперь класс Person будет иметь метод printData().
Если в нескольких примесях будут методы с одинаковой сигнатурой, то в класс попадёт реализация метода из самого последнего подмешанного Mixin.
Есть несколько особенностей:
-
Mixin не может наследоваться от какого-то класса
-
Mixin не может иметь конструктора
-
Нельзя создать объект на основе Mixin
-
В качестве Mixin можно подмешать любой класс без конструктора
-
При наследовании класса с Mixins, они также попадут в дочерний класс
-
Можно подмешать неограниченное количество Mixins
Отличия от Абстрактных классов:
-
Ключевое слово для добавления: implements — для Абстрактных классов, with — для Миксинов
-
Абстрактный класс указывает на методы, которые необходимо реализовать, а Mixin сразу добавляет готовый функционал
-
В иерархии наследования Абстрактный класс находится на одну позицию выше класса-имплементатора, а Mixin находится на одном уровне с этим классом
Extensions (Расширения)
Extension — расширение класса вне класса. Позволяет расширить функционал класса, добавив к нему, например, какой-то метод, не прибегая к изменению кода самого класса.
Создаётся с помощью ключевого слова extension:
extension StringExtension on String { bool get isValidEmail { return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this); } }
Например, вот таким простым образом классовый тип данных String (то есть любая переменная с типом данных String) теперь имеет getter — валидатор адреса электронной почты.
Не просто так в качестве примера я расширил именно базовый класс языка Dart, ведь расширение своих классов таким образом считается плохой практикой — приводит код в состояние нечитаемого и сложноподдерживаемого. В смысле зачем нам расширять класс кто знает где, если это можно сделать сразу внутри класса.
С базовыми классами дела обстоят иначе — мы просто не можем влезть в реализацию класса String и переписать его — язык не даст этого сделать. Конечно можно создать свой кастомный класс MyString, который будет наследовать String и расширять его функционал, однако это даже звучит ужасно — не то, чтобы это нормально выглядело.
В общем запомнили: расширяем только базовые классы языка, к которым не имеем доступа
Error и Exeption (Ошибки и Исключения)
Между Error и Exeption есть отличия:
-
Error — ошибки, при возникновении которых происходит аварийное завершение программы, то есть программа не подлежит восстановлению. Примером могут стать ошибки работы с памятью из языка программирования C.
-
Exeption — исключения, которые мы можем отслеживать и как-то реагировать на них. Например — ошибка доступа к данным на сервере. В этом случае мы можем обработать исключение и, к пример, отправить запрос повторно, или же просто вывести на экран сообщение, что запросы выполнен с ошибкой. Исключения к завершению программы не приводят
Исключения отлавливаются с помощью конструкции Try — Catch, где в блок Try помещается выполняемый код, который может вызвать исключение, а в блоке Catch мы прописываем реакции на все исключения или же какие-то конкретные исключения. Блок Catch сработает только в том случае, если в блоке Try будет выброшено исключение.
В дополнение к этой конструкции есть ещё один блок — Finally. Код в этом блоке будет выполнен в любом случае — не зависимо от того, будет выброшено исключение или нет.
Асинхронность
Асинхронность — механизм, который позволяет ускорить выполнение программы за счёт контроля порядка выполнения операций.
Некоторые операции выполняются долго, поэтому, чтобы не останавливать работу всего проекта, используется асинхронность. Примером такой операции может быть запрос на сервер или же какие-то сложные вычисления (однако вычисления лучше выносить в отдельный поток, но об этом позже).
В Dart существует класс, который позволяет получить асинхронный результат выполнения операции и обработать его, как только он будет получен. Этот тип данных называется Future.
Future имеет 3 основных метода:
-
then() — код, который выполнится после получения результата асинхронного запроса
-
whenComplite() — код, который выполнится после завершения асинхронного запроса, однако в этом случае мы не имеем доступ к результату
-
catchError() — отлов исключений, которые могут возникнуть в результате выполнения запроса. Исключения также можно отлавливать с комощью конструкции Try — Catch
Однако такой подход устарел. Сейчас в большинстве случаев используется ключевое слово await. Функция с асинхронным запросом помечается как async, а строка, результат которой нам нужно подождать, помечается словом await. Под капотом это работает следующим образом: весь код, который находится после ключевого слова await в этой функции, попадает в метод .then() у асинхронного запроса.
Также класс Future имеет несколько интересных и полезных в работе конструкторов:
-
.delayed() — создаёт задержку в указанное количество времени
-
.value() — возвращает переданное в аргументах значение
-
.error() — возвращает переданное в аргументах исключение
Кроме того, во Flutter есть FutureBuilder, который строит изображение на экране после получения результата асинхронного запроса.
Future позволяет получить результат асинхронного запроса, а что делать, если нам нужно обработать несколько асинхронных запросов, то есть их поток? Для этого есть другой класс — Stream.
Stream — поток операций Future.
Необходим при работе, например, с WebSocket.
Stream имеет 2 типа:
-
Subscription — поток, у которого одновременно может быть только один слушатель
-
Broadcast — поток, на который может подписаться неограниченное количество слушателей
Значение в Stream можно получать только в RealTime, то есть нельзя обратиться к предыдущим результатам. Однако, если это необходимо, нам ничего не мешает хранить результаты в кокой-либо коллекции.
У Stream, так же, как и у Future, во Flutter есть StreamBuilder, который позволяет перестраивать экран при получении нового результата в отслеживаемом Stream.
А теперь самое интересное. Как я уже сказал, асинхронность позволяет контролировать порядок выполнения операций. Так как же это работает?
В потоке Dart есть 2 очереди: MicroTaskQueue и EventQueue. А также есть цикл, который работает до завершения программы — EventLoop. Этот цикл и контролирует порядок выполнения операций.
Есть несколько видов операций:
-
Синхронная операция — стандартная операция. Например, команда print()
-
Асинхронная операция — находится в EventQueue
-
Microtask — операции, которые находятся в MicroTaskQueue. Чтобы преобразовать Future операцию в Microtask достаточно просто вызвать метод .microtask
Future.microtask(() => print('Hello'));
Порядок выполнения этих операций можно рассмотреть на изображении ниже:
То есть сначала выполняются Синхронные операции, потом все операции из MicroTaskQueue, затем одна операция из EventQueue (то есть Future) после чего снова возвращаемся к MicroTaskQueue.
При встрече Future с ключевым словом await асинхронная функция замораживается до полного выполнения этого Future, а EventLoop продолжает выполнять другие операции из предыдущей функции.
Соглашусь, тут уже потруднее…
Однако дальше ещё веселее 🙂
Многопоточность
Dart — однопоточный язык программирования, однако, при желании, мы можем создавать дополнительные потоки.
В Dart потоки называются не привычным словом Thread — здесь у нас Isolate (Изолят). Такое название дано неспроста — потоки в Dart делят память между собой, то есть каждый из Изолятов имеет свой собственный, изолированный участок памяти. Общаться между собой потоки могут с помощью портов, однако так могут только созданный вручную, контролируемые программистом, потоки.
Как же создать свой Изолят?
-
Создать поток на основе класса Isolate. В этом случае программист полностью контролирует жизненный цикл потока, то есть вручную должен удалить его после использования
-
С помощью метода функции compute(). В таком случае с программиста снимаются все трудности контроля жизненного цикла. Процесс запускается в потоке, выполняется и возвращает результат, после чего поток удаляется.
В отдельном потоке стоит запускать, например, сложные вычисления. Это используется для того, чтобы не затормаживать основной поток и не вызывать «зависаний» программы.
В каждом потоке есть свои EventQueue и MicroTaskQueue, а также свой цикл EventLoop и Garbage Collector.
Кроме изолятов можно использовать потоки операционной системы, в которых вычисления происходят ещё быстрее. Это делается с помощью пакета dart:ffi.
С этим тоже покончено…
Dart VM (Виртуальная машина Dart)
Эта глава довольно объёмная, поэтому разделю её на подглавы:
-
Dart Compiler
-
Dart VM
-
Garbage Collector
Меньше слов — сразу к делу!
Dart Compiler
Для начала: Компилятор — программа, которая переводит высокоуровневый код в машинный (двоичный). Компилятор переводит весь код в файл с машинным кодом, который запускается при выполнении программы. Рядом стоит и ещё одно понятие: Интерпретатор — программа, которая переводит высокоуровневый код в машинный в момент выполнения, то есть при запуске программы.
В Dart есть 2 вида компиляции:
-
JIT (Just In Time) или же подход динамической интерпретации — интерпретация кода в момент выполнения программы. Такой подход используется в Debug версии, то есть при разработке. Он позволяет использовать огромное преимущество Flutter — Hot Reload (перезагрузка открытой страницы с обновлениями интерфейса) и Hot Reload (полный перезапуск приложения без перекомпиляции). Эти возможности позволяют значительно ускорить разработку приложения, поскольку нам не приходится перекомпилировать или перезапускать приложение каждый раз при внесении малейшего изменения.
-
AOT (Ahead Of Time) или же подход статической компиляции — компиляция кода в файл, который запускается в момент выполнения программы. Такой подход используется в Release и Profile версиях, что повышает производительность приложения.
Именно из-за использования разных способов компиляции производительность в Release и Debug версиях может существенно отличаться.
Так же стоит отметить, что компиляция происходит на хосте (то есть на машине разработчика), а выполнение уже на устройстве пользователя.
Dart VM
Виртуальная машина, в которой выполняется весь код Dart.
Dart VM выполняет код, преобразованный в двоичный формат. Виртуальная машина работает как при AOT, так и при JIT компиляции.
К этому разделу относят понятие группы изолятов — изоляты с общим Garbage Collector и изолированной друг от друга памятью.
Существует такое понятие, как неоптимизация компилятора. Каждый раз, когда происходит процесс компиляции — компилятор пытается оптимизировать код для ускорения его выполнения. При компиляции первый раз происходит неоптимизация компиляции — то есть код компилируется в неоптимизированный. Это делается для того, чтобы выполнить компиляцию намного быстрее. Далее происходит процесс оптимизации, в результате чего неоптимизированный код подменяется его оптимизированой версией. Если при выполнении оптимизированного кода происходит ошибка — выполняется его неоптимизированнная версия (процесс деоптимизации).
Виртуальная машина имеет возможность работать со снимками кода. Если говорить просто, то снимок — скомпилированный участок кода, в который небыли внесены изменения, благодаря чему, при повторной компиляции не приходится перекомпилировать эти участки кода.
Garbage Collector
Процесс оптимизации памяти (очистки неиспользуемых участков памяти), который запускается, когда приложение простаивает, то есть появляются свободные ресурсы.
Реализуется это следующим образом: память делится на 2 части — активную и неактивную. Изначально все объекты помещаются в активную память, а при оптимизации «живые» объекты попадают в неактивный участок памяти, после чего участки меняются местами. В дальнейшем мёртвые объекты будут перезаписаны новыми живыми.
Живыми считаются те объекты, к которым ведут ссылки, начиная от корневого элемента, то есть от вершины стека элементов.
Кстати, здесь же можно подметить очередное преимущество const объектов. При создании нового const объекта создаётся лишь ссылка на уже существующий, вместо выделения памяти на целый новый объект, благодаря чему сборщику мусора не нужно обрабатывать множество идентичный объектов.
И с этой главой закончили
Hash Code (Хеш Код)
Очень лёгкий для понимания раздел, время отдохнуть
Hash Code — уникальный код для каждого элемента, который используется для идентификации элемента, например, в хеш-таблице. Создаётся с помощью хеш-функции. Существует множество видов хеш-функций на любой вкус и цвет, однако самой банальной будет суммирование ASCII кодов каждого символа в строке, к примеру (далеко не лучший вариант).
Однако, в случае любой хуш-функции для разных переменных может возникнуть одинаковое значение хеш-функций. Такое явление называется коллизией. Для предотвращения коллизий также существует множество методов, не буду вдаваться в подробности.
CodeStyle Dart
Здесь всё ещё намного проще
Запомнить стоит следующее:
-
Переменные и константы — lowerCamelCase
-
Классы, миксины, enum-ы — UpperCamelCase
-
Папки, файлы — snake_case
С проверкой на CodeStyle существует встроенный в Dart анализатор кода — DartAnalasis. Также с этим помогает Линтер — настраеваемый софт, который отслеживает индивидуальный для проекта стиль написания кода.
Вот и завершился раздел с изучением языка программирования Dart. Теперь Вы с гордостью можете заявлять, что знаете большинство фич языка, включая даже некоторые подкапотные реализации. Ну а ещё теперь мы готовы преступать к самому важному для Flutter Developer разделу. GO GO GO!
Flutter
Вот и финальная из 3-ёх основных глав.
В ней мы поговорим о преимуществах и недостатках Flutter, его внутреннем устройстве, а так же о том, как его вообще использовать и какие фишки в нём есть.
Основная концепция Flutter
Flutter — кроссплатформенный фреймворк, использующий язык программирования Dart для написания кода, который дальше транслируется в нативный код платформы, для которой создаётся приложение (Android — Kotlin, Java, iOS — Swift, Objective-C). Благодаря такому подходу программист одновременно может разрабатывать проект под множество различных платформ (на момент написания статьи их 6 — Android, iOS, Linux, MacOS, Web, Windows).
Стоит запомнить одну ключевую фразу: Всё во Flutter — это Widgets (Виджеты). Что же такое эти самые Виджет? — С этим разберёмся немного позже.
Преимущества Flutter
На самом деле Flutter — очень удобный фреймворк. Он позволяет за кратчайшие сроки создавать приложение на несколько платформ. Давайте разберёмся в магии Flutter чуть подробнее:
-
Высокая скорость разработки за счёт переиспользования виджетов
-
Высокая масштабируемость (доступность на разных платформах)
-
Поддержка крупных пакетов готовых виджетов: Material — в стиле Android, Cupertino — в стиле iOS
-
Hot Reload и Hot Restart — возможности перерисовки экрана без перезапуска приложения (Hot Reload) и перезапуска приложения без перекомпиляции (Hot Restart) — что существенно ускоряет процесс разработки
-
Объёмная документация, однако доступна только на английском языке
-
Крупная компания-разработчик — Google — благодаря чему присутствует крупная поддержка фреймворка со стороны разработчика и комьюнити
Всё во Flutter направлено на скорость и комфорт разработки — удобно!
Основные команды для работы с Flutter
Перед тем, как преступить к изучению самого Flutter, стоит разобраться, как же создать проект, обновить зависимости или же удалить ненужный пакет:
-
flutter create <name> — создать проект в текущей директории
-
flutter pub get — подгрузить зависимости, которые прописаны в файле pubspec.yaml текущего модуля
-
flutter pub update — обновить зависимости, которые прописаны в файле pubspec.yaml текущего модуля, до последней доступной версии
-
flutter pub remove <name> — удалить зависимость
-
flutter clean — очистить кеш проекта, включая подгруженные зависимости
По самым частоиспользуемым командам — всё. Что-то для конкретного случая можно легко найти на просторах интернета.
Widgets (Виджеты)
Для начала я дам простое понятие Виджета. Однако в следующих главах я разверну его, затрагивая новый изученный материал.
Виджет — описание пользовательского интерфейса.
Во Flutter есть 3 вида виджетов:
-
Proxy — виджеты, которые отвечают за хранение и передачу информации. Никак не влияют на отрисовку. Примером может стать Inhherited Widget, который позволяет своим потомкам обратиться к информации, которая хранится в нём.
-
Rendered — виджеты, которые строят макет страница, то есть указывают, где и что должно находиться. За примерами далеко ходить не нужно: Row, Column, Stack, Padding, Align
-
Component — виджеты, которые отрисовывают на экране определённый контент. Здесь примеры ещё очевиднее: Text, FloatingActionButton, Scaffold
При написании любого Front-End продукта стоит знать о двух типах вёрстки: Адаптивной и Отзывчивой:
-
Адаптивная вёрстка — вёрстка, которая подразумевает изменение элементов или их расположения на экране при изменении размеров экрана или других характеристик (например платформы). То есть при увеличении экрана могут появиться какие-то дополнительные кнопки или показатели, а при уменьшении — вообще HamburgerButton
-
Отзывчивая вёрстка — вёрстка, которая подразумевает изменение размеров элементов при изменении размеров экрана. То есть на большом экране кнопочки тоже будут побольше
Для соблюдения этих правил во Flutter есть множество специальных виджетов. Давайте рассмотрим некоторые из них:
-
LayoutBuilder — виджет, который позволяет возвращать разные элементы, в зависимости от какого-то параметра, при этом он также позволяет узнать размеры доступного ему пространства. То есть может, например, отрисовать кнопку, если ширина доступного пространства больше 100 пикселей. Является прямым примером виджета для адаптивной вёрстки
-
Expadned — виджет, который позволяет дочернему виджету растягиваться на всё доступное ему пространство. Является прямым примером виджета для отзывчивой вёрстки
-
Spacer — виджет, который устанавливает «пробел» на всё доступное пространство
-
Row&Column — набор виджетов, который задают сетку, в каждую ячейку которой можно установить другие виджеты
Как-то многовато слова виджет…
Во Flutter есть несколько типов виджетов, однако чаще всего вы будете использовать всего 2 из них:
-
Stateless — самые простые виджеты, которые не имеют своего состояния и не могут перерисовываться без указания из родительского виджета
-
Statefull — виджеты со своим состоянием. Достаточно самостоятельные, чтобы самовольно перерисовываться при необходимости. Для перерисовки используется метод setState(). Под капотом происходит следующее: виджет помечается меткой markNeedsBuild, то есть становится Dirty и перерисовывается на следующей итерации (в следующем кадре)
Что же такое это Состояние? А всё очень просто — возможность виджета хранить какое-то значение и обновлять его внутри себя. Если говорить просто, то, чтобы, при нажатии на кнопку «+», счётчик на экране увеличился — не достаточно просто увеличить значение счётчика, а нужно ещё и перерисовать (обновить) экран. За счёт сохранения своего состояния у Statefull виджета есть возможность перерисоваться и обносить тот самый счётчик на экране. Однако такие виджеты отрисовываются дольше, поэтому не нужно использовать их везде и всюду.
Statefull виджет также имеет свой жизненный цикл:
-
createState() — вызывается один раз при создании виджета и создаёт его состояние
-
initState() — вызывается один раз при создании виджета и инициализирует его состояние
-
didChangeDependencies() — вызывается в начале, а потом каждый раз при изменении состояния InheritedWidget сверху по иерархии виджетов. Подгружает изменения из InheritedWidget в состояние текущего виджета, если это необходимо
-
build() — вызывается каждый раз при перерисовке виджета. Именно этот метод мы переопределяем при написании своего виджета
-
setState() — вызывается для пометки виджета, как Dirty, то есть как нуждающегося в перерисовке
-
deactivate() — вызывается, когда виджет становится неактивным. Например, когда пользователь переходит на другой экран. Flutter хранит в кеше 3 последние открытые страницы, то есть все виджеты с тех страниц находятся в неактивном состоянии. Активируются виджеты, очевидно, при возврате на предыдущую страницу
-
dispose() — вызывается при удалении виджета
Всё, виджеты кончились…
StateManagement (Контроль состояния)
Что такое состояние — мы уже разобрались. Однако, смешивать логику и интерфейс — не лучшая практика. Почему? Уже отвечал на этот вопрос в главе про Архитектуру приложения.
Тогда как же нам быть? Куда же вынести эту логику из виджетов?
Ответ очень прост — в StateManager!
StateManager, или же Контроллер состояния — механизм контроля состояния, который позволяет вынести логику из виджета, то есть разделить состояние и пользовательский интерфейс.
Самым популярным (на момент написания статьи) StateManager является Bloc. Лично я чаще всего встречал его на реальной практике.
Bloc реализует архитектуру MVVM, то есть использует механизм data binding — двустороннее связывание (подписки на события). С помощью специальных виджетов он позволяет перерисовывать экран при изменении своего внутреннего состояния.
Для взаимодействия с состоянием использует 3 сущности:
-
BlocProvider — позволяет перерисовывать экран при изменении состояния
-
BlocListener — позволяет изменять данные внутри виджета или вызвать какой-то его метод при изменении состояния без пересоздания страницы. Он используется, например, когда нам нужно отобразить SnackBar или Toast (алерты с уведомлениями) без обновления самой страницы
-
BlocConsumer — объединят в себе функционал как BlocProvider, так и BlocListener. Позволяет одновременно и взаимодействовать с данными виджета, и обновлять страницу.
У Bloc также есть облегчённая реализация — Cubit. Всё отличие лишь в способе применения, под капотом они работают почти что одинаково. Однако Bloc даёт возможность контролировать порядок выполнения операций.
Так же на практике мне довелось поработать с Riverpod. Работает примерно так же, то есть позволяет хранить логику вне виджета, а так же даёт возможность перерисовывать экран при изменении внутреннего состояния. Под капотом выглядит как расширенный Provider, однако использует кастомный Inharited Widget и кодогенерацию
Я специально не буду расписывать «команды» для работы со StateManager, поскольку у каждого из них они свои, а если настолько подробно говорить про каждый — никакого времени не хватит.
Существует множество контроллеров состояния: Bloc, Provider, Riverpod, GetX, MobX, Redux. Вы можете самостоятельно изучить их при необходимости.
Flutter trees (Деревья)
После изучения этой темы Вы наконец-то поймёте, что такое Widget и как она работает на самом деле.
Во Flutter существуют 3 дерева:
-
Widget Tree (Дерево виджетов) — отвечает за пользовательский интерфейс. Состоит из Widgets
-
RenderObject Tree (Дерево объектов визуализации) — отвечает за отрисовку и расположение виджетов, а так же за обработку нажатий. Сверху дерева находится RenderView — специальный RenderObject, который представляет весь экран, а глубже находятся остальные RenderObjects
-
Element Tree (Дерево элементов) — отвечает за State (состояние) и жизненный цикл виджета. Состоит из Elements. Связывает между собой деревья Виджетов и Объектов визуализации
Стоит отметить несколько важных деталей:
-
Redner Object есть не у всех виджетов, а только у тех, которые визуализируются на экране
-
Элемент автоматически создаётся с помощью метода Widget.createElement
Здесь же стоит затронуть тему Build Context
BuildContext — расположение виджета в дереве элементов (тоесть сам элемент). Используется для получения информации о родительском виджете, о теме приложения, а так же для навигации
Ещё один подкапотный раздел завершён…
Внутренняя конструкция Flutter
Flutter «под капотом» делится на 3 уровня:
-
Framework — всё, с чем работает программист, используя Flutter. Написан на Dart. Включает такие компоненты, как Material/Cupertino, Widgets, Animations, Gestures, Packages
-
Engine — движок. Здесь находится вся подкапотная часть фреймворка. Написан полностью на C/C++. В этот слой входят Platform Channels, Rendering, System Events, Dart Isolate Setup
-
Embedder (Platform) — платформенный слой. Предназначен для взаимодействия с конкретной платформой. На нём находятся Plugins, Thread Setup, Event Loop Interop
Кроме того, стоит знать, что сам фреймворк Flutter работает в четырёх потоках:
-
UI — обработка пользовательского интерфейса и касаний. Flutter позволяет обрабатывать всевозможные жесты, включая все общепринятые современные жесты, такие как клики и удержания, свайпы, растяжение и так далее…
-
Raster — отрисовка виджетов на экране устройства
-
I/O — поток ввода / вывода данных
-
Platform — взаимодействие Flutter с платформой
А теперь минутка интересностей и каверзных вопросов:
А как же Flutter обновляет экран?
Сцена создаётся на слое Framework и передаётся в Engine, который уже и ответственен за отрисовку изображения на экране.
А как тогда они взаимодействуют между собой?
В момент запуска runApp() выполняются привязки (создаются абстракции) между Framework и Engine (bindings), которые позволяют обмениваться данными между слоями, например — SchedulerBinding — заставляет Engine оповестить Framework, когда необходимо создать новый экран для обновления.
Так, стоп, а зачем оповещать об этом Framework, почему он просто не может генерировать новые экраны?
А всё дело в том, что в промежутках между генерацией экранов Framework «спит». Сделано это, конечно, для оптимизации процесса выполнения кода.
То есть, каждый раз, когда нужно обновить экран — Engine с помощью SchedulerBinding «будит» Framework, который, в ответ на оповещение, отправляет новый, только что созданный, экран, который, в свою очередь, отрисовывается на экране устройства уже благодаря Engine слою.
Здесь же, в виде бонуса, расскажу об отличии Package от Plugin.
На самом деле — всё просто. И то, и другое расширяют наши возможности, однако Plugin используются для конкретной платформы, то есть являются платформозависимыми Packages.
Вот и всё, ведь совершенно ничего сложно, правда?..
Keys (Ключи)
Что же такое, эти ваши Ключи? Ключ — уникальный идентификатор элемента в дереве виджетов.
Есть несколько областей использования ключей:
-
Получение данных о context и state извне виджета. Например размер виджета после его отрисовки на экране
-
Перерисовка виджета (даже Stateless). При изменении ключа виджет перерисовывает на следующей итерации, то есть на следующем кадре
-
Анимация и переходы между страницами
Ключи делятся на 2 основных типа:
-
GlobalKey — ключ с глобальной зоной видимости. Используется для доступа к виджет из любого участка программы
-
LocalKey — ключ с локальной зоной видимости. Используется для идентификации виджета в коллекции с одинаковыми значениями
В свою же очередь LocalKey делятся ещё на 3 типа:
-
UniqueKey — генерируется автоматически при отрисовке виджета и гарантирует его уникальность в дереве виджетов, что позволяет перерисовывать его не из кеша, а с нуля
-
ValueKey — позволяет идентифицировать виджет по его значению, например значение ввода в форме
-
ObjectKey — также позволяет идентифицировать виджет по его значению, однако может содержать целый объект
Выдохните, ключи не то, чтобы часто, используются, но знать про них необходимо
Анимации
Во Flutter довольно просто можно реализовать множество анимаций. Для многих из них достаточно всего одного виджета. Давайте разберём основные типы анимаций:
-
Imlplicit — простая анимация, которая проигрывается в определённый момент, например, при нажатии на кнопку. Примером таких анимаций может стать перемещение виджета на экране или же увеличение его в размерах. Такие анимации можно реализовать с помощью таких виджетов, как AnimatedWidget, AnimatedOpacity …
-
Explicit — более сложная, настраиваемая анимация, которую полностью можно контролировать с помощью AnimationController, Tween
-
Frameworks — пакеты со сложными анимациями, создаваемыми в редакторах. Например Lottie — анимации, которые строятся из JSON, Flare — анимации, которые создаются в AfterEffects или же Rive — анимации из Rive Editor. На сайтах этих пакетов есть множество готовых к использованию анимаций, который также можно редактировать
Немного выше я затронул интересную сущность — Tween. Позволяет задать начальную и конечную точку анимации, а также длительность выполнения, за которое произойдёт плавный переход из начального состояния в конечное.
В этой же главе я должен рассказать про TickerProvider — системный таймер, который позволяет работать с Tick (процессорная единица времени). Позволяет выполнять действие на каждом тике, благодаря чему появляется возможность привязать анимацию к таймеру. У TickerProvider существует более лёгкий брат-близнец — SingleTickerProvider. Единственное отличие в том, что SingleTickerProvider позволяет работать только с одним AnimationController, то есть одновременно контролировать только одну анимацию.
Навигация
Навигация необходима для перемещения между страницами.
Во Flutter существует встроенный механизм для этого — Navigator. Он работает следующим образом: складывает все открывающиеся маршруты в специальный стек. Благодаря такой реализации всегда есть возможность вернуться к предыдущему маршруту, если не был использован метод, очищающий стек маршрутов.
Кроме того, относительно недавно появился Navigator 2.0 — Router. Его основным изменением является схожесть с маршрутизацией в Web, то есть использование именованных маршрутов. По имени маршрута открывается так или иная страница.
Основные команды для работы с Navigator следующие:
-
.push(context, MaterialPageRoute(builder: (context) => NewScreen())
— переход на новую страницу -
.pushNamed()
— именованный переход -
.pushReplacement()
— замена текущего маршрута на новый -
.pushAndRemoveUntil()
— добавление нового маршрута и удаление всех предыдущих -
.pop()
— возврат на предыдущую страницу -
.popUntil()
— возврат на конкретную предыдущую страницу (возврат на несколько страниц назад)
Ещё один встроенный способ навигации по приложению — BottomNavigationBar и TabBar. Представляют собой виджеты с кнопками, где каждая кнопка перенаправляет на необходимую страницу. Чаще всего используются для перемещения между главными разделами приложения
Креме того существует множество сторонних решений для реализации маршрутизации по приложению, такие как go_router, auto_route, fluro (функционал fluro уже реализован в Navigator 2.0)
А теперь навигируемся к следующему разделу!
Platform Channels (Платформенные каналы)
В некоторых случаях нам необходимо получить некоторую информацию об устройстве, то есть об устройстве конкретной платформы. Уже догадываетесь, что на каждой платформе это делается по-разному. Самым банальным примером для этого будет возможность получения данных о заряде аккумулятора устройства.
Именно для этой цвели и используются Платформенные каналы.
Есть несколько типов Platform Channels:
-
MethodChannel — используются для запуска нативных методов со стороны Flutter
-
EventChannel — используются для подписки на нативные события со стороны Flutter
Для справки: нативный — написанный на языке платформы
Передача данных по Платформенным каналам доступна только в виде следующих типов данных: null, bool, num, String, Uint8List, Int(32/64)List, Float64List, lists и maps с вышеперечисленными типами
Permissions (Разрешения)
Зачастую нашему приложению может понадобиться доступ к какому-то расширенному функционалу устройства пользователя. Например — доступ к камере, галерее, микрофону или ещё к какому-то функционалу.
Мы не можем просто так взять и получить доступ к галерее пользователя — для этого нужно получить его согласие. Это и называется Permissions. Они делятся на 2 типа:
-
Normal — стандартные разрешения, предоставив которые пользователь не может пострадать
-
Dangerous — разрешения, которые могут навредить пользователю. Например доступ к списку контактов или СМС
Требовать стоит только те разрешения, которые необходимы для работы приложения, а если точнее, то только те, которые необходимы для работы используемого пользователем функционала. Не стоит запрашивать доступ к микрофону при первом запуске — возможно пользователь не захочет использовать звукозапись в целом. Запрашиваем разрешение прямо перед использованием конкретного функционала — не раньше!
Даже в случае, если пользователь предоставил разрешения, проверять их наличие нужно каждый раз при использовании соответствующего функционала, ведь пользователь мог отозвать разрешение в настройках операционной системы. Если в этом случае мы попытаемся запустить функционал без разрешения, то получим ошибку, а возможно и вылет приложения.
К слову, файл, в котором прописываются требуемые разрешения, называется Манифестом.
Debugging (Отладка)
При написании кода с использованием фреймворка Flutter мы также, как и в любом другом случае, можем использовать Точки остановки (Breakpoints).
Однако, Flutter также предлагает свой собственный метод отладки, который позволяет удобно работать не только с данными, но и с отрисовкой и расположением компонентов на экране. Этот функционал называется Flutter DevTools. Кроме отладки, благодаря DevTools мы также можем оценивать производительность приложения на каждом этапе (то есть количество кадров в секунду (FPS) и затрачиваемое в данный момент количество памяти). Благодаря этому можно найти «слабые места» приложения и оптимизировать их.
Тесты и тестируемость
Тесты необходимы для проверки программы на корректность выполнения. За счёт тестов легко можно прогнать несколько наборов данных через одну и ту же программу, при этом, при внесении изменений в программу, можно также запустить написанный тест и быстро узнать, корректны ли внесённые изменения или же нет.
Существует 3 типа тестов:
-
UnitTest — тест определённого функционала, например, какого-то отдельного метода
-
WidgetTest — тест конкретного виджета. При использовании этого теста можно, например, имитировать нажатия на кнопки или передачу в виджет каких-то данных
-
IntegrationTest — тест взаимодействия нескольких виджетов или какого-то другого функционала между собой
Хорошая тестируемость достигается путём использования принципов SOLID и выполнения IoC, об этом уже была сказано выше.
К слову, существую библиотеки, которые помогают быстро писать любой из вышеперечисленных видов тестов.
Если ты продержался аж до этого момента, то спешу поздравить тебя, ведь ты выучил все основные темы, необходимые для работы с Flutter и Dart, а также для грамотного построения собственной архитектуры приложения. Теперь тебе будет намнооооого легче пройти любое собеседование — ведь все мы стремимся к этому 🙂
Следующий раздел скорее не дополнительный, а обобщённы, что-ли. Большинство из тем следующего раздела довольно просты для понимания и запоминания, однако тоже достаточно важны. Почему они не включены в вышеизученные разделы? А они просто не подходят к темам, поэтому были вынесены в отдельный раздел.
Предлагаю добить последний раздел и с удовольствием пойти получать практический опыт!
Углубленность для повышения уровня
Хоть этот раздел и называется углубленным — здесь речь пойдёт о базовых вещах, однако, которые часто не используют начинающие программисты. А ещё сюда будут включены все темы, которые не поместились в предыдущие разделы. Начнём с самого важного:
Git
Git — самая популярная на сегодняшний день VCS (Version Control System) — система контроля версий. Каждый программист обязан уметь работать с ней. В эту тему стоит углубиться отдельно, поэтому рекомендую почитать дополнительную статью на тему внутреннего устройства системы Git, ну а я же в свою очередь расскажу про основные команды для работы с ней (каждая из них начинается со слова git, поэтому я опущу его):
-
add . — добавить все файлы в список отслеживаемых (index)
-
commit -m — сохранить изменения
-
checkout -b — создать новую ветку
-
push — отправить изменения в удалённый репозиторий
-
pull — скачать изменения текущей ветки из удалённого репозитория
-
fetch — скачать все изменения всех веток из удалённого репозитория
-
merge — слить две ветки путём смешивания истории
-
rebase — слить две ветки путём присоединения одной к концу другой
-
cherry-pick — скопировать определённый коммит в текущую ветку
-
restore —staged . — очистить список отслеживаемых файлов (index)
-
reset — откатиться к одному из предыдущих коммитов
Это основные команды, которые чаще всего используются. Ещё раз настоятельно рекомендую углубиться в эту тему самостоятельно — она слишком объёмная, чтобы я мог подробно и в деталях расписать её здесь.
Сетевое взаимодействие
Зачастую в современных приложения используется не только клиентская часть. Иногда мы хотим просто сохранять информацию пользователя, чтобы он мог легко восстановить её в случае переустановки приложения, а иногда мы хотим организовать, например, чат, в котором пользователи смогут обмениваться информацией в реальном времени — для той и той цели необходим сервер. Давайте разберём 2 основных понятия:
-
Сервер — удалённая программа, которая позволяет Клиентам обмениваться информацией, а также реализовывать дополнительный функционал, например, работу с базами данных
-
Клиент — приложение, которое устанавливается на устройства пользователей, а также обменивается информацией с Сервером
Клиенту и Серверу необходимо как-то общаться. Как же это возможно реализовать? Для этого используется протокол HTTP. Он содержит:
-
Request Line (Строка запроса) — метод передачи, URL, версия протокола
-
Message Headers (Заголовки) — тело, параметры и информация
-
<пустая строка-разделитель>
-
Entity Body (Тело сообщения) — данные, которые передаются в запросе. Является необязательным параметром
Довольно сложно, да? А вот и нет! На практике нам нужно всего лишь знать тип нужного в нашем случае запроса, ссылку, к которой обращаемся и передаваемые параметры.
Все сервера отвечают такие запросы на конкретную ссылку. Выполняют какую-то обработку и возвращают результат и код результата:
-
2xx — успешно
-
3xx — перенаправление
-
4xx — ошибка на стороне клиента. Да да, та самая известная ошибка 404 — Not Found
-
5xx — ошибка на стороне сервера
Методы запросов предоставляет такая штука, как REST. Это этакий SOLID в мире взаимодействие Клиента и Сервера. Методов существует множество, однако на практике чаще всего используются всего четыре:
-
GET — получение данных с сервера. Работает только на чтение, поэтому безопасен (т.е. не может изменить сервер)
-
POST — создание новых данных на сервере. В отличии от GET, не помещает параметры запроса в URL браузера, то есть скрывает их, поэтому, в некоторых случая используется также и вместо GET для получения информации
-
PUT — обновление существующих данных на сервере
-
DELETE — удаление данных на сервере
Обмен данными происходит в формате JSON, поэтому, перед отправкой данных, необходимо выполнить сериализацию (конвертация в JSON) данных, а в случае получения — процесс десериализации (конвертация в переменные или объекты класса). Для этого в Dart используется удобный пакет json_serializable.
Для отправки запросов в Dart используется библиотека http, однако предпочтительнее использовать пакеты dio или retrofit, поскольку они имеют расширенный функционал.
Кроме REST также используются и другие протоколы для передачи данных по сети Интернет, например gRPC или GraphQL. gRPC в основном используется для взаимодействия Сервер — Сервер из-за своей подкапотной реализации, а вот GraphQL используется и в Клиент — Сервер, поэтому давайте рассмотрим и его.
Основным преимуществом GraphQL является возможность получать и передавать на сервер только необходимые данные, например, только нужные нам поля определённого объекта класса. За счёт этого с данными работать удобнее, а также передача по сети происходит быстрее из-за меньшего размера данных. Давайте рассмотрим основные методы GraphQL:
-
Query — получить данные с сервера
-
Mutation — изменить данные на сервере
Выглядит удобнее, однако некоторые программисты считают такой подход несовершенным.
С Интернетом покончено, идём дальше…
Storage и Database (Хранилища информации)
Существует множество типов баз данных и все они отличаются по типо хранения информации внутри себя. В основном выделяют 2 типа:
-
SQL (реляционная) — база данных, которая хранит данные в виде таблиц. Например: PostgreSQL, MySQL
-
NoSQL (нереляционная) — база данных, которая хранит данные в виде структуры, отличной от таблиц. Это могут быть документы, графи и много чего другого. Например: Firestore, MongoDB, Redis
Выбор типа базы данных зависит от множества критериев, например, в реляционных базах данных зачастую поиск выполняется быстрее.
С Базами данных можно взаимодействовать на стороне Клиента, однако лучше использовать для этого Сервер.
Кроме того во Flutter можно использовать небольшие «локальные базы данных»:
-
path_provider — позволяет работать с файловой системой устройства и сохранять в папку приложения любые данные. Также даёт возможность создания временных папок (удаляются по завершению сессии) и папок для сохранения манифестов (например, для получения Permissions). Удобно хранить какие-то данные, включая изображения
-
shared_preferences — позволяет хранить данные в структуре типа Map, которые сохраняются даже после перезапуска (но не после переустановки приложения). Удобно хранить настройки приложения, политику конфиденциальности…
Всё, и с хранением разобрались!
CI / CD
-
CI (Сontinuous Integration) — запуск pipline (конвейера) с Build, Tests, Deploy и другими необходимыми действиями в автоматическом режиме каждый раз, когда происходит Push в репозиторий. Примеры CI — Jenkins, GitLab CI/CD, GitHub Actions
-
CD (Continuous Delivery) — доставка проекта, прошедшего CI, на сервер для запуска. Примеры CD — Docker, Kubernetes
То есть, если проще, CI — набор операций, которые должен выполнить проект перед запуском на сервере, а CD — доставка проекта, прошедшего все проверки, на уже готовый к работе сервер.
Firebase
Firebase — сервис, который предоставляет готовые Back-End решения. Он очень удобен при написании приложений, поскольку предоставляет возможность почти что полностью отказаться от создания Серверной части проекта. Вот список самых полезных и частоиспользуемых лично на моей практике сервисов:
-
Authentication — сервис, который позволяет легко реализовать аутентификацию в приложении. Есть возможность доступа к аккаунту с помощью Мобильного телефона, Электронной почты, Пароля или каких-либо социальных сетей, например Facebook, Apple ID…
-
Cloud Firestore — облачная база данных, с которой очень просто работать
-
Storage — облачное хранилище типа S3. Позволяет удобно хранить крупные данные, такие как фотографии, картинки, иконки
-
Crashlitics — сервис, позволяет собирать информацию об ошибках и анализировать и систематизировать её. Ошибки можно собирать в режиме реального времени (то есть отправлять отчёт об ошибке в сервис в момент возникновения), а можно отправлять список накопленных ошибок каждый раз при запуске приложения
У Firebase есть достойные аналога, например Supabase.
UX / UI
UX / UI расшифровывается очень просто — User Experience / User Interface. При создании качественного дизайна обе эти части должны быть доведены до идеала. А чем же они отличаются и что вообще подразумевают?
-
User Experience — пользовательсикй опыт при работе с интерфейсом. Отвечает за удобство пользования приложением. Расположение кнопок, количество октрытых страниц прежде, чем будет получен желаемый результат, размер любых иконок — это всё кардинально влияет на Пользовательский опыт, а значит и на удобство использования приложения
-
User Interface — внешний вид интерфейса, с которым взаимодействует пользователь. Сюда относятся любые дизайнерские решения: стили кнопочек, шрифты, тени, наклонности текст — всё, что влияет именно на внешний вид
Определённо не самая важная глава для программиста, но тем, кто хотя бы периодически открывает Figma — знать полезно.
Вот и ВСЁ! Мне больше нечему обучить тебя, юный Падаван.
Ну а если серьёзно, то этих знаний хватит для написания собственного приложения любого масштаба и дальнейшего удобного масштабирования и поддержания его в здоровом виде. А, ну и конечно, этих знаний должно с лихвой хватить для прохождения начальных собеседований в какую-то IT компанию, осталось всего-лишь то запомнить ВСЁ.
Мы изучили как базу создания программ, так и более углубленную структуру разработки приложений с использованием фреймворка Flutter и языка программирования Dart.
Очень жду дополнения и поправки в комментариях, с удовольствием по-дискутирую с каждым желающим и выслушаю Вашу точку зрения, поскольку сам не являюсь нереально опытным программистом и тоже допускаю, что могу (не могу, а так и есть) чего-то не знать.
Всем спасибо за прочтение, не забудьте оценить статью, если понравилась — хочу, чтобы она смогла помочь большему количеству начинающих разработчиков.
Хорошего дня, Товарищи!
ссылка на оригинал статьи https://habr.com/ru/articles/831876/
Добавить комментарий