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

前置知识

  • 需要了解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字节码插桩,与上面所讲的思路是一样的,不过我目前还未研究,等研究出来了再贴出代码。

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

推荐阅读更多精彩内容