AndroidのXMLフォントをAPIレベル26未満で使う話

f:id:fullstuck_sato:20210615090456j:plain:w480

 こんにちは。マネックス・ラボの佐藤です。今回は、Androidネイティブアプリのフォント指定で使える、XMLフォントについて検証したので、これについて書いてみます。公式ドキュメントだと情報量が少なく、ウェブで検索してもWebにおけるfont-familyの話ばかりで、ネイティブアプリでのfont-familyの構成のナレッジがほとんどないので、記事にしてみました。

XMLフォントについて

XMLフォントとは、下記の公式ドキュメントにあるような、XMLファイルでfont-familyを作成できる機能です。このXMLファイルを定義すれば、レイアウトファイル側でUIコンポーネントのfontFamilyプロパティでこの XMLファイルを指定し、さらにtextFontWeighttextStyleを指定すれば、自動的に適切なフォントファイルを選んで表示してくれます。直接フォントファイルを指定してしまうと、後からアプリ全体のフォントを変えたいときに困ってしまうので、便利ですね。

https://developer.android.com/guide/topics/ui/look-and-feel/fonts-in-xml

ドキュメントに書かれていない、XMLフォントの疑問

しかしこのドキュメントはかなり大雑把で、細かいところがよくわかりません。

  • APIレベル28未満の場合に、UIコンポーネントでtextStyle=”bold”に指定したとき、XMLフォントで定義したどの<font/>が使われるかがわからない。
    • これは、UIコンポーネントとXMLフォントでのウェイトの指定の仕方が異なることによります。UIコンポーネントのtextFontWeightプロパティを使ったウェイト指定はAPIレベル28以上であれば利用できますが、28未満の場合には、textStyleプロパティを使って、textStyle=”bold”といった大雑把な指定しかできません。このとき、XMLフォントで定義した<font-family/>から、どの<font/>が使われるのかドキュメントからはわかりません。
  • XMLフォントで定義した<font/>に一致するものが見つからなかった場合にどの<font/>が使われるのかがわからない。
    • APIレベル26以上の場合でも、一致するものが見つからなかった場合の挙動については何も書かれていません。
  • 結局APIレベル26未満と26以上をサポートしたい場合に、スタイルやUIへのフォントの指定をどうしたら良いのかがわからない。

(本筋からそれますが、XMLフォントはAPIレベル26以上から利用でき、レイアウトにおけるtextFontWeightプロパティは、APIレベル28以上から指定できるというややこしさもあります。)

これらの疑問について、いくつかのGoogleフォントを使って検証してみましたが、こういうのは最初にまとめを書いた方が良いと思うので、先にまとめてしまいます。この結論に至った経緯に興味がある方はその先も読んでいただければと思います。

まとめ

  • UIコンポーネントでtextStyle=”bold”にすると、textFontWeight=”700”と同じ扱いになる(ように見える。ここは少し推測が入ってます)。
    • 推測の理由としては、XMLフォントにおける<font-family/>の定義から、fontWeight=”700”に一致するフォントが選択されるためです。
    • これは割と納得のいく答えで、CSSのfont-weightの定義と同じになっています。 https://developer.mozilla.org/ja/docs/Web/CSS/font-weight
  • XMLフォントで定義した<font-family/>の中でfontWeightfontStyleに一致する<font/>がない場合は、その<font-family/>の中から一番近い<font/>が選択される。
  • APIレベル26未満でも26以上でも一つの実装で同じ見た目にするためには、下記が必要。
    • アプリ全体のフォントを、RegularとBoldに限定する。デザイナと相談が必要。
    • UIコンポーネントのフォント指定では、textStyleを使ってRegularとBoldを切り替える。textFontWeightは使わない。
    • どうしてもSemi-Boldのように、RegularとBoldとは異なるウェイトのフォントを表示したい場合には、fontFamilyプロパティに直接Semi-Boldのフォントファイルを指定する。あるいは、Styleを定義してそれを当てる。

結局のところ、アプリ内でウェイトを細かく指定したいのであれば、アプリが対応しているOSをAPIレベル28(AndroidOS9)以上にした方が、XMLフォントを使いつつ保守性を維持しながら作りやすい、ということです。しかしAndroidOS9は2018年にリリースされたものであり、AndroidのOSシェアは割と古いOSでもそれなりの比率を占めるので、対応するOSバージョンを絞りすぎることになってしまいます。

一方、APIレベルを気にせず細かくウェイトを指定したいのであれば、RegularとBold以外を指定したい箇所についてはStyle定義すればある程度保守性を保ちながらUIを作り込むこともできますが、XMLフォントだけを使う場合に比べてメンテナンス性は劣るかなと思います。

検証した内容

フォントの選定

試したいのは、UIコンポーネントのtextStyletextFontWeightプロパティの有無や値に応じて、XMLフォントからどのフォントファイルが選ばれてレンダリングされるか、です。違いが分かりやすくなるように、複数の見た目が異なるフォントファイルを用意し、どれが選ばれたかがわかりやすいようにする方が良いです。今回は、下記を選んでみました。

