スポンサーリンク

2016年9月10日土曜日

Android AppCompat の闇 - カスタムスタイルの落とし穴

テーマ、スタイル、アトリビュート...

Android のテーマとスタイルは謎に包まれています。Android の闇と言っていいかも知れません。

スタイルは個々の View に適用するもので、スタイルを集めたものがテーマです。テーマは Application や Activity に適用するものです。ここまでの考え方は非常にシンプルです。
しかしソースコードの中に入るとシンプルとは程遠いものです。まずややこしいのが、このテーマとスタイル、XML 的にはどちらも同じ <style> タグで定義していること。そしてシステムの themes.xml や styles.xml、attrs.xml あたりをトレースしたことがある人なら分かると思いますが、これらのファイル、定義が定義を呼びあって、肥大化し、依存関係がスパゲッティの様に絡まりあっています。悪いデザインの見本みたいなものです。まあよくこれで秩序を保っていられるなあと逆に感心してしまいます。

ここに AppCompat 系のテーマが入ってくると更に話はややこしくなります。この AppCompat が引き起こす問題について説明したいと思います。
例としてスタイルとテーマを使って Button をカスタマイズする方法を考えてみます。画面上にある全ての Button の文字を赤くする必要があるとします。

テーマに直接アトリビュートを設定する

最初の試みとして、まず Application に設定してあるメインのテーマに直接アトリビュートを設定してみます。
    <style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
        <item name="android:textColor">@color/red</item>
    </style>
color リソースは予め設定してあるものとします。これで一応ボタン文字を赤くすることはできます。しかしこの方法のダメなところは、Button 以外の例えば TextView 等の文字も全て赤くしてしまうことです。なのでもう少し気の効いた方法を考えてみます。

カスタムスタイルを作る

そこで次にボタン専用のカスタムスタイルを作ることを考えます。以下の様なカスタムスタイルを定義します。
    <style name="MyButtonStyle" parent="Widget.AppCompat.Button">
        <item name="android:textColor">@color/red</item>
    </style>
スタイル名は任意の名前で構いませんが、parent= で指定する親スタイルには注意が必要です。使用するテーマと View の種類によって適切なものを選択しなくてはなりません。これがまた厄介です。ドキュメントのどこにも書かれていません。正確に知るには Java のソースコードと XML 定義を見て探し出す必要があります。慣れてくると代表的な View であればだいたい把握できるようになります。例えば Theme.Holo を使った TextView では Widget.Holo.TextView みたいに。

作成したカスタムスタイルをテーマに適用します。
    <style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
        <item name="android:buttonStyle">@style/MyButtonStyle</item>
    </style>
ここでまた android:buttonStyle という訳の分からないものが出てきました。これはデフォルトでボタンに適用されるスタイルのアトリビュート id (リソース id)です。これもソースコードを見ないと分からないパラメータです。Button のコンストラクタのコードを見ると以下のようなっています。
    public Button(Context context, AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.buttonStyle);
    }
スタイルを指定しなかった場合、デフォルトで com.android.internal.R.attr.buttonStyle が適用されるようになっています。これはアプリケーションの Java コードドからは android.R.attr.buttonStyle として参照できるものです。これが XML 的には android:buttonStyle となります。

テーマとスタイルのコードをまとめると以下の様になります。
    <style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
        <item name="android:buttonStyle">@style/MyButtonStyle</item>
    </style>

    <style name="MyButtonStyle" parent="Widget.AppCompat.Button">
        <item name="android:textColor">@color/red</item>
    </style>
これでボタンだけを赤くすることが出来ました。しかしこのコード、一つだけ間違いがあります。どこだか分かりますか?
実はこのコード Android5 以降でないと機能しません。Button と TextView を並べただけのレイアウトでこのスタイルを使い、Android4.x と Android5.x で実行してみると以下の様になります。

Android 4.3
Android 5.1

Android4.x ではボタン文字が赤くなっていません。ではどうすればいいのでしょう?
答えを言ってしまうと、テーマを以下の様に変更する必要があります。
    <style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
        <item name="buttonStyle">@style/MyButtonStyle</item>
    </style>
