Привет! На связи команда разработчиков из Новосибирска.

Нам давно хотелось рассказать сообществу о том, как мы разрабатываем фичи в 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. Поэтому мы взяли ее и разобрались, что мы реально можем сделать:

  1. Настроить генерацию KMM-кода для классов сообщений и для gRPC-клиента, должно даже на корутинах работать. Пример настройки плагина есть на сайте gRPC.

  2. Из коробки есть реализация клиента для Android, которая под капотом использует OkHttp от этой же команды разработчиков. В клиенте есть возможность устанавливать параметры запросов, используя OkHttpClient.Builder.addInterceptor.

  3. Из коробки нет реализации клиента для iOS, только интерфейс с заглушками.

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

Основной путь решения проблемы продемонстрируем на проекте Hello world, заодно покажем, как с нулевого состояния поднять проект на основе шаблона и добавить туда новую фичу. Основной упор будет на iOS-платформу. В качестве спецификации возьмем готовый пример из gprc-go. Все шаги будут сопровождаться коммитами в репозитории.

В итоге в статье мы рассмотрим:

А также расскажем, что делать в Android-приложении.

Шаг 1. Подготавливаем тестовое окружение

Здесь все просто — берем из примера команды для установки сервера и клиента:

$ go get google.golang.org/grpc/examples/helloworld/greeter_client

$ go get google.golang.org/grpc/examples/helloworld/greeter_server

Затем выполняем запуск в разных терминалах:

$ ~/go/bin/greeter_server

2022/02/13 20:04:13 server listening at 127.0.0.1:50051

2022/02/13 20:04:20 Received: world
$ ~/go/bin/greeter_client

2022/02/13 20:04:20 Greeting: Hello world

Теперь терминал с клиентом нам не понадобится. Закрываем клиент, а сервер оставляем работать: вернемся к нему ближе к концу статьи.

Шаг 2. Стартуем новый MPP-проект

Мы в IceRock уже довольно давно для старта мультиплатформенных проектов используем свой шаблон и сейчас начнем с него же. Генерируем по нему проект на GitHub, импортируем всю папку в Android Studio или IDEA и смотрим, что для нас уже настроено.

В mpp-library/feature видим две готовые фичи — config и list:

Еще есть реализация доменной логики для них в отдельном пакете domain:

Связывающая их фабрика в корне пакета mpp-library:

Шаг 3. Добавляем новый модуль фичи

Для ускорения скопируем модуль config с новым именем. Например, grpcTest. Почистим от логики и переименуем файлы:

Содержимое новых файлов (коммит):

  • /model/GrpcTestRepository.kt — интерфейс доменной логики для фичи, предоставляется из корневой фабрики проекта SharedFactory:

package org.example.library.feature.grpcTest.model

interface GrpcTestRepository {

}
  • /presentation/GrpcTestViewModel.kt — пустая вью-модель. Она наследуется от dev.icerock.moko.mvvm.viewmodel.ViewModel, поэтому имеет coroutine scope для выполнения асинхронных вызовов. Также в ней объявляем интерфейс событий, которые вью-модель может кидать на платформенную часть и принимаем диспетчер этих событий (eventsDispatcher) в качестве параметра:

package org.example.library.feature.grpcTest.presentation

import dev.icerock.moko.mvvm.dispatcher.EventsDispatcher

import dev.icerock.moko.mvvm.dispatcher.EventsDispatcherOwner

import dev.icerock.moko.mvvm.viewmodel.ViewModel

import org.example.library.feature.grpcTest.model.GrpcTestRepository

class GrpcTestViewModel(

   override val eventsDispatcher: EventsDispatcher<EventsListener>,

   private val repository: GrpcTestRepository

) : ViewModel(), EventsDispatcherOwner<GrpcTestViewModel.EventsListener> {

   interface EventsListener {

   }

}
  • /di/GrpcTestFactory.kt — фабрика вью-модели для фичи. Создается в корневой фабрике проекта SharedFactory. Там же решается, какой будет реализация репозитория. Методы фабрики вызываются с нативной платформы:

package org.example.library.feature.grpcTest.di

import dev.icerock.moko.mvvm.dispatcher.EventsDispatcher

import org.example.library.feature.grpcTest.model.GrpcTestRepository

import org.example.library.feature.grpcTest.presentation.GrpcTestViewModel

class GrpcTestFactory(

  private val repository: GrpcTestRepository

) {

    fun createViewModel(

        eventsDispatcher: EventsDispatcher<GrpcTestViewModel.EventsListener>,

    ) = GrpcTestViewModel(

        eventsDispatcher = eventsDispatcher,

        repository = repository

    )

}

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

Также добавим путь до модуля фичи в settings.gradle.kts в корне проекта (коммит):


include(":mpp-library:feature:grpcTest")

Подключим модуль фичи к модулю mpp-library в /mpp-library/build.gradle.kts (коммит):

...

dependencies {

...

commonMainApi(projects.mppLibrary.feature.grpcTest) //Чтобы видеть классы фичи в SharedFactory

...

}

...

framework {

  ...

  export(projects.mppLibrary.feature.grpcTest)  // Чтобы классы фичи попали в фреймворк для iOS

  ...

}

И не забываем переименовать пакет в AndroidManifest.xml (коммит):


<?xml version="1.0" encoding="utf-8"?>

<manifest package="org.example.library.feature.grpcTest" />

Шаг 4. Пишем логику фичи

Функции клиента у нас очень простые: нужно будет инициировать запрос и показать на экране ответ. Для использования метода объявим его в GrpcTestRepository (коммит):


interface GrpcTestRepository {

    suspend fun helloRequest(word: String): String

}

Для отображения текста в алерте (текст успешного ответа от сервера или текст ошибки) добавим новое событие в EventsListener (коммит):


interface EventsListener {

    fun showMessage(message: String)

}

Для отправки запроса сделаем метод в GrpcTestViewModel, который будем вызывать с нативной стороны по какому-нибудь событию. Заодно покажем ошибку, если что-то пойдет не так (коммит):


fun onMainButtonTap() {

    viewModelScope.launch {

        var message: String = ""

        try {

            message = repository.helloRequest("world")

        } catch (exc: Exception) {

            message = "Error: " + (exc.message ?: "Unknown error")

        }

        eventsDispatcher.dispatchEvent { showMessage(message) }

    }

}

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

Шаг 5. Подключаем генерацию моделей сообщений по proto-файлам

Для начала берем файл спецификации нашего клиента helloworld.proto и помещаем в папку /domain/src/proto:

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

Мы используем libs.versions.toml для версионирования зависимостей. С него и начинаем:

  1. Добавляем версию wire в секцию [versions]:

# wire
wireVersion = "4.0.0-alpha.15"
  1. Добавляем библиотеки и плагин в секцию [libraries]:

# wire
wireGradle = { module = "com.squareup.wire:wire-gradle-plugin", version.ref = "wireVersion"}
wireRuntime = { module = "com.squareup.wire:wire-runtime", version.ref = "wireVersion"}
wireGrpcClient = { module = "com.squareup.wire:wire-grpc-client", version.ref = "wireVersion"}

Затем цепляем сам плагин и настраиваем в /mpp-library/domain/build.gradle.kts:

  1. Поскольку Wire хостится на jitpack.io, убедимся, что все плагины будут скачиваться в том числе и оттуда в /build-logic/build.gradle.kts:

repositories {
    mavenCentral()
    google()

    gradlePluginPortal()
    maven("https://jitpack.io")
}
  1. И здесь же сам плагин в dependencies:

dependencies {
  ...
  api("com.squareup.wire:wire-gradle-plugin:4.0.0-alpha.15")
}
  1. Далее работаем с domain-модулем, добавляем плагин в секцию plugins в /mpp-library/domain/build.gradle.kts:

  ...
  id("com.squareup.wire")
}
  1. Добавляем в секцию dependencies библиотеку клиента и рантайма:

  ...
  commonMainImplementation(libs.wireGrpcClient)
  commonMainImplementation(libs.wireRuntime)
}
  1. Добавляем секцию wire в конец файла и синхронизируем проект:

