一键换肤背后的原理

该文主要从三个方面去介绍,分别为Activity的布局流程,资源加载流程以及换肤思路。

Activity的布局流程

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


1624602494(1).png

从图中我们可以看到主要的两个东西,LayoutInflater 和setFactory。LayoutInflater是将xml转化为界面元素的控制类,在这里面会得到xml布局的相应控件的相关属性并进行view的创建,然后会调用Factory的onCreateView()进行view的转换。

LayoutInflater

关于LayoutInflater的介绍,可以看下下面这个的博客,写的很详细,在这就不一一去添代码讲流程了,毕竟用别人写好的就很香。

Android 中LayoutInflater(布局加载器)之介绍篇

下面我们只挑我需要的代码讲。

在setContentView中,之后调用 LayoutInflater.inflate 方法,来解析 XML 资源文件,

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root) {
        return inflate(resource, root, root != null);
    }

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;

            try {
                final String name = parser.getName();

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

                    rInflate(parser, root, inflaterContext, attrs, false);
                } else {
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                    ViewGroup.LayoutParams params = null;

                    if (root != null) {
                        
                        params = root.generateLayoutParams(attrs);
                        if (!attachToRoot) {
                            temp.setLayoutParams(params);
                        }
                    }

                    rInflateChildren(parser, temp, attrs, true);

                    if (root != null && attachToRoot) {
                        root.addView(temp, params);
                    }

                    if (root == null || !attachToRoot) {
                        result = temp;
                    }
                }

            } catch (XmlPullParserException e) {
                
            }

            return result;
        }
    }

从代码中我们可以看到,先判断是不是TAG_MERGE标签,如果是,则调用rInflate()方法对其处理:

使用 merge 标签必须有父布局,且依赖于父布局加载

merge 并不是一个 ViewGroup,也不是一个 View,它相当于声明了一些视图,等待被添加,解析过程中遇到 merge 标签会将 merge 标签下面的所有子 view 添加到根布局中

merge 标签在 XML 中必须是根元素

相反的 include 不能作为根元素,需要放在一个 ViewGroup 中

使用 include 标签必须指定有效的 layout 属性

使用 include 标签不写宽高是没有关系的,会去解析被 include 的layout

而这些解析的过程中,最终会调用rInflateChildren(),所以说为什么复杂的布局会产生卡顿,XmlResourseParser 对 XML 的遍历,随着布局越复杂,层级嵌套越多,所花费的时间也越长,调用 onCreateView 与 createView 方法是通过反射创建 View 对象导致的耗时。

在 Android 10上,新增 tryInflatePrecompiled 方法是为了减少 XmlPullParser 解析 XML 的时间,但是用一个全局变量 mUseCompiledView 来控制是否启用 tryInflatePrecompiled 方法,根据源码分析,mUseCompiledView 始终为 false,所以 tryInflatePrecompiled 方法目前在 release 版本中不可使用。

不是则调用createViewFromTag来创建View,然后判断root是否为null,如果不为null,拿到root的params,如果不添加到父布局root中,就将解析到的LayoutParams设置到该view中去。具体的操作规则如下:

当 attachToRoot == true 且 root != null 时,新解析出来的 View 会被 add 到 root 中去,然后将 root 作为结果返回

当 attachToRoot == false 且 root != null 时,新解析的 View 会直接作为结果返回,而且 root 会为新解析的 View 生成 LayoutParams 并设置到该 View 中去

当 attachToRoot == false 且 root == null 时,新解析的 View 会直接作为结果返回

那我们继续看createViewFromTag。

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
            boolean ignoreThemeAttr) {
       ......
        try {
            View view = tryCreateView(parent, name, context, attrs);

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

            return view;
        } catch (InflateException e) {
           
        }
    }

在该方法中可以看到,先调用tryCreateView()去创建View,在这个方法里面就是调用下面需要讲的Factory,所以放在后面讲,onCreateView最终调用的是createView,而在createView中采用的是反射的方式创建View实例。

setFactory

讲这个之前先看几个图,


1624601622.png
1624601622(1).png

我们在布局中写的是Button,TextView,ImageView,但是在AS的Layout Inspector功能查看下,变成了AppCompatButton,AppCompatTextView,AppComaptImageView,那到底是我们的按钮真的已经在编译的时候自动变成了AppCompatXXX系列,还是只是单纯的在这个工具里面看的时候我们的控件只是显示给我们看到的名字是AppCompatXXX系列而已。
我们把我们的Activity的父类做下修改,改为:

public class TestActivity extends AppCompatActivity{
    ......
}
变为
public class TestActivity extends Activity{
    ......
}

我们再来查看下Layout Inspector界面:


1624601622(2).png

我们可以看到,控件就自动变成了我们布局里面写的控件名称了,造成这种现象的原因就是下面我们要分析的。我们首先来看tryCreateView()方法。

public final View tryCreateView(@Nullable View parent, @NonNull String name,
    
        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);
        }

        return view;
    }

