首页 / 资讯中心 / 文章详情

告别shape文件爆炸:Android自定义Drawable属性全解析

告别shape文件爆炸:Android自定义Drawable属性全解析 ★ FEATURED ARTICLE
入行第三年的时候我接过一个需求同一个积分商城页面里八个业务模块共用了十几种状态的卡片背景红色模块要圆角蓝色模块要呼吸描边灰色模块要有禁用态还有一张要按压变亮、选中变色。用 shape 写十几个 XML 当然能做但每个模块的状态一组合就不是十几个文件的问题而是几十种排列组合的问题。更闹心的是产品经理每隔两周就改一次圆角半径和边框粗细。也就是从那次开始我认真研究了 Android Drawable 自定义属性后来这类需求全被一个通用 Drawable 吞掉了改样式只改 XML 属性就行再也不用复制粘贴 shape 文件。这篇文章就把这条完整链路讲清楚为什么要给 Drawable 配自定义属性属性从 XML 到你手里的传递机制一个能直接抄的实战案例以及我在排查中发现的一些怪坑。1. 自定义 Drawable 什么时候才值得配自定义属性1.1 我遇到的那个八个模块八套背景的下午先说那天下午的具体场景。UI 同学在蓝湖上切了二十多张卡片状态图但我打开一看这些图本质上都是圆角矩形 纯色背景 可选描边区别只在颜色值、圆角大小、边框宽度和选中时的那点透明度变化。用最原始的办法我会去 drawable 目录下堆 XMLshape xmlns:androidhttp://schemas.android.com/apk/res/android solid android:color#FF4D4F / corners android:radius12dp / stroke android:width1dp android:color#FF7875 / /shape再给每个状态配 selectorselector xmlns:androidhttp://schemas.android.com/apk/res/android item android:state_pressedtrue android:drawabledrawable/bg_card_red_pressed / item android:drawabledrawable/bg_card_red_normal / /selector这套方案在只有一两个模块时没问题但如果八套背景各来四个状态那就是三十二个 XML 文件而且里面九成内容是重复的。更麻烦的是一旦某个模块要改 12dp 为 16dp你得手工同步四五个文件漏改一个就会出现按下去圆角变大的灵异现象。相比之下给 Drawable 配自定义属性的做法是我把圆角大小、背景色、描边色、描边宽度、按压变暗比例变成属性写进一个通用 Drawable 的构造参数里然后每个页面或每种状态只需要写一条 XMLcom.example.ui.StateRoundDrawable app:radius12dp app:bgColor#FF4D4F app:strokeColor#FF7875 app:strokeWidth1dp /虽然当时觉得这东西不如 shape 直观但维护成本完全不是一个量级改全局圆角时替换一个默认值就行改某个模块时单独覆盖一个属性就行。1.2 不配属性的妥协方案style、selector、shape 各有什么瓶颈可能有人会问既然要复用为什么不用 styles.xml 把公共参数抽出来方案能解决的问题卡住的点shape XML selector简单圆角、背景、状态切换状态一多、模块一多文件爆炸复制粘贴易漏改styles.xml 抽取属性统一 shape 里的部分公共属性但 selector 内部 item 不能直接用 style 覆盖 drawable 里的子节点属性圆角、颜色仍然拆不开代码里 new GradientDrawable灵活避免 XML 冗余和业务代码耦合UI 样式散落在 Java/Kotlin 文件里不好统一管理自定义 Drawable 自定义属性每个状态单独写一条 XML参数可配可复用需要理解 inflate、TypedArray、ConstantState 这些机制入门门槛略高我的结论很直接如果你的 UI 背景就固定两三种样子老老实实用 shape别折腾。但如果出现同一种视觉风格要套多个模块、多个状态并且参数经常微调自定义 Drawable 的性价比就体现出来了它把变化的部分从代码和文件里抽离到属性层这比继续堆 XML 健康得多。1.3 Drawable 和 View 的自定义属性定位差异很多同学熟悉的是自定义 View 里的declare-styleable在构造函数里通过context.obtainStyledAttributes读取属性。但 Drawable 不一样它没有构造函数里的AttributeSet参数对应的读取入口是重写inflate(Resources, XmlPullParser, AttributeSet, Theme)方法。这个差异带来一个很重要的思维转换View 的属性通常服务于控件级配置比如layout_width、maxLength、srcCompat而 Drawable 的属性更单纯基本都在描述怎么画。它不关心控件尺寸到底多宽只关心圆角、颜色、描边这些绘制参数。所以设计自定义属性时不要一上来就考虑 View 层的东西脑子里要清楚这个 Drawable 将来会被setBackground()还是setImageDrawable()使用它的绘制区域由onBoundsChange()通知这一点会在后面实战部分反复提到。2. 先拆属性传递链从 XML 到 TypedArray 的完整路径2.1 declare-styleable 到底在声明什么自定义属性不是随便在 XML 里写一个app:radius就能被读取的第一步是在res/values/attrs.xml里声明。不要把它想象成一个很玄的东西它就是定义我们这个自定义组件/Drawable 支持哪些属性、每个属性的类型是什么。resources declare-styleable nameStateRoundDrawable attr nameradius formatdimension / attr namebgColor formatcolor / attr namestrokeColor formatcolor / attr namestrokeWidth formatdimension / attr namepressedAlpha formatfloat / /declare-styleable /resourcesdeclare-styleable起到的作用是一份清单name是这个集合的标识通常和类名保持一致每个attr的format声明类型。清单本身不占用运行内存它只是在资源编译阶段生成一个 int 型的索引数组让你能够在代码里用R.styleable.StateRoundDrawable_radius快速定位某个属性在TypedArray中的位置。这里要注意一个经常被忽略的事实format是可以多个叠加的比如写成formatcolor|reference这表示该属性既可以传颜色值也可以传color/xxx引用。我们自定义 Drawable 属性时建议在早期就想好是否需要支持引用。因为实色值写起来方便但如果是多主题换肤需求大概率希望app:bgColorcolor/dynamic_color传引用这时如果 format 里没有 reference编译期就会报类型不匹配。2.2 xmlns:app 前缀跟着谁走自定义属性之所以要用app:前缀而不是android:是因为它根本不属于 Android 系统命名空间。Android 系统属性都挂在http://schemas.android.com/apk/res/android下面框架层会帮你自动识别自定义属性挂在应用自己的包或资源表里所以要显式声明一个命名空间。惯例写法是xmlns:apphttp://schemas.android.com/apk/res-autores-auto的好处是不需要写死包名构建工具会自动解析到当前应用的资源表。但很多新手出问题的点在于这个东西是按 XML 根节点生效的如果你在布局文件中声明了xmlns:app那么这个布局里所有 View 和 Drawable 标签都可以用app:前缀如果你的自定义 Drawable 是写在单独一个 XML 文件里res/drawable/bg_round.xml那这个文件里也要自己声明命名空间否则编译器会直接报错。这个报错通常不是前缀未声明而是Attribute app:radius is not allowed here极其迷惑人后面坑那一节我会细聊。2.3 format 五种高频类型与对应读取姿势在实战之前先把最常见的几种 format 怎么读摸透因为它们对应完全不同的TypedArray方法formatXML 写法示例读取方法注意事项color#FF4D4F或color/xxxtypedArray.getColor(index, default)如果加了 reference读到的可能是 ColorStateListdimension12dp、4pxtypedArray.getDimension(index, defaultFloat)返回值单位是 px要做 dp2px 转换需自己乘 densityfloat0.6typedArray.getFloat(index, defaultFloat)不存在 getFloat但 getFraction 不是这个booleantrue/falsetypedArray.getBoolean(index, defaultBoolean)常用来开关是否启用按压反馈enum / intnormal、roundtypedArray.getInt(index, 0)配合enum子标签使用值是枚举的整形索引还有一个高频的是reference比如这个 Drawable 支持自定义前景图时用app:foregrounddrawable/xxx读取姿势是typedArray.getDrawable(index)或者拿资源 id 后手动context.getDrawable(id)。我踩过的一个小跟头是 dimension 读取。getDimension返回值以 px 为单位但如果你在 XML 里写的是12dp这个值其实已经被乘以 density 了不需要再手动乘一次。真正需要手动转换的场景是你在 Java/Kotlin 里直接传参比如代码里StateRoundDrawable(dp2px(12f))这时候才需要手动做 dp 到 px 的换算。3. 实战手写一个可配置圆角、边框与状态色的通用 Drawable3.1 先定 XML API后写实现很多人写自定义 Drawable 是从底层画法开始我先反着来先把想要的使用形态定下来。因为 Drawable 跟 View 一样第一要务是在 XML / 代码里的调用足够舒服。我给这个 Drawable 定的调用目标是com.example.ui.StateRoundDrawable xmlns:apphttp://schemas.android.com/apk/res-auto app:radius12dp app:bgColor#FF4D4F app:strokeColor#FF7875 app:strokeWidth1dp app:pressedDarken0.15 app:statenormal /然后在布局或 selector 里直接引用android:backgrounddrawable/bg_card_statestate字段支持三档normal、pressed、selected。这三档主要控制整体绘制的颜色深浅和是否多画一层高亮。读起来直观写起来也像一个小 DSLUI 同学看着 XML 就能猜出这个模块的视觉特征不需要翻代码。3.2 inflate、onBoundsChange、draw 三层协作实现一个自定义 Drawable核心要重写的方法就三个inflate、onBoundsChange、draw。外加一个getIntrinsicWidth/Height不过我们这种自适应背景型 Drawable 通常是-1告诉系统我没有固有尺寸你根据 View 决定。关于inflate我给出一个最常用的 Kotlin 模板重点在注释里标出它做了什么class StateRoundDrawable : Drawable() { private var radiusPx 0f private var bgColor 0 private var strokeColor 0 private var strokeWidthPx 0f private var pressedDarken 0.15f private var state STATE_NORMAL override fun inflate( resources: Resources, parser: XmlPullParser, attrs: AttributeSet, theme: Resources.Theme? ) { super.inflate(resources, parser, attrs, theme) val typedArray if (theme ! null) { resources.obtainStyledAttributes(attrs, R.styleable.StateRoundDrawable, 0, 0) } else { resources.obtainAttributes(attrs, R.styleable.StateRoundDrawable) } radiusPx typedArray.getDimension(R.styleable.StateRoundDrawable_radius, 0f) bgColor typedArray.getColor(R.styleable.StateRoundDrawable_bgColor, 0) strokeColor typedArray.getColor(R.styleable.StateRoundDrawable_strokeColor, 0) strokeWidthPx typedArray.getDimension(R.styleable.StateRoundDrawable_strokeWidth, 0f) pressedDarken typedArray.getFloat(R.styleable.StateRoundDrawable_pressedDarken, 0.15f) state typedArray.getInt(R.styleable.StateRoundDrawable_state, STATE_NORMAL) typedArray.recycle() } ... }这里最值得说的是obtainStyledAttributes和obtainAttributes的区别。如果你在某个 View 的构造函数里通常是带着 theme 的但 Drawable 从 XML inflate 时不一定带完整的 theme 对象可能只是一个裸AttributeSet。如果想要做主题换肤、读取?attr/xxx这类动态属性必须走obtainStyledAttributes并传递 theme如果只是读取固定值后者也够用。我建议统一走obtainStyledAttributes兼容性更好。onBoundsChange是 Drawable 感知尺寸变化的地方View 在布局完成后会调用setBounds这里可以拿到最终宽高。注意不要把拿尺寸这个逻辑放在draw里反复计算性能不好正确做法是只在onBoundsChange里根据尺寸重建 Path 或圆角矩形参数private lateinit var path: Path override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) path Path().apply { addRoundRect( bounds.left.toFloat(), bounds.top.toFloat(), bounds.right.toFloat(), bounds.bottom.toFloat(), radiusPx, radiusPx, Path.Direction.CW ) } }最后一个核心是draw。绘制时先画背景再画描边最后判断 state 是否要叠加一个透明度遮罩override fun draw(canvas: Canvas) { val paint Paint(Paint.ANTI_ALIAS_FLAG) paint.color bgColor canvas.drawPath(path, paint) if (strokeWidthPx 0) { paint.style Paint.Style.STROKE paint.strokeWidth strokeWidthPx paint.color strokeColor canvas.drawPath(path, paint) paint.style Paint.Style.FILL } if (state STATE_PRESSED pressedDarken 0f) { paint.color Color.argb( (255 * pressedDarken).toInt(), 0, 0, 0 ) canvas.drawPath(path, paint) } }按当前 state 用 Color.argb 叠加一层黑色半透明实现按压变暗效果。这里可以用ColorMatrixColorFilter或者setTint更精细地处理但初版直接用遮罩最简单。3.3 为什么这样设计对比 GradientDrawable 与 LayerDrawable实际做的时候肯定有人会说系统不是有 GradientDrawable 吗可以gradientDrawable.cornerRadius 12f颜色、描边也都支持为什么还要自己画GradientDrawable 确实强大但它有两个问题。第一它是一套全局配置而不是按状态配置selector 的每个 item 都需要单独一个 GradientDrawable 实例状态切换时如果使用同一个 Drawable 改颜色会引发我在下一节讲的 ConstantState 缓存问题。第二GradientDrawable 的自定义属性只有一套系统内置 set扩展不出pressedDarken、state这种业务字段你没法在 XML 里直接描述按压时变暗 15%。LayerDrawable 则适合叠加场景比如背景 前景边框 中央图形但在一个圆角背景 N 种状态的场景下它要写很多item层级XML 冗长也不符合改一个属性就能转型的需求。所以我最终选择自绘。本质上是因为这个 Drawable 的视觉形态足够简单简单到一个 Path Paint 就能画完却要承担复杂的业务状态组合。把复杂的状态判断交给state属性比交给一堆嵌套 layer 要直观得多。选择自绘还有一个附带好处更可控。比如按下时不仅要变暗还可以用ValueAnimator插值圆角从 12dp 变到 4dp这种变化在 GradientDrawable 里写也不难但在自定义属性的框架下它和 XML 配置天然一体后面进阶那一章我会演示。4. 四个让我排查到凌晨的隐藏坑完整排查链路4.1 坑一ConstantState 缓存导致全局颜色被串先描述现象我做了第一个版本后在列表页把同一个 Drawable 资源同时setBackground到两个不同模块的 View 上一个设置成红色另一个设置成蓝色。结果两个 View 全都变成后设置的那个颜色找了半天没发现代码哪里循环了。排查链路大概是这样我先在每个draw里打日志确认两个 View 的是不是同一个对象。打印出来发现确实不是同一个对象它们是通过resources.getDrawable(R.drawable.bg_card_state)拿的看起来是两个不同实例。然后我去看内存里 Drawable 的constantState发现两者内部共享了一份ConstantState——这是 Drawable 的优化机制为了节省内存系统只保留一份状态数据多个实例通过它共享。好那问题就变成我给 A 设置的bgColor到底写在哪里。如果你在自定义 Drawable 里把bgColor直接写成成员变量它不应该共享。但当时我做了一个快速 V2把颜色值存进一个StateSet并放在constantState里结果成了共享状态A 改了 B 也变。修复方案很简单第一不要试图在ConstantState里放业务可变字段第二如果确实需要动态改颜色调用前必须mutate()它会强制复制一份 ConstantState 出来避免影响其他地方。这里补一句Android 官方推荐对想动态修改的 Drawable统一调用mutate()否则很多系统优化会静默坑你。这个坑的根因是共享状态和按需复制这两件事没拎清如果你也写了自定义 Drawable建议优先把颜色、描边、圆角整个视为不可变配置每次需求变化都通过new Drawable().apply { ... }创建省心很多。4.2 坑二没有固有尺寸的 Drawable 在布局里隐身现象是把一个使用自定义 Drawable 当作android:background的控件放到LinearLayout里结果控件高度坍缩成一个 0dp 的窄条背景完全看不见。当时我的逻辑是背景型 Drawable 尺寸由 View 决定所以getIntrinsicWidth/Height返回 -1这是对的常规场景下 View 自身有固定宽高时没问题。问题出在目标 View 的高度是wrap_content它需要先问背景 你希望我多高 才能确定自身大小。而我的 Drawable 回答 -1等于告诉 View 我没有偏好View 就把高度压到最小最终导致背景消失。排查链路我先给onBoundsChange打日志发现 bounds 是 0x0证明 View 根本没把自己的尺寸传给背景。再用布局编辑器看发现 View 只有文字撑起来的高度背景面积是零。确认是getIntrinsicWidth返回 -1 造成的连锁反应。解决方法要按场景区分如果你的自定义 Drawable 只是当背景用目标 View 一般会有固定尺寸或外部约束返回 -1 没问题但如果你要把它放进ImageView或一个可能wrap_content的容器里建议返回一个合理的默认固有尺寸比如 80dp x 80dp并同时考虑setIntrinsicWidth。大多数情况下在onBoundsChange之后你才知道真实尺寸固有尺寸只是给布局系统一个建议值不要写成 -1 然后期待所有情况都正常。4.3 坑三setTint 与自定义 color 属性打架这个坑很隐蔽。我在一个需要联动状态的页面里给自定义 Drawable 外部调用了setTint(Color.RED)希望它整体变红但内部draw用的是自己保存的bgColor结果发现 tint 完全没生效甚至颜色有些发暗。排查后发现setTint作用于 Drawable 内部的ColorFilter上只有当你调用paint.colorFilter ...或在draw里真正使用getColorFilter()时才会生效。很多系统 Drawable 内部默认支持 tint但自绘 Drawable 如果不写这个逻辑setTint就是空操作。解决思路是在draw里主动适配override fun setTint(color: Int) { super.setTint(color) paint.colorFilter PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) invalidateSelf() }但更稳妥的做法是不要依赖 tint 来做状态色切换把当前要画的颜色作为一个组合逻辑统一处理比如bgColorstate 外部传入的overrideColor。tint 适合简单全局变色不适合做精细的状态细节特别是你还叠加了圆角、描边、透明度遮罩时tint 的叠加顺序很容易让颜色漂移。4.4 坑四编译期 attribute not found 但运行时正常有一次我把自定义 Drawable 的 XML 从res/drawable/bg_card_state.xml改成res/drawable-night/bg_card_state.xml为了支持夜间模式然后编译报了一堆AAPT: attribute app:state not found。我看了一眼 XML命名空间声明也在attrs.xml 也在怎么突然找不到后来发现是因为我把根标签换成了selector包裹item结构而 selector 内部 item 的根节点不是我自定义 Drawable 的类名了AAPT 不知道app:state应该解析到哪个declare-styleable里。正确做法是selector的每个item里如果要使用自定义 Drawable 属性必须给 item 单独写android:drawable并指向一个独立的com.example.ui.StateRoundDrawable标签文件属性要写在那层标签上而不是直接写 item 上。换句话说自定义 Drawable 自定义属性不能像系统 view 一样随便包在 selector 里需要每一层都保持完整的标签结构。这个报错让我理解了一个重要规律declare-styleable的命名匹配不是靠类似类名猜的而是 AAPT 检查的那条 XML 路径上根标签的类名是否出现在某个declare-styleable的适用范围里。如果类名不匹配属性解析直接失败而且编译报错信息往往指向泛泛的 attribute not found不会告诉你真正原因。5. 进阶免 XML 的属性方案、动效衔接与复用优化5.1 不写 attrs.xml代码里也能动态创建属性有些项目里 UI 经常运行时下发配置比如后台突然返回一组圆角 16dp、主题色 #333333的参数这种场景如果还去解析 XML 就太笨了。自定义 Drawable 属性在代码里只是普通成员变量完全可以直接暴露 setter只是在外部调用上要让它像属性一样好读。我的做法是给自定义 Drawable 加一个Config数据类把属性全部收拢data class Config( val radiusPx: Float 0f, val bgColor: Int 0, val strokeColor: Int 0, val strokeWidthPx: Float 0f, val pressedDarken: Float 0.15f ) fun bind(config: Config) { this.radiusPx config.radiusPx this.bgColor config.bgColor this.strokeColor config.strokeColor this.strokeWidthPx config.strokeWidthPx this.pressedDarken config.pressedDarken onBoundsChange(bounds) invalidateSelf() }这样就把XML 配置属性和代码配置属性统一到一个模型。在服务端下发皮肤数据时你只需要把 JSON 映射到Config然后background StateRoundDrawable().bind(config)几乎和 XML 一样直观。这个思路特别适合模块化项目因为你的 Drawable 不再依赖 res 资源 id彻底解耦。5.2 用属性加 ValueAnimator 做圆角过渡自定义属性顺便给动效留了一扇门。标准做法是创建一个 ValueAnimator在动画更新回调里不断修改 Drawable 的某个属性然后调用invalidateSelf()。圆角从 12dp 平滑变成 4dp 的动画大概长这样private fun animateRadius(from: Float, to: Float, duration: Long 200L) { ValueAnimator.ofFloat(from, to).apply { addUpdateListener { radiusPx it.animatedValue as Float onBoundsChange(bounds) invalidateSelf() } setDuration(duration) start() } }注意这里调用onBoundsChange(bounds)是因为我们要重新构建 Path如果不变更 bounds 就不能触发系统重建圆角。这个小细节如果不加动画只会改成员变量的值路径不更新画出来完全没有变化。属性化和动画结合后我的那个状态 Drawable 就可以做非常细腻的反馈按压时不仅颜色变暗圆角还会轻微收窄松开后再回弹。这种体验如果没有属性集中管理的底子做起来会很零散。5.3 重复 inflate 的内存问题与复用姿势自定义 Drawable 的一个隐藏问题是每次从资源取都会新建对象。比如在RecyclerView里给每个 item 设置同一个drawable/bg_card_state如果不做缓存setBackgroundResource会让序列化的列表频繁创建 Drawable。排查方式是在inflate方法里打一个计数 log看看一次滑动列表会触发多少次。我自己测过一个 50 条数据的列表滑动到底能触发上百次 inflate。如果 Drawable 本身绘制简单还撑得住一旦内部持有复杂 Path、BitmapShader 或渐变内存和 CPU 开销就会很明显。复用姿势有两个层次。第一层普通场景用ContextCompat.getDrawable()并全局持有一个单例然后setBackground(drawable.mutate())第二层如果列表容量大且展示样式分散可以做一个LruCacheString, Drawablekey 由Config序列化生成拿到配置先查缓存命中就复用未命中再 new。我实际项目里选的是第二层因为后台下发皮肤很频繁没有缓存时每改一次 JSON整个列表背景都要重新 new。用 LruCache 后相同配置的 Drawable 只会创建一次后续全部复用。这个方案在内存和绘制性能上都稳定很多也更容易做单元测试——你可以把拿 drawable的逻辑抽出来单独验证缓存命中率。6. 从热搜词里看圈友最容易卡住的地方6.1 为什么总有人把自定义 Drawable和自定义 View混在一起我看相关搜索词里混着android进度条、android九宫格、android背景、android动态图标主题估计有不少人是搜自定义 Drawable结果误入到这个概念里的。这里明确区分一下进度条和九宫格这类通常承载交互和测量逻辑更适合自定义 View背景、描边、色块、动态图标这类纯绘制内容才适合交给自定义 Drawable。判断标准其实很简单这个东西需不需要接收触摸事件需不需要自己在onMeasure里算尺寸如果需要是 View 的活如果它只是别人画我我负责把颜色和形状画对那就是 Drawable 的活。我见过很多人非要用自定义 View 去画一个圆角背景结果白白陷入onDraw、onMeasure、invalidate的循环里这属于用错工具。另一个高频点是android studio 怎么设置中文、android sdk 安装这类环境问题跟自定义属性无关但很多人其实是卡在装了 IDE 但跑不通一个自定义资源上。我建议新同学不要一上来就试自定义 Drawable先在 res 里用系统 shape 跑通selector 背景再逐步替换成自定义标签这样排查范围会小很多。6.2 影响范围和性能的提醒我之前看到一个项目用自定义 Drawable 做九宫格解锁点的背景里面有渐变、有阴影、有模糊结果绘制性能很差。这里想强调一句自定义 Drawable 是性能敏感路径draw会被频繁调用尤其列表滚动时。我实际做过的优化集中在三块第一把所有 Paint 对象做成字段不要每次draw里 new第二Path、圆角矩形、阴影路径全部放到onBoundsChange重建不要在draw里计算第三能用 Canvas 的drawRoundRect就不要用 Bitmap 加模糊后者开销大得多。另外android 权限汇总、内存这些词提示我有些刚入门的同学在自定义资源时还会顺手纠结权限其实完全没必要。自定义 Drawable 不涉及任何权限它是纯 UI 层的绘制资源别被无关搜索带偏。6.3 建议的最小练习清单如果这篇文章你只带走一份行动建议我想应该是下面这个练习路径。它是我实际带人时用过的路线图按顺序做基本不会踩太多坑先用系统GradientDrawable在代码里实现一个圆角背景确认自己能写出cornerRadius、setStroke、setColor。把它换成自定义Drawable子类重写draw里面只画一个实心圆角矩形。增加一个declare-styleable给 Drawable 配radius和bgColor在 XML 里验证属性读取。增加 state 支持给另一个 Drawable 设置同样的资源故意不mutate()感受一下共享状态带来的串色问题。最后尝试用ValueAnimator做圆角过渡把它接到点击事件上。每一步都很小但每完成一步你对属性从 XML 到 Canvas这条链路都会多一层理解。我在实际项目里也是按这条思路把团队里那条大背景 Drawable接手过来的后来所有新模块都统一走这个方案再也没有出现过复制 shape 漏改一个角的问题。最后再提一句如果你们的 UI 规范经常调整请一定要把默认值集中在attrs.xml或Config.DEFAULT上否则默认值散落在代码各处改起来你会想砸键盘。
阅读完成 · 觉得有帮助?
咨询建站