edo1z blog

プログラミングなどに関するブログです

Android 開発 マルチ画面対応の研究

Androidは多種多様な端末があり、それらはそれぞれ画面サイズやDPIといったものが全然違います。マルチ画面に対応する為のルールや手法について研究します。

研究の為の簡単なプロジェクトを作成しましました。DpiTestActivityとDpiTestViewの2つのクラスから構成されます。res/drawable-mdpiに、tw.pngを格納しています。tw.pngはtwitterのbirdのフリーのイメージで、サイズは幅70(px)x高さ85(px)です。

DpiTestActivityクラス
package com.homuhomu.dpitest;

import android.app.Activity;
import android.os.Bundle;

public class DpiTestActivity extends Activity {
 private DpiTestView dView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        dView = new DpiTestView(this);
        setContentView(dView);
    }
}
DpiTestViewクラス
package com.homuhomu.dpitest;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.view.View;

public class DpiTestView extends View {
 private Bitmap img;
 private Paint paint;

 public DpiTestView(Context context) {
  super(context);
  //paintの設定
  paint = new Paint();
  paint.setAntiAlias(true);
  paint.setTextSize(70);
  paint.setARGB(255, 255, 255, 255);
  //imgの登録
  Resources res = context.getResources();
  img = BitmapFactory.decodeResource(res, R.drawable.tw);
 }

 @Override
 protected void onDraw(Canvas c){
  c.drawBitmap(img,0,0,null); //imgの描画
  c.drawBitmap(img,70,0,null); //imgの描画
  c.drawText("A", 70, 85, paint);
 }
}


これを、AVDで起動してみます。AVDはDensity160のHVGAというやつで、これはDPIがmiddleだそうです。つまりres/drawable-mdpiのイメージがそのままのサイズで表示されることになると思います。結果は下記です。

70px横にずらして鳥を配置するようにプログラムしてますが、結果はしっかりとかぶらずに密接していますので、幅70pxの画像がスケールされずにそのまま配置されていることが分かります。一方dpiがhightのAVDでは、鳥が重なります。これはつまり70pxよりも鳥が大きくなっているということになります。また、lowのAVDでは、鳥が離れます。これはつまり70pxよりも鳥が小さくなっているということになります。つまりDPIがmiddle以外であれば、そのDPIに応じてイメージが勝手にスケールし、イメージ自体は物理的に同じ大きさに見えるようになるということです。しかし、画面配置をpxで設定すると、勝手にスケールするため全体的な構成が壊れることになります。

さて、試しに今度は鳥の同じ画像をres/drawable-hdpiにのみ格納して、HVGAという同じAVDで起動してみます。予想としては鳥の画像は70pxよりも小さくなるため、鳥は離れるはずです。結果はこれです。

やはり小さくなって離れました。DPIとは1インチに何px入るか?という密度を表す単位のようです。1インチに5px入る画面の5pxは1インチですが、インチに10px入る画面の5pxは0.5インチです。つまり密度の高い画面の方が同じ画像が小さく表示されます。物理的に同じ大きさで表示するには、密度の高い画面でより大きな画像を使う必要があるわけです。このスケールをAndroidは自動的に実施してくれるわけです。せっかく自動的にスケールしてくれるのですから、画面構成も壊れないようにpxによる設定以外の方法で配置設定したいものです。

DPIに依存しない画面構成にする方法


そこで出てくるのが、密度非依存ピクセル (dp)です。

密度非依存ピクセルは、160 dpi の画面の 1 物理ピクセルに相当し、"medium" 密度の画面に対しシステムにより想定される基底となります。実行時において、システムは必要に応じ画面の実際の密度を基に dp 単位によるすべてのスケーリングを透過的にハンドルします。dp の単位を画面のピクセルに変換するのは px = dp * (dpi / 160) とシンプルです。

ということです。まあこれは、c.drawBitmap(img,70,0,null); の中で使えるものではないです。つまり、c.drawBitmap(img,70dp,0dp,null); ってできるわけではないので、この概念を適用するようにプログラムしてみようと思います。プログラムする場合は、起動している端末の画面が何dpiなのかを確認する必要があります。ちなみに、hightが240dpi、middleが160dpi、lowが120dpiというのがそれぞれの標準値のようです。とはいえ、プログラム上でこれを動的に把握する必要がありますので、そのやり方を調べます。

画面の解像度やdensity(ピクセル密度)の取得方法というページに下記のように記載があります。

