一年前,用 Java 写了一个高可扩展选择按钮库。单个控件实现单选、多选、菜单选,且选择模式可动态扩展。
一年后,一个新的需求要用到这个库,项目代码已经全 Kotlin 化,强硬地插入一些 Java 代码显得格格不入,Java 冗余的语法也降低了代码的可读性,于是决定用 Kotlin 重构一番,在重构的时候也增加了一些新的功能。这一篇分享下重构的过程。
选择按钮的可扩展性主要体现在 4 个方面:
- 选项按钮布局可扩展
- 选项按钮样式可扩展
- 选中样式可扩展
- 选择模式可扩展
扩展布局
原生的单选按钮通过RadioButton
+ RadioGroup
实现,他们在布局上必须是父子关系,而RadioGroup
继承自LinearLayout
,遂单选按钮只能是横向或纵向铺开,这限制的单选按钮布局的多样性,比如下面这种三角布局就难以用原生控件实现:
为了突破这个限制,单选按钮不再隶属于一个父控件,它们各自独立,可以在布局文件中任意排列,图中 Activity 的布局文件如下(伪码):
AgeSelector
表示一个具体的按钮,本例中它是一个“上面是图片,下面是文字”的单选按钮。它继承自抽象的Selector
。
扩展样式
从业务上讲,Selector
长什么样是一个频繁的变化点,遂把“构建按钮样式”这个行为设计成Selector
的抽象函数onCreateView()
,供子类重写以实现扩展。
public abstract class Selector extends FrameLayout{
public Selector(Context context) {
super(context);
initView(context, null);
}
private void initView(Context context, AttributeSet attrs) {
// 初始化按钮算法框架
View view = onCreateView();
LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
this.addView(view, params);
}
// 如何构建按钮视图,延迟到子类实现
protected abstract View onCreateView();
}
Selector
继承自FrameLayout
,实例化时会构建按钮视图,并把该视图作为孩子添加到自己的布局中。子类通过重写onCreateView()
扩展按钮样式:
public class AgeSelector extends Selector {
@Override
protected View onCreateView() {
View view = LayoutInflater.from(this.getContext()).inflate(R.layout.age_selector, null);
return view;
}
}
AgeSelector
的样式被定义在 xml 中。
按钮被选中之后的样式,也是一个业务上的变化点,用同样的思路可以将Selector
这样设计:
// 抽象按钮实现点击事件
public abstract class Selector extends FrameLayout implements View.OnClickListener {
public Selector(Context context) {
super(context);
initView(context, null);
}
private void initView(Context context, AttributeSet attrs) {
View view = onCreateView();
LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
this.addView(view, params);
// 设置点击事件
this.setOnClickListener(this);
}
@Override
public void onClick(View v) {
// 原有选中状态
boolean isSelect = this.isSelected();
// 反转选中状态
this.setSelected(!isSelect);
// 展示选中状态切换效果
onSwitchSelected(!isSelect);
return !isSelect;
}
// 按钮选中状态变化时的效果延迟到子类实现
protected abstract void onSwitchSelected(boolean isSelect);
}
将选中按钮状态变化的效果抽象成一个算法,延迟到子类实现:
public class AgeSelector extends Selector {
// 单选按钮选中背景
private ImageView ivSelector;
private ValueAnimator valueAnimator;
@Override
protected View onCreateView() {
View view = LayoutInflater.from(this.getContext()).inflate(R.layout.selector, null);
ivSelector = view.findViewById(R.id.iv_selector);
return view;
}
@Override
protected void onSwitchSelected(boolean isSelect) {
if (isSelect) {
playSelectedAnimation();
} else {
playUnselectedAnimation();
}
}
// 播放取消选中动画
private void playUnselectedAnimation() {
if (ivSelector == null) {
return;
}
if (valueAnimator != null) {
valueAnimator.reverse();
}
}
// 播放选中动画
private void playSelectedAnimation() {
if (ivSelector == null) {
return;
}
valueAnimator = ValueAnimator.ofInt(0, 255);
valueAnimator.setDuration(800);
valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
ivSelector.setAlpha((int) animation.getAnimatedValue());
}
});
valueAnimator.start();
}
}
AgeSelector
在选中状态变化时定义了一个背景色渐变动画。
函数类型变量代替继承
在抽象按钮控件中,“按钮样式”和“按钮选中状态变换”被抽象成算法,算法的实现推迟到子类,用这样的方式,扩展按钮的样式和行为。
继承的一个后果就是类数量的膨胀,有没有什么办法不用继承就能扩展按钮样式和行为?
可以把构建按钮样式的成员方法onCreateView()
设计成一个View
类型的成员变量,通过设值函数就可以改变其值。但按钮选中状态变换是一种行为,在 Java 中行为的表达方式只有方法,所以只能通过继承来改变行为。
Kotlin 中有一种类型叫函数类型
,运用这种类型,可以将行为保存在变量中:
class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
// 选中状态变换时的行为,它是一个lambda
var onSelectChange: ((Selector, Boolean) -> Unit)? = null
// 按钮是否被选中
var isSelecting: Boolean = false
// 按钮样式
var contentView: View? = null
set(value) {
field = value
value?.let {
// 当按钮样式被赋值时,将其添加到 Selector,作为子视图
addView(it, LayoutParams(MATCH_PARENT, MATCH_PARENT))
}
}
// 变更按钮选中状态
fun setSelect(select: Boolean) {
showSelectEffect(select)
}
// 展示选中状态变换效果
fun showSelectEffect(select: Boolean) {
// 如果选中状态发生变化,则执行选中状态变换行为
if (isSelecting != select) {
onSelectChange?.invoke(this, select)
}
isSelecting = select
}
}
选中样式和行为都被抽象为一个成员变量,只需赋值就可以动态扩展,不再需要继承:
// 构建按钮实例
val selector = Selector {
layout_width = 90
layout_height = 50
contentView = ageSelectorView
onSelectChange = onAgeSelectStateChange
}
// 构建按钮样式
private val ageSelectorView: ConstraintLayout
get() = ConstraintLayout {
layout_width = match_parent
layout_height = match_parent
// 按钮选中背景
ImageView {
layout_id = "ivSelector"
layout_width = 0
layout_height = 30
top_toTopOf = "ivContent"
bottom_toBottomOf = "ivContent"
start_toStartOf = "ivContent"
end_toEndOf = "ivContent"
background_res = R.drawable.age_selctor_shape
alpha = 0f
}
// 按钮图片
ImageView {
layout_id = "ivContent"
layout_width = match_parent
layout_height = 30
center_horizontal = true
src = R.drawable.man
top_toTopOf = "ivSelector"
}
// 按钮文字
TextView {
layout_id = "tvTitle"
layout_width = match_parent
layout_height = wrap_content
bottom_toBottomOf = parent_id
text = "man"
gravity = gravity_center_horizontal
}
}
// 按钮选中行为
private val onAgeSelectStateChange = { selector: Selector, select: Boolean ->
// 根据选中状态变换按钮选中背景
selector.find("ivSelector")?.alpha = if (select) 1f else 0f
}
在构建Selector
实例的同时,指定了它的样式和选中变换效果(其中运用到 DSL 简化构建代码,详细介绍可以点击这里)
扩展选中模式
单个Selector
已经可以很好的工作,但要让多个Selector
形成一种单选或多选的模式,还需要一个管理器来同步它们之间的选中状态,Java 版本的管理器如下:
public class SelectorGroup {
// 选中模式
public interface ChoiceAction {
void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener);
}
// 选中状态监听器
public interface StateListener {
void onStateChange(String groupTag, String tag, boolean isSelected);
}
// 选中模式实例
private ChoiceAction choiceMode;
// 选中状态监听器实例
private StateListener onStateChangeListener;
// 用于上一次选中的按钮的 Map
private HashMap selectorMap = new HashMap<>();
// 注入选中模式
public void setChoiceMode(ChoiceAction choiceMode) {
this.choiceMode = choiceMode;
}
// 设置选中状态监听器
public void setStateListener(StateListener onStateChangeListener) {
this.onStateChangeListener = onStateChangeListener;
}
// 获取之前选中的按钮
public Selector getPreSelector(String groupTag) {
return selectorMap.get(groupTag);
}
// 变更指定按钮的选中状态
public void setSelected(boolean selected, Selector selector) {
if (selector == null) {
return;
}
// 记忆选中的按钮
if (selected) {
selectorMap.put(selector.getGroupTag(), selector);
}
// 触发按钮选中样式变更
selector.setSelected(selected);
if (onStateChangeListener != null) {
onStateChangeListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), selected);
}
}
// 取消之前选中的按钮
private void cancelPreSelector(Selector selector) {
// 每个按钮有一个组标识,用于标识它属于哪个组
String groupTag = selector.getGroupTag();
// 获取该组中之前选中的按钮并将其取消选中
Selector preSelector = getPreSelector(groupTag);
if (preSelector != null) {
preSelector.setSelected(false);
}
}
// 当按钮被点击时,会将点击事件通过该函数传递给 SelectorGroup
void onSelectorClick(Selector selector) {
// 将点击事件委托给选择模式来处理
if (choiceMode != null) {
choiceMode.onChoose(selector, this, onStateChangeListener);
}
// 将选中的按钮记录在 Map 中
selectorMap.put(selector.getGroupTag(), selector);
}
// 预定的单选模式
public class SingleAction implements ChoiceAction {
@Override
public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
cancelPreSelector(selector);
setSelected(true, selector);
}
}
// 预定的多选模式
public class MultipleAction implements ChoiceAction {
@Override
public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
boolean isSelected = selector.isSelected();
setSelected(!isSelected, selector);
}
}
}
SelectorGroup
将选中模式抽象成接口ChoiceAction
,以便通过setChoiceMode()
动态地扩展。
SelectorGroup
还预定了两种选中模式:单选和多选。
- 单选可以理解为:点击按钮时,选中当前的并取消选中之前的。
- 多选可以理解为:点击按钮时无条件地反转当前选中状态。
Selector
会持有SelectorGroup
实例,以便将按钮点击事件传递给它统一管理:
public abstract class Selector extends FrameLayout implements View.OnClickListener {
// 按钮组标签
private String groupTag;
// 按钮管理器
private SelectorGroup selectorGroup;
// 设置组标签和管理器
public Selector setGroup(String groupTag, SelectorGroup selectorGroup) {
this.selectorGroup = selectorGroup;
this.groupTag = groupTag;
return this;
}
@Override
public void onClick(View v) {
// 将点击事件传递给管理器
if (selectorGroup != null) {
selectorGroup.onSelectorClick(this);
}
}
}
然后就可以像这样实现单选:
SelectorGroup singleGroup = new SelectorGroup();
singleGroup.setChoiceMode(SelectorGroup.SingleAction);
selector1.setGroup("single", singleGroup);
selector2.setGroup("single", singleGroup);
selector3.setGroup("single", singleGroup);
也可以像这样实现菜单选:
SelectorGroup orderGroup = new SelectorGroup();
orderGroup.setStateListener(new OrderChoiceListener());
orderGroup.setChoiceMode(new OderChoiceMode());
// 前菜组
selector1_1.setGroup("starters", orderGroup);
selector1_2.setGroup("starters", orderGroup);
// 主食组
selector2_1.setGroup("main", orderGroup);
selector2_2.setGroup("main", orderGroup);
// 汤组
selector3_1.setGroup("soup", orderGroup);
selector3_2.setGroup("soup", orderGroup);
// 菜单选:组内单选,跨组多选
private class OderChoiceMode implements SelectorGroup.ChoiceAction {
@Override
public void onChoose(Selector selector, SelectorGroup selectorGroup, SelectorGroup.StateListener stateListener) {
cancelPreSelector(selector, selectorGroup);
selector.setSelected(true);
if (stateListener != null) {
stateListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), true);
}
}
// 取消之前选中的同组按钮
private void cancelPreSelector(Selector selector, SelectorGroup selectorGroup) {
Selector preSelector = selectorGroup.getPreSelector(selector.getGroupTag());
if (preSelector != null) {
preSelector.setSelected(false);
}
}
}
将 Java 中的接口改成lambda
,存储在函数类型的变量中,这样可省去注入函数,Kotlin 版本的SelectorGroup
如下:
class SelectorGroup {
companion object {
// 单选模式的静态实现
var MODE_SINGLE = { selectorGroup: SelectorGroup, selector: Selector ->
selectorGroup.run {
// 查找同组中之前选中的,取消其选中状态
findLast(selector.groupTag)?.let { setSelected(it, false) }
// 选中当前按钮
setSelected(selector, true)
}
}
// 多选模式的静态实现
var MODE_MULTIPLE = { selectorGroup: SelectorGroup, selector: Selector ->
selectorGroup.setSelected(selector, !selector.isSelecting)
}
}
// 所有当前选中按钮的有序集合(有些场景需要记忆按钮选中的顺序)
private var selectorMap = LinkedHashMap>()
// 当前的选中模式(函数类型)
var choiceMode: ((SelectorGroup, Selector) -> Unit)? = null
// 选中状态变更监听器, 将所有选中按钮回调出去(函数类型)
var selectChangeListener: ((List/*selected set*/) -> Unit)? = null
// Selector 将点击事件通过这个方法传递给 SelectorGroup
fun onSelectorClick(selector: Selector) {
// 将点击事件委托给选中模式
choiceMode?.invoke(this, selector)
}
// 查找指定组的所有选中按钮
fun find(groupTag: String) = selectorMap[groupTag]
// 根据组标签查找该组中上一次被选中的按钮
fun findLast(groupTag: String) = find(groupTag)?.takeUnless { it.isNullOrEmpty() }?.last()
// 变更指定按钮的选中状态
fun setSelected(selector: Selector, select: Boolean) {
// 或新建,或删除,或追加选中的按钮到Map中
if (select) {
selectorMap[selector.groupTag]?.also { it.add(selector) } ?: also { selectorMap[selector.groupTag] = mutableSetOf(selector) }
} else {
selectorMap[selector.groupTag]?.also { it.remove(selector) }
}
// 展示选中效果
selector.showSelectEffect(select)
// 触发选中状态监听器
if (select) {
selectChangeListener?.invoke(selectorMap.flatMap { it.value })
}
}
// 释放持有的选中控件
fun clear() {
selectorMap.clear()
}
}
然后就可以像这样使用SelectorGroup
:
// 构建管理器
val singleGroup = SelectorGroup().apply {
choiceMode = SelectorGroup.MODE_SINGLE
selectChangeListener = { selectors: List->
// 在这里可以拿到选中的所有按钮
}
}
// 构建单选按钮1
Selector {
tag = "old-man"
group = singleGroup
groupTag = "age"
layout_width = 90
layout_height = 50
contentView = ageSelectorView
}
// 构建单选按钮2
Selector {
tag = "young-man"
group = singleGroup
groupTag = "age"
layout_width = 90
layout_height = 50
contentView = ageSelectorView
}
构建的两个按钮拥有相同的groupTag
和SelectorGroup
,所以他们属于同一组并且是单选模式。
动态绑定数据
项目中一个按钮通常对应于一个“数据”,比如下图这种场景:
图中的分组数据和按钮数据都由服务器返回。点击创建组队时,希望在selectChangeListener
中拿到每个选项的 ID。那如何为Selector
绑定数据?
当然可以通过继承,在Selector
子类中添加一个具体的业务数据类型来实现。但有没有更通用的方案?
ViewModel
中设计了一种为其动态扩展属性的方法,将它应用在Selector
中(详情可移步读源码长知识 | 动态扩展类并绑定生命周期的新方式)
class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
// 存放业务数据的容器
private var tags = HashMap()
// 获取业务数据(重载取值运算符)
operator fun get(key: Key): T? = (tags.getOrElse(key, { null })) as T
// 添加业务数据(重载设值运算符)
operator fun set(key: Key, closeable: Closeable) {
tags[key] = closeable
}
// 清除所有业务数据
private fun clear() {
group?.clear()
tags.forEach { entry ->
closeWithException(entry.value)
}
}
// 当控件与窗口脱钩时,清理业务数据
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
clear()
}
// 清除单个业务数据
private fun closeWithException(closable: Closeable?) {
try {
closable?.close()
} catch (e: Exception) {
}
}
// 业务数据的键
interface Key
}
为Selector
新增一个Map
类型的成员用于存放业务数据,业务数据被声明为Closeable
的子类型,目的是将各式各样清理资源的行为抽象为close()
方法,Selector
重写了onDetachedFromWindow()
且会遍历每个业务数据并调用它们的close()
,即当它生命周期结束时,释放业务数据资源。
Selector
也重载了设值和取值这两个运算符,以简化业访问业务数据的代码:
// 游戏属性实体类
data class GameAttr( var name: String, var id: String ): Closeable {
override fun close() {
name = null
id = null
}
}
// 构建游戏属性实例
val attr = GameAttr("黄金", "id-298")
// 和游戏属性实体配对的键
val key = object : Selector.Key {}
// 构建选项组
val gameSelectorGroup by lazy {
SelectorGroup().apply {
// 选择模式(省略)
choiceMode = { selectorGroup, selector -> ... }
// 选中回调
selectChangeListener = { selecteds ->
// 遍历所有选中的选项
selecteds.forEach { s ->
// 访问与每个选项绑定的游戏属性(用到取值运算符)
Log.v("test","${s[key].name} is selected")
}
}
}
}
// 构建选项
Selector {
tag = attr.name
groupTag = "匹配段位"
group = gameSelectorGroup
layout_width = 70
layout_height = 32
// 绑定游戏属性(用到设值运算符)
this[key] = attr
}
因为重载了运算符,所以绑定和获取游戏属性的代码都更加简短。
用泛型就一定要强转?
绑定给 Selector
的数据被设计为泛型,业务层只有强转成具体类型才能使用,有什么办法可以不要在业务层强转?
CoroutineContext
的键就携带了类型信息:
public interface CoroutineContext {
public interface Key
public operator fun get(key: Key): E?
}
而且每一个CoroutineContext
的具体子类型都对应一个静态的键实例:
public interface Job : CoroutineContext.Element {
public companion object Key : CoroutineContext.Key {}
}
这样,不需要强转就能获得具体子类型:
coroutineContext[Job]//返回值为 Job 而不是 CoroutineContext
模仿CoroutineContext
,业务Selector
的键设计了一个带泛型的接口:
interface Key
在为Selector
绑定数据时需要先构建“键实例”:
val key = object : Selector.Key {}
传入的键带有类型信息,可以在取值方法中提前完成强转再返回给业务层使用:
// 值的具体类型被参数 key 指定,强转之后再返回给业务层
operator fun get(key: Key): T? = (tags.getOrElse(key, { null })) as T
借助于 DSL 根据数据动态地构建选择按钮就变得很轻松,上一幅 Gif 展示的界面代码如下:
// 游戏属性集合实体类
data class GameAttrs(
var title: String?,// 选项组标题
var attrs: List? // 选项组内容
)
// 简化的单个游戏属性实体类(它会被绑定到Selector)
data class GameAttrName(
var name: String?
) : Closeable {
override fun close() {
name = null
}
}
这是两个 Demo 中用到的数据实体类,真实项目中他们应该是服务器返回的,简单起见,本地模拟一些数据:
val gameAttrs = listOf(
GameAttrs(
"大区", listOf(
GameAttrName("微信"),
GameAttrName("QQ")
)
),
GameAttrs(
"模式", listOf(
GameAttrName("排位赛"),
GameAttrName("普通模式"),
GameAttrName("娱乐模式"),
GameAttrName("游戏交流")
)
),
GameAttrs(
"匹配段位", listOf(
GameAttrName("青铜白银"),
GameAttrName("黄金"),
GameAttrName("铂金"),
GameAttrName("钻石"),
GameAttrName("星耀"),
GameAttrName("王者")
)
),
GameAttrs(
"组队人数", listOf(
GameAttrName("三排"),
GameAttrName("五排")
)
)
)
最后用 DSL 动态构建选择按钮:
// 纵向布局
LinearLayout {
layout_width = match_parent
layout_height = 573
orientation = vertical
// 遍历游戏集合,动态添加选项组
gameAttrs?.forEach { gameAttr ->
// 添加选项组标题
TextView {
layout_width = wrap_content
layout_height = wrap_content
textSize = 14f
textColor = "#ff3f4658"
textStyle = bold
text = gameAttr.title
}
// 自动换行容器控件
LineFeedLayout {
layout_width = match_parent
layout_height = wrap_content
// 遍历游戏属性,动态添加选项按钮
gameAttr.attrs?.forEachIndexed { index, attr ->
Selector {
layout_id = attr.name
tag = attr.name
groupTag = gameAttr.title
// 为按钮设置控制器
group = gameSelectorGroup
// 为按钮指定视图
contentView = gameAttrView
// 为按钮设置选中效果变换器
onSelectChange = onGameAttrChange
layout_width = 70
layout_height = 32
// 为按钮绑定数据并更新视图
bind = Binder(attr) { _, _ ->
this[gameAttrKey] = attr
find("tvGameAttrName")?.text = attr.name
}
}
}
}
}
}
其中的按钮视图、按钮控制器、按钮效果变换器定义如下:
// 与游戏属性对应的键
val gameAttrKey = object : Selector.Key {}
// 构建游戏属性视图
val gameAttrView: TextView?
get() = TextView {
layout_id = "tvGameAttrName"
layout_width = 70
layout_height = 32
textSize = 12f
textColor = "#ff3f4658"
background_res = R.drawable.bg_game_attr
gravity = gravity_center
padding_top = 7
padding_bottom = 7
}
// 按钮选中状态变化时,变更背景色及按钮字体颜色
private val onGameAttrChange = { selector: Selector, select: Boolean ->
selector.find("tvGameAttrName")?.apply {
background_res = if (select) R.drawable.bg_game_attr_select else R.drawable.bg_game_attr
textColor = if (select) "#FFFFFF" else "#3F4658"
}
Unit
}
// 构建按钮控制器
private val gameSelectorGroup by lazy {
SelectorGroup().apply {
choiceMode = { selectorGroup, selector ->
// 设置除“匹配段位选项组”之外的其他组为单选
if (selector.groupTag != "匹配段位") {
selectorGroup.apply {
findLast(selector.groupTag)?.let { setSelected(it, false) }
}
selectorGroup.setSelected(selector, true)
}
// 设置“匹配段位选项组”为多选
else {
selectorGroup.setSelected(selector, !selector.isSelecting)
}
}
// 选中按钮发生变化时,都会在这里回调
selectChangeListener = { selecteds ->
selecteds.forEach { s->
Log.v("test","${s[gameAttrKey]?.name} is selected")
}
}
}
}
talk is cheap, show me the code
完整代码可以点击这里
推荐阅读
文中有一些未展开的细节,比如“构建布局的 DSL”、“ViewModel 动态扩展属性原理”、“在 DSL 中运用数据绑定”,“重载运算符”。它们的详细讲解可以点击如下链接:
- Android自定义控件 | 高可扩展单选按钮(再也不和产品经理吵架了)
- Android自定义控件 | 运用策略模式扩展单选按钮和产品经理成为好朋友
- Android自定义控件 | 源码里有宝藏之自动换行控件
- Android性能优化 | 把构建布局用时缩短 20 倍(下)
- 读源码长知识 | 动态扩展类并绑定生命周期的新方式