Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

RecyclerViewで Drawable に tint を設定する際は気をつけよう

こんにちは、モバイルクライアントグループの中島です。 最近健康診断で久しぶりに出社しましたが、体脂肪率が痛かったのでランニングを始めました。 頑張っていきたい。

さて、9月頭にも本ブログで紹介いたしましたが、Studyplusでは8月31日にiOS/Android両OSでダークモード/ダークテーマがリリースされました

tech.studyplus.co.jp

この開発に伴いアプリ内の画像リソースに多くのメスが入ったのですが、その際に起きたhotfixについて今回は話していきたいと思います。

プロジェクトとしてはダークテーマ対応でしたが、それに関わらず発生しうる事象だと思いますので、皆様のお役に立てれば幸いです。

何が起きたのか

Studyplusでは、登録した教材をカテゴリ分けできる機能があります。 そして、そのカテゴリのマークとして様々な色付きのアイコンを使っています。 ダークテーマ対応の際に、それらの色もダークテーマ用に調整したアイコンが必要になったのですが、各色で個別にpngの素材を持っていたためリソースファイル数が多くなってしまう問題がありました。 もともと単純なアイコン系素材はsvgで登録してtintで色分けしていきたいという気持ちもあり、この機会にデザイナーの方にも相談して黒単色のsvg素材を作ってもらいました。

8月31日のことです。 ダークテーマの対応がされたアプリを利用しているユーザーから、「カテゴリアイコンの色がチカチカ変わる」とのお問い合わせが届きました。

手元の端末(Pixel3 Android 10)では再現しなかったのですが、社内端末での再現状況から条件を絞り込んだところ、端末のOSバージョン依存ではないかと推測しました。

API 27 のエミュレータを起動してみた結果が以下の動画です。

f:id:nacatl:20200917140637g:plain

スクロールして次のカテゴリアイコンの描画が行われるたびに、画面内の全ての色が変わっている様子がわかります。 バージョンごとに検証をしてみましたが、 API 24 ~ 28 で同様の現象を確認しました。

調査とその結果

ConstantState

この現象を見たとき、ふと思い出したことがありました。 つい4日前に見たばかりである、DroidKaigi 2020 Liteで公開された、HiroYUKI Seto さんの発表で紹介されていた Drawable の ConstantState です。

MDCの内部実装から学ぶ 表現力の高いViewの作り方

www.youtube.com

この発表の 8:00 辺りからの情報を一部、以下に抜粋します。

  • ConstantState は Drawable の アルファ値 ColorStateList Tint の情報を保持している
  • ConstantState.newDrawable() で作成されたDrawable間で共有される
    • Resources.getDrawable() の内部で使われている
    • Drawable.mutate() を呼ぶことで状態が独立する

複数の Drawable 間で tint が共有される…今回の現象と関連がありそうです。

コードを見ていく

次に現象が起きたコードを見ていきます。

// RecyclerView.onBindViewHolder

    val drawable = ContextCompat.getDrawable(context, R.drawable.ic_bookshelf_category_24dp)
    drawable?.setTint(/* ColorInt */)
    imageView.setImageDrawable(drawable)

コードを書いたときは「Drawable を都度生成しているので問題ないだろう」と思っていたのですが、ConstantState の話を知った後ではかなり危ないように見えてきます。

API 25のAOSPで内部を確認していきましょう。

ContextCompat.getDrawable の中を見ていきますと、最終的に Drawable の生成は ResourceImpl 内で行われていました。

以下 ResourceImpl.javaより一部抜粋します。

@Nullable
Drawable loadDrawable(Resources wrapper, TypedValue value, int id, Resources.Theme theme,
        boolean useCache) throws NotFoundException {

    // ~~~~~

    // ↓同じ theme のコンテキストで同じリソースから作られた Drawable が既にあるか確認
    // 問題になった画面で、2つ目以降のアイコンはキャッシュ処理で return されている

    // First, check whether we have a cached version of this drawable
    // that was inflated against the specified theme. Skip the cache if
    // we're currently preloading or we're not using the cache.
    if (!mPreloading && useCache) {
        final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
        if (cachedDrawable != null) {
            return cachedDrawable;
        }
    }

    // ここより下は新規作成時のコードなので割愛

    // ~~~~~

}

DrawableCacheの取得を見ます。

class DrawableCache extends ThemedResourceCache<Drawable.ConstantState> {

    public Drawable getInstance(long key, Resources resources, Resources.Theme theme) {
        final Drawable.ConstantState entry = get(key, theme);
        if (entry != null) {
            return entry.newDrawable(resources, theme);
        }

        return null;
    }

    // ~~~~

}

entry.newDrawable(resources, theme);

確認できました。

これで「同じ ConstantState から生成された Drawable なので tint が共有されている」ことが原因だと確定できました。

普段なら問題なくキャッシュ取得で運用できるのだと思います。事実、私も今まで特に気にした覚えはありませんでした。 今回は「同じ画面上に同じ画像リソースで違う色のものを多く並べる」画面が発生したことで、 ConstantState を意識する必要が生まれた形になります。

修正

原因がわかったところで修正方法を考えます。

mutate() を追加する

