Android 换肤之旅——主题切换

    随着手机应用的成熟发展,市面上的应用已不在以简单的实现功能为目标了,它们反而会更加注重用户体验。我们常说的换肤(主题)功能——针对用户的喜好来提供一个可选的主题也是提高用户体验的方式之一。换肤功能不仅提高了用户体验并且还具有一定商业价值。许多大厂的app如QQ、网易云音乐都具有换肤的功能。那么我们来聊一聊Android的换肤功能如何实现。从原理出发,我们需要了解两点:

  1. 换肤的本质?
  2. 皮肤是什么?

1.换肤的本质

     通过观察许多主流的app可以发现,它们的皮肤(主题)大多数都是要下载的,不可能把这些五花八门的样式全都都放在apk文件上,那样的话应用会很大。所以皮肤是通过网络下载的(当然可以有几套默认的皮肤),下载完之后点击一款皮肤就可以对整个应用的样式进行替换了,替换的是什么呢?当然就是资源文件啦,再简单一点说,就是去替换界面上的字体、颜色、背景、图片这些东西。 既然是替换资源文件,不管我们有多少个apk皮肤包,我们所定义的资源名称肯定要相同才行,不然无法做一个对应的关系,好比我们要替换drawable文件夹下的一张名为ic_bg.png的图片,那么新的图片也要这样去命名才能够正确替换。皮肤替换的过程就是加载皮肤包里面的资源文件,然后重新对每个view进行setXXX()这类操作。

2.皮肤是什么

     上面说了,换肤的本质就是去替换资源文件。我们知道,Android应用程序由代码和资源组成。所以皮肤其实就是一个仅包含资源的apk文件

     通过以上两点,可以对Android的换肤功能有一个大体的了解,这里我们可以做一个小结:

  • 皮肤是一个apk文件
  • 换肤三部曲:下载皮肤文件 ->获取资源 ->替换

     抛砖引玉一番之后,下面我们来具体实现这个过程。 我们创建一个Demo来模拟换肤的实现流程,这个Demo很简单,先来看一下最终实现的效果(水印请忽略)。

换肤Demo
我们要实现的效果是,点击右边的按钮应用蓝色的皮肤,点击左边的按钮恢复到默认的皮肤,这个界面的布局如下:

<RelativeLayout
    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:background="@drawable/ic_bg"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textSize="20sp"
        android:gravity="center"
        android:lineSpacingExtra="7dp"
        android:textColor="@color/mainText"
        android:text="Kotlin is now an official language on Android. It's expressive, concise, and powerful. Best of all, it's interoperable with our existing Android languages and runtime."
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>


    <Button
        android:id="@+id/btn_default"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/mainButton"
        android:layout_alignParentBottom="true"
        android:layout_marginStart="8dp"
        android:text="默认"/>

    <Button
        android:id="@+id/btn_blue"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:background="@color/mainButton"
        android:layout_marginEnd="8dp"
        android:layout_alignParentEnd="true"
        android:text="闷骚蓝"/>

</RelativeLayout>

其中ic.bg就是那张白色的背景图,引用的颜色资源在color.xml中定义。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="mainText">#181717</color>
    <color name="mainButton">#d3e4db</color>
</resources>

1.创建一个皮肤包

     看到上面的gif图便可知道,这里替换掉了根布局的背景、TextView的字体也能色和Button的背景颜色。接下来我们就创建这个“闷骚蓝”的皮肤。创建一个Android工程,命名为blue-skin。
蓝色皮肤
这个工程,不需要创建任何的java类,只需要添加一张蓝色的背景图,命名为ic.bg,在color.xml中添加两个相同名称的颜色值。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="mainText">#03e0fd</color>
    <color name="mainButton">#7a24e2</color>
</resources>

就是这么简单!直接选择Build apk生成皮肤包。生成皮肤包后,我们将扩展名更改成skin(也可以改成其他的),这样做的目的是为了防止系统的安装程序进行安装(毕竟啥都没)。然后将皮肤包拷贝到sd卡的根目录中去。这个过程就模拟了换肤过程中下载皮肤包到本地的过程,实际开发中应是通过网络下载,这里简化了这个步骤。

2.获取皮肤包的资源

