(Android-skin-support)换肤框架原理研究并运用

背景

很多app需要进行换肤,到网络上找到了一个库--android-skin-support。很快就实现了需求,根据文档集成很方便,代码侵入性也低。

本来也没有去深究它的实现原理,前几天有个迭代的需求如下:
美股市场是“绿涨红跌”,而A股市场是“红涨绿跌”,产品要求用户可以自由选择。这个需求跟以前的换肤很类似,所以就仔细研究学习了"android-skin-support"库的实现原理,并根据其原理实现“红涨绿跌”.

原理

  • 使用观察者模式
    框架抽象了一个SkinCompatSupportable接口:
    public interface SkinCompatSupportable {
        void applySkin();
    }

所有需要换肤的控件都实现该接口,在用户执行“换肤”操作时候,通知所有实现该接口的订阅者执行换肤操作applySkin()

按照上面的原理,那所有的控件都需要实现接口SkinCompatSupportable,那原生的控件怎么办呢?框架层面把所有常用的原生控件都重写了一遍,在包skin.support.widget下。框架层在解析布局文件的时候会把原生的控件替换成实现了接口SkinCompatSupportable的对应控件。框架层是怎么做的呢?请继续查看下文。

  • 注册activity生命周期监听器
    查看类skin.support.app.SkinActivityLifecycle,

    private SkinActivityLifecycle(Application application) {
        application.registerActivityLifecycleCallbacks(this);
        installLayoutFactory(application);
        SkinCompatManager.getInstance()
           .addObserver(getObserver(application));
    }
    
    private void installLayoutFactory(Context context) {
        LayoutInflater layoutInflater = LayoutInflater.from(context);
        try {
            Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
            field.setAccessible(true);
            field.setBoolean(layoutInflater, false);
            LayoutInflaterCompat.setFactory(layoutInflater, getSkinDelegate(context));
        } catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
    
    @Override
    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
        if (isContextSkinEnable(activity)) {
            installLayoutFactory(activity);
            updateStatusBarColor(activity);
            updateWindowBackground(activity);
            if (activity instanceof SkinCompatSupportable) {
                ((SkinCompatSupportable) activity).applySkin();
            }
    
            if (activity instanceof SkinCompatChangeGreenRed) {
                boolean isSupport = ((SkinCompatChangeGreenRed)activity).isSupportChange();
                ((SkinCompatChangeGreenRed)activity).applyChangeColor(isSupport ? SkinCompatChangeGreenRed.STATE_CHANGE : SkinCompatChangeGreenRed.STATE_DEFAULT);
            }
        }
    }
    

注意  installLayoutFactory 方法,在每个activity的#onActivityCreated中把自己的LayoutInflaterFactory类(SkinCompatDelegate)设置进去,而把原生控件替换成库中的控件就在这个类中实现的,并且把所有实现SkinCompatSupportable接口的观察者都收集起来。这样代码的侵入性就变得很低,使得几行代码就可以实现换肤操作。

或许有人疑惑为什么这样做就可以实现xml解析拦截? 我们再来看看 ```AppCompatActivity``` 的代码实现。

