LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


LIFF Android でのダークモード対応について

こんにちは。コミュニケーションアプリ「LINE」のAndroidクライアント開発をしている小川琢也です。

今回は、LINE Android アプリの LIFF ブラウザでダークモード対応を行った際の話を紹介します。

LIFF ブラウザ

LIFF は LINE Front-end Framework の略で、LINE が提供するウェブアプリのプラットフォームです。 LIFF についての詳細は こちらを参照ください。 このプラットフォーム上で動作するウェブアプリを LIFF アプリと呼び、これら LIFF アプリを LINE アプリの上で表示するためのブラウザが LIFF ブラウザです。

Android での LIFF ブラウザは WebView を利用しており、ダークモードの対応も WebView に沿ったものとなります。

WebView のダークモード対応

Andrid 12 (Api level 32) まで

androidx.webkit を利用し、 WebSettingsCompat.setForceDark  
WebSettingsCompat.setForceDarkStrategy を呼び出すことにより、ダークテーマ対応のウェブサイトを WebView 上にダークテーマ表示することができます。

setForceDark は設定値として以下の値を取ります

value
FORCE_DARK_AUTO (default) 親の View の状態に従って force dark を有効にする
FORCE_DARK_OFF 親の View の状態に関係なく force dark を無効にする
FORCE_DARK_ON 親の View の状態に関係なく force dark を有効にする

setForceDarkStrategy の設定値と表示されるウェブサイトに適用される CSS media query prefers-color-scheme の関係は以下のとおりです。
設定値により適用される prefers-color-scheme に違いがあります。

value prefers-color-scheme (dark mode site) prefers-color-scheme (no dark mode site)
DARK_STRATEGY_WEB_THEME_DARKENING_ONLY ウェブページが提供するダークテーマによりダーク表示されます。ダークテーマが提供されていなければデフォルトのテーマで表示されます dark light (default)
DARK_STRATEGY_USER_AGENT_DARKENING_ONLY ウェブページがダークテーマを提供していても無視され、強制ダーク表示されます light light
DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING ウェブページがダークテーマをサポートしていればそちらによりダーク表示され、サポートされてない場合に強制ダーク表示されます dark light (default)

User Agent とはウェブページにアクセスする際にHTTPを解釈するプログラム、ソフトウェアのことであり、ウェブブラウザやウェブクローラなどのことを指し、ここではウェブブラウザに該当します。

以下、setForceDark  setForceDarkStrategy に渡す設定値を変えた場合の WebView 上の表示の具体例です。

ダークテーマ非適用サイト

ダークモードに対応していないウェブサイト例です。
ウェブサイトにダークモードの定義がないので、DARK_STRATEGY_WEB_THEME_DARKENING_ONLY の場合はテーマに従った表示の変更は発生せず、DARK_STRATEGY_USER_AGENT_DARKENING_ONLY または DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING の場合は、ブラウザにより薄い背景色がダークな色に書き換えられて、白字は黒字となって表示されることがわかります。

force dark OFF ON (DARK_STRATEGY_WEB_THEME_DARKENING_ONLY) ON (DARK_STRATEGY_USER_AGENT_DARKENING_ONLY) ON (DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING)
background #ffffff
background #cbf7c7
background #90ee90
background #487047
background #000000

ダークテーマ適用サイト

先ほどのウェブサイトにおいてダークモードの場合に backgroundColor が Gray となるようにダークテーマ定義を追加した際の挙動です。
DARK_STRATEGY_WEB_THEME_DARKENING_ONLY  DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING の場合はダークテーマ表示され、DARK_STRATEGY_USER_AGENT_DARKENING_ONLY の場合は先ほどと同じ ブラウザ による色の書き換えが起きることがわかります。

force dark OFF ON (DARK_STRATEGY_WEB_THEME_DARKENING_ONLY) ON (DARK_STRATEGY_USER_AGENT_DARKENING_ONLY) ON (DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING)
background #ffffff
background #cbf7c7
background #90ee90
background #487047
background #000000

