插件化换肤杂谈

1,668 阅读8分钟

前言

插件化技术主要应用在动态化和换肤领域,前者还需要解决插件化Activity跳转的问题,跳转带来的兼容性问题等。这里主要是分析插件化换肤功能,从视图的流程分析原理到方案实现,Darren鸿洋ChangeSkin、网易云音乐等换肤框架思路都差不多,但是现在不需要继承BaseActivity了,可以用LifeCycle避免入侵性。虽然说技术已经过时了,大家都会,还是输出一下更深刻点。

API :androidx 1.2.0、29

目录

一、视图加载

这里直接从setContentView方法入手。作为移动开发,Android类启动、类的加载流程已经是基本知识了,多看多画流程图就清除了,这里不作过多介绍。

1、Activity启动流程简单介绍

Activity的启动过程,我们从ContextstartActivity说明,其实现是ContextImplstartActivity,然后内部会通过Instrumentation来尝试启动Activity,这是一个跨进程过程,它会调用AMS的startActivity方法,当AMS校验Activity的合法性后,会通过ApplicationThread回调到我们的进程,这也是一次跨进程通信,而ApplicationThread就是一个Binder。回调逻辑是在Binder线程池中完成的,所以需要通过Handler H将其切回UI线程,第一个消息是LAUNCH_ACTIVITY,它对应这handleLaunchActivity。这个方法里面完成了Activity的创建和启动。接着,在ActivityonResume中,Activity的内容将开始渲染到Window上面,然后开始绘制直到我们可以看见。

2、setContentView

创建DecorView是在这个过程,后续显示是onResume阶段创建ViewRootImpl并关联到PhoneWindow,再显示。

API 环境 adnroidx 1.2.0

setContentView方法,在androidx中是交给 AppCompatDelegateImpl 处理的,接下来代码关注流程主要在视图创建部分

    @Override
    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();
    }

先看下 ensureSubDecor

     private void ensureSubDecor() {
        if (!mSubDecorInstalled) {
            mSubDecor = createSubDecor();
        .........
    }

createSubDecor方法中有一段关键代码 mWindow.getDecorView()mWindow就是PhoneWindow

    @Override
    public final @NonNull View getDecorView() {
        if (mDecor == null || mForceDecorInstall) {
            installDecor();
        }
        return mDecor;
    }

这里会确保创建DecorView,这里主要是设置一些主题等、为布局设置ID,创建一个contentParent,就是下图的FrameLayout,看下面的图大致了解一下。

我们再回到最初的setContentView方法,我们主要是拿到contentParent,然后呢,把我们setContentView中写的视图加载进去,说白了,就是inflate方法

LayoutInflater.from(mContext).inflate(resId, contentParent);

3、inflate

    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        if (DEBUG) {
            Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
                  + Integer.toHexString(resource) + ")");
        }

        View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
        if (view != null) {
            return view;
        }
        XmlResourceParser parser = res.getLayout(resource);// xml解析
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }

上面的 tryInflatePrecompiled 方法必然是返回 null 的,因为在 API 中是否开启预编译的字段 mUseCompiledView 始终是false的,所以我们关注下面的 inflate 方法即可,layout布局这里是通过 XmlResourceParser 解析。

    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;
          ...........
                    // Temp is the root view that was found in the xml
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                    ViewGroup.LayoutParams params = null;

                    if (root != null) {
                        if (DEBUG) {
                            System.out.println("Creating params from root: " +
                                    root);
                        }
                        // Create layout params that match root, if supplied
                        params = root.generateLayoutParams(attrs);
                        if (!attachToRoot) { // false ,不绑定,则设置Params,有时候我们写的attachToRoot为false会崩溃,是因为这里,设置父布局的LayoutParams,可能类型不一样
                            // Set the layout params for temp if we are not
                            // attaching. (If we are, we use addView, below)
                            temp.setLayoutParams(params);
                        }
                    }
                    //这里会addView ,如果说attachToRoot了
....

            return result;
        }
    }

