如果需要绘制这样的效果咋办?
即左上角和右上角是圆角,其余的不是。
drawRoundRect()
做不到这个效果,只能用canvas.drawPath()
。
抽象一个Corners
类来表示四个角的圆角程度:
class Corners( var leftTopRx: Float = 0f, var leftTopRy: Float = 0f, var leftBottomRx: Float = 0f, var LeftBottomRy: Float = 0f, var rightTopRx: Float = 0f, var rightTopRy: Float = 0f, var rightBottomRx: Float = 0f, var rightBottomRy: Float = 0f ) { // 将 8 个表示圆角的属性,按照 Android api 需要的顺序组织成数组 val radii: FloatArray get() = floatArrayOf( leftTopRx, leftTopRy, rightTopRx, rightTopRy, rightBottomRx, rightBottomRy, leftBottomRx, LeftBottomRy ) }
其中一共有 8 个属性,分为四对分别表示左上,右上,左下,右下四个角。
Shape
会持有一个Corners
实例:
class Shape { var color: String? = null var radius: Float = 0f // 绘制的路径 internal var path: Path? = null var corners: Corners? = null set(value) { field = value // 当 corners 被赋值时构建 Path 实例 path = Path() } }
再需要改写一下Text
中绘制背景的方法:
class Text : OneViewGroup.Drawable() { var shapePaint: Paint? = null var shape: Shape? = null set(value) { field = value shapePaint = Paint().apply { isAntiAlias = true style = Paint.Style.FILL color = Color.parseColor(value?.color) } } private fun drawBackground(canvas: Canvas?) { if (shape == null) return val _shape = shape!! // 如果设置了 radius 表示四个角都是圆角 if (_shape.radius != 0f) { canvas?.drawRoundRect(0f, 0f, measuredWidth, measuredHeight, _shape.radius, _shape.radius, shapePaint!!) } // 如果设置了 corners 表示有些角是圆角 else if (_shape.corners != null) { // 根据 radii 属性构建 path _shape.path!!.apply { addRoundRect( RectF(0f, 0f, measuredWidth, measuredHeight), _shape.corners!!.radii, Path.Direction.CCW ) } // 绘制 path canvas?.drawPath(_shape.path!!, shapePaint!!) } } }
还是借助于 Kotlin 的语法糖,让构建Corners
变得更加可读:
fun Shape.corners(init: Shape.Corners.() -> Unit): Corners = Corners().apply(init)
然后就可以像这样构建上面截图中的形状了:
OneViewGroup { layout_width = match_parent layout_height = match_parent text { id = "title" width = 100 text = "title" textSize = 40f textColor = "#000000" shape = shape { color = "#ffffff" corners = corners{ leftTopRx = 30f leftTopRy = 30f rightTopRx = 30f rightTopRy = 30f } } } }
绘制图片
图片的加载就要复杂很多。如何异步获取图片?如何绘制图片?即使解决了这两个问题,如果没有办法做到局部刷新,那当图片显示时,布局中的文字也会跟着闪一下。(欢迎有思路的小伙伴留言)
又没看过ImageView
的源码,自己也很难较好地处理这些问题。那就先退一步,图片依然采用ImageView
控件展示。但这样的话就产生了一个新的问题:如何确定 ImageView
控件和 OneViewGroup
控件中绘制文字的相对位置?
控件与控件之间的相对位置很好确定,但如何确定一个控件和另一个控件中绘制内容的相对位置?
OneViewGroup
中的绘制内容被抽象为一个Drawable
对象,该对象用一组属性来标识和另一个Drawable
对象的相对位置。如果ImageView
也是一个Drawable
对象,那就能很方便的确定它和绘制文字的相对位置了!
怎么把一个类装扮成另一个类?—— 多重继承
但 Kotlin 不支持多重继承,所以只能把抽象类Drawable
重构成接口:
interface Drawable { // 测量后的宽高 var layoutMeasuredWidth: Int var layoutMeasuredHeight: Int // 布局后的上下左右边框 var layoutLeft: Int var layoutRight: Int var layoutTop: Int var layoutBottom: Int // 唯一标识 id var layoutId: Int // 相对布局属性 var leftPercent: Float var topPercent: Float var startToStartOf: Int var startToEndOf: Int var endToEndOf: Int var endToStartOf: Int var topToTopOf: Int var topToBottomOf: Int var bottomToTopOf: Int var bottomToBottomOf: Int var centerHorizontalOf: Int var centerVerticalOf: Int // 记录业务层设置的宽高 var layoutWidth: Int var layoutHeight: Int // 内边距 var layoutPaddingStart: Int var layoutPaddingEnd: Int var layoutPaddingTop: Int var layoutPaddingBottom: Int // 外边距 var layoutTopMargin: Int var layoutBottomMargin: Int var layoutLeftMargin: Int var layoutRightMargin: Int // 布局的终点:确定上下左右 fun setRect(left: Int, top: Int, right: Int, bottom: Int) { this.layoutLeft = left this.layoutRight = right this.layoutTop = top this.layoutBottom = bottom } // 测量的终点:确定宽高 fun setDimension(width: Int, height: Int) { this.layoutMeasuredWidth = width this.layoutMeasuredHeight = height } // 抽象的 测量 布局 绘制 , 供子类实现多态 fun doMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) fun doLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) fun doDraw(canvas: Canvas?) }
然后新建一个类,即继承了ImageView
又实现了Drawable
接口:
class ImageDrawable @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : AppCompatImageView(context, attrs, defStyleAttr), OneViewGroup.Drawable { override var leftPercent: Float = -1f override var topPercent: Float = -1f override var startToStartOf: Int = -1 override var startToEndOf: Int = -1 override var endToEndOf: Int = -1 override var endToStartOf: Int = -1 override var topToTopOf: Int = -1 override var topToBottomOf: Int = -1 override var bottomToTopOf: Int = -1 override var bottomToBottomOf: Int = -1 override var centerHorizontalOf: Int = -1 override var centerVerticalOf: Int = -1 override var layoutWidth: Int = 0 override var layoutHeight: Int = 0 override var layoutMeasuredWidth: Int = 0 get() = measuredWidth override var layoutMeasuredHeight: Int = 0 get() = measuredHeight override var layoutLeft: Int = 0 get() = left override var layoutRight: Int = 0 get() = right override var layoutTop: Int = 0 get() = top override var layoutBottom: Int = 0 get() = bottom override var layoutId: Int = 0 get() = id override var layoutPaddingStart: Int = 0 get() = paddingStart override var layoutPaddingEnd: Int = 0 get() = paddingEnd override var layoutPaddingTop: Int = 0 get() = paddingTop override var layoutPaddingBottom: Int = 0 get() = paddingBottom override var layoutTopMargin: Int = 0 get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0 override var layoutBottomMargin: Int = 0 get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 0 override var layoutLeftMargin: Int = 0 get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.leftMargin ?: 0 override var layoutRightMargin: Int = 0 get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.rightMargin ?: 0 override fun doMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // 在 View 体系中测量 } override fun doLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { // 在 View 体系中布局 layout(left, top, right, bottom) } override fun doDraw(canvas: Canvas?) { // 在 View 体系中绘制 } }
接口中的属性都是抽象的,在子类中如果不给它指定一个初始值,就要添加set()
和get()
方法。
ImageDrawable
的测量宽高,上下左右,内外边距的获取都委托给了View
体系中的值,并且在布局自己的时候调用了View.layout()
,以确定自己和其他Drawable
的相对位置。相对位置的计算在OneViewGroup.onLayout()
中完成:
class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {// 重构为 ViewGroup private val drawables = mutableListOf<Drawable>() override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { val parentWidth = right - left val parentHeight = bottom - top // 依次计算每个 Drawable 的相对位置 drawables.forEach { val left = getChildLeft(it, parentWidth) val top = getChildTop(it, parentHeight) it.doLayout(changed, left, top, left + it.layoutMeasuredWidth, top + it.layoutMeasuredHeight) } } }
为了让OneViewGroup
除了容纳Drawable
之外,还能容纳View
,所以不得不将其继承自ViewGroup
。
OneViewGroup
必须得测量自己的孩子ImageDrawable
,否则孩子就没有宽高数据:
class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) { private val drawables = mutableListOf<Drawable>() override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // 测量 ImageDrawable measureChildren(widthMeasureSpec,heightMeasureSpec) super.onMeasure(widthMeasureSpec, heightMeasureSpec) // 测量其他 Drawable drawables.forEach { it.doMeasure(widthMeasureSpec, heightMeasureSpec) } } }
用 Kotlin 语法糖构建 ImageDrawable:
inline fun OneViewGroup.image(init: ImageDrawable.() -> Unit) = ImageDrawable(context).apply(init).also { addView(it) // 添加为 OneViewGroup 的子控件 addDrawable(it) // 添加为 OneViewGroup 的子 Drawable }
就像多重继承的语义一样,ImageDrawable
有双重身份,它既是OneViewGroup
的子控件,又是OneViewGroup
的子Drawable
。
ImageDrawable
的测量、布局、绘制都依赖于 View 体系,唯独布局的参数依赖于其他的Drawable
。
然后就可以像这样图文混排了:
OneViewGroup { layout_width = match_parent layout_height = match_parent text { id = "title" width = 100 text = "title" textSize = 40f textColor = "#000000" } image { id = "avatar" layout_width = 40 layout_height = 40 scaleType = fit_xy startToEndOf = "title" // 位于 title 的后面 centerVerticalOf = "title" // 和 title 垂直居中 } }
因为没有将 ImageView 去掉,所以这是一个曲线救国的方案。但从另一个角度看,这也是将OneViewGroup
和任何其他控件组合使用的通用方案。
点击事件
原先可以通过View.setOnClickListener()
分别为子控件设置点击事件。
OneViewGroup
把子控件抽象为Drawable
后该如何处理点击事件?
更好的 RecyclerView 表项子控件点击监听器中提到一种解决方案,即判断触点坐标是否和子控件有交集。可以沿用到OneViewGroup
上:
先为Drawable
新增一个表示其矩形区域的属性rect
:
interface Drawable { var layoutLeft: Int var layoutRight: Int var layoutTop: Int var layoutBottom: Int // 用上下左右构建矩形对象 val rect: Rect get() = Rect(layoutLeft, layoutTop, layoutRight, layoutBottom) ... }
再在OneViewGroup
中拦截触摸事件:
class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) { private val drawables = mutableListOf<Drawable>() // 手势监听器, 用于将触摸事件解析成点击事件 private var gestureDetector: GestureDetector? = null // 设置 Drawable 点击监听器 fun setOnItemClickListener(onItemClickListener: (String) -> Unit) { // 构造手势监听器 gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener { override fun onShowPress(e: MotionEvent?) { } // 当单击事件发生时 override fun onSingleTapUp(e: MotionEvent?): Boolean { e?.let { // 若在触摸点找到对应 Drawable 则回调点击事件 findDrawableUnder(e.x, e.y)?.let { onItemClickListener.invoke(it.layoutIdString) } } return true } // 必须返回 true 表示处理 ACTION_DOWN 事件, 否则后续事件不会传递到 OneViewGroup override fun onDown(e: MotionEvent?): Boolean { return true } override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean { return false } override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean { return false } override fun onLongPress(e: MotionEvent?) { } }) } // 根据坐标查找 Drawable 对象 private fun findDrawableUnder(x: Float, y: Float): Drawable? { // 遍历所有 Drawable ,返回矩形区域内包含触点的 Drawable drawables.forEach { if (it.rect.contains(x.toInt(), y.toInt())) { return it } } return null } // 将触摸事件传递给手势监听器 override fun onTouchEvent(event: MotionEvent?): Boolean { return gestureDetector?.onTouchEvent(event) ?: super.onTouchEvent(event) } }
然后就可以像这样为OneViewGroup
设置子 Drawable 的点击事件了:
OneViewGroup { layout_width = match_parent layout_height = match_parent text { id = "title" width = 100 text = "title" textSize = 40f textColor = "#000000" } setOnItemClickListener { id-> when (id) { "title" -> { Log.v("test", "title is clicked") } } } }
Talk is cheap, show me the code
OneViewGroup
源码可以点击这里
- Demo 源码地址可以点击这里 (其中的
RecyclerViewPerformanceActivity
)
最后附上,文章开头截图布局用OneViewGroup
的重构版本(经重构, 其中和 OneViewGroup 有关的属性都以 drawable 开头):
class OneRankProxy : VarietyAdapter2.Proxy<BetterRank, OneRankViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val itemView = parent.context.run { LinearLayout { layout_id = "container" layout_width = match_parent layout_height = wrap_content orientation = vertical margin_start = 20 margin_end = 20 padding_bottom = 16 shape = shape { corner_radius = 20 solid_color = "#ffffff" } OneViewGroup { // 用 OneViewGroup 重构的表头 layout_id = "one" layout_width = match_parent layout_height = 60 shape = shape { corner_radii = intArrayOf(20, 20, 20, 20, 0, 0, 0, 0) solid_color = "#ffffff" } text { drawable_layout_id = "tvTitle" drawable_max_width = 60 drawable_text_size = 16f drawable_text_color = "#3F4658" drawable_start_to_start_of = parent_id drawable_left_margin = 20 topPercent = 0.23f } text { drawable_layout_id = "tvRank" drawable_max_width = 60 drawable_text_size = 11f drawable_text_color = "#9DA4AD" leftPercent = 0.06f topPercent = 0.78f } text { drawable_layout_id = "tvName" drawable_max_width = 60 drawable_text_size = 11f drawable_text_color = "#9DA4AD" leftPercent = 0.18f topPercent = 0.78f } text { drawable_layout_id = "tvCount" drawable_max_width = 100 drawable_text_size = 11f drawable_text_color = "#9DA4AD" drawable_end_to_end_of = parent_id drawable_right_margin = 20 topPercent = 0.78f } } } } return OneRankViewHolder(itemView) } override fun onBindViewHolder(holder: OneRankViewHolder, data: BetterRank, index: Int, action: ((Any?) -> Unit)?) { holder.tvTitle?.text = data.title holder.tvCount?.text = data.countColumn holder.tvRank?.text = data.rankColumn holder.tvName?.text = data.nameColumn holder.container?.apply { // 遍历主播数据, 动态构建每个主播的布局 data.ranks.forEachIndexed { index, rank -> OneViewGroup { layout_width = match_parent layout_height = 35 background_color = "#ffffff" text { drawable_layout_id = "tvRank" drawable_layout_width = 18 drawable_text_size = 14f drawable_text_color = "#9DA4AD" leftPercent = 0.08f drawable_center_vertical_of = parent_id text = rank.rank.toString() } image { layout_id = "ivAvatar" layout_width = 20 layout_height = 20 scaleType = scale_center_crop drawable_center_vertical_of = parent_id leftPercent = 0.15f load(rank.avatarUrl) } text { drawable_layout_id = "tvName" drawable_max_width = 200 drawable_text_size = 11f drawable_text_color = "#3F4658" drawable_gravity = gravity_center drawable_max_lines = 1 drawable_start_to_end_of = "ivAvatar" drawable_top_to_top_of = "ivAvatar" drawable_left_margin = 5 drawable_text = rank.name } text { drawable_layout_id = "tvTag" drawable_max_width = 100 drawable_text_size = 8f drawable_text_color = "#ffffff" drawable_gravity = gravity_center drawable_padding_top = 1 drawable_padding_bottom = 1 drawable_padding_start = 2 drawable_padding_end = 2 drawable_text = "save" drawable_shape = drawableShape { radius = 4f color = "#8cc8c8c8" } drawable_start_to_start_of = "tvName" drawable_top_to_bottom_of = "tvName" } image { layout_id = "ivLevel" layout_width = 10 layout_height = 10 scaleType = scale_fit_xy drawable_center_vertical_of = "tvName" drawable_start_to_end_of = "tvName" drawable_left_margin = 5 load(rank.levelUrl) } text { drawable_layout_id = "tvLevel" drawable_max_width = 200 drawable_text_size = 7f drawable_text_color = "#ffffff" drawable_gravity = gravity_center drawable_padding_start = 2 drawable_padding_end = 2 drawable_shape = drawableShape { color = "#FFC39E" radius = 20f } drawable_center_vertical_of = "tvName" drawable_start_to_end_of = "ivLevel" drawable_left_margin = 5 drawable_text = rank.level.toString() } text { drawable_layout_id = "tvCount" drawable_max_width = 200 drawable_text_size = 14f drawable_text_color ="#3F4658" drawable_gravity = gravity_center drawable_center_vertical_of = parent_id drawable_end_to_end_of = parent_id drawable_right_margin = 20 drawable_text = rank.count.formatNums() } } } } } } class OneRankViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val oneViewGroup = itemView.find<OneViewGroup>("one") val container = itemView.find<LinearLayout>("container") val tvTitle = oneViewGroup?.findDrawable<Text>("tvTitle") val tvRank = oneViewGroup?.findDrawable<Text>("tvRank") val tvName = oneViewGroup?.findDrawable<Text>("tvName") val tvCount = oneViewGroup?.findDrawable<Text>("tvCount") }
代码中沿用了上一篇中提到的将首屏的多个表项合并成一个表项的方案,动态地为每个主播构建表项并添加到表项容器中。
其中OneViewGroup.findDrawable()
的作用类似于View.findViewById()
:
class OneViewGroup @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) { // 保存所有 Drawable 和其 id 对应关系的 map private val drawableMap = HashMap<Int, Drawable>() // 按插入顺序保存所有 Drawable private val drawables = mutableListOf<Drawable>() // 插入 Drawable 到 OneViewGroup fun addDrawable(drawable: Drawable) { drawables.add(drawable) drawableMap[drawable.layoutId] = drawable } // 按 id 查找 Drawable (将 String 类型 id 转换成 Int 并在 map 中查找) fun <T> findDrawable(id: String):T? = drawableMap[id.toLayoutId()] as? T }
重构完毕,运行 Demo 看下耗时:
measure + layout=75, unknown delay=33, anim=0, touch=0, draw=9, total=121 measure + layout=0, unknown delay=0, anim=0, touch=0, draw=0, total=3 measure + layout=0, unknown delay=0, anim=0, touch=0, draw=0, total=7
再援引此次优化前的数据做对比:
measure + layout=170, unknown delay=41, anim=0, touch=0, draw=18, total= 200 measure + layout=0, unknown delay=250, anim=1, touch=0, draw=0, total=289 measure + layout=4, unknown delay=4, anim=0, touch=0, draw=2, total=13 measure + layout=4, unknown delay=0, anim=0, touch=0, draw=1, total=13
measure + layout 从 170 ms 骤降到 75 ms。
虽然绘制图片还需要借助于 ImageView,但光凭将所有的 TextView 汇聚成一个控件展示就有如此大的性能提升。后续文章还会继续优化,将图片加载补上!
经过了 5 次优化,布局加载时间从原先的 370 ms 降到 75 ms,优化手段总结如下:
- 用动态构建布局取代 xml,蒸发 IO 和 反射的性能损耗,缩短构建表项布局耗时。
- 替换表项根布局,由更简单的
PercentLayout
取代ConstraintLayout
,以缩短 measure + layout 时间。
- 使用协程 + Glide 同步加载方法,以缩减加载图片耗时。
- 将列表首屏显示的表项合并成一个新的表项类型,以缩短填充表项耗时。
- 用
OneViewGroup
代替PercentLayout
,大幅减少表项的控件数量。
推荐阅读
RecyclerView 系列文章目录如下: