Android换肤体验-------主题换肤框架搭建(全)

《Android换肤体验-------侧滑菜单的实现+换肤的原理》中,简单介绍了一下换肤的基本原理,在APP中存在很多控件,那么在换肤时,不会将所有的控件都换肤,那么如何根据获取到的控件得知是否要换肤。

(1)自定义属性:自定义一个属性值,在控件中如果查找到了这个属性值,那么这个控件就需要换肤;
(2)判断这个控件中是否存在“background”、“src”、“textColor”等类型的属性,那么这个控件就需要换肤。

一、问题:
1.什么时候换肤?
在我们点击换肤按钮的时候,不仅在当前Activity需要换肤,在这个APP中的所有Activity都需要换肤,所以这个换肤的时机要把握好。

《Android换肤体验-------侧滑菜单的实现+换肤的原理》中介绍过,系统加载布局的内部原理,在setContentView时其实是内部的AppCompatDelegateImpl调用setContentView方法,通过LayoutInflater调用inflate方法加载XML布局,在inflate方法中有一个createViewFromTag,会根据LayoutInflater当中Factory的接口类型(Factory or Factory2)调用CreateView方法加载,其中通过“name”可以得到加载的控件Tag,通过AttributeSet得到控件的全部属性,所以在此进行换肤就是最佳时机。

2.如何快速找到换肤的控件?
每个Activity中都有换肤的控件,如何快速找到这个需要换肤的控件。

同样在上一问当中回答了,就是在加载XML布局,即控件实例化的时候,通过AttributeSet得到控件的属性,判断该属性是否符合换肤的属性,如果是,那么就将其定义为一个换肤控件SkinView。

3.如何批量对控件进行换肤?
在找到换肤控件后,肯定是大批量的控件,因为Activity众多,控件自然也多,一个一个地换肤,耗时不高效。

在上一问中,当找到要换肤的控件时,会将其定义为一个换肤View,然后把找到的所有换肤View放在一个容器中,当点击换肤按钮时,就将这些控件属性全部换肤。

4.通过什么方式换肤?
这一部分在《Android换肤体验-------侧滑菜单的实现+换肤的原理》中介绍过,有静态换肤和动态换肤两种,即在应用内换肤以及动态加载插件apk资源的换肤。

二、换肤框架的搭建:
**1.采用动态加载技术,获取资源包中的资源对象;**在《Android换肤体验-------侧滑菜单的实现+换肤的原理》中介绍过了动态加载技术。

2.通过资源对象获取资源包中的资源;

在这里再写一遍吧,单独列了一个管理类。

public class SkinManager {
    private Context context;
    //插件包的资源
    private Resources resources;
    //插件包的包信息类
    private String packageName;

    private SkinManager(){}
    private static SkinManager instance = new SkinManager();
    public static SkinManager getInstance(){
        return instance;
    }
    public void setContext(Context context){
        this.context = context;
    }

    /**
     * 加载获取资源包的资源
     * @param pluin_path   资源apk的路径
     */
    public void getPluginResource(String pluin_path){
        try {
            //获取插件apk的包名
            PackageManager pm =context.getPackageManager();
            PackageInfo packageArchiveInfo = pm.getPackageArchiveInfo
                    (pluin_path, PackageManager.GET_ACTIVITIES);
            //资源包的包名
            packageName = packageArchiveInfo.packageName;

            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager,pluin_path);

            //获取系统资源
            Resources SuperResources = context.getResources();
            //获取插件资源
            resources = new Resources(assetManager,SuperResources.getDisplayMetrics(),
                    SuperResources.getConfiguration());

        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        }catch (NoSuchMethodException e) {
            e.printStackTrace();
        }catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取资源包apk的颜色资源
     * @param id  主APP中的资源id
     * @return  资源包中的资源id
     */
    public int getColor(int id){
        if(resources == null){
            return id;
        }
        //获取当前APP中id的资源类型,如果id是颜色类型,那么返回的就是颜色类型  resourceTypeName:color
        String resourceTypeName = context.getResources().getResourceTypeName(id);
        //获取到传入的id在资源中的名字  colorPrimary
        String resourceEntryName = context.getResources().getResourceEntryName(id);
        //根据名字和类型去皮肤资源apk中查找这个对应值 @color/colorPrimary
        int identifier = resources.getIdentifier(resourceEntryName, resourceTypeName, packageName);
        if(identifier == 0){
            return id;
        }
        return resources.getColor(identifier);
    }

    public Drawable getDrawable(int id){
        if(resources == null){
            return ContextCompat.getDrawable(context,id);
        }
        //获取当前APP中id的资源类型,如果id是drawable类型,那么返回的就是图片类型  resourceTypeName:drawable
        String resourceTypeName = context.getResources().getResourceTypeName(id);
        //获取到传入的id在资源中的名字  xxx.jpg
        String resourceEntryName = context.getResources().getResourceEntryName(id);
        //根据名字和类型去皮肤资源apk中查找这个对应值
        int identifier = resources.getIdentifier(resourceEntryName, resourceTypeName, packageName);
        if(identifier == 0){
            return ContextCompat.getDrawable(context,id);
        }
        return resources.getDrawable(identifier);
    }
}

3.收集需要换肤的控件
在之前介绍过,在获取的控件中,如果有background属性、textColor属性、src属性,就是需要换肤的控件。

android:background="@drawable/bg1"
android:textColor="@color/colorPrimary"

因为APP的Activity很多,我们不可能在每一个Activity中,都通过LayoutInflater去加载XML布局,在之前MVP架构中`《MVP架构设计模式1》,我们创建过一个BaseActivity,那么通过在BaseActivity中进行控件捕获,更加高效,因为每个Activity都继承自BaseActivity。

public abstract class BaseActivity<V,P extends BasePresenter<V>> extends AppCompatActivity {
    protected P mPresenter;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        //在此做换肤的准备
        LayoutInflaterCompat.setFactory2(getLayoutInflater(),new SkinFactory());
        mPresenter = createPresenter();
        mPresenter.attachView((V) this);
        //一定放在后边,不然会先onCreate,在这个当中已经有LayoutInflater加载布局了,会报错!
        super.onCreate(savedInstanceState);
    }

这样每个继承自BaseActivity的Activity都可以监听到加载XML布局的过程。

public class SkinFactory implements LayoutInflater.Factory2 {
    @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    
        return null;
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        return null;
    }
}

onCreateView方法当中,我们可以根据“name”来获取当前控件的类型,通过AttributeSet 来遍历该控件的全部属性值,通过属性值中是否存在“background”、“src”、“textColor”等类型的属性,如果存在那么这个控件就需要换肤。

因为我们通过hook截获了系统加载XML实例化控件的过程,因为需要我们自己实现控件的实例化并返回。

2020-02-10 11:01:56.339 32250-32250/? E/TAG: name====TextView
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====textSize
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====textColor
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====gravity
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====layout_width
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====layout_height
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====layout_marginTop
2020-02-10 11:01:56.339 32250-32250/? E/TAG: attrs=====text

我们从打印的控件信息中看出,并没有带包名,不像是自定义控件都带全类名。如果带包名,直接通过反射实例化,如果不带包名,那么该控件一定是下面三个包里面的。

private static final String[] packList = 
            {"android.widget.","android.view.","android.webkit."};

所以不带包名的系统控件,那就一个一个去匹配,肯定有一个是匹配的。

@Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    	//实例化
        View view = null;
        if(name.contains(".")){
            //带包名,反射实例化
            view = onCreateView(name,context,attrs);
        }else{
            //不带包名
            for (String s :
                    packList) {
                String viewName = s + "name";
                view = onCreateView(viewName,context,attrs);
                if(view != null){
                    //一旦匹配成功,直接break;
                    break;
                }
            }
        }
        //在return View之前去判断是不是换肤View,如果是那么就去换肤
        if(view != null){
            parseView(view,name,attrs);
        }
        return view;
    }

匹配成功之后,通过反射实例化控件。

@Nullable
    @Override
    public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        View view = null;
        try {
            Class aClass = context.getClassLoader().loadClass(name);
            //获取View的第二个构造方法,重载
            Constructor<? extends View> constructor = aClass.getConstructor(Context.class, AttributeSet.class);
            view = constructor.newInstance(context, attrs);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return view;
    }
}

如此以来,就完成了控件的实例化返回,也就是通过hook截获了系统加载实例化控件的过程。

接下来,就判断该控件是否符合之前说的那样,存在“background”、“src”、“textColor”等类型的属性,如果有,那么就将这些控件收集起来。

在收集View的时候,需要将View放在一个容器里,但是如果将一整个View放在容器中,在换肤的时候,需要遍历所有的属性(包括不需要换肤的属性),这样会非常耗时,所以我们考虑的是,将单个属性封装为一个对象,这样只需要保存这个属性对象即可。

android:textColor="@color/colorPrimary"

在一条属性中,包含这么几个值:属性名textColor、属性id @color/colorPrimary、属性类型color、属性值colorPrimary,可以构建成属性对象

/**
 *   background = "@drawable/bg"
 */
public class SkinItem {
    //属性名字  textColor background
    private String name;
    //属性值的类型 color mipmap drawable
    private String typeName;
    //属性值的名字 colorPrimary
    private String entryName;
    //属性id  @color/colorPrimary
    private int id;

    public SkinItem(String name, String typeName, String entryName, int id) {
        this.name = name;
        this.typeName = typeName;
        this.entryName = entryName;
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getTypeName() {
        return typeName;
    }

    public void setTypeName(String typeName) {
        this.typeName = typeName;
    }

    public String getEntryName() {
        return entryName;
    }

    public void setEntryName(String entryName) {
        this.entryName = entryName;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }
}

那么每一个换肤控件就包含其中的换肤属性:

public class SkinView {
    private View view;
    //一个view控件有多个属性
    private List<SkinItem> skinItems;

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

那么我们寻找换肤的控件容器中,存放的就是SkinView,换肤的控件以及换肤的属性值。

private List<SkinView> viewList = new ArrayList<>();

然后在onCreateView方法中,是循环遍历去初始化View,当获取一个实例化View时,就需要去判断是否需要换肤。

/**
     * 判断当前控件是否符合换肤标准,如果符合,就装到容器中
     * @param view
     * @param name
     * @param attrs
     */
    private void parseView(View view, String name, AttributeSet attrs) {
        List<SkinItem> skinItems = new ArrayList<>();
        Log.e("TAG",name);
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //遍历当前所有View的属性  attributeName相当于是一个View的属性集合
            //从layoout_width  layout_height id textColor.....开始遍历
            String attributeName = attrs.getAttributeName(i);
            Log.e("TAG","attributeName==="+attributeName);
            if(attributeName.contains("textColor") || attributeName.contains("background")
                    ||attributeName.contains("src")){
                //如果包含这三个属性其中一个,就是换肤控件

                //key-value  key:name value:value
                int resId = Integer.parseInt(attrs.getAttributeValue(i).substring(1));
                //获取属性名 属性值的类型,属性值的名字
                String typeName = view.getResources().getResourceTypeName(resId);
                String entryName = view.getResources().getResourceEntryName(resId);
                SkinItem skinItem = new SkinItem(attributeName,typeName,entryName,resId);
                skinItems.add(skinItem);
            }
        }
        if(skinItems.size() >0){
            //当前这个换肤View,需要换肤的属性
            SkinView skinView = new SkinView(view,skinItems);
            viewList.add(skinView);
        }
    }

4.将皮肤资源包中的资源替换到主APP中。

public class SkinView {
    private View view;
    private List<SkinItem> skinItems;

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

    //换肤
    public void apply(){
        for (SkinItem skin :
                skinItems) {
            if (skin.getName().equals("background")){
                //背景可能使用的是图片,也可能使用的是图片
                if(skin.getTypeName().equals("color")){
                    view.setBackgroundColor(SkinManager.getInstance().getColor(skin.getId()));
                }else if(skin.getTypeName().equals("drawable")){
                    view.setBackground(SkinManager.getInstance().getDrawable(skin.getId()));
                }
            }else if(skin.getName().equals("textColor")){
                if(view instanceof TextView){
                    ((TextView) view).setTextColor(SkinManager.getInstance().getColor(skin.getId()));
                }else if(view instanceof Button){
                    view.setBackgroundColor(SkinManager.getInstance().getColor(skin.getId()));
                }
            }else if(skin.getName().equals("src")){

            }
        }
    }
}

在SkinView中换肤,每个需要换肤的View,判断换肤的属性,然后设置换肤的资源,最终在实例化View的时候,加载。

public void apply(){
        for (SkinView views :
                viewList) {
            //启动
            views.apply();
        }
    }

三、创建一个资源包
创建一个Module或者Library都可以。

在主APP中,某个TextView的字体颜色为colorPrimary

<TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="主页"
        android:textSize="18sp"
        android:textColor="@color/colorPrimary"
        android:gravity="center_horizontal"
        android:layout_marginTop="2dp"
        ></TextView>

主APP的颜色资源:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#008577</color>
    <color name="colorPrimaryDark">#00574B</color>
    <color name="colorAccent">#D81B60</color>
</resources>

那么在换肤的时候,如果发现资源apk中,也有这个颜色,那么就将资源apk中的这个颜色值,替换到主APP的TextView的颜色值。

资源apk的颜色资源:(全部是黑色,当得到资源apk的这个颜色信息colorPrimary后,会将黑色替换到主app,之前主app是绿色)

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#000000</color>
    <color name="colorPrimaryDark">#000000</color>
    <color name="colorAccent">#000000</color>
</resources>
发布了22 篇原创文章 · 获赞 6 · 访问量 2632

猜你喜欢

转载自blog.csdn.net/qq_33235287/article/details/104243819