name= で指定するアトリビュートを android:buttonStyle から buttonStyle に変更しました。こうすると全てのバージョンでカスタムスタイルが有効になります。

ここでは例として Button を使いましたが、AppCompat 系テーマを使った場合、どの View でもこの問題は発生します。AppCompat では android: プレフィックス無しのアトリビュートを使うようにしましょう。

android: プレフィックスの例

ではこの android: プレフィックスが付く、付かないの違いは何なのでしょう?答えを出す前にもう一つ android: プレフィックスの例を揚げておきます。

Application や Activity に適用するテーマを作る時、例えば Holo 系や Material 系のテーマの場合、正式には以下の様に書きます。
    <style name="AppTheme" parent="@android:style/Theme.Holo">
        .... 
    </style>
    <style name="AppTheme" parent="@android:style/Theme.Material">
        .... 
    </style>
これらは省略形が使え、一般には以下の様に記述します。
    <style name="AppTheme" parent="android:Theme.Holo">
        .... 
    </style>
    <style name="AppTheme" parent="android:Theme.Material">
        .... 
    </style>

これに対し AppCompat 系のテーマを使う場合は以下の様に書きます。
    <style name="AppTheme" parent="Theme.AppCompat">
        ....
    </style>

ここでも android: プレフィックスが付く、付かない違いがあります。

android: プレフィックスの意味

プレフィックスの意味を理解するには、Holo や Material 等の Android 標準テーマと、AppCompat 系テーマの違いを理解しなくてはなりません。

Holo や Material 等の標準テーマの場合、それらを定義するリソースは Android デバイスにファームウェアの形で組込まれています。それに対し AppCompat 等のテーマはライブラリにより提供されており、各アプリがそれぞれローカルにリソースを保持しています。

この違いが分かると android: プレフィックスの意味も理解できると思います。デバイス組込みのリソースを参照する場合は android: プレフィックスを付け、アプリのローカルリソース参照には付けない、ということです。

上で挙げた例は見ると分かる通り Theme.AppCompat 系のデザインを使っているので、android: プレフィックスを付けないローカルリソースの buttonStyle アトリビュートを指定するのが正解です。

更に説明すると、AppCompt 系テーマを使った場合、レイアウト XML の中で Button を指定しても、実際に使われるのは AppCompatButton という Button を継承した別クラスなのです。これについては別の記事に詳しく書きました。
http://extra-vision.blogspot.jp/2016/09/android-appcompat.html

この AppCompatButton のソースコードを見ると、コンストラクタは以下の様になっています。
    public AppCompatButton(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.buttonStyle);
    }
デフォルトで適用されているスタイルは R.attr.buttonStyle になっています(android.R でない点に注意)。つまりアプリケーションのローカルリソースを参照しています。このことからも android: プレフィックス無しの buttonStyle を使う必要があることが分かります。

まとめ

この android: プレフィックス問題、相当危険なトラップです。バージョンによって動く場合もあるし、動かない場合もある。なまじ Android5 以降ではちゃんと動いてしまい、しかもクラッシュするような致命的な問題でもないので、そのまま見過されてしてしまうかもしれません。更にタチが悪いのは、上の例で buttonStyle と入力すると Android Studio の補完機能が勝手に android: プレフィックスを付けてしまうことです。まさにトラップに誘導しているようにしか見えません。

しかしこの件についてちゃんと説明している解説を見たことがありません。僅かにここで EditText の問題として取り上げていますが、理由までは述べられていません。(EditText だけの問題じゃないんだけどな)

また AppCompat でカスタムスタイルを使う例として、この android: プレフィックスを付けたまま解説している例が多く見られます。まんまとトラップに嵌まっています。

Stackoverflow などでも時々、android: プレフィックスを外したら動くようになったという報告がありますが、その意味まではあまり理解されていないようです。

それにしてもテーマとスタイルの複雑さ、更に AppCompat が加わることによる訳の分からなさは頭がくらくらしてきます。テーマとスタイルについてはドキュメントが整備されていないことは Google も認めています。
https://developer.android.com/guide/topics/ui/themes.html#PlatformStyles

なので、あの悪夢のような XMLファイルを見て、少しずつ体を慣らしていくしかないようです。

0 件のコメント :

コメントを投稿