```java

 protected void onCreate(@Nullable Bundle savedInstanceState) {
    final AppCompatDelegate delegate = getDelegate();
    delegate.installViewFactory();
    delegate.onCreate(savedInstanceState);
    if (delegate.applyDayNight() && mThemeId != 0) {
        // If DayNight has been applied, we need to re-apply the theme for
        // the changes to take effect. On API 23+, we should bypass
        // setTheme(), which will no-op if the theme ID is identical to the
        // current theme ID.
        if (Build.VERSION.SDK_INT >= 23) {
            onApplyThemeResource(getTheme(), mThemeId, false);
        } else {
            setTheme(mThemeId);
        }
    }
    super.onCreate(savedInstanceState);
    }

我们看到有一个AppCompatDelegate,它是Activity的委托,AppCompatActivity将大部分生命周期都委托给了AppCompatDelegate,这点可从上面的源码中可以看出.
继续看源码我们发现,解析xml布局的解析起也是在AppCompatDelegate对象中设置的。

AppCompatDelegateImplV9.java

@Override
    public void installViewFactory() {
        LayoutInflater layoutInflater = LayoutInflater.from(mContext);
        if (layoutInflater.getFactory() == null) {
            LayoutInflaterCompat.setFactory2(layoutInflater, this);
        } else {
            if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImplV9)) {
                Log.i(TAG, "The Activity's LayoutInflater already has a Factory installed"
                        + " so we can not install AppCompat's");
            }
        }
    }

LayoutInflaterCompat.setFactory2(layoutInflater, this);最终是调用的LayoutInflater的setFactory2()方法,看看实现

/**
* Like {@link #setFactory}, but allows you to set a {@link Factory2}
* interface.
*/
public void setFactory2(Factory2 factory) {
    if (mFactorySet) {
        throw new IllegalStateException("A factory has already been set on this LayoutInflater");
    }
    if (factory == null) {
        throw new NullPointerException("Given factory can not be null");
    }
    mFactorySet = true;
    if (mFactory == null) {
        mFactory = mFactory2 = factory;
    } else {
        mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
    }
}

这里有个小细节,Factory2只能被设置一次,设置完成后mFactorySet属性就为true,下一次设置时被直接抛异常.
那么Factory2有什么用呢?看看其实现
它是一个接口,只有一个方法,看起来是用来创建View的.

综上所述,我们就可以根据其机制实现xml解析并拦截,让解析xml原生控件的时候返回我们想要的支持“换肤”动作的对应控件。

  • 拦截xml控件解析事件

代码如下

SkinCompatViewInflater.java

private View createViewFromFV(Context context, String name, AttributeSet attrs) {
        View view = null;
        if (name.contains(".")) {
            return null;
        }
        switch (name) {
            case "View":
                view = new SkinCompatView(context, attrs);
                break;
            case "LinearLayout":
                view = new SkinCompatLinearLayout(context, attrs);
                break;
            case "RelativeLayout":
                view = new SkinCompatRelativeLayout(context, attrs);
                break;
            case "FrameLayout":
                view = new SkinCompatFrameLayout(context, attrs);
                break;
            case "TextView":
                view = new SkinCompatTextView(context, attrs);
                break;
            case "ImageView":
                view = new SkinCompatImageView(context, attrs);
                break;
            case "Button":
                view = new SkinCompatButton(context, attrs);
                break;
            case "EditText":
                view = new SkinCompatEditText(context, attrs);
                break;
            case "Spinner":
                view = new SkinCompatSpinner(context, attrs);
                break;
            case "ImageButton":
                view = new SkinCompatImageButton(context, attrs);
                break;
            case "CheckBox":
                view = new SkinCompatCheckBox(context, attrs);
                break;
            case "RadioButton":
                view = new SkinCompatRadioButton(context, attrs);
                break;
            case "RadioGroup":
                view = new SkinCompatRadioGroup(context, attrs);
                break;
            case "CheckedTextView":
                view = new SkinCompatCheckedTextView(context, attrs);
                break;
            case "AutoCompleteTextView":
                view = new SkinCompatAutoCompleteTextView(context, attrs);
                break;
            case "MultiAutoCompleteTextView":
                view = new SkinCompatMultiAutoCompleteTextView(context, attrs);
                break;
            case "RatingBar":
                view = new SkinCompatRatingBar(context, attrs);
                break;
            case "SeekBar":
                view = new SkinCompatSeekBar(context, attrs);
                break;
            case "ProgressBar":
                view = new SkinCompatProgressBar(context, attrs);
                break;
            case "ScrollView":
                view = new SkinCompatScrollView(context, attrs);
                break;
        }
        return view;
    }

这里还是有点迷惑, 那么我们再来看看android创建view的过程

平时我们最常使用的Activity中的setContentView()设置布局ID,看看Activity中的实现,

public void setContentView(@LayoutRes int layoutResID) {
        getWindow().setContentView(layoutResID);
        initWindowDecorActionBar();
    }

调用的是Window中的setContentView(),而Window只有一个实现类,就是PhoneWindow.看看setContentView()实现

@Override
    public void setContentView(int layoutResID) {
        ...
        if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                    getContext());
            transitionTo(newScene);
        } else {
            mLayoutInflater.inflate(layoutResID, mContentParent);
        }
        ...
    }

看到了今天的主角mLayoutInflater,mLayoutInflater是在PhoneWindow的构造方法中初始化的.用mLayoutInflater去加载这个布局(layoutResID).点进去看看实现,来看看createViewFromTag()的实现

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

可以看到如果mFactory2不为空的话,那么就会调用mFactory2去创建View(mFactory2.onCreateView(parent, name, context, attrs)) . 这句结论很重要.前面的答案已揭晓.如果设置了mFactory2就会用mFactory2去创建View.而mFactory2在上面已经被我们替换了。****

  • 加载皮肤

我们前面已经了解了换肤的原理,现在就根据上述原理进行换肤。 该框架提供了几种加载皮肤的策略,前后缀价值,apk加载等等。

SkinCompatManager.getInstance().loadSkin

总结

简单总结一下原理(本文精髓)

监听APP所有Activity的生命周期(registerActivityLifecycleCallbacks())
在每个Activity的onCreate()方法调用时setFactory(),设置创建View的工厂.将创建View的琐事交给SkinCompatViewInflater去处理.
库中自己重写了系统的控件(比如View对应于库中的SkinCompatView),实现换肤接口(接口里面只有一个applySkin()方法),表示该控件是支持换肤的.并且将这些控件在创建之后收集起来,方便随时换肤.
在库中自己写的控件里面去解析出一些特殊的属性(比如:background, textColor),并将其保存起来
在切换皮肤的时候,遍历一次之前缓存的View,调用其实现的接口方法applySkin(),在applySkin()中从皮肤资源(可以是从网络或者本地获取皮肤包)中获取资源.获取资源后设置其控件的background或textColor等,就可实现换肤.

借鉴应用

现在根据上述原理低侵入性实现“红涨绿跌”,

  • 接口抽象

定义一个接口,让支持“红涨绿跌”切换的控件都实现该接口
SkinCompatChangeGreenRed

  • 所有观察者都集合起来
  • 执行“红涨绿跌”操作的时候通知所有观察者。
public void notifyChangeColor(boolean isSupport){
        SkinObserver[] arrLocal;

        synchronized (this) {
            arrLocal = observers.toArray(new SkinObserver[observers.size()]);
        }

        for (int i = arrLocal.length-1; i>=0; i--)
            arrLocal[i].updateChangeColor(this, isSupport? SkinCompatChangeGreenRed.STATE_CHANGE : SkinCompatChangeGreenRed.STATE_DEFAULT);
    }
  • 切换颜色的动作 每个控制自己处理。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容