【Android】换肤技术讲解

本文详细介绍了APP换肤技术的实现原理与步骤,包括自定义LayoutInflaterFactory解析视图、通过反射获取资源、实现资源切换等功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

主题,是许多APP必备的一个功能,用户可以根据自己的喜好,来切换具有个性的主题,同时能让我们的APP更具把玩性。这篇博文就来聊聊皮肤切换的原理,效果图如下:
这里写图片描述

这里为了便于理解,在换肤的时候,只是简单切换背景图片,文件颜色和组件背景色
这篇博文将用到一下知识点:

  • classLoader:实例化控件
  • PackageManager:拿到插件的包信息
  • 反射:拿到插件的resource
  • LayoutInflaterFactory:解析xml

一、思路

首先通过LayoutInflaterCompat的setFactory方法设置自定义的LayoutInflaterFactory,并实现onCreateView,我们可以在该方法中解析xml的每一个节点(即view ),先通过组件名创建对应的view ,再遍历每一个view的 attrs属性和值,并以map保存,以便后续调用(即皮肤资源的切换)。
我们知道,在Android中是通过resource来获取资源的,若能获取插件的resource对象,那么就可以获取其图片等资源,到达换肤的目的,说得简单点,换肤就是换resource和packName。
在拿到插件的resource之后,就可以通过resource Id 来给每一个view设置其属性(如background)
当然,道理想必大家都懂,show me your code ~

二、偷梁换柱,换掉应用的LayoutInflaterFactory

我们需要持切换皮肤的组件,因此创建SkinFactory类实现LayoutInflaterFactory接口,并实现该接口中的方法onCreateView

 /**
     * Hook you can supply that is called when inflating from a LayoutInflater.
     * You can use this to customize the tag names available in your XML
     * layout files.
     *
     * @param parent The parent that the created view will be placed
     * in; <em>note that this may be null</em>.
     * @param name Tag name to be inflated.
     * @param context The context the view is being created in.
     * @param attrs Inflation attributes as specified in XML file.
     *
     * @return View Newly created view. Return null for the default
     *         behavior.
     */
    View onCreateView(View parent, String name, Context context, AttributeSet attrs);

显然这是一个hook, 执行LayoutInflater.inflate()的时候调用,如上所述,我们可以通过该方法获取每一个节点的属性和值(即资源id),资源类型(drawable 、color 等)。先简单介绍这四个参数:

  • parent:即当前节点的父类节点,可能为null
  • name :节点名,列如 TextView
  • context :该执行过程的上下文
  • attrs:该节点的属性集合,例如 background属性

那么,我们怎么通过节点来创建对应的组件对象呢?我们都知道在android.widget包下的Button在布局文件中的节点名只有Button,并不是完整的包路径,例如

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

以及android.view包下的SurfaceView等等。

<SurfaceView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

想必读者明白列举以上的用意了,对,我们需要先对获取到的节点名字进行处理,判断获取到的节点名是系统组件,还是自定义组件,从而构建完整的class name 。如下代码

    private static final String[] preFixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };  //这些都是系统组件
    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        View view = null;
        if (name.indexOf(".") == -1) {
            //系统控件
            for (String prix : preFixList) {
                view = createView(context, attrs, prix + name);
                if (null != view) {
                    break;
                }
            }
        } else {
            //自定义控件
            view = createView(context, attrs, name);
        }
        if (null != view) {
            parseSkinView(view, context, attrs);
        }
        return view;
    }

这里需要我们返回一个view,即该组件对应的view,既然能拿到组件对应的class name,那就好办,直接通过classloader去load一个class即可

    //创建一个view
    private View createView(Context context, AttributeSet attrs, String name) {
        try {
            //实例化一个控件
            Class clarr = context.getClassLoader().loadClass(name);
            Constructor<? extends View> constructor =
                    clarr.getConstructor(new Class[]{Context.class, AttributeSet.class});
            constructor.setAccessible(true);
            return constructor.newInstance(context, attrs);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
        return null;
    }

在拿到该控件后,需要遍历其需要替换值的属性,例如background,存放在list集合中。

    //找到需要换肤的控件
    private void parseSkinView(View view, Context context, AttributeSet attrs) {
        List<SkinInterface> attrList = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //拿到属性名
            String attrName = attrs.getAttributeName(i);
            String attrValue = attrs.getAttributeValue(i);
            int id =-1;
            String entryName ="";

            String typeName ="";

            SkinInterface skinInterface = null ;

            switch (attrName) {
                case "background"://需要进行换肤
                    id = Integer.parseInt(attrValue.substring(1));
                    entryName = context.getResources().getResourceEntryName(id);
                    typeName = context.getResources().getResourceTypeName(id);
                    skinInterface = new BackgroundSkin(attrName,id,entryName,typeName);
                    break;
                case "textColor":
                    id = Integer.parseInt(attrValue.substring(1));
                    entryName = context.getResources().getResourceEntryName(id);
                    typeName = context.getResources().getResourceTypeName(id);
                    skinInterface = new TextSkin(attrName,id,entryName,typeName);
                    break;
                default:
                    break;
            }
            if(null != skinInterface){
                attrList.add(skinInterface);
            }

        }
        SkinItem skinItem = new SkinItem(attrList,view);
        map.put(view,skinItem);
        //在这里进行应用,判断是皮肤资源还是本地资源
        skinItem.apply();
    }

为了方便属性的替换,这里用SkinItem对象来持有view和view对应的属性集合list。

  class SkinItem {
        public SkinItem(List<SkinInterface> attrList, View view) {
            this.attrList = attrList;
            this.view = view;
        }

        public List<SkinInterface> attrList;
        public View view;
        //更新组件资源,调用skinInterface 的实现类
        public void apply() {
            for (SkinInterface skinInterface : attrList) {
                skinInterface.apply(view);
            }
        }
    }

在进行皮肤切换的时候,有设置background的,有设置textColor的,但他们都需要以下参数

  • 组件的属性名称,例如 background
  • 组件引用资源的id (integer 类型)
  • 组件引用资源的名称,例如 app_icon
  • 组件引用资源的类型,例如 drawable

所以我们这里可以抽象出一个类SkinInterface,所有需要换肤的实现类都继承该类

public abstract class SkinInterface {
    String attrName;
    int refId = 0;
    String attrValueName;
    String attrType;
    public SkinInterface(String attrName, int refId, String attrValueName, String attrType) {
        this.attrName = attrName;
        this.refId = refId;
        this.attrType = attrType;
        this.attrValueName = attrValueName;
    }
   /**
     * 执行具体切换工作 
     * @param view 作用对象
     */
    public abstract void apply(View view);
}

列如SkinInterface的继承类 TextSkin


public class TextSkin extends SkinInterface {
    public TextSkin(String attrName, int refId, String attrValueName, String attrType) {
        super(attrName, refId, attrValueName, attrType);
    }

    @Override
    public void apply(View view) {
        if(view instanceof TextView){
            TextView textView = (TextView)view ;
            textView.setTextColor(SkinManager.getInstance().getColor(refId));
        }
    }
}

还有BackgroundSkin类的实现

public class BackgroundSkin extends SkinInterface{
    private static final String TAG = "BackgroundSkin";

    public BackgroundSkin(String attrName, int refId, String attrValueName, String attrType) {
        super(attrName, refId, attrValueName, attrType);
    }

    @Override
    public void apply(View view) {
        if("color".equals(attrType)){
            view.setBackgroundColor(SkinManager.getInstance().getColor(refId));
        }else if("drawable".equals(attrType)){
            view.setBackgroundDrawable(SkinManager.getInstance().getDrawable(refId));
        }
    }
}

最后在SkinFactory中提供一个更新的方法,来实现资源的替换工作

   public void upDate() {
        for(View view : map.keySet()){
            if(null == view){
                continue;
            }
            map.get(view).apply();
        }
    }

总之一句话,SkinFactory 负责创建view并获取其属性名和值,以及后续的切换资源工作

三、resource的中心枢纽——SkinManager

上一节在讲到皮肤切换具体实现类的时候,涉及到SkinManager对象,他就是resource的主要负责人,负责返回组件所需要的资源。
回想一下,我们是如何在activity中获取资源的?是不是通过getResources().get……方法?显然我们需要获取插件的resource对象,才能拿到插件里的资源,先来看看resource的构造函数

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

这里需要三个参数?有什么办法,那就给他咯~
首先看AssetManager ,他有两个构造函数,一个是hide的,一个是private的,均不能直接new 出来,这个好办~~

AssetManager  assetManager = AssetManager.class.newInstance();

AssetManager 有一个addAssetPath方法可以通过文件路径来加载资源,但也是hide状态,怎么办?easy !反射啦~~

 Method method = AssetManager.class.getMethod("addAssetPath", String.class);
 method.invoke(assetManager, path);

这样我们就顺利滴拿到了插件的AssetManager对象,剩下的两个参数就直接使用宿主项目上下文的resource的默认值即可

Resources resources = context.getResources();
Resources  skinResource = new Resources(assetManager, resources.getDisplayMetrics(),
                    resources.getConfiguration());

于是乎,就这样顺利的拿到了插件的resource对象,但是我们还需要获取插件的包名

PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageArchiveInfo(path, PackageManager.GET_ACTIVITIES);
String skinPackage = packageInfo.packageName;

获取资源不是通过resource吗,为什么还需要插件的packageName 呢?接着往下看

在获取resource对象后,就可以提供接口给其他类获取资源了,例如获取color

 public int getColor(int refId) {
        if (null == skinResource) {
            return refId;
        }
        String resName = context.getResources().getResourceEntryName(refId);
        int realId = skinResource.getIdentifier(resName, "color", skinPackage);
        int color;
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
            color = skinResource.getColor(realId, null);
        } else {
            color = skinResource.getColor(realId);
        }
        return color;
    }

看到这里,想必大家知道packageName的用处了吧?就是获取插件同资源名对应的id,然后再通过插件的id 获取对应的资源,获取drawable同理

   public Drawable getDrawable(@DrawableRes int refId) {
        Drawable drawable = ContextCompat.getDrawable(context, refId);
        if (null == skinResource) {
            return drawable;
        }
        String resName = context.getResources().getResourceEntryName(refId);
        int resId = skinResource.getIdentifier(resName, "drawable", skinPackage);
        return skinResource.getDrawable(resId);
    }

这样SkinManager 就创建完成了

四、创建基类

大家说得好,万物基于…..基类~~,这里我们需要创建一个抽象的SkinBaseActivity,凡是需要进行换肤的activity都要继承该类

public abstract class SkinBaseActivity extends Activity {
    private SkinFactory skinFactory ;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        skinFactory = new SkinFactory();
        //设置当前activity解析xml的工厂类
        LayoutInflaterCompat.setFactory(getLayoutInflater(),skinFactory );//LayoutInflaterFactory
    }
    //手动更换皮肤
    public void upDate(){
        skinFactory.upDate();
    }

}

然后在MainActivity中继承该类,并将SkinManager初始化

public class MainActivity extends SkinBaseActivity {

    private static final String TAG = "MainActivity ";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        SkinManager.getInstance().init(this);
        setContentView(R.layout.activity_main);
    }
}

在进行皮肤切换的时候执行(要确保file的路径正确,否则会出错)

   public void change(View view) {
        String path = new File(Environment.getExternalStorageDirectory(), "skin.apk").getAbsolutePath();
        SkinManager.getInstance().loadSkin(path);
        upDate();
    }

这里要注意加上权限,并到权限管理中心给该应用读写权限

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

那么,如何恢复默认皮肤呢?很简单,将SKinmanager中的resource,packageName替换为当前应用的即可

   public void back(View view) {
        SkinManager.getInstance().setSkinResource(getResources());
        upDate();
    }

五、run it!

创建一个module,按照宿主apk的资源名重新建立新的资源即可,选择module,打包成apk,再将apk copy到手机的根目录下,在as中切换到宿主apk,将宿主项目打包到手机,即可
至此,换肤技术就讲解完毕
源码下载

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值