commonMain видит только stdlib Kotlin, поэтому типов java.* там нет: для дат берите kotlinx-datetime с kotlin.time.Clock/Instant, для файлов kotlinx-io, для блокировок Mutex, для UUID kotlin.uuid.Uuid.
Дата и время: kotlinx-datetime для LocalDate и TimeZone; Instant и Clock лежат в stdlib и стабильны с Kotlin 2.3.
Прошедшее время: TimeSource.Monotonic.markNow(), а не разность двух показаний настенных часов: коррекция по NTP может сделать её отрицательной.
Файлы: kotlinx-io: Path, SystemFileSystem, Source, Sink.
Блокировки: корутинный Mutex или атомики из stdlib вместо synchronized.
AndroidMiddle
Shared-модуль KMP на практике
Ktor, kotlinx.serialization, SQLDelight, Koin, source set-ы, Gradle, multiplatform-тесты
Закрыто вопросов
0 / 21
- 0Повторить
- 2Часто спрашивают
Вопросы
Он создаёт новый HttpClient на каждый вызов и никогда его не закрывает: каждый запрос платит за свежий движок (новый пул соединений, новый TCP- и TLS-хендшейк), а старые клиенты утекают вместе со своими ресурсами.
Нет переиспользования соединений: keep-alive и мультиплексирование HTTP/2 живут внутри движка (пул OkHttp, NSURLSession у Darwin), и одноразовый клиент выбрасывает их вместе с собой.
Утечка: незакрытый клиент держит живыми свой движок и свой coroutine scope; на JVM это могут быть и потоки движка.
Расползание конфигурации: каждое место вызова повторяет настройку плагинов, и то, где забыли HttpTimeout или auth-плагин, ведёт себя иначе.
Исправление: один клиент на приложение, внедрённый в репозитории, и close() только тогда, когда заканчивается приложение или пользовательская сессия. HttpClient можно спокойно делить между корутинами.
Внедрённый снаружи клиент заодно делает репозиторий тестируемым: commonTest передаёт клиент на MockEngine.
HttpClient даёт только API: реальную работу по сети делает движок конкретной платформы, и артефакт этого движка нужно объявить в каждом платформенном source set.
Android: ktor-client-okhttp.
iOS и macOS: ktor-client-darwin, обёртка над NSURLSession.
JVM desktop: ktor-client-cio либо тот же OkHttp.
Тесты: ktor-client-mock в commonTest; в commonMain уезжает только ktor-client-core и плагины.
Забытый движок: HttpClient { } без движка ищет его в classpath в момент создания клиента. Компиляция проходит, но конструктор кидает IllegalStateException: Failed to find HTTP client engine implementation: consider adding client engine dependency, а при лениво создаваемом клиенте это выглядит как падение на первом запросе. Android-джоба в CI зелёная, iOS-приложение умирает на первом экране.
Два стиля: полагаться на этот автопоиск либо объявить expect fun httpClient(): HttpClient и вернуть из actual-ов HttpClient(Darwin) и HttpClient(OkHttp): забытый таргет тогда падает на компиляции, а настройкам конкретного движка есть где жить.
body<T>() умеет только десериализовать; решение о том, что ответ является ошибкой, требует отдельной работы, и по умолчанию Ktor её не делает, поэтому 502 нужно превратить в типизированную ошибку до любого разбора.
**Один Json**: соберите его один раз в commonMain и передайте в install(ContentNegotiation) { json(appJson) }. ignoreUnknownKeys = true в продакшене обязателен.
Отправка: setBody(dto) требует contentType(ContentType.Application.Json); без него запрос падает до отправки с IllegalStateException про тип тела и null в Content-Type.
Почему 502 взрывается: expectSuccess по умолчанию false, поэтому 502 приходит как обычный ответ, и выполнение доходит до десериализации. Для text/html конвертера нет, отсюда NoTransformationFoundException (это UnsupportedOperationException): в его сообщении есть URL, 502 и text/html, но это сбой десериализации, по которому UI не может ветвиться.
Где место ошибке: expectSuccess = true заставляет любой не-2xx кидать ClientRequestException (4xx) или ServerResponseException (5xx) до разбора. Один раз переведите все случаи, включая JsonConvertException для тела неправильной формы, в HttpResponseValidator { handleResponseExceptionWithRequest { … } } в закрытую иерархию доменных ошибок, и оба приложения ветвятся по одному Kotlin-типу.
"/users/me" начинается со слеша, поэтому defaultRequest считает его абсолютным путём, который заменяет базовый: запрос уходит на https://api.example.com/users/me и теряет /v2/, а относительный "feed" по-прежнему попадает в /v2/feed.
Как склеивается URL: defaultRequest разрешает URL каждого запроса относительно базового так же, как браузер разрешает ссылку: путь со / в начале заменяет базовый путь, относительный дописывается к последней «папке» базового.
Баг-близнец: у url("https://api.example.com/v2") без завершающего слеша сегмент v2 считается именем файла и отбрасывается, так что даже "feed" уходит на /feed. Правило: базовый URL заканчивается на /, пути запросов никогда со / не начинаются.
Почему баг прячется: при expectSuccess = false по умолчанию 404 считается обычным ответом, и приложение видит ошибку десериализации из body(), а не понятное «не найдено».
Как поймать: тест в commonTest на MockEngine, который проверяет request.url.encodedPath для каждого эндпоинта. Logging с LogLevel.INFO печатает полный URL каждого вызова.
Выведет {"amount":5,"note":"sale"}: currency выпал, потому что равен значению по умолчанию, note попал, потому что отличается от своего инициализатора, а isFree пропущен, потому что это только геттер.
Значения по умолчанию не кодируются: Json пропускает свойство, значение которого совпадает с дефолтным (encodeDefaults = false). Нетронутое свойство из тела класса с инициализатором тоже пропускается, поэтому один Price(5) выводит {"amount":5}. Бэкенд, который требует currency, теперь отвергает каждую цену в USD. Лечится @EncodeDefault на этом свойстве (API пока экспериментальный) или encodeDefaults = true в общем Json.
Свойства из тела класса тоже считаются: плагин сериализует каждое свойство с backing field, включая объявленные в теле класса, поэтому note записывается, как только изменится. Пометьте его @Transient (из kotlinx.serialization, а не kotlin.jvm.Transient, который плагин игнорирует) или сделайте геттером.
Геттеры не считаются: у isFree нет backing field, поэтому он никогда не попадает в JSON и его никогда не нужно декодировать.
Декодирование работает зеркально: отсутствующий currency декодируется в "USD", а отсутствующий amount бросает MissingFieldException.
Установите в Ktor плагин Auth с провайдером bearer { }: loadTokens отдаёт сохранённую пару, а на 401 Ktor вызывает refreshTokens, один раз повторяет запрос с новым токеном и пропускает 401 дальше, если refresh вернул null.
**loadTokens вызывается один раз, и результат кешируется в памяти; читайте там из хранилища поверх Keychain/Keystore.
refreshTokens** получает oldTokens и упавший response. Вызовите эндпоинт обновления с markAsRefreshTokenRequest(), чтобы он не проходил через auth-логику, сохраните новую пару и верните её.
Параллельные 401 не тратят refresh-токен каждый по отдельности: одновременно идёт одно обновление, остальные запросы ждут его и повторяются.
Неудачное обновление возвращает null; 401 доходит до вашего валидатора ответов, тот превращает его в Unauthorized, и приложение разлогинивает пользователя. При логауте вызывайте ещё и clearToken() у провайдера, иначе следующий пользователь стартует с закешированным токеном.
Чужие хосты: bearer заранее отправляет токен с каждым запросом, в том числе на CDN; ограничьте это через sendWithoutRequest { it.url.host == apiHost }.
Логи: Logging на LogLevel.HEADERS печатает Authorization, если не добавить sanitizeHeader { it == HttpHeaders.Authorization }.
Опишите их как @Serializable sealed-иерархию: плагин компилятора сам регистрирует наследников, а Json пишет и читает дискриминатор класса (по умолчанию type), чтобы их различать.
Sealed-база: sealed interface или sealed class с аннотацией @Serializable, и каждый наследник тоже @Serializable.
**@SerialName("post") на каждом наследнике. Без него значением дискриминатора становится полное имя класса, и перенос или переименование класса молча меняет формат на проводе.
Другой ключ**: @JsonClassDiscriminator("kind") (@ExperimentalSerializationApi) на иерархию или classDiscriminator у Json глобально. Свойство наследника с именем type конфликтует с дискриминатором, и Json отвергает иерархию при первом же использовании.
Открытый полиморфизм: не-sealed базу нужно регистрировать руками в SerializersModule: polymorphic(Base::class) { subclass(Impl::class) }. Ничто не сканирует classpath, а на Kotlin/Native нет Class.forName, поэтому незарегистрированный подтип даёт SerializationException в рантайме на любом таргете.
SQLDelight генерирует обычный блокирующий код, драйвер остаётся единственной платформенной частью, а любой сгенерированный вызов выполняется на том потоке, с которого вы его позвали.
Android: AndroidSqliteDriver(AppDatabase.Schema, context, "app.db") поверх SupportSQLiteOpenHelper; файл ложится в /data/data/<pkg>/databases/.
iOS и остальные native-таргеты: NativeSqliteDriver(AppDatabase.Schema, "app.db") поверх SQLiter, который по умолчанию использует каталог Documents; восстановимый кэш перенесите в Application Support (внутри Library) через onConfiguration.
JVM-тесты: JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY), и AppDatabase.Schema.create(driver) вы вызываете сами: open helper за вас этого не сделает.
Подключение: expect class DriverFactory либо драйвер собирается в каждом приложении и прокидывается внутрь.
Поток: executeAsList(), executeAsOne() и transaction { } блокируют. Защиты от запросов на главном потоке в рантайме, как в Room, нет, StrictMode на iOS нет, а JVM-тест с базой в памяти слишком быстрый, чтобы это вскрыть. Заворачивайте чтение и запись в withContext(Dispatchers.IO) (этот диспетчер есть и на Kotlin/Native) и держите транзакции короткими: SQLite сериализует писателей.
Файл .sq описывает схему, которую получает чистая установка; обновление уже существующей базы описывает отдельный .sqm-файл, поэтому правка одного только .sq оставляет старые установки со старыми колонками и новыми запросами, которые из них выбирают.
**.sq**: актуальные CREATE TABLE плюс именованные запросы: всегда конечное состояние, а не история.
**.sqm: по одной миграции на файл, имя обозначает версию, из** которой мигрируем: 1.sqm переводит базу v1 в v2. AppDatabase.Schema.version равен максимальному номеру миграции плюс один.
В рантайме драйвер вызывает Schema.create на новом файле и Schema.migrate(old, new) на существующем; AfterVersion(n) { driver -> … } навешивает досыпку данных на конкретный шаг.
Как заставить сборку это ловить: задайте schemaOutputDirectory и verifyMigrations = true в блоке sqldelight { databases { … } }, запустите generate<SourceSet><Name>Schema, чтобы снять слепок <version>.db, и закоммитьте его. verifySqlDelightMigration прогоняет все .sqm поверх слепков и валит сборку, если результат не совпал с .sq; она подцеплена к check, так что CI её уже запускает.
asFlow() регистрирует Query.Listener на таблицы, которые читает запрос, а SQLDelight уведомляет этих слушателей только тогда, когда запись идёт через сгенерированный API того же самого драйвера.
Конвейер: queries.selectAll().asFlow().mapToList(io) из app.cash.sqldelight:coroutines-extensions. asFlow() излучает объекты Query, а не строки; mapToList выполняет их в переданном CoroutineContext, поэтому передавайте Dispatchers.IO, но не главный диспетчер.
Излучения: один раз при подписке, дальше после каждой уведомляющей записи.
Гранулярность: таблица, а не строка: любая вставка в player перезапускает все запросы, читающие player, поэтому добавляйте .distinctUntilChanged().
Транзакции: внутри transaction { } уведомления ждут коммита внешней транзакции, так что двадцать вставок дают одно излучение.
Молчаливые сбои: запрос, выполненный прямо на драйвере (сырой SQL), не сообщает реестру, какие таблицы изменились, и это обычная причина «замороженного» списка. У второго драйвера над тем же файлом свой реестр в памяти, поэтому два драйвера друг друга не слышат.
Swift никогда не общается с Koin напрямую: контейнер стартует из top-level Kotlin-функции, которую iOS-приложение вызывает один раз, а наружу выставляются типизированные аксессоры, потому что get<T>() объявлен как inline reified, а reified-дженерики через мост в Objective-C не проходят.
Модули: объявлены в commonMain; всё платформенное (драйвер SQLDelight, защищённое хранилище, движок Ktor) прячется за expect fun platformModule(): Module.
Одна точка входа: fun initKoin(extra: KoinAppDeclaration = {}) = startKoin { extra(); modules(sharedModule, platformModule()) } служит единственным определением «графа». Android вызывает её из Application.onCreate с androidContext(this).
Старт на iOS: обёртка без аргументов, например fun startShared() = initKoin {}, один раз из инициализатора App (второй startKoin бросает KoinAppAlreadyStartedException): Swift не видит ни значений по умолчанию, ни лямбды KoinAppDeclaration. Префикса init лучше избегать: в Objective-C он зарезервирован, и Swift увидит doInitKoin().
Резолвинг: get<T>() подставляется в место вызова и не попадает в сгенерированный заголовок. Резолвить нужно на стороне Kotlin: Kotlin-object, реализующий KoinComponent, с одной обычной функцией на каждую сущность, нужную iOS; тогда Swift пишет Deps.shared.feedViewModel() и получает конкретный класс.
Контейнер даёт вам lifetime-ы, одну точку подмены и способ достать зависимость, чтобы iOS не выписывал конструктор на двенадцать аргументов, а пока это не нужно, ручные фабрики остаются нормальным DI и читаются проще.
Ручная сборка даёт constructor injection, проверенный компилятором: забытая зависимость становится ошибкой сборки, а не NoDefinitionFoundException в рантайме.
Ничего лишнего: ни klib, ни рефлексии, ничего нового для iOS-команды; фейки в commonTest пишутся сразу, потому что вы и так всё передаёте снаружи.
Цена ручной сборки: бухгалтерия: каждый синглтон превращается в by lazy, который вы поддерживаете сами, и платформа всё равно передаёт то, что умеет создать только она (SQLDelight-драйвер, Ktor-движок, обёртку над Keychain).
Контейнер добавляет именованные lifetime-ы (single, factory, scope на сессию или экран), одно место для подмены реализации (тестовый модуль, переопределяющий FeedApi) и точку резолвинга, до которой Swift добирается через маленький Kotlin-фасад.
Цена: ошибки связывания переезжают в первый вызов. Koin 4.x умеет проверять модуль в тесте, но только на JVM; iOS-сборку это не страхует.
Альтернативы: kotlin-inject (через KSP) и Metro (плагин компилятора) проверяют граф на этапе компиляции.
multiplatform-settings является правильным дефолтом для настроек и неправильным домом для токена, потому что гарантии, которые нужны секрету, невыразимы через putString(key, value).
Что это: общий интерфейс Settings поверх SharedPreferencesSettings и NSUserDefaultsSettings плюс экспериментальный KeychainSettings. Ни один из стандартных бэкендов ничего не шифрует: NSUserDefaults хранится как plist, а shared prefs как XML на диске.
Доступность в Keychain: у элемента есть класс, например kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly против дефолта, который восстанавливается на новом устройстве из бэкапа. Сказать это через key-value API негде.
Переустановка: записи в Keychain переживают удаление приложения: «разлогиненный» пользователь может вернуться залогиненным, если не чистить хранилище на первом запуске.
Android: EncryptedSharedPreferences из Jetpack Security признан deprecated; надёжнее держать в Keystore ключ, которым шифруется токен, а шифротекст класть в DataStore.
Форма API: вызовы Keychain могут упасть и могут блокировать, поэтому нужен suspend и настоящая ошибка, а не синхронный геттер, возвращающий null и на «нет значения», и на «устройство заблокировано».
Решение: интерфейс TokenStore в commonMain, платформенные реализации, инъекция и фейк в commonTest; скучные ключи остаются в Settings.
expect/actual работает как переключатель на этапе компиляции без шва: имя жёстко связано ровно с одной реализацией на target, поэтому всё, что вы захотите подменить, настроить или зафейкать, должно жить за интерфейсом.
Подходит для мелких листьев с одной очевидной реализацией и без тестов: модель устройства, тег текущей локали.
**actual typealias отображает ожидаемое объявление на уже существующий платформенный тип, без единой строки обёртки.
Полнота** контролируется: если добавили target, сборка падает, пока не написаны все actual, и для листа это именно то, что нужно.
Фейка нет: commonTest не может подставить actual, поэтому общий тест над expect на деле гоняет JVM-реализацию, а iOS-реализацию покрывает только прогон на симуляторе.
Одна реализация на target: ни in-memory варианта, ни выбора по flavor, ни решения в рантайме.
**expect class** всё ещё в бете: компилятор предупреждает, если не передать -Xexpect-actual-classes, а члены обязаны совпадать точно, так что рефакторинг болит сразу в трёх местах.
Что масштабируется: интерфейс в commonMain, реализации в androidMain и iosMain и один выбор там, где собирается приложение: через DI или через единственную expect fun-фабрику.
Их генерирует Kotlin Gradle plugin из объявленных вами таргетов по default hierarchy template, то есть список таргетов и определяет дерево, а добавление одного тихо меняет то, подо что обязаны компилироваться общие родители.
Шаблон: applyDefaultHierarchyTemplate() создаёт промежуточные наборы (nativeMain, appleMain, iosMain, watchosMain) над листьями отдельных таргетов, и плагин применяет его сам, пока вы не пропишете ни одного dependsOn руками.
Группе нужны таргеты: iosMain существует потому, что вы объявили iOS-таргеты, а не потому, что на диске есть папка с таким именем; каталог, которого шаблон не создавал, не входит ни в одну компиляцию.
**Явный dependsOn: достаточно одного, и шаблон отключается для всего проекта; дерево дальше поддерживаете вы сами.
UIKit**: appleMain теперь компилируется и под watchOS, а на watchOS нет UIKit, так что общий Apple-код, который дёргал UIDevice, должен опуститься в iosMain.
**expect-ы**: каждому в commonMain нужен actual, покрывающий новый лист.
Зависимости: каждая, объявленная в родительском наборе, обязана публиковать klib для нового таргета, иначе резолв там упадёт.
Замените сеть на MockEngine из Ktor в commonTest, установите HttpRequestRetry с exponentialDelay(randomizationMs = 0) и хуком delay { } и проверяйте записанные задержки: тест ничего не ждёт, а числа детерминированы.
Фейковый движок: ktor-client-mock мультиплатформенный, поэтому фейк живёт в commonTest, и один и тот же тест идёт и на JVM, и на симуляторе.
Каждая попытка: MockEngine { request -> ... } вызывается на каждую попытку, включая ретраи. Три ретрая означают четыре вызова движка: трижды ответьте respondError(HttpStatusCode.ServiceUnavailable), а затем отдайте настоящее тело.
Успешному ответу нужен headers = headersOf(HttpHeaders.ContentType, "application/json"), иначе ContentNegotiation откажется его десериализовать, и вы будете отлаживать не ту ошибку.
Виртуальное ожидание: задержка плагина выполняется в корутине вызывающего, поэтому runTest делает её виртуальной, а не спит семь настоящих секунд.
Детерминированная проверка: exponentialDelay по умолчанию добавляет случайный разброс; randomizationMs = 0 и хук delay { waited += it } позволяют проверить ровно listOf(1000L, 2000L, 4000L).
Никогда не измеряйте тайминги реальными часами или обёрткой withTimeout: тест, который проходит потому, что машина оказалась быстрой, упадёт на CI.
HttpRequestRetry здесь повторяет запросы любого метода, поэтому POST /payments, получивший 503 или обрыв соединения уже после того, как сервер списал деньги, отправляется снова: всего до четырёх раз, и каждая попытка может стать новым списанием.
5xx не значит «ничего не произошло»: шлюз может ответить 503, пока бэкенд дописывает платёж, а обрыв соединения после того, как запрос ушёл с устройства, говорит не больше. retryOnExceptionOrServerErrors считает оба случая безопасными для повтора независимо от метода.
Какие методы безопасны: GET, HEAD, PUT и DELETE идемпотентны по семантике HTTP: повтор оставляет то же состояние. POST и PATCH не идемпотентны. Исправление ограничивает глобальную политику через retryIf и retryOnExceptionIf по request.method.
Когда POST всё же нужно повторять: отправляйте Idempotency-Key, который сервер хранит вместе с результатом, чтобы повтор вернул первый результат, а не списал деньги ещё раз. Ключ создаётся один раз на логическую операцию (на заказ или нажатие «Оплатить»), никогда не на попытку; затем именно этот запрос включает повторы через retry { }.
Отмена: свой retryOnExceptionIf заменяет предикат по умолчанию, который пропускает CancellationException и по умолчанию таймауты; проверку отмены нужно сохранить, а про таймауты решить осознанно.
Как поймать: тест в commonTest на MockEngine, который отвечает 503 на POST и проверяет, что движок вызван ровно один раз.
На Android общий код представляет собой обычный JVM-байткод, и Crashlytics деобфусцирует его по вашему R8 mapping-файлу; на iOS это машинный код, поэтому отчёт читается ровно настолько, насколько вы загрузили dSYM, а само Kotlin-исключение по умолчанию до репортера вообще не доходит.
Android
Общие классы лежат в том же DEX, что и приложение, поэтому необработанное исключение приходит с целыми Kotlin-фреймами.
Gradle-плагин Crashlytics заливает mapping автоматически; если кто-то выключил mappingFileUploadEnabled ради скорости, все релизные сборки после этого нечитаемы.
Обработанные ошибки из общего кода отправляются через recordException как non-fatal, а не как краш.
iOS
Kotlin/Native кладёт .dSYM рядом с фреймворком. При isStatic = true символы попадают в dSYM самого приложения; в любом случае этот файл нужно выгрузить из архива, run-скриптом Crashlytics или командой firebase crashlytics:symbols:upload. CI, который выбрасывает архив, оказывается самой частой причиной hex-фреймов.
В релизных бинарниках остаётся только light debug info; флаг -Xadd-light-debug=enable сохраняет имена функций в трейсе.
Необработанное Kotlin-исключение при переходе в Objective-C убивает процесс, и отчёт обвиняет рантайм Kotlin/Native, а не ваш IllegalStateException. Поставьте setUnhandledExceptionHook, сами отправьте stackTraceToString(), а затем вызовите terminateWithUnhandledException, чтобы краш всё-таки дошёл.
На практике: опишите один интерфейс CrashReporter в commonMain, реализуйте его через Crashlytics на Android и передайте Swift-реализацию на iOS (или возьмите KMP SDK от Sentry, где есть обе стороны). Kotlin-хук считайте источником правды, а нативный отчёт используйте как подтверждение.
KMP-публикация даёт не один артефакт, а по модулю на каждый target плюс корневой модуль, чьи Gradle-метаданные на них ссылаются, и ничем из этого не воспользуется потребитель из Xcode: ему нужен XCFramework, опубликованный рядом.
Что получается на выходе
Корневой модуль shared с .pom и файлом .module, где перечислены все варианты. Потребитель пишет одну координату, а Gradle резолвит shared-jvm, shared-android или shared-iosarm64 под свой target.
Модули по таргетам с настоящими артефактами: jar для JVM и Android, .klib для каждого нативного таргета, плюс исходники.
klib читается только совместимым компилятором Kotlin, так что версия Kotlin входит в ваш публичный контракт: пишите её в release notes и повышайте осознанно.
Команда iOS-приложения не берёт ничего из этого. Она получает .xcframework: как binary target для SPM с checksum или как Pod, собранный в том же релизном job-е.
Во что обходится поломка
Потребители на JVM и Android пересобираются с новым jar, поэтому заглушка с @Deprecated даёт им окно на миграцию.
Потребитель на iOS компилирует Swift против сгенерированных Objective-C-заголовков. Переименование или новый параметр у существующей функции меняют селектор: это поломка исходников, которые вам уже не поправить. Аннотация @Deprecated экспортируется (WARNING становится deprecated, ERROR и HIDDEN становятся unavailable), и это единственное предупреждение, которое они увидят.
Всё internal в заголовок не попадает, а @HiddenFromObjC убирает оттуда и публичное объявление: маленькая экспортируемая поверхность и даёт вам свободу рефакторить.
Закройте это на CI закоммиченным ABI-дампом (binary-compatibility-validator или встроенной ABI-валидацией Kotlin Gradle plugin), чтобы удаление ломало вашу сборку, а не чужую.
Gradle резолвит отдельный compile classpath на каждый target, поэтому JVM-only зависимость падает именно на резолве нативного таргета («no matching variant» со списком запрошенных атрибутов) задолго до компиляции и тем более до линковки.
Как читать ошибку
В сообщении напечатаны варианты, которые библиотека действительно публикует (apiElements, org.jetbrains.kotlin.platform.type = jvm), и то, что просил потребитель (native плюс org.jetbrains.kotlin.native.target = ios_arm64). Это и есть весь диагноз: klib для вашего таргета там нет.
Транзитивный случай выглядит так же, только названа не та координата, которую вы добавляли: мультиплатформенная библиотека вправе тянуть JVM-only зависимость в свой JVM-вариант, но не из общего кода.
Чтобы узнать до того, как впишете координату: посмотрите .module-метаданные библиотеки на Maven Central или поищите на klibs.io, где мультиплатформенные библиотеки проиндексированы по таргетам. ./gradlew :shared:dependencies --configuration iosArm64CompileKlibraries показывает, что реально резолвится для нативного таргета.
Настоящие ошибки линковки выглядят иначе: Undefined symbols for architecture arm64 из-за незалинкованного cinterop или фреймворка, а не отказ резолва. Раз Gradle дошёл дотуда, с зависимостью всё в порядке.
Варианты
Не пускать её в commonMain: интерфейс в общем коде, SDK подключён в androidMain, на iOS работает своя реализация. Называйте этот вариант первым, он и по сути обычно верный.
Заменить мультиплатформенным аналогом: Ktor вместо OkHttp, kotlinx-datetime вместо JVM-ной работы с датами, Kermit вместо Timber.
Портировать, если это маленький чистый алгоритм, а не интеграция с платформой.
Оставить фичу нативной на iOS и шарить только модель.
Не варианты: заглушка в iosMain, которая бросает исключение (она компилируется и уезжает в прод), и замена implementation на api, потому что варианты подбираются по атрибутам, а не по scope. Инвариант, который стоит проговорить: commonMain может зависеть только от библиотек, публикующих klib под каждый ваш target.