手写动态换肤

前言:

换肤,目前包括静态换肤和动态换肤
静态换肤

这种换肤的方式,也就是我们所说的内置换肤,就是在APP内部放置多套相同的资源。进行资源的切换。

这种换肤的方式有很多缺点,比如, 灵活性差,只能更换内置的资源、apk体积太大,在我们的应用Apk中等一般图片文件能占到apk大小的一半左右。

当然了,这种方式也并不是一无是处, 比如我们的应用内,只是普通的 日夜间模式 的切换,并不需要图片等的更换,只是更换颜色,那这样的方式就很实用。
动态换肤

适用于大量皮肤,用户选择下载,像QQ、网易云音乐这种。它是将皮肤包下载到本地,皮肤包其实是个APK。

换肤包括替换图片资源、布局颜色、字体、文字颜色、状态栏和导航栏颜色。

动态换肤步骤包括:

1、采集需要换肤的控件
2、 加载皮肤包
3、 替换资源
链接:https://www.jianshu.com/p/eebb8eae5ea1

按照步骤我们试着实现一下动态换肤的效果

1、采集需要的换肤控件,比如(android.widget.TextView,android.widget.ImageView)

通过采集支持换肤的控件以及属性,然后保存到集合中,待遍历替换
那么怎么采集控件呢?

我们可以看下setContentView(int id)这个指定布局的方法

  @Override
    public void setContentView(int resId) {
        ensureSubDecor();
        ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent);//这里实现view布局的加载
        mOriginalWindowCallback.onContentChanged();
    }
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
    }
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        if (DEBUG) {
            Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                    + Integer.toHexString(resource) + ")");
        }

        final XmlResourceParser parser = res.getLayout(resource);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
 public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
            ...
            final String name = parser.getName();
            final View temp = createViewFromTag(root, name, inflaterContext, attrs);
            ...
            return temp;
    }

可以看到inflate会返回具体的View对象出去,那么我们的关注焦点就放在createViewFromTag中了

    /**
     * Creates a view from a tag name using the supplied attribute set.
     * 

* Note: Default visibility so the BridgeInflater can * override it. * * @param parent the parent view, used to inflate layout params * @param name the name of the XML tag used to define the view * @param context the inflation context for the view, typically the * {@code parent} or base layout inflater context * @param attrs the attribute set for the XML tag used to define the view * @param ignoreThemeAttr {@code true} to ignore the {@code android:theme} * attribute (if set) for the view being inflated, * {@code false} otherwise */ View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) { try { View view; if (mFactory2 != null) { view = mFactory2.onCreateView(parent, name, context, attrs); } else if (mFactory != null) { view = mFactory.onCreateView(name, context, attrs); } else { view = null; } return view; } catch (Exception e) { } }

为了方便,这里代码直接转至 https://www.jianshu.com/p/eebb8eae5ea1
inflate最终调用了createViewFromTag方法来创建View,在这之中用到了factory,如果factory存在就用factory创建对象,如果不存在就由系统自己去创建。我们只需要实现我们的Factory然后设置给mFactory2就可以采集到所有的View了。

到目前为止我们只知道要去自定义一个factory,那么这个东西到底是什么呢? 上面我们通过源码简单的了解了,如果factory存在就用factory创建对象,如果不存在就由系统自己去创建view。
那么我们就重写一个factory

public class SkinLayoutInflateFactory implements LayoutInflater.Factory2 {
    private static final String TAG = SkinLayoutInflateFactory.class.getSimpleName();
    private Activity activity;

    public SkinLayoutInflateFactory(Activity mActivity) {
        this.activity = mActivity;
    }

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

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

        return null;
    }
}

可以看到方法onCreateView是创建view的方法,其中AttributeSet表示属性集。为了方便管理以及尽可能减少代码的入侵,我们使用ActivityLifecycleCallbacks。

public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
    private static final String TAG = SkinActivityLifecycle.class.getSimpleName();
    private Map factoryMap = new HashMap<>();

    @Override
    public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
        LayoutInflater layoutInflater = LayoutInflater.from(activity);

        try {
            //Android 布局加载器 使用 mFactorySet 标记是否设置过Factory
            //如设置过抛出一次
            //设置 mFactorySet 标签为false
            Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
            mFactorySet.setAccessible(true);
            mFactorySet.setBoolean(layoutInflater,false);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        Log.d(TAG, "onActivityCreated: ");
        //使用factory2 设置布局加载工程
        SkinLayoutInflateFactory skinLayoutInflaterFactory = new SkinLayoutInflateFactory(activity);
        LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory);
        factoryMap.put(activity,skinLayoutInflaterFactory);

    }

    @Override
    public void onActivityStarted(@NonNull Activity activity) {

    }

    @Override
    public void onActivityResumed(@NonNull Activity activity) {

    }

    @Override
    public void onActivityPaused(@NonNull Activity activity) {

    }

    @Override
    public void onActivityStopped(@NonNull Activity activity) {

    }

    @Override
    public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {

    }

    @Override
    public void onActivityDestroyed(@NonNull Activity activity) {
        factoryMap.remove(activity);
    }
}

