Kotlin MultiplatformSeniorExperience

Представьте, экран на Kotlin Multiplatform тормозит, теряет состояние или ведёт себя по-разному на разных платформах. Как вы будете искать причину?

Диагностику начинают с изоляции симптома: только одна платформа или обе, debug или release. Затем используют Android Profiler / Instruments для перфоманс-проблем и добавляют логирование в expect/actual для разного поведения на платформах.

Диагностика проблем производительности и поведения в KMP

Когда экран тормозит, теряет состояние или ведёт себя по-разному на Android и iOS, важно систематически локализовать проблему: это баг в commonMain, в platform-specific коде, в boundary между Kotlin и Swift/ObjC, или в lifecycle платформы.

Шаг 1 — воспроизвести и изолировать симптом

Сначала нужно ответить на вопросы: проблема только на одной платформе или на обеих? Только на device или и на simulator? Только в release или и в debug? Ответы сразу сужают область поиска.

# Запуск iOS-таргета в симуляторе с логами
xcrun simctl launch --console booted com.example.app

# Сборка только iOS-фреймворка для быстрой проверки
./gradlew :shared:iosSimulatorArm64Framework

Шаг 2 — тормозит экран: профилировщики

На Android используйте Android Profiler (CPU Profiler, Memory Profiler) или Systrace. На iOS — Instruments (Time Profiler, Allocations). Типичные виновники в KMP: тяжёлые вычисления в commonMain, вызываемые с main thread без dispatcher, или частые recomposition в Compose Multiplatform.

// ПРОБЛЕМА: тяжёлая операция на main thread
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    fun loadFeed() {
        // Вызов без IO-dispatcher — блокирует UI
        val items = repo.getAllItemsSync() // blocking call
        _state.value = FeedState.Success(items)
    }
}

// ИСПРАВЛЕНИЕ: явный dispatcher
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    fun loadFeed() {
        viewModelScope.launch {
            val items = withContext(Dispatchers.IO) { repo.getAllItemsSync() }
            _state.value = FeedState.Success(items)
        }
    }
}

Шаг 3 — потеря состояния: lifecycle и Flow

Состояние теряется при ротации (Android) или при backgrounding (iOS). Проверьте, где живёт StateFlow: в ViewModel (переживёт ротацию) или в Composable (не переживёт). На iOS проблема другая: если Flow собирается в Swift через collect, нужно явно управлять отменой через Cancellable.

// commonMain — ViewModel с состоянием
class ProfileViewModel(
    private val profileRepo: ProfileRepository
) {
    private val _profile = MutableStateFlow<Profile?>(null)
    val profile: StateFlow<Profile?> = _profile.asStateFlow()

    init {
        // StateFlow в ViewModel переживает конфигурационные изменения Android
        viewModelScope.launch {
            _profile.value = profileRepo.getProfile()
        }
    }
}
// iosMain Swift — правильная отмена
class ProfileViewController: UIViewController {
    private var cancellable: Cancellable?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        cancellable = viewModel.profile.watch { profile in
            // обновить UI
        }
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        cancellable?.cancel() // без этого Flow продолжает работать
    }
}

Шаг 4 — разное поведение на платформах

Проверьте expect/actual реализации: часто поведение различается именно там. Добавьте логирование в каждую actual-реализацию и сравните вывод. Также проверьте часовые пояса (kotlinx-datetime ведёт себя одинаково, но TimeZone.currentSystemDefault() возвращает разные зоны на разных устройствах) и locale-зависимый форматинг строк.

// Добавить логирование в actual для диагностики
// androidMain
actual fun getCurrentTimezone(): String {
    val tz = TimeZone.currentSystemDefault().id
    Log.d("TZ_DEBUG", "Android TZ: $tz")
    return tz
}

// iosMain
actual fun getCurrentTimezone(): String {
    val tz = TimeZone.currentSystemDefault().id
    NSLog("iOS TZ: %@", tz)
    return tz
}

Шаг 5 — минимальный воспроизводящий пример

Если проблема не очевидна, изолируйте её в отдельный тест в commonTest или iosTest. Это быстрее, чем каждый раз собирать полный фреймворк.

// commonTest
class FeedViewModelTest {
    @Test
    fun `state is updated after loadFeed`() = runTest {
        val fakeRepo = FakeFeedRepository(listOf(Item("1", "Title")))
        val vm = FeedViewModel(fakeRepo)
        vm.loadFeed()
        advanceUntilIdle()
        assertEquals(1, (vm.state.value as FeedState.Success).items.size)
    }
}

Подводные камни

  • Instruments на iOS не показывает Kotlin-стектрейсы — нужно сопоставлять с логами вручную или использовать -Xg0 компилятор флаг для отладочных символов.
  • Dispatchers.Main на iOS требует подключённого kotlinx-coroutines-core с iOS-поддержкой — без него coroutine зависнет.
  • StateFlow на iOS собирается иначе, чем на Android: нет аналога collectAsStateWithLifecycle, нужно вручную отменять подписку.
  • Release-сборка Kotlin/Native агрессивно оптимизирует код — баги, скрытые в debug, проявляются в release из-за inlining и dead code elimination.
  • Разные реализации kotlinx-datetime DST-перехода могут дать разные результаты на Android (java.time) и iOS (NSCalendar).
  • Memory leak в iOS: если ViewModel держит ссылку на ViewController через лямбду, ARC не освободит объект.
  • Ktor на iOS использует NSURLSession — таймауты настраиваются иначе, чем OkHttp на Android.
  • Проверять поведение только на симуляторе недостаточно: simulator не воспроизводит реальные threading-ограничения device.

What hurts your answer

  • Сразу обвинять Kotlin Multiplatform, не проверив соседние слои системы
  • Чинить симптом без минимального воспроизведения и evidence
  • Не учитывать версии, конфигурацию, окружение и recent changes

What they're listening for

  • Умеет локализовать проблему вокруг Kotlin Multiplatform
  • Двигается от симптома к гипотезам и проверкам
  • Отличает баг инструмента от ошибки использования или окружения

Related topics