В последнее время все очевиднее спрос на более надежные и распределенные инструменты, применяемые в командах. Особенно хороши в таких сценариях средства gRPC, которыми генерируется серверный код для Golang или Dart, поддерживаются серверные языки вроде Python, Ruby, Node.js, PHP и .NET, а также генерируется код для клиентских языков Dart, Swift, Objective-C, Java и Kotlin.
Благодаря gRPC намечаются контуры будущего API, генерируются сетевой код и код объектной модели для использования на сервере и любом клиенте.
В этом руководстве вы научитесь определять API при помощи gRPC и генерировать его инструментами код для серверных приложений Golang и клиентских Flutter, а также:
- Читать и изменять файл .proto, которым описывается API.
- Взаимодействовать с API-интерфейсом gRPC при помощи Evans.
- Инструментом командной строки protoc генерировать код Dart для приложения Flutter.
- Напрямую взаимодействовать с gRPC в приложении Flutter.
Примечание: применяемый здесь серверный код создан на Go, доступен в репозитории.
Начинаем
Установка Evans
Evans — это версия gRPC наподобие cURL и Insomnia, для которой, прежде чем выполнять вызовы к серверу с gRPC, не требуется писать целое клиентское приложение. А еще с Evans быстро проверяется на наличие ошибок файл .proto.
Evans поэтапно устанавливается на macOS диспетчером пакетов Homebrew:
brew tap ktr0731/evans && brew install evans
Проверяем корректность установки Evans:
evans --version
Установка Protoc и плагина Dart
Прежде чем установить плагин Dart, загружаем с официального сайта и устанавливаем SDK-пакет Dart.
Чтобы сгенерировать код Dart из файла спецификации gRPC, понадобятся:
- Исполняемый файл
protoc
. - Плагины для Dart: один для генерирования кода для элементов
Message
, другой для генерирования элементовService
.
Установив Dart, запускаем:
dart pub global activate protoc_plugin
Подробнее о gRPC
gRPC — это технология, разработанная в Google для определения и сопровождения интерфейса приложения. Ее инструментами и плагинами генерируются нативный сетевой код и код объектной модели для использования с интерфейсом на сервере и клиенте.
В gRPC серверный и клиентский код генерируется из одной спецификации, кодированной в файле .proto, независимо от используемого языка программирования.
То есть разработчики определяют интерфейс службы в этом файле буферами протокола — всеязычным, платформенно независимым механизмом для сериализации структурированных данных. После чего в gRPC генерируется код для разных языков программирования, а разработчики уже на своем языке обеспечивают согласованность различных компонентов системы.
С gRPC упрощением сетевого взаимодействия минимизируется вероятность ошибок, вызванных ручной реализацией сетевых протоколов. Здесь выполняются сериализация, десериализация и сетевая передача, благодаря чему разработчики сосредотачиваются на создании основной функциональности приложений.
Чем gRPC отличается от REST?
Разница между gRPC и JSON
В gRPC объекты данных кодируются и декодируются буферами протокола.
В отличие от обычных текстовых файлов JSON, буферы протокола — это разновидность двоичных файлов, не читаемых человеком. В них обмениваются меньшими полезными нагрузками, что эффективнее и оптимальнее для вариантов применения с низкой пропускной способностью.
Сначала необходимо определить сообщения gRPC и модели данных в файле .proto.
Работа с файлом .proto
.proto — это текстовый файл, в котором содержатся определения для всех API. Акцентируем внимание на сообщениях и службах. Сообщения — это объекты данных, отправляемые между клиентом и сервером. Службами определяется способ передачи сообщений. В файле .proto содержатся также метаданные для различных генераторов кода.
Самое интересное для всех, у кого случались проблемы с комментариями в JSON: комментарии в файле .proto имеются. И в стиле //
, и в стиле /* ... */
.
В первой строке файла todo.proto корневого каталога проекта указывается используемая версия gRPC:
syntax = "proto2";
Если строка отсутствует, в gRPC рабочей считается версия 2, а не 3. Затем генератором кода при помощи параметров присваиваются названия.
Определение служб
В генерируемом коде Dart каждая запись rpc
становится функцией.
syntax = "proto3";
package pb;
service TodoGrpc {
rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse){}
rpc GetTask(Empty) returns (GetAllTaskResponse){}
rpc DeleteTask(DeleteTaskRequest) returns (Empty){}
rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse){}
}
1. Объявление синтаксиса
syntax = "proto3";
: в этой строке указывается версия используемых буферов протокола, здесь это proto3 — последняя на момент написания данной статьи версия, усовершенствованная по сравнению с предыдущими.
2. Объявление пакета
package pb;
: ключевым словомpackage
файлы буферов протокола организуются в пространства имен, здесьpb
— пространство имен для этого определения службы.
3. Объявление службы
service TodoGrpc { ... }
: в этом блоке определяется служба gRPCTodoGrpc
. Служба gRPC — это набор удаленных методов, называемых также RPC, которые вызываются клиентами.
4. RPC, или удаленные вызовы процедур
rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse){}
: в этой строке объявляется метод RPCCreateTask
, которым принимается сообщениеCreateTaskRequest
и возвращается сообщениеCreateTaskResponse
.rpc GetTask(EmptyGetRequest) returns (GetAllTaskResponse){}
: методом RPCGetTask
не принимается никаких входных данных, что обозначается какEmpty
, а возвращаетсяGetAllTaskResponse
.rpc DeleteTask(DeleteTaskRequest) returns (Empty){}
: методом RPCDeleteTask
принимаетсяDeleteTaskRequest
, а возвращаетсяEmptyTaskRequest
.rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse){}
: методом RPCUpdateTask
принимаетсяUpdateTaskRequest
, а возвращаетсяUpdateTaskResponse
.
Определение сообщений
Вот спецификация сообщения для объекта Task
:
syntax = "proto3";
package pb;
CreateTaskRequest:
message CreateTaskRequest {
string task_name = 1;
string task_description = 2;
string task_deadline = 3;
optional string task_status = 4;
optional string task_priority = 5;
optional google.protobuf.Timestamp task_createdAt = 6;
}
- Здесь определяется сообщение для создания новой задачи с полями для ее названия, описания, сроков, статуса, приоритета и метки времени создания.
CreateTaskResponse:
message CreateTaskResponse {
string task_name = 1;
string task_description = 2;
string task_deadline = 3;
optional string task_status = 4;
optional string task_priority = 5;
google.protobuf.Timestamp task_createdAt = 6;
optional google.protobuf.Timestamp task_updatedAt = 7;
}
- А это ответ после создания задачи с аналогичными полями и полем для обновленной метки времени.
Task:
message Task {
string task_name = 1;
optional string task_description = 2;
optional string task_deadline = 3;
optional string task_status = 4;
optional string task_priority = 5;
optional google.protobuf.Timestamp task_createdAt = 6;
optional google.protobuf.Timestamp task_updatedAt = 7;
}
Это объект задачи с полями: название, описание, сроки, статус, приоритет и метки времени для создания и обновления.
- Empty:
message Empty {}
А это пустое сообщение, часто используется как заполнитель или для указания на недостаток данных.
- GetAllTaskResponse:
message GetAllTaskResponse {
repeated Task tasks = 1;
}
Это ответ с несколькими задачами, полем repeated типа Task
.
- DeleteTaskRequest:
message DeleteTaskRequest {
int64 id = 1;
}
А это запрос на удаление задачи по ее идентификатору.
- UpdateTaskRequest:
message UpdateTaskRequest {
int64 id = 1;
string title = 2;
optional string description = 3;
optional string task_status = 4;
optional string task_priority = 5;
}
Это запрос на обновление задачи с полями: идентификатор, название, описание, статус и приоритет.
- UpdateTaskResponse:
message UpdateTaskResponse {
Task task = 1;
}
А это ответ после обновления задачи с обновленным объектом Task
.
Первое поле помечено как optional
. Пока значение не сохранено в базе данных, его в этом поле не будет. Каждое поле в сообщении отслеживается по присвоенному ему генератором кода номеру.
Кроме string
, у поля могут быть различные скалярные типы, и в каждом языке генератором выполняются соответствующие преобразования. Полями бывают перечисления или даже другие сообщения.
Ключевым словом repeated
в коде генерируется массив задач Tasks
. Подробнее о различных вариантах — здесь.
В Google имеется немало известных типов: Empty
, Timestamp
, BoolValue
и другие. Однако homebrew
-версия protoc
не представлена, поэтому здесь применяется пользовательская. Тем не менее обе стратегии рабочие.
Пробуем файл .proto с Evans
Открываем терминал и в корневом каталоге проекта, где содержится файл to-do.proto, запускаем Evans в режиме REPL:
evans repl --host localhost --port 1234 --proto ./todo.proto
Появляется заставка Evans:
______
| ____|
| |__ __ __ __ _ _ __ ___
| __| \ \ / / / _. | | '_ \ / __|
| |____ \ V / | (_| | | | | | \__ \
|______| \_/ \__,_| |_| |_| |___/
more expressive universal gRPC client
pb.TodoGrpc@localhost:9090>
Сервер gRPC еще не запущен и не сгенерирован код gRPC на Golang, а в Evans с помощью файла todo.proto уже предоставляется информация о службах.
Вспоминаем из файла todo.proto, что имеется четыре сообщения.
В командной строке Evans вводим:
show message
Появятся четыре определенные ранее сообщения:
+--------------------+
| MESSAGE |
+--------------------+
| CreateTaskRequest |
| CreateTaskResponse |
| DeleteTaskRequest |
| Empty |
| GetAllTaskResponse |
| UpdateTaskRequest |
| UpdateTaskResponse |
+--------------------+
Теперь командой show service
выводим в Evans список служб:
+----------+------------+-------------------+--------------------+
| SERVICE | RPC | REQUEST TYPE | RESPONSE TYPE |
+----------+------------+-------------------+--------------------+
| TodoGrpc | CreateTask | CreateTaskRequest | CreateTaskResponse |
| TodoGrpc | GetTask | Empty | GetAllTaskResponse |
| TodoGrpc | DeleteTask | DeleteTaskRequest | Empty |
| TodoGrpc | UpdateTask | UpdateTaskRequest | UpdateTaskResponse |
+----------+------------+-------------------+--------------------+
Закрываем Evans и возвращаемся в терминал командой exit
.
Мы на верном пути, дальше сгенерируем код Dart.
Генерирование кода Dart из файла .proto
Открываем в терминале каталог с файлом todo.proto и вводим:
protoc --dart_out=grpc:lib/core/proto/generate \\
-Ilib/core/proto lib/core/proto/*.proto
Работа с proto в приложении Flutter
Сначала создаем новый проект Flutter:
Flutter create todogrpc
Затем добавляем пакеты и импортируем все необходимое для работы с gRPC во Flutter:
dependencies:
protobuf: ^latest
get_it: ^latest
grpc: ^latest
dartz: ^latest
flutter_riverpod: ^latest
fixnum: ^latest
dev_dependencies:
freezed: ^latest
- protobuf: этим пакетом обеспечивается поддержка буферов протокола, то есть метода сериализации структурированных данных, применяемого обычно для эффективного взаимодействия служб в распределенных системах.
get_it
— это простой, но мощный фреймворк внедрения зависимостей для Dart и Flutter. Им обеспечивается слабая связанность компонентов в приложении, благодаря чему экземпляры зависимостей получаются без прямого к ним обращения.grpc
— пакет для взаимодействия приложений Dart с серверами удаленного вызова процедур gRPC. Им предоставляются инструменты для генерирования клиентского и серверного кода из определений служб protobuf, обеспечивается бесперебойное взаимодействие различных служб.dartz
— это библиотека функционального программирования для Dart. Ею предоставляются служебные программы для работы с типичными концепциями функционального программирования: Option, Either, Try и другими. Так пишется более лаконичный, выразительный код, особенно в асинхронных и подверженных ошибкам сценариях.flutter_riverpod
— библиотека управления состоянием для Flutter, в основе которой — пакет Provider. Это простой и гибкий способ управления состоянием приложения, зависимостями и обновлениями пользовательского интерфейса. С ним проще создавать масштабируемые и сопровождаемые приложения Flutter.freezed
— библиотека генерирования кода для Dart и Flutter, с которой легко создавать неизменяемые классы. В основе freezed аннотированные классы, поэтому ею автоматически генерируется шаблонный код для эквивалентности значений, методов copyWith и многого другого. Так уменьшается перегруженность кода, обеспечивается типобезопасность.
Создадим в серверной папке файл grpc_handler.dart и определим класс GrpcHandlerService:
import '../proto/generate/todo.pbgrpc.dart';
import 'package:grpc/grpc.dart';
class GrpcHandlerService {
late TodoGrpcClient client;
static final GrpcHandlerService _grpcHandlerService =
GrpcHandlerService._internal();
factory GrpcHandlerService() {
return _grpcHandlerService;
}
GrpcHandlerService._internal();
static Future<GrpcHandlerService> init() async {
final channel = ClientChannel('localhost',
port: 9090,
options: const ChannelOptions(
credentials: ChannelCredentials.insecure(),
idleTimeout: Duration(minutes: 30),
));
try {
await channel.getConnection();
_grpcHandlerService.client = TodoGrpcClient(channel,
options: CallOptions(
timeout: const Duration(minutes: 30),
));
} catch (e) {
print('unable to conntect');
rethrow;
}
return GrpcHandlerService();
}
}
- Класс
GrpcHandlerService
- В этом классе обрабатывается gRPC-взаимодействие, и содержится поздно инициализируемая переменная экземпляра
client
типаTodoGrpcClient
, которая инициализируется после создания канала gRPC. - А еще здесь определяются конструкторы: закрытый
_internal()
и фабричныйGrpcHandlerService()
. В последнем при помощи шаблона «Одиночка» возвращается единственный экземплярGrpcHandlerService
. GrpcHandlerService
инициализируется статическим методомinit()
, которым возвращается завершаемая экземпляромGrpcHandlerService
футура.
2. Метод init()
- Внутри метода
init()
создается gRPCClientChannel
с адресом'localhost'
и портом9090
, сконфигурированный небезопасными учетными данными и тайм-аутом ожидания 30 минут. - Соединение с каналом устанавливается с помощью
channel.getConnection()
. - Если оно установлено, то инициализированным каналом и параметрами вызова с 30-минутным тайм-аутом создается
TodoGrpcClient
. - Выдаваемая во время инициализации ошибка перехватывается, выводится на печать и выдается повторно.
- В конце возвращается экземпляр
GrpcHandlerService
.
Создадим папку failures
и добавим файл failure.dart
, который ранее применялся для обработки различных случаев сбоев:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'failure.freezed.dart';
@freezed
class Failure with _$Failure {
const factory Failure.failure(final String message) = _Failure;
const factory Failure.notFoundFailure(final String message) = _NotFoundFailure;
const factory Failure.errorFailure(final String message) = _ErrorFailure;
}
Следующей командой терминала сгенерируем класс сбоя freezed
, создадим в серверной папке файл gapi.dart
, определим абстрактный класс TodoFacade
и реализуем функции в ITodoFacade
:
import 'package:dartz/dartz.dart';
import '../../error/failure.dart';
import '../../proto/generate/todo.pb.dart' as $pb;
import '../grpc_handler.dart';
abstract class TodoFacade {
Future<Either<Failure, Unit>> createTodo(
$pb.CreateTaskRequest createTaskRequest);
Future<Either<Failure, Unit>> updateTodo(
$pb.UpdateTaskRequest updateTaskRequest);
Future<Either<Failure, Unit>> deleteTodo(
$pb.DeleteTaskRequest deleteTaskRequest);
Future<Either<Failure, $pb.GetAllTaskResponse>> listTodo();
}
class ITodoFacade implements TodoFacade {
final GrpcHandlerService _grpcHandlerService;
ITodoFacade(this._grpcHandlerService);
@override
Future<Either<Failure, Unit>> createTodo(
$pb.CreateTaskRequest createTaskRequest) async {
final response =
await _grpcHandlerService.client.createTask(createTaskRequest);
if (response.isInitialized()) {
return right(unit);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}
@override
Future<Either<Failure, Unit>> deleteTodo(
$pb.DeleteTaskRequest deleteTaskRequest) async {
final response =
await _grpcHandlerService.client.deleteTask(deleteTaskRequest);
if (response.isInitialized()) {
return right(unit);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}
@override
Future<Either<Failure, $pb.GetAllTaskResponse>> listTodo() async {
print(_grpcHandlerService.client.getTask($pb.EmptyGetRequest()));
final response =
await _grpcHandlerService.client.getTask($pb.EmptyGetRequest());
if (response.isInitialized()) {
return right(response);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}
@override
Future<Either<Failure, Unit>> updateTodo(
$pb.UpdateTaskRequest updateTaskRequest) async {
final response =
await _grpcHandlerService.client.updateTask(updateTaskRequest);
if (response.isInitialized()) {
return right(unit);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}
}
- Импорты
dartz
: импорт библиотеки Dartz с ее конструкциями функционального программированияEither
для обработки ошибок.failure.dart
: импорт пользовательского классаFailure
для различных сценариев сбоя в приложении.todo.pb.dart
: импорт сгенерированных классов protobuf, которыми определяются типы сообщений запросов и ответов для Todo-задач.grpc_handler.dart
: импорт пользовательского классаGrpcHandlerService
, которым обрабатывается gRPC-взаимодействие.
2. Абстрактный класс TodoFacade
- Им объявляется четыре абстрактных метода для CRUD-операций Todo-задач:
createTodo
,updateTodo
,deleteTodo
,listTodo
.
3. Класс ITodoFacade
- Реализуется абстрактный класс
TodoFacade
. - В конструкторе для обработки gRPC-взаимодействия принимается экземпляр
GrpcHandlerService
. - Каждый абстрактный метод реализуется клиентскими gRPC-методами
GrpcHandlerService
.
4. Метод createTodo
- Метод
createTask
клиента gRPC вызывается для создания Todo-задачи. - Если получен ответ об успехе или ответа нет, возвращается
Right(unit)
, в противном случае —Failure
с сообщением об ошибке.
5. Метод deleteTodo
- Метод
deleteTask
клиента gRPC вызывается для удаления Todo-задачи. - Аналогично
createTodo
проверяется, получен ли успешный ответ, и возвращается соответствующий результатEither
.
6. Метод listTodo
- Метод
getTask
клиента gRPC вызывается для получения всех Todo-задач. - При успехе возвращается ответ
Right(response)
, в противном случае —Failure
.
7. Метод updateTodo
- Метод
updateTask
клиента gRPC вызывается для обновления Todo-задачи. - Аналогично
createTodo
иdeleteTodo
проверяется, получен ли успешный ответ, и возвращается соответствующий результатEither
.
Посредством доступа к статическому элементу I
для GetIt
создается глобальный экземпляр sl
, которым во всем приложении регистрируются и получаются зависимости.
Реализуем управление состояниями, для их обработки понадобится Riverpod:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'core/proto/generate/todo.pb.dart';
part 'todo_states.freezed.dart';
@freezed
class TodoState with _$TodoState {
const factory TodoState.initial() = _Initial;
const factory TodoState.loading() = _Loading;
const factory TodoState.getTodo({required GetAllTaskResponse data}) =
_Success;
const factory TodoState.error({required String message}) = _Error;
}
Следующей командой терминала сгенерируем класс Todo-состояний:
dart run build_runner build
Теперь реализуем управление состоянием с Riverpod:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/grpc/api/todo.dart';
import 'core/injector/injector.dart';
import 'core/proto/generate/todo.pb.dart';
import 'todo_states.dart';
class TodoNotifier extends StateNotifier<TodoState> {
TodoNotifier() : super(const TodoState.initial()){
getTodo();
}
final todoList = sl<TodoFacade>();
getTodo() async {
final todo = await todoList.listTodo();
todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
state = TodoState.getTodo(data: r);
});
}
createTodo({required CreateTaskRequest createTaskRequest}) async {
state = const TodoState.loading();
final todoList = sl<TodoFacade>();
final todo = await todoList.createTodo(createTaskRequest);
todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
getTodo();
});
}
updateTodo({required UpdateTaskRequest updateTaskRequest}) async {
state = const TodoState.loading();
final todoList = sl<TodoFacade>();
final todo = await todoList.updateTodo(updateTaskRequest);
todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
getTodo();
});
}
deleteTodo({required DeleteTaskRequest deleteTaskRequest}) async {
state = const TodoState.loading();
final todoList = sl<TodoFacade>();
final todo = await todoList.deleteTodo(deleteTaskRequest);
todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
getTodo();
});
}
}
Создадим Provider для StateNotifier
:
final todoNotifierProvider =
StateNotifierProvider<TodoNotifier, TodoState>((ref) => TodoNotifier());
Теперь в main.dart
создаем простой пользовательский интерфейс:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/injector/injector.dart';
import 'core/localization/generated/strings.dart';
import 'core/proto/generate/google/protobuf/timestamp.pb.dart';
import 'core/proto/generate/todo.pbgrpc.dart';
import 'todo_notifier.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
await injector();
runApp(const ProviderScope(child: MyHomePage()));
}
class MyHomePage extends ConsumerStatefulWidget {
const MyHomePage({super.key});
@override
ConsumerState<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends ConsumerState<MyHomePage> {
@override
void initState() {
super.initState();
ref.read(todoNotifierProvider.notifier).getTodo();
}
@override
Widget build(BuildContext context) {
return
MaterialApp(
title: 'gRPC Demo',
debugShowCheckedModeBanner: false,
home: const Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text("gRPC Demo"),
centerTitle: true,
),
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
ref.watch(todoNotifierProvider).maybeWhen(
orElse: () {
return const CircularProgressIndicator();
},
loading: () {
return const Center(child: CircularProgressIndicator());
},
getTodo: (data) {
return Expanded(
child: ListView.builder(
itemCount: data.tasks.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(data.tasks[index].taskName),
onLongPress: () {
ref.read(todoNotifierProvider.notifier).deleteTodo(
deleteTaskRequest: DeleteTaskRequest(
id: data.tasks[index].taskId));
},
subtitle: Text(data.tasks[index].taskDescription),
);
}),
);
},
error: (error) {
return Text(error.toString());
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Добавляем в список фиктивную задачу
DateTime currentDate = DateTime.now(); //DateTime
ref.read(todoNotifierProvider.notifier).createTodo(
createTaskRequest: CreateTaskRequest(
taskName: 'Hello',
taskCreatedAt: Timestamp.fromDateTime(currentDate),
taskDeadline: 'Tomorrow',
taskDescription: 'A short Tasks',
taskPriority: 'High',
taskStatus: 'Not Started'));
},
tooltip: "Add Todo",
child: const Icon(Icons.add),
),
),
],
),
);
}
}
Запускаем приложение Flutter:
Что дальше?
Мы изучили основы работы с файлом .proto, которым описывается API-интерфейс gRPC, научились генерировать код Dart с protoc и модифицировать приложение Flutter для применения gRPC вместо HTTP.
Дополнительные документация и руководства имеются в проектах на GitHub для protobuf, grpc-dart и protoc.
Читайте также:
- Навигация во Flutter с использованием AutoRoute
- Подключение приложений Android к серверу с помощью gRPC
- 10 рекомендаций, которые повысят производительность разработки на Flutter в 2023 году
Читайте нас в Telegram, VK и Дзен
Перевод статьи Ademola Kolawole: Exploring gRPC and Flutter for Modern App Development