Android 换肤原理分析

当了解了一些知识,应该用文字记录它,再抽个时间再看它,永远记住它

Android 换肤的理论知识和文章已经很多了,这里记录一下自己对这块的理解。本文效果如下:
在这里插入图片描述

工程:一键换肤的快乐

一、换肤的由来

首先,为什么要换肤呢?那肯定是一套UI不满足需求,无法面对多变的需求,从而需要有可以自由去更换UI 的手段,而这也是换肤想要达到的目的。

比如,一个imageview , 现在设置了一张图片,但是 618 来了, 我先更换成新的图片,怎么办?总不能让用户再更新一遍吧,虽然可以增量更新,但总不能每次都直接更新吧?

那我么一般怎么更新 imageview 的图片呢?

 ImageView imageView =  findViewById(R.id.image);
 imageView.setImageResource(R.mipmap.bg);

可以通过 setImageResource() 设置更新图片。

1.1 应用内换肤分析

那如果我下发了换肤命令,怎么更新呢?如果是应用内更新,那图片的名字肯定是不能一样的,不能R文件找不到;这个时候,我们可以新建一个 res_skin ,skin_bg 改个名字,比如 skin_bg。

然后在换肤命令来的时候,换成如下代码:

 imageView.setImageResource(R.mipmap.skin_bg);

那我换肤命令哪知道你有多少个 view 啊 ?怎么知道你要替换是 mipmap ,还是 color 啊?
别急,这个后面会讲。

1.2 插件换肤分析

上面是应用内换肤,如果是插件换肤呢。
插件换肤的话,就是把要替换的资源,比如上面的 bg 图片,放到一个apk 中,然后从这个apk 中取出这个资源,插件换肤不需要给资源名称,与原apk 保持一致即可。

怎么取呢,从上面 R.mipmap.bg 知道 ,所有得知道资源是从 mipmap 取,且名字叫做 bg 就可以取到这个 id 了。
幸运的是,Resource 有个方法:

    public int getIdentifier(String name, String defType, String defPackage) {
        return mResourcesImpl.getIdentifier(name, defType, defPackage);
    }

参数解释如下:

  • name:资源名称
  • defType : 资源类型,比如 mipmap,color,string…
  • defPackage : 目标包名

那这样的话,事实上,

 imageView.setImageResource(R.mipmap.bg);

也可以写成:

        int res = getResources().getIdentifier("bg","mipmap",getPackageName());
        if (res != 0){
            imageView.setImageResource(res);
        }

可以看到,确实显示出来了:
在这里插入图片描述
咦,那我只要去加载皮肤的资源包,再通过 resource 的 getIdentifier 不就可以拿到资源文件了吗,然后同通过 view 去设置就可以了。

那怎么去解析这个 皮肤资源包呢?
我们知道 Android 的资源管理,除了 Resource ,还有 AssetManager;其中 Resource 类可以通过 ID 来查找资源,而 AssetManager 则可以根据文件名来查找资源。

那这里就好办了,就使用 AssetManager ,然后它有个方法:

    /**
     * @deprecated Use {@link #setApkAssets(ApkAssets[], boolean)}
     * @hide
     */
    @Deprecated
    public int addAssetPath(String path) {
        return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
    }

但这个是 hide 方法,且标注为 Deprecated,建议我们去使用setApkAssets,但 ApkAsset 又是 hide,难顶。

但笔者搜索了一下 setApkAssets 基本都是源码在使用,而主流的换肤,插件基本还是用 addAssetPath,且在 Android P 上试了一下,也没啥问题,所以这里也暂时用这个把。既然是 hide ,那肯定用反射了:

try {
      //拿到资源加载器

      AssetManager assetManager = AssetManager.class.newInstance();

      Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
      addAssetPath.setAccessible(true);
      addAssetPath.invoke(assetManager, skinPath);

  } catch (Exception e) {
      LggUtils.e("SkinManager - loadSkinPath error: " + e.getMessage());
      e.printStackTrace();
  }

最后,还是要用 Resource去加载 id 的,所以,这里创建的 Resource,使用 assetmanager 参数的,

 Resources skinResources = new Resources(assetManager, mContext.getResources().getDisplayMetrics()
           , mContext.getResources().getConfiguration());

后面咱们就可以使用 skinResource 和 getIdentifier 去加载资源了。

Ok,两种原理都分析完了。上面遗留的问题就是:

  1. 如何获取需要换肤的 View
  2. 如何知道这个view的换肤属性,比如是 bitmap,还是 color等

下面一起解决这个问题。

二、View 的生成过程

从 activity 下手,一般我们都是 setContentView(R.layout.main_activity) 去设置我们的 xml,但有没有想过,为啥设置了这个方法之后,就能拿到 View 呢?
再抛出一个问题,比如你在 xml,写个 textview 和 button 如下:

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="测试换肤"/>

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