// RecyclerView.onBindViewHolder

    val drawable = ContextCompat.getDrawable(context, R.drawable.ic_bookshelf_category_24dp)
    drawable?.setTint(/* ColorInt */)

    drawable?.mutate()  // ←追加

    imageView.setImageDrawable(drawable)

HiroYUKI Seto さんが発表内で紹介されていた通り、 mutate() を呼ぶことで ConstantState が分離されるのでこれで問題が解消されます。

ただ後々、修正の意図を把握していないエンジニアの方がこのコードを見た時に、意図を調べ直す必要が出るかもしれません。 その点に関してはコメントを付ければいいですが、そもそもこのコードは「同じ画像リソースで色だけ分岐する」という目的に対し Drawable の生成まで記述しているのが冗長に思えてきます。 この処理の流れは、元々 Drawable ごと分岐させていた時の名残ですが、Drawable リソースが固定になったのならxml側で指定してしまいたいですね。

ImageView.setImageTintList() で色指定する

そもそも Drawable にではなく ImageView から tint を指定した場合はどうなるのか、 ImageView.imageTintList の中身を確認しました。

public class ImageView extends View {

    // ~~~~

    public void setImageTintList(@Nullable ColorStateList tint) {
        mDrawableTintList = tint;
        mHasDrawableTint = true;

        applyImageTint();
    }

    private void applyImageTint() {
       if (mDrawable != null && (mHasDrawableTint || mHasDrawableBlendMode)) {
           mDrawable = mDrawable.mutate();

           if (mHasDrawableTint) {
               mDrawable.setTintList(mDrawableTintList);
           }

           if (mHasDrawableBlendMode) {
               mDrawable.setTintBlendMode(mDrawableBlendMode);
           }

           // The drawable (or one of its children) may not have been
           // stateful before applying the tint, so let's try again.
           if (mDrawable.isStateful()) {
               mDrawable.setState(getDrawableState());
           }
       }
    }

    // ~~~~

}

mDrawable = mDrawable.mutate();

ImageView が内部で mutate() を呼んでくれていることが確認できましたので、この方針で修正していきます。 (API 24~29でこの処理に変更がないことも確認しています)

<!-- ImageView内 -->

    app:srcCompat="@drawable/ic_bookshelf_category_24dp"
// RecyclerView.onBindViewHolder

    imageView.imageTintList = ColorStateList.valueOf(/* ColorInt */)

これならシンプルでコメントも要らないですね。 現在この修正を行なったコードでリリースされていますが、新たな不具合などもなくほっとしています。

なお、Studyplusでは現在 minSdkVersion 23 で開発を行なっているため、ImageViewCompat は必要ありませんでした。 ImageViewCompat.setImageTintList では (Build.VERSION.SDK_INT >= 21) で内部分岐を行なっているため、環境によってはこちらを利用しましょう。

なぜ Android 10 では再現しなかったのか

この不具合が世に出てしまったのは自分がOS別検証を怠ったのが原因ではありますが、ではなぜ Android 10 では再現しなかったのか、Android Code Searchで調査してみました。 結果として明確な答えは出なかったのですが、 Drawable への理解が深まったかなとは思います。

mutateを呼ぶようになったわけではない

まずはResourcesImpl.loadDrawable())から流れをまた追ってみましたが、 mutate() が追加されているような箇所は見当たりませんでした。 VectorDrawable クラスには mMutated というBooleanメンバ変数も存在していますが、そちらもfalseのままでした。

ColorFilter の変更

VectorDrawable の draw 関数において、 PorterDuffColorFilter から、 API29で追加されたBlendModeColorFilter への参照変更が見受けられました。

API 25

private PorterDuffColorFilter mTintFilter;

// ~~~~

    final ColorFilter colorFilter = (mColorFilter == null ? mTintFilter : mColorFilter);

API 29

private BlendModeColorFilter mBlendModeColorFilter;

// ~~~~

    final ColorFilter colorFilter = (mColorFilter == null ? mBlendModeColorFilter : mColorFilter);

その後の描画は native 関数になってしまい追えませんでしたが、この描画周りに手が加わったためかなと個人的には推測しています。

終わりに

RecyclerView で アイコンを色分けする際にハマってしまった事例を紹介しました。

今回については、発覚の直前に関連した情報を得られていて運が良かったと思います。 hotfixで急ぎ修正する必要があったこともあり、DroidKaigi 2020 Liteの知見が大いに助けとなりました。 発表者の HiroYUKI Seto さん、DroidKaigi 2020 Lite を企画運営してくださった皆様に多大なる感謝をお送りしたいと思います、ありがとうございます。

本ブログをまとめるにあたり、修正時には急いでいて調べきれなかったことも調査しましたが、様々なクラスについてOSバージョンごとに比較確認する必要がありなかなか大変でした。 最初は mutate() が追加されているんじゃないかと思ったのですが探しても見つからず、改めて最初から関連コードを細かく追い直したりもしました。 修正後はコードも綺麗になり、調査も知見が深まっていい経験になったと思います。 教訓としては、OS別の検証について改めて注意していきたいと思いました。

最後までご覧いただき、ありがとうございました!