Android换肤总结

换肤方案

据我所知目前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换肤

动态换肤的一般步骤为:

  1. 下载并加载皮肤包
  2. 拿到皮肤包Resource对象
  3. 标记需要换肤的View
  4. 缓存需要换肤的View
  5. 切换时即时刷新页面
  6. 制作皮肤包

下面的代码参考一个动态换肤框架
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.制作皮肤包

  1. 新建工程project
  2. 将换肤的资源文件添加到res文件下,无java文件
  3. 直接运行build.gradle,生成apk文件(注意,运行时Run/Redebug configurations 中Launch Options选择launch nothing),否则build 会报 no default Activty的错误。
  4. 将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。

总结

  1. 静态换肤的优势是简单稳定,但是将所有主题写在应用里会增大安装包的体积,并且不利于扩展。
  2. 动态换肤虽然不会占用安装包的体积,并且可以随意扩展,但是他毕竟侵入了系统,有潜在的风险,而且实现起来也有一点麻烦,但是利大于弊,所以目前大多数app都是使用的这种方式。
  3. 如果只是想实现夜间模式,那么第三种方案我认为是最好的,而且在老项目上实现这个功能也不会很繁琐。
  4. 对于静态换肤的重新创建Activity问题我建议重启App,这样可以省去很多多余的操作。
发布了65 篇原创文章 · 获赞 24 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/shanshui911587154/article/details/104820919