概要
- あるアプリで Flutterのバージョンを上げる対応をしたら、Android版として生成したAPKのファイルサイズが倍くらいに大きくなってしまった
- 色々調べると結構複雑な問題が絡んでおり、かつドキュメントが分散していて理解がしづらかった
- 今回APKのファイルサイズが大きくなったのはネイティブライブラリが非圧縮でアーカイブされていたことが原因だったが、これ自体は問題がなく、むしろ推奨動作であることがわかった
なお、これは Flutterを使わない通常の Androidアプリにも適用される話です。
起こったこと
Flutterの バージョン1系を使っていたあるアプリをバージョン2系を使うようにしたところ、生成されたAPKのサイズが倍くらいになってしまいました。具体的には 100MBくらいだったものが 180MBくらいになったイメージです。
APKの生成は以下のような感じで行なっていて、この処理自体は特に変更していません。
> flutter build apk --flavor production -t lib/main.dart --release
何のサイズが増えたのか?
APKはZipファイルなので、バラして中を見ることができます。または APK Analyzer というツールを使ってAPKの詳細を見ることができます。
APK Analyzerで解析してみると、 libapp.so
や libflutter.so
などのファイルサイズ(Raw File Size)が大きく増えているのがわかりました。
一方で、Download Sizeの列に書かれている数字はあまり変化がありません。
この Raw File Size や Download Size が何を意味しているのか や、 そもそもなぜこういうことが起こったのか の詳細を調べてみました。
APK Analyzer の Raw File Sizeと Download Sizeについて
説明には以下のように書かれています。
- Raw File Size
- Raw File Size represents the unzipped size of the entity on disk
- Raw File Size はエンティティ(対象のファイル)をディスク上でzip展開したサイズを表します
- Download Size
- Download Size represents the estimated compressed size of the entity as it would be delivered by Google Play
- Download Size はエンティティ(対象ファイル)が Google Play経由で配信された場合の推定圧縮サイズを表します
まずここで一つ目のトラップです。上記のうち Download Sizeの説明は合っているのですが、 Raw File Sizeの説明は間違っています。なぜ自信を持って言えるかというと、実際に APKを unzipで展開してみると、以下のようになるからです。
File name | Raw File Size | Download Size | Unzipped File Size | |
---|---|---|---|---|
before | libapp.so | 7.9MB | 7.9MB | 24.5MB |
after | libapp.so | 24.7MB | 8.5MB | 24.7MB |
みたところ Raw File Sizeの合計値がちょうど APKファイルのサイズになっているので、おそらく、Raw File Sizeの正しい説明は以下の通りです。
- Raw File Size
- Raw File Size はエンティティ(対象ファイル)がAPKファイル(Zipアーカイブ)内で占めるサイズを表します
以上を踏まえ、さらに詳細を調べます。そもそも beforeも afterも、Unzipped File Sizeはほぼ同一です。にもかかわらず、APKのファイルサイズが倍近いのはなぜでしょうか? その秘密は unzip -v コマンドを実行してみるとわかります。
【before】
> unzip -v before.apk | grep libapp.so
25662384 Defl:N 8240352 68% 00-00-1980 00:00 0926cfde lib/x86_64/libapp.so
【after】
> unzip -v before.apk | grep libapp.so
25895856 Stored 25895856 0% 01-01-1981 01:01 b3cc3560 lib/x86_64/libapp.so
beforeの方は圧縮率 68%で、3割強くらいのサイズになっていますが、一方で afterの方は圧縮率 0%、つまり、 無圧縮状態でZipファイルにアーカイブされています。
これはなぜなのでしょうか? 何のためにこういうことが起こっているのでしょうか? それを次の節で説明します。
Android における APKの推奨設定
Androidの Developerサイトに 「Reduce your app size(アプリサイズを小さくする方法)」 というページがあり、その対策の一つとして 「Avoid extracting native libraries(ネイティブライブラリの抽出を避ける)」 という節があります。
この説を引用して翻訳します。
●Avoid extracting native libraries
When building the release version of your app, package uncompressed .so files in the APK by ensuring that useLegacyPackaging is set to false in your app's build.gradle file. Disabling this flag prevents PackageManager from copying .so files from the APK to the filesystem during installation and has the added benefit of making updates of your app smaller.
●ネイティブライブラリの抽出を避ける
アプリのリリースバージョンをビルドするとき、アプリの build.gradle ファイルで useLegacyPackaging が false に設定されていることを確認して、APK 内の圧縮されていない .so ファイルをパッケージ化します。このフラグを無効にすると、インストール中に PackageManager が .so ファイルを APK からファイルシステムにコピーすることを防ぎ、アプリの更新をより小さくすることができます。
www.DeepL.com/Translator(無料版)で翻訳しました。
さて、ここで2つ目のトラップです。 この説明は 「アプリサイズを小さくする方法の一つ」 として書かれています。にもかかわらず、 「.soファイルを圧縮しないでパッケージ化しなさい」 と書かれています。なんか矛盾しているような気がしますよね?私も英語の読み方を間違えているのではないかと何度も読み直してみましたが、やはり合っています。これは一体何を言っているのでしょうか?
通常、APKのファイルサイズが大きければアプリのサイズが大きくなり、APKが小さくなればアプリのサイズは小さくなると考えられています。にもかかわらず、ここでは .soファイルは無圧縮のままにした方が小さくなる と書かれているのです。
これが今回の問題の核心部分ですので以下をよく理解してください。
なぜ .soファイルを無圧縮状態で持つとアプリサイズが小さくなるのか?
この疑問には、上記文章の後半部分にヒントがあります。
このフラグを無効(false)にすると、インストール中に PackageManager が .so ファイルを APK からファイルシステムにコピーすることを防ぎ、アプリの更新をより小さくすることができます。
この説明の逆をいうと、 useLegacyPackaging
を trueにすると、インストール時に .so ファイルをAPKからファイルシステムにコピーする ということになります。
この挙動の詳細は、 build.gradle の useLegacyPackaging
プロパティの前身である、 AndroidManifest.xmlの android:extractNativeLibs
の説明を見るとわかります。
★の部分は飛ばして、後半部分を訳すと、以下のように書かれています。
この属性は、パッケージインストーラが APK からファイルシステムにネイティブライブラリを抽出するかどうかを示します。"false" に設定すると、ネイティブライブラリは APK 内に圧縮されずに保存されます。APK のサイズは大きくなるかもしれませんが、実行時に APK から直接ライブラリが読み込まれるため、アプリケーションのロードは速くなるはずです。
extractNativeLibsのデフォルト値は、minSdkVersionと使用しているAGPのバージョンに依存します。ほとんどの場合、デフォルトの動作が希望通りであると思われるので、この属性を明示的に設定する必要はありません。
www.DeepL.com/Translator(無料版)で翻訳しました。
色々見えてきましたね。 APK(Zipファイル)に .soファイル(ネイティブライブラリ)が無圧縮状態で含まれている場合、Androidはアプリ実行時にAPKから直接 .soファイルを読み込めるため、起動が早くなる。 とあります。 逆にいうと、圧縮状態でアーカイブされている場合は Extract (抽出、解凍、展開)する必要がある ということです。
上記をまとめて図解すると以下のようになります。
つまり、右のようにAPKの中に圧縮されたネイティブライブラリ(.soファイル)が入っている状態でアプリをインストールすると、インストール時(or 初回起動時?) に .soファイルを展開してストレージ上に一時ファイルとして保持し、それをロードして起動するようになります。
一方、左のように、APK内に無圧縮のネイティブライブラリ(.soファイル)が入っている場合、システムはAPKから直接 .soファイルをメモリにロードするため、余計なストレージを消費しない、というわけです。
これが、 「.soファイルを無圧縮で持っていた方が APKファイルは大きくなるが、アプリのインストールサイズ1は小さくなる」 という秘密になります。
ここで 3つ目のトラップがあります。 先ほど紹介した 「Reduce your app size」というページなのですが、ページのURLにあるページ名と、その中身に書いてあることが矛盾しています。
- URL
- reduce-apk-size (APKサイズを縮小する)
- タイトル
- Reduce your app size (アプリサイズを縮小する=インストールサイズを小さくする)
このページに書かれている内容は後者の方、つまり、インストールサイズを小さくする方法が書かれています。にもかかわらず、URLのページ名が矛盾しています。おそらく、以前はAPKのサイズを小さくすればアプリのインストールサイズも小さくなるという前提が成り立っていたのですが、いつからかそれが常に成立するわけではなくなり、 APKファイルを大きくした方がインストールサイズは小さくなるという現象が発生してしまったため、URLのパスが意味する内容とページ内に実際に書いてある内容が一致しなくなってしまったのだと思われます
なお、先ほどの ★印の部分には、 AGP (Android Gradle Plugin) の v4.2.0 以降では、 AndroidManifest.xmlの android:extractNativeLibs
の代わりに、先ほど説明した build.gradleの useLegacyPackaging
を使うように、という注意書きがされています。
この挙動を変更したい場合は、使用しているGradleのバージョンに注意するようにしてください。
APKのサイズが大きくなると、ダウンロードに時間がかかるのでは?
APK内に無圧縮でネイティブライブラリ(.soファイル)を持つメリットは分かりましたが、でもAPKファイルサイズが大きいと、アプリのDLに時間がかかってしまうのではないか?という疑問が湧きます。
実際はそういうことはなく、ダウンロードに必要なデータ転送量は変わりません。先ほど出てきた Download Size がほとんど変化していないことがそれを表しています。なぜそんなことが起こるのでしょうか?
どうやら Google Playは APKファイルをそのまま転送しているわけではなく、ファイルを圧縮しながら転送しているようです。HTTPの Content-Encoding
ヘッダに gzip
を指定して転送するようなイメージでしょうか。 Zipで圧縮されたファイルの転送をさらに gzip
で圧縮しても通常であれば効果はありません。しかし、このAPKは .soファイルを無圧縮で内包しているため、情報の冗長性が高く、圧縮転送がちゃんと効く、というわけです。
ですので、APKファイルサイズが大きくなっても気にしなくていいよ、ということです。
もちろんこれはGoogle Playで配信している場合を指しています。自社のWebサーバーや CDNなどで直接ユーザにAPKを配信する必要がある場合は、独自に圧縮転送を行う必要がありますのでご注意ください。
Flutterにおける挙動
ここまでは Android全般におけるネイティライブラリの扱いと APKファイルサイズの関係をご説明してきました。ここからは Flutterの挙動についてです。
Flutter with FFI produces overly large APK #53738
GitHubの flutter/flutter に上がっている、issue 番号 #53738 をみてください。こちらは 2020年頃の issueですが、簡単にいうと 「objectboxというパッケージへの依存を追加すると、APKだけ3倍近く大きくなる(.aabはほとんど変化がない)」 というものです。
この中に興味深いコメントがあります。ちょっと引用してみます。
Android guidelines in general recommend distributing native shared objects uncompressed because that actually saves on device space: they can be directly loaded from the APK instead of unpacking them on device into a temporary location and then loading. APKs are additionally packed in transit - that is why you should be looking at download size (if you worry about that). If you worry about on device space you should actually keep shared objects uncompressed.
Flutter APKs by default don't follow these guidelines (we are in discussions about that) and compress libflutter.so and libapp.so - this leads to smaller APK size but larger on device size.
My guess would be that including FFI somehow flips default setting and stops compressing these libraries. @dcharkes Daco could you check this?
In which case we should definitely document this.
Android のガイドラインでは、一般にネイティブの共有オブジェクトを非圧縮で配布することを推奨していますが、これは実際にデバイスの容量を節約するためです。デバイス上で一時的な場所に解凍してから読み込むのではなく、APK から直接読み込むことができます。APKは輸送中にさらに圧縮されます。そのため、ダウンロードサイズに注目する必要があります(気になる場合)。デバイス上のスペースを気にするのであれば、実際には共有オブジェクトを非圧縮のままにしておくべきです。
FlutterのAPKはデフォルトではこのガイドラインに従わず(それについては議論中です)、libflutter.soとlibapp.soを圧縮しています - これはAPKサイズは小さくなりますが、デバイス上のサイズは大きくなります。
私の推測では、FFIを含めると、何らかの方法でデフォルトの設定を反転させ、これらのライブラリの圧縮を停止します。@dcharkes Dacoさんはこれを確認することができますか?
その場合、我々は間違いなくこれを文書化する必要があります。
www.DeepL.com/Translator(無料版)で翻訳しました。
このQiita記事の前半を読んだ方ならば、ここで意味していることが理解できると思います。
- Flutterはデフォルトで、Androidの推奨に従わず、 APK内のネイティブライブラリ(.so)を圧縮して格納している
- そのためAPKが小さくなっている
- この挙動がいいかどうかは議論中(2020年頃の話なので、今は変わっているかもしれません)
- FFIというパッケージに依存すると、そいつがその挙動を上書きし、ネイティブライブラリを 非圧縮状態でパッケージングするようになる
- そのせいでAPKファイルが大きくなる
- が、前半で述べた通りこれは問題ではなくむしろメリットである
ここに出てくるFFIというのは これ のことです。 objectboxが依存しているパッケージなので、objectboxに依存すると引きずられてアプリに取り込まれるのですが、この方の推測ではこの FFI によって Androidの APKのパッケージングの挙動が変更されたためだろうとなっています。
しかし、APKが大きくなること自体は問題ではないので、特に issueとして修正することはなく、単に ドキュメントを修正するという対応によってクローズされています。
上記対応によって書き加えられた記述は、このページの最下部にある 「Android APK size (shared object compression)」という節です。
引用します。
Android APK size (shared object compression)
Android guidelines in general recommend distributing native shared objects uncompressed because that actually saves on device space. Shared objects can be directly loaded from the APK instead of unpacking them on device into a temporary location and then loading. APKs are additionally packed in transit—that’s why you should be looking at download size.Flutter APKs by default don’t follow these guidelines and compress libflutter.so and libapp.so—this leads to smaller APK size but larger on device size.
Shared objects from third parties can change this default setting with android:extractNativeLibs="true" in their AndroidManifest.xml and stop the compression of libflutter.so, libapp.so, and any user-added shared objects. To re-enable compression, override the setting in your_app_name/android/app/src/main/AndroidManifest.xml in the following way.
Android のガイドラインでは、一般的にネイティブの共有オブジェクトを非圧縮で配布することを推奨しています。共有オブジェクトは、デバイス上で一時的な場所に解凍してからロードするのではなく、APKから直接ロードすることができます。APKはさらに輸送中に圧縮されます-これがダウンロードサイズに注目する理由です。
FlutterのAPKはデフォルトでこれらのガイドラインに従わず、libflutter.soとlibapp.soを圧縮します。これはAPKのサイズは小さくなりますが、デバイス上のサイズは大きくなります。
サードパーティの共有オブジェクトは、AndroidManifest.xmlでandroid:extractNativeLibs="true "を指定してこのデフォルト設定を変更でき、libflutter.so、libapp.so、およびユーザーが追加した共有オブジェクトの圧縮を停止することが可能です。圧縮を再度有効にするには、以下の方法で your_app_name/android/app/src/main/AndroidManifest.xml の設定を上書きしてください。
www.DeepL.com/Translator(無料版)で翻訳しました。
つまり、C言語のライブラリとのインターフェースのために FFIを使用すると、Flutterの挙動が上書きされ、Android推奨の動作(ネイティブライブラリを非圧縮でアーカイブする動作)をするようになるので、もし嫌だったら自分で設定を上書きして圧縮するようにしてね、ということです。
私のプロジェクトで起こったこと
ようやく冒頭の問題に戻ってきました。私のプロジェクトでは Flutterのバージョンを上げた際に APKのサイズが肥大化しましたが、これは一体何の設定変更が影響したのでしょう?
FFIのような新規に依存が増えたライブラリがあるわけではなかったのですが、上記を踏まえて調査したところ、 バージョンアップに伴って Gradleのバージョンを 3.3.3から 4.2.2にアップデートしたことが原因 とわかりました。
前半で説明した通り、AGP (Android Gradle Plugin) の v4.2.0以降、useLegacyPackagingプロパティが使えるようになりましたが、 このプロパティの設定はデフォルトで falseである というのがキモです。Gradleをバージョンアップしたことで、知らず知らずのうちに useLegacyPackaging=false
を指定した動作をするようになった、というわけです。
実際、明示的に userLegacyPackaging=true
にしてみたところ、従来通りにAPKファイルサイズが小さくなりました。
(が、上記で述べた通り、ネイティブライブラリを無圧縮でアーカイブし、APKが大きい方がメリットがあるので、今後は false の設定で行くことになりました)
まとめ
色々な話が入り組んでいてわかりづらかったと思いますが、まとめると以下のようになります。
- Gradleのバージョンを 4.2.0以上に上げたことで、APKに含まれるネイティブライブラリ (.soファイル) は無圧縮で保持されるようになった
- 上記動作は Androidの 推奨動作として規定されているもので、このほうが最終的に消費するストレージ容量は少なくて済み、起動も速くなると考えられている
- たとえAPKのファイルサイズが大きくなったとしても、Google Playで配信する際は APK自体を圧縮転送してくれれうため、転送量、ダウンロード時間的にもデメリットにはならない
- (以前の) Flutteは上記 Androidの推奨設定に従わず2、ネイティブライブラリ (.soファイル) を圧縮してアーカイブしようとするため、APKファイルが小さく見えていた 3
以上です。
もし 「突然APKファイルが倍くらいになっちゃった!どうしよう!?」 となって困っている方がいらっしゃいましたら参考にしてくださいませ。
関係表(おまけ)
今回の問題は、
- minSDKVersion
- Gradleのバージョン
- AndroidManifest.xmlの設定
- build.propertiesの設定
に依存しています。これの関係を表にまとめてみました。(一部、ドキュメントからの推測を含むので実際にやってみたら違う結果が出る可能性があります)
minSdkVersionが 23未満 (Android 6.0未満)
APK内にあるネイティブライブラリを抽出せずにAPKから直接ロードするという仕組み自体が導入されたのが Android 6.0からになります。そのため、minSdkVersionが 23未満、つまり Android 5.0系をサポートしている場合は、旧来の手法(圧縮して保持する方法)しか取れず、APKは小さくなります(インストールサイズは大きくなる)
※APKはただの Zipなのでもちろん.soファイルを無圧縮で入れることはできると思いますが、Android 5.0系の場合、単にファイルサイズがデカくなるだけでメリットは皆無です
Gradleバージョンが 4.2.0未満の場合
extractNativeLibs | minSdk=23 | 24 | 25以上 |
---|---|---|---|
未設定 | 圧縮 | ? | ? |
true | 圧縮 | 圧縮 | 圧縮 |
false | 無圧縮 | 無圧縮 | 無圧縮 |
extractNativeLibsが導入された当初、デフォルト値は "true" つまり旧来通り、ネイティブライブラリは圧縮されたものが格納される動作だったようです。その後、どこかでデフォルトの挙動が変わるポイントがあるようですが、今のところどこかはまだわかっていません。
Gradleバージョンが 4.2.0以降の場合
Gradleのバージョンが 4.2.0以降になると、AndroidManifest.xmlの extractNativeLibsの代わりに、 build.gradleの useLegacyPackagingの方が挙動を制御するようになります。
useLegacyPackaging | minSdk=23 | 24 | 25以上 |
---|---|---|---|
未設定 | 無圧縮 | 無圧縮 | 無圧縮 |
true | 圧縮 | 圧縮 | 圧縮 |
false | 無圧縮 | 無圧縮 | 無圧縮 |
useLegacyPackaging
の説明を見ると、未設定かつ minSdkVersionが 23以上ならば無圧縮で保持すると書かれていますので、上記動作をすると思われます。
-
インストールサイズという用語が適切か分かりませんが、アプリをインストールすることによって消費されるストレージ容量のことを指しています。アプリのフットプリント(ストレージフットプリント)などと呼ばれることもあります。 ↩
-
Flutterが明示的に圧縮しようとしているというよりも、Flutterが 生成した AndroidManifest.xmlに
extractNativeLibs=false
が明示的に指定されていない(もしくは trueと書かれている?)、ということを言っているのかなと思っています ↩ -
最新版ではデフォルトの挙動が変わっている可能性があります(特に Gradleを 4.2.0以上にした場合) ↩