ブラウザによる表示の変更では期待しない表示となることが発生しうるため、基本的には DARK_STRATEGY_WEB_THEME_DARKENING_ONLY  setForceDark の組み合わせで使うこととなるかと思います。

    val webContentForceDarkMode = if (isDarkModeEnabled) {
        WebSettingsCompat.FORCE_DARK_ON
    } else {
        WebSettingsCompat.FORCE_DARK_OFF
    }
    if (WebViewFeature.isFeatureSupported(FORCE_DARK)) {
        WebSettingsCompat.setForceDark(webViewSettings, webContentForceDarkMode)
    }
    if (WebViewFeature.isFeatureSupported(FORCE_DARK_STRATEGY)) {
        WebSettingsCompat.setForceDarkStrategy(webViewSettings, DARK_STRATEGY_WEB_THEME_DARKENING_ONLY)
    }

Android 13 (Api level 33) 以降

WebSettingsCompat.setForceDarkStrategy  WebSettingsCompat.setForceDark はともに Deprecated となり、呼び出しても何も起きなくなりました。
代わりに WebView に適用されている テーマ の isLightTheme の値に従って prefers-color-scheme が渡され、ウェブアプリはそれに従ったテーマ表示を行うようになります。 各継承元のテーマと WebView に適用されるテーマの関係は以下のようになり、DayNightテーマが継承されている場合に、OSのダークモードの設定にしたがった表示が行われることになります。

theme isLightTheme prefers-color-scheme
Theme.AppCompat.NoActionBar N/A light
Theme.AppCompat.Light.NoActionBar true light
Theme.AppCompat.Night.NoActionBar false dark
Theme.AppCompat.DayNight.NoActionBar false at light mode/true at dark mode light at light mode/dark at dark mode

また、ブラウザによるダークテーマ表示を行うための互換性のための API として WebSettingsCompat.setAlgorithmicDarkeningAllowed という API が追加されました。

以下、DayNightテーマを使ったサンプルアプリにて先ほどのウェブサイトを表示した例です。

ダークテーマ非適用サイト

setAlgorithmicDarkeningAllowed により 
setForceDarkStrategy  DARK_STRATEGY_USER_AGENT_DARKENING_ONLY が設定された時と同じ表示となります。

dark mode OFF ON(darkning false) ON(setAlgorithmicDarkeningAllowed true)
background #ffffff
background #cbf7c7
background #90ee90
background #487047
background #000000

ダークテーマ適用サイト

ウェブサイトのダークテーマが反映された表示になり、setAlgorithmicDarkeningAllowed の影響は受けません。

dark mode OFF ON(darkning false) ON(setAlgorithmicDarkeningAllowed true)
background #ffffff
background #cbf7c7
background #90ee90
background #487047
background #000000

ブラウザによる表示の変更が不必要であれば、DayNight テーマを適用するだけで、WebView 上の表示はダークモード対応としては十分となります。

強制ダークテーマで使われるアルゴリズム

Android 12 (API 32) での setForceDarkStrategy に DARK_STRATEGY_USER_AGENT_DARKENING_ONLY が指定された場合、および Android 13 (API 33) での setAlgorithmicDarkeningAllowed が有効になった場合に強制ダークテーマがどう適用されるかについて説明します。

HTML ファイルが描画されるまでの流れは以下のようになっており、 HTML の解析、CSS の解析をしたのち Render Tree を構築し、Layout 作業でHTML の各 node のサイズと位置を計算し、Painting にて結果のレンダリングが行われます。

Android WebView (Chrome) では、WebKit から分岐して開発が行われている Blink という レンダリングエンジンが使われています。 Blink では 強制ダークテーマの適用は Painting フェーズにて以下の処理により行われます。

まず、Brightness の計算が行なわれます。

int DarkModeColorClassifier::CalculateColorBrightness(SkColor color) {
  int weighted_red = SkColorGetR(color) * 299;
  int weighted_green = SkColorGetG(color) * 587;
  int weighted_blue = SkColorGetB(color) * 114;
  return (weighted_red + weighted_green + weighted_blue) / 1000;
}

この計算は W3 にて推奨された計算方法で、こちらに記載があります。

計算された Brightness が決められた閾値内にある場合 (Background 要素の場合 205 より大きい場合、Foreground 要素の場合 150 より小さい場合)に次の InvertColor が呼ばれ色の変換が行われます。

  SkColor4f InvertColor(const SkColor4f& color) const override {
    SkV3 rgb = {color.fR, color.fG, color.fB};
    SkV3 lab = transformer_.SRGBToLAB(rgb);
    lab.x = std::min(110.0f - lab.x, 100.0f);
    rgb = transformer_.LABToSRGB(lab);

    SkColor4f inverted_color{rgb.x, rgb.y, rgb.z, color.fA};
    return AdjustGray(inverted_color);
  }

