Kotlin Multiplatform — ключевой функционал языка Kotlin, призванный способствовать совместному использованию кода Android и iOS в экосистеме мобильной разработки. Однако специфические сложности и нюансы разработки на стороне iOS сказываются на соответствующих разработчиках.
Изучим приемы по совершенствованию процесса разработки в проектах Kotlin Multiplatform, особенно для команд на платформе iOS. Благодаря этим техникам здесь добиваются плавной интеграции Kotlin и Swift, а в итоге — более эффективных процессов разработки.
Применим эти подходы и архитектурные решения в проекте на основе карточной игры Magic.
Предполагается, что читатель знаком с Kotlin, Swift и Kotlin Multiplatform.
Золотое правило
Хотя в синтаксисе Kotlin и Swift много общего, их базовые архитектуры различны. Внедрение iOS всегда сложнее, поэтому команды сталкиваются с трудностями, которые нужно воспринимать как собственные и находить оптимальные решения.
Отправная точка
Важно определиться с подходом: одномодульный или многомодульный и как его структурировать.
Мы выбрали многомодульный проект, структурированный по слоям с четким разделением обязанностей между пользовательским интерфейсом (ПИ), предметной областью (необязательно) и данными, чем также обнаруживаются сходства в платформенно-независимости, благодаря которой совершенствуется совместная работа и оптимизируется синхронизированная разработка функционала в командах.
Затем определяемся с общими слоями: это слой данных вместе со слоем ПИ, где содержится состояние, или только слой данных? И с компонентами внутри этих слоев — сеть, база данных, диспетчеры-репозитории, модели, модели представлений и т. д.
Общим выбрали только слой данных — возможно, это наиболее востребованный подход, вы поймете почему.
В итоге остановились на подходе с монорепозиторием — из-за размера команды, регулирования кода, сложности проекта.
Архитектура
Выбрав общим только слой данных, реализацию слоев ПИ и предметной области полностью отдали каждой платформе.

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

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

