Google Play и 16KB Page Size: как проверить APK/AAB до загрузки и найти проблемные native

В 2025 году Google Play начал постепенно вводить новые требования для приложений, ориентированных на Android 15+ (targetSdk 35). Одно из самых неприятных — совместимость с устройствами, использующими 16 KB страницы памяти (16KB page size).

На практике это выливается в ситуацию:

  • приложение собирается нормально,
  • локально работает,
  • но при публикации в Google Play появляется ошибка, что приложение не поддерживает 16KB page size,
  • и виноваты… нативные библиотеки .so, которые лежат внутри APK/AAB.

В этой статье — как быстро проверить сборку до загрузки в Play, найти проблемные .so и автоматизировать проверку через Gradle.


Что такое 16KB page size и почему это ломает публикацию

Раньше подавляющее большинство Android-устройств работали с размером страницы памяти 4 KB. Но на части новых устройств (особенно на Android 15 и новее) используется 16 KB.

Если ваше приложение содержит нативный код (NDK, движки, SDK, ML, игры, и т.д.), то в APK/AAB попадают .so файлы.

И вот важный момент:

Совместимость определяется не “zipalign”, не настройками Gradle и не версией targetSdk, а тем, как собраны ELF-библиотеки.

У .so есть параметр выравнивания сегментов загрузки (LOAD Align), который можно увидеть через readelf.

Если .so собрана с Align = 0x1000 (4 KB) — она может быть несовместима с 16 KB page size и Google Play заблокирует публикацию.


Какие ABI реально важны

Требование 16 KB page size актуально в первую очередь для 64-битных ABI:

  • arm64-v8a
  • x86_64

А вот 32-битные ABI (armeabi-v7a, x86) чаще всего не критичны (и реальные устройства на них почти всегда 4 KB). Но в зависимости от конкретной проверки Play Console, иногда ошибки могут показываться и по 32-битным библиотекам.

В своей проверке я в итоге оставил только 64-битные ABI — так результат соответствует реальной проблеме.


Почему “вчера опубликовалось, а сегодня не проходит”

Очень частый кейс:

  • старый релиз уже опубликован,
  • вы делаете небольшой фикс,
  • собираете обновление,
  • и вдруг Google Play начинает ругаться.

Это происходит потому что:

  • Play постепенно усиливает проверки,
  • или вы обновили targetSdk / AGP,
  • или обновились зависимости,
  • или просто новая сборка подтянула другие .so.

Особенно часто такое случается с:

  • игровыми движками (libGDX, Unity, Godot),
  • ML/vision SDK,
  • рекламными SDK,
  • “готовыми” AAR/JAR, внутри которых лежат .so.

Как понять, какие .so реально проблемные

Для проверки нужны два инструмента:

Вариант A: readelf (Linux/macOS)

На Linux он обычно есть сразу. На macOS можно поставить через brew.

Вариант B (универсальный): llvm-readelf из Android NDK

Это самый удобный вариант, потому что:

  • работает на Windows,
  • работает стабильно,
  • всегда есть, если установлен Android NDK.

Что считать “валидным” Align

Google говорит про совместимость с 16 KB.

Значит корректные значения выравнивания:

  • 0x4000 (16 KB) ✅
  • 0x8000 (32 KB) ✅
  • 0x10000 (64 KB) ✅

То есть правило простое:

Align должен быть не меньше 0x4000 и кратен 0x4000.

А вот значения:

  • 0x1000 (4 KB) ❌
  • 0x2000 (8 KB) ❌

— это проблема.


Быстрая проверка APK/AAB (Python-утилита)

Я сделал небольшой скрипт, который:

  1. распаковывает APK или AAB
  2. находит все .so
  3. проверяет LOAD Align через llvm-readelf
  4. выводит список проблемных библиотек
  5. завершает работу с ошибкой (можно использовать в CI)

Скачать скрипт

👉 Ссылка на скачивание:
app_checker.py


Пример запуска

python app_checker.py app-release.apk

Вывод в случае проблемы выглядит так:

16KB page-size check failed:
[bad align=0x1000] artifact=app-release.apk abi=arm64-v8a file=lib/arm64-v8a/libgdx.so
[bad align=0x1000] artifact=app-release.apk abi=x86_64 file=lib/x86_64/libgdx-freetype.so

Что делать, если нашли 0x1000

Если проблема в ваших собственных .so (вы собираете NDK-код сами) — решение обычно:

  • обновить NDK до свежей версии,
  • пересобрать .so.

Если проблема в сторонней библиотеке — варианты:

  • обновить SDK/движок до версии, где .so уже пересобраны под 16KB,
  • или заменить библиотеку,
  • или (в редких случаях) собрать natives самостоятельно.

Автоматическая проверка в Gradle (до загрузки в Google Play)

Ручная проверка полезна, но лучше автоматизировать всё так, чтобы сборка падала сразу, если в APK/AAB попали несовместимые .so.

Я добавил Gradle task check16kPageSize, который:

  • ищет release APK/AAB,
  • распаковывает,
  • проверяет .so через llvm-readelf из Android NDK,
  • проверяет только arm64-v8a и x86_64,
  • падает с понятным списком проблемных файлов.

Требования

Чтобы Gradle-скрипт работал, нужно:

  • установленный Android NDK
  • и чтобы Gradle мог найти llvm-readelf

Обычно NDK уже установлен через Android Studio:

Tools → SDK Manager → SDK Tools → NDK (Side by side)


Gradle task check16kPageSize

Вставьте код в android/build.gradle (или в модуль, который собирает APK/AAB).

task check16kPageSize {
    group = "verification"
    description = "Checks all .so in release APK/AAB and verifies LOAD Align is 16KB-compatible for 64-bit ABIs using NDK llvm-readelf."

    doLast {
        def llvmReadelf = resolveLlvmReadelf()
        def outputsRoots = [file("$buildDir/outputs"), file("$projectDir/release")].findAll { it.exists() }
        def artifacts = files(outputsRoots.collect { root ->
            fileTree(root) {
                include "**/*.apk"
                include "**/*.aab"
            }
        }).files.findAll { it.isFile() }.sort { a, b -> a.absolutePath <=> b.absolutePath }

        artifacts = artifacts.findAll {
            it.name.toLowerCase().contains("release") || it.parentFile.name.toLowerCase().contains("release")
        }

        if (artifacts.isEmpty()) {
            def searched = [file("$buildDir/outputs"), file("$projectDir/release")]*.absolutePath.join(", ")
            throw new GradleException("No release APK/AAB found. Searched: ${searched}")
        }

        def errors = []
        def tmpRoot = file("$buildDir/tmp/check16kPageSize")
        tmpRoot.mkdirs()

        // Check only 64-bit ABIs
        def allowedAbis = ["arm64-v8a", "x86_64"] as Set

        artifacts.each { artifact ->
            def unpackDir = new File(tmpRoot, artifact.name + "-" + artifact.lastModified())
            if (unpackDir.exists()) unpackDir.deleteDir()
            unpackDir.mkdirs()

            copy {
                from zipTree(artifact)
                into unpackDir
            }

            def soFiles = fileTree(unpackDir) { include "**/*.so" }.files
            soFiles.each { soFile ->
                def relPath = soFile.absolutePath.substring(unpackDir.absolutePath.length() + 1).replace('\\', '/')
                def abiMatcher = (relPath =~ /(arm64-v8a|armeabi-v7a|x86_64|x86)/)
                def abi = abiMatcher.find() ? abiMatcher.group(1) : "unknown"

                if (!allowedAbis.contains(abi)) {
                    return
                }

                def stdout = new ByteArrayOutputStream()
                def execResult = exec {
                    commandLine llvmReadelf.absolutePath, "-lW", soFile.absolutePath
                    standardOutput = stdout
                    errorOutput = stdout
                    ignoreExitValue = true
                }

                if (execResult.exitValue != 0) {
                    errors << "[readelf failed] artifact=${artifact.name} abi=${abi} file=${relPath}"
                    return
                }

                def output = stdout.toString("UTF-8")
                output.eachLine { line ->
                    def load = (line =~ /^\s*LOAD\s+.*\s(0x[0-9a-fA-F]+)\s*$/)
                    if (load.matches()) {
                        def alignStr = load.group(1).toLowerCase()
                        long alignVal = Long.parseLong(alignStr.substring(2), 16)

                        // Valid if >= 0x4000 and multiple of 0x4000
                        if (alignVal < 0x4000L || (alignVal % 0x4000L) != 0L) {
                            errors << "[bad align=${alignStr}] artifact=${artifact.name} abi=${abi} file=${relPath}"
                        }
                    }
                }
            }
        }

        if (!errors.isEmpty()) {
            throw new GradleException("16KB page-size check failed:\n" + errors.join("\n"))
        }

        logger.lifecycle("check16kPageSize: OK (${artifacts.size()} artifact(s) scanned)")
    }
}

tasks.matching { it.name in ["assembleRelease", "bundleRelease"] }.configureEach {
    finalizedBy check16kPageSize
}

Как запускать

После сборки релиза:

./gradlew assembleRelease

или

./gradlew bundleRelease

проверка выполнится автоматически.

Можно запускать и отдельно:

./gradlew check16kPageSize

Почему проблема чаще всего в сторонних SDK

Самое неприятное в этой истории:

  • вы можете обновить Gradle, AGP, targetSdk
  • но если в вашем APK лежит .so, собранная сторонним SDK с Align = 0x1000, то ничего не изменится.

Именно поэтому эта проблема массово бьёт по:

  • libGDX (особенно libgdx.so и libgdx-freetype.so)
  • старым версиям TensorFlow Lite / ML Kit
  • рекламным SDK
  • любым AAR/JAR с embedded natives

Итог

Если ваше приложение содержит .so, лучше прямо сейчас:

  • ✅ прогнать проверку APK/AAB локально
  • ✅ добавить проверку в Gradle/CI
  • ✅ заранее поймать проблемные библиотеки до загрузки в Play

Потому что когда Play Console начинает ругаться — обычно дедлайн уже рядом, а менять SDK в последний момент крайне неприятно.


Полезные ссылки

  • Документация Android про page size:
    https://developer.android.com/guide/practices/page-sizes
  • Пост Android Developers Blog про переход на 16 KB:
    https://android-developers.googleblog.com/

Если статья помогла — можете написать в комментариях, с какими SDK/движками вы столкнулись с проблемой 16KB. Я добавлю в пост список “самых частых виновников” и решений.