Android 插件化换肤原理

2022-10-16  本文已影响0人  夏木友人

一、什么是换肤?

换肤功能是指:我们预先准备好几套皮肤资源包,然后用户可以随意选择一套皮肤进行更换,更换后界面上的 View 相关资源(颜色、样式、图片、背景等)相应发生改变;
知道换肤表现,我们就要想想如何实现这个过程:

  1. 我们要如何加载皮肤资源包
  2. 我们要如何对view进行换肤
  3. 我们要如何通知所有view进行换肤

二、View如何换肤?

首先我们来解决下最重要的问题?下如何对View进行换肤?

在这之前,我们看下Activity是如何解析xml,创建view的。AppCompatActivity setContentView()

AppCompatActivity.java
    public void setContentView(@LayoutRes int layoutResID) {
        initViewTreeOwners();
        getDelegate().setContentView(layoutResID);
    }

  ....
  ....
    public AppCompatDelegate getDelegate() {
        if (mDelegate == null) {
            mDelegate = AppCompatDelegate.create(this, this);
        }
        return mDelegate;
    }

AppCompatDelegate是AppCompatActivity的代理处理类

AppCompatDelegate.java
    public void setContentView(int resId) {
        ensureSubDecor();
        ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
        contentParent.removeAllViews();
        LayoutInflater.from(mContext).inflate(resId, contentParent);
        mAppCompatWindowCallback.getWrapped().onContentChanged();
    }

LayoutInflater我们都很熟悉的布局填充类,解析xml转化成view

LayoutInflater.java
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    ...
    ...
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }


public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {

          ...
          //解析xml,根据标签创建对应的view
            final View temp = createViewFromTag(root, name, inflaterContext, attrs);
          ...
            return result;
        }
    }

    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {

//根据标签,创建系统view,
 View view = tryCreateView(parent, name, context, attrs);
      ...
      ...
            if (view == null) {
                try {
                  //如果为空就创建自定义View
                    if (-1 == name.indexOf('.')) {
                        view = onCreateView(context, parent, name, attrs);
                    } else {
                        view = createView(context, name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }
    ....
    ....
  return view;
}

可以看到如果 mFactory2不为空的话,那么就会调用 mFactory2 去创建View(mFactory2.onCreateView(parent, name, context, attrs)) . 这句结论很重要,我们往下看便知道。

    public final View tryCreateView(@Nullable View parent, @NonNull String name,
        @NonNull Context context,  @NonNull AttributeSet attrs) {
      
        ....
        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;
    }

可以看到,最后是调用的 AppCompatViewInflater 的对象的 createView() 去创建 View.我感觉 AppCompatViewInflater 就是专门用来创建 View 的,面向对象的五大原则之一–单一职责原则.

AppCompatViewInflater 类非常重要,先来看看上面提到的 createView() 方法的源码:

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

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

            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);
        }
       return view;
    }

我们随便看下AppCompatTextView ,AppCompatBackgroundHelper 和 AppCompatTextHelper 拿到了xml 中定义的属性的值之后,将其值赋值给控件.就是这么简单.

    public AppCompatTextView(
            @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(TintContextWrapper.wrap(context), attrs, defStyleAttr);

        ...
        //处理背景工具
        mBackgroundTintHelper = new AppCompatBackgroundHelper(this);
        mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr);
     
        //处理文本样式工具
        mTextHelper = new AppCompatTextHelper(this);
        mTextHelper.loadFromAttributes(attrs, defStyleAttr);
        mTextHelper.applyCompoundDrawablesTints();
        ...

        mTextClassifierHelper = new AppCompatTextClassifierHelper(this);
    }

至此,Android 的控件加载方式已全部剖析完毕

看了前面这么多乱七八糟的东西,好像跟换肤没啥关系??
不急,我们接着往下看

源码中可以通过拦截 View 创建过程, 替换一些基础的组件(比如 TextView -> AppCompatTextView ), 然后对一些特殊的属性(比如:background, textColor ) 做处理, 那我们是不是也可替换自己换肤SkinCompatTextView? 一语惊醒梦中人啊,我们也可以搞一个类似于 AppCompatViewInflater 的控件加载器啊,我们也可以设置自己mFactory2 ,相当于创建View的过程由我们接手.既然我们接手了,那岂不是对所有控件都可以为所欲为????

Android-skin-support换肤原理详解

明白了原理,我们是不是可以自己写一个换肤框架,网上有现成开源换肤框架Android-skin-support,我们直接看下它们源码
https://github.com/ximsfei/Android-skin-support