如果mFactory2不为null的时候就会调用mFactory2.onCreateView(),

public interface Factory2 extends Factory {
      
        @Nullable
        View onCreateView(@Nullable View parent, @NonNull String name,
                @NonNull Context context, @NonNull AttributeSet attrs);
    }

Factory2是一个接口,具体的实现类是AppCompatDelegateImpl。

 @Override
    public View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs) {
        if (mAppCompatViewInflater == null) {
            TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);
            String viewInflaterClassName =
                    a.getString(R.styleable.AppCompatTheme_viewInflaterClass);
            if ((viewInflaterClassName == null)
                    || AppCompatViewInflater.class.getName().equals(viewInflaterClassName)) {
                // Either default class name or set explicitly to null. In both cases
                // create the base inflater (no reflection)
                mAppCompatViewInflater = new AppCompatViewInflater();
            } else {
                try {
                    Class viewInflaterClass = Class.forName(viewInflaterClassName);
                    mAppCompatViewInflater =
                            (AppCompatViewInflater) viewInflaterClass.getDeclaredConstructor()
                                    .newInstance();
                } catch (Throwable t) {
                    Log.i(TAG, "Failed to instantiate custom view inflater "
                            + viewInflaterClassName + ". Falling back to default.", t);
                    mAppCompatViewInflater = new AppCompatViewInflater();
                }
            }
        }

        boolean inheritContext = false;
        if (IS_PRE_LOLLIPOP) {
            inheritContext = (attrs instanceof XmlPullParser)
                    // If we have a XmlPullParser, we can detect where we are in the layout
                    ? ((XmlPullParser) attrs).getDepth() > 1
                    // Otherwise we have to use the old heuristic
                    : shouldInheritContext((ViewParent) parent);
        }

        return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
                IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
                true, /* Read read app:theme as a fallback at all times for legacy reasons */
                VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
        );
    }

会调用mAppCompatViewInflater的createView(),

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

protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
        return new AppCompatTextView(context, attrs);
    }

从这里我们知道,为什么我们在前面的截图中看到的变成那样了。
那这个Factory2什么时候设置的?

protected void onCreate(@Nullable Bundle savedInstanceState) {
        final AppCompatDelegate delegate = getDelegate();
        delegate.installViewFactory();//1
        delegate.onCreate(savedInstanceState);
        super.onCreate(savedInstanceState);
    }

重点就在这个注释1,

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

常规操作先上一张图,

1624610684(1).png

从图中可以知道,

1、ResourcesManager管理着一个Resources类

2、Resources类里有他的实现类ResourcesImpl,各种创建,调用,getColor等方法都是在实现类里实现的

3、ResourcesImpl里管理着一个AssetManager

4、AssetManager负责从apk里获取资源,写入资源等 addAssetPath()

下面我们来分析一下。起点是ActivityThread.java handleBindApplication()方法 ,在这加载Application的。

final ContextImpl appContext = ContextImpl.createAppContext(this, data.info);

static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo) {
        return createAppContext(mainThread, packageInfo, null);
    }

static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo,
            String opPackageName) {
        if (packageInfo == null) throw new IllegalArgumentException("packageInfo");
        ContextImpl context = new ContextImpl(null, mainThread, packageInfo, null, null, null, 0,
                null, opPackageName);
        context.setResources(packageInfo.getResources());
        return context;
    }    

在handleBindApplication中创建ContextImpl,然后setResources.而这个资源是从LoadedApk中获取到。

public Resources getResources() {
        if (mResources == null) {
            final String[] splitPaths;
            try {
                splitPaths = getSplitPaths(null);
            } catch (NameNotFoundException e) {
                // This should never fail.
                throw new AssertionError("null split not found");
            }

            mResources = ResourcesManager.getInstance().getResources(null, mResDir,
                    splitPaths, mOverlayDirs, mApplicationInfo.sharedLibraryFiles,
                    Display.DEFAULT_DISPLAY, null, getCompatibilityInfo(),
                    getClassLoader());
        }
        return mResources;
    }

然后调用ResourcesManager中的getResources(),

public @Nullable Resources getResources(@Nullable IBinder activityToken,
            @Nullable String resDir,
            @Nullable String[] splitResDirs,
            @Nullable String[] overlayDirs,
            @Nullable String[] libDirs,
            int displayId,
            @Nullable Configuration overrideConfig,
            @NonNull CompatibilityInfo compatInfo,
            @Nullable ClassLoader classLoader) {
        try {
            Trace.traceBegin(Trace.TRACE_TAG_RESOURCES, "ResourcesManager#getResources");
            final ResourcesKey key = new ResourcesKey(
                    resDir,
                    splitResDirs,
                    overlayDirs,
                    libDirs,
                    displayId,
                    overrideConfig != null ? new Configuration(overrideConfig) : null, // Copy
                    compatInfo);
            classLoader = classLoader != null ? classLoader : ClassLoader.getSystemClassLoader();
            return getOrCreateResources(activityToken, key, classLoader);
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
        }
    }

根据传过来的参数封装成key.这个key在后面用来生成Resources。

private @Nullable Resources getOrCreateResources(@Nullable IBinder activityToken,
            @NonNull ResourcesKey key, @NonNull ClassLoader classLoader) {
        synchronized (this) {
            if (DEBUG) {
                Throwable here = new Throwable();
                here.fillInStackTrace();
                Slog.w(TAG, "!! Get resources for activity=" + activityToken + " key=" + key, here);
            }

            if (activityToken != null) {
                final ActivityResources activityResources =
                        getOrCreateActivityResourcesStructLocked(activityToken);

                // Clean up any dead references so they don't pile up.
                ArrayUtils.unstableRemoveIf(activityResources.activityResources,
                        sEmptyReferencePredicate);

                // Rebase the key's override config on top of the Activity's base override.
                if (key.hasOverrideConfiguration()
                        && !activityResources.overrideConfig.equals(Configuration.EMPTY)) {
                    final Configuration temp = new Configuration(activityResources.overrideConfig);
                    temp.updateFrom(key.mOverrideConfiguration);
                    key.mOverrideConfiguration.setTo(temp);
                }

                ResourcesImpl resourcesImpl = findResourcesImplForKeyLocked(key);
                if (resourcesImpl != null) {
                    if (DEBUG) {
                        Slog.d(TAG, "- using existing impl=" + resourcesImpl);
                    }
                    return getOrCreateResourcesForActivityLocked(activityToken, classLoader,
                            resourcesImpl, key.mCompatInfo);
                }

                // We will create the ResourcesImpl object outside of holding this lock.

            } else {
                // Clean up any dead references so they don't pile up.
                ArrayUtils.unstableRemoveIf(mResourceReferences, sEmptyReferencePredicate);

                // Not tied to an Activity, find a shared Resources that has the right ResourcesImpl
                ResourcesImpl resourcesImpl = findResourcesImplForKeyLocked(key);
                if (resourcesImpl != null) {
                    if (DEBUG) {
                        Slog.d(TAG, "- using existing impl=" + resourcesImpl);
                    }
                    return getOrCreateResourcesLocked(classLoader, resourcesImpl, key.mCompatInfo);
                }

                // We will create the ResourcesImpl object outside of holding this lock.
            }

            // If we're here, we didn't find a suitable ResourcesImpl to use, so create one now.
            ResourcesImpl resourcesImpl = createResourcesImpl(key);
            if (resourcesImpl == null) {
                return null;
            }

            // Add this ResourcesImpl to the cache.
            mResourceImpls.put(key, new WeakReference<>(resourcesImpl));

            final Resources resources;
            if (activityToken != null) {
                resources = getOrCreateResourcesForActivityLocked(activityToken, classLoader,
                        resourcesImpl, key.mCompatInfo);
            } else {
                resources = getOrCreateResourcesLocked(classLoader, resourcesImpl, key.mCompatInfo);
            }
            return resources;
        }
    }

首先从软引用中获取,如果获取不到,则调用createResourcesImpl创建。

private @Nullable ResourcesImpl createResourcesImpl(@NonNull ResourcesKey key) {
        final DisplayAdjustments daj = new DisplayAdjustments(key.mOverrideConfiguration);
        daj.setCompatibilityInfo(key.mCompatInfo);

        final AssetManager assets = createAssetManager(key);
        if (assets == null) {
            return null;
        }

        final DisplayMetrics dm = getDisplayMetrics(key.mDisplayId, daj);
        final Configuration config = generateConfig(key, dm);
        final ResourcesImpl impl = new ResourcesImpl(assets, dm, config, daj);

        if (DEBUG) {
            Slog.d(TAG, "- creating impl=" + impl + " with key: " + key);
        }
        return impl;
    }

ResourcesImpl的创建需要AssetManager作为参数。

关于AssetManager的详细解析请参考下面这篇文章。

Android资源管理框架:Asset Manager的创建过程

换肤思路

具体思路为:

1.收集xml数据,根据View创建过程的Factory2(源码里拷贝过来就行)需要修改的地方就是View创建完事以后,将需要修改的属性及他的View记录下来(比如要改color、src、backgrand)

2.读取皮肤包里的内容。先通过assets.addAssetPath()加载进来,这样就能通过assetManager来获取皮肤包里的资源了

3.如果遇到了需要替换的属性(color、src、backgrand等)那就替换,通过assetManager里的方法

另外参考下面两篇文章:

Android 无缝换肤深入了解与使用

Android 换肤那些事儿, Resource包装流 ?AssetManager替换流?

参考资料

Android 中LayoutInflater(布局加载器)之介绍篇

Android资源管理框架:Asset Manager的创建过程

Android 无缝换肤深入了解与使用

Android 换肤那些事儿, Resource包装流 ?AssetManager替换流?

你可能感兴趣的:(一键换肤背后的原理)