DisplayMetrics metrics = new DisplayMetrics();
context.getWindowManager().getDefaultDisplay().getMetrics(metrics);
Log.d("test", "density=" + metrics.density);
Log.d("test", "densityDpi=" + metrics.densityDpi);
Log.d("test", "scaledDensity=" + metrics.scaledDensity);
Log.d("test", "widthPixels=" + metrics.widthPixels);
Log.d("test", "heightPixels=" + metrics.heightPixels);
Log.d("test", "xDpi=" + metrics.xdpi);
Log.d("test", "yDpi=" + metrics.ydpi);
ここで、metrics.scaledDensityという値があります。これが、現在の1ピクセルに対する1dipの倍率になります。 つまり、1ピクセル×scaledDensity=1dipとなります。

とりあえずこのコードをActivityのonCreateに埋め込んで実行してみます。すると下記のようなログが出力されました。(実行環境は今までのHVGAというAVDになります)

11-13 15:10:50.675: DEBUG/test(669): density=1.0
11-13 15:10:50.675: DEBUG/test(669): densityDpi=160
11-13 15:10:50.685: DEBUG/test(669): scaledDensity=1.0
11-13 15:10:50.685: DEBUG/test(669): widthPixels=480
11-13 15:10:50.696: DEBUG/test(669): heightPixels=320
11-13 15:10:50.696: DEBUG/test(669): xDpi=160.0
11-13 15:10:50.715: DEBUG/test(669): yDpi=160.0

続いて、WVGA800というAVDで実行すると、下記のようなログが出力されました。

11-13 15:20:06.199: DEBUG/test(535): density=1.5
11-13 15:20:06.199: DEBUG/test(535): densityDpi=240
11-13 15:20:06.209: DEBUG/test(535): scaledDensity=1.5
11-13 15:20:06.209: DEBUG/test(535): widthPixels=800
11-13 15:20:06.209: DEBUG/test(535): heightPixels=480
11-13 15:20:06.218: DEBUG/test(535): xDpi=240.0
11-13 15:20:06.218: DEBUG/test(535): yDpi=240.0

しっかり機能しているようです。では、このコードを使ってdpの概念を実装してみます。考え方としては、dpi160のmdpiフォルダに 標準サイズのリソースを格納するとともに、そのリソースに合わせた配置構成の為のpx数値を定数として持ちます(これがdpに当たります)。コードで実行している画面のscaledDensityを取得し、dp x scaledDensityによって動的にpx数値を取得し、そのpx数値を使って画像を表示します。

鳥の画像をmdpiのみに格納し直して上記考え方でプログラムを修正したものが下記になります。

DpiTestActivityクラス
package com.homuhomu.dpitest;

import android.app.Activity;
import android.os.Bundle;
import android.util.DisplayMetrics;

public class DpiTestActivity extends Activity {
 private DpiTestView dView;
 private DisplayMetrics metrics;
 static float scaledDensity;
 static int widthPixels;
 static int heightPixels;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        dView = new DpiTestView(this);
        setContentView(dView);

        metrics = new DisplayMetrics();
        getWindowManager().getDefaultDisplay().getMetrics(metrics);
        scaledDensity = metrics.scaledDensity;
        widthPixels = metrics.widthPixels;
        heightPixels = metrics.heightPixels;
    }
}
DpiTestViewクラス
package com.homuhomu.dpitest;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.view.View;

public class DpiTestView extends View {
 private Bitmap img;
 private Paint paint;
 private final int twW = 70; //mdpiにおける鳥イメージの幅(px)
 private final int twH = 85; //mdpiにおける鳥イメージの高さ(px)


 public DpiTestView(Context context) {
  super(context);
  //paintの設定
  paint = new Paint();
  paint.setAntiAlias(true);
  paint.setTextSize(70);
  paint.setARGB(255, 255, 255, 255);
  //imgの登録
  Resources res = context.getResources();
  img = BitmapFactory.decodeResource(res, R.drawable.tw);
 }

 @Override
 protected void onDraw(Canvas c){
  c.drawBitmap(img,0,0,null); //imgの描画
  c.drawBitmap(img,twW*DpiTestActivity.scaledDensity,0,null); //imgの描画
  c.drawText("A", twW*DpiTestActivity.scaledDensity,
    twH*DpiTestActivity.scaledDensity, paint);
 }
}

これを各AVDで実行すると下記のように鳥はどのAVDでも重ならずに密接しました。これで画面構成もどの端末においても基本的に担保されるようになったといえると思います。(あまりに画面サイズが小さい場合などはアプリの対象外にするなどといった処置が必要にはなります)

HVGA(dpi160)での実行結果
WVGA800(dpi240)での実行結果