wire {
    sourcePath {
      srcDir("./src/proto")
    }
    kotlin {
        rpcRole = "client"
        rpcCallStyle = "suspending"
    }
}
  1. После синхронизации проекта появляется gradle-таска generateProtos:

  1. Итоги ее выполнения можно найти в /mpp-library/domain/build/generated/source:

Здесь у нас довольно объемные сгенерированные классы для запроса (HelloRequest) и ответа (HelloReply) метода, интерфейс клиента (GreeterClient) и его gRPC-реализация (GrpcGreeterClient).

Забегая вперед: на Android мы используем все эти классы, на iOS — только классы сообщений.

Шаг 6. Объявляем MPP-интерфейс для gRPC-клиента

На текущий момент у нас есть сгенерированные модельки HelloReply и HelloRequest и интерфейс для репозитория конечной фичи GrpcTestRepository. Поскольку использовать сгенерированный готовый клиент в общем коде мы не сможем, нужно объявить его интерфейс, а реализовать по отдельности на платформах.

В нашем случае интерфейс gRPC-клиента будет выглядеть так:

interface HelloWorldSuspendClient {
    suspend fun sendHello(message: HelloRequest): HelloReply
}

Однако для iOS реализовать интерфейс с suspend-методами не получится, поэтому понадобится еще один интерфейс на callback'ах:

interface HelloWorldCallbackClient {
    fun sendHello(message: HelloRequest, callback: (HelloReply?, Exception?) -> Unit)
}

И реализация, переводящая методы с callback'ами в suspend-методы:

class HelloWorldSuspendClientImpl(
    private val callbackClientCalls: HelloWorldCallbackClient
): HelloWorldSuspendClient {

    //Пока что у нас в интерфейсе всего один метод, но на будущее очень пригодится generic-функция для конвертации, сразу реализуем ее
    private suspend fun <In, Out> convertCallbackCallToSuspend(
        input: In,
        callbackClosure: ((In, ((Out?, Throwable?) -> Unit)) -> Unit),
    ): Out {
        return suspendCoroutine { continuation ->
            callbackClosure(input) { result, error ->
                when {
                    error != null -> {
                        continuation.resumeWith(Result.failure(error))
                    }
                    result != null -> {
                        continuation.resumeWith(Result.success(result))
                    }
                    else -> { //both values are null
                        continuation.resumeWith(Result.failure(IllegalStateException("Incorrect grpc call processing")))
                    }
                }
            }
        }
    }

    override suspend fun sendHello(message: HelloRequest): HelloReply {
        return convertCallbackCallToSuspend(message, callbackClosure = { input, callback ->
            callbackClientCalls.sendHello(input, callback)
        })
    }
}

Размещаем все это там же, где генерировали модельки, в domain-модуле (коммит):

Теперь в общем коде осталось только принять на вход в SharedFactory реализацию этого интерфейса и передать на вход фабрики фичи.

  1. Добавляем репозиторий как параметр в фабрику фичи GrpcTestFactory.kt (коммит):

class GrpcTestFactory(
    private val repository: GrpcTestRepository
) {
    fun createViewModel(
        eventsDispatcher: EventsDispatcher<GrpcTestViewModel.EventsListener>,
    ) = GrpcTestViewModel(
        eventsDispatcher = eventsDispatcher,
        repository = repository
    )
}
  1. Добавляем новое поле в конструкторы SharedFactory и сразу для кастомного конструктора используем suspend-обертку клиента:

class SharedFactory(
    ...
    helloWorldClient: HelloWorldSuspendClient
) {
  //Специально для вызова со стороны iOS-платформы мы не используем аргумент со значением «по умолчанию»
constructor(
    ...
    helloWorldCallbackClient: HelloWorldCallbackClient
) : this(
    ...
    helloWorldClient = HelloWorldSuspendClientImpl(helloWorldCallbackClient)
)
...
  1. Создаем экземпляр этой фабрики, используем gRPC-клиент как репозиторий (коммит):

val grpcTestFactory = GrpcTestFactory(
    repository = object : GrpcTestRepository {
        override suspend fun helloRequest(word: String): String {
            return helloWorldClient.sendHello(HelloRequest(word)).message
        }
    }
)

В общем коде все готово, осталось реализовать gRPC-клиент со стороны платформ.

Шаг 7. iOS: генерация классов gRPC-клиента

Для генерации классов возьмем библиотеку и генератор gRPC-Swift. Сначала поставим генератор, например через Homebrew:


brew install swift-protobuf grpc-swift

Затем нам понадобятся плагины к нему, устанавливаются через cocoapods:


pod 'gRPC-Swift-Plugins'

Если все прошло успешно, то оба плагина появятся по пути /ios-app/Pods/gRPC-Swift-Plugins/bin/, и теперь их можно использовать следующим образом:

  1. Сделать папку для сгенерированных классов, например, /ios-app/src/generated/proto.

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

protoc \
--plugin=./ios-app/Pods/gRPC-Swift-Plugins/bin/protoc-gen-swift \
--swift_out=./ios-app/src/generated/proto \
--proto_path=./mpp-library/domain/src/proto \
./mpp-library/domain/src/proto/helloworld.proto
  1. Находясь в корне проекта, вызвать команду для генерации методов gRPC-клиента:

protoc \
--plugin=./ios-app/Pods/gRPC-Swift-Plugins/bin/protoc-gen-grpc-swift \
--grpc-swift_out=./ios-app/src/generated/proto \
--grpc-swift_opt=Client=true,Server=false \
--proto_path=./mpp-library/domain/src/proto \
./mpp-library/domain/src/proto/helloworld.proto

В итоге получаем два файла: helloworld.grpc.swift, helloworld.pb.swift. Добавляем их в проект и в Podfile саму библиотеку gRPC-Swift (коммит):


pod 'gRPC-Swift', '~> 1.7.0'

Шаг 8. iOS: реализация HelloWorldClient

Создаем новый класс, реализующий HelloWorldCallbackClient. Сделаем так, чтобы при его инициализации сразу создавались и сохранялись gRPC-канал и gRPC-клиент:

class HelloWorldCallbackBridge: HelloWorldCallbackClient {

    private var commonChannel: GRPCChannel?
    private var helloClient: Helloworld_GreeterClient?

    init() {

        //Настраиваем логгер
        var logger = Logger(label: "gRPC", factory: StreamLogHandler.standardOutput(label:))
        logger.logLevel = .debug

        //loopCount — сколько независимых циклов внутри группы работают внутри канала (могут одновременно отправлять/принимать сообщения)
        let eventGroup = PlatformSupport.makeEventLoopGroup(loopCount: 4)

        //Создаем канал, указываем тип защищенности, хост и порт
        let newChannel = ClientConnection
            //Можно вместо .insecure использовать .usingTLS, но к нашему тестовому серверу так подключиться не выйдет, у него нет сертификата
            .insecure(group: eventGroup)
            //Логгируем события самого канала
            .withBackgroundActivityLogger(logger)
            .connect(host: "127.0.0.1", port: 50051)

        //Работаем без дополнительных заголовков, логгируем запросы
        let callOptions = CallOptions(
            customMetadata: HPACKHeaders([]),
            logger: logger
        )

        //Создаем и сохраняем экземпляр клиента
        helloClient = Helloworld_GreeterClient(
            channel: newChannel,
            defaultCallOptions: callOptions,
            interceptors: nil
        )
        //Сохраняем канал
        commonChannel = newChannel
    }
...

Реализуем метод sayHello(..):

func sendHello(message: HelloRequest, callback: @escaping (HelloReply?, KotlinException?) -> Void) {
    //Проверяем что все идет по плану
    guard let client = helloClient else {
        callback(nil, nil)
        return
    }

    //Создаем SwiftProtobuf.Message из WireMessage
    var request = Helloworld_HelloRequest()
    request.name = message.name

    //Получаем экземпляр вызова
    let responseCall = client.sayHello(request)
    DispatchQueue.global().async {
        do {
            //В фоне дожидаемся результата вызова
            let swiftMessage = try responseCall.response.wait()
            DispatchQueue.main.async {
                //Конвертируем SwiftProtobuf.Message в WireMessage (объект ADAPTER умеет парсить конкретный класс WireMessage из бинарного формата)
                let (wireMessage, mappingError) = swiftMessage.toWireMessage(adapter: HelloReply.companion.ADAPTER)
                //Обязательно вызываем callback на том же потоке на котором фактически создался wireMessage, иначе получим ошибку в KotlinNative-рантайме
                callback(wireMessage, mappingError)
            }
        } catch let err {
            DispatchQueue.main.async {
                callback(nil, KotlinException(message: err.localizedDescription))
            }
        }
    }
}

Функция toWireMessage(..) довольно простая: она берет представление SwiftMessage в виде NSData, переводит в KotlinByteArray и отдает на вход адаптеру:

fileprivate extension SwiftProtobuf.Message {
    func toWireMessage<WireMessage, Adapter: Wire_runtimeProtoAdapter<WireMessage>>(adapter: Adapter) -> (WireMessage?, KotlinException?) {
        do {
            let data = try self.serializedData()
            let result = adapter.decode(bytes: data.toKotlinByteArray())

            if let nResult = result {
                return (nResult, nil)
            } else {
                return (nil, KotlinException(message: "Cannot parse message data"))
            }
        } catch let err {
            return (nil, KotlinException(message: err.localizedDescription))
        }
    }
}

Самый примитивный вариант конвертации NSData в KotlinByteArray:

fileprivate extension Data {
    //Побайтово копируем NSData в KotlinByteArray
    func toKotlinByteArray() -> KotlinByteArray {
        let nsData = NSData(data: self)

        return KotlinByteArray(size: Int32(self.count)) { index -> KotlinByte in
            let byte = nsData.bytes.load(fromByteOffset: Int(truncating: index), as: Int8.self)
            return KotlinByte(value: byte)
        }
    }
}

Сохраняем все и пробуем проверить прямо в AppDelegate (коммит):

@UIApplicationMain
class AppDelegate: NSObject, UIApplicationDelegate {

    var window: UIWindow?

    let gRPCClient = HelloWorldCallbackBridge()

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {

        let request = HelloRequest(name: "AppDelegate", unknownFields: OkioByteString.companion.EMPTY)
        gRPCClient.sendHello(message: request) { reply, error in
            print("Reply: \(reply?.message) - Error: \(error?.message)")
        }
        return true
    }
}

В терминале с запущенным сервером увидим сообщение:


2022/02/17 23:51:28 Received: AppDelegate

А в консольном выводе XCode — много логов по состоянию канала и наш print:

2022-02-17T23:51:27+0700 debug gRPC : old_state=idle grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 new_state=connecting connectivity state change

2022-02-17T23:51:27+0700 debug gRPC : grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 connectivity_state=connecting vending multiplexer future

2022-02-17T23:51:27+0700 debug gRPC : grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 making client bootstrap with event loop group of type NIOTSEventLoop

2022-02-17T23:51:27+0700 debug gRPC : grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 Network.framework is available and the EventLoopGroup is compatible with NIOTS, creating a NIOTSConnectionBootstrap

2022-02-17 23:51:28.487194+0700 mokoApp[34306:38235189] [] nw_protocol_get_quic_image_block_invoke dlopen libquic failed

2022-02-17T23:51:28+0700 debug gRPC : connectivity_state=connecting grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 activating connection

2022-02-17T23:51:28+0700 debug gRPC : h2_settings_max_frame_size=16384 grpc.conn.addr_remote=127.0.0.1 grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 grpc.conn.addr_local=127.0.0.1 HTTP2 settings update

2022-02-17T23:51:28+0700 debug gRPC : connectivity_state=active grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 connection ready

2022-02-17T23:51:28+0700 debug gRPC : grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 old_state=connecting new_state=ready connectivity state change

2022-02-17T23:51:28+0700 debug gRPC : grpc.conn.addr_remote=127.0.0.1 grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 grpc_request_id=682A7FB4-4543-4609-A2C0-498B8A1445A3 grpc.conn.addr_local=127.0.0.1 activated stream channel

2022-02-17T23:51:28+0700 debug gRPC : grpc.conn.addr_local=127.0.0.1 grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 grpc.conn.addr_remote=127.0.0.1 h2_stream_id=HTTP2StreamID(1) h2_active_streams=1 HTTP2 stream created

2022-02-17T23:51:28+0700 debug gRPC : h2_active_streams=0 grpc.conn.addr_remote=127.0.0.1 grpc.conn.addr_local=127.0.0.1 grpc_connection_id=7E6AA2F6-3F83-4448-BEB1-F0C3C85131AD/0 h2_stream_id=HTTP2StreamID(1) HTTP2 stream closed

Reply: Optional("Hello AppDelegate") - Error: nil

Шаг 9. iOS: проверяем работу gRPC-клиента внутри фичи

Пожалуй, не будем создавать новый контроллер. Добавим еще одну вью-модель на ConfigViewController, будем вызывать ее метод при появлении контроллера на экране и показывать алерт по событию из EventsListener (коммит):

override func viewDidLoad() {
  ...
  grpcTestViewModel = AppComponent.factory.grpcTestFactory.createViewModel(eventsDispatcher: EventsDispatcher(listener: self))
  }

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    grpcTestViewModel.onMainButtonTap()
}

deinit {
    //Очищаем вью-модель, чтобы сразу же остановить все корутины
    viewModel.onCleared()
    grpcTestViewModel.onCleared()
}
...

extension ConfigViewController: GrpcTestViewModelEventsListener {
    func showMessage(message: String) {
        let alert = UIAlertController(title: "gRPC test", message: message, preferredStyle: .alert)
        present(alert, animated: true, completion: nil)
    }
}

В результате при запуске приложения получаем:

Что делать для Android-приложений

С стороны Android-платформы можно использовать именно сгенерированный код Wire-клиента, дав ему экземпляр платформенного клиента. Выглядеть это может примерно так:

  • CommonMain-код:

class WireClientWrapper(grpcClient: GrpcClient): HelloWorldSuspendClient {
    private val greeterClient = GrpcGreeterClient(grpcClient)
    override suspend fun sendHello(message: HelloRequest): HelloReply {
        return greeterClient.SayHello().execute(message)
    }
}
  • AndroidMain-код:

val grpcOkhttpClient = OkHttpClient().newBuilder()
    .protocols(listOf(okhttp3.Protocol.HTTP_2, okhttp3.Protocol.HTTP_1_1))
    .build()

val grpcClient = GrpcClient.Builder()
    .client(grpcOkhttpClient)
    .baseUrl("127.0.0.1:50051")
    .build()

val helloClient = WireClientWrapper(grpcClient)

return SharedFactory(
           settings = settings,
           antilog = antilog,
           newsUnitsFactory = newsUnitFactory,
           baseUrl = BuildConfig.BASE_URL,
           helloWorldClient = helloClient
        )

Итоги

Конечно, в приведенном решении еще много чего можно улучшить:

  1. Заменить долгую реализацию копирования NSData в KotlinByteArray на использование memcpy.

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

  3. Реализовать универсальный маппинг сообщений из WireMessage в SwiftMessage.

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

До новых встреч!

Комментарии (1)


  1. slutsker
    22.06.2022 13:28
    +1

    Отличная работа и материал, молодцы