View 属性的解析与换肤场景

1,055 阅读4分钟
  1. 如何访问 XML 文件内的 View?
  2. 如何解析 XML 文件每个 View 定义的属性?
  3. 解析出来的属性有什么使用场景?

带着上述问题我们逐一分析。

1、如何访问 View?

我们通过 LayoutInflater 加载布局文件,并获取到布局文件对应的 View;因此切入点可以考虑从 LayoutInflater 入手,在 LayoutInflater 内通过 Factory 创建 View,所以我们可以代理 LayoutInflater 内的 Factory,这样就可以访问到 XML 文件定义的所有 View;

// 自定义 Factory 类
override fun onCreateView(parent: View?, name: String, context: Context, attrs: AttributeSet): View? {
    // 通过 activity 代理方式创建 View
    var view = activity?.delegate?.createView(parent, name, context, attrs)
    if (null == view) {
        Log.d(TAG, "onCreateView name: $name, count: ${++viewCount}")
        if (!name.contains(".")) {
            for (prefix in sClassPrefixList) {
                try {
                    // 通过 inflater 方式创建 View
                    view = inflater.createView(context, name, prefix, attrs)
                    if (null != view) break
                } catch (e: Exception) {
                    Log.d(TAG, "createView e: $e")
                }
            }
        } else {
            // 通过 inflater 方式创建 View
            view = inflater.createView(context, name, null, attrs)
        }
    }
    if (null != view) {
        // 解析View的属性
        parseViewAttrs(view, attrs)
    }
    return view
}

将定义的代理 Factory 类,设置到 LayoutInflater 内,代理 Factory 类不需要关注如何创建View,我们只需要关注访问创建后的 View。

如何将代理 Factory 设置到 LayoutInflater,可以在 Activity.onCreate 父类方法调用之前设置;

fun onCreate(savedInstanceState: Bundle?) {
    layoutInflater.factory2 = XXXFactory(layoutInflater, this)
    super.onCreate(savedInstanceState)
}

2、如何解析View内的属性?

通过代码来分析属性内含义的字段信息

// parseViewAttrs attrName: foreground, attrValue: ?16842641, nameSpace: http://schemas.android.com/apk/res/android
// parseViewAttrs 9, attrName: style, attrValue: @2131820816, nameSpace: 

fun parseViewAttrs(view: View, attrs: AttributeSet) {
    // 通过解析 View 内定义的所有属性
    for (index in 0 until attrs.attrbuteCounte) {
        // 属性名 foreground/style etc
        val attrName = attrs.getAttributeName(index)
        // 属性值,R.java 文件内定义的资源ID ?16842641/@2131820816
        val attrValue = attrs.getAttrbuteValue(index)
        val nameSpace = attrs.getAttributeNamespace(index)
        
        // 解析 View style 属性内定义的特定 item,intArrayOf 内定义
        if (attrName == "style") {
            parseViewStyle(view, attrs, intArrayOf(android.R.atrt.foreground), attrValue.substring(1).toInt)
        }

        // 解析 View 普通属性,通过 filter 方法过滤解析特定 attr 属性
        if (filter(attrName)) {
            parseViewAttr(view, attrName, attrValue)
        }
    }
}

// 过滤方法
private fun filter(attrName: String): Boolean {
    return attrName == "textColor" || attrName == "src" || attrName == "background"
}

解析 View style 属性资源

// parseViewStyle [ index: 1, count: 2, value: res/color/hint_foreground_material_light.xml, resId: 17170805, entryName: hint_foreground_material_light, typeName: color, attrItemName: textColorHint ]

parseViewStyle(view: View, attrs: AttributeSet, attrArray: IntArray, styleId: Int) {
    view.context.obtainStyledAttributes(styledId, attrArray).use {
        // 逐条解析 style 内每个属性 item 内容
        for (i in 0 until it.indexCount) {
            // 资源文件地址
            val value = it.getString(i)
            // R.java 文件内定义的资源ID
            val resId = it.getResourceId(i, -1)
            if (resId < 0 || value.isNullOrEmpty) continue
            // 资源名称 hint_foreground_material_light
            val entryName = view.resource.getResourceEntryName(resId)
            // 资源类型 color
            val typeName = view.resource.getResourceTypeName(resId)
            // 属性名 textColorHint
            val itemAttrName = view.resource.getResourceEntryName(attrArray[i])
        }
    }
}

解析出来的 itemAttrName -> resId,可以用于动态加载该属性的资源,当在不同的资源包内包含该资源时,可以实现主题的切换;

解析 View 普通属性资源

// parseViewAttr theme [ themeId: 16842841, attrName: foreground, attrValue: ?16842841, < entryName: windowContentOverlay, typeName: attr, resolve: true, typed: { resourceId: 0, sourceResourceId: 0, type: 1, data: 0 }  > ]

// parseViewAttr resource [ attrName: textColor, attrValue: @2131099911, resId: 2131099911, < entryName: textColorNormalAlpha, typeName: color > ]

fun parseViewAttr(view: View, attrName: String, attrValue: String) {

    if(attrValue.startWith("?")) {
        // parse theme
        val themeId = attrValue.substring(1).toInt()
        if(0 != themeId) {
            // 定义的资源名称,windowContentOverlay
            val entryName = view.resource.getResourceEntryName(themeId)
            // attr/color etc
            val typeName = view.resource.getResourceTypeName(themeId)
            // 解析资源值引用
            val typedValue = TypedValue()
            view.context.theme.resolveAttribute(themeId, typedValue)
            // R.java 文件内定义的资源ID
            val resId = typedValue.getResourceId()
        }
    } else if(attrValue.startWith("@")) {
        // parse resource
        val resId = attrValue.substring(1).toInt()
        if (0 != resId) {
            // 定义的资源名称,textColorNormalAlpha
            val entryName = view.resource.getResourceEntryName(resId)
            // color/boolean etc
            val typeName = view.resource.getResourceTypeName(resId)
        }
    }
}

解析出来的 attrName -> resId,可以用于动态加载该属性的资源,当在不同的资源包内包含该资源时,可以实现主题的切换;

3、View 属性的使用场景-主题切换

换肤框架(一).png

将需要换肤的属性记录到每个 View 内,当换肤事件发生时,遍历需要换肤的View,并设置需要更新的属性,从而触发加载新的主题包内的资源;

换肤框架可以分为3个部分,注册换肤属性拦截器、解析与记录 View 换肤属性的工厂、监听换肤事件、记录需要换肤的根View;

通过上述流程,实现了整个应用的换肤操作;

整体思路非常简单,简单梳理如下:

首先注册需要换肤的属性,主要涉及 color/drawable 等资源的属性; 然后通过View工厂,解析每个View的换肤属性,并记录该属性资源ID; 最后监听主题切换事件,从根View开始遍历所有需要换肤的View,并重新加载相关属性的资源即可;