さて、これで画像の自動スケールと配置構成をどの画面でも担保できるようになりましたが、少なくとも後2つ対処が必要な点があると思っています。1つは、例えばゲームをつくっている場合、画面にタッチする関係上あまりに小さい画面ですと仮に同じ配置構成で画面が表示できたとしてもゲームとして成立し得ないケースがあります。あるいは、あまりに画面が大きい場合、想定しているゲームと異なる難易度になってしまうケースなどもあるかもしれません。作成しているアプリの特性を考慮し、対象外の画面を明確化する必要があります。もう1つは、背景画像についてです。背景画像は上記の研究によりほぼ検討がついていますが、同じdpiでも画面サイズが異なるケースがありますので、例えば幅800pxの背景画像をつくった場合、854pxの幅がある画面では右に空白ができてしまいます。これらの対処法を検討する必要があります。

対象外の画面の明確化の方法


このページの画面サイズの宣言という項目に方法が書いてありますが英語です。残念です。ただ要するにmanifestに書けばいいだけのようです。 あと、supports-screensというAndroidのリファレンスに詳細が記載されているようです。あと、画面にフィットしないアプリケーションにも解説が記載されています。何もしなければ全てtrueになるようですので、後はケースバイケースで設定する形になりますね。

背景画像のマルチ画面対応の方法


画面いっぱいに画像を表示する場合についてです。まあこれは色んな方法があると思いますので、一旦私の独断と偏見による一方法を試したいと思います。全てを明らかにするのは時間がかかりますし。仮説ですので、全然ベストプラクティスではない可能性が高いです。一旦前提として画面サイズsmallとxLargeは対象外にすることにします。ここに下記のような記載があります。

xlarge 画面は 960dp x 720dp が最小 large 画面は 640dp x 480dp が最小 normal 画面は 470dp x 320dp が最小 small 画面は 426dp x 320dp が最小

よって、xlargeの最小幅である960dpを基準に考えます。何を考えるかというと背景画像を表示しうる最大のサイズで格納し、実行時の画面サイズに応じて縮小して表示するという考えの基に、その最大サイズについて960dpを基準に考えようと思います。960dpってとは、mdpiであれば960pxなんですが、hdpiですと、基本的にmdpiの1.5倍になるようなので、1,440pxになります。よって背景画像のファイルサイズは1440pxにしようと思います。これres/drawable-nodpiに格納します。そして、実行時にmetrics.widthPixelsなどで幅、高さを取得し、そのサイズに合わせて画面をスケールさせます。

修正版は下記になります。

DpiTestActivityクラス
package com.homuhomu.dpitest;

import android.app.Activity;
import android.os.Bundle;
import android.util.DisplayMetrics;

public class DpiTestActivity extends Activity {
 private DpiTestView dView;
 private DisplayMetrics metrics;
 static float scaledDensity;
 static int width;
 static int height;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //画面サイズ関連データの取得
        metrics = new DisplayMetrics();
        getWindowManager().getDefaultDisplay().getMetrics(metrics);
        scaledDensity = metrics.scaledDensity;
        width = metrics.widthPixels;
        height = metrics.heightPixels;
        //Viewの設定
        dView = new DpiTestView(this);
        setContentView(dView);
    }
}
DpiTestViewクラス
package com.homuhomu.dpitest;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.view.View;

public class DpiTestView extends View {
 private Bitmap img,backImg;
 private Paint paint;
 private final int twW = 70; //mdpiにおける鳥イメージの幅(px)
 private final int twH = 85; //mdpiにおける鳥イメージの高さ(px)
 private final int backW = 1440; //nodpiにおける背景画像の幅(px)
 private final int backH = 864; //nodpiにおける背景画像の高さ(px)
 private Rect src,dst; //背景画像スケール用Rect


 public DpiTestView(Context context) {
  super(context);
  //paintの設定
  paint = new Paint();
  paint.setAntiAlias(true);
  paint.setTextSize(70);
  paint.setARGB(255, 0, 0, 0);
  //imgの登録
  Resources res = context.getResources();
  img = BitmapFactory.decodeResource(res, R.drawable.tw);
  backImg = BitmapFactory.decodeResource(res, R.drawable.back);
  //背景画像のスケール
  src = new Rect(0,0,backW,backH);
  dst = new Rect(0,0,DpiTestActivity.width,DpiTestActivity.height);
 }

 @Override
 protected void onDraw(Canvas c){
  c.drawBitmap(backImg, src, dst, null); //背景画像の表示
  c.drawBitmap(img,0,0,null); //imgの描画
  c.drawBitmap(img,twW*DpiTestActivity.scaledDensity,0,null); //imgの描画
  c.drawText(""+DpiTestActivity.width, twW*DpiTestActivity.scaledDensity,
    twH*DpiTestActivity.scaledDensity, paint);
 }
}

結果は下記のようになります。

HVGA(dpi160)での実行結果
WVGA800(dpi240)での実行結果

これで背景画像もマルチ画面対応になりました。