2016 年のサンタチームは、新しいコンテンツを導入しつつ、今まで以上に小さく効率的なアプリにするという難題に挑戦しました。 本投稿では、この「サンタを追いかけよう」をさらにスリムで高速にする道のりについて紹介します。
APK の肥大化
「サンタを追いかけよう」には、さまざまなゲームや対話型シーンで使用するイメージやオーディオなどのアセットが何年にもわたって追加され続けています。 2015 年版の「サンタを追いかけよう」の APK サイズは 66.1 MB でした。
なぜ 2015 年版のアプリがここまで大きくなっているのかを調べたい場合は、Android Studio APK アナライザー ツールが非常に便利です。
まず、APK サイズは 66.1 MB ですが、ダウンロード サイズは 59.5 MB であることがわかります。このサイズの大部分はリソース フォルダが占めていますが、アセットやネイティブ ライブラリもかなりのサイズを占めています。
2016 年版のアプリには、2015 年版のアプリに含まれているものがすべて含まれており、さらに 4 つのまったく新しいゲームが追加されています。 当初、これらを追加しつつアプリを小さくするのは不可能ではないかと思われましたが、(ネタバレ注意!)2016 版の最終的なサイズは次のようになっています。
4 つの新しいゲームが追加され、見た目も更新されているにもかかわらず、アプリのダウンロード サイズは、10 MB 近く小さくなっています。本セクションの以降の部分では、どのようにこれを実現したのかについて見てゆきます。
APK の分割による Google Play での複数 APK のサポート
2015 年版のアプリには、Google の
Fun Propulsion Labs チームによる 「Snowdown」ゲームが追加されています。 このゲームは C++ で書かれているので、ネイティブ ライブラリとして「サンタを追いかけよう」に組み込まれています。 このチームからは、armv5、armv7、x86 の各アーキテクチャ向けにコンパイルしたライブラリを受け取りました。 それぞれのバージョンが約 3.5 MB であったため、2015 年版の APK の lib エントリには最大で 10.5 MB が追加されていました。
各端末はこれらのアーキテクチャのいずれか 1 つしか使わないので、容量削減のためにネイティブ ライブラリの 3 分の 2 は削除できます。ただし、ここでトレードオフとなるのは、複数の APK を公開しなければならなくなることです。 Android の gradle ビルドシステムは、各アーキテクチャ(ABI)向けの APK ビルドを
ネイティブ サポートしています。これは、次のようにアプリの build.gradle ファイルに数行設定を追加するだけで利用できます。
ext.abiList = ['armeabi', 'armeabi-v7a', 'x86']
android {
// ...
splits {
abi {
// ABI 分割の有効化
enable true
// snowdown でサポートする 3 つのアーキテクチャを含める
reset()
include(*abiList)
// どの端末でも実行できる「ユニバーサル」APK もビルド
universalApk true
}
}
}
|
分割を有効にする場合は、Play Store で共存できるように異なるバージョン コードを生成する必要があります。
// APK バリアントごとに異なる versionCode を生成: ZXYYSSSSS
// Z はメジャー バージョン番号
// X はマイナー バージョン番号
// YY はパッチ バージョン番号
// SSSS は分割についての情報(デフォルトは 0000)
// 新しいバリエーションは先頭に追加される
import com.android.build.OutputFile;
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
// abi を 8 桁シフト
def abiFilter = output.getFilter(OutputFile.ABI)
int abiVersionCode = (abiList.indexOf(abiFilter) + 1)
// すべてのバージョン コードを結合
output.versionCodeOverride = variant.mergedFlavor.versionCode + abiVersionCode
}
}
|
最新版の「サンタを追いかけよう」では、armv5、armv7、x86 向けのバージョンがそれぞれ公開されています。 この変更によって、どの機能も犠牲にすることなく、10.5 MB あった各バリアントのネイティブ ライブラリを約 4 MB に縮小することができました。
イメージの最適化
「サンタを追いかけよう」APK の大部分はイメージ リソースです。それぞれのゲームには数百個のイメージがあり、さまざまな画面密度に対応できるように、異なるサイズのイメージが格納されています。これらのイメージはほぼすべて PNG です。過去数年間はすべてのファイルに対して PNGCrush を実行することで最適化は完了したものと考えていました。 しかし、2016 年度版の開発にあたり、ロスレス PNG 圧縮が以前よりも進化していることを発見しました。現在この分野で最先端のツールは Google の
zopfli です。
すべての PNG アセットに zopflipng を実行したところ、画質を下げることなく大半のイメージのサイズを 10% ほど縮小できました。中には 30% ほど小さくなったものもあります。これにより、画質を犠牲にすることなく、アプリのサイズを 5 MB 近く削減することに成功しました。たとえば、次のサンタのイメージは 10 KB から 7KB に圧縮されていますが、画質はまったく低下していません。 表示されるイメージは完全に同じなので、違う部分を見つけようとしても無駄です!
使われてないリソース
エンジニア チームでは「サンタを追いかけよう」の開発にあたり、毎年アプリをリファクタリングして、過去のロジックや UI の見直しを行っています。コードを見直したり lint を使用することで使われていないコードは比較的簡単に発見できますが、使われていないリソースは見落とされがちです。 さらに、リソース向けの ProGuard のようなツールは存在しないため、使われていないイメージなどのリソースがアプリに紛れ込むのをツールチェーンで防ぐことはできません。
実際には使われていないにもかかわらず APK を肥大化させているリソースを見つけるには、Android Studio を使うと便利です。 Android Studio で「Analyze 」>「Run Inspection by Name」>「Unused Resources」をクリックすると、既存のどのコードパスからも使われていないリソースが特定されます。 ただし、使われていないコードの中で「使われている」リソースは未使用のものとして検知されないため、使われていないコードを先にすべて削除しておくことが重要になります。
Android Studio の便利なツールを使って何サイクルか分析を重ねることで、実際には使われていないファイルを数十個見つけることができ、アプリからさらに数 MB 分のリソースを削除することができました。
メモリ使用量
「サンタを追いかけよう」は世界中のユーザーに広まっていて、何千台もの Android 端末上でプレイされています。 しかし、512 MB 以下の RAM しか搭載していない数年前の端末も多く、ゲーム中に OutOfMemoryErrors が発生するケースが以前から報告されていました。
上記の最適化によってディスク上の PNG は小さくなっているものの、Bitmap を読み込んだときのメモリ使用量は変わりません。 「サンタを追いかけよう」の各ゲームでは、それぞれ数十個のイメージが読み込まれるため、すぐにメモリ使用量が危険領域に到達してしまいます。
2015 年版では、クラッシュのトップ 10 のうち 6 つがメモリに関連するものでした。しかし、以下の最適化(とその他の取り組み)によって、メモリ関連のクラッシュはトップ 10 から完全になくなりました。
縮退版のイメージを読み込む
通常、「サンタを追いかけよう」に含まれるゲームを初期化する際には、ゲームをスムーズに実行できるように、必要なすべての Bitmap を最初のシーンでメモリに読み込んでいます。 もっとも単純なアプローチは次のようなものです。
private LruCache<Integer, Drawable> mMemoryCache;
private BitmapFactory.Options mOptions;
public void init() {
// キャッシュの初期化
mMemoryCache = new LruCache<Integer, Drawable>(240);
// ビットマップ サンプリングなしで起動
mOptions = new BitmapFactory.Options();
mOptions.inSampleSize = 1;
}
public void loadBitmap(@DrawableRes int id) {
// ビットマップの読み込み
Bitmap bmp = BitmapFactory.decodeResource(getResources(), id, mOptions);
BitmapDrawable bitmapDrawable = new BitmapDrawable(getResources(), bmp);
// キャッシュに追加
mMemoryCache.put(id, bitmapDrawable);
}
|
しかし、RAM の空き容量が十分でないために Bitmap をメモリに読み込めない場合は、decodeResource 関数が OutOfMemoryError をスローします。 これを防ぐには、次のようにこのエラーをキャッチし、段階的にサンプリング レートを上げて(毎回 2 倍ずつ上げて)すべてのイメージを読み込み直します。
private static final int MAX_DOWNSAMPLING_ATTEMPTS = 3;
private int mDownsamplingAttempts = 0;
private Bitmap tryLoadBitmap(@DrawableRes int id) throws Exception {
try {
return BitmapFactory.decodeResource(getResources(), id, mOptions);
} catch (OutOfMemoryError oom) {
if (mDownSamplingAttempts < MAX_DOWNSAMPLING_ATTEMPTS) {
// サンプリング レートを 2 倍上げる
mOptions.inSampleSize *= 2;
mDownSamplingAttempts++;
}
}
throw new Exception("Failed to load resource ID: " + resourceId);
}
|
このテクニックを使うと、メモリの少ない端末では粗い画像が表示されますが、それと引き替えにビットマップの読み込みに関連するメモリエラーをほぼ完全になくすことができます。
透過ピクセル
前述のように、ディスク上のイメージサイズは必ずしもメモリ使用量の指標にはなりません。その良い例は、巨大な透過部分を持つイメージです。 PNG 圧縮を行うと、このような透過部分のディスクサイズはほぼゼロになりますが、透過ピクセルも通常のピクセルと同じ量の RAM を使用します。
たとえば、「Dasher Dancer」ゲーム内のアニメーションでは、各フレームに 1280x720 の PNG イメージを使用しています。 アニメーション化されているオブジェクトが画面外に移動すると、大部分のフレームが透明になります。 そこで、透明な領域をすべて切り捨てつつ、全体では 1280x720 の見かけを維持できるように、各フレームを表示する際の「
オフセット」を記録するスクリプトを書きました。その結果、あるテストでは、このゲームのランタイムにおける RAM 使用量が 60 MB も削減されました。透過ピクセルによる無駄なメモリ消費が抑えられたので、イメージのダウンスケールを行う必要性も減り、メモリ容量が少ない端末でも高い解像度のイメージを使用できるようになりました。
その他の取り組み
以上で説明した主な最適化の他にも、アプリを小さく高速にするための細かい取り組みをいくつか行っており、さまざまなレベルで成功を収めています。
スプラッシュ画面
2015 年版のアプリではマテリアル デザインを採用し、中央に表示される「カード」の一覧からゲームを起動するように仕様変更を行いました。半数近くのゲームで起動時にカードが「ちらつき」、不自然な表示になる現象が見られましたが、その根本原因はわからず、問題を修正できていませんでした。
2016 年版アプリの開発を進める際には、ゲーム起動時に表示がおかしくなるこの問題を修正することを決めていました。何時間もの調査の結果、画面の向きが横向きに固定されているゲームだけでこの現象が発生することがわかりました。 強制的に画面の向きを変更することにより、一部のフレームが欠けてしまっていたのです。そこで、スムーズなユーザー エクスペリエンスを提供するために、ランチャー アクティビティとゲーム アクティビティの間に
スプラッシュ画面をはさむことにしました。 現在は、このスプラッシュ画面が表示されている間に、端末画面の向きと読み込み中のゲームをプレイする際に必要な画面の向きを検知し、必要に応じてランタイム時に画面を回転するようになっています。 これによってゲーム起動時の画面の乱れが即座に解消され、アプリ全体がスムーズに感じられるようになりました。
SVG
リソースのサイズ削減に取りかかり始めた当初は、SVG イメージを使用すると劇的な効果が得られるのではないかと期待していました。 ベクター イメージのサイズは非常に小さく、1 つのイメージで複数の画面密度をサポートできるからです。 「サンタを追いかけよう」の見た目は「フラット」な感じなので、画質を低下させることなく多くの PNG を小さな SVG に変換できると考えていました。しかし、遅い端末では、こういった SVG の読み込みは完全に非現実的でした。パスの複雑さによっては、PNG の数十倍から数百倍アプリの動作が遅くなるからです。
最終的に、
推奨事項に従ってベクター イメージの使用はイメージサイズ 200x200 dp までに制限し、大きなグラフィックスやゲームアセットには使用せず、アプリ内の小さなアイコンにのみ SVG を使用することにしました。
まとめ
2016 年版「サンタを追いかけよう」の開発に着手する際、1 つの厄介な問題に直面することになりました。どうすれば楽しい新コンテンツを追加しつつ、アプリを高速化し、サイズも小さくできるかという問題です。以上で説明した最適化は、どうすればリソースの消費を減らしつつアプリをさらに充実させることができるかをチーム内で繰り返し議論し、それぞれの変更にまつわるリソースの制約を検討する中で発見されたものです。最終的に、「サンタを追いかけよう」アプリは、今までになく健全に動作するものになってゆきました。私たちの次の仕事は、サンタさんの
シェイプアップのお手伝いになるでしょう。
Posted by
Yuichi Araki - Developer Relations Team