2012年4月5日木曜日

AndroidのボタンをSVGで描画するには?

なぜSVGか?

FlexだとボタンのスキンをFXG、つまりベクターで描画することもできる。しかし、どうやらAndroidのボタンはビットマップで描画するしかないらしい。

ベクター描画の何が便利かというと

  • 解像度ごとにファイルを作らなくて済む(DPIからサイズを計算してリサイズ)
  • 拡大しても画質が劣化しない
  • 形状が同じで色違いのファイルを作らなくて済む
  • いちいち9patchの設定をする必要がない

といったところだろうか。

特にAndroidの9patchツール(draw9patch)を使ってみて痛感したのだ。これは現実的ではないぞと。オリジナルが少しでも変わるたんびにあのツールで9patchの設定をしなければならないのか?1つ2つならまだしも、数が多くなってきた時は?しかも3つの解像度ごとに作るのか?頭くらくら_| ̄|○

もちろんAndroidでもクラスをつくってプログラムでグラフィックスを描画すればできるが、複雑な形状を描画するプログラムを書くのは非効率だし面倒だ。ていうか、単純な形状でも書きたくない(^^); こういうのはイラストレーターとかでベースを作った方が早いよね。


AndroidでSVGは描画できるのか?

ということで、何とかAndroidのボタンをSVGで描画できないものか調べてみた。

はじめはJavaでSVGだから、やっぱりBatikかなぁと思ったが、解凍してみて思った。やっぱりあれは重すぎる。

何とかAndroid向けの軽量なものは無いかなぁと調べると、こちらの記事で「svg-android」というライブラリがある事を知る。

Svg-android | アカベコマイリ

svg-android - SVG parsing and rendering for Android - Google Project Hosting

最近は「svg-android2」というオリジナルからフォークして改善したライブラリもあるみたい。

Introduction - svg-android-2 - Purpose and Roadmap for the svg-android-2 project - Enhanced SVG library for Android devices - Google Project Hosting

「svg-android」を作った方は、「Androidify」というアプリを作った方らしい。GoogleCodeのページのイラストに何か見覚えがあるなぁと思ったらそういうことだった。

Android メーカー - Google Play の Android アプリ

ライブラリのドキュメントを見ると、たくさん機能があるわけではなさそうだけど、逆に簡単に使えそうな感じ。


できました。

何とかできそうかも。と思ってやってみたのがこれ。


Eclipseプロジェクトファイル SVGButton.zip(208KB)

レイアウトのXMLだけで色の違うボタンを作れる。タップするとXMLで指定したハイライト色になる。