我们主要看上面的 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 {
                //这里是因为如果没有的.的需要加前缀,例如TextView这种,我们需要加上android.widget,才能去反射构建该类,也就是加上包名
                    if (-1 == name.indexOf('.')) {
                        view = onCreateView(context, parent, name, attrs);
                    } else {
                        view = createView(context, name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }

            return view;
........
    }

关注点到上面的 tryCreateView 方法,主要是这个方法创建View的,如果它能正确返回view的话,后面 if 就不会执行了。这个方法内主要代码如下

    public final View tryCreateView(@Nullable View parent, @NonNull String name,
        @NonNull Context context,
        @NonNull AttributeSet attrs) {
        //注意 彩蛋 一闪一闪亮晶晶的效果  TAG_1995 = blink
        if (name.equals(TAG_1995)) {
            // Let's party like it's 1995!
            return new BlinkLayout(context, attrs);
        }

        View view;
        if (mFactory2 != null) {
            view = mFactory2.onCreateView(parent, name, context, attrs); // 4个参数哦
        } 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是空的,就通过 mFactory去构建view,如果还是空的,通过mPrivateFactory创建view,如果都是空的,那只能返回 null,给外层去处理了。

实际上这个方法确实是返回 null的,所以换肤的技术关键点就在于如何使得mFactory2 或者 mFactory 替换成我们自己的类,然后在这个类里面,我们可以搜集所有的视图View,然后每次就可以动态改变了。至于创建视图如何做,我们可以按照 createViewFromTag 方法内创建视图照抄过来,内部两个细节不能丢失,一个是对构造函数的缓存,另一个是对不是类的全限定名需要加前缀进行反射构造类

接下来还有一个问题?我们已经知道了,可以替换成我们写的类,那么对于其它APK中的资源如何获取?

4、资源加载

之前在 inflate 方法开头时就创建了Resources

Resources res = getContext().getResources();

具体对应于ContextImplgetResources方法

    @Override
    public Resources getResources() {
        return mResources;
    }

那这个mResources是怎么创建的呢?

Android启动后,会调用到ActivityThreadhandleBindApplication 方法,该方法内有一行代码,

app = data.info.makeApplication(data.restrictedBackupMode, null);

主要是构造Application的,我们关注makeApplication方法,截取部分内部代码

            java.lang.ClassLoader cl = getClassLoader();
            if (!mPackageName.equals("android")) {
                Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER,
                        "initializeJavaContextClassLoader");
                initializeJavaContextClassLoader();
                Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
            }
            ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
            app = mActivityThread.mInstrumentation.newApplication(
                    cl, appClass, appContext);
            appContext.setOuterContext(app);

继续关注 ContextImpl.createAppContext 这行,发现创建上下文的过程中,会设置Resources,主要是通过packageInfo拿到Resources,所以我们也可以通过插件APK包的PackageInfo拿到插件资源。

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

我们再看下PackageInfo是如何拿到资源的

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

后面就不具体看代码了,主要是会创建Resources,然后会创建ResourcesImplResources关联,还会调用 createAssetManager 创建AssetManager 关联给ResourcesImpl,然后缓存起来,下次就不用创建了。

    protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key) {
        final AssetManager.Builder builder = new AssetManager.Builder();

        // resDir can be null if the 'android' package is creating a new Resources object.
        // This is fine, since each AssetManager automatically loads the 'android' package
        // already.
        if (key.mResDir != null) {
            try {
                builder.addApkAssets(loadApkAssets(key.mResDir, false /*sharedLib*/,
                        false /*overlay*/));
            } catch (IOException e) {
                Log.e(TAG, "failed to add asset path " + key.mResDir);
                return null;
            }
        }
        .....
      }

所以对于插件包的资源处理,可以按照源码

 Resources appResource = mContext.getResources();

//反射创建AssetManager 与 Resource
AssetManager assetManager = AssetManager.class.newInstance();
//资源路径设置 目录或压缩包
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",
        String.class);
addAssetPath.invoke(assetManager, skinPath);

//根据当前的设备显示器信息 与 配置(横竖屏、语言等) 创建Resources
Resources skinResource = new Resources(assetManager, appResource.getDisplayMetrics
                (), appResource.getConfiguration());

//获取外部Apk(皮肤包) 包名
PackageManager mPm = mContext.getPackageManager();
          PackageInfo info = mPm.getPackageArchiveInfo(skinPath, PackageManager
                 .GET_ACTIVITIES);
String packageName = info.packageName;

5、其他布局是否会调用createViewFromTag

你可能还会问,之前分析的是Activity创建时布局的构建,我们可以通过 createViewFromTag 方法链中的 mFactory2或者 mFactory来替代系统创建View,那么子View,我们自定义的View会不会调用呢?不会调用,那还是没法处理动态换肤

结果肯定是会的。

之前的inflate方法中 还有一个方法

 // Inflate all children under temp against its context.  
rInflateChildren(parser, temp, attrs, true);

这个方法内主要代码就是

final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);

所以可以放心用了

二、方案实现

1、方案脉络

主要分为三步

  • 收集XML数据,利用生产对象过程中的Factory2接口,onCreateView方法

  • 记录需要换肤的属性名和对应的资源IDFactory2生产View的时候,对应SkinAttributeSkinViewSkinPair

  • 读取皮肤包的内容,通过AssetManager加载进来,最终替换的原理是通过拿到插件包的Resources,然后去插件包里面找之前记录的ID所对应的资源名和资源类型,再去插件包里找到插件包的资源ID,设置给控件。

2、注意事项

  • 处理视图时需要对xml中不同的写法做处理,例如xml中控件EditTextandroidx.constraintlayout.widget.ConstraintLayout 这种写法区分,像没有写类的全限定名的,我们需要加上包名,全路径,然后才能反射构造类。还需要注意xml中控件的属性值写法不同,例如textColor=“#FFFFFF”textColor="@color/white"textColor="?colorAccent" ,针对第一个,我们处理不了,因为值是这样写死的。

  • 如果是通过 LayoutInflaterCompat.setFactory2 方式设置mFactory2的值,需要注意 mFactorySet 会反射设置失败,具体写法可以看下代码

    SkinLayoutInflaterFactory skinLayoutInflaterFactory = new SkinLayoutInflaterFactory(activity);
            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
            //反射
            try {
                Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
                field.setAccessible(true);
                field.setBoolean(layoutInflater, false);
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory);
            mLayoutInflaterFactories.put(activity, skinLayoutInflaterFactory);
    
            }else{
                try {
                    Field field = LayoutInflater.class.getDeclaredField("mFactory2");
                    field.setAccessible(true);
                    field.set(layoutInflater,skinLayoutInflaterFactory);
                } catch (Exception e) {
                    e.printStackTrace();
                }
    
            }
    

    通过第一种方法,将mFactorySet置为false,如果成果确实是可以的,但是在API 29 中

        @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
        private boolean mFactorySet;  //受限制了
        @UnsupportedAppUsage
        private Factory mFactory;
        @UnsupportedAppUsage
        private Factory2 mFactory2;
    

    Android Q的更新中有一条就是非SDK接口在其中的受限情况变化,上面这个就反应出来了,反射 mFactorySetfalse,始终不会成功,除非 targetSdk版本改成29 以下,否则运行即闪退。

3、代码地址

SkinDemo