Привет! На связи команда разработчиков из Новосибирска. Нам давно хотелось рассказать сообществу о том, как мы разрабатываем фичи в KMM-проектах, и вот на одном из них подвернулась хорошая нестандартная задача. На ней, помимо собственно решения задачи, продемонстрируем путь добавления новой фичи в проект. Также мы очень хотим продвигать мультиплатформу именно в среде iOS-разработчиков, поэтому бонусом делаем особый акцент на этой платформе. Обычно в мобильных проектах общение с бэкендом происходит по REST API и спецификация оформляется в swagger-файлах. При таком раскладе мы спокойно используем Ktor и нашу библиотеку moko-network, в которой используем плагин для генерации кода запросов и моделей ответов по Swagger'у. В очень редких случаях требовалось дополнительно немного использовать WebSockets или Sockets.IO. Это решалось индивидуально на каждой платформе. Позднее мы сделали для этого библиотеку moko-sockets-io. В этот раз ситуация была интереснее: помимо набора swagger-файлов мобильный API был представлен несколькими gRPC-сервисами, и нам сразу же захотелось сделать процесс работы с ними максимально комфортным и приближенным к работе с REST API. В статье описан полный путь интеграции gRPC в мультиплатформенный проект, пройденный нашей командой. Он включает и создание проекта, и настройку фичи в проекте. Если вас интересует gRPC-специфичная часть и вы уже обладаете знаниями о мультиплатформе, то шаги 2, 3 и 4 можно пропустить. Для интеграции мы сразу же начали искать готовые библиотеки. В идеале хотелось следующего: уметь генерировать kotlin-классы для моделей сообщений в common-коде; уметь генерировать kotlin-классы для gRPC-клиента в common-коде; иметь из коробки реализации этих классов для iOS и Android; уметь настраивать gRPC-клиент из общего кода: подставлять адрес сервера, заголовки авторизации. На тот момент нашлась только одна библиотека для работы с gRPC, в которой KMM-часть была реализована и поддерживалась, — Wire от коллег из Square. Поэтому мы взяли ее и разобрались, что мы реально можем сделать: Настроить генерацию KMM-кода для классов сообщений и для gRPC-клиента, должно даже на корутинах работать. Пример настройки плагина есть на сайте gRPC. Из коробки есть реализация клиента для Android, которая под капотом использует OkHttp от этой же команды разработчиков. В клиенте есть возможность устанавливать параметры запросов, используя OkHttpClient.Builder.addInterceptor. Из коробки нет реализации клиента для iOS, только интерфейс с заглушками. Очевидно, что со стороны iOS библиотека не готова. Однако мы решили попробовать использовать хотя бы часть инструментов из нее: задачу решать надо, при этом со стороны Android все уже должно работать хорошо. Основной путь решения проблемы продемонстрируем на проекте Hello world, заодно покажем, как с нулевого состояния поднять проект на основе шаблона и добавить туда новую фичу. Основной упор будет на iOS-платформу. В качестве спецификации возьмем готовый пример из gprc-go. Все шаги будут сопровождаться коммитами в репозитории. подготовку тестового окружения (шаг 1); создание новой фичи в проекте (шаги 2, 3, 4); подключение wire-плагина к common-коду (шаг 5); настройку модуля фичи из корневой фабрики (шаг 6); генерацию и настройку gRPC-клиента для iOS (шаги 7, 8); реализацию KMM-интерфейса через нативный gRPC-клиент (шаг 8); проверку работы gRPC-клиента внутри фичи (шаг 9). А также расскажем, что делать в Android-приложении. Здесь все просто — берем из примера команды для установки сервера и клиента: Затем выполняем запуск в разных терминалах: Теперь терминал с клиентом нам не понадобится. Закрываем клиент, а сервер оставляем работать: вернемся к нему ближе к концу статьи. Мы в IceRock уже довольно давно для старта мультиплатформенных проектов используем свой шаблон и сейчас начнем с него же. Генерируем по нему проект на GitHub, импортируем всю папку в Android Studio или IDEA и смотрим, что для нас уже настроено. В mpp-library/feature видим две готовые фичи — config и list: Еще есть реализация доменной логики для них в отдельном пакете domain: Связывающая их фабрика в корне пакета mpp-library: Для ускорения скопируем модуль config с новым именем. Например, grpcTest. Почистим от логики и переименуем файлы: Содержимое новых файлов (коммит): /model/GrpcTestRepository.kt — интерфейс доменной логики для фичи, предоставляется из корневой фабрики проекта SharedFactory: /presentation/GrpcTestViewModel.kt — пустая вью-модель. Она наследуется от dev.icerock.moko.mvvm.viewmodel.ViewModel, поэтому имеет coroutine scope для выполнения асинхронных вызовов. Также в ней объявляем интерфейс событий, которые вью-модель может кидать на платформенную часть и принимаем диспетчер этих событий (eventsDispatcher) в качестве параметра: /di/GrpcTestFactory.kt — фабрика вью-модели для фичи. Создается в корневой фабрике проекта SharedFactory. Там же решается, какой будет реализация репозитория. Методы фабрики вызываются с нативной платформы: EventsDispatcher реализован здесь и нужен для гарантированной отправки событий на платформу. Для iOS это будет происходить по умолчанию на главной очереди. Для Android — в рамках главного цикла. Также добавим путь до модуля фичи в settings.gradle.kts в корне проекта (коммит): Подключим модуль фичи к модулю mpp-library в /mpp-library/build.gradle.kts (коммит): И не забываем переименовать пакет в AndroidManifest.xml (коммит): Функции клиента у нас очень простые: нужно будет инициировать запрос и показать на экране ответ. Для использования метода объявим его в GrpcTestRepository (коммит): Для отображения текста в алерте (текст успешного ответа от сервера или текст ошибки) добавим новое событие в EventsListener (коммит): Для отправки запроса сделаем метод в GrpcTestViewModel, который будем вызывать с нативной стороны по какому-нибудь событию. Заодно покажем ошибку, если что-то пойдет не так (коммит): Общий код модуля фичи на этом готов, теперь нужна имплементация собственно grpc-запросов и наша вью-модель с нативной стороны. Для начала берем файл спецификации нашего клиента helloworld.proto и помещаем в папку /domain/src/proto: Теперь нужно будет очень аккуратно подключить wire-плагин к доменному модулю. Все шаги из этого блока намеренно собраны в один коммит, чтобы при воспроизведении не потеряться. Мы используем libs.versions.toml для версионирования зависимостей. С него и начинаем: Добавляем версию wire в секцию [versions]: Добавляем библиотеки и плагин в секцию [libraries]: Затем цепляем сам плагин и настраиваем в /mpp-library/domain/build.gradle.kts: Поскольку Wire хостится на jitpack.io, убедимся, что все плагины будут скачиваться в том числе и оттуда в /build-logic/build.gradle.kts: И здесь же сам плагин в dependencies: Далее работаем с domain-модулем, добавляем плагин в секцию plugins в /mpp-library/domain/build.gradle.kts: Добавляем в секцию dependencies библиотеку клиента и рантайма: Добавляем секцию wire в конец файла и синхронизируем проект: После синхронизации проекта появляется gradle-таска generateProtos: Итоги ее выполнения можно найти в /mpp-library/domain/build/generated/source: Здесь у нас довольно объемные сгенерированные классы для запроса (HelloRequest) и ответа (HelloReply) метода, интерфейс клиента (GreeterClient) и его gRPC-реализация (GrpcGreeterClient). Забегая вперед: на Android мы используем все эти классы, на iOS — только классы сообщений. На текущий момент у нас есть сгенерированные модельки HelloReply и HelloRequest и интерфейс для репозитория конечной фичи GrpcTestRepository. Поскольку использовать сгенерированный готовый клиент в общем коде мы не сможем, нужно объявить его интерфейс, а реализовать по отдельности на платформах. В нашем случае интерфейс gRPC-клиента будет выглядеть так: Однако для iOS реализовать интерфейс с suspend-методами не получится, поэтому понадобится еще один интерфейс на callback'ах: И реализация, переводящая методы с callback'ами в suspend-методы: Размещаем все это там же, где генерировали модельки, в domain-модуле (коммит): Теперь в общем коде осталось только принять на вход в SharedFactory реализацию этого интерфейса и передать на вход фабрики фичи. Добавляем репозиторий как параметр в фабрику фичи GrpcTestFactory.kt (коммит): Добавляем новое поле в конструкторы SharedFactory и сразу для кастомного конструктора используем suspend-обертку клиента: Создаем экземпляр этой фабрики, используем gRPC-клиент как репозиторий (коммит): В общем коде все готово, осталось реализовать gRPC-клиент со стороны платформ. Для генерации классов возьмем библиотеку и генератор gRPC-Swift. Сначала поставим генератор, например через Homebrew: Затем нам понадобятся плагины к нему, устанавливаются через cocoapods: Если все прошло успешно, то оба плагина появятся по пути /ios-app/Pods/gRPC-Swift-Plugins/bin/, и теперь их можно использовать следующим образом: Сделать папку для сгенерированных классов, например, /ios-app/src/generated/proto. Находясь в корне проекта, вызвать команду для генерации классов сообщений: Находясь в корне проекта, вызвать команду для генерации методов gRPC-клиента: В итоге получаем два файла: helloworld.grpc.swift, helloworld.pb.swift. Добавляем их в проект и в Podfile саму библиотеку gRPC-Swift (коммит): Создаем новый класс, реализующий HelloWorldCallbackClient. Сделаем так, чтобы при его инициализации сразу создавались и сохранялись gRPC-канал и gRPC-клиент: Реализуем метод sayHello(..): Функция toWireMessage(..) довольно простая: она берет представление SwiftMessage в виде NSData, переводит в KotlinByteArray и отдает на вход адаптеру: Самый примитивный вариант конвертации NSData в KotlinByteArray: Сохраняем все и пробуем проверить прямо в AppDelegate (коммит): В терминале с запущенным сервером увидим сообщение: А в консольном выводе XCode — много логов по состоянию канала и наш print: Пожалуй, не будем создавать новый контроллер. Добавим еще одну вью-модель на ConfigViewController, будем вызывать ее метод при появлении контроллера на экране и показывать алерт по событию из EventsListener (коммит): В результате при запуске приложения получаем: С стороны Android-платформы можно использовать именно сгенерированный код Wire-клиента, дав ему экземпляр платформенного клиента. Выглядеть это может примерно так: CommonMain-код: AndroidMain-код: Конечно, в приведенном решении еще много чего можно улучшить: Заменить долгую реализацию копирования NSData в KotlinByteArray на использование memcpy. Добавить в интерфейс клиента метод для установки значений заголовков запросов и пересоздавать канал и клиенты при его вызове. Реализовать универсальный маппинг сообщений из WireMessage в SwiftMessage. Да и сам шаблон проекта мы еще будем развивать и дорабатывать. Надеемся, что цель статьи достигнута, и всем осилившим будет интересно заниматься разработкой на KMM и особенно новыми нестандартными задачами в ней. До новых встреч!  Разработчики мобильных приложений на KMM