Android 深色模式适配
Android 10 开始支持配置深色模式,如果系统是深色主题,但是打开APP又是浅色主题就会显得格格不入。下面介绍几种适配深色模式的方法。
一、forceDarkAllowed
样式中设置 android:forceDarkAllowed
属性,深色主题下系统会自动进行适配。
-
新建 values-v29 目录,因为
android:forceDarkAllowed
属性 Android 10开始才有。 -
设置
android:forceDarkAllowed
属性为true
-
适配效果
布局文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="200dp"
android:minHeight="100dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:text="深色模式"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
浅色主题:
深色主题:
从布局文件中可以看到,并没有设置任何背景色,但深色主题下,APP自动进行了适配。
这种适配方式十分简单,但是不够美观,无法自定义控件颜色样式,全凭系统控制,并不推荐这种自动化方式实现深色模式。
二、设置深色主题
官方推荐另外一种方法,即分别创建浅色和深色的主题样式。
-
新建 values-night 目录,存放深色主题的样式
-
适配效果
浅色主题:
深色主题:
与 forceDarkAllowed
最大的区别在于,深色主题可以手动设置颜色样式。
一些常用的方法:
- 判断深色主题
public static boolean isDarkTheme(Context context) {
int flag = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
return flag == Configuration.UI_MODE_NIGHT_YES;
}
- 代码中切换深色主题
if (isDarkTheme(MainActivity.this)) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
}
- 禁止界面适配深色主题
Activity 的 configChanges
属性当中配置 uiMode
避免Activity 重新创建,从而阻止界面适配深色主题。
<activity
android:exported="true"
android:name=".MainActivity"
android:configChanges="uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
这时候虽然界面不会重新创建,但是会触发 onConfigurationChanged
方法回调,可以根据回调做一些处理。
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
int mSysThemeConfig = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
switch (mSysThemeConfig) {
// 亮色主题
case Configuration.UI_MODE_NIGHT_NO:
break;
// 深色主题
case Configuration.UI_MODE_NIGHT_YES:
break;
default:
break;
}
}
三、使用 Android-skin-support
上述两种方式只支持Android 10 系统,且系统切换深色主题界面会重新创建,并不是太灵活。如果想适配Android 10 以下的系统可以使用 Android-skin-support 框架实现。
Android-skin-support 是一个换肤框架,通过加载不同的皮肤包从而实现换肤,深色模式只需要创建对应的深色主题皮肤包,然后替换当前的默认样式就可以实现适配。
它的实现流程大致如下:
- 控制View的创建,将所有View替换为对应的SkinxxxView
- SkinxxxView中会根据布局中的属性ID值,找到皮肤包中对应的资源进行替换
- 动态换肤,即通知所有的SkinXXXView进行更新
3.1 Android View 创建流程
在使用 Android-skin-support 之前,可以先了解下 Android View 创建流程,有利于我们之后使用该库。
从 setContentView 方法开始,它作用就是设置界面布局资源。
//Activity
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}
调用的Window中的 setContentView 方法。
//PhoneWindow
@Override
public void setContentView(int layoutResID) {
......
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
mLayoutInflater.inflate(layoutResID, mContentParent);
}
......
}
调用 LayoutInflater 的 inflate 方法
//LayoutInflater
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
if (DEBUG) {
Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
+ Integer.toHexString(resource) + ")");
}
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
XmlResourceParser parser = res.getLayout(resource);
try {
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}
可以看到根据布局资源创建一个Xml 解析器进行解析
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
......
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
......
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true);
......
}
}
先通过createViewFromTag方法创建rootView,然后再使用rInflateChildren解析子布局,最终都是通过createView创建View
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
......
View view = tryCreateView(parent, name, context, attrs);
......
}
public final View tryCreateView(@Nullable View parent, @NonNull String name,
@NonNull Context context,
@NonNull AttributeSet attrs) {
......
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
......
}
可以看到如果mFactory2不为空则通过mFactory2来创建View,而mFactory2又是哪里进行初始化的呢?
通过查看代码发现,mFactory2初始化代码如下:
//AppCompatActivity
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
final AppCompatDelegate delegate = getDelegate();
delegate.installViewFactory();
delegate.onCreate(savedInstanceState);
super.onCreate(savedInstanceState);
}
//AppCompatDelegateImpl
@Override
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(mContext);
if (layoutInflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else {
if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
+ " so we can not install AppCompat's");
}
}
}
public static void setFactory2(
@NonNull LayoutInflater inflater, @NonNull LayoutInflater.Factory2 factory) {
inflater.setFactory2(factory);
......
}
public void setFactory2(Factory2 factory) {
//mFactory2 不可以重复设置,否则会直接抛出异常
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
if (factory == null) {
throw new NullPointerException("Given factory can not be null");
}
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
上述代码可以发现,mFactory2其实就是AppCompatDelegateImpl,现在我们再看下AppCompatDelegateImpl的 onCreateView方法
@Override
public View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs) {
......
return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
true, /* Read read app:theme as a fallback at all times for legacy reasons */
VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
);
}
final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
......
View view = null;
// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
case "TextView":
view = createTextView(context, attrs);
verifyNotNull(view, name);
break;
case "ImageView":
view = createImageView(context, attrs);
verifyNotNull(view, name);
break;
case "Button":
view = createButton(context, attrs);
verifyNotNull(view, name);
break;
case "EditText":
view = createEditText(context, attrs);
verifyNotNull(view, name);
break;
......
default:
// The fallback that allows extending class to take over view inflation
// for other tags. Note that we don't check that the result is not-null.
// That allows the custom inflater path to fall back on the default one
// later in this method.
view = createView(context, name, attrs);
}
......
return view;
}
@NonNull
protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
return new AppCompatTextView(context, attrs);
}
上述代码可以看到,创建View其实是通过控件的名称,然后再new对应的控件,通过createTextView可以发现创建的也不是TextView,而是AppCompatTextView,所以只要能够修改mFactory2,就可以控制所有View的创建。
3.2 使用方法
集成 Android-skin-support
implementation 'skin.support:skin-support:4.0.5' // skin-support
implementation 'skin.support:skin-support-appcompat:4.0.5' // skin-support 基础控件支持
implementation 'skin.support:skin-support-design:4.0.5' // skin-support-design material design 控件支持[可选]
implementation 'skin.support:skin-support-cardview:4.0.5' // skin-support-cardview CardView 控件支持[可选]
implementation 'skin.support:skin-support-constraint-layout:4.0.5' // skin-support-constraint-layout ConstraintLayout 控件支持[可选]
初始化
//初始化换肤框架
SkinCompatManager.withoutActivity(this)
//添加各类控件的拦截器
.addInflater(new SkinAppCompatViewInflater())
.addInflater(new SkinConstraintViewInflater())
.addInflater(new SkinCardViewInflater())
.addInflater(new SkinMaterialViewInflater());
//Activity中重写下面方法,可以放到BaseActivity中
@NonNull
@Override
public AppCompatDelegate getDelegate() {
return SkinAppCompatDelegateImpl.get(this, this);
}
withoutActivity 方法
public static SkinCompatManager withoutActivity(Application application) {
init(application);
SkinActivityLifecycle.init(application);
return sInstance;
}
重点看 SkinActivityLifecycle.init 方法,主要做了两件事情:
- 注册Activity生命周期回调,
- 替换系统的mFactory2,控制View的创建
public static SkinActivityLifecycle init(Application application) {
if (sInstance == null) {
synchronized (SkinActivityLifecycle.class) {
if (sInstance == null) {
sInstance = new SkinActivityLifecycle(application);
}
}
}
return sInstance;
}
private SkinActivityLifecycle(Application application) {
//注册Activity生命周期回调
application.registerActivityLifecycleCallbacks(this);
//替换系统的mFactory2,控制View的创建
installLayoutFactory(application);
SkinCompatManager.getInstance().addObserver(getObserver(application));
}
private void installLayoutFactory(Context context) {
try {
LayoutInflater layoutInflater = LayoutInflater.from(context);
LayoutInflaterCompat.setFactory2(layoutInflater, getSkinDelegate(context));
} catch (Throwable e) {
Slog.i("SkinActivity", "A factory has already been set on this LayoutInflater");
}
}
getSkinDelegate(context) 方法,初始化并返回 SkinCompatDelegate 对象
private SkinCompatDelegate getSkinDelegate(Context context) {
if (mSkinDelegateMap == null) {
mSkinDelegateMap = new WeakHashMap<>();
}
SkinCompatDelegate mSkinDelegate = mSkinDelegateMap.get(context);
if (mSkinDelegate == null) {
mSkinDelegate = SkinCompatDelegate.create(context);
mSkinDelegateMap.put(context, mSkinDelegate);
}
return mSkinDelegate;
}
SkinCompatDelegate 实现了LayoutInflater.Factory2 接口,重写 onCreateView 方法,从而控制所有View的创建
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
//创建View
View view = createView(parent, name, context, attrs);
if (view == null) {
return null;
}
//保存到列表中
if (view instanceof SkinCompatSupportable) {
mSkinHelpers.add(new WeakReference<>((SkinCompatSupportable) view));
}
return view;
}
public View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs) {
if (mSkinCompatViewInflater == null) {
mSkinCompatViewInflater = new SkinCompatViewInflater();
}
......
return mSkinCompatViewInflater.createView(parent, name, context, attrs);
}
public final View createView(View parent, final String name, @NonNull Context context, @NonNull AttributeSet attrs) {
View view = createViewFromHackInflater(context, name, attrs);
if (view == null) {
//通过初始化的各个拦截器,根据控件名称创建对应的控件
view = createViewFromInflater(context, name, attrs);
}
if (view == null) {
//如果没有找到,就类似系统的实现方法
view = createViewFromTag(context, name, attrs);
}
if (view != null) {
// If we have created a view, check it's android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
这里的实现有些类似OkHttp的拦截器,View的创建会经过多个 “ViewInflater”,这边选一个拦截器 SkinConstraintViewInflater 看下内部是怎么实现的。
public class SkinConstraintViewInflater implements SkinLayoutInflater {
@Override
public View createView(Context context, final String name, AttributeSet attrs) {
View view = null;
switch (name) {
case "androidx.constraintlayout.widget.ConstraintLayout":
view = new SkinCompatConstraintLayout(context, attrs);
break;
default:
break;
}
return view;
}
}
代码很简单,就是通过控件名称创建自己的 SkinCompatConstraintLayout
换肤控件,
public class SkinCompatConstraintLayout extends ConstraintLayout implements SkinCompatSupportable {
private final SkinCompatBackgroundHelper mBackgroundTintHelper;
......
public SkinCompatConstraintLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mBackgroundTintHelper = new SkinCompatBackgroundHelper(this);
mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr);
}
@Override
public void setBackgroundResource(int resId) {
super.setBackgroundResource(resId);
if (mBackgroundTintHelper != null) {
mBackgroundTintHelper.onSetBackgroundResource(resId);
}
}
//收到换肤事件,
@Override
public void applySkin() {
if (mBackgroundTintHelper != null) {
mBackgroundTintHelper.applySkin();
}
}
}
SkinCompatConstraintLayout 实现 SkinCompatSupportable接口,触发换肤时会调用applySkin方法替换控件的背景。
加载皮肤包
//加载皮肤包
SkinCompatManager.getInstance().loadSkin(DARK_SKIN_NAME, new SkinLoaderListener() {
@Override
public void onStart() {
}
@Override
public void onSuccess() {
}
@Override
public void onFailed(String errMsg) {
}
}, SKIN_LOADER_STRATEGY_ASSETS);
/**
* 加载皮肤包.
*
* @param skinName 皮肤包名称.
* @param listener 皮肤包加载监听.
* @param strategy 皮肤包加载策略.SKIN_LOADER_STRATEGY_ASSETS从assets目录加载皮肤包
*/
loadSkin(String skinName, SkinLoaderListener listener, int strategy)
重点看下loadSkin方法
public AsyncTask loadSkin(String skinName, SkinLoaderListener listener, int strategy) {
SkinLoaderStrategy loaderStrategy = mStrategyMap.get(strategy);
if (loaderStrategy == null) {
return null;
}
return new SkinLoadTask(listener, loaderStrategy).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, skinName);
}
使用AsynTask进行异步操作,加载皮肤包
private class SkinLoadTask extends AsyncTask<String, Void, String> {
private final SkinLoaderListener mListener;
private final SkinLoaderStrategy mStrategy;
SkinLoadTask(@Nullable SkinLoaderListener listener, @NonNull SkinLoaderStrategy strategy) {
mListener = listener;
mStrategy = strategy;
}
@Override
protected void onPreExecute() {
if (mListener != null) {
mListener.onStart();
}
}
@Override
protected String doInBackground(String... params) {
synchronized (mLock) {
while (mLoading) {
try {
mLock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mLoading = true;
}
try {
if (params.length == 1) {
String skinName = mStrategy.loadSkinInBackground(mAppContext, params[0]);
if (TextUtils.isEmpty(skinName)) {
SkinCompatResources.getInstance().reset(mStrategy);
return "";
}
return params[0];
}
} catch (Exception e) {
e.printStackTrace();
}
SkinCompatResources.getInstance().reset();
return null;
}
@Override
protected void onPostExecute(String skinName) {
synchronized (mLock) {
// skinName 为""时,恢复默认皮肤
if (skinName != null) {
SkinPreference.getInstance().setSkinName(skinName).setSkinStrategy(mStrategy.getType()).commitEditor();
notifyUpdateSkin();
if (mListener != null) {
mListener.onSuccess();
}
} else {
SkinPreference.getInstance().setSkinName("").setSkinStrategy(SKIN_LOADER_STRATEGY_NONE).commitEditor();
if (mListener != null) {
mListener.onFailed("皮肤资源获取失败");
}
}
mLoading = false;
mLock.notifyAll();
}
}
}
doInBackground 通过皮肤包名称获取皮肤包资源,notifyUpdateSkin 通知所有 SkinCompatSupportable
对象更新皮肤
创建皮肤包
- 新建一个Application Module
- 创建对应皮肤的颜色等资源
- 资源名称和原工程一样,颜色值修改成对应皮肤包色值
- 打apk包,改为 .skin 文件,放在原工程的 assets目录下
实现效果:
浅色模式
浅色模式色值
点击深色模式按钮切换深色模式
皮肤包的色值
其他使用
- 恢复默认样式
SkinCompatManager.getInstance().restoreDefaultTheme();
- setBackgroundColor、setBackground、setTextColor等方法失效问题
因为框架是根据资源ID,如R.color.black,找到皮肤包中对应名称的资源,所以如果代码中直接设置颜色或者图片,是不支持换肤的。可以改为setBackgroundResource
- 适配Dialog
SkinXXXView 创建时会获取控件设置的颜色(textColor)、背景(background)等属性的ID,然后再根据ID加载皮肤包中对应的资源。
但是系统的Dialog的布局控件并没有设置背景属性 background等,所以默认情况下Dialog是不支持换肤的。
Skin-Support的解决方法就是替换系统默认的Dialog布局,然后再设置对应的颜色等属性,最终实现换肤效果。
在styles.xml中做如下的声明:
<item name="alertDialogStyle">@style/AlertDialog.SkinCompat</item>
<!-- 以下属性需要用户自行实现,并在皮肤包中提供对应值 -->
<item name="skinAlertDialogBackground"></item>
<item name="skinAlertDialogTitleTextColor"></item>
<item name="skinAlertDialogMessageTextColor"></item>
<item name="skinAlertDialogNeutralButtonTextColor"></item>
<item name="skinAlertDialogNegativeButtonTextColor"></item>
<item name="skinAlertDialogPositiveButtonTextColor"></item>
<item name="skinAlertDialogControlHighlightColor"></item>
<item name="skinAlertDialogListDivider"></item>
<item name="skinAlertDialogListItemTextColor"></item>
</style>
- 自定义控件换肤
Skin-Support 只提供系统组件的换肤,自定义控件需要实现 SkinCompatSupportable 接口或者直接继承 SkinXXXView,然后获取布局中属性的ID和重写 applySkin 方法。
public class SkinSwitchButton extends SwitchButton implements SkinCompatSupportable {
private static final String TAG = "SkinSwitchButton";
private int kswThumbDrawable;
private int kswBackDrawable;
public SkinSwitchButton(Context context, AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
//获取SwitchButton的属性ID
TypedArray ta = attrs == null ? null : context.obtainStyledAttributes(attrs, R.styleable.SwitchButton);
if (ta != null) {
kswThumbDrawable = ta.getResourceId(R.styleable.SwitchButton_kswThumbDrawable, INVALID_ID);
kswBackDrawable = ta.getResourceId(R.styleable.SwitchButton_kswBackDrawable, INVALID_ID);
ta.recycle();
}
applySkin();
}
public SkinSwitchButton(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SkinSwitchButton(Context context) {
this(context, null, 0);
}
@Override
public void applySkin() {
//通过ID获取皮肤包对应的资源
kswThumbDrawable = SkinCompatHelper.checkResourceId(kswThumbDrawable);
if (kswThumbDrawable != INVALID_ID) {
setThumbDrawable(SkinCompatResources.getDrawable(getContext(), kswThumbDrawable));
}
kswBackDrawable = SkinCompatHelper.checkResourceId(kswBackDrawable);
if (kswBackDrawable != INVALID_ID) {
setBackDrawable(SkinCompatResources.getDrawable(getContext(), kswBackDrawable));
}
}
}
动态修改颜色
Skin-Support 也支持代码中动态修改颜色,且优先级最高
//动态修改颜色
SkinCompatUserThemeManager.get().addColorState(colorId, newColor);
//动态修改图片
SkinCompatUserThemeManager.get().addDrawablePath(int drawableRes, String drawablePath);
//颜色修改完后需要调用该方法才可以生效
SkinCompatUserThemeManager.get().apply();
//清除自定义的颜色和图片
SkinCompatUserThemeManager.get().clearColors();
SkinCompatUserThemeManager.get().clearDrawables();
参考:
https://www.jianshu.com/p/2c3833b8a1d2?utm_campaign=hugo
https://blog.csdn.net/c10WTiybQ1Ye3/article/details/119223672