为了方便,可以定义一个类似SkinManager的类来控制皮肤的加载、替换等过程,加载皮肤包资源的代码如下:

  public void loadSkin(String skinPath) {
        if (skinPath== null)
            return;
        new LoadTask().execute(skinPath);
    }
  class LoadTask extends AsyncTask<String, Void, Resources> {

        @Override
        protected Resources doInBackground(String... paths) {
            try {
                if (paths.length == 1) {
                    String skinPkgPath = paths[0];
                    File file = new File(skinPkgPath);
                    if (!file.exists()) {
                        return null;
                    }
                    PackageManager mPm = context.getPackageManager();
                    PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager
                            .GET_ACTIVITIES);
                    skinPackageName = mInfo.packageName;
                    AssetManager assetManager = AssetManager.class.newInstance();
                    Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",
                            String.class);
                    addAssetPath.invoke(assetManager, skinPkgPath);
                    Resources superRes = context.getResources();
                    Resources skinResource = new Resources(assetManager, superRes
                            .getDisplayMetrics(), superRes.getConfiguration());
                    saveSkinPath(skinPkgPath);
                    return skinResource;
                }
            } catch (Exception e) {
                return null;
            }
            return null;
        }

        @Override
        protected void onPostExecute(Resources resources) {
            super.onPostExecute(resources);
            mSkinResources = resources;
            if (mSkinResources != null) {
                isExternalSkin = true;
                notifySkinUpdate();
            }
        }
    }

loadSkin()的内容比较简单,开启一个AsyncTask去加载皮肤包,这里面的参数输入的是皮肤包的全路径。我们知道Android程序的资源分为两大类,assert和resource,分别对应api中的AssertManager和Resource类,而AssertManager又在ResourcesImpl中,ResourcesImpl是Resource的一个具体实现类。通常在我们自己的工程中,可以通过调用context对象的getResource()方法获取Resource的示例,这是因为在应用启动的过程中就为我们创建了这个Resource对象。那如果我们要获取皮肤包的资源,就要去构造这个Resource对象了,Resource的构造方法如下:

  public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
        this(null);
        mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
    }

Resource的构造方法中需要传入三个参数,重点看AssetManager ,由于AssetManager 的大多数api都是@hide的,包括public的构造方法,所以我们只能通过反射去创建一个AssetManager 对象,并通过反射去调它的addAssetPath 方法把皮肤包路径传进去。这一步是必须的,它可以让assetManager包含特定的PackageName的资源信息。Resource后面的两个参数是关于一些配置信息,影响不大,可以直接使用当前工程的Resource对象的配置。创建好Resource之后,就表示已经获取到皮肤包的资源了。

3.替换资源

    既然我们已经获取到了Resource对象了,那么替换资源的工作就变得很简单了,但是,如何通知每个界面都进行替换呢?这一步还是比较简单的,通过观察者模式即可实现。在BaseActivity中去设置一个监听器,当加载完皮肤包资源的时候就可以去通知界面替换了。主要的问题是如何为每个view重新设置资源,如果去遍历整个view树再去找需要换肤的view显然是不太现实的,所以比较合适的做法就是,在创建每个View的时候,就把符合换肤条件的view收集起来,然后在需要换肤的时候再去遍历这个集合进行替换,简单分析一下这个过程。

首先,自定义一个创建view的工厂,收集需要换肤的view。

LayoutInflater.Factory

对于LayoutInflater的使用大家比较熟悉,调用LayoutInflater对象的inflate()方法即可将一个xml文件转成一个View对象。事实上,我们在activity中调用setContextView()去加载布局也是用到LayoutInflater这个类。

 @Override
    public void setContentView(int resId) {
        //省略...
        ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent);
         //省略...
    }

这个创建view的过程是系统默认实现的,我们完全可以提供一个Factory去创建view,LayoutInflater.Factory是LayoutInflater内部的一个接口,当创建view的时候,就会调用Factory中的onCreateView方法:

public View onCreateView(String name, Context context, AttributeSet attrs);

明白这一点,我们就可以去实现创建一个Factory,在onCreateView中去收集需要换肤的view。

Activity是实现了LayoutInflater.Factory的,所以也可以直接去重写BaseActivity的oncreateView方法来收集需要换肤的view。

这里先定义两个Bean类,SkinAttr是关于某个属性的信息。

public class SkinAttr {

    private String attrName;    //属性名(例如:background、textColor)

    private String attrType;    //属性类型(例如:drawable、color)

    private int resId;       //资源id值(例如:123)

    private String resName;     //资源名称(例如:ic_bg)

