Mobile VK Hub
Open in Telegram
Комьюнити от VK для мобильных разработчиков. Здесь всё о том, как создаются приложения для миллионов: от нативных подходов до кросс-платформы.
Show more724
Subscribers
No data24 hours
+17 days
-430 days
Posts Archive
LazyLayoutCacheWindow в Compose 1.13: prefetch в ленивых списках теперь настраивается окном
Лента с тяжёлыми карточками дёргается на быстром скролле по понятной причине: новый элемент нужно скомпоновать, измерить и нарисовать за один кадр, и на сложной карточке бюджет кадра кончается раньше. Ленивые списки и раньше готовили элементы заранее, но скромно: по умолчанию один элемент по ходу скролла, а ушедшие с экрана сразу выбрасывались.
В Compose 1.13 эту механику довели до конца.
LazyLayoutCacheWindow стал стабильным в 1.13.0-alpha01, а в alpha03 от 9 сентября LazyListPrefetchStrategy со всей императивной обвязкой помечен устаревшим. 7 октября ветка вышла в бету. Вместо коллбэков планирования теперь описывается область вокруг вьюпорта: сколько готовить впереди и сколько держать позади.
LazyColumn(
cacheWindow = LazyLayoutCacheWindow(ahead = 300.dp, behind = 150.dp),
) {
items(feed, key = { it.id }) { post -> PostCard(post) }
}
Окно можно задать и долей вьюпорта через aheadFraction и behindFraction. Элементы из зоны ahead готовятся заранее по направлению скролла, а элементы из зоны behind не уничтожаются и быстро возвращаются, если пользователь листает обратно. Передача окна через rememberLazyListState в 1.13 устарела, теперь параметр задаётся прямо у LazyColumn, LazyRow и сеток.
Дефолт по-прежнему осторожный: позади ничего не хранится, а пока пользователь не скроллит, кеш не заполняется. Под флагом ComposeFoundationFlags.isUsingDynamicDefaultCacheWindowInLists дефолтнjе окно подстраивается под средний размер видимых элементов и держится в пределах от 10 до 50% вьюпорта.
Большое окно не означает одного тяжёлого кадра. С версии 1.10 prefetch по умолчанию использует pausable composition: компоновка заготовленного элемента разбивается на части и раскладывается по нескольким кадрам, и сильнее всего это заметно на карточках с долгой композицией.
Платить приходится в другом месте. Заготовленные элементы компонуются раньше, чем появляются на экране, поэтому LaunchedEffect и DisposableEffect внутри них тоже срабатывают раньше. Аналитика показов, повешенная на эффекты, начнёт считать карточки, которые никто не видел. Показы лучше привязывать к реальной видимости:
PostCard(
post = post,
modifier = Modifier.onVisibilityChanged(
minDurationMs = 500,
minFractionVisible = 0.5f
) { visible ->
if (visible) analytics.impression(post.id)
}
)
Коллбэк сработает, когда карточка видна хотя бы наполовину и продержалась на экране полсекунды.
Если в коде есть LazyListPrefetchStrategy, после перехода на 1.13 её стоит заменить на cacheWindow в самом списке. Размер окна лучше подбирать замерами: тот же Google предлагает начать, например, с 50% вьюпорта и сравнить несколько вариантов.
#mobilevkhub #compose+5
iOS 27 SDK: что сломается при пересборке на Xcode 27
Каждую осень новый SDK приносит список изменений, и большая их часть проявляется, только когда проект пересобирают на свежем Xcode. С 9 сентября App Store принимает сборки на Xcode 27, а с апреля 2027 года будет принимать только сборки на SDK iOS 27 и новее.
В этом году список интереснее. Приложение без scene-based жизненного цикла, собранное с iOS 27 SDK, просто не запустится, launch screen стал обязательным для ревью, а из Xcode убрали старый линкер. Часть изменений не даёт ошибок сборки и всплывает уже в интерфейсе.
➡️ В карточках разберём scene-based жизненный цикл, launch screen, линкер и требования Xcode 27 к CI, замену
canOpenURL, новый @State и поведение UIKit, которое меняется без предупреждений.
#mobilevkhub #ios #xcodeГлавное в мобильной разработке в сентябре 2026 года — осенние релизы Apple. iOS 27, Xcode 27 и Swift 6.4 вышли на одной неделе и привнесли требования, из-за которых старые проекты ломаются при пересборке. У Android вышел стабильный Navigation3 1.2, Compose начал уходить от текстовых полей на
value, а Kotlin показал первую бету 2.5.
🟣iOS 27 и Xcode 27: scene-based жизненный цикл обязателен
С 9 сентября App Store принимает сборки на Xcode 27, а 14 сентября вышли сами iOS 27 и Xcode 27. Приложение, собранное с новым SDK, не запустится без scene-based жизненного цикла, а без launch screen в Info.plist его отклонит ревью. Из Xcode убрали линкер ld64 вместе с флагом -ld_classic, PreviewProvider помечен устаревшим. С апреля 2027 года App Store будет принимать только сборки на SDK iOS 27 и новее.
🟣SwiftUI: новый @State работает и на iOS 17
@State стал макросом и больше не вызывает инициализатор модели при каждом пересоздании вью. Согласно release notes, новое поведение работает на всех системах начиная с iOS 17, если проект собран в Xcode 27. Код, где значение задано в объявлении и ещё раз присваивается в init, частично перестал компилироваться.
🟣Swift 6.4: await в defer
Релиз от 15 сентября разрешил await внутри defer и добавил withTaskCancellationShield для очистки, которую не должна прерывать отмена задачи. Swift Build стал сборщиком по умолчанию в SwiftPM, а проверки двух тестовых фреймворков стали взаимозаменяемы: XCTAssert работает в Swift Testing, #expect — в XCTest.
🟣Kotlin 2.4.20 и 2.5.0-Beta1
В 2.4.20 от 7 сентября Swift export превращает sealed-иерархии в Swift enum, так что switch по ним обходится без default. Swift-классы теперь могут наследовать открытые классы Kotlin. Бета 2.5 от 23 сентября сделала стабильной деструктуризацию по именам и добавила экспериментальные блоки companion {}.
🟣Navigation3 1.2.0
В стабильной версии от 23 сентября появились передача результатов между экранами через ResultEventBus, мультиплатформенные диплинки с UriDeepLinkMatcher и NavigationBackHandler для predictive back.
🟣Compose: TextFieldState вместо value
В 1.13.0-alpha03 от 9 сентября перегрузки BasicTextField и TextField из Material 2 с параметрами value и onValueChange помечены устаревшими в пользу TextFieldState. Вместо maxLength появились maxLengthTrim и maxLengthReject, а API Grid и FlexBox вышли из экспериментальных.
🟣Сборка: AGP 9.4
AGP 9.4 требует Gradle 9.6 и JDK 17. В AGP 10 новый Variant API станет обязательным, а модули, которые к нему ещё не готовы, пока можно исключить через android.newDsl.optOut.
🟣Безопасность Android
Сентябрьский бюллетень закрывает критическую CVE-2026-28604: удалённое выполнение кода в ADB без участия пользователя. Все уязвимости из бюллетеня закрывает уровень патча 2026-09-05.
#mobilevkhub #дайджест@State в iOS 27: Observable-объекты перестали создаваться впустую
Строка
@State private var model = DataModel() выглядит безобидно, пока в инициализаторе не появится точка останова. SwiftUI пересоздаёт структуру вью на каждом обновлении родителя, и конструктор вызывается снова и снова. Лишние экземпляры система выбрасывает сразу, оставляя первый, а вот код внутри init успевает отработать целиком. Если там разбор конфигурации, поднятие клиента или чтение с диска, за одну прокрутку списка эта работа уходит в мусор десятки раз.
Обходили по-разному. Кто-то возвращался к @StateObject, ленивому с самого начала, кто-то держал инициализатор пустым и догружал данные в task. Третий путь — вынести объект уровнем выше и прокинуть через окружение.
В SDK iOS 27, вышедшего 14 сентября 2026, @State стал макросом, и классы внутри него инициализируются лениво, ровно один раз за время жизни вью:
@Observable
final class DataModel {
var items: [Item] = []
init() {
// теперь выполнится один раз, а не на каждом обновлении родителя
}
}
struct FeedView: View {
@State private var model = DataModel()
var body: some View {
List(model.items) { ItemRow(item: $0) }
}
}
Код выглядит так же, как год назад, а поведение под ним другое. Объект перестал быть расходником, поэтому тяжёлую подготовку можно держать прямо в инициализаторе, не растаскивая её по onAppear и task.
Разница заметна там, где вью обновляется часто: на экране с таймером, в ленте с подгрузкой, в любой вложенной иерархии, где родитель перерисовывается из-за чужого состояния. Раньше каждое такое обновление тянуло за собой конструктор модели ребёнка, теперь нет.
Одно осталось прежним. Ленивость распространяется только на выражение в объявлении, так что, если модель присваивается свойству в init() самого вью, инициализатор по-прежнему выполняется на каждом пересоздании, а присвоение игнорируется, и в свойстве может остаться устаревшее значение.
Перепроверить стоит и участки кода,, где инициализатор модели делал что-то неидемпотентное: писал аналитику, инкрементировал счётчик, регистрировался в синглтоне. Такой код годами работал вместе с лишними вызовами, и после перехода на новый SDK число событий изменится.
Заодно в этом же релизе ViewBuilder стал доступен как ContentBuilder, и сборка сложных вложенных вью в Xcode 27 заметно ускорилась. На скорость самого приложения это не влияет, зато сокращает время компиляции.
Если проект уже собирается новым SDK, включать ленивость отдельно не нужно, она работает сама. А вот привычку выносить дорогую инициализацию в task можно пересмотреть: модель снова имеет право быть тяжёлой.
А вы уже поднимали target на iOS 27?
#mobilevkhub #iosexplicit backing fields: как убрать пару _state и state из каждой ViewModel
В любой ViewModel живёт одна и та же конструкция: приватный MutableStateFlow и публичный StateFlow поверх него. Две строки вместо одной, подчёркивание в имени, о котором когда-то договорились на ревью, и постоянный риск отдать наружу мутабельный тип по невнимательности.
class ProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
}
В Kotlin 2.4 explicit backing fields стали стабильными, и эта пара схлопывается в одно свойство. Публичный тип объявляется как обычно, а ключевое слово field задаёт реальный тип хранения:
class ProfileViewModel : ViewModel() {
val uiState: StateFlow<UiState>
field = MutableStateFlow<UiState>(UiState.Loading)
fun retry() {
uiState.value = UiState.Loading // внутри класса это MutableStateFlow
}
}
Дальше всё решает точка обзора. Внутри класса компилятор видит поле и отдаёт MutableStateFlow, поэтому значение меняется привычным .value. Снаружи то же имя имеет тип StateFlow, и присвоить в него не выйдет. Гарантия осталась прежней, а лишнее имя ушло.
С коллекциями приём работает так же, и там он избавляет сразу от двух костылей: не нужен ни toList() на каждом обращении, ни отдельное неизменяемое представление.
class Cart {
val items: List<String>
field = mutableListOf()
fun add(item: String) = items.add(item)
}
Требований к такому свойству немного, но соблюдаются они строго. Оно обязано быть val, без собственного геттера, не open и не делегированное. Тип поля должен быть подтипом типа свойства, а видимость у поля всегда приватная. Для var синтаксис не работает, так что изменяемые свойства остаются на старой схеме.
Снаружи не меняется ничего: collectAsStateWithLifecycle() в Compose и подписки в тестах видят прежний StateFlow. Под ту же схему попадают события через MutableSharedFlow и вообще любые пары, где внутри нужен изменяемый тип, а наружу отдаётся его неизменяемый родитель.
Сам синтаксис появился ещё в 2.3.0 в декабре 2025, но собирался только с флагом -Xexplicit-backing-fields. Теперь опт-ин не нужен, достаточно поднять версию Kotlin в проекте.
Переписывать разом весь модуль смысла мало.
Больше всего выигрывают классы, где таких пар несколько: экранные ViewModel с отдельными потоками для контента, ошибок и прогресса. Там уходит половина объявлений, а вместе с ними и путаница, к какому из двух имён обращаться.
#mobilevkhub #kotlinПоговорим про AI в Android-разработке
📱 Как автоматизировать ручной разбор крашей и инцидентов и начать реально экономить время своей команды? Как сделать автономную систему для массовой генерации UI-автотестов из независимых ИИ-агентов? Чтобы она:
🟣брала тест-кейс из Allure,
🟣анализировала проект и приложение,
🟣писала UI-тест и Page Objects,
🟣запускала всё на эмуляторе,
🟣проверяла, действительно ли тест способен находить ошибку.
Подробнее можно будет узнать на онлайн-конференции для Android-разработчиков Podlodka Android Crew. Тема недели: как ускорить работу команды с AI и не превратить проект в хаос.
Участвуют спикеры VK: Роман Гергерт, разработчик в Core Auto, и Дмитрий Мовчан, руководитель отдела соцсервисов подразделения разработки в Одноклассниках.
🟣Завтра, 22 сентября, с 10:00 до 11:00 Роман расскажет, как устроена автономная AI-система для массовой генерации UI-тестов в его команде и какие инженерные решения позволили её собрать.
🟣В пятницу, 25 сентября, с 10:00 до 11:00 выступит Дмитрий Мовчан. Вместе с Иваном Луценко (Bereke Bank) и Филиппом Шадриным (RWB) они разберут свои кейсы автоматизации работы с крашами и инцидентами, расскажут, в чём AI действительно экономит время команды, а где надёжнее использовать обычный статический анализ и другие инструменты.
🗓 Подробная программа на сайте конференции.
#mobilevkhub #конференция #android
+5
Android 17: изменения, которые сломают приложение молча
Каждый релиз Android приносит список изменений поведения, и большинство из них не требует ничего делать. Но в семнадцатой версии есть несколько таких, которые не выдают ошибку компиляции и не пишут предупреждение в лог — приложение просто начинает вести себя иначе после поднятия
targetSdk до 37.
Часть из них ломает код, который годами работал: рефлексия по приватным полям системных классов, правка static final, чтение SMS сразу после получения. Другая часть меняет то, чего пользователь ждёт от приложения.
Разбираем шесть таких изменений и то, как проверить, задевают ли они вас.
#mobilevkhub #android📌 2 ноября VK Видео проведет трансляцию ежегодной конференции наших партнеров RuStore Mobile GameDev Conf 2026.
Это крупнейшая в России конференция для мобильной игровой индустрии и премия для лучших игр и приложений года.
В прямом эфире будут:
🔵выступления представителей ведущих игровых студий
🔵кейсы продвижения, монетизации и развития мобильных приложений
🔵обзоры ключевых изменений в индустрии и трендов, аналитика рынка
🔵вручение Премии RuStore 2026
🗓 2 ноября
⏲ 16:00–19:00 МСК
Подробнее о конференции
Dispatchers.IO: почему limitedParallelism не ограничивает то, что вы думаете
Приложение ходит в базу и во внешний API, оба вызова обёрнуты в withContext(Dispatchers.IO). Работает, пока API отвечает быстро. Потом на той стороне что-то ломается, ответы идут по 10 секунд — и вместе с ними встаёт локальная база, которая свободна и вообще ни при чём.
Причина в том, что Dispatchers.IO один на всё приложение. Медленный источник держит потоки ожиданием, остальные задачи стоят за ним в очереди. Разделить их может limitedParallelism, только работает эта функция не так, как обещает название.
Что такое Dispatchers.IO на самом деле
Под капотом — не отдельный пул, а представление общего планировщика, который делят IO и Default. Потоки создаются по мере надобности и гасятся, когда простаивают.
Ограничение у IO — 64 параллельные задачи или число ядер, если их больше; меняется свойством kotlinx.coroutines.io.parallelism. Число — это про задачи, а не про потоки: гарантии, что потоков будет ровно столько, никто не даёт.
У Default картина другая — параллелизм равен числу ядер, минимум два. Логика понятная: он для вычислений, а больше ядер вычислять всё равно не выйдет.
limitedParallelism на IO ничего не ограничивает
Название подсказывает, что вызов отрезает долю внутри тех же 64 задач. На IO всё наоборот.
val dbDispatcher = Dispatchers.IO.limitedParallelism(100)
val apiDispatcher = Dispatchers.IO.limitedParallelism(60)
В документации формулировка прямая: представления, полученные через limitedParallelism, ограничением Dispatchers.IO не связаны. На пике система может держать 64 задачи самого IO плюс 100 плюс 60 параллельно. Каждый вызов заводит себе отдельную квоту сверху, а не отрезает кусок общей.
Логика за этим такая: IO про блокирующие операции, где поток большую часть времени просто ждёт. Общий жёсткий лимит на них бессмысленен, ожидающий поток почти не стоит процессорного времени.
Расплата за удобство — легко наплодить потоков. Пять модулей, каждый со своим limitedParallelism(50), дают на пике 250 параллельных блокирующих задач. В спокойном режиме потоков будет мало: представления делят общий пул, лишние гасятся. А вот пиковую картину придётся считать самому.
На Default всё наоборот
Тот же вызов на Dispatchers.Default ведёт себя противоположно: он именно режет, создавая представление на тех же потоках, которое занимает не больше указанного числа.
val imageDispatcher = Dispatchers.Default.limitedParallelism(4)
Тяжёлая обработка не съест все ядра и не заморозит остальные вычисления. Здесь название функции описывает происходящее честно.
Разница в коде никак не видна: две внешне одинаковые строки делают противоположные вещи, и понять это можно только по тому, от какого диспетчера идёт вызов.
Как этим пользоваться
Заводите отдельный диспетчер на каждый внешний источник. База, сеть, файлы — у каждого своя квота, и медленный источник перестаёт тормозить соседей.
class ApiClient(private val http: HttpClient) {
private val dispatcher = Dispatchers.IO.limitedParallelism(20)
suspend fun fetch(url: String) = withContext(dispatcher) {
http.get(url)
}
}
Размер квоты стоит соотносить с тем, что на другом конце. Пул соединений к базе на 10 штук делает бессмысленным диспетчер на 100: 90 задач просто встанут в ожидание свободного соединения, занимая потоки впустую. Держите квоту близкой к размеру пула.
И держите в голове сумму. Каждая новая квота на IO добавляется к общему пиковому числу, а не берётся из уже выделенного.
А вы разделяете диспетчеры по источникам или ходите везде через общий Dispatchers.IO? Расскажите в комментариях, ловили ли ситуацию, когда одна медленная зависимость подвешивала всё остальное.
#mobilevkhub #kotlin🟣Compose 1.12: mesh-градиенты и широкий цвет
Август в мобильной разработке прошёл вокруг Compose. Стабильная версия приехала 12 августа вместе с BOM 2026.08.00. Главное в релизе — mesh-градиенты. Обычный градиент тянет цвет вдоль линии или от центра, и дальше двух-трёх оттенков начинает выглядеть грязно: в местах смешивания появляется серость, которой в исходных цветах не было. Mesh устроен иначе — задаётся сетка вершин, у каждой свой цвет, и заливка растекается между ними во все стороны сразу.
Те самые мягкие переливы на фонах и карточках, ради которых раньше подкладывали картинку из макета или тащили в проект библиотеку с шейдерами, теперь рисует сам фреймворк.
Рядом — поддержка широкого цветового охвата. Экраны флагманов давно показывают цвета за пределами sRGB, но Compose работал в старом пространстве и обрезал всё, что туда не помещалось. На практике это выглядело так: дизайнер подобрал насыщенный брендовый оттенок, проверил на своём мониторе, а до пользователя тот доехал блёклым. Теперь цвета и шейдеры уходят на отрисовку как есть.
Grid получил именованные области. Раскладку описываешь словами вместо индексов строк и колонок — подход знаком всем, кто писал CSS Grid, и здесь он работает так же: схема видна прямо в коде, а перестановка блоков сводится к правке пары строк.
Плюс появились готовые точки входа в Credential Manager, через который проходят passkeys и вход по сохранённым паролям. Раньше эту связку собирали через Activity и колбэки, что для Compose-экрана выглядело инородно.
🟣Цена обновления
Версия 1.12 собирается только под compileSdk 37 и требует AGP не ниже 9.1.1, так что обновление затрагивает не библиотеку, а всю сборку проекта.
Команды со свежим тулчейном отделаются формальностью. Остальным придётся заводить отдельную задачу: AGP 9 выкинул поддержку proguard-android.txt, поменял упаковку нативных библиотек и тянет за собой более новый Gradle.
Каждый пункт по отдельности решается за полчаса, вместе они складываются в спринтовую историю — и узнать об этом лучше до того, как кто-то поднимет BOM в ветке и уйдёт разбираться с красной сборкой.
🟣Что дальше
26 августа следом вышла 1.13.0-alpha02: ветка следующего релиза уже в работе, и в ней продолжают перебирать экспериментальную Slot Table — внутреннюю структуру, на которой держится состояние композиции.
Material3 при этом остаётся на стабильной 1.4.0, а альфа 1.5 идёт своим темпом и до BOM пока не доехала. Если проект тянет Material3 отдельной зависимостью, версии стоит сверить руками: расхождение с BOM здесь обычное дело.
#mobilevkhub #дайджест+9
OOM в Android: почему чистый heap dump больше ничего не гарантирует
Дамп кучи чист, свободные мегабайты на месте, а процесс убит системой. Сегодня это нормальная ситуация: Android 17 ограничивает память всего процесса. Google объявил, что с февраля 2027 года станут обязательными пороги качества по anonymous RSS + swap и bitmap usage.
В карточках разбираем, почему пустой дамп кучи больше не спасает от вылетов.
#mobilevkhub #android
Compose 1.12: mesh-градиенты, широкий цвет и обязательный AGP 9.1.1
Августовский Compose привёз набор визуальных API, но начать придётся не с них. Версия 1.12 собирается только под compileSdk 37 и требует AGP не ниже 9.1.1 — то есть трогает не библиотеку, а всю сборку проекта.
Команды со свежим тулчейном отделаются формальностью. Остальным придётся заводить отдельную задачу: AGP 9 выкинул поддержку proguard-android.txt, поменял упаковку нативных библиотек и тянет более новый Gradle. Узнать об этом лучше до того, как кто-то поднимет BOM в ветке.
implementation(platform("androidx.compose:compose-bom:2026.08.00"))
Ради чего всё это — видно с первого нововведения. Обычный градиент растягивает цвет вдоль линии или от центра, и дальше двух-трёх оттенков выглядит грязно. Mesh-градиент устроен иначе: задаёте сетку вершин со своим цветом у каждой, и заливка растекается между ними во все стороны. Так рисуют мягкие переливы, ради которых раньше подкладывали картинку или тащили библиотеку с шейдерами.
val painter = remember {
MeshGradientPainter(rows = 1, columns = 1) {
setVertex(0, 0, Offset(0f, 0f), Color.Red)
setVertex(0, 1, Offset(1f, 0f), Color.Blue)
setVertex(1, 0, Offset(0f, 1f), Color.Green)
setVertex(1, 1, Offset(1f, 1f), Color.Yellow)
}
}
Box(Modifier.fillMaxWidth().aspectRatio(16 / 9f).paint(painter))
Читается это проще, чем выглядит. Координаты нормализованные: (0f, 0f) — левый верхний угол, (1f, 1f) — правый нижний, пиксели знать не нужно. Сетка 1 × 1 даёт один участок и четыре вершины по углам: их всегда на одну больше, чем участков. Кривизну границ задают контрольные точки Безье, но их можно не трогать — Compose подберёт сам.
Второе изменение из той же области — широкий цветовой охват. Экраны флагманов давно показывают цвета за пределами sRGB, но Compose работал в старом пространстве и обрезал лишнее: насыщенный брендовый оттенок доезжал до пользователя блёклым. Теперь цвета и шейдеры уходят на отрисовку как есть, а если платформа пространство не поддерживает — например, OkLab на старом API, — Compose откатится к sRGB.
Есть в релизе и вещи попроще. Вариативные шрифты научились загружаться на лету, Grid получил именованные области — раскладку описываешь словами, а не индексами строк и колонок, как в CSS Grid. А связка с Credential Manager, через который проходят passkeys, обзавелась готовыми точками входа вместо сборки через Activity.
А ещё Modifier.onFirstVisible() пометили устаревшим. Вместо него теперь Modifier.onVisibilityChanged() — он отслеживает порог видимости точнее.
Если проект уже на AGP 9, обновляйтесь смело: mesh-градиенты и широкий цвет закрывают то, ради чего дизайнеры просят «как в макете, но у нас так не умеет». Если сидите на AGP 8, спешить некуда — Compose 1.11 продолжает получать патчи, а тулчейн лучше поднимать отдельно от продуктовых задач.
#mobilevkhub #composeSwift export в Kotlin 2.4: общий код без прослойки из Objective-C
В KMP давно живёт неприятная асимметрия. Android-разработчик подключает общий модуль и пишет обычный Kotlin. iOS-разработчик получает фреймворк, прошедший через Objective-C, и видит в Xcode совсем не то, что написано в общем коде.
Виноват путь, по которому Kotlin попадал в Swift. Компилятор собирал заголовки Objective-C, Swift читал их и достраивал свою модель — и на каждом шаге что-то терялось.
Int? превращался в обёртку KotlinInt, потому что в Objective-C примитив не бывает nil. Перегруженные функции получали суффиксы к именам, чтобы не столкнуться друг с другом.
Пакеты схлопывались в плоский список с подчёркиваниями. Suspend-функции приезжали колбэками, а Flow в Swift вообще не существовал, под него писали обёртки руками или тащили отдельные библиотеки интеропа.
В Kotlin 2.4 Swift export дошёл до альфы и убирает Objective-C из этой цепочки. Компилятор генерирует Swift-код напрямую.
kotlin {
swiftExport {
moduleName = "Shared"
flattenPackage = "com.example.shared"
}
}
Int? остаётся Int?. Перегрузки вызываются по-человечески. Пакеты Kotlin превращаются во вложенные Swift-энумы, а flattenPackage срезает общий префикс, если он только мешает читать.
Больше всего повседневную работу меняют корутины. Suspend-функция вызывается из Swift как обычная асинхронная, а Flow<String> приезжает как AsyncSequence с элементом String — по нему сразу работает for await. Настраивать ничего не нужно, так работает по умолчанию.
Заодно в 2.4 появилась обратная дорога: Swift-пакеты объявляются зависимостями KMP-модуля прямо в Gradle. Раньше связку с SwiftPM собирали вручную на стороне Xcode, теперь она описывается там же, где остальные зависимости — и уходить с CocoaPods стало проще.
При этом у альфы есть ограничение. Swift export работает только при прямой интеграции фреймворка. Если проект интегрирует KMP через CocoaPods или подключает KMP‑фреймворк как binaryTarget в SwiftPM, включить Swift export не получится. На этой схеме сидит большинство существующих проектов, так что пробовать удобнее на новом модуле, чем на боевом.
API ещё будет ломаться между версиями, а часть возможностей доезжает по ходу: sealed-иерархии и наследование Kotlin-интерфейсов на стороне Swift появились уже в 2.4.20.
#mobilevkhub #kotlinNavigation 3: back stack, которым наконец владеете вы
Старая навигация в Compose всегда была компромиссом. Библиотеку спроектировали семь лет назад под фрагменты, и Compose она увидела уже взрослой — отсюда
NavController, который прячет стек экранов внутри себя, и строковые маршруты вместо типов. Чтобы узнать, что лежит в истории, приходилось спрашивать разрешения у библиотеки.
В ноябре 2025 вышла Navigation 3 и перевернула эту модель. Стек экранов стал обычным списком, который живёт в вашем состоянии. Добавляете экран — добавляете элемент, идёте назад — убираете последний. Библиотека на список только смотрит и рисует то, что в нём лежит.
@Serializable data object Home : NavKey
@Serializable data class Detail(val id: String) : NavKey
val backStack = rememberNavBackStack<NavKey>(Home)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Home> { HomeScreen(onOpen = { backStack.add(Detail(it)) }) }
entry<Detail> { key -> DetailScreen(key.id) }
}
)
Экраны здесь — сериализуемые объекты с интерфейсом NavKey, а не строки с параметрами. Компилятор проверит, что Detail получил объявленные поля, а rememberNavBackStack восстановит стек после смерти процесса.
Теперь про декораторы, потому что с ними легко ошибиться. rememberViewModelStoreNavEntryDecorator привязывает жизненный цикл ViewModel к записи в стеке: убрали экран — вью-модель очистилась. Забудете его добавить, и вью-модели доживут до конца Activity, утащив с собой состояние экранов, с которых пользователь ушёл десять минут назад. В Nav2 это делалось само.
Широкие экраны обслуживает механизм Scenes: он решает, сколько элементов стека показать одновременно. Готовую стратегию под список и детали подключает rememberListDetailSceneStrategy, а экраны помечаются метаданными вроде ListDetailSceneStrategy.listPane(). Телефон покажет одну панель, планшет развернёт две — из того же стека, без второй ветки навигации.
Есть пара мест, где придётся повозиться. Глубокие ссылки собираются руками, аналога deepLink из Nav2 пока нет — рецепты Google выложил в отдельный репозиторий. И нижняя навигация с сохранением состояния вкладок требует своего стека на каждую вкладку.
Совместимости с Nav2 нет, так что модуль придётся переписать целиком. К релизу Google выпустил гайд по миграции и предложил скормить его агенту в Android Studio. Nav2 при этом продолжает получать патчи, гнать никто не заставляет.
А вы уже пробовали Nav3?
#mobilevkhub #kotlin+5
Swift Testing: как Apple переписала тесты на макросах
XCTest появился в 2013 году, ещё до Swift, и родословная видна в любом тестовом файле: класс-наследник
XCTestCase, метод с обязательным префиксом test, вызов одной из четырёх десятков функций XCTAssert.
Swift Testing приехал с Xcode 16 и Swift 6 и пересобрал модель целиком. Тесты стали функциями, наборы — типами, настройка — трейтами, сорок ассертов схлопнулись в два макроса, а параллельный запуск включён по умолчанию.
➡️ В карточках: синтаксис, диагностика падений, параметризованные тесты, что ломается при параллельном прогоне и до какой границы вообще доходит миграция.
#mobilevkhub #swiftSwift 6.4 — релиз, который убирает то, к чему все успели привыкнуть
Swift в этом году обновился дважды: 6.3 в марте с официальным Android SDK, и 6.4 на июньском WWDC внутри Xcode 27. Второй релиз скромнее по содержанию — ни крупных нововведений, ни ломающих изменений. Он состоит из мелких правок там, где годами приходилось писать обходные пути, причём настолько давно, что многие перестали считать это проблемой.
Самая заметная из них живёт в проверках доступности. Если приложение крутится на пяти платформах Apple, каждая аннотация превращается в простыню:
@available(macOS 27, iOS 27, watchOS 27, tvOS 27, visionOS 27, *)
func showStatus() { }
В 6.4 это сворачивается в @available(anyAppleOS 27, *) — одно имя вместо пяти, работает и в #if. Оговорка существенная: сокращение годится для случая, когда список платформ был шумом. Если API реально приезжает в разных версиях на разных системах, платформы по-прежнему выписываются поимённо — эта разница несёт смысл.
Следующее изменение ждали дольше. SE-0493 разрешил вызывать асинхронный код внутри defer:
func fetchData() async throws -> Data {
let connection = try await open()
defer { await connection.close() }
return try await connection.read()
}
Раньше закрытие асинхронного ресурса приходилось либо дублировать на каждом выходе из функции, либо отправлять в отдельную задачу — теряя гарантию, что очистка вообще успеет отработать. Теперь defer работает одинаково для синхронных и асинхронных ресурсов. Держать в голове стоит две вещи: await добавляет точки приостановки в самый конец функции, а отмена задачи внутри блока работает как обычно, так что очистку можно случайно оборвать на полпути.
Третье изменение адресное. Noncopyable-типы вроде Span и InlineArray упирались в обычный цикл: Sequence копирует элементы по ходу обхода, а копировать тут нечего — код не собирался. Обходили индексной арифметикой, ручным while или объявляли тип копируемым, возвращая ровно то дублирование, от которого уходили. Новый протокол Iterable заимствует элементы вместо копирования, и for-in начинает работать. Побочный выигрыш достаётся остальным: на объектах и copy-on-write типах обход больше не дёргает подсчёт ссылок.
Из мелочей, заметных в повседневной работе: withTaskCancellationShield защищает участок, который обязан доработать даже после отмены — сброс файла на диск, например. Тип теперь может явно отказаться от Sendable через ~Sendable. Класс, которому @unchecked Sendable требовался только из-за weak var, переводится на weak let и проходит нормальную проверку. any P? пишется без скобок. А компилятор начал предупреждать, когда задача молча теряет брошенную ошибку.
Отдельно стоит выигрыш, который не требует правок в коде. Перенос Foundation с Objective-C на Swift дошёл до URL: NSURL и CFURL объединены в одну реализацию, разбор адресов ускорился до четырёх раз. Обновления тулчейна достаточно.
#mobilevkhub #swiftDelegated properties в Kotlin — что стоит за
by
by lazy, by viewModels(), by remember, by Delegates.observable встречаются в любом Kotlin-проекте. За синтаксическим сахаром — один механизм: delegated properties. Понимание работы позволяет читать библиотечный код и писать свои делегаты для повторяющихся паттернов в кодовой базе.
Идея
Property делегируется другому объекту через by. Компилятор превращает чтение и запись в вызовы getValue и setValue у делегата:
class User {
var name: String by NameDelegate()
}
class NameDelegate {
private var value: String = "default"
operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
value = newValue
}
}
Для val нужен только getValue, для var — оба. Сигнатуры фиксированы: thisRef — владелец свойства, property — метаинформация.
Стандартная библиотека даёт готовые делегаты. lazy вычисляет значение при первом чтении и кеширует:
val heavyResource: Resource by lazy {
computeExpensiveResource()
}
По умолчанию thread-safe (SYNCHRONIZED). Для однопоточного контекста — lazy(LazyThreadSafetyMode.NONE), быстрее.
Delegates.observable — var с колбэком на смену значения:
var status: Status by Delegates.observable(Status.Idle) { _, old, new ->
log("status: $old -> $new")
}
Ещё из stdlib — Delegates.vetoable (отклоняет присваивание при false-предикате) и Delegates.notNull (аналог lateinit для примитивов).
Отдельный случай — property из Map. val name: String by map компилируется в map["name"] с приведением типа. Удобно для JSON-подобных структур.
Кастомные делегаты — для повторяющихся паттернов. Частый случай — SharedPreferences с типизацией:
class StringPref(
private val prefs: SharedPreferences,
private val key: String,
private val default: String = ""
) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String =
prefs.getString(key, default) ?: default
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
prefs.edit().putString(key, value).apply()
}
}
class Settings(prefs: SharedPreferences) {
var userName: String by StringPref(prefs, "user_name")
var theme: String by StringPref(prefs, "theme", "light")
}
Присваивание пишет в prefs, чтение читает — без строки prefs.getString в каждом месте. Аналогично для DataStore, in-memory кеша с TTL, property из flag-системы.
Ещё случай — property, доступная только по роли или в состоянии. Делегат проверяет условие в getValue, кидает исключение или возвращает дефолт. Переиспользуется без копипаста.
Delegated properties — из тех фич, что сначала выглядят экзотикой, а через месяц обнаруживаются в своём же коде. Писать делегат стоит, когда одна логика getter/setter появляется в третьем месте.