Kotlin 实战 | 用语法糖干掉形状 xml 文件

6,518 阅读7分钟

这是“干掉 xml”系列的第二篇,上一篇干掉的是res/layout目录下的布局文件,这一篇想把res/drawable目录下的形状配置文件也干掉。

Android 中的 xml 资源文件将静态配置和动态代码解耦,便于集中管理。但它会拖累性能,不仅增大包体积,读 xml 也是 I/O 耗时操作。

实战项目中有 650+ 布局文件(2.9 MB),1000+ drawable 非图片文件(700 KB),如果这些文件都能被干掉,也可以为缩包做一点贡献。

“干掉 xml”系列文件目录如下:

  1. Android性能优化 | 把构建布局用时缩短 20 倍(下)

  2. 干掉 xml | 再也不用为各种形状写 xml 了

用 xml 静态配置形状

如下界面需要定义几个<shape>文件?

答案是 6 个。最气人的是,这 6 个文件内容几乎是相同的:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <corners android:radius="15dp" />
    <solid android:color="#FFEBF1" />
</shape>

唯一的区别仅是颜色和圆角不同。<shape>文件的尴尬之处在于它无法被复用。

用代码动态构建形状

<shape>标签对应的 Java 类是GradientDrawable,可以用代码动态构建如图形状:

// 构建GradientDrawable对象并设置属性
val drawable = GradientDrawable().apply {
    shape = GradientDrawable.RECTANGLE // 矩形
    cornerRadius = 10f // 圆角
    colors = intArrayOf(Color.parseColor("#ff00ff"),Color.parseColor("#800000ff")) //渐变色
    gradientType = GradientDrawable.LINEAR_GRADIENT // 渐变类型
    orientation = GradientDrawable.Orientation.LEFT_RIGHT // 渐变方向
    setStroke(2.dp,Color.parseColor("#ffff00")) // 描边宽度和颜色
}
// 将GradientDrawable对象设置为控件背景
findViewById<Text>(R.id.tvTitle)?.background = drawable

借助于apply(),这段代码还比较清晰易懂。如果能有 DSL 的加持,就可以做得更好。

构建布局 DSL

回顾下上一篇中动态构建布局的 DSL:

class MainActivity : AppCompatActivity() {
	// 用 DSL 动态构建布局
    private val contentView by lazy {
        ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent

            TextView {
                layout_width = wrap_content
                layout_height = wrap_content
                text = "commit"
                textSize = 30f
                gravity = gravity_center
                center_horizontal = true
                top_toTopOf = parent_id
                padding = 10
                // 为 TextView 设置圆角渐变形状
                background_res = R.drawable.shape
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 将布局设置为 content view
        setContentView(contentView)
    }
}

DSL 把原先定义在 xml 中的布局搬到了 Activity 中,减少了一个 xml 文件,避免了一次读文件的 I/O 操作。

构建布局 DSL 中的每一个被赋值的属性都是一个预定义的扩展属性,比如layout_width

// 为 View 扩展一个 Int 类型的属性
inline var View.layout_width: Int 
    get() {
        return 0
    }
    // 当扩展属性被赋值时,将其转化为 MarginLayoutParams 并设置为控件的布局参数
    set(value) {
        val w = if (value > 0) value.dp else value
        val h = layoutParams?.height ?: 0
        layoutParams = ViewGroup.MarginLayoutParams(w, h)
    }

// 为 Int 值扩展属性,将其转换为 DP 值
val Int.dp: Int
    get() {
        return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            this.toFloat(),
            Resources.getSystem().displayMetrics
        ).toInt()
    }

关于动态构建布局 DSL 的详细讲解可以点击这里

新增形状属性

运用同样的思路为View新增一个“形状属性”,将构建GradientDrawable的细节隐藏(或者说增加构建代码的可读性):

inline var View.shape: GradientDrawable
    get() {
        return GradientDrawable()
    }
    set(value) {
        background = value
    }

View新增shape属性,它是GradientDrawable类型的。

inline fun shape(init: GradientDrawable.() -> Unit) = GradientDrawable().apply(init)

新增一个顶层方法,用于构建GradientDrawable实例。

直接GradientDrawable()不就能构建实例,为啥还要再新增一个方法做同样的事情?

这个方法的魔力在于它接收一个带接收者的lambda作为参数,即GradientDrawable.() -> Unit,这样一来就隐藏了“构建”和“设值”这两个动作的细节,使得可以用声明型的语句来完成构建:

class MainActivity : AppCompatActivity() {
	// 用 DSL 动态构建布局
    private val contentView by lazy {
        ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent

            TextView {
                layout_width = wrap_content
                layout_height = wrap_content
                text = "commit"
                textSize = 30f
                gravity = gravity_center
                center_horizontal = true
                top_toTopOf = parent_id
                padding = 10
                // 为 TextView 设置形状
                shape = shape {
                    shape = GradientDrawable.RECTANGLE
                    cornerRadius = 10f
                    colors = intArrayOf(Color.parseColor("#ff00ff"),Color.parseColor("#800000ff")) 
                    gradientType = GradientDrawable.LINEAR_GRADIENT 
                    orientation = GradientDrawable.Orientation.LEFT_RIGHT 
                    setStroke(2.dp,Color.parseColor("#ffff00"))
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 将布局设置为 content view
        setContentView(contentView)
    }
}

将原本通过R.drawable.shpae引用的静态资源转变为动态构建。

上面的代码虽然已经将布局和形状文件都干掉了,但为形状设值的过程还是有点啰嗦,可以通过预定义扩展属性来简化:

// 为 GradientDrawable 扩展圆角半径属性
inline var GradientDrawable.corner_radius: Int
    get() {
        return -1
    }
    set(value) {
    	// 将半径值转化成dp值
        cornerRadius = value.dp.toFloat()
    }

// 为 GradientDrawable 扩展渐变色属性
inline var GradientDrawable.gradient_colors: List<String>
    get() {
        return emptyList()
    }
    set(value) {
    	// 将 string 转换成颜色 Int 值
        colors = value.map { Color.parseColor(it) }.toIntArray()
    }

// 为 GradientDrawable 扩展描边属性
inline var GradientDrawable.strokeAttr: Stroke?
    get() {
        return null
    }
    set(value) {
    	// 将描边数据实体类拆解并传递给 setStroke()
        value?.apply { setStroke(width.dp, Color.parseColor(color), dashWidth.dp, dashGap.dp) }
    }

// 描边数据实体类
data class Stroke(
    var width: Int = 0, 
    var color: String = "#000000", 
    var dashWidth: Float = 0f, 
    var dashGap: Float = 0f
)

// 给常量取一个较短的别名以增加可读性
val shape_rectangle = GradientDrawable.RECTANGLE
val gradient_type_linear = GradientDrawable.LINEAR_GRADIENT
val gradient_left_right = GradientDrawable.Orientation.LEFT_RIGHT

运用这些属性进一步简化构建过程:

class MainActivity : AppCompatActivity() {
	// 用 DSL 动态构建布局
    private val contentView by lazy {
        ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent

            TextView {
                layout_width = wrap_content
                layout_height = wrap_content
                text = "commit"
                textSize = 30f
                gravity = gravity_center
                center_horizontal = true
                top_toTopOf = parent_id
                padding = 10
                // 为 TextView 设置形状
                shape = shape {
                    corner_radius = 10
                    shape = shape_rectangle
                    gradientType = gradient_type_linear
                    orientation = gradient_left_right
                    gradient_colors = listOf("#ff00ff", "#800000ff")
                    strokeAttr = Stroke(2, "#ffff00")
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 将布局设置为 content view
        setContentView(contentView)
    }
}

新增颜色状态列表属性

实际业务场景中,按钮的背景颜色通常有好几种状态,最开始我通过这样的代码来控制背景色的变化:

val tv = findViewById<TextView>(R.id.tv)

fun onResponse(success: Boolean){
	if (success) {
        tv.bacground = R.drawable.success
    } else {
        tv.background = R.drawable.fail
    }
}

这样写,语义还是简单明了的,但缺点是 **“控件单个属性的控制逻辑散落在各处” ** 。

假设分别有 2 个网络请求和 1 个界面按钮可以影响该控件的背景色,则tv.background = R.drawable.xxx会散落在 2 个网络请求和 1 个控件点击事件回调中。

这增加了后期维护的复杂度,因为“该控件在不同条件下会对应呈现什么样的背景色?”这个知识被撕碎了且散落在不同的地方,你得查找出所有的碎片拼凑起来才能知道故事的详情,以便进行修改。

(你一定经历过这样的遭遇,只是单纯地想把某控件的属性值修改一下,但死活不成功,因为其他 n 个地方也在修改它。。。这样的隐藏剧情,让人非常抓狂)

后来我学会了<selecotr>

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/bg_red" android:state_enabled="true" />
    <item android:drawable="@drawable/bg_blue" android:state_enabled="false" />
</selector>

<selector>将不同状态对应的颜色提前配置好并归总在一个文件中,这样改控件背景色变化的逻辑就一目了然,然后只需要在不同的业务场景处改变控件状态即可:

val tv = findViewById<TextView>(R.id.tv)
tv.background = R.drawable.selector

fun onResponse(success: Boolean){
	if (success) {
    	tv.enable = true
    } else {
    	tv.enbale = false
    }
}

是不是也可以将<selector>配置文件动态构建?

// 为 GradientDrawable 扩展颜色状态列表属性
inline var GradientDrawable.color_state_list: List<Pair<IntArray, String>>
    get() {
        return listOf(intArrayOf() to "#000000")
    }
    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    set(value) {
    	// 状态列表
        val states = mutableListOf<IntArray>()
        // 颜色列表
        val colors = mutableListOf<Int>()
        // 将属性值分别转换成状态列表和颜色列表
        value.forEach { pair ->
            states.add(pair.first)
            colors.add(Color.parseColor(pair.second))
        }
        // 创建 ColorStateList 对象 并调用setColor(ColorStateList colorStateList)
        color = ColorStateList(states.toTypedArray(), colors.toIntArray())
    }
    
// 给常量取一个较短的别名以增加可读性
val state_enable = android.R.attr.state_enabled
val state_disable = -android.R.attr.state_enabled
val state_pressed = android.R.attr.state_pressed
val state_unpressed = -android.R.attr.state_pressed

然后就可以像这样在构建 TextView 的同时为其构建颜色状态列表:

class MainActivity : AppCompatActivity() {
	// 用 DSL 动态构建布局
    private val contentView by lazy {
        ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent

            TextView {
                layout_width = wrap_content
                layout_height = wrap_content
                text = "commit"
                textSize = 30f
                gravity = gravity_center
                center_horizontal = true
                top_toTopOf = parent_id
                padding = 10
                // 为 TextView 设置形状
                shape = shape {
                    corner_radius = 10
                    shape = shape_rectangle
                    gradientType = gradient_type_linear
                    orientation = gradient_left_right
                    gradient_colors = listOf("#ff00ff", "#800000ff")
                    strokeAttr = Stroke(2, "#ffff00")
                    // 为形状构建颜色状态列表
                    color_state_list = listOf(
                        // 在 enable 和 pressed 状态下显示一种颜色
                        intArrayOf(state_enable, state_pressed) to "#007EFF",
                        // 在 disable 和 unpressed 状态下显示另一种颜色 
                        intArrayOf(state_disable, state_unpressed) to "#FDB2DA"
                    )
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 将布局设置为 content view
        setContentView(contentView)
    }
}

新增 drawable 状态列表属性

将颜色和状态绑定只能满足一小部分需求,大多数时候需要将表现形式更多样的 drawable 和状态绑定。与<selector>对应的 Java 类是StateListDrawable,遂为控件新增 drawable 状态列表属性:

inline var View.background_drawable_state_list: List<Pair<IntArray, Drawable>>
    get() {
        return listOf(intArrayOf() to GradientDrawable())
    }
    set(value) {
    	// 构建 StateListDrawable 实例并将属性值转换成一个个 state
        background = StateListDrawable().apply {
            value.forEach { pair ->
            	// 为 StateListDrawable 添加一个状态值和 Drawable 实例的对应关系
                addState(pair.first, pair.second)
            }
        }
    }

之前,为了让一个按钮在可点击与不可点击时具备不一样的背景,需要定义三个 xml 文件:selector.xml + shape_clickable.xml + shape_unclickable.xml,现在一个都不需要了:

class MainActivity : AppCompatActivity() {
	// 用 DSL 动态构建布局
    private val contentView by lazy {
        ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent

            TextView {
                layout_width = wrap_content
                layout_height = wrap_content
                text = "commit"
                textSize = 30f
                gravity = gravity_center
                center_horizontal = true
                top_toTopOf = parent_id
                padding = 10
                // 为 TextView 设置 drawable 状态列表
                background_drawable_state_list = listOf(
                	// 可点击状态 = 带圆角的矩形
                    intArrayOf(state_enable) to shape {
                        shape = shape_rectangle
                        corner_radius = 10
                        solid_color = "#FDB2DA"
                    },
                    // 不可点击状态 = 带透明度的圆角矩形
                    intArrayOf(state_disable) to shape {
                        shape = shape_rectangle
                        corner_radius = 10
                        solid_color = "#80FDB2DA"
                    }
                )
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 将布局设置为 content view
        setContentView(contentView)
    }
}

Talk is cheap, show me the code

推荐阅读