现在演示下SkinLayoutInflateFactory的使用,我们在SkinLayoutInflateFactory的onCreateView打印AttributeSet属性

 @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
        Log.d(TAG, "onCreateView: name "+ name);
        int attributeCount = attrs.getAttributeCount();
        for (int i = 0; i < attributeCount; i++) {
            Log.d(TAG, "onCreateView: "+attrs.getAttributeName(i)+"---"+attrs.getAttributeValue(i));
        }

        return null;
    }
name androidx.constraintlayout.widget.ConstraintLayout
layout_width----1
layout_height----1
name TextView
layout_width----2
layout_height----2
text---Hello World!
layout_constraintBottom_toBottomOf---0
layout_constraintLeft_toLeftOf---0
layout_constraintRight_toRightOf---0
layout_constraintTop_toTopOf---0

对应的布局




    


可以看到布局和属性是一一对应的,那么现在我们玩一个好玩的东西,把textview换成button

 @Nullable
    @Override
    public View onCreateView(@Nullable View parent, @NonNull String name, 
                @NonNull Context context, @NonNull AttributeSet attrs) {
       switch (name){
           case "TextView":
               Button button = new Button(context);
               button.setText("替换文本");
               button.setTextColor(Color.RED);

               return button;
       }

        return null;
    }
图片.png

通过以上的实验我们就可以简单的理解,自定义factory就可以获取到需要换肤的控件了,但是控件还包含了自定义控件,比如com.xxx.widget.MyView, 或者Android系统的控件,剩下的就是只有标签的比如ImageView的控件,但是只带标签的控件需要补全包名,android.widget.ImageView.才能转成View
通过以上分析我们先完成控件筛选

private static final String[] mClassPrefixList = {
            "android.widget.",
            "android.view.",
            "android.webkit."
    };
  @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
      //获取只带标签的控件
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 则不是SDK中的view 可能是自定义view包括support库中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
        if (null != view) {

            L.e(String.format("检查[%s]:" + name, context.getClass().getName()));

        }
        return view;
    }

 private View createViewFromTag(String name, Context context, AttributeSet
            attrs) {
        //如果包含 . 则不是SDK中的view 可能是自定义view包括support库中的View
        if (-1 != name.indexOf('.')) {
            return null;
        }
        for (int i = 0; i < mClassPrefixList.length; i++) {
            return createView(mClassPrefixList[i] +
                    name, context, attrs);

        }
        return null;
    }

    private View createView(String name, Context context, AttributeSet
            attrs) {
        L.e(String.format("name= [%s]:",name));
        Constructor constructor = findConstructor(context, name);
        try {
            return constructor.newInstance(context, attrs);
        } catch (Exception e) {
        }
        return null;
    }

    private Constructor findConstructor(Context context, String name) {
        Constructor constructor = mConstructorMap.get(name);
        if (null == constructor) {
            try {
                Class clazz = context.getClassLoader().loadClass
                        (name).asSubclass(View.class);
                constructor = clazz.getConstructor(mConstructorSignature);


                L.e(String.format("constructor name = [%s]",constructor.getName()));

                mConstructorMap.put(name, constructor);
            } catch (Exception e) {
            }
        }
        return constructor;
    }

代码分析

如果name包含有 " . "则说明可能是自定义控件(比如com.wzw.MyView)或者系统控件(android.support.v4.view.ViewPager)否则需要添加完整包名。出现异常比如循环中可能出现
android.view.TextView则抛出异常不处理,最后通过反射的原理转换成view。

        //获取只带标签的控件,添加包名转成view
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 则不是SDK中的view 可能是自定义view包括support库中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
    
        return view;
2、加载皮肤包

实际上皮肤包也是一个Android文件,不管你将后缀名改为什么,只要是创建出的Android 项目就会存在res包以及底下的文件,利用这个特点,我们可以将想要替换的资源文件放到对应的包中,那么换肤的时候就只要去加载皮肤包中的对应的图片就可以了。
好了问题来了,比如我们需要替换某个ImageView的图片,那么正常的做法是要先加载到皮肤包中的资源文件,context.getResource.getDrawable(xxxx);那么请问,如果我们这么写能加载到资源吗?
答案是否定的,因为context是属于当前app的上下文,并不能加载插件app的资源文件。那么要怎么获取资源并设置呢?
我们先来看下Resource的构造方法:

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