sRGB カラーを CIE XYZ カラースペースに変換し、さらに CIE LAB カラースペースへと変換します。 詳細な変換の計算法についてはこちらに記載されています。

CIE LAB カラースペースでは、x が Lightness (明度) を意味し、人間の明度の知覚に近い 0(Black) から 100(White) の値をとります。 110.0f - lab.x  100.0f のうち小さい値を明度とすることによりダークな明度への変換が行われます。 その後、CIE LAB カラースペースから sRGB に戻し、マテリアルデザインで推奨されるダークグレー色が使われるように調整されたダークテーマ適用後の色となります。

LIFF ブラウザでのダークモード対応

LINE アプリの着せかえ機能の考慮

LINE アプリには「着せかえ」機能があり、「着せかえ」が有効になっている場合は Android 端末のダークモード設定には依存せずにアプリ全体としてライトモードが適用されるようになっています。
また、着せかえ設定として「ダークモードではブラック着せかえを適用」というオプションがあり、こちらが設定されている場合にはライトモードとするかダークモードとするかは Android 端末のダークモード設定に従うようになっています。

LINE アプリでのダークモード設定は、アプリ起動時にローカルデータベースに保存された「着せかえ」の情報と Android 端末のダークモード設定を以下の流れにより決定し、 AppCompatDelegate.setDefaultNightMode を呼び出すことで設定しています。

setDefaultNightMode が取る現在有効な設定値は以下の通りです。

value
MODE_NIGHT_FOLLOW_SYSTEM (default) Android system のナイトモード設定に従う
MODE_NIGHT_AUTO_BATTERY バッテリーセイバー設定が有効な時にナイトモード設定を使う
MODE_NIGHT_NO 常にライトモードを使う
MODE_NIGHT_YES 常にナイトモードを使う

この値は getDefaultNightMode により参照可能です。

LIFF アプリへのダークテーマ設定

LIFF アプリはそれぞれが、ライトテーマのみ利用可能か、ダークテーマとライトテーマの両方を利用可能かの設定値を持っています。

LIFF アプリを LIFF ブラウザ上に表示する際には、アプリとしてダークモードが適用されており、かつ LIFF アプリがダークテーマ利用を許可されている場合に WebView にダークテーマ表示にする必要があります。

前述の通り、Android 12 (API 32) までは setForceDark を使って、WebView の状態を動的に変更することが可能でしたが、Android 13 (API 33) 以降は WebView へのダークテーマの適用は Android アプリのテーマに従うので、LIFF の設定情報を反映させるには、開かれる LIFF アプリに応じて Android アプリのテーマを切り替えることが必要となります。

setDefaultNightMode

Android アプリのテーマの切り替えは AppCompatDelegate.setDefaultNightMode を使って行うことができます。

AppCompatActivity を継承している Activity では onCreate の際に AppCompatDelegate の instance を生成し、delegate.onCreate にて active な AppCompatDelegate  sActivityDelegates という ArraySet に保持するようになっています。

public class AppCompatActivity extends FragmentActivity implements AppCompatCallback,
        TaskStackBuilder.SupportParentable, ActionBarDrawerToggle.DelegateProvider {
...

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        final AppCompatDelegate delegate = getDelegate();
        delegate.installViewFactory();
        delegate.onCreate(savedInstanceState);
        if (delegate.applyDayNight() && mThemeId != 0) {
            // If DayNight has been applied, we need to re-apply the theme for
            // the changes to take effect. On API 23+, we should bypass
            // setTheme(), which will no-op if the theme ID is identical to the
            // current theme ID.
            if (Build.VERSION.SDK_INT >= 23) {
                onApplyThemeResource(getTheme(), mThemeId, false);
            } else {
                setTheme(mThemeId);
            }
        }
        super.onCreate(savedInstanceState);
    }
public abstract class AppCompatDelegate {
...
    @NightMode
    private static int sDefaultNightMode = MODE_NIGHT_UNSPECIFIED;