初始化

SkinCompatManager.withoutActivity(application)
                .addInflater(new SkinAppCompatViewInflater());

首先我们从库的初始化处着手,这里将 Application 传入,又添加了一个 SkinAppCompatViewInflater,SkinAppCompatViewInflater 其实就是用来创建换肤 View 的,和系统的 AppCompatViewInflater 差不多.我们来看看 withoutActivity(application) 做了什么.

//SkinCompatManager.java
public static SkinCompatManager withoutActivity(Application application) {
    init(application);
    SkinActivityLifecycle.init(application);
    return sInstance;
}

//SkinActivityLifecycle.java
public static SkinActivityLifecycle init(Application application) {
    if (sInstance == null) {
        synchronized (SkinActivityLifecycle.class) {
            if (sInstance == null) {
                sInstance = new SkinActivityLifecycle(application);
            }
        }
    }
    return sInstance;
}
private SkinActivityLifecycle(Application application) {
    //就是这里,注册了ActivityLifecycleCallbacks,可以监听所有Activity的生命周期
    application.registerActivityLifecycleCallbacks(this);
    //这个方法稍后看
    installLayoutFactory(application);
    SkinCompatManager.getInstance().addObserver(getObserver(application));
}

可以看到,初始化时在 SkinActivityLifecycle 中其实就注册了 ActivityLifecycleCallbacks,来监听 app 所有 Activity 的生命周期.

来看看 SkinActivityLifecycle 中监听到 Activity 的 onCreate() 方法时都干了什么

    @Override
    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
          //判断是否允许换肤
          if (isContextSkinEnable(activity)) {
          //初始化自己换肤工厂
            installLayoutFactory(activity);
            updateWindowBackground(activity);
            if (activity instanceof SkinCompatSupportable) {
                ((SkinCompatSupportable) activity).applySkin();
            }
        }
    }
  
    //设置自己View工厂
    private void installLayoutFactory(Context context) {
        try {
            LayoutInflater layoutInflater = LayoutInflater.from(context);
            LayoutInflaterCompat.setFactory2(layoutInflater, getSkinDelegate(context));
        } catch (Throwable e) {
            Slog.i("SkinActivity", "A factory has already been set on this LayoutInflater");
        }
    }

可以看到 SkinCompatDelegate 是一个 SkinCompatViewInflater 的委托.

当系统需要创建 View 的时候,就会回调 SkinCompatDelegate 的 @Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs)方法,因为前面设置了 LayoutInflater 的 Factory 为 SkinCompatDelegate. 然后 SkinCompatDelegate 将创建 View 的工作交给 SkinCompatViewInflater去处理(也是和我们上面看的系统一模一样).

public class SkinCompatDelegate implements LayoutInflater.Factory2 {

    ...
    public View createView(View parent, final String name, @NonNull Context context,
                           @NonNull AttributeSet attrs) {
        if (mSkinCompatViewInflater == null) {
            mSkinCompatViewInflater = new SkinCompatViewInflater();
        }

        List<SkinWrapper> wrapperList = SkinCompatManager.getInstance().getWrappers();
        for (SkinWrapper wrapper : wrapperList) {
            Context wrappedContext = wrapper.wrapContext(mContext, parent, attrs);
            if (wrappedContext != null) {
                context = wrappedContext;
            }
        }
        return mSkinCompatViewInflater.createView(parent, name, context, attrs);
    }
}

来看看 SkinCompatViewInflater 是如何创建 View 的

SkinCompatViewInflater.java
 public View createView(Context context, String name, AttributeSet attrs) {
        View view = createViewFromFV(context, name, attrs);

        if (view == null) {
            view = createViewFromV7(context, name, attrs);
        }
        return view;
    }

    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;
            default:
                break;
        }
        return view;
    }

    private View createViewFromV7(Context context, String name, AttributeSet attrs) {
        View view = null;
        switch (name) {
            case "androidx.appcompat.widget.Toolbar":
                view = new SkinCompatToolbar(context, attrs);
                break;
            default:
                break;
        }
        return view;
    }

??? 这不就是之前我们在 Android 源码中看过的代码吗?几乎是一模一样。我们在这里将 View 的创建拦截,然后创建自己的控件。既然是我们自己创建的控件,那想怎么换肤还不容易?

我们看一下 SkinCompatTextView 的源码

