欢迎访问移动开发之家(rcyd.net),关注移动开发教程。移动开发之家  移动开发问答|  每日更新
页面位置 : > > > 内容正文

趣头条大佬带你飞:实现阿里无抖动换肤,阿里巴巴大佬,弊端:换肤范围仅限于这个

来源: 开发者 投稿于  被查看 44805 次 评论:24

趣头条大佬带你飞:实现阿里无抖动换肤,阿里巴巴大佬,弊端:换肤范围仅限于这个


作者: 西柚9102
原文: https://www.jianshu.com/p/4c8d46f58c4f


引子


产品大佬又提需求啦,要求app里面的图表要实现白天黑夜模式的切换,以满足不同光线下都能保证足够的图表清晰度. 怎么办?可能解决的办法很多,你可以给图表view增加一个toggle方法,参数Stringday/night,然后切换之后postInvalidate 刷新重绘.
OK,可行,但是这种方式切换白天黑夜,只是单个View中有效,那么如果哪天产品又要另一个View换肤,难道我要一个一个去写toggle么?未免太low了.

那么能不能要实现一个全app内的一键换肤,一劳永逸~~~

1. 什么是一键换肤

所谓"一键"

,就是通过

"一个"

**接口的调用,就能实现全app范围内的所有资源文件的替换.包括 文本,颜色,图片等.


一些换肤实现方式的对比

  • 方案1:自定义View中,要换肤,那如同引言中所述,toggle方法,invalidate重绘。
    弊端:换肤范围仅限于这个View.

  • 方案2:给静态变量赋值,然后重启Activity. 如果一个Activity内用静态变量定义了两种色系,那么确实是可以通过关闭Activity,再启动的方式,实现 貌似换肤的效果(其实是重新启动了Activity)
    弊端:太low,而且很浪费资源

也许还有其他方案吧,View重绘,重启Activity,都能实现,但是仍然不是最优雅的方案,那么,有没有一种方案,能够实现全app内的换肤效果,又不会像重启 Activity 这样浪费资源呢?请看下图:

这个动态图中,首先看到的是Activity1,点击换肤,可直接更换界面上的background,图片的src,还有textViewtextColor,跳转Activity2之后的textView颜色,在我换肤之前,和换肤之后,是不同的。换肤的过程我并没有启动另外的Activity,界面也没有闪烁。我在Activity1里面换肤,直接影响了Activity2textView字体颜色。

既然给出了效果,那么肯定要给出Demo,不然太没诚意,嘿嘿嘿
github地址奉上:

https://github.com/18598925736/HookSkinDemoFromHank


2. 界面上哪些东西是可以换肤的



3. 利用HOOK技术实现优雅的“一键换肤"


4. 相关android源码一览

  • Activity 的 setContentView(R.layout.XXX) 到底在做什么?
    回顾我们写app的习惯,创建Activity,写xxx.xml,在Activity里面setContentView(R.layout.xxx). 我们写的是xml,最终呈现出来的是一个一个的界面上的UI控件,那么setContentView到底做了什么事,使得XML里面的内容,变成了UI控件呢?

如果不先来点干货,估计有些人就看不下去了,各位客官请看下图:

源码索引:

setContentView(R.layout.activity_main);

---》

getDelegate().setContentView(layoutResID);

OK,这里暴露出了两个方法,getDelegate()setContentView()

先看getDelegate:
这里返回了一个AppCompatDelegate对象,跟踪到AppCompatDelegate内部,阅读源码,可以得出一个结论:AppCompatDelegate 是 替Activity生成View对象的委托类,它提供了一系列setContentView方法,在Activity中加入UI控件。
那它的AppCompatDelegatesetContentView方法又做了什么?

插曲:关于如何阅读源码?这里漏了一个细节:那就是,当你在源码中看到一个接口或者抽象类,你想知道接口的实现类在哪?很简单...如果你没有更改androidStudio的快捷键设置的话,Ctrl+T可以帮你直接定位 接口和抽象类的实现类.