Общий код
Общий модуль тоже решили строить на многомодульной архитектуре: модули data-models
, core-database
и core-network
независимы, а data-managers
зависим от всех троих. Каждым из них предоставляется собственная логика внедрения зависимостей, а модулем core-di
управляется корневая система внедрения зависимостей, используемая всеми платформами. Поэтому-то core-di
и зависим от других, в нем также содержатся настройки фреймворков на Objective-C.
Из этих пяти модулей образован слой данных.
Фасад
Используемый каждой платформой артефакт максимально оптимизируется по размеру и содержимому. Для этого применяются разные практики. Чтобы инкапсулировать и защитить доступ к внутренним модулям core-database
и core-network
, классом CardsManager
реализуется шаблон проектирования Фасад.
Модификаторы видимости
Модификатором internal
функции, свойства, классы, объекты и интерфейсы делаются видимыми только в одном модуле. Чтобы сделать элементы доступными для других модулей Kotlin, применяется модификатор public
, но iOS к ним обращаться не обязательно. Из компиляции iOS они исключаются аннотацией @HiddenFromObjC
, которой предотвращается экспорт функции или свойства:
// Модуль: core-network
@HiddenFromObjC
class ApiClient(val client: HttpClient, val baseUrl: String) {}
// Модуль: core-database
@HiddenFromObjC
class MagicDao(driver: SqlDriver) {}
// Модуль: data-managers
class CardsManager : KoinComponent {
private val remote: ApiClient by inject()
private val local: MagicDao by inject()
}
Настройки фреймворков
Выбрав многомодульную архитектуру с взаимозависимостями между модулями, важно специальными приемами сохранить эти связи в конечном двоичном файле, причем в удобном для восприятия виде. Сейчас в Kotlin Multiplatform имеется ограничение: каждый двоичный фреймворк компилируется как «замкнутый мир». В итоге передать пользовательские типы между двумя фреймворками невозможно, даже если в Kotlin они идентичны.
Модулями shared
и shared-models
предоставляются собственные двоичные фреймворки: Shared и SharedModels соответственно. В shared-models
содержится data class Hello
, а shared
зависим от shared-models
с открытым методом, которым принимается параметр Hello
. Когда эти модули экспортируются в Swift, наблюдается вот что:
SharedModels: public class Hello : KotlinBase
Shared: public class Shared_modelsHello : KotlinBase
Shared: open func update(state: Shared_modelsHello)
Вместо этого:
SharedModels: public class Hello : KotlinBase
Shared: open func update(state: Hello)
То есть во фреймворк Shared включаются все внешние зависимости SharedModel и для обращения к этим внешним типам генерируются новые типы. В итоге вместо простого Hello
имеем Shared_modelsHello
.
Это ограничение устраняется объединением классов не только текущего проекта, но и его зависимостей. Экспортируемые в двоичный файл зависимости указываются в методе export
. Так, без дублирования типов, ссылаться на внешние типы из зависимых модулей корректно:
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
export(projects.sharedModels)
}
}
sourceSets {
commonMain.dependencies {
api(projects.sharedModels)
}
}
}
Этим решением — зонтичным фреймворком — предотвращается заполнение приложения iOS дублированными зависимостями, оптимизируется получаемый артефакт, устраняются проблемы несовместимости зависимостей. Кроме того, шаблоном «Фасад» предотвращается экспорт кода в конечный двоичный файл, что особенно важно для модуля core-database
, которым при помощи SQLDelight генерируется весь код доступа к базе данных.
export
полезен и для других задач, подробнее об этом позже.
Koin
Инверсия зависимостей управляется в проекте благодаря Koin. Эксклюзивная обязанность модуля core-di
— управление корневой системой внедрения зависимостей, используемой всеми платформами, централизация ее конфигурирования и инициализации:
object DependencyInjection {
/**
* Инициализация механизма внедрения зависимостей.
* Эта функция должна вызываться приложением iOS внутри соответствующей структуры.
*/
@Suppress("unused")
fun initKoin(enableNetworkLogs: Boolean) = initKoin(enableNetworkLogs = enableNetworkLogs, appDeclaration = {})
/**
* Инициализация механизма внедрения зависимостей.
* Эта функция должна вызываться приложением Android внутри соответствующего класса.
*/
@HiddenFromObjC
fun initKoin(enableNetworkLogs: Boolean = false, appDeclaration: KoinAppDeclaration) {
startKoin {
appDeclaration()
modules(
networkDiModule("https://api.magicthegathering.io/v1/", enableNetworkLogs),
databaseDiModule(),
managersDiModule()
)
}
}
}
Делегируя каждому модулю обязанность предоставления собственной структуры внедрения зависимостей посредством такой функции, как networkDiModule
, databaseDiModule
и managersDiModule
, получаем легковесную конфигурацию Koin и доступ к внедрению зависимостей конкретного модуля без использования core-di
, что особенно кстати в сценариях тестирования, где требуются лишь некоторые зависимости.
Техника фреймворков с использованием export
важна и для поддержания согласованности внешних типов из зависимых модулей, применяемых в iOS:
kotlin {
androidTarget()
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "MagicDataLayer"
export(projects.dataManagers)
export(projects.dataModels)
}
}
sourceSets {
commonMain.dependencies {
implementation(projects.coreNetwork)
implementation(projects.coreDatabase)
api(projects.dataManagers)
api(projects.dataModels)
implementation(libs.kmp.koin.core)
}
}
}
dataManagers
и dataModels
доступны для iosApp
, поэтому важно поддерживать согласованность типов.
Тесты
При структурировании команд применяется горизонтальный подход, согласно которому одна команда занимается общим модулем, а другая — платформами. Или вертикальный, при котором функционал ведется каждой командой от общего модуля до платформ. При любом подходе важно максимально полно покрыть общий модуль полезными тестами.
Поскольку каждым модулем предоставляется собственная структура внедрения зависимостей, внутри commonTest
при тестировании модуля core-database
имеется вот это:
@HiddenFromObjC
expect fun databaseDiTestModule(): Module
Что и используем:
class MagicDaoTest : KoinTest {
private lateinit var dao: MagicDao
@BeforeTest
fun setUp() {
startKoin {
modules(databaseDiTestModule())
}
dao = get<MagicDao>()
}
@AfterTest
fun finish() {
stopKoin()
}
// Функции тестирования...
}
При тестировании модуля data-managers
, в частности класса CardsManager
, одно из его свойств — экземпляр MagicDao
. data-managers
зависим от модуля core-database
, но из-за отсутствия доступа к его папке commonTest
метод databaseDiTestModule()
в commonTest
из data-managers
использовать невозможно. Обходим это текущее ограничение, воспроизводя его инициализацию в папке commonMain
:
@HiddenFromObjC
expect fun databaseDiModule(): Module
@HiddenFromObjC
expect fun databaseDiTestModule(): Module
При таком подходе databaseDiTestModule()
используется зависимыми от core-database
модулями, его тестовый экземпляр извлекается ими для применения в папках commonTest
:
class CardsManagerTest : KoinTest {
@BeforeTest
fun setUp() {
startKoin {
modules(databaseDiTestModule(), managersDiModule(), ...)
}
}
}

