Представьте, экран на 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-datetimeDST-перехода могут дать разные результаты на 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
- Двигается от симптома к гипотезам и проверкам
- Отличает баг инструмента от ошибки использования или окружения