    /**
     * All AppCompatDelegate instances associated with a "live" Activity, e.g. lifecycle state is
     * post-onCreate and pre-onDestroy. These instances are used to instrument night mode's uiMode
     * configuration changes.
     */
    private static final ArraySet<WeakReference<AppCompatDelegate>> sActivityDelegates =
            new ArraySet<>();
    

それぞれの AppCompatDelegate はナイトモードに関する設定値を保持しており、setDefaultNightMode が呼ばれると sActivityDelegates に保持されている全ての AppCompatDelegate に対して applyDayNight を呼び出して NightMode の設定が反映されます。

setDefaultNightMode の影響範囲はアプリ全体であり、LIFF を閉じた際に元に戻す考慮が必要となるので適していません。

    private static void applyDayNightToActiveDelegates() {
        synchronized (sActivityDelegatesLock) {
            for (WeakReference<AppCompatDelegate> activeDelegate : sActivityDelegates) {
                final AppCompatDelegate delegate = activeDelegate.get();
                if (delegate != null) {
                    if (DEBUG) {
                        Log.d(TAG, "applyDayNightToActiveDelegates. Applying to " + delegate);
                    }
                    delegate.applyDayNight();
                }
            }
        }
    }

setLocalNightMode

現在の表示されている Activity にのみ設定を反映させる API として setLocalNightMode が用意されています。

AppCompatDelegate が持つ setDefaultNightMode によって設定されたナイトモード設定値を上書きするようになっているため、 LIFF 画面のみのように特定の画面のみに反映させるにはこちらが使えます。

    @Override
    @RequiresApi(17)
    public void setLocalNightMode(@NightMode int mode) {
        if (DEBUG) {
            Log.d(TAG, String.format("setLocalNightMode. New: %d, Current: %d",
                    mode, mLocalNightMode));
        }
        if (mLocalNightMode != mode) {
            mLocalNightMode = mode;
            if (mBaseContextAttached) {
                // If we the base context is attached, we call through to apply the new value.
                // Otherwise we just wait for attachBaseContext/onCreate
                applyDayNight();
            }
        }
    }

動的な設定の反映

さらに LIFF Browser では、LIFF アプリを WebView に表示している状態から別の LIFF アプリを同じ WebView 上に表示することがあり、この考慮も必要となります。

最初に開かれた LIFF アプリがダークテーマの利用を許可されおり、次に開いた LIFF アプリには許可されていないような場合では、WebView を開いている Activity 上で動的にテーマの変更が必要となるのですが、setLocalNightMode は Activity の onCreate でしか設定値の反映させることができない制限があり、その他の場所から呼び出した場合は Activity を recreate する必要があります。

以下に Android 13 (API 33) の端末上で必要となるシーケンス図を記載します。
ここでは OS のダークモードが有効、LIFF_A がダークテーマ表示可能、LIFF_B はダークテーマ表示不可な場合としています。

WebView を表示する Activity 上で onCreate が呼び出されたタイミング上で適用されるテーマが決定され、WebView に反映されるのがライトテーマになるのか、ダークテーマになるのかが決まります。

recreate を行った場合は Activity の onDestroy が呼ばれ、その後 onCreate が呼び出されるので、WebView の状態を復元するために saveState と restoreState も実行することも必要となります。

なお、ここまで LIFF ブラウザにダークモードを適用するために必要となる実装について説明しましたが、LIFF ブラウザでは LIFF アプリ向けに提供されているフロントエンドの LIFF SDK の挙動による制約の関係で、現在 saveState/restoreState をまだ利用できていない状況です。 このため、現在は上記の手法は取り入れることはできず、LIFF アプリのダークテーマ設定値に従ってライトテーマ・ダークテーマを表示し分けることは残念ながらできていません。

LIFF アプリのダークテーマの利用可否の設定値が導入された背景がダークテーマ未対応な LIFF アプリがダークテーマ表示された際の意図しない表示の問題を避けるためでした。 昨今はウェブサイトにダークテーマを正しく適用するサイトも増えてきており、またこの設定値によりダークテーマの制御をすることはアクセシビリティの観点はよくなさそうという話となり、これを機に全ての LIFF アプリに対してダークテーマ設定を許可するように設定値について見直すこととなりました。 LIFF ブラウザ上ではアプリ全体のダークモード設定に従うことで十分となり、複雑な考慮は必要なくなりました。

まとめ

今回は LINE の LIFF ブラウザで利用している WebView をダークモード対応するために調査したこと、解決策として考えたことについて紹介しました。

今回紹介した内容が少しでも参考になれば幸いです。

参考

LIFF

Android dark theme

Blink

色変換