public class SkinCompatTextView extends AppCompatTextView implements SkinCompatSupportable {
    private SkinCompatTextHelper mTextHelper;
    private SkinCompatBackgroundHelper mBackgroundTintHelper;

    public SkinCompatTextView(Context context) {
        this(context, null);
    }

    public SkinCompatTextView(Context context, AttributeSet attrs) {
        this(context, attrs, android.R.attr.textViewStyle);
    }

    public SkinCompatTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mBackgroundTintHelper = new SkinCompatBackgroundHelper(this);
        mBackgroundTintHelper.loadFromAttributes(attrs, defStyleAttr);
        mTextHelper = SkinCompatTextHelper.create(this);
        mTextHelper.loadFromAttributes(attrs, defStyleAttr);
    }

    @Override
    public void applySkin() {
        if (mBackgroundTintHelper != null) {
            mBackgroundTintHelper.applySkin();
        }
        if (mTextHelper != null) {
            mTextHelper.applySkin();
        }
    }

执行换肤方法applySkin,里面SkinCompatVectorResources来判断是用换肤资源的SkinCompatResources,还是系统自带AppCompatResources,来达到换肤效果

如何加载皮肤资源包?

其实皮肤包就是一个 apk,只不过里面没有任何代码,只有一些需要换肤的资源或者颜色什么的.而且这些资源的名称必须和当前 app 中的资源名称是一致的,才能替换. 需要什么皮肤资源,直接去皮肤包里面去拿就好了.

使用方式

SkinCompatManager.getInstance().loadSkin("night.skin", null, CustomSDCardLoader.SKIN_LOADER_STRATEGY_SDCARD);

loadSkin() 启动一个异步线程SkinLoadTask加载换肤资源

    /**
     * 加载皮肤包.
     *
     * @param skinName 皮肤包名称.
     * @param listener 皮肤包加载监听.
     * @param strategy 皮肤包加载策略.
     * @return
     */
    public AsyncTask loadSkin(String skinName, SkinLoaderListener listener, int strategy) {
        SkinLoaderStrategy loaderStrategy = mStrategyMap.get(strategy);
        if (loaderStrategy == null) {
            return null;
        }
        return new SkinLoadTask(listener, loaderStrategy).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, skinName);
    }