Совместимость Kotlin и Swift
Поскольку в Kotlin генерируется главным образом код Objective-C, вызовы Swift к Kotlin через Objective-C чреваты потерей важного функционала Kotlin: перечислений, изолированных классов, корутин, аргументов по умолчанию, дженериков.
Подробнее о совместимости Kotlin и Swift — здесь.
По корутинам сообществом предлагается два решения: библиотека KMP-NC и плагин SKIE. Мы воспользовались библиотекой.
Конкурентность с KMP-NC
Благодаря KMP-NC устраняется два главных ограничения: отсутствие поддержки отмены для приостанавливающих функций Kotlin при преобразовании в асинхронную функцию Swift и потеря дженериков в протоколах Objective-C. В коде, генерируемом библиотекой через KSP, при помощи конфигурации export
применяются корректные внешние типы во избежание ошибок вот таких:
// Модуль «B»
class MyObject {
@NativeCoroutines
suspend fun observeValue(): String
}
// Модуль «A»
object MyObjectProvider : KoinComponent {
fun myObject() = get<MyObject>()
}
> This causes an issue on iOS:
> Generic parameter 'Output' could not be inferred
> Value of type 'Module_bMyObject' has no member 'observeValue'
Важны также исключения. Все исключения Kotlin непроверяемые: ошибки отлавливаются во время выполнения. В Swift же ошибки только проверяемые и обрабатываются во время компиляции. Поэтому, если в коде Swift или Objective-C вызывается метод Kotlin, которым выбрасывается исключение, этот метод помечается аннотацией @Throws
с указанием списка «ожидаемых» классов исключений.
Однако в KMP-NC исходное объявление скрывается и @Throws
удаляется из сгенерированных функций, поскольку ими не выбрасываются исключения. Решение простое: создаем открытую функцию, в которой явно указываются типы выбрасываемых исключений, и добавляем их в общедоступный API:
class RateLimitException(message: String) : Throwable(message)
@Throws(RateLimitException::class, ThrowableType2:class, ThrowableType3:class, ...)
fun exportedExceptions() {}
Это преобразуется в:
public class RateLimitException : KotlinThrowable {
public init(message: String)
}
public class CardsManager : KotlinBase, Koin_coreKoinComponent {
public init()
/**
* @note В этом методе экземпляры «RateLimitException» преобразуются в ошибки.
* Другие неперехваченные исключения Kotlin — критические.
*/
open func exportedExceptions() throws
}
Оптимальные исключения
Для работающих с платформой команд важно перебирать различные типы ошибок и соответственно корректировать логику слоя ПИ. В Kotlin обычной практикой является создание изолированного класса Result<T>
, которым обозначается успех или неудача:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
}
При преобразовании в Swift получаем:
open class Result<T> : KotlinBase where T : AnyObject {}
public class ResultError : Result<KotlinNothing> {
public init(exception: KotlinThrowable)
open func doCopy(exception: KotlinThrowable) -> ResultError
open func isEqual(_ other: Any?) -> Bool
open func hash() -> UInt
open func description() -> String
open var exception: KotlinThrowable { get }
}
Проблема Nothing
как типа ошибки заключается в невозможности получить конкретные ошибки, например, в Result<NSArray>
:
let successResult = result as? ResultSuccess<NSArray> // Выполняется
let errorResult = result as? ResultError // Не выполняется
Cast from 'Result<NSArray>' to unrelated type 'ResultError' always fails
Дело в том, что ResultError
и ResultSuccess<NSArray>
не находятся в отношении прямого наследования или подтипа. Проблема решается адаптированием Result
к использованию обобщенного параметра и для этого типа ошибки тоже:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error<T>(val exception: Throwable) : Result<T>()
}
Компилятором разрешается преобразование, потому что ResultError<T>
и ResultSuccess<T>
становятся обобщенными экземплярами Result<T>
. В итоге устанавливаются более гибкие отношения между двумя типами, их приведение осуществляется корректно, поэтому имеем такой сценарий:
do {
let result = try await ...
if let successResult = result as? ResultSuccess<NSArray> {
print("Success! \(successResult.data)")
} else if let errorResult = result as? ResultError {
switch errorResult.exception {
case is Type1Exception:
print("We got a Type1Exception")
case is Type2Exception:
print("We got a Type2Exception")
default:
print("We got a \(errorResult.exception)")
}
} else {
print("Unexpected result type...")
}
} catch {
print("Unexpected error \(error)")
}
Так командой разработчиков iOS получается доступ к конкретному типу ошибок, возвращаемых в общем слое данных, а не просто обрабатывается более универсальный в Swift тип Error
внутри оператора do-catch
.
Документация
Для совершенствования процесса разработки необходима и документация. Воспользуемся соответствующим инструментом. Экспортируем документацию кода, просто добавляя в цели iOS эту конфигурацию:
listOf(iosArm64(), iosSimulatorArm64()).forEach { _ ->
compilerOptions {
freeCompilerArgs.add("-Xexport-kdoc")
}
}
Вот типичный синтаксис блока тегов Javadoc:
/**
* Из локальной базы данных извлекаются все наборы карт.
*
* @return Список [CardSet] — это все наборы карт в базе данных.
*/
fun getSets(): List<CardSet>
Он экспортируется в:
/**
* Из локальной базы данных извлекаются все наборы карт.
*
* @return Список [CardSet] - это все наборы карт в базе данных.
*/
open func getSets() -> [CardSet]

И настолько прост, что нет никаких причин им не пользоваться.
Рассмотренные приемы совершенствования процесса разработки iOS, акцентированные на общем слое данных, дополним конкретными техниками для iosApp.
Код iosApp
Приложение iOS тоже разделяется на слои. Пакет App
является точкой входа со структурой внедрения зависимостей. В Core
определяются протоколы внедрения зависимостей и контейнер. А Data
— это мост между слоем данных приложения и общим слоем данных, где находится логика реализации. В Domain
содержатся протоколы моста, структуры данных и протоколы приложения. Наконец, в Presentation
находится вся логика слоя ПИ:

Предметная область
В этом слое определяются протоколы, реализуемые слоем данных и используемые системой внедрения зависимостей, благодаря чему минимизируется применение конкретных типов из общего слоя данных. Этим подходом устанавливается четкая граница взаимодействия с MagicDataLayer
: оно осуществляется внутри слоя данных, действующего как моста, и не распространяется на другие слои приложения, на слой ПИ например.
Внутри пакета Domain
находится библиотека DomainProtocols
с файлом DomainBridge
, в котором определены протоколы моста:
public protocol ErrorException {}
public protocol DomainRateLimitException: ErrorException {}
public protocol DomainCardSet { ... }
public protocol DomainCard { ... }
public class DomainCardList {
let cards: [any DomainCard]
...
}
public class DomainException: Error {
public let error: Error?
public let domainError: ErrorException?
...
}
Библиотека DomainProtocols
Эти протоколы используются как расширения extensions
, благодаря которым на конкретные типы ссылаются по их «типам предметной области». Скоро продемонстрируем это.
Имеется также библиотека CardDomain
с протоколами, у которых появятся конкретные реализации:
import DomainProtocols
public protocol DomainCardsManagerProtocol {
func getCardSet(setCode: String) async -> Result<DomainCardList, DomainException>
func getCardSets() -> [any DomainCardSet]
func observeCardSets() async throws -> AsyncStream<[any DomainCardSet]>
...
}
Библиотека CardDomain
Данные
В этом слое реализуются протоколы Domain
и соединяются с соответствующими типами общего слоя данных. Для этого в библиотеке DataExtensions
создали файл DataBridge
:
import DomainProtocols
import MagicDataLayer
extension KotlinThrowable: @retroactive ErrorException {}
extension RateLimitException: @retroactive DomainRateLimitException {}
extension Card: @retroactive DomainCard {}
extension CardSet: @retroactive DomainCardSet {}
Библиотека DataExtensions
В MagicDataLayer
содержатся типы вроде KotlinThrowable
, RateLimitException
, Card
, CardSet
, CardsManager
. Способность Swift расширять класс и приводить его в соответствие с «типами предметной области» невероятно полезна: теперь, ссылаясь на Card
, вместо интерфейса будем использовать тип-псевдоним DomainCard
.
Остается еще библиотека CardData
, где реализуются конкретные версии протоколов из CardDomain
:
import CardDomain
import DomainProtocols
import MagicDataLayer
public class CardsManagerMock: DomainCardsManagerProtocol { ... }
import CardDomain
import DataExtensions
import DomainProtocols
import KMPNativeCoroutinesAsync
import MagicDataLayer
extension CardsManager: @retroactive DomainCardsManagerProtocol { ... }
Чтобы предотвратить прямой доступ, приводим CardsManager
в соответствие с DomainCardsManagerProtocol
, а для взаимодействия используем протокол.
Благодаря установленным между слоями данных, предметной области и общим слоем данных отношениям, зависимости в общих типах не разбросаны по всем слоям и нужные типы включаются системой внедрения зависимостей, где и когда это необходимо. Например, пока ведется работа команды общего слоя данных, командой на платформе при помощи мок-объектов продолжается разработка слоя ПИ.
Такой подход применяется не только к Kotlin Multiplatform, но и к любой сторонней библиотеке Swift или даже нашей собственной. К тому же им обеспечивается пригодная для тестирования архитектура.
Представление
То же относится и к этому слою: зависимости его классов построены на протоколах из слоя предметной области, а системой внедрения зависимостей предоставляются нужные экземпляры.
@MainActor
public class CardListViewModel: ObservableObject, CardListViewModelProtocol {
private let manager: DomainCardsManagerProtocol
public init(manager: DomainCardsManagerProtocol) {
self.manager = manager
...
}
}
Экземпляр DomainCardManagerProtocol
может быть имитируемым или реальным.
Внедрение зависимостей
Разделив код на слои и собрав зависимости в общих типах, предоставим нужные экземпляры, эффективно используя систему внедрения зависимостей.
В пакете Core
содержатся две библиотеки: DI
, где определяется DIContainer
, и FactoryProtocols
вот с этим:
@MainActor
public protocol FactoryProtocol {
associatedtype T
static var createName: String { get }
static var mockName: String { get }
static func register()
static func create<T>() -> T
static func mock<T>() -> T
}
В App
создаются фабрики для каждого нужного типа. Например, CardsManagerFactory
:
import CardData
import CardDomain
import DI
import FactoryProtocols
import MagicDataLayer
class CardsManagerFactory: FactoryProtocol {
typealias T = DomainCardsManagerProtocol
public private(set) static var createName: String = "CardsManager"
public private(set) static var mockName: String = "CardsManagerMock"
static func register() {
DIContainer.shared.register(DomainCardsManagerProtocol.self, name: createName) { _ in CardsManager() }
DIContainer.shared.register(DomainCardsManagerProtocol.self, name: mockName) { _ in CardsManagerMock() }
}
public static func create<CardsManager>() -> CardsManager {
return DIContainer.shared.resolve(DomainCardsManagerProtocol.self, name: createName) as! CardsManager
}
public static func mock<CardsManagerMock>() -> CardsManagerMock {
return DIContainer.shared.resolve(DomainCardsManagerProtocol.self, name: mockName) as! CardsManagerMock
}
}
При такой структуре легко переключаться между типами:
import CardListPresentation
import SwiftUI
@main
struct MagicApp: App {
init() {
AppDependencies().setupDependencies()
}
var body: some Scene {
WindowGroup {
// CardListView(viewModel: CardListViewModelFactory.mock())
CardListView(viewModel: CardListViewModelFactory.create())
}
}
}

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

MagicDataLayer
включается через настройку прямого связывания на этапе сборки цели приложения, при этом создается скрипт Compile Kotlin Framework:

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

Это процесс корректного разрешения пакетов диспетчером пакетов Swift.
Мы изучили способы совершенствования разработки — от общего кода до кода iosApp — для команд на платформе iOS. Важный участник этого процесса — команда в целом.
Команда
В основе Kotlin Multiplatform заложена проверенная технология. Хотя в отдельных случаях сохраняется своя специфика и здесь продолжится совершенствование, успех этой технологии в конечном счете определяется тем, как Kotlin Multiplatform используется в командах и какой при этом опыт, преимущества и недостатки ими получаются.
Вовлечь команду iOS в разработку сейчас проще, и благодаря описанным приемам этот процесс совершенствуется еще больше.
Важнейший фактор успеха команды — коммуникация. И строжайший процесс разработки, особенно при планировании задач и заключении четких контрактов с командами. Неплохо бы сопоставить то, что уже сделано или должно быть сделано командами фронтенда и бэкенда. Команды мобильной разработки сплотились, то же должно случиться внутри самой команды при продолжении внешней коммуникации.
Это станет ключом к успеху.
Заключение
Мы рассмотрели стратегии и приемы для совершенствования процесса разработки iOS с применением Kotlin Multiplatform. Командами решаются типичные проблемы проектов Kotlin Multiplatform в ключевых областях — архитектуре проекта, внедрении зависимостей, Kotlin-Swift совместимости. Четким разделением слоев и тщательным контролем зависимостей не только упрощается процесс разработки, но и обеспечивается более независимая работа команд iOS и Android при сохранении общей функциональности.
В итоге успех Kotlin Multiplatform определяется не только самой технологией, но и эффективным взаимодействием команд. Скоординированным подходом, с надежной коммуникацией и четкими границами, обеспечивается гармоничная работа модулей — специфичных для платформы и общих. Kotlin Multiplatform постоянно развивается, и потенциал для дальнейшего совершенствования огромен. Благодаря применению этих практик в командах iOS создаются оптимальные рабочие процессы, добиваются плавной интеграции.
Изучите эти стратегии в песочнице:

Читайте также:
- Использование Kotlin Flow для отображения наблюдаемого состояния UI на экране в Android
- UseCase: лучшие практики чистой архитектуры и красные флаги
- Ознакомление с функциями высшего порядка в Kotlin
Читайте нас в Telegram, VK и Дзен
Перевод статьи Guilherme Delgado: Kotlin Multiplatform — How to improve the iOS development experience