これらのフォントをダウンロードしてきて、使うウェイトの.ttfファイルだけをAndroidStudioのプロジェクトに取り込みます。ファイル名はAndroidのリソース命名規則に従わないといけないので、適当にスネークケースに置き換えます。今回は、bai_jamjuree_regular.ttfdancing_script_semi_bold.ttfnoto_serif_jp_bold.ttfとしました。

XMLフォントの作成

作成したXMLフォントは下記の通りです。ファイル名は、font_file_test.xmlです。 <font-family/>タグの中に複数の<font/>タグを書いて、font-familyを構成します。各<font/>タグには、上記の3つのフォントを定義します。

<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:app="http://schemas.android.com/apk/res-auto">
    <font
        app:font="@font/bai_jamjuree_regular"
        app:fontWeight="400"
        app:fontStyle="normal" />
    <font
        app:font="@font/dancing_script_semi_bold"
        app:fontWeight="600"
        app:fontStyle="normal" />
    <font
        app:font="@font/noto_serif_jp_bold"
        app:fontWeight="700"
        app:fontStyle="normal" />
</font-family>

サンプルUIの作成

また、このXMLフォントを使ったサンプルのUIを作ります。今回は、3つのTextViewを並べました。上から順に、Bai Jamjuree、DancingScript、Noto Serif JPを表示することを意図して、異なるtextFontWeightを設定しています。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        android:textFontWeight="400"
        />

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        android:textFontWeight="600"
        />

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        android:textFontWeight="700"
        />
</LinearLayout>

APIレベル28以上で、表示を確認する

まずは、APIレベル28以上(AndroidOS9以上)で、それぞれのTextViewで指定したtextFontWeightに一致したフォントが選択されるかどうかを確認してみます。実行した結果は下記の通り。それぞれで設定したウェイトに合わせて、フォントが選ばれています。

f:id:fullstuck_sato:20210614191908p:plain

XMLフォントとレイアウトファイルを変えずにAPIレベル24で実行する

次はレイアウトファイルを変えずに、APIレベル24(AndroidOS7.0)で実行してみます。見事にtextFontWeightの指定が無視され、全てRegularで指定したフォントで表示されました。サポートしていないと言っているので当たり前ですが。

f:id:fullstuck_sato:20210614191958p:plain

textStyle="bold"を指定した時に選択される<font/>を調べる

APIレベル28未満ではtextFontWeightプロパティには意味がないことがわかったので削除し、3つ目のTextViewだけ、textStyle=”bold”を指定してみます。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        />

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        />

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        android:textStyle="bold"
        /> <!-- textStyle="bold" にする -->
</LinearLayout>

APIレベル24で実行した結果がこちら。3つ目のTextViewに、XMLフォント側でfontWeight="700"を指定してあるNoto Serif JPのBoldが表示されていることがわかります。

f:id:fullstuck_sato:20210614192104p:plain

なお、APIレベル28で実行した場合はこのようになります。APIレベル24と同じであり、一つのレイアウトの実装で、異なるAPIレベルに対応できていることがわかります。

f:id:fullstuck_sato:20210614192156p:plain

XMLフォントの<font/>と一致しないウェイトをレイアウトファイルで指定したとき

ここまでの検証でどう作れば良いのかはわかりましたが、最後に指定したウェイトの<font/>が無かった場合にどうなるのか試してみます。一番最初のレイアウトファイルで、2つ目のTextViewのtextFontWeight"800"に変更してみます。XMLフォントではfontWeight=”800”<font/>がないので、どのフォントファイルが選ばれるのでしょうか。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        android:textFontWeight="400"
        />

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        android:textFontWeight="800"
        /><!-- textFontWeightを、XMLフォントの中で定義がない"800"にする -->

    <TextView
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:text="012abcABC"
        android:fontFamily="@font/font_file_test"
        android:textSize="16sp"
        android:textFontWeight="700"
        />
</LinearLayout>

APIレベル28での実行結果がこちら。2つ目のTextViewが、fontWeight=”700”で指定したNoto Serif JPで描画されています。一致しないからといっていきなりOSのデフォルトフォントを使うのではなく、できるだけ近い<font/>を探してきてくれることは確かです。

f:id:fullstuck_sato:20210614191744p:plain

最後に

 今回は、ドキュメントに書かれていないことを調べるために、実験的なアプローチを取りました。プログラマであるからにはソースコードを読み解いたりGitHub上のIssueを探すような方法で裏取りをしたかったのですが、あまり時間を割きたくないこと、フロントエンドは結局のところ機種依存のような問題も出てきますし、フォントのレンダリングについてはロジックに影響を与えるようなものでもないので、今回のような調査方法を取りました。

 証券会社というのはお客様の資産をお預かりして正しい根拠を積み上げてシステムを開発/運用していく必要がありますが、領域によっては異なるアプローチが必要になることもあり、特に変化の激しいWebやネイティブアプリといったフロントエンドは、Web上の情報や、手元での検証に基づいた判断も必要になってきます。こういった開発の文化に対する社内の理解も得ながら、金融機関でフロントエンドを作っていくのは、大変でもあり、また面白いことでもあるのかなと思います。

佐藤 俊介マネックス・ラボ