然后打开 Tool - Layout Insepctor 查看:
在这里插入图片描述
额,怎么我的 textview 和 button 变成了 AppCompatTextView 和 AppCompatButton 了?

带着这个疑惑,我们从 setContentView 跟踪下去:

    @Override
    public void setContentView(@LayoutRes int layoutResID) {
        getDelegate().setContentView(layoutResID);
    }

首先,如果你的 activity 继承 AppCompatActivity,那么它会通过 一个 Delegate 代理类去设置 setContentView,它是个抽象方法,它的具体实现类是AppCompatDelegateImpl,但为了更好的看到整个过程,我建议你把targetSdkVersion改成26,然后去看 AppCompatDelegateImplV9,原理都是一样的,这是更加清晰。
好了,题外话过,去到实现类的 setContentView,可以看到:
在这里插入图片描述
除了我们熟悉的 R.id.content,最重要的就是 LayoutInflater 的 inflate 方法了,进入看看:
在这里插入图片描述
可以看到,拿到了 resource 之后,通过 res.getLayout(resource) 去解析 xml 布局,最后继续执行 inflate 方法,继续看下去:

    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {
            Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");

            final Context inflaterContext = mContext;
            final AttributeSet attrs = Xml.asAttributeSet(parser);
            Context lastContext = (Context) mConstructorArgs[0];
            mConstructorArgs[0] = inflaterContext;
            View result = root;

          ...

                if (TAG_MERGE.equals(name)) {
                    if (root == null || !attachToRoot) {
                        throw new InflateException("<merge /> can be used only with a valid "
                                + "ViewGroup root and attachToRoot=true");
                    }

                    rInflate(parser, root, inflaterContext, attrs, false);
                } else {
                    // Temp is the root view that was found in the xml
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                    ViewGroup.LayoutParams params = null;

                  .....

            return result;
        }
    }

这个方法会先去解析是否有自定义属性,然后可以从 xml 文件根部去解析;最重要的是里面有个方法 createViewFromTag,它是 view 生成的关键点,进入看看:

    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;
            }

            if (view == null && mPrivateFactory != null) {
                view = mPrivateFactory.onCreateView(parent, name, context, attrs);
            }

            if (view == null) {
                final Object lastContext = mConstructorArgs[0];
                mConstructorArgs[0] = context;
                try {
                    if (-1 == name.indexOf('.')) {
                        view = onCreateView(parent, name, attrs);
                    } else {
                        view = createView(name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }

            return view;
        ... 

重点可以看到,View 的解析首先,会先判断 mFactory2 是否不为null,如果不是,则去通过 onCreateView 去创建这个 view,如果为 null,则判断 mFactory (其实如果你设置 mFactory ,到源码里面还是被替换成 mFactory2 的,具体自己跟踪),以此类推;

等等, 这个 mFactory2 哪来的?跟踪的时候没看到啊?
别急,当你继承 AppCompatActivity 的时候,我们进入看看
在这里插入图片描述
在 oncreate 方法的时候,有个 installViewFactory()方法,它的具体实现类是 AppCompatDelegateImpl ,可以看到:

在这里插入图片描述
恩恩,这个就好说了。

接着如果都找不到这个 view,则会通过 createView 这个方法去重新解析 View。去到 mFactory2 中的 onCreateView 方法,你是一个接口,具体实现类是 AppCompatDelegateImpl 或 AppCompatDelegateImplV9 (targetSdkVersion 26),看看里面的方法:
在这里插入图片描述
里面会把它再交给 mAppCompatViewInflater.createView(),然后可以看到:

    public final View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs, boolean inheritContext,
            boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
        final Context originalContext = context;

		...

        View view = null;

        // We need to 'inject' our tint aware Views in place of the standard framework versions
        switch (name) {
            case "TextView":
                view = new  (context, attrs);
                break;
            case "ImageView":
                view = new AppCompatImageView(context, attrs);
                break;
            case "Button":
                view = new AppCompatButton(context, attrs);
                break;
            case "EditText":
                view = new AppCompatEditText(context, attrs);
                break;
            case "Spinner":
                view = new AppCompatSpinner(context, attrs);
                break;
            case "ImageButton":
                view = new AppCompatImageButton(context, attrs);
                break;
            case "CheckBox":
                view = new AppCompatCheckBox(context, attrs);
                break;
            case "RadioButton":
                view = new AppCompatRadioButton(context, attrs);
                break;
            case "CheckedTextView":
                view = new AppCompatCheckedTextView(context, attrs);
                break;
            case "AutoCompleteTextView":
                view = new AppCompatAutoCompleteTextView(context, attrs);
                break;
            case "MultiAutoCompleteTextView":
                view = new AppCompatMultiAutoCompleteTextView(context, attrs);
                break;
            case "RatingBar":
                view = new AppCompatRatingBar(context, attrs);
                break;
            case "SeekBar":
                view = new AppCompatSeekBar(context, attrs);
                break;
        }

        if (view == null && originalContext != context) {
            // If the original context does not equal our themed context, then we need to manually
            // inflate it using the name so that android:theme takes effect.
            view = createViewFromTag(context, name, attrs);
        }

        if (view != null) {
            // If we have created a view, check its android:onClick
            checkOnClickListener(view, attrs);
        }

        return view;
    }

真香最终打败了,原来如果 activity 继承 AppCompatActivity,则在内部,会把 textView 替换成 AppCompatTextView,这也是我们在 xml 中写 TextView,在 layout inspector 却显示 AppCompatTextView 的问题了。

当然,不是每个 view 都替换,如果找不到这个 view,则通过 createViewFromTag(context, name, attrs); 去解析:
在这里插入图片描述
可以发现,还是用了 createView 去解析,createView方法时通过 类加载去加载的,这里不深入了解了。

2.1 简单替换 View

从上面知道了,View 的生成在 mFactory2 中的 onCreateView 中,那么,这里,我们做个小实验,比如检测到 textview ,把它改成 button 试试,由于 AppCompatActivity 在 onCreate 之前就设置了 mFactory2,所以,我们自己的 factory 要放到 super.oncreate() 之前,如下:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        
        LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), new LayoutInflater.Factory2() {
            @Override
            public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {

                if (name.equals("TextView") ){
                    Button button = new Button(context);
                    button.setText("我被替换了");
                    return button;
                }
                return null;
            }

            @Override
            public View onCreateView(String name, Context context, AttributeSet attrs) {
                //这个方法时 mFactory,因为 mFactory2 继承 mFactory ,所以可以不用管
                return null;
            }
        });
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

好了,看一下效果,换肤前:
在这里插入图片描述
换肤后:
在这里插入图片描述
可以看到,TextView 确实被替换了,不过我们看一下 layout insepctor:
在这里插入图片描述
咦,我的 Button 没有替换成 AppCompatButton了,为啥呢?
因为我们自己设置了 factory ,且在 onCreateView 回调的时候,直接返回 button了:
在这里插入图片描述
都没经过 系统的替换,那这里肯定没变了。那我想享受 AppCompat 带来的额外属性怎么办?

简单,我们自己不去创建 View,交还给系统去创建,把 name 改成 button 就可以了,如下:

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

    if (name.equals("TextView") ){
        name = "Button";
    }
    View view  = getDelegate().createView(parent,name,context,attrs);
    return view;
}

再看看 layout inspector:
在这里插入图片描述

三、实际应用

通过上面分析,你应该知道 factory 的作用,常见的实际应用有以下:

3.1 全局替换字体

有时候需要一键该字体,那我们检测到当前view 为 textview,全局替换即可,简单代码如下:

        final Typeface typeface = Typeface.createFromAsset(getAssets(),"yahei.ttf");
        
        LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), new LayoutInflater.Factory2() {
            @Override
            public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {

               
                View view  = getDelegate().createView(parent,name,context,attrs);
                if (view instanceof TextView){
                    TextView textView = (TextView) view;
                    textView.setTypeface(typeface);
                }
                return view;
            }

            @Override
            public View onCreateView(String name, Context context, AttributeSet attrs) {
                //这个方法时 mFactory,因为 mFactory2 继承 mFactory ,所以可以不用管
                return null;
            }
        });

3.2 换肤

这个网上都很成熟的方法了,但自己搞一个不香吗;可以应用上面的知识尝试一下;

很多网上的换肤框架都需要继承 baseActivity,baseFragment 的,又或者说什么需要传递 context 的,比如 skinManager.with(this)。

额,其实这里有小技巧,其实我们在自己的库里,编写一个 contentprovider,从 onCreate 拿到 context,检测到这个 context 是application,就可以通过 application 去拿到所有的 activity 了。比如:
在这里插入图片描述
然后在 onActivityCreated 的时候,添加我们的皮肤注入即可,如下:

在这里插入图片描述
感兴趣可以看看这个:https://github.com/LillteZheng/ZSkinPlugin
效果如下:
在这里插入图片描述

3.3无需编写shape、selector,直接在xml设置值

前段时间火到爆的,原理也是用到 factory,上面的 contentprovider 小技巧也是参考这个的哦;

地址: https://juejin.im/post/5b9682ebe51d450e543e3495

这样,这篇文章就写完了。

参考:https://mp.weixin.qq.com/s/1ua0geFnrbQbyHi8KG2VJQ

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值