@Override
        protected String doInBackground(String... params) {
            synchronized (mLock) {
  
            try {
                if (params.length == 1) {
                  //加载资源
                    String skinName = mStrategy.loadSkinInBackground(mAppContext, params[0]);        
                    return params[0];
                }
 
            SkinCompatResources.getInstance().reset();
            return null;
        }

//策略加载,我们选择SkinSDCardLoader来看下
    public String loadSkinInBackground(Context context, String skinName) {
        if (TextUtils.isEmpty(skinName)) {
            return skinName;
        }
        String skinPkgPath = getSkinPath(context, skinName);
        if (SkinFileUtils.isFileExists(skinPkgPath)) {
            String pkgName = SkinCompatManager.getInstance().getSkinPackageName(skinPkgPath);
            Resources resources = SkinCompatManager.getInstance().getSkinResources(skinPkgPath);
            if (resources != null && !TextUtils.isEmpty(pkgName)) {
                SkinCompatResources.getInstance().setupSkin(
                        resources,
                        pkgName,
                        skinName,
                        this);
                return skinName;
            }
        }
        return null;
    }

获取皮肤资源resources


    /**
     * 获取皮肤包包名.
     *
     * @param skinPkgPath sdcard中皮肤包路径.
     * @return
     */
    public String getSkinPackageName(String skinPkgPath) {
        PackageManager mPm = mAppContext.getPackageManager();
        PackageInfo info = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
        return info.packageName;
    }

    /**
     * 获取皮肤包资源{@link Resources}.
     *
     * @param skinPkgPath sdcard中皮肤包路径.
     * @return
     */
    @Nullable
    public Resources getSkinResources(String skinPkgPath) {
        try {
            PackageInfo packageInfo = mAppContext.getPackageManager().getPackageArchiveInfo(skinPkgPath, 0);
            packageInfo.applicationInfo.sourceDir = skinPkgPath;
            packageInfo.applicationInfo.publicSourceDir = skinPkgPath;
            Resources res = mAppContext.getPackageManager().getResourcesForApplication(packageInfo.applicationInfo);
            Resources superRes = mAppContext.getResources();
            return new Resources(res.getAssets(), superRes.getDisplayMetrics(), superRes.getConfiguration());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

SkinCompatTextView调用换肤方法applySkin()

    @Override
    public void applySkin() {
        if (mBackgroundTintHelper != null) {
            mBackgroundTintHelper.applySkin();
        }
        if (mTextHelper != null) {
            mTextHelper.applySkin();
        }
    }

来看下SkinCompatBackgroundHelper 执行applySkin方法

    @Override
    public void applySkin() {
        mBackgroundResId = checkResourceId(mBackgroundResId);
        if (mBackgroundResId == INVALID_ID) {
            return;
        }
      //获取背景图片,可以是APP本身资源,也可以是换肤资源
        Drawable drawable = SkinCompatVectorResources.getDrawableCompat(mView.getContext(), mBackgroundResId);
        if (drawable != null) {
            int paddingLeft = mView.getPaddingLeft();
            int paddingTop = mView.getPaddingTop();
            int paddingRight = mView.getPaddingRight();
            int paddingBottom = mView.getPaddingBottom();
            ViewCompat.setBackground(mView, drawable);
            mView.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);
        }
    }

SkinCompatVectorResources根据类型区分是用换肤资源SkinCompatResources,还是App本身的资源AppCompatResources

    private Drawable getSkinDrawableCompat(Context context, int resId) {
        if (AppCompatDelegate.isCompatVectorFromResourcesEnabled()) {
            if (!SkinCompatResources.getInstance().isDefaultSkin()) {
                try {
                    return SkinCompatDrawableManager.get().getDrawable(context, resId);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            // SkinCompatDrawableManager.get().getDrawable(context, resId) 中会调用getSkinDrawable等方法。
            // 这里只需要拦截使用默认皮肤的情况。
            if (!SkinCompatUserThemeManager.get().isColorEmpty()) {
                ColorStateList colorStateList = SkinCompatUserThemeManager.get().getColorStateList(resId);
                if (colorStateList != null) {
                    return new ColorDrawable(colorStateList.getDefaultColor());
                }
            }
            if (!SkinCompatUserThemeManager.get().isDrawableEmpty()) {
                Drawable drawable = SkinCompatUserThemeManager.get().getDrawable(resId);
                if (drawable != null) {
                    return drawable;
                }
            }
            Drawable drawable = SkinCompatResources.getInstance().getStrategyDrawable(context, resId);
            if (drawable != null) {
                return drawable;
            }
            return AppCompatResources.getDrawable(context, resId);
        } else {
            if (!SkinCompatUserThemeManager.get().isColorEmpty()) {
                ColorStateList colorStateList = SkinCompatUserThemeManager.get().getColorStateList(resId);
                if (colorStateList != null) {
                    return new ColorDrawable(colorStateList.getDefaultColor());
                }
            }
            if (!SkinCompatUserThemeManager.get().isDrawableEmpty()) {
                Drawable drawable = SkinCompatUserThemeManager.get().getDrawable(resId);
                if (drawable != null) {
                    return drawable;
                }
            }
            Drawable drawable = SkinCompatResources.getInstance().getStrategyDrawable(context, resId);
            if (drawable != null) {
                return drawable;
            }
            if (!SkinCompatResources.getInstance().isDefaultSkin()) {
                int targetResId = SkinCompatResources.getInstance().getTargetResId(context, resId);
                if (targetResId != 0) {
                    return SkinCompatResources.getInstance().getSkinResources().getDrawable(targetResId);
                }
            }
            return AppCompatResources.getDrawable(context, resId);
        }
    }

总结

总结流程如下:
1. 监听APP所有 Activity 的生命周期( registerActivityLifecycleCallbacks())

2. 在每个 Activity 的 onCreate()方法调用时setFactory(),设置创建View的工厂.将创建View的琐事交给 SkinCompatViewInflater 去处理.

3. 库中自己重写了系统的控件(比如 View 对应于库中的 SkinCompatView),实现换肤接口(接口里面只有一个 applySkin()方法),表示该控件是支持换肤的.并且将这些控件在创建之后收集起来,方便随时换肤.

4. 在库中自己写的控件里面去解析出一些特殊的属性(比如:background, textColor),并将其保存起来

5. 在切换皮肤的时候,遍历一次之前缓存的 View,调用其实现的接口方法 applySkin(),在applySkin() 中从皮肤资源(可以是从网络或者本地获取皮肤包)中获取资源.获取资源后设置其控件的 background 或 textColor 等,就可实现换肤.

上一篇下一篇

猜你喜欢

热点阅读