    public SkinAttr(String attrName, String attrType, String resName,int resId) {
        this.attrName = attrName;
        this.attrType = attrType;
        this.resId = resId;
        this.resName = resName;
    }

    /**
     * API
     * @return
     */
    public String getAttrName() {
        return attrName;
    }

    public void setAttrName(String attrName) {
        this.attrName = attrName;
    }

    public String getAttrType() {
        return attrType;
    }

    public void setAttrType(String attrType) {
        this.attrType = attrType;
    }

    public int getResId() {
        return resId;
    }

    public void setResId(int resId) {
        this.resId = resId;
    }

    public String getResName() {
        return resName;
    }

    public void setResName(String resName) {
        this.resName = resName;
    }
}

定义一个SkinItem类将view和它的属性联系起来:

public class SkinItem {

    private View view;

    private List<SkinAttr> attrs;


    public SkinItem(View view, List<SkinAttr> attrs) {
        this.view = view;
        this.attrs = attrs;
    }

    public void apply() {
        if (view == null || attrs == null)
            return;
        for (SkinAttr attr : attrs) {
            String attrName = attr.getAttrName();
            String attrType = attr.getAttrType();
            String resName = attr.getResName();
            int resId = attr.getResId();
            if ("background".equals(attrName)) {
                if ("color".equals(attrType)) {
                    view.setBackgroundColor(SkinManager.getInstance().getColor(resName,resId));
                } else if ("drawable".equals(attrType)) {
                    view.setBackground(SkinManager.getInstance().getDrawable(resName,resId));
                }
            } else if ("textColor".equals(attrName)) {
                if (view instanceof TextView && "color".equals(attrType)) {
                    ((TextView) view).setTextColor(SkinManager.getInstance().getColor(resName,resId));
                }
            }
        }
    }

}

最后我们创建MySkinFactory类并实现LayoutInflater.Factory来收集这些需要换肤的view的信息 :

public class MySkinFactory implements LayoutInflater.Factory {

    private List<SkinItem> skinItems = new ArrayList<>();

    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        View view = createView(name,context,attrs);
        if (view!=null){
            collectViewAttr(view,context,attrs);
        }
        return view;
    }

    private View createView(String name, Context context, AttributeSet attrs) {
        View view = null;
        try {
            if (-1 == name.indexOf('.')){   //不带".",说明是系统的View
                if ("View".equals(name)) {
                    view = LayoutInflater.from(context).createView(name, "android.view.", attrs);
                }
                if (view == null) {
                    view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);
                }
                if (view == null) {
                    view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);
                }
            }else { //带".",说明是自定义的View
                view = LayoutInflater.from(context).createView(name, null, attrs);
            }
        } catch (Exception e) {
            view = null;
        }
        return view;
    }

    private void collectViewAttr(View view,Context context, AttributeSet attrs) {
        List<SkinAttr> skinAttrs = new ArrayList<>();
        int attCount = attrs.getAttributeCount();
        for (int i = 0;i<attCount;++i){
            String attributeName = attrs.getAttributeName(i);
            String attributeValue = attrs.getAttributeValue(i);
            if (isSupportedAttr(attributeName)){
                if (attributeValue.startsWith("@")){    //必须是引用
                    int resId = Integer.parseInt(attributeValue.substring(1));
                    String resName = context.getResources().getResourceEntryName(resId);
                    String attrType = context.getResources().getResourceTypeName(resId);
                    skinAttrs.add(new SkinAttr(attributeName,attrType,resName,resId));
                    SkinItem skinItem = new SkinItem(view, skinAttrs);
                    if (SkinManager.getInstance().isExternalSkin()){
                        skinItem.apply();
                    }
                    skinItems.add(skinItem);
                }
            }
        }
    }

    private boolean isSupportedAttr(String attributeName){
        return "background".equals(attributeName) || "textColor".equals(attributeName);
    }

    public void apply(){
        for (SkinItem item : skinItems) {
            item.apply();
        }
    }

}

collectViewAttr()方法中去遍历这个view的属性名和属性值,如果属性名是background或者是textColor我们就认为这个view是需要换肤的(具体由什么来确定根据需要),如果这个属性的属性值是引用类型,那么我们就把这个view以及它对应的属性、属性值收集起来。创建完Factory之后,在BaseActivity中去设置:

 protected void onCreate(@Nullable Bundle savedInstanceState) {
        mSkinFactory = new MySkinFactory();
        getLayoutInflater().setFactory(mSkinFactory);
        super.onCreate(savedInstanceState);  
        SkinManager.getInstance().addSkinUpdateListener(this);
    }

