Android 開発の落とし穴

今回は Android の話です。Javaプログラマが Androidアプリを開発する際に陥りやすい事象を4つご紹介します。

本記事はJava習得済みの方向けです。
Javaの基本や、ちょっとしたテクニック等を知りたい、という方は 弊社HP「Java 講座」をどうぞ! (↓に出てくるシングルトンの解説もありますよ)

シングルトンのはずがいなくなる件

Java 界隈では常識な、生存し続けるはずのシングルトンなインスタンス。
OutOfMemory例外さえ気をつけていれば良いと思いきや!

Android では、バックグラウンドに引っ込んでいてメモリがきつくなったら、 たとえstatic finalなインスタンスでも
メモリから無言で跡形もなくいなくなります。(えー
消えるときに自動でどこかに永続化もしてくれません。(えー!

ダメな例

public class Singleton {
    private static final instance = new Singleton();

    private int value;

    private Singleton() {
        // NOP
    }

    public static Singleton getInstance() {
        return instance;
    }

    public static int getValue() {
        return value;
    }

    public static void setValue(int val) {
        value = val;
    }
}

この書き方だと、value の値が 0 になってしまうかもしれません。
なんてこった。

大丈夫な例

解決策がこちら。
アプリケーションクラスを拡張して Application インスタンスを取得できるようにします。

public class MyApplication extends Application {
    private static Application app;

    public static Application getInstance() {
        return app;
    }

    @Override
    public void onCreate() {
        app = this;
    }
}

シングルトンクラスを変更。

public class Singleton {
    private static final instance = new Singleton();

    private int value;

    private Singleton() {
        // プリファレンスから復元しよう!
        MyApplication.getInstance().getApplicationContext().getSharedPreferences(
            "appname", Context.MODE_PRIVATE).getInt("value", 0);
    }

    public static Singleton getInstance() {
        return instance;
    }

    public static int getValue() {
        return value;
    }

    public static void setValue(int val) {
        value = val;
        // ついでにプリファレンスに保存しよう!
        MyApplication.getInstance().getApplicationContext().getSharedPreferences(
            "appname", Context.MODE_PRIVATE).edit().putInt("value", value).commit();
    }
}

これなら、プリファレンスから復元できるのでメモリから消えてしまっても大丈夫です。
プリファレンスに読み書きするオーバーヘッドが生じてしまうのは仕方なし…ということで。

どこへいったアクティビティ

Androidでは、アクティビティがバックグラウンドに引っ込んでいて、メモリがきつくなった場合、
メモリからアクティビティがいなくなります。(えー

書きかけのテキストなどが消えてしまうわけです。困ります。 こういう事態は、カメラやアルバムを開く際等に、画像展開でメモリがカツカツになってよく起きます。

ちなみに、Androidの「設定」-「開発者向けオプション」の下の方にある「アクティビティを保持しない」をオンにすると、バックグラウンドになったら問答無用でいなくなってくれるので再現に重宝します。

(開発者向けオプションは Android のバージョンにより項目が異なります。 また、デフォルトでは「設定」に表示されていません。 開発者向けオプションについてはこちらなどをご参考に。)

幸いにして、 Activity クラスには、

onSaveInstanceState(Bundle bundle)
インスタンスの保存
onCreate(Bundle savedInstanceState)
インスタンスの復帰

がある!

インスタンスの保存・復帰

EditText editTextMessage;

@override
public void onSaveInstanceState(Bundle state) {
    super.onSaveInstanceState(state);
    state.putString(KEY, editTextMessage.getText().toString());
}

@override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    if (savedInstanceState != null) {
        String messageText = savedInstanceState.getString(KEY);
        editTextMessage.setText(messageText);
    }
}

カメラ、アルバム画像の怪

1000万画素の写真をそのまま読み込むと、どれだけメモリを消費するでしょう? JPEGファイルとしては、1MBくらいかもしれませんが…

プログラム上ではJPEGやPNGのまま扱いません。 BMPにして画像処理をします。 BMPはビットマップファイル、読んで字のごとく。 1画素あたり4バイト消費するので、

4バイト * 1000万画素 = 4000万バイト(40MB)

これはキツいです。
一応、ロードするときにARGB8888以外を選べばサイズは縮小できますが、 色数が減るので汚くなります。(RGB565, ARGB4444など)

なので、原寸大のまま読み込まないこと。
縦横サイズを 1/n にして読み込む方法があるので、それを使いましょう。

画像を 1/n サイズで読み込む

// uri にカメラ画像の Uri が設定されているとします

Bitmap scaledImage = null;

// 端末の画面サイズ取得
Display display = getWindowManager().getDefaultDisplay();
DisplayMetrics metrics = new DisplayMetrics();
display.getMetrics(metrics);
Point outSize = new Point();
display.getSize(outSize);
int maxPicSize = outSize.x > outSize.y ? outSize.x : outSize.y;

try {
    // 読み込み用のオプション生成
    BitmapFactory.Options options = new BitmapFactory.Options();
    // ほかのオプションを適宜設定しつつ...
    // 画像のサイズ情報だけを取得するフラグを有効に
    options.inJustDecodeBounds = true;

    // ファイルパス取得する
    String path = uri.getPath();

    // サイズだけ読み込み、画面に収まるよう縮尺値を計算
    BitmapFactory.decodeFile(path, options);
    float sd = metrics.scaledDensity;
    int sw = Math.round((options.outWidth * sd) / maxPicSize) + 1;
    int sh = Math.round((options.outHeight * sd) / maxPicSize) + 1;
    int scale = Math.max(sw, sh);

    // 今度は予め小さくした画像を読み込みたいのでfalseを指定
    options.inJustDecodeBounds = false;
    // 縮尺値を指定
    options.inSampleSize = scale;

    // 指定されたパスの画像を取得する。
    InputStream is = getContentResolver().openInputStream(uri);
    scaledImage = BitmapFactory.decodeStream(is, new Rect(-1, -1, -1, -1), options);
} catch (Exception e) {
    // TODO エラー処理してください
    e.printStackTrace();
    return;
}

画像リソースをケチると、低スペック端末で落ちる件

Drawable リソース(画像リソース)を用意する際、 大は小を兼ねると思ってリソースの準備を怠ると痛い目を見るよ、というお話。

Android はディスプレイの Density(密度)に合わせて、 読み込む画像リソースを自動的に切り替えたり、拡大縮小したりするようになっています。 Density は 160dpi(dot per inch)を基準として下記の表のように分類されていて、 res/drawable-XXXX (XXXX は Density の種類)以下に 画像リソースを定義できます。

Density dpi 比率 備考
ldpi 〜120 x0.75 絶滅種
mdpi 〜160 x1.0 主に Android 2.x 時代の機種
hdpi 〜240 x1.5 Nexus S
xhdpi 〜320 x2.0 Galaxy Nexus, Nexus 4, Nexus 9
xxhdpi 〜480 x3.0 Nexus 5
xxxhdpi 〜640 x4.0 Nexus 6

こうした画像リソースを定義する際、たとえば drawable-xxxhdpi だけ用意したとします。

Nexus Sのようなdrawable-hdpiが本来のリソースである場合、 drawable-hdpiを用意していなくてもそれ以外の解像度のリソースを引っ張ってきます。 スケールダウンで見た目は小さすぎず、大きすぎずちょうど良く表示してくれます。

が、しかし、この場合メモリのフットプリントはガッチリ持っていかれてしまいます。 面積比で7倍以上違うので、drawable-hdpiを使うよりもメモリ7倍以上食う訳です。

というわけで結論。
大きい素材からリサイズして低解像度用のリソースも用意しましょう。

WindowsだったらRalpha(窓の杜/Vector)で一括リサイズがおすすめです。 また、PNGGauntletを使うと一括でPNGサイズを極限まで小さくできるのでAPKのサイズを縮めるのに役立ちます。

以上、JavaプログラマがAndroidアプリ開発で陥りやすい落とし穴まとめでした。