ライブラリの組み込み

  • android-svgのページからライブラリをありがたくダウンロードする(Apache 2.0ライセンス)
  • 「SVGButton」というプロジェクトを作る
  • SVGButton/libフォルダを作る
  • libにandroid-svg-1.1.jarをコピー
  • SVGButtonプロジェクトを選択 > Project > Properties > Java Build Path > Libraries > Add JARs > lib/android-svg-1.1.jarを選択 > OK
  • Order and Export > android-svg-1.1.jarにチェック > OK (ここ重要。これをしないとClass Def Not Found出る。


ボタン用SVG作成

イラストレーターで適当にボタン用イラストを作成する。ここではこのようなスケルトンを作っておく。


SVGの書き出しは

ファイル > 別名で保存 > フォーマットでSVG(svg)を選択 > SVGプロファイルでSVG1.1(デフォルト) > OK

なぜここでSVG1.1を選ぶかは、ライブラリのチュートリアルに「This library supports a subset of the SVG Basic 1.1 specification」と書いてあったから。

SVGのソースはこんな風。

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="layer1" x="0px" y="0px" width="108px" height="36px" viewBox="0 0 108 36" enable-background="new 0 0 108 36">
<g id="roundbutton">
    <path id="inner" fill="#4D4D4D" d="M8.334,35C4.29,35,1,31.298,1,26.747V9.253C1,4.703,4.29,1,8.334,1h91.332 C103.71,1,107,4.703,107,9.253v17.494c0,4.551-3.29,8.253-7.334,8.253H8.334z"/>
    <path id="outer" fill="#FFFFFF" d="M99.666,2C103.158,2,106,5.254,106,9.253v17.494c0,3.999-2.842,7.253-6.334,7.253H8.334 C4.841,34,2,30.746,2,26.747V9.253C2,5.254,4.841,2,8.334,2H99.666 M99.666,0H8.334C3.731,0,0,4.143,0,9.253v17.494 C0,31.855,3.731,36,8.334,36h91.332c4.604,0,8.334-4.145,8.334-9.253V9.253C108,4.143,104.27,0,99.666,0L99.666,0z"/>
</g>
</svg>

これを SVGButton/res/raw/roundbutton.svg として保存する。


スタイルパラメーターの定義

res/values/attrs.xml にSVGボタンのスタイルパラメーターを定義する。

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="SVGButton">
        <attr name="innerColorUp" format="string" />
        <attr name="innerColorDown" format="string" />
    </declare-styleable>

</resources>


SVGButtonクラスを作成

ex.svgbuttonパッケージにSVGButtonクラスを作る。ちょっと長いけど貼付け。

package ex.svgbutton;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.Date;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.TransformerFactoryConfigurationError;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Picture;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display;
import android.view.MotionEvent;
import android.view.WindowManager;
import android.widget.TextView;

import com.larvalabs.svgandroid.SVGParser;


public class SVGButton extends TextView {


  protected static Document svgDoc;
  protected static float screenDensity = 1.0f;
  protected Context context;
  protected AttributeSet attrs;
  protected Picture picOnScreen;
  protected Picture picUp;
  protected Picture picDown;


  public SVGButton(Context context, AttributeSet attrs) {
    super(context, attrs);
    this.attrs = attrs;
    this.context = context;
    setClickable(true); // これをしないとタッチイベントがDOWNのみになる
    // @TargetApi(11) //このアノテーションはADT Rev.17では動くはずだけど動かない
    // Galaxy Nexusの実機ではこれを入れないとGLES関連のエラーが出て動かない。
    // ハードウェアアクセラレーション関連と思われる。APIレベルは11以上。
    // setLayerType(View.LAYER_TYPE_SOFTWARE, null);
    init();
  }


  protected void init() {

    // SVGのDOMのパースと画面密度の取得
    // (svgDocがstaticであることに注意。つまり、SVGRoundButtonがいくつも作られる場合は最初の一度だけ行うことになる)
    if (svgDoc == null) {
      try {
        svgDoc = loadResourceAsDom(R.raw.roundbutton); // SVGのDOMをパース
        screenDensity = getDisplayMetrics(context).density; // 画面密度を取得
      } catch (Exception e) {
        e.printStackTrace();
      }
    }

    // SVGのDOMがちゃんと読み込まれてたら
    if (svgDoc != null) {
      try {

        Log.v("init", "スタイリング開始");
        long start = new Date().getTime();

        // innerのエレメント取得
        Element eleInner = svgDoc.getElementById("inner");

        // スタイルパラメーターをパース
        TypedArray styles = context.obtainStyledAttributes(attrs, R.styleable.SVGButton);

        // innerのUP用カラー取得・設定
        setElementAttribute(eleInner, styles, R.styleable.SVGButton_innerColorUp, "fill", "#444444");

        // UP用Picture取得
        picUp = SVGParser.getSVGFromString(dom2XmlString(svgDoc)).getPicture();

        // innerのDOWN用カラー取得・設定
        setElementAttribute(eleInner, styles, R.styleable.SVGButton_innerColorDown, "fill", "#999999");

        // DOWN用Picture取得
        picDown = SVGParser.getSVGFromString(dom2XmlString(svgDoc)).getPicture();

        // UP用Pictureを背景描画に設定
        picOnScreen = picUp;

        long end = new Date().getTime();
        Log.v("init", "スタイリング終了 " + (end - start) + " msec");

      } catch (Exception e) {
        e.printStackTrace();
      }
    }

  }


  public final Document loadResourceAsDom(int resourceid) throws ParserConfigurationException, SAXException, IOException {
    long start = new Date().getTime();
    Log.v("loadResourceAsDom", "開始");
    InputStream is = getResources().openRawResource(resourceid);
    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
    DocumentBuilder builder = factory.newDocumentBuilder();
    Document doc = builder.parse(is);
    is.close();
    long end = new Date().getTime();
    Log.v("loadResourceAsDom", "終了 " + (end - start) + " msec");
    return doc;
  }


  public final String dom2XmlString(Document dom) throws TransformerFactoryConfigurationError, TransformerException, IOException {
    long start = new Date().getTime();
    Log.v("dom2XmlString", "開始");
    StringWriter sw = new StringWriter();
    Transformer xformer = TransformerFactory.newInstance().newTransformer();
    xformer.transform(new DOMSource(dom), new StreamResult(sw));
    sw.flush();
    sw.close();
    long end = new Date().getTime();
    Log.v("dom2XmlString", "終了 " + (end - start) + " msec");
    return sw.toString();
  }


  protected void setElementAttribute(Element element, TypedArray styles, int styleId, String attributeName, String defaultValue) {
    String val = styles.getString(styleId);
    if (val == null) {
      val = defaultValue;
    }
    element.setAttribute(attributeName, val);
  }


  public static final DisplayMetrics getDisplayMetrics(Context context) {
    WindowManager winMan = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    Display disp = winMan.getDefaultDisplay();
    DisplayMetrics dispMet = new DisplayMetrics();
    disp.getMetrics(dispMet);
    return dispMet;
  }


  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (picOnScreen == null) {
      return;
    }
    // SVGのサイズと画面密度を使って、このボタンを描画すべきサイズをセットする(init()の後に自動的に呼ばれる)
    int nw = (int) (picOnScreen.getWidth() * screenDensity);
    int nh = (int) (picOnScreen.getHeight() * screenDensity);
    setMeasuredDimension(nw, nh);
  }


  @Override
  public boolean onTouchEvent(MotionEvent event) {

    // タッチイベントによって表示するPictureを変える
    String action = "";
    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        action = "ACTION_DOWN";
        picOnScreen = picDown;
        break;
      case MotionEvent.ACTION_UP:
        action = "ACTION_UP";
        picOnScreen = picUp;
        break;
    }
    Log.v("onTouchEvent", "action = " + action);

    // 再描画リクエスト
    invalidate();

    return super.onTouchEvent(event);

  }


  @Override
  protected void onDraw(Canvas canvas) {
    Matrix mat = canvas.getMatrix(); // CanvasのMatrixを保存しとく
    canvas.scale(screenDensity, screenDensity); // ScreenDensityでCanvasをスケーリング
    canvas.drawPicture(picOnScreen); // 背景となるSVGを描画
    canvas.setMatrix(mat); // 保存しといたMatrixを戻す
    super.onDraw(canvas); // 文字を描画
  }


}