设置Factory的代码要放在 super.onCreate(savedInstanceState) 之前,上面还有一句代码是设置皮肤更新的监听器,实现如下:

@Override
    public void onSkinUpdate() {
        mSkinFactory.apply();
    }

调用的是mSkinFactory的apply()方法:

 public void apply() {
        if (view == null || attrs == null)
            return;
        for (SkinAttr attr : attrs) {
            String attrName = attr.getAttrName();
            String attrType = attr.getAttrType();
            String resName = attr.getResName();
            int resId = attr.getResId();
            if ("background".equals(attrName)) {
                if ("color".equals(attrType)) {
                    view.setBackgroundColor(SkinManager.getInstance().getColor(resName,resId));
                } else if ("drawable".equals(attrType)) {
                    view.setBackground(SkinManager.getInstance().getDrawable(resName,resId));
                }
            } else if ("textColor".equals(attrName)) {
                if (view instanceof TextView && "color".equals(attrType)) {
                    ((TextView) view).setTextColor(SkinManager.getInstance().getColor(resName,resId));
                }
            }
        }
    }

走到这一步就可以到清楚换肤的本质了,不过就是调用我们玩的行云流水的一系列setXXX()操作罢了。SkinManager中的getColor方法如下:

  public int getColor(String resName,int resId) {
        int originColor = context.getResources().getColor(resId);
        if(mSkinResources == null || !isExternalSkin){
            return originColor;
        }
        int newResId = mSkinResources.getIdentifier(resName, "color", skinPackageName);
        int newColor;
        try{
            newColor = mSkinResources.getColor(newResId);
        }catch(Resources.NotFoundException e){
            e.printStackTrace();
            return originColor;
        }
        return newColor;
    }

主要是使用Resource的getIdentifier()方法去获得某个颜色的资源id值(不是颜色值),注意这里的资源id就是皮肤包上的资源id了,然后再通过这个资源id值去获取对应的颜色值。如果没有找到,则返回默认的颜色值。getDrawable()方法和getColor()方法类型,这里不再赘述,文末会附上源代码。做到这里,我们就可以任意切换不同的皮肤包了,那如果要恢复默认的皮肤呢?没有问题,上马。哦不是。。。上代码:

 public void restoreDefaultTheme(){
        SPUtil.put(context, KEY, "");
        isExternalSkin= false;
        mSkinResources = null;
        notifySkinUpdate();
    }

将SkinResources置空即可。好了,我们来到主界面,为这两个按钮加上监听器,来测试一下:

public class MainActivity extends BaseActivity implements View.OnClickListener {

    private Button btnDefault;

    private Button btnBlue;

    private String skinPath;    //皮肤包路径

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btnDefault = findViewById(R.id.btn_default);
        btnBlue = findViewById(R.id.btn_blue);
        btnDefault.setOnClickListener(this);
        btnBlue.setOnClickListener(this);
        skinPath = Environment.getExternalStorageDirectory().getAbsolutePath() +
                File.separator + "blue-skin.skin";  
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.btn_default:
                SkinManager.getInstance().restoreDefaultTheme();
                break;
            case R.id.btn_blue:
                SkinManager.getInstance().loadSkin(skinPath);
                break;
        }
    }

}

效果和上面的gif图是一致的。还有一个比较重要的地方,用户在下一次打开这个app应用的应是他最后一次选择的皮肤,不可能每次都要他选吧?那样会崩溃的。所以,在每次调用loadSkin() 加载皮肤包成功后,需要将此皮肤路径保存起来, 待下一次应用启动时就去加载此路径的皮肤,这样做的体验效果就会比较好些。这个过程可以放在Application的onCreate()去进行。这部分代码就不放了,源码中有。

总结

    单从原理和实现手段来讲,Android的换肤功能还是非常简单的,但是本示例并没有给出一个通用的框架结构,只是针对换肤功能来进行说明,github上有不少优秀的换肤框架均可参考,本示例也是参考了Android-Skin-Loader这个框架所写,继续放一些参考博文:

1.Android换肤原理和Android-Skin-Loader框架解析

2.Android 在线换肤方案总结分享

这里写图片描述

Demo源码地址: https://github.com/ouchangxin/DynamicSkinDemo

猜你喜欢

转载自blog.csdn.net/weixin_38261570/article/details/82079540