Android Q深色主题模式(暗黑模式)适配

Android Q深色主题模式(暗黑模式)适配

深色主题模式简介

深色主题是将应用或者手机背景颜色设置为黑色或深色,深色主题有以下优点

深色主题模式优点

更好的用户体验

帮助你为用户提供一个更好的用户体验,尤其是在某些环境下,例如说在光线比较暗的时候,虽然说在晚上看手机是一个非常不好的习惯,但是我们都知道很多人都有这样子的习惯,那么当光线比较暗的时候,如果用户打开你的应用,这个时候你的应用发出了非常刺眼的亮光,那么这样的一个主题会为用户提供不是那么好的一个体验。所以说针对这样特殊的环境光线暗的环境,如果你能为用户提供一种主题上的选择,那么对他来说是一个更好的体验。

省电

其实在 Android 的过去几个版本上,我们一直都在讲为用户省电过去推出的很多的功能,比如后台上的限制。但其实我们大家都知道电量最多的消耗是当屏幕亮着应用在前台跑的时候,那么如果大家在应用当中加入这种深色主题的话,我们看到真的对用户有非常对电量有非常大的节省,通过减少发光的像素点,我们看到在有的情况下可以帮助用户减少高达60%的电量。

无障碍支持

尤其是对于在视力上有些障碍的用户来说,深色主题对他们来说是非常重要的,因为深色主题可以大大的减少视疲劳。可能也是由于这些原因,近些年来深色主题一直都是安卓用户非常投票非常高的一个非常受欢迎的功能。

因为以上几点很多操作系统或第三方ROM已加入了暗黑或者深色主题模式。原生Android也不能落后,于是Android Q上开始原生支持了,具体使用方式就是在设置->显示->深色主题背景,效果如下:

开启深色主题模式

深色主题模式与夜间模式

说到深色主题模式就不得不说夜间模式了,因为他们两个非常相似。夜间模式是support库在23.2版本开始新增的功能。只不过夜间模式的开关需要应用自己实现,而深色主题模式是整个操作系统已实现全局的切换功能。下面我们简单讲一下夜间模式的适配与源码分析,深色主题模式的适配与夜间模式的适配也大致相同。

夜间模式的适配

  1. 确保需要适配夜间模式的Activity继承于AppCompatActivity
  2. 定义一套主题,可以继承于Theme.AppCompat.DayNight,因为这个主题以及适配了夜间模式。
  3. values-night目录下创建color.xml等文件定义夜间模式的颜色等,同理其他资源目录也一样
  4. 调用AppCompatDelegate.setDefaultNightMode方法设置是否使用夜间模式,参数AppCompatDelegate.MODE_NIGHT_YES打开夜间模式,AppCompatDelegate.MODE_NIGHT_NO关闭夜间模式。

夜间模式源码分析

先看一个大致的流程图:

夜间模式流程

下面进行详细代码分析:首先看一下AppCompatActivity的setDefaultNightMode

public static void setDefaultNightMode(@NightMode int mode) {
    switch (mode) {
        case MODE_NIGHT_NO:
        case MODE_NIGHT_YES:
        case MODE_NIGHT_FOLLOW_SYSTEM:
        case MODE_NIGHT_AUTO_TIME:
        case MODE_NIGHT_AUTO_BATTERY:
            if (sDefaultNightMode != mode) {
                sDefaultNightMode = mode;
                applyDayNightToActiveDelegates();
            }
            break;
        default:
            Log.d(TAG, "setDefaultNightMode() called with an unknown mode");
            break;
    }
}

这个方法主要调用了applyDayNightToActiveDelegates,代码如下:

private static void applyDayNightToActiveDelegates() {
    synchronized (sActiveDelegatesLock) {
        for (WeakReference<AppCompatDelegate> activeDelegate : sActiveDelegates) {
            final AppCompatDelegate delegate = activeDelegate.get();
            if (delegate != null) {
                delegate.applyDayNight();
            }
        }
    }
}

这里通过遍历了sActiveDelegates,sActiveDelegates是从哪来的呢,其实每一个AppCompatActivity都持有一个AppCompatDelegate,在onStart将当前AppCompatDelegate记录到sActiveDelegates,onStop删除,代码如下:

@Override
protected void onStart() {
    super.onStart();
    getDelegate().onStart();
}

@Override
protected void onStop() {
    super.onStop();
    getDelegate().onStop();
}

AppCompatDelegate的onStart和onStop代码如下:

@Override
public void onStart() {
    mStarted = true;

    // This will apply day/night if the time has changed, it will also call through to
    // setupAutoNightModeIfNeeded()
    applyDayNight();

    markStarted(this);
}

@Override
public void onStop() {
    mStarted = false;

    markStopped(this);

    ActionBar ab = getSupportActionBar();
    if (ab != null) {
        ab.setShowHideAnimationEnabled(false);
    }

    if (mHost instanceof Dialog) {
        // If the host is a Dialog, we should clean up the Auto managers now. This is
        // because Dialogs do not have an onDestroy()
        cleanupAutoManagers();
    }
}

这里分别调用了markStarted与markStopped方法,实际就是将AppCompatDelegate示例存放进sActiveDelegates。

我们再回到刚才的applyDayNightToActiveDelegates方法中遍历调用了AppCompatDelegate的applyDayNight方法,我们看一下这个方法是怎么实现的:

private boolean applyDayNight(final boolean allowRecreation) {
    if (mIsDestroyed) {
        // If we're destroyed, ignore the call
        return false;
    }

    @NightMode final int nightMode = calculateNightMode();
    @ApplyableNightMode final int modeToApply = mapNightMode(nightMode);
    final boolean applied = updateForNightMode(modeToApply, allowRecreation);

    if (nightMode == MODE_NIGHT_AUTO_TIME) {
        getAutoTimeNightModeManager().setup();
    } else if (mAutoTimeNightModeManager != null) {
        // Make sure we clean up the existing manager
        mAutoTimeNightModeManager.cleanup();
    }
    if (nightMode == MODE_NIGHT_AUTO_BATTERY) {
        getAutoBatteryNightModeManager().setup();
    } else if (mAutoBatteryNightModeManager != null) {
        // Make sure we clean up the existing manager
        mAutoBatteryNightModeManager.cleanup();
    }

    return applied;
}

这里很多都是状态的重置计算等,我们看到有一个方法名为updateForNightMode,我们看看这个方法干了什么:

private boolean updateForNightMode(@ApplyableNightMode final int mode,
        final boolean allowRecreation) {
    boolean handled = false;
    ...
    if ((sAlwaysOverrideConfiguration || newNightMode != applicationNightMode)
            && !activityHandlingUiMode
            && Build.VERSION.SDK_INT >= 17
            && !mBaseContextAttached
            && mHost instanceof android.view.ContextThemeWrapper) {
        // If we're here then we can try and apply an override configuration on the Context.
        final Configuration conf = new Configuration();
        conf.uiMode = newNightMode | (conf.uiMode & ~Configuration.UI_MODE_NIGHT_MASK);

        try {
            if (DEBUG) {
                Log.d(TAG, "updateForNightMode. Applying override config");
            }
            ((android.view.ContextThemeWrapper) mHost).applyOverrideConfiguration(conf);
            handled = true;
        } catch (IllegalStateException e) {
            // applyOverrideConfiguration throws an IllegalStateException if its resources
            // have already been created. Since there's no way to check this beforehand we
            // just have to try it and catch the exception.
            Log.e(TAG, "updateForNightMode. Calling applyOverrideConfiguration() failed"
                    + " with an exception. Will fall back to using"
                    + " Resources.updateConfiguration()", e);
            handled = false;
        }
    }

    final int currentNightMode = mContext.getResources().getConfiguration().uiMode
            & Configuration.UI_MODE_NIGHT_MASK;

    if (!handled
            && currentNightMode != newNightMode
            && allowRecreation
            && !activityHandlingUiMode
            && mBaseContextAttached
            && (Build.VERSION.SDK_INT >= 17 || mCreated)
            && mHost instanceof Activity) {
        // If we're an attached Activity, we can recreate to apply
        // The SDK_INT check above is because applyOverrideConfiguration only exists on
        // API 17+, so we don't want to get into an loop of infinite recreations.
        // On < API 17 we need to use updateConfiguration before we're 'created'
        if (DEBUG) {
            Log.d(TAG, "updateForNightMode. Recreating Activity");
        }
        ActivityCompat.recreate((Activity) mHost);
        handled = true;
    }

    if (!handled && currentNightMode != newNightMode) {
        // Else we need to use the updateConfiguration path
        if (DEBUG) {
            Log.d(TAG, "updateForNightMode. Updating resources config");
        }
        updateResourcesConfigurationForNightMode(newNightMode, activityHandlingUiMode);
        handled = true;
    }

    if (DEBUG && !handled) {
        Log.d(TAG, "updateForNightMode. Skipping. Night mode: " + mode);
    }

    // Notify the activity of the night mode. We only notify if we handled the change,
    // or the Activity is set to handle uiMode changes
    if (handled && mHost instanceof AppCompatActivity) {
        ((AppCompatActivity) mHost).onNightModeChanged(mode);
    }

    return handled;
}