ポイントは、

  • init()でSVGを読み込んでDOM操作でスタイリングしてボタンのアップとダウン状態のPictureを作る
  • onMeasure()でSVGの幅・高さと画面密度(screenDensity)から、描画すべき幅・高さをセットする
  • onTouchEvent()で表示するPictureを切り替える
  • onDraw()でscreenDensityを使ってSVGのイメージをスケーリングして描画しつつ、文字はノーマルスケールで描画する

というところだろうか。


SVGボタンのレイアウト

res/layout/main.xml にSVGボタンを配置する。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:svgbutton="http://schemas.android.com/apk/res/ex.svgbutton"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical"
    android:padding="10dp" >

    <!-- カラー指定なし -->
    <ex.svgbutton.SVGButton
        android:id="@+id/btnDefault"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:gravity="center"
        android:text="DEFAULT"
        android:textColor="#FFFFFF" />

    <!-- 赤っぽいボタン -->
    <ex.svgbutton.SVGButton
        android:id="@+id/btnRed"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:gravity="center"
        android:text="RED"
        android:textColor="#FFFFFF"
        svgbutton:innerColorDown="#FF3333"
        svgbutton:innerColorUp="#993333" />

    <!-- 緑っぽいボタン -->
    <ex.svgbutton.SVGButton
        android:id="@+id/btnGreen"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:gravity="center"
        android:text="GREEN"
        android:textColor="#FFFFFF"
        svgbutton:innerColorDown="#33FF33"
        svgbutton:innerColorUp="#339933" />

    <!-- 青っぽいボタン -->
    <ex.svgbutton.SVGButton
        android:id="@+id/btnBlue"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:gravity="center"
        android:text="BLUE"
        android:textColor="#FFFFFF"
        svgbutton:innerColorDown="#3333FF"
        svgbutton:innerColorUp="#333399" />

</LinearLayout>

第一のポイントは「xmlns:svgbutton="http://schemas.android.com/apk/res/ex.svgbutton"」のネームスペースの設定。太字以外は定形句。

第2のポイントは「svgbutton:innerColorDown」と「svgbutton:innerColorUp」のスタイルパラメーター。「svgbutton」はネームスペースの設定による。「innerColorDown」と「innerColorUp」はattrs.xmlで設定したパラメーター名による。


まとめ

以上で上のアプリができる。

Androidは様々な画面サイズ・画面密度に対応する必要があるので、SVGを標準APIでサポートすべきではないだろうか。

ただ、問題はパフォーマンスだ。XMLの処理が遅いために、多くのUIコンポーネントをSVGで作ると画面が表示されるまでに結構時間がかかってしまう。

少しだけ、そして簡単に速くする方法は、DOM4Jを使うことだ。

琴線探査: Android上での標準XML API・JDOM・DOM4Jの速度比較

しかし、もっと速くするには、「svg-android」のライブラリを根本的に改造する必要があるだろう。

そもそも用途が違うからだとおもわれるのだけど、「svg-android」はXMLをSAXで処理してしまうために動的なXML操作ができない。処理をDOMベースにして、DOM to Pictureできるようになれば、結構速くなるのではと思った。

あとは、それをマジでやるのか?というところですわなぁ(^^);

「svg-android2」の方がやろうとしているっぽい感じだけど・・・


追記12.04.06:ちょっとした考え方の転換をして3.6倍程度高速化できた。これくらい速くなればもう十分かも?

琴線探査: 続・AndroidのボタンをSVGで描画するには?(ローテクだけど高速版)