目次
概要
Android 15からPixel 8以上、Galaxy S26などの新型機種はメモリページサイズを従来の4KBから16KBに切り替えて使用し始めました。AAB(Android App Bundle)内部に含まれるネイティブライブラリ(.so)が16KBアライメントを満たさない場合、Play Storeは該当端末向けにアプリを非対応と判定し、検索結果に表示されなくなります。
この記事では、Flutterプロジェクトで16KBページサイズ互換性を確保するために行った作業を整理します。
- ビルド設定でアライメント保持を明示
- AAB成果物のアライメントを自動検証する静的ゲートの構築
- 16KBエミュレーターでのランタイム動作確認
なぜ16KBページサイズが問題になるのか
Linuxカーネルは仮想メモリをページ単位で管理します。4KBページを使用する端末では、ELF(Executable and Linkable Format)バイナリのLOADセグメントが4KB(0x1000)アライメントさえ満たしていれば、メモリマッピングが可能でした。
16KBページ端末では、LOADセグメントが16KB(0x4000)アライメントを満たす必要があります。アライメントされていない.soに対してdlopen()を呼び出すと、ランタイムで次のようなエラーが発生します。
dlopen failed: "libxxx.so" is not page-aligned
Play StoreはAABアップロード時にこのアライメントを検査します。AGP 8.5+ は未アライメントのライブラリに対して警告を出力し、AGP 8.7+ ではビルド自体を失敗として処理します。それでも事前ビルドされたプラグインの.soがアライメントを満たさないケースがしばしば発生するため、CIで成果物を直接検証することが必要です。
ビルド設定の変更
AGPバージョンの確認
まず、android/settings.gradle.kts(またはandroid/build.gradle)でAndroid Gradle Pluginのバージョンが8.5以上であることを確認します。
plugins {
id("com.android.application") version "8.7.0" apply false
// ...
}
8.5未満であればアップグレードが必要です。AGP 8.5+ はビルド時に16KBアライメントを強制するため、アライメントされていないプラグインが含まれているとビルドが失敗し、どのプラグインが原因かを通知してくれます。
useLegacyPackagingの明示
android/app/build.gradle.ktsのandroid { ... }ブロックにpackaging設定を明示的に宣言します。
android {
// ...
// 16KB page-size compatibility — keep extractNativeLibs=false so AGP/Android packs
// .so files uncompressed and preserves their LOAD-segment alignment for Galaxy S26
// and other 16KB-page devices. AGP 8.5+ enforces 16KB alignment at packaging time.
packaging {
jniLibs {
useLegacyPackaging = false
}
}
}
useLegacyPackaging = falseは、.soファイルをAPK/AAB内部で圧縮せずそのままパッケージングするという意味です。圧縮しないことでZIPエントリ単位でアライメントが保持され、ランタイムでmmap()を使って直接マッピングできます。
AGP 8.0+ から既にデフォルト値がfalseですが、回帰防止のために明示的に宣言します。他のプラグインがmanifest mergerでextractNativeLibs="true"を注入する場合があり、デフォルト値に依存すると意図せず圧縮パッケージングに戻ってしまう可能性があるためです。
静的検証ゲートの構築
ビルド設定だけでは不十分です。CIでAAB成果物を直接展開してアライメントを検証するスクリプトを作成し、回帰を事前にブロックします。
検証は2段階で構成されます。
- ステージA: AAB(ZIP)内の
base/lib/arm64-v8a/*.soおよびbase/lib/x86_64/*.soのELF LOADセグメントアライメントを確認 - ステージB: bundletoolでuniversal APKを作成し、ZIPエントリ単位で16KBアライメントを確認
llvm-readobjで.soのLOADアライメント確認
llvm-readobjはELFヘッダーをパースしてLOADセグメントのAlignment値を出力します。Android NDKに同梱されているため、別途インストールは不要です。
#!/usr/bin/env bash
# verify_16kb_alignment.sh (要約)
AAB_PATH="${1:-build/app/outputs/bundle/release/app-release.aab}"
WORK_DIR="$(mktemp -d -t verify16kb_XXXXXX)"
trap 'rm -rf "${WORK_DIR}"' EXIT
# AAB(ZIP)を展開
unzip -q "${AAB_PATH}" -d "${WORK_DIR}/aab"
ISSUES=0
for abi in arm64-v8a x86_64; do
for so in "${WORK_DIR}/aab/base/lib/${abi}"/*.so; do
# LOADセグメントのAlignment値は16384(0x4000)以上である必要がある
align=$(llvm-readobj -l "${so}" \
| awk '/Type: PT_LOAD/{found=1} found && /Alignment:/{print $2; exit}')
if [ "${align}" -lt 16384 ]; then
echo "FAIL: ${abi}/$(basename ${so}) p_align=${align} (< 0x4000)"
ISSUES=$((ISSUES + 1))
else
echo "OK: ${abi}/$(basename ${so}) aligned p_align=${align}"
fi
done
done
if [ "${ISSUES}" -eq 0 ]; then
echo "RESULT: PASS"
exit 0
else
echo "RESULT: FAIL (${ISSUES} issues)"
exit 1
fi
ポイントはllvm-readobj -lで取得したPT_LOADセグメントのAlignment値が16384(0x4000)以上であるかを確認することです。arm64-v8aとx86_64の両方のABIを検査します。32-bit ABI(armeabi-v7a、x86)は16KBページ端末がサポートしないため、検査対象から除外されます。
zipalignでuniversal APKのZIPアライメント確認
ELFアライメントだけでなく、APK ZIP自体のエントリアライメントも検証します。bundletoolでuniversal APKを作成し、zipalign -P 16オプションで検査します。
# universal APKを生成
bundletool build-apks \
--bundle="${AAB_PATH}" \
--output="${WORK_DIR}/app-release.apks" \
--mode=universal \
--ks=android/key.jks --ks-pass=pass:... --ks-key-alias=...
# .apksからuniversal.apkを抽出
unzip -j "${WORK_DIR}/app-release.apks" universal.apk -d "${WORK_DIR}"
# 16KB ZIPアライメント検査 (-P 16 = ページサイズ16KB, -c = check, -v = verbose)
zipalign -c -v -P 16 4 "${WORK_DIR}/universal.apk"
-P 16はページサイズ16KB基準でアライメントを検査するという意味で、4は通常ファイルのZIPエントリアライメント単位です。zipalignの終了コードが0以外の場合、アライメントされていないエントリが存在することを意味します。
CIワークフローでは、この2つの段階をまとめてリリースビルドの事後検証ステップとして配置します。
# .github/workflows/release_android.yml (要約)
- name: Build release AAB
run: flutter build appbundle --release
- name: Verify 16KB alignment
run: bash scripts/verify_16kb_alignment.sh build/app/outputs/bundle/release/app-release.aab
もしFAILが出力された場合は、原因プラグインの上流PRをトラッキングするか、一時的なforkや代替プラグインで回避します。いずれの場合でも、CIが失敗した状態でリリースが進行しないことが重要です。
16KBエミュレーターでのランタイム検証
静的検証を通過しても、JNI呼び出し時点で16KBアライメントが壊れるケースがあり得ます(例:広告SDKが動的に.soをロードするケース)。ランタイム動作は静的には検出できないため、リリース前に1回は16KBエミュレーターで直接実行します。
16KBシステムイメージのインストール
16KBページサイズのシステムイメージのSDKパッケージ名は..._ps16kサフィックスを使用します。in_app_purchaseのような決済SDKの検証のため、Play Storeを含むバリアント(...playstore_ps16k)を推奨します。
# ライセンス未承諾の場合は1回のみ実行
"${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses
# 16KBシステムイメージのインストール (API 36 / Apple Silicon の例)
"${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --install \
"system-images;android-36;google_apis_playstore_ps16k;arm64-v8a"
# AVD作成
"${ANDROID_HOME}/cmdline-tools/latest/bin/avdmanager" create avd \
--name "Pixel_8_Pro_16KB_API36" \
--package "system-images;android-36;google_apis_playstore_ps16k;arm64-v8a" \
--device "pixel_8_pro" \
--force
Apple Silicon Macではarm64-v8a ABI、Intelホストではx86_64 ABIを選択します。
起動後のページサイズ確認
# 起動
"${ANDROID_HOME}/emulator/emulator" -avd Pixel_8_Pro_16KB_API36 &
# ページサイズ確認 — 16384が出力されれば正常
adb shell getconf PAGE_SIZE
16384ではなく4096が出力された場合は、通常の4KB AVDを起動しています。システムイメージパッケージ名に_ps16kが含まれているか再度確認します。
リリースAABのインストールと動作確認
bundletoolでuniversal APKを生成し、16KBエミュレーターにインストールします。
# universal APKを生成 (release署名を使用)
bundletool build-apks \
--bundle=build/app/outputs/bundle/release/app-release.aab \
--output=build/app/outputs/bundle/release/app-release.apks \
--mode=universal \
--ks=android/key.jks --ks-pass=pass:... --ks-key-alias=...
# インストール
bundletool install-apks --apks=build/app/outputs/bundle/release/app-release.apks
インストール後、以下の項目をチェックリストとして確認します。
- アプリ起動(スプラッシュ → ホーム画面遷移)
- DBクエリ動作(sqlite3ネイティブライブラリ)
- TTS再生(
flutter_tts)と音声認識(speech_to_text) - 広告読み込み(
google_mobile_ads) - 課金画面への遷移時の商品一覧読み込み(
in_app_purchase) - Firebase Analytics / Crashlytics / Performance の初期化
Logcatでクラッシュやnot page-alignedメッセージがないかをモニタリングします。
adb logcat -v time | grep -E "(FATAL|ANR|page-size|JNI|ELF)" --color=never
FATAL EXCEPTION、ANR、dlopen failed、not page-alignedメッセージが5分以上の通常使用中に出現しなければ通過と判断します。
Play Console側の事前検証
実際のリリースなしにPlayコンソールまで正常に通過するかを事前に検証できます。Fastlaneのsupply --validate-only経由でAABを送信すると、実際のリリース/トラック変更なしに次の項目が検証されます。
- AAB署名(keystore aliasの整合性)
applicationIdの整合性versionCode衝突チェック- 16KBアライメントのPlay側enforcement(静的ゲートのダブルチェック)
- track / metadataの整合性
# android/fastlane/Fastfile
lane :validate_release do
upload_to_play_store(
aab: "../build/app/outputs/bundle/release/app-release.aab",
track: "production",
validate_only: true,
)
end
cd android && bundle exec fastlane validate_release
成功時はSuccessfully finished the upload to Google Playが出力され、実際のアップロードは発生しません。失敗時は明確な理由(例:versionCode 1234 has already been used)と共に終了します。リリースワークフローがタグpush時に遭遇するエラーを事前に特定できるので有用です。
まとめ
Galaxy S26など16KBページサイズの端末でFlutterアプリがPlay Storeに正常に表示されるようにするための作業を整理すると次のようになります。
まとめ:
- AGP 8.5+ を使用し、
packaging.jniLibs.useLegacyPackaging = falseをbuild.gradle.ktsに明示的に宣言して回帰防止 - ビルド設定だけを信頼せず、CIでAAB成果物のELF LOADアライメントとZIPエントリアライメントを直接検査するゲートを構築
llvm-readobj -lでPT_LOADセグメントのAlignment ≥ 0x4000を確認- bundletool +
zipalign -P 16でuniversal APKを検証
- ランタイム動作(JNI / 広告 / 課金 / Firebase)は静的検証では捉えられないため、リリース前に16KBエミュレーター(
_ps16kシステムイメージ)で1回手動検証 - Play Console enforcementはFastlane
validate_onlyで事前にdry-run可能
新型端末が16KBページサイズを採用する流れはしばらく続くでしょう。ビルド設定 + 静的ゲート + ランタイムSOPの3層を一度整備しておけば、新型端末がリリースされるたびにリリースが揺れる事態を防げます。
関連資料
私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!
アプリ広報
Dekuが開発したアプリを使ってみてください。Dekuが開発したアプリはFlutterで開発されています。興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。