这个方法比较长,简单分析一下,此方法主要是进行了更新Configuration,分为3种方式:

  1. applyOverrideConfiguration方法新创建Configuration覆盖原有Configuration。此方法仅在api 21-25使用,通过注释发现api 21-25有bug,导致修改根Resource不生效。

  2. ActivityCompat.recreate:当allowRecreation为true时重启activity重建Resource

  3. updateResourcesConfigurationForNightMode 调用Resource的updateConfiguration方法更新Configuration,此处注意sdk 26以下需要刷新Resource的drawable缓存,具体代码可参考ResourcesFlusher.flush(res);

那么Resource是如何确认夜间模式来读取-night目录的资源的呢?

其实在Android SDK 8以上就支持了night模式与资源,我们可以继续跟踪updateResourcesConfigurationForNightMode方法,看最终是怎么实现的。

updateResourcesConfigurationForNightMode方法刚才也说了核心是Resource.updateConfiguration(),最终的实现是在android.content.res.ResourcesImpl#updateConfiguration方法代码如下:

public void updateConfiguration(Configuration config, DisplayMetrics metrics,
                                    CompatibilityInfo compat) {
        Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "ResourcesImpl#updateConfiguration");
        try {
            synchronized (mAccessLock) {
                ……
                mAssets.setConfiguration(mConfiguration.mcc, mConfiguration.mnc,
                        adjustLanguageTag(mConfiguration.getLocales().get(0).toLanguageTag()),
                        mConfiguration.orientation,
                        mConfiguration.touchscreen,
                        mConfiguration.densityDpi, mConfiguration.keyboard,
                        keyboardHidden, mConfiguration.navigation, width, height,
                        mConfiguration.smallestScreenWidthDp,
                        mConfiguration.screenWidthDp, mConfiguration.screenHeightDp,
                        mConfiguration.screenLayout, mConfiguration.uiMode,
                        mConfiguration.colorMode, Build.VERSION.RESOURCES_SDK_INT);

                ……
            }
            synchronized (sSync) {
                if (mPluralRule != null) {
                    mPluralRule = PluralRules.forLocale(mConfiguration.getLocales().get(0));
                }
            }
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
        }
    }

省略不必要部分,核心代码调用了 mAssets.setConfiguration继续跟踪:

public void setConfiguration(int mcc, int mnc, @Nullable String locale, int orientation,
            int touchscreen, int density, int keyboard, int keyboardHidden, int navigation,
            int screenWidth, int screenHeight, int smallestScreenWidthDp, int screenWidthDp,
            int screenHeightDp, int screenLayout, int uiMode, int colorMode, int majorVersion) {
        synchronized (this) {
            ensureValidLocked();
            nativeSetConfiguration(mObject, mcc, mnc, locale, orientation, touchscreen, density,
                    keyboard, keyboardHidden, navigation, screenWidth, screenHeight,
                    smallestScreenWidthDp, screenWidthDp, screenHeightDp, screenLayout, uiMode,
                    colorMode, majorVersion);
        }
    }

发现其最终实现是在nativeSetConfiguration方法,是一个native方法,声明如下:

private static native void nativeSetConfiguration(long ptr, int mcc, int mnc,
            @Nullable String locale, int orientation, int touchscreen, int density, int keyboard,
            int keyboardHidden, int navigation, int screenWidth, int screenHeight,
            int smallestScreenWidthDp, int screenWidthDp, int screenHeightDp, int screenLayout,
            int uiMode, int colorMode, int majorVersion);

深色主题适配

Android Q的深色主题模式给开发者提供了比较大的开发空间,开发者可以直接定义一套深色的颜色与资源随系统自动切换,开发者也可以读取或监听深色主题模式的状态,通过这个状态手动切换APP的主题。

主题方式适配

此时Android Q的深色主题模式就可以使用夜间模式的主题,该夜间主题是Theme.AppCompat.DayNight,与它相关的还有DayNight.NoActionBar,DayNight.DarkActionBar,DayNight.Dialog,而且其主题向下兼容到了API14。

  1. 定义主题

    首先定义一套主题继承于Theme.AppCompat.DayNight

     <style name="AppTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
         <item name="colorPrimary">@color/colorPrimary</item>
         <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
         <item name="colorAccent">@color/colorAccent</item>
         <item name="windowActionBar">false</item>
         <item name="windowNoTitle">true</item>
     </style>
    
  2. 定义资源

    与夜间模式一样对于颜色主题等文件可以创建一个values-night目录,再创建color.xml文件等,在color.xml文件中定义深色主题的同名色值如下:

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
     <color name="colorPrimary">#000000</color>
     <color name="colorPrimaryDark">#666666</color>
     <color name="colorAccent">#333333</color>
    </resources>
    

    对于图片等资源文件也可以创建drawable-night、drawable-night-xxhdpi等目录,放置对应的图片等资源。

    至此我们完成了深色主题的适配。

换肤方式适配

这种方式主要是获取和监听系统深色主题模式的开启状态来动态设置主题或者皮肤。

首先我们需要定义好深色主题或者皮肤,本例换肤功能通过Android-skin-support实现。

Activity onCreate方法中setContentView之前判断是否开启暗黑模式,设置暗黑主题

        int mode = this.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
        if (mode == Configuration.UI_MODE_NIGHT_YES && !"night".equals(SkinPreference.getInstance().getSkinName())) {
            SkinCompatManager.getInstance().loadSkin("night", null, SkinCompatManager.SKIN_LOADER_STRATEGY_BUILD_IN);
        } else if (mode == Configuration.UI_MODE_NIGHT_NO && "night".equals(SkinPreference.getInstance().getSkinName())) {
            SkinCompatManager.getInstance().restoreDefaultTheme();
        }

到这里就完成了适配Android Q的深色主题模式的换肤功能。如果Activity配置android:configChanges=”uiMode”也可通过下面方式实现换肤onConfigurationChanged中监听暗黑模式状态,此处采用了recreate方法重建Activity来换肤

    override fun onConfigurationChanged(newConfig: Configuration) {
        super.onConfigurationChanged(newConfig)
        when (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
            Configuration.UI_MODE_NIGHT_YES -> {
                // 暗黑模式已开启
                SkinCompatManager.getInstance().loadSkin("night", null, SkinCompatManager.SKIN_LOADER_STRATEGY_BUILD_IN);
            }
            Configuration.UI_MODE_NIGHT_NO -> {
                // 暗黑模式已关闭
                SkinCompatManager.getInstance().restoreDefaultTheme();
            }
        }
    }

   转载规则


《Android Q深色主题模式(暗黑模式)适配》 KongXiaojun 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
58App新版首页之AppBarLayout与RecyclerView的Fling连接 58App新版首页之AppBarLayout与RecyclerView的Fling连接
AppBarLayout与底部RecyclerView的Fling无法连接,导致在AppBarLayout往上Fling时当滚动到AppBarLayout底部时会立即停住,动画会比较生硬。
2019-09-23
下一篇 
Android Lottie动画实战踩坑 Android Lottie动画实战踩坑
Lottie是一个iOS,Android和React Native库,可以实时渲染After Effects动画,允许应用程序像使用静态图像一样轻松使用动画。
2019-09-18
  目录