文章目录
换肤方案
据我所知目前Android换肤有两种类型,静态换肤和动态换肤;静态换肤就是将所有的皮肤方案放到项目中,而动态换肤则就是从网络加载皮肤包动态切换;
通常静态换肤是通过Theme实现,通过在项目中定义多套主题,使用setTheme方法切换的方式实现换肤;
动态换肤是通过替换系统的Resouce动态加载下载到本地的资源包实现换肤。
实际上静态换肤还有一种方式,使用系统自带的UiModeManager,只是它只能用来实现夜间模式。
下面我们对这个三种换肤方式进行讲解。
Theme换肤
这种方式是谷歌官方推荐的方式,很多google的app都是使用的这种方式,据说知乎也是使用的这种方式,这种方式的优点就是使用很简单,首先在res/color.xml下定义多套颜色资源(需要几套皮肤就定义几套,我们这里用两套):
<?xml version="1.0" encoding="utf-8"?>
<resources>
//日间模式
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
//夜间模式
<color name="nightColorPrimary">#3b3b3b</color>
<color name="nightColorPrimaryDark">#383838</color>
<color name="nightColorAccent">#a72b55</color>
</resources>
然后在定义两套主题,分别引用不同的颜色资源:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
//日间模式主题
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<!--自定义的属性 用作背景色-->
<item name="ColorBackground">@color/backgroundColor</item>
<!--文字颜色-->
<item name="android:textColor">@color/textColor</item>
</style>
//夜间模式主题
<style name="NightAppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/nightColorPrimary</item>
<item name="colorPrimaryDark">@color/nightColorPrimaryDark</item>
<item name="colorAccent">@color/nightColorAccent</item>
<!--自定义的属性 用作背景色-->
<item name="ColorBackground">@color/nightColorPrimary</item>
<!--文字颜色-->
<item name="android:textColor">@android:color/white</item>
</style>
</resources>
接着在布局文件中通过下面的方式引用资源文件
android:background="?attr/colorPrimary"
最后在Activity的setContentView方法前设置想要的主题就可以了。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if () {
setTheme(R.style.AppTheme);
}
setContentView(R.layout.activity_main2);
}
这里有几个问题:
1.当我们在应用中切换主题时只有重新创建的Activity才会使用新的主题,所以这里就需要我们手动调用一下recreate()方法,这样就可以重新创建页面切换主题,但是这样做有两个弊端,一是屏幕会出现一下闪烁,二是页面重新创建之后要考虑数据的恢复。这个问题也是这个方案最大的弊端,我们可以不重新创建Activity而是在切换主题后手动修改已创建Activity的View的颜色等信息,但是我们不能在每个页面都写上修改页面View信息的方法,这样的工作量太大了,我们把切换主题的页面入口放到最底层,也就是说如果你想切换主题必须回到主页面,这样我们只需要在主页面添加这样的方法就可以了,这也算一个取巧的方法。
2.这个方案如果用在新项目上貌似没有什么不妥,但是如果一个老项目想用这个方案就很难受了,因为除了要定义多套资源外,我们还要把布局文件中的资源引用全部修改一遍,反正我是不想这么做。
Resouce换肤
动态换肤的一般步骤为:
- 下载并加载皮肤包
- 拿到皮肤包Resource对象
- 标记需要换肤的View
- 缓存需要换肤的View
- 切换时即时刷新页面
- 制作皮肤包
下面的代码参考一个动态换肤框架
Android-Skin-Loader
2.拿到皮肤包Resource对象
public Resources getSkinResources(Context context){
/**
* 插件apk路径
*/
String apkPath = Environment.getExternalStorageDirectory()+"/skin.apk";
AssetManager assetManager = null;
try {
AssetManager assetManager = AssetManager.class.newInstance();
AssetManager.class.getDeclaredMethod("addAssetPath", String.class).invoke(assetManager, apkPath);
} catch (Throwable th) {
th.printStackTrace();
}
return new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
}
构造一个AssetManager对象并调用addAssetPath方法设置资源文件的路径,由于addAssetPath方法是hide注解的,我们不能直接调用,所以我们通过反射来调用这个方法,最后使用我们构造的AssetManager对象和原来的DisplayMetrics、Configuration构造一个Resources对象。
拿到Resoures对象通过下面的方法获取需要的资源:
getIdentifier(String name, String defType, String defPackage)
第一个参数是资源的名称,比如R.color.red,其中red就是name
第二个参数是资源类型,比如R.String.appname,其中String就是类型
第三个参数是资源所在的包名,这是打皮肤包设置的,一般是自己应用的包名
3.标记需要换肤的View
在布局文件中自定义一个属性,例如:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:skin="http://schemas.android.com/android/skin"
android:layout_width="match_parent"
android:layout_height="match_parent"
skin:enable="true"
android:background="@color/color_app_bg" >
<TextView
android:id="@+id/detail_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
skin:enable="true" />
</RelativeLayout>
skin:enable=“true” 就是我们自定义的属性,名字什么的都无所谓。
4.缓存需要换肤的View
这里需要用到一个工具LayoutInflaterFactory,它可以拦截替换View的创建过程,具体的介绍看一下这篇文章Android技能树 — LayoutInflater Factory小结,我们在view创建之前(一般是setContentView方法之前)设置自定义的LayoutInflaterFactory就可以拿到并缓存被标记的View。
public class SkinInflaterFactory implements LayoutInflater.Factory {
//缓存View
private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
// if this is NOT enable to be skined , simplly skip it
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
if (!isSkinEnable) {
return null;
}
//创建view
View view = createView(context, name, attrs);
if (view == null) {
return null;
}
//缓存view
parseSkinAttr(context, attrs, view);
return view;
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLayoutInflater().setFactory(new SkinInflaterFactory());
setContentView(R.layout.activity_main2);
}
如果Acitivity继承的是AppCompatActivity,可以直接调用:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
LayoutInflaterCompat.setFactory(LayoutInflater.from(this),new SkinInflaterFactory());
setContentView(R.layout.activity_main2);
}
在Android3.0之后新增了LayoutInflater.Factory2接口,我们最好使用新的接口,使用起来是类似的,只是把Factory改成Factory2:
public class SkinInflaterFactory implements LayoutInflater.Factory2 {
//缓存View
private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// if this is NOT enable to be skined , simplly skip it
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
if (!isSkinEnable) {
return null;
}
//创建view
View view = createView(context, name, attrs);
if (view == null) {
return null;
}
//缓存view
parseSkinAttr(context, attrs, view);
return view;
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLayoutInflater().setFactory2(new SkinInflaterFactory());
setContentView(R.layout.activity_main2);
}
如果Acitivity继承的是AppCompatActivity,可以直接调用:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
LayoutInflaterCompat.setFactory2(LayoutInflater.from(this),new SkinInflaterFactory());
setContentView(R.layout.activity_main2);
}
如果skin:enbale不为true则直接返回null交给系统默认去创建。而如果为true,则自己去创建这个View,并将这个VIew的所有属性比如id, width height,textColor,background等与支持换肤的属性进行对比。比如我们支持换background textColor listSelector等, android:background="@color/hall_back_color" 这个属性,在进行换肤的时候,如果皮肤包里存在hall_back_color这个值的设置,就将这个颜色值替换为皮肤包里的颜色值,以完成换肤的需求。同时,也会将这个需要换肤的View保存起来。
如果在切换换肤之后,进入一个新的页面,就在进入这个页面Activity的 InlfaterFacory的onCreateView里根据skin:enable=“true” 这个标记,进行判断。为true则进行换肤操作。而对于切换换肤操作时,已经存在的页面,就对这几个存在页面保存好的需要换肤的View进行换肤操作。
这里并没有把所有方法都贴出来,想继续深入的可以去看框架的源码。
5.切换时即时刷新页面
每个Activity的SkinInflaterFactory中都有着一个缓存View的集合,使用观察者模式在换肤成功之后通知到每个Activity去刷新View。
6.制作皮肤包
- 新建工程project
- 将换肤的资源文件添加到res文件下,无java文件
- 直接运行build.gradle,生成apk文件(注意,运行时Run/Redebug configurations 中Launch Options选择launch nothing),否则build 会报 no default Activty的错误。
- 将apk文件重命名如black.apk,重命名为black.skin防止用户点击安装
UiModeManager换肤
UiModeManager是在API8添加的,它用来管理界面显示模式的服务,我们实现夜间模式主要用到他的setNightMode方法,我们看一下这个方法的注释:
On API 22 and below, changes to the night mode
* are only effective when the {@link Configuration#UI_MODE_TYPE_CAR car}
* or {@link Configuration#UI_MODE_TYPE_DESK desk} mode is enabled on a
* device. Starting in API 23, changes to night mode are always effective.
*/
public void setNightMode(@NightMode int mode)
在API22及其以下的Android版本,只有在设置了UI_MODE_TYPE_CAR或者UI_MODE_TYPE_DESK之后,设置night模式才会有效。(翻译的我都不懂了),简单说就是如果你想设置night也就是夜间模式,必须先设置UI_MODE_TYPE_CAR或者UI_MODE_TYPE_DESK其中一个,第一个是驾驶模式,第二个我也搞不懂是什么,反正设置了这两个flag之后系统UI会有变动我们不能设置。那怎么办呢?当然有办法。
分析了源码之后发现setNightMode最终是通过设置Configuration的uiMode属性来实现的夜间模式,那不就简单了,我们自己也可以设置呀,这样就可以跳过驾驶模式了:
public static void updateNightMode(boolean on) {
DisplayMetrics dm = sRes.getDisplayMetrics();
Configuration config = sRes.getConfiguration();
config.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK;
config.uiMode |= on ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO;
sRes.updateConfiguration(config, dm);
}
调用这个方法就可以实现夜间模式,哦,不对,我们还没放资源文件呢 ,这里就非常简单了,是我最喜欢的一种换肤方式,直接在res下创建-night结尾的资源文件夹,然后放入你想修改的资源就可以了,比如我想修改color.xml下的colorPrimary,先创建values-night文件夹,然后将values下的color.xml拷贝过去,再修改values-night文件夹下的colorPrimary,这样切换到夜间模式就会直接使用values-night下的colorPrimary了。注意这里还有一个问题,我们切换到夜间模式时已经创建的Activity不会改变,还是要重新创建。
我封装了一个简单的类用来实现这种方式:
public class NightModeHelper {
private static final String TAG = "NightModeHelper";
private static final String PREF_KEY = "nightModeState";
public static void updateConfig(Context context) {
int currentMode = (context.getResources().getConfiguration()
.uiMode & Configuration.UI_MODE_NIGHT_MASK);
SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
updateConfig(context, mPrefs.getInt(PREF_KEY, currentMode));
}
private static void updateConfig(Context context, int newNightMode) {
if (context == null) {
return;
}
Resources res = context.getResources();
Configuration conf = res.getConfiguration();
int currentNightMode = conf.uiMode & Configuration.UI_MODE_NIGHT_MASK;
if (currentNightMode != newNightMode) {
Configuration config = new Configuration(conf);
DisplayMetrics metrics = res.getDisplayMetrics();
config.uiMode = newNightMode | (config.uiMode & ~Configuration.UI_MODE_NIGHT_MASK);
res.updateConfiguration(config, metrics);
// if (!(Build.VERSION.SDK_INT >= 26)) {
// ResourcesFlusher.flush(res);
// }
} else {
Log.d(TAG, "applyNightMode() | Skipping. Night mode has not changed: " + newNightMode);
}
}
public static int getCurrentMode(Context context) {
SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
return mPrefs.getInt(PREF_KEY, context.getResources().getConfiguration()
.uiMode & Configuration.UI_MODE_NIGHT_MASK);
}
/**
* 手动设置模式
* @param context
* @param mode {@link Configuration#UI_MODE_NIGHT_YES} {@link Configuration#UI_MODE_NIGHT_NO}
*/
public static void setMode(Context context, int mode) {
SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
mPrefs.edit()
.putInt(PREF_KEY, mode)
.apply();
}
/**
* 切换模式
*
* @param context
*/
public static void toggle(Context context) {
SharedPreferences mPrefs = PreferenceManager.getDefaultSharedPreferences(context);
if (getCurrentMode(context) == Configuration.UI_MODE_NIGHT_YES) {
mPrefs.edit()
.putInt(PREF_KEY, Configuration.UI_MODE_NIGHT_NO)
.apply();
} else {
mPrefs.edit()
.putInt(PREF_KEY, Configuration.UI_MODE_NIGHT_YES)
.apply();
}
}
}
注意这里我并没有做Activity的重新创建工作。
如果你的Acitivty是继承AppCompatActivity的,那么可以使用AppCompatActivity封装的代码进行夜间模式切换:
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
或者:
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
这两行代码的效果是一样的,并且他们会自动重新创建Activity。
总结
- 静态换肤的优势是简单稳定,但是将所有主题写在应用里会增大安装包的体积,并且不利于扩展。
- 动态换肤虽然不会占用安装包的体积,并且可以随意扩展,但是他毕竟侵入了系统,有潜在的风险,而且实现起来也有一点麻烦,但是利大于弊,所以目前大多数app都是使用的这种方式。
- 如果只是想实现夜间模式,那么第三种方案我认为是最好的,而且在老项目上实现这个功能也不会很繁琐。
- 对于静态换肤的重新创建Activity问题我建议重启App,这样可以省去很多多余的操作。