用上面的方法,找到setContentView的具体过程

那么就进入下一个环节:LayoutInflater又做了什么?

  • LayoutInflater这个类是怎么把layout.xml的 <TextView> 变成TextView对象的?
    我们知道,我们传入的是int,是xxx.xml这个布局文件,在R文件里面的对应int值。LayoutInflater拿到了这个int之后,又干了什么事呢?

一路索引进去:会发现这个方法:

发现一个关键方法:CreateViewFromTag,tag是指的什么?其实就是 xml里面 的标签头:<TextView ....> 里的

TextView.
跟踪进去:

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
        if (name.equals("view")) {
            name = attrs.getAttributeValue(null, "class");
        }

        // Apply a theme wrapper, if allowed and one is specified.
        if (!ignoreThemeAttr) {
            final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
            final int themeResId = ta.getResourceId(0, 0);
            if (themeResId != 0) {
                context = new ContextThemeWrapper(context, themeResId);
            }
            ta.recycle();
        }

        if (name.equals(TAG_1995)) {
            // Let's party like it's 1995!
            return new BlinkLayout(context, attrs);
        }

        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;
        } catch (InflateException e) {
            throw e;

        } catch (ClassNotFoundException e) {
            final InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": Error inflating class " + name, e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;

        } catch (Exception e) {
            final InflateException ie = new InflateException(attrs.getPositionDescription()
                    + ": Error inflating class " + name, e);
            ie.setStackTrace(EMPTY_STACK_TRACE);
            throw ie;
        }
    }

这个方法有4个参数,意义分别是:

  • View parent 父组件

  • String name xml标签名

  • Context context 上下文

  • AttributeSet attrs view属性

  • boolean ignoreThemeAttr 是否忽略theme属性

并且在这里,发现一段关键代码:

实际上,可能有人要问了,你怎么知道这边是走的哪一个if分支呢?

方法:新创建一个Project,跟踪MainActivity onCreate里面setContentView()一路找到这段代码debug:你会发现:

答案很明确了,系统在默认情况下就会走Factory2的onCreateView(),

应该有人好奇:这个mFactory2对象是哪来的?是什么时候set进去的*
答案如下:

如果细心Debug,就会发现

当时,getDelegate()得到的对象,和 LayoutInflater里面mFactory2其实是同一个对象

