美文网首页
Android插件化换肤(仅限Android P以前可使用)

Android插件化换肤(仅限Android P以前可使用)

作者: _柚子啊 | 来源:发表于2021-12-16 10:20 被阅读0次

    前置知识

    • 需要了解setContentView的具体流程
    • 需要了解LayoutInflater的inflate过程
    • 需要了解Resources资源文件是如何获取的

    原理

    首先我们要先从AppCompatActivity 中的 setContentView开始追溯,因为我们需要知道Android是如何创建View的,只有这样才能知道如何修改这个View的属性。

    AppCompatActivity setContentView

    @Override
    public void setContentView(@LayoutRes int layoutResID) {
        getDelegate().setContentView(layoutResID);
    }
    

    可以看到这个调用了AppCompatDelegateImpl的setContentView,继续往下看:

    AppCompatDelegateImpl setContentView

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

    这里我们需要重点看的是LayoutInflater.from(mContext).inflate(resId, contentParent)这句代码,众所周知,android.R.id.content实际上就是一个FrameLayout,而我们平时调用的setContentView实际上就是被这个FrameLayout包裹着的,所以这里通过LayoutInfalter的inflate方法把我们传入的layout布局文件加载到拿到的contentParent中。接下来来看一下这个inflate方法里面做了什么:

    LayoutInflater inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot)

    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);
        try {
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
    

    可以看到这里创建了 XmlResourceParser XML 解析器,并调用inflate(parser, root, attachToRoot)方法,下面来看看这个方法。

    LayoutInflater inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot)

    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {
            // 此处省略多行代码......
            try {
                if (TAG_MERGE.equals(name)) {
                    // 此处省略多行代码......
                } else {
                    // Temp is the root view that was found in the xml
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);
                    // 此处省略多行代码......
                }
            } catch (XmlPullParserException e) {
                // 此处省略多行代码......
            } catch (Exception e) {
                // 此处省略多行代码......
            } finally {
                // 此处省略多行代码......
            }
            return result;
        }
    }
    
    

    LayoutInflater createViewFromTag

    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, 
                           boolean ignoreThemeAttr) {
        // 此处省略多行代码......
        View view = tryCreateView(parent, name, context, attrs);
        try {
            if (view == null) {
                final Object lastContext = mConstructorArgs[0];
                mConstructorArgs[0] = context;
                try {
                    // 判断是否是系统View
                    if (-1 == name.indexOf('.')) {
                        view = onCreateView(context, parent, name, attrs);
                    } else {
                        // 若非系统View,则通过构造器反射生成
                        view = createView(context, name, null, attrs);
                    }
                } finally {
                    mConstructorArgs[0] = lastContext;
                }
            }
            return view;
        } catch() {
            
        }
        // 此处省略多行代码......
    }
    

    在这个方法里最重要的是 createViewFromTag 这个方法,这个方法使用来完成 View 的加载,这个会先通过 tryCreateView 来创建 View ,可以看到下面的代码会先通过判断 mFactory2 或者 mFactory 是否为null,如果不为null则会直接创建View,否则View返回null,然后在 createViewFromTag 中还会通过 name.indexOf('.') 来判断该 View 是系统 View 还是自定义 View ,如果是系统 View ,则直接给该 View 加上 “android.view.” 前缀,否则直接使用全包名来创建 View 实例。

    LayoutInflater tryCreateView

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

    最后会调用 createView(context, name, null, attrs) 来创建 View 实例,在这个方法中的 sConstructorMap 缓存了 View 的构造方法,如果已经加载过,则直接从缓存使用构造方法创建View的实例,否则使用 ClassLoader 反射得到 View 的构造器。最后通过构造器使用反射,调用了 View 的两个构造方法反射完成 View 的创建,将创建完的 View 执行 addView 将视图添加到到 DecorView 中。这部分代码这里就不贴出来了,大家可以自己在AS里面点进去看看。

    通过上面的代码可以看出,如果我们想要改变一个View的属性,我们可以通过创建一个Factory2来拦截View的加载,并在这个加载过程中改变View的属性。

    如何·获取资源文件

    首先我们来手动搜索一下Resources类中的getColor方法

    Resources

    @ColorInt
    public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
        final TypedValue value = obtainTempTypedValue();
        try {
            final ResourcesImpl impl = mResourcesImpl;
            impl.getValue(id, value, true);
            if (value.type >= TypedValue.TYPE_FIRST_INT
                && value.type <= TypedValue.TYPE_LAST_INT) {
                return value.data;
            } else if (value.type != TypedValue.TYPE_STRING) {
                throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)
                                            + " type #0x" + Integer.toHexString(value.type) + " is not valid");
            }
    
            final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
            return csl.getDefaultColor();
        } finally {
            releaseTempTypedValue(value);
        }
    }
    

    可以看到我们平时在获取资源文件时都做了哪些操作,这里重点看ResourcesImpl的getValue方法,下面贴出该方法的代码:

    ResourcesImpl

    void getValue(@AnyRes int id, TypedValue outValue, boolean resolveRefs) throws NotFoundException {
        boolean found = mAssets.getResourceValue(id, 0, outValue, resolveRefs);
        if (found) {
            return;
        }
        throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id));
    }
    

    而上面的 mAssets 是 AssetManager ,也就是说我们最后是通过 AssetManager 的 getResourceValue 方法来拿到资源文件的。看懂了上面的原理的话,就能来实操了,但是这里不提供实操代码,只提供思路。

    如何实现插件化换肤

    • 首先要实现插件化换肤的话首先在项目中定义资源文件(如color、drawable)等的时候命名要规范,比如color不能直接使用#ffffff这种形式,一定要在value目录下创建相对应的资源,同时要在插件包上定义一模一样的名字。示例如下:

      app module下的values colors.xml

      <color name="title_bar_bg">#ff2244</color>
      <color name="title_bar_text_color">#ffffff</color>
      <color name="button_primary">#2D3136</color>
      <color name="button_secondary">#41474D</color>
      

      插件包skinpkg下的values colors.xml

      <color name="TextPrimary">#000000</color>
      <color name="TextSecondary">#90959A</color>
      <color name="TextTertiary">#60646A</color>
      <color name="title_bar_bg_skin">#FFC53D</color>
      
    • 当资源文件都处理完毕后,可以把插件打包打成apk,打完包后可以把该apk的后缀改名,我一般是改成xxx.skin的,目的是为了当该插件包是保存在本地的时候,避免用户把它当成apk安装(如果获取插件包的形式是通过网路下载,则可忽略此点)。打包之后先把该包放在本地路径中进行测试。

    • 然后就是实现换肤的最重要步骤啦,上面我们已经知道了我们是通过 AssetManager 来获取资源文件的,所以我们首先需要通过反射来创建一个插件包的AssetManager:

      val assetManager = AssetManager::class.java.newInstance()
      val addAssetPath = assetManager.javaClass.getMethod("addAssetPath", String::class.java)
      addAssetPath.invoke(assetManager, skinPath) // skinPath为插件包保存在本地的文件路径
      

      然后通过反射得到的assetManager来创建插件包的Resources,这里需要提一下的是:大家平时在获取color或者drawable的时候都是通过Resources.getXxx()来获取的,所以这里要创建插件包的Resources:

      val appResource = mContext.resources
      // 根据当前的设备显示器信息 与配置(横竖屏、语言等)创建Resources
      val skinResource = Resources(assetManager, appResource.displayMetrics, appResource.configuration)
      

    这里我们先来说一下我们是如何改变View的属性值的(如background、src、textColor、drawableStart、drawableTop、drawableEnd、drawableBottom),每个View都会有AttributeSet,而在AttributeSet中会记录着该View中的所有属性,我们需要遍历这些属性看看是否有我们需要修改的属性值,然后拿到这些属性的resId。回到一开始的问题,如何改变属性值呢?当我们拿到resId的时候,我们可以通过resId来拿到该resId对应的resName,然后再通过该resName去拿到插件包中的对应的resId,最后再通过上面创建的skinResource来获取对应的资源就好了。

    下面来一段代码讲解一下,不然可能都看懵了:

    private val mAttributes = arrayOf(
        "background",
        "src",
        "textColor",
        "drawableLeft",
        "drawableTop",
        "drawableRight",
        "drawableBottom",
    )
    
    fun getViewAttrs(view: View, attrs: AttributeSet) {
        for (i in 0 until attrs.attributeCount) {
            val attributeName = attrs.getAttributeName(i)
            if (mAttributes.contains(attributeName)) {
                val attributeValue = attrs.getAttributeValue(i)
                if (attributeValue.startsWith("#")) { // 如果是以 # 开头的则跳过,因为不符合规范
                    continue
                }
                 // 以 ?attr 开头的
                val resId = if (attributeValue.startsWith("?")) {
                    val attrId = attributeValue.substring(1).toInt()
                    // 这里是获取 ?attr 资源id的写法,在这里不会把这段代码贴出来,想要了解的可以自行搜索
                    SkinThemeUtil.getResId(view.context, intArrayOf(attrId))[0]
                } else {
                    // 以 @ 开头的
                    attributeValue.substring(1).toInt()
                }
            }
        }
    }
    

    通过以上的代码获取resId后,就可以获取得到该resId对应的resName了(注意这里拿到的都是app内的资源id,以下称为宿主app),接下来就可以拿到skinResource中对应的resId了,看下面的代码:

    private val  mAppResources by lazy { applicationContext.resources }
    
    /**
     * 通过原始app中的resId获取resName
     * 然后通过resName与resType获取皮肤包中的resId
     */
    private fun getIdentifier(resId: Int) : Int {
        val resName = mAppResources.getResourceEntryName(resId)
        val resType = mAppResources.getResourceTypeName(resId)
        // 这里的mSkinPkgName是插件包的包名
        // 可以通过packageManager.getPackageArchiveInfo(skinPath,       PackageManager.GET_ACTIVITIES)?.packageName来获取
        return mSkinResources?.getIdentifier(resName, resType, mSkinPkgName) ?: 0 
    }
    
    /**
     * 通过上述方法拿到skinResId,然后就可以通过mSkinResources?.getColor(skinResId)去修改View的属性了
     */
    fun getColor(resId: Int): Int {
        val skinResId = getIdentifier(resId)
        return if (skinResId == 0) mAppResources.getColor(resId)
        else mSkinResources?.getColor(skinResId) ?: 0
    }
    
    /**
     * @return 可能是Color 也可能是drawable
     */
    fun getBackground(resId: Int): Any? {
        val resourceTypeName = mAppResources.getResourceTypeName(resId)
        // 当修改background的时候要注意属性是color还是drawable,这个相信大家都知道~
        return if ("color" == resourceTypeName) {
            getColor(resId)
        } else {
            // 此方法大家可自行实现,这里就不贴出来了
            getDrawable(resId)
        }
    }
    
    
    

    好了,我们的写完这些资源文件的获取逻辑之后就可以自定义LayoutInflater.Factory2来重写onCreateView然后加入我们的逻辑啦:

    // 此处为伪代码
    override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
        // 创建系统View,这里需要跟最上面讲的inflate过程中的通过name.indexOf('.')来判断是否是系统View对应,可自行了解
        var view: View? = createSDKView(name, context, attrs)
        if (null == view) {
            // 如果不是系统View,则通过全包名创建自定义View,创建的过程与上面讲的createView(context, name, null, attrs)一致,通过ClassLoader反射得到View的构造器,然后通过构造器使用反射,调用了View的构造方法完成View的创建
            view = createView(name, context, attrs)
        }
        //这就是我们加入的逻辑
        if (null != view) {
            //加载属性
           
        }
        return view
    }
    

    未解决的问题

    由于Androidn P 更新了非SDK接口的限制,导致我们需要重设的LayoutInflate中的mFactorySet字段无法被反射重设,所以如果要自己封装一个插件换肤框架的话暂时还没有解决方法(Android 10之前不受影响),而上面所说的自定义Factory2并重写onCreateView的方法,目前只适用于在每个需要更改的Activity中的onCreate中的super方法前调用,这样才能拦截View的加载。

    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P)
    private boolean mFactorySet;
    

    可以看到这里的mFactorySet被限制了,而为什么需要反射修改这个字段呢,可以看下面的代码:

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

    如果不反射修改这个字段的话,就会抛出A factory has already been set on this LayoutInflater异常,无法重新设置一个新的LayoutInflater,而如果按照我上述说的在Activity中的onCreate中的super方法前调用,这样就不会受该字段的影响,因为在Activity的onCreate方法中的super方法,已经间接的调用了setFactory2,上面的代码也可以看到,当调用了这个方法后 mFactorySet = true ,所以下次要想重设LayoutInflater的话,就会抛异常。

    总结

    这个通过反射修改mFactorySet的插件换肤目前只有Android 10之前才有用,而如果Android 10之后想要写插件换肤的可以尝试一下ASM字节码插桩,与上面所讲的思路是一样的,不过我目前还未研究,等研究出来了再贴出代码。

    相关文章

      网友评论

          本文标题:Android插件化换肤(仅限Android P以前可使用)

          本文链接:https://www.haomeiwen.com/subject/nxamfrtx.html