先看后面两个参数分别表示屏幕相关参数和设备信息。这两个参数可以使用本app的context提供,重点看AssetManager
AssetsManager 直接对接Android系统底层。
Assets Manager有一个方法:addAssetPath(String path) 方法,app启动的时候会把当前的APK路径传递进去,然后我们就可以访问资源了。
根据思路我们将插件apk放在app项目的assets文件夹下,然后写入缓存文件中提供访问。接着我们必须创建出resource和assetmanger,以及dexclassloader(可访问未安装apk类)

 //获取assetManager
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
            addAssetPath.invoke(assetManager,dataFile.getAbsolutePath());

            //获取到插件resource
            Resources appResource = application.getResources();
            Resources skinResource = new Resources(assetManager,appResource.getDisplayMetrics(),appResource.getConfiguration());

            //获取插件进程名
            PackageManager packageManager = application.getPackageManager();
            PackageInfo packageArchiveInfo = packageManager.getPackageArchiveInfo(dataFile.getAbsolutePath(), PackageManager.GET_ACTIVITIES);
            String packageName = packageArchiveInfo.applicationInfo.packageName;

            //获取插件dexClassloader
            File optimizedDirectory = application.getDir("dex", Context.MODE_PRIVATE);
            DexClassLoader dexClassLoader = new DexClassLoader(dataFile.getAbsolutePath(),optimizedDirectory.getAbsolutePath(),
                    null,application.getClassLoader());

            SkinResource.getInstance().init(dexClassLoader,skinResource,appResource,packageName);
3、替换资源

第一步我们以及筛选除了需要替换的控件以及控件的属性,为了简单说明,目前我们制作Imageview的background的替换以及Textview的文本颜色替换。

public void setDrawable(ImageView imageView, String drawableName) {
        try {
            Class aClass = dexClassLoader.loadClass(skinPackageName + ".R$mipmap");
            Field field = aClass.getField(drawableName);
            //获取到图片的id
            int anInt = field.getInt(R.id.class);
            imageView.setImageDrawable(skinResources.getDrawable(anInt));

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    public void setTextColor(TextView textView, String colorName) {
        try {
            Class aClass = dexClassLoader.loadClass(skinPackageName + ".R$color");
            Field field = aClass.getField(colorName);
            Log.d(TAG, "setTextColor: "+field.getName());
            int anInt = field.getInt(R.id.class);
            textView.setTextColor(skinResources.getColor(anInt));

        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

代码说明:
通过dexClassloader后去mipmap和color资源文件,其中两个方法中的drawableName和colorName分别表示资源名,比如mipmap中保存了一张 ic_bg.png,那么drawableName 就等于“ ic_bg”。
接着通过field获取同名资源文件的资源id,最后通过插件的resource对象设置,达到替换的效果。

那么现在的重点就是怎么获取drawableName(colorName)。
在第一步的时候我们收集了xml中的控件以及属性

   @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        //获取只带标签的控件
        View view = createViewFromTag(name, context, attrs);
        //如果包含 . 则不是SDK中的view 可能是自定义view包括support库中的View
        if (null == view) {
            view = createView(name, context, attrs);
        }
        if (view != null) {
            attribute.load(view, attrs);
        }
        return view;
    }

我们继续看 attribute.load(view, attrs);方法

public void load(View view, AttributeSet attrs) {
        List skinPains = new ArrayList<>();
        for (int i = 0; i < attrs.getAttributeCount(); i++) {
            //获取属性名字
            String attributeName = attrs.getAttributeName(i);
            if (mAttributes.contains(attributeName)) {
                //获取属性对应的值
                String attributeValue = attrs.getAttributeValue(i);
                if (attributeValue.startsWith("#")) {
                    continue;
                }

                int resId = 0;
                //判断前缀字符串 是否是"?"
                //attributeValue  = "?2130903043"
                if (attributeValue.startsWith("?")) {  //系统属性值

                } else {
                    //@1234564
                    resId = Integer.parseInt(attributeValue.substring(1));
                }
                if (resId != 0) {
                    SkinPain skinPain = new SkinPain(attributeName, resId, view.getClass().getName());
                    skinPains.add(skinPain);
                }
            }
        }
        if (!skinPains.isEmpty() || view instanceof TextView) {
            SkinView skinView = new SkinView(view, skinPains);
//            skinView.applySkin();
            skinViews.add(skinView);
        }
    }

其实以上代码还是通过attrs这个类获取到控件名字,控件的属性,以及通过attrs.getAttributeValue(int i);获取到了设置的资源名字,比如?6453213432,通过字符串截取,就可以获取到当前设置的resId,紧接利用resId,通过Resources.getResourceEntryName(resId)方法我们就可以获取到resName了。

   public String getResName(int resId) {
        //R.drawable.ic_launcher
        return appResources.getResourceEntryName(resId);//ic_launcher   /colorPrimaryDark

    }

由于换肤的前提是宿主设置的资源名和插件的资源名一致,所以通过获取到宿主设置的资源名我们就可以获取到插件的资源名从而设置进去。
本例子我们使用了Observable观察者,当点击按钮加载资源的时候就通知被观察设置插件中的同名资源从而达到了换肤的效果。

总结

该例子只做了ImageView背景替换和TextView文本颜色替换,当然还有类似自定义控件的替换,文本字体替换等,这里就不做一一解释。因为我们只要懂得核心就可以举一反三。总体的步骤就是:
1、采集需要换肤的控件
2、 加载皮肤包
3、 替换资源
他的核心还是离不开Android的插件化
Android插件化从技术上来说就是如何启动未安装的apk(主要是四大组件)里面的类,主要问题涉及如何加载类、如何加载资源、如何管理组件生命周期。
感兴趣的可以参考 https://zhuanlan.zhihu.com/p/136001039

你可能感兴趣的:(手写动态换肤)