那么继续跟踪,一直到:AppCompatViewInflater 类:

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;

        // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
        // by using the parent's context
        if (inheritContext && parent != null) {
            context = parent.getContext();
        }
        if (readAndroidTheme || readAppTheme) {
            // We then apply the theme on the context, if specified
            context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
        }
        if (wrapContext) {
            context = TintContextWrapper.wrap(context);
        }

        View view = null;

        // We need to 'inject' our tint aware Views in place of the standard framework versions
        switch (name) {
            case "TextView":
                view = createTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageView":
                view = createImageView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Button":
                view = createButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "EditText":
                view = createEditText(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Spinner":
                view = createSpinner(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageButton":
                view = createImageButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckBox":
                view = createCheckBox(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RadioButton":
                view = createRadioButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckedTextView":
                view = createCheckedTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "AutoCompleteTextView":
                view = createAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "MultiAutoCompleteTextView":
                view = createMultiAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RatingBar":
                view = createRatingBar(context, attrs);
                verifyNotNull(view, name);
                break;
            case "SeekBar":
                view = createSeekBar(context, attrs);
                verifyNotNull(view, name);
                break;
            default:
                // The fallback that allows extending class to take over view inflation
                // for other tags. Note that we don't check that the result is not-null.
                // That allows the custom inflater path to fall back on the default one
                // later in this method.
                view = createView(context, name, attrs);
        }

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

这边利用了大量的switch case来进行系统控件的创建,例如:TextView

都是new 出来一个具有兼容特性的TextView,返回出去。


但是,使用过
switch 的人都知道,这种case形式的分支,无法涵盖所有的类型怎么办呢?这里switch之后,view仍然可能是null.
所以,switch之后,谷歌大佬加了一个if,但是很诡异,这段代码并未进入if,因为 originalContext != context并不满足....具体原因我也没查出来,(;´д`)ゞ

然而,这里的补救措施没有执行,那自然有地方有另外的补救措施:

回到之前的LayoutInflater的下面这段代码:

这段代码的下面,如果view是空,补救措施如下:

这里的两个方法onCreateView(parent, name, attrs)createView(name, null, attrs);都最终索引到:

public final View createView(String name, String prefix, AttributeSet attrs)
            throws ClassNotFoundException, InflateException {
        Constructor<? extends View> constructor = sConstructorMap.get(name);
        if (constructor != null && !verifyClassLoader(constructor)) {
            constructor = null;
            sConstructorMap.remove(name);
        }
        Class<? extends View> clazz = null;

        try {
            Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);

            if (constructor == null) {
                // Class not found in the cache, see if it's real, and try to add it
                clazz = mContext.getClassLoader().loadClass(
                        prefix != null ? (prefix + name) : name).asSubclass(View.class);

                if (mFilter != null && clazz != null) {
                    boolean allowed = mFilter.onLoadClass(clazz);
                    if (!allowed) {
                        failNotAllowed(name, prefix, attrs);
                    }
                }
                constructor = clazz.getConstructor(mConstructorSignature);
                constructor.setAccessible(true);
                sConstructorMap.put(name, constructor);
            } else {
                // If we have a filter, apply it to cached constructor
                if (mFilter != null) {
                    // Have we seen this name before?
                    Boolean allowedState = mFilterMap.get(name);
                    if (allowedState == null) {
                        // New class -- remember whether it is allowed
                        clazz = mContext.getClassLoader().loadClass(
                                prefix != null ? (prefix + name) : name).asSubclass(View.class);

                        boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
                        mFilterMap.put(name, allowed);
                        if (!allowed) {
                            failNotAllowed(name, prefix, attrs);
                        }
                    } else if (allowedState.equals(Boolean.FALSE)) {
                        failNotAllowed(name, prefix, attrs);
                    }
                }
            }

            Object lastContext = mConstructorArgs[0];
            if (mConstructorArgs[0] == null) {
                // Fill in the context if not already within inflation.
                mConstructorArgs[0] = mContext;
            }
            Object[] args = mConstructorArgs;
            args[1] = attrs;

            final View view = constructor.newInstance(args); // 真正需要关注的关键代码,就是这一行,执行了构造函数,返回了一个View对象
            if (view instanceof ViewStub) {
                // Use the same context when inflating ViewStub later.
                final ViewStub viewStub = (ViewStub) view;
                viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
            }
            mConstructorArgs[0] = lastContext;
            return view;

        } catch (NoSuchMethodException e) {
           ·····
        }
    }

这么一大段好像有点让人害怕。其实真正需要关注的,就是反射的代码,最后的 newInstance().
OK,Activity上那些丰富多彩的View的来源,就说到这里, 如果有看不懂的,欢迎留言探讨. ( ̄▽ ̄) !

  • app中资源文件大管家 Resources / AssetManager 是怎么工作的

从我们的终极目的出发:我们要做的是“换肤”,如果我们拿到了要换肤的View,可以对他们进行setXXX属性来改变UI,那么属性值从哪里来?
界面元素丰富多彩,但是这些View,都是用资源文件来进行 "装扮"出来的,资源文件大致可以分为:
图片,文字,颜色,声音视频,字体等。如果我们控制了资源文件,那么是不是有能力对界面元素进行set某某属性来进行“再装扮”呢? 当然,这是可行的。因为,我们平时拿到一个TextView,就能对它进行setTextColor,这种操作,在view还存活的时候,都可以进行操作,并且这种操作,并不会造成Activity的重启。
这些资源文件,有一个统一的大管家。可能有人说是R.java文件,它里面统筹了所有的资源文件int值.没错,但是这个R文件是如何产生作用的呢? 答案:Resources.*

本来这里应该写上源码追踪记录的,但是由于 源码无法追踪,原因暂时还没找到,之前追查setContentView(R.layout.xxxx)的时候还可以debug,现在居然不行了,很诡异!

答案找到了:因为我使用的是 真机,一般手机厂商都会对原生系统进行修改,然后将系统写到到真机里面。
而,我们debug,用的是原生SDK。用实例来说,我本地是SDK 27的源码,真机也是27的系统,但是真机的运行起来的系统的代码,是被厂家修改了的,和我本地的必然有所差别,所以,有些代码报红,就很正常了,无法debug也很正常。

既然如此,那我就直接写结论了,一张图说明一切:



5. "全app一键换肤" Demo源码详解

  • 项目工程结构:


  • 关键类 SkinFactory

SkinFactory类, 继承LayoutInflater.Factory2 ,它的实例,会负责创建View,收集 支持换肤的view

public class SkinFactory implements LayoutInflater.Factory2 {

    private AppCompatDelegate mDelegate;//预定义一个委托类,它负责按照系统的原有逻辑来创建view

    private List<SkinView> listCacheSkinView = new ArrayList<>();//我自定义的list,缓存所有可以换肤的View对象

    /**
     * 给外部提供一个set方法
     */
    public void setDelegate(AppCompatDelegate mDelegate) {
        this.mDelegate = mDelegate;
    }

    /**
     * Factory2 是继承Factory的,所以,我们这次是主要重写Factory的onCreateView逻辑,就不必理会Factory的重写方法了
     */
    @Override
    public View onCreateView(String name, Context context, AttributeSet attrs) {
        return null;
    }

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

        // TODO: 关键点1:执行系统代码里的创建View的过程,我们只是想加入自己的思想,并不是要全盘接管
        View view = mDelegate.createView(parent, name, context, attrs);//系统创建出来的时候有可能为空,你问为啥?请全文搜索 “标记标记,因为” 你会找到你要的答案
        if (view == null) {//万一系统创建出来是空,那么我们来补救
            try {
                if (-1 == name.indexOf('.')) {//不包含. 说明不带包名,那么我们帮他加上包名
                    view = createViewByPrefix(context, name, prefixs, attrs);
                } else {//包含. 说明 是权限定名的view name,
                    view = createViewByPrefix(context, name, null, attrs);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        //TODO: 关键点2 收集需要换肤的View
        collectSkinView(context, attrs, view);

        return view;
    }

    /**
     * TODO: 收集需要换肤的控件
     * 收集的方式是:通过自定义属性isSupport,从创建出来的很多View中,找到支持换肤的那些,保存到map中
     */
    private void collectSkinView(Context context, AttributeSet attrs, View view) {
        // 获取我们自己定义的属性
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.Skinable);
        boolean isSupport = a.getBoolean(R.styleable.Skinable_isSupport, false);
        if (isSupport) {//找到支持换肤的view
            final int Len = attrs.getAttributeCount();
            HashMap<String, String> attrMap = new HashMap<>();
            for (int i = 0; i < Len; i++) {//遍历所有属性
                String attrName = attrs.getAttributeName(i);
                String attrValue = attrs.getAttributeValue(i);
                attrMap.put(attrName, attrValue);//全部存起来
            }

            SkinView skinView = new SkinView();
            skinView.view = view;
            skinView.attrsMap = attrMap;
            listCacheSkinView.add(skinView);//将可换肤的view,放到listCacheSkinView中
        }

    }

    /**
     * 公开给外界的换肤入口
     */
    public void changeSkin() {
        for (SkinView skinView : listCacheSkinView) {
            skinView.changeSkin();
        }
    }

    static class SkinView {
        View view;
        HashMap<String, String> attrsMap;

        /**
         * 真正的换肤操作
         */
        public void changeSkin() {
            if (!TextUtils.isEmpty(attrsMap.get("background"))) {//属性名,例如,这个background,text,textColor....
                int bgId = Integer.parseInt(attrsMap.get("background").substring(1));//属性值,R.id.XXX ,int类型,
                // 这个值,在app的一次运行中,不会发生变化
                String attrType = view.getResources().getResourceTypeName(bgId); // 属性类别:比如 drawable ,color
                if (TextUtils.equals(attrType, "drawable")) {//区分drawable和color
                    view.setBackgroundDrawable(SkinEngine.getInstance().getDrawable(bgId));//加载外部资源管理器,拿到外部资源的drawable
                } else if (TextUtils.equals(attrType, "color")) {
                    view.setBackgroundColor(SkinEngine.getInstance().getColor(bgId));
                }
            }

            if (view instanceof TextView) {
                if (!TextUtils.isEmpty(attrsMap.get("textColor"))) {
                    int textColorId = Integer.parseInt(attrsMap.get("textColor").substring(1));
                    ((TextView) view).setTextColor(SkinEngine.getInstance().getColor(textColorId));
                }
            }

            //那么如果是自定义组件呢
            if (view instanceof ZeroView) {
                //那么这样一个对象,要换肤,就要写针对性的方法了,每一个控件需要用什么样的方式去换,尤其是那种,自定义的属性,怎么去set,
                // 这就对开发人员要求比较高了,而且这个换肤接口还要暴露给 自定义View的开发人员,他们去定义
                // ....
            }
        }

    }

    /**
     * 所谓hook,要懂源码,懂了之后再劫持系统逻辑,加入自己的逻辑。
     * 那么,既然懂了,系统的有些代码,直接拿过来用,也无可厚非。
     */
    //*******************************下面一大片,都是从源码里面抄过来的,并不是我自主设计******************************
    // 你问我抄的哪里的?到 AppCompatViewInflater类源码里面去搜索:view = createViewFromTag(context, name, attrs);
    static final Class<?>[] mConstructorSignature = new Class[]{Context.class, AttributeSet.class};//
    final Object[] mConstructorArgs = new Object[2];//View的构造函数的2个"实"参对象
    private static final HashMap<String, Constructor<? extends View>> sConstructorMap = new HashMap<String, Constructor<? extends View>>();//用映射,将View的反射构造函数都存起来
    static final String[] prefixs = new String[]{//安卓里面控件的包名,就这么3种,这个变量是为了下面代码里,反射创建类的class而预备的
            "android.widget.",
            "android.view.",
            "android.webkit."
    };

    /**
     * 反射创建View
     */
    private final View createViewByPrefix(Context context, String name, String[] prefixs, AttributeSet attrs) {

        Constructor<? extends View> constructor = sConstructorMap.get(name);
        Class<? extends View> clazz = null;

        if (constructor == null) {
            try {
                if (prefixs != null && prefixs.length > 0) {
                    for (String prefix : prefixs) {
                        clazz = context.getClassLoader().loadClass(
                                prefix != null ? (prefix + name) : name).asSubclass(View.class);//控件
                        if (clazz != null) break;
                    }
                } else {
                    if (clazz == null) {
                        clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
                    }
                }
                if (clazz == null) {
                    return null;
                }
                constructor = clazz.getConstructor(mConstructorSignature);//拿到 构造方法,
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
            constructor.setAccessible(true);//
            sConstructorMap.put(name, constructor);//然后缓存起来,下次再用,就直接从内存中去取
        }
        Object[] args = mConstructorArgs;
        args[1] = attrs;
        try {
            //通过反射创建View对象
            final View view = constructor.newInstance(args);//执行构造函数,拿到View对象
            return view;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}
关键类 SkinEngine

因字数超出

用户评论