这是“干掉 xml”系列的第二篇,上一篇干掉的是res/layout
目录下的布局文件,这一篇想把res/drawable
目录下的形状配置文件也干掉。
Android 中的 xml 资源文件将静态配置和动态代码解耦,便于集中管理。但它会拖累性能,不仅增大包体积,读 xml 也是 I/O 耗时操作。
实战项目中有 650+ 布局文件(2.9 MB),1000+ drawable 非图片文件(700 KB),如果这些文件都能被干掉,也可以为缩包做一点贡献。
“干掉 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)
}
}