Irbisa · Шпаргалка

Shared-модуль KMP на практике

Ktor, kotlinx.serialization, SQLDelight, Koin, source set-ы, Gradle, multiplatform-тесты

Middle-разработчик · 21 вопрос

  1. 01

    commonMain отказывается компилироваться с java.util.Date и java.io.File. Чем заменить JDK-типы, к которым тянется рука по привычке?

    commonMain видит только stdlib Kotlin, поэтому типов java.* там нет: для дат берите kotlinx-datetime с kotlin.time.Clock/Instant, для файлов kotlinx-io, для блокировок Mutex, для UUID kotlin.uuid.Uuid.

    import kotlin.time.Clockimport kotlin.time.TimeSourceimport kotlinx.coroutines.sync.Muteximport kotlinx.coroutines.sync.withLockimport kotlinx.datetime.TimeZoneimport kotlinx.datetime.toLocalDateTime…
  2. 02

    Что не так с этим shared-репозиторием и как это исправить?

    class WeatherRepository {  suspend fun forecast(city: String): Forecast {    val client = HttpClient {      install(ContentNegotiation) { json() }    }    return client.get("https://api.example.com/forecast") {      parameter("city", city)    }.body()  }}

    Он создаёт новый HttpClient на каждый вызов и никогда его не закрывает: каждый запрос платит за свежий движок (новый пул соединений, новый TCP- и TLS-хендшейк), а старые клиенты утекают вместе со своими ресурсами.

  3. 03

    Общий Ktor-клиент собирается, но на iOS падает, как только клиент начинают использовать. Что HttpClient требует от каждого таргета?

    HttpClient даёт только API: реальную работу по сети делает движок конкретной платформы, и артефакт этого движка нужно объявить в каждом платформенном source set.

    // shared/build.gradle.kts: core in common, an engine in every leafkotlin {  sourceSets {    commonMain.dependencies {      implementation(libs.ktor.client.core)      implementation(libs.ktor.client.contentNegotiation)…
  4. 04

    Шлюз отдаёт HTML с кодом 502, и общий клиент падает внутри body<User>(). Как в Ktor подключается JSON и где место этой ошибке?

    body<T>() умеет только десериализовать; решение о том, что ответ является ошибкой, требует отдельной работы, и по умолчанию Ktor её не делает, поэтому 502 нужно превратить в типизированную ошибку до любого разбора.

    val appJson = Json { ignoreUnknownKeys = true }   // the backend will add fieldssealed class ApiError : Exception() {  data object Unauthorized : ApiError()  data class Http(val status: Int) : ApiError()  data object BadPayload : ApiError()  data class Network(val reason: Throwable) : ApiError()…
  5. 05

    Найдите баг: после переезда API на /v2 лента по-прежнему грузится, а каждый запрос профиля получает 404.

    val client = HttpClient {  install(ContentNegotiation) { json(appJson) }  defaultRequest {    url("https://api.example.com/v2/")    contentType(ContentType.Application.Json)  }} class AccountApi(private val client: HttpClient) {  suspend fun feed(page: Int): List<Post> =    client.get("feed") { parameter("page", page) }.body()   suspend fun profile(): User = client.get("/users/me").body()}

    "/users/me" начинается со слеша, поэтому defaultRequest считает его абсолютным путём, который заменяет базовый: запрос уходит на https://api.example.com/users/me и теряет /v2/, а относительный "feed" по-прежнему попадает в /v2/feed.

  6. 06

    Что выведет этот код и почему бэкенд может отвергнуть такой payload?

    @Serializabledata class Price(val amount: Long, val currency: String = "USD") {  var note: String = ""  val isFree: Boolean get() = amount == 0L} fun main() {  val price = Price(5).apply { note = "sale" }  println(Json.encodeToString(price))}

    Выведет {"amount":5,"note":"sale"}: currency выпал, потому что равен значению по умолчанию, note попал, потому что отличается от своего инициализатора, а isFree пропущен, потому что это только геттер.

  7. 07

    Как в общем Ktor-клиенте подставлять bearer-токен и обновлять его по 401, не засветив его в логах и на чужих хостах?

    Установите в Ktor плагин Auth с провайдером bearer { }: loadTokens отдаёт сохранённую пару, а на 401 Ktor вызывает refreshTokens, один раз повторяет запрос с новым токеном и пропускает 401 дальше, если refresh вернул null.

    // SessionStore: suspend read(): TokenPair?, write(TokenPair), clear()fun apiClient(engine: HttpClientEngine, store: SessionStore, apiHost: String) =  HttpClient(engine) {    install(ContentNegotiation) { json(appJson) }    install(Logging) {      level = LogLevel.HEADERS…
  8. 08

    В ленте перемешаны объекты post, ad и banner под одним ключом type. Как разобрать это через kotlinx.serialization в общем коде?

    Опишите их как @Serializable sealed-иерархию: плагин компилятора сам регистрирует наследников, а Json пишет и читает дискриминатор класса (по умолчанию type), чтобы их различать.

    // Closed polymorphism: sealed + @Serializable, subclasses registered by the plugin// @JsonClassDiscriminator("kind") (experimental) changes the key for this hierarchy@Serializablesealed interface FeedItem {  @Serializable @SerialName("post")   data class Post(val id: Long, val body: String) : FeedItem  @Serializable @SerialName("ad")     data class Ad(val id: Long, val campaign: String) : FeedItem…
  9. 09

    Запрос SQLDelight мгновенный в JVM-тесте и роняет кадры на устройстве. Что делает драйвер каждой платформы и где выполняется сам запрос?

    SQLDelight генерирует обычный блокирующий код, драйвер остаётся единственной платформенной частью, а любой сгенерированный вызов выполняется на том потоке, с которого вы его позвали.

    // commonMainexpect class DriverFactory { fun create(): SqlDriver } // androidMainactual class DriverFactory(private val context: Context) {  actual fun create(): SqlDriver =…
  10. 10

    Вы добавили колонку в уже выпущенный .sq-файл, и установленные приложения падают при старте. Как на самом деле работают миграции SQLDelight?

    Файл .sq описывает схему, которую получает чистая установка; обновление уже существующей базы описывает отдельный .sqm-файл, поэтому правка одного только .sq оставляет старые установки со старыми колонками и новыми запросами, которые из них выбирают.

    // src/commonMain/sqldelight/com/example/db/Player.sq: the DESTINATION schema// CREATE TABLE player (//   id      INTEGER NOT NULL PRIMARY KEY,//   name    TEXT    NOT NULL,//   team_id INTEGER                       -- added in v2// );…
  11. 11

    Список на запросе SQLDelight не обновляется после записи. На что подписывается asFlow() и что заставляет его переизлучать?

    asFlow() регистрирует Query.Listener на таблицы, которые читает запрос, а SQLDelight уведомляет этих слушателей только тогда, когда запись идёт через сгенерированный API того же самого драйвера.

    class PlayerRepo(  private val db: AppDatabase,  private val io: CoroutineDispatcher = Dispatchers.IO,) {  // asFlow() emits Query objects; mapToList runs them on the context you hand it.  // Dispatchers.Main there would execute SQL on the UI thread.…
  12. 12

    Koin-модули лежат в commonMain. Как iOS-приложение запускает этот граф и как Swift достаёт из него репозиторий?

    Swift никогда не общается с Koin напрямую: контейнер стартует из top-level Kotlin-функции, которую iOS-приложение вызывает один раз, а наружу выставляются типизированные аксессоры, потому что get<T>() объявлен как inline reified, а reified-дженерики через мост в Objective-C не проходят.

    // commonMain: one graph, platform pieces behind an expectval sharedModule = module {  single { httpClient() }  single<FeedRepo> { FeedRepoImpl(get(), get()) }  factory { FeedViewModel(get()) }}…
  13. 13

    Весь shared-модуль собирается в одном объекте с фабричными функциями, и коллега предлагает подключить Koin. Что реально даёт контейнер?

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

  14. 14

    Где в KMP-приложении хранить auth-токен: в multiplatform-settings или за собственным интерфейсом поверх Keychain и Keystore?

    multiplatform-settings является правильным дефолтом для настроек и неправильным домом для токена, потому что гарантии, которые нужны секрету, невыразимы через putString(key, value).

  15. 15

    Объявлений expect уже больше десятка, и каждый новый target означает ещё одну папку с actual. Когда expect/actual становится неправильным инструментом?

    expect/actual работает как переключатель на этапе компиляции без шва: имя жёстко связано ровно с одной реализацией на target, поэтому всё, что вы захотите подменить, настроить или зафейкать, должно жить за интерфейсом.

    // wrong: everything as expect/actual (untestable, and a new target means new files everywhere)// commonMainexpect class AnalyticsTracker() {  fun track(event: String, params: Map<String, String>)}// androidMain / iosMain: actual class AnalyticsTracker { ... }…
  16. 16

    Вы добавили target watchosArm64, и код в appleMain, который вчера собирался, перестал компилироваться. Откуда вообще берутся source set-ы вроде iosMain и appleMain?

    Их генерирует Kotlin Gradle plugin из объявленных вами таргетов по default hierarchy template, то есть список таргетов и определяет дерево, а добавление одного тихо меняет то, подо что обязаны компилироваться общие родители.

    kotlin {  androidTarget()  iosArm64()  iosSimulatorArm64()  watchosArm64()                    // <- adding this widens appleMain for everyone…
  17. 17

    Как доказать, что shared-репозиторий делает три попытки с backoff после 503, но тест при этом не висит семь настоящих секунд?

    Замените сеть на MockEngine из Ktor в commonTest, установите HttpRequestRetry с exponentialDelay(randomizationMs = 0) и хуком delay { } и проверяйте записанные задержки: тест ничего не ждёт, а числа детерминированы.

    class RetryTest {   @Test  fun retriesThreeTimesWithBackoff() = runTest {    var calls = 0    val waited = mutableListOf<Long>()…
  18. 18

    Найдите баг: во время инцидента на бэкенде у части клиентов за один заказ списали деньги два-три раза.

    val client = HttpClient {  install(ContentNegotiation) { json(appJson) }  install(HttpRequestRetry) {    retryOnExceptionOrServerErrors(maxRetries = 3)    exponentialDelay()  }  defaultRequest { url("https://api.example.com/v2/") }} class PaymentApi(private val client: HttpClient) {  suspend fun pay(orderId: String, amountCents: Long): Receipt =    client.post("payments") {      contentType(ContentType.Application.Json)      setBody(PayRequest(orderId, amountCents))    }.body()}

    HttpRequestRetry здесь повторяет запросы любого метода, поэтому POST /payments, получивший 503 или обрыв соединения уже после того, как сервер списал деньги, отправляется снова: всего до четырёх раз, и каждая попытка может стать новым списанием.

  19. 19

    Падение внутри общего Kotlin-кода на Android читается как нормальный stack trace, а в iOS-отчёте это стена из hex. Что должно быть настроено на каждой стороне?

    На Android общий код представляет собой обычный JVM-байткод, и Crashlytics деобфусцирует его по вашему R8 mapping-файлу; на iOS это машинный код, поэтому отчёт читается ровно настолько, насколько вы загрузили dSYM, а само Kotlin-исключение по умолчанию до репортера вообще не доходит.

    // commonMaininterface CrashReporter {  fun log(message: String)  fun setKey(key: String, value: String)  fun record(error: Throwable)          // non-fatal}…
  20. 20

    Две другие команды хотят зависеть от вашего shared-модуля. Что на самом деле кладёт в репозиторий ./gradlew publish и во что обойдётся ломающее изменение каждому потребителю?

    KMP-публикация даёт не один артефакт, а по модулю на каждый target плюс корневой модуль, чьи Gradle-метаданные на них ссылаются, и ничем из этого не воспользуется потребитель из Xcode: ему нужен XCFramework, опубликованный рядом.

  21. 21

    Добавленная библиотека прекрасно собирается под Android и падает на резолве для iosArm64. Как поймать JVM-only зависимость заранее и какие есть варианты?

    Gradle резолвит отдельный compile classpath на каждый target, поэтому JVM-only зависимость падает именно на резолве нативного таргета («no matching variant» со списком запрошенных атрибутов) задолго до компиляции и тем более до линковки.