Android自定义控件 | 运用策略模式扩展单选按钮和产品经理成为好朋友

3,629 阅读6分钟

变化是永恒的,产品需求稳定不变是不可能的,和产品经理互怼是没有用的,但有一个方向是可以努力的:让代码更有弹性,以不变应万变。

继上一次发版前突然变更单选按钮样式之后,又新增了两个和选项按钮有关的需求。它们分别是多选和菜单选。多选类似于原生CheckBox,而菜单选是多选和单选的组合,类似于西餐点菜,西餐菜单将食物分为前菜、主食、汤,每种只能选择 1 个(即同组内单选,多组间多选)。

上一篇中的自定义单选按钮Selector + SelectorGroup完美 hold 住按钮样式的变化,这一次能否从容应对新增需求?

自定义单选按钮

回顾下Selector + SelectorGroup的效果: selector.gif

其中每一个选项就是Selector,它们的状态被SelectorGroup管理。

这组自定义控件突破了原生单选按钮的布局限制,选项的相对位置可以用 xml 定义(原生控件只能是垂直或水平铺开),而且还可以方便地更换按钮样式以及定义选中效果(上图中选中后有透明度动画)

实现关键逻辑如下:

  1. 单个按钮是一个抽象容器控件,它可以被点击并借助View.setSelected()记忆按钮选中状态。按钮内元素布局由其子类填充。
public abstract class Selector extends FrameLayout implements View.OnClickListener {
    //'按钮唯一标示符'
    private String tag;
    //'按钮所在组的标示符,单选的按钮应该设置相同的groupTag'
    private String groupTag;
    private SelectorGroup selectorGroup;

    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);
    }
    
    //'构建视图(在子类中自定义视图)'
    protected abstract View onCreateView();
    
    //'设置按钮的按钮组'
    public Selector setGroup(String groupTag, SelectorGroup selectorGroup) {
        this.selectorGroup = selectorGroup;
        this.groupTag = groupTag;
        return this;
    }
    
    @Override
    public void setSelected(boolean selected) {
        //'设置按钮选中状态'
        boolean isPreSelected = isSelected();
        super.setSelected(selected);
        if (isPreSelected != selected) {
            onSwitchSelected(selected);
        }
    }
    
    //'按钮选中状态变更(在子类中自定义变更效果)'
    protected abstract void onSwitchSelected(boolean isSelect);
    
    @Override
    public void onClick(View v) {
        //'通知选中组,当前按钮被选中'
        if (selectorGroup != null) {
            selectorGroup.onSelectorClick(this);
        }
    }
}

Selector通过模版方法模式,将构建按钮视图和按钮选中效果延迟到子类构建。所以当按钮内部元素布局发生改变时不需要修改Selector,只需要新建它的子类。

  1. 单选组会保存上一次选中的按钮,以便新的按钮被选中时取消之前按钮的选中状态。
public class SelectorGroup {
    //'持有上次选中的按钮组'
    private HashMap<String, Selector> selectorMap = new HashMap<>();
    
    //'获取上次选中按钮'
    public Selector getPreSelector(String groupTag) {
        return selectorMap.get(groupTag);
    }

    //'取消上次选中按钮'
    private void cancelPreSelector(Selector selector) {
        String groupTag = selector.getGroupTag();
        Selector preSelector = getPreSelector(groupTag);
        if (preSelector != null) {
            preSelector.setSelected(false);
        }
    }
    
    void onSelectorClick(Selector selector) {
        //'选中当前按钮'
        selector.setSelected(true);
        //'取消之前按钮'
        cancelPreSelector(selector);
        //'将这次选中按钮保存在map中'
        selectorMap.put(selector.getGroupTag(), selector);
    }
}

剥离行为

选中按钮后的行为被写死在SelectorGroup.onSelectorClick()中,这使得SelectorGroup中的行为无法被替换。

每次行为扩展都重新写一个SelectorGroup怎么样?不行!因为Selector是和SelectorGroup耦合的,这意味着Selector的代码也要跟着改动,这不符合开闭原则。

SelectorGroup中除了会变的“选中行为”之外,也有不会变的成分,比如“持有上次选中按钮”。是不是可以增加一层抽象将变化的行为封装起来,使得SelectorGroup与变化隔离?

接口是封装行为的最佳选择,可以运用策略模式将选中行为封装起来

策略模式的详细介绍可以点击这里

这样就可以在外部构建具体的选中行为,再将其注入到SelectorGroup中,以实现动态修改行为:

public class SelectorGroup {
    private ChoiceAction choiceMode;

    //'注入具体选中行为'
    public void setChoiceMode(ChoiceAction choiceMode) {
        this.choiceMode = choiceMode;
    }
    
    //'当按钮被点击时应用选中行为'
    void onSelectorClick(Selector selector) {
        if (choiceMode != null) {
            choiceMode.onChoose(selector, this, onStateChangeListener);
        }
    }
    
    //'选中后的行为被抽象成接口'
    public interface ChoiceAction {
        void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener);
    }
}

将具体行为替换成接口后就好像是在原本严严实实的SelectorGroup中挖了一个洞,只要符合这个洞形状的东西都可以塞进来,这样就很灵活了。

如果每次使用SelectorGroup,都需要重新自定义选中行为也很费力,所以在其中添加了最常用的单选和多选行为:

public class SelectorGroup {
    public static final int MODE_SINGLE_CHOICE = 1;
    public static final int MODE_MULTIPLE_CHOICE = 2;
    private ChoiceAction choiceMode;
    
    //'通过这个方法设置默认行为'
    public void setChoiceMode(int mode) {
        switch (mode) {
            case MODE_MULTIPLE_CHOICE:
                choiceMode = new MultipleAction();
                break;
            case MODE_SINGLE_CHOICE:
                choiceMode = new SingleAction();
                break;
        }
    }
    
    //'单选行为'
    private class SingleAction implements ChoiceAction {
        @Override
        public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
            selector.setSelected(true);
            cancelPreSelector(selector);
            if (stateListener != null) {
                stateListener.onStateChange(selector.getSelectorTag(), true);
            }
        }
    }
    
    //'多选行为'
    private class MultipleAction implements ChoiceAction {
        @Override
        public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
            boolean isSelected = selector.isSelected();
            selector.setSelected(!isSelected);
            if (stateListener != null) {
                stateListener.onStateChange(selector.getSelectorTag(), !isSelected);
            }
        }
    }
}

将原本具体的行为都移到了接口中,而SelectorGroup只和抽象的接口互动,不和具体行为互动,这样的SelectorGroup具有弹性。

现在只要像这样就可以分别实现单选和多选:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //多选
        SelectorGroup multipleGroup = new SelectorGroup();
        multipleGroup.setChoiceMode(SelectorGroup.MODE_MULTIPLE_CHOICE);
        multipleGroup.setStateListener(new MultipleChoiceListener());
        ((Selector) findViewById(R.id.selector_10)).setGroup("multiple", multipleGroup);
        ((Selector) findViewById(R.id.selector_20)).setGroup("multiple", multipleGroup);
        ((Selector) findViewById(R.id.selector_30)).setGroup("multiple", multipleGroup);
        //单选
        SelectorGroup singleGroup = new SelectorGroup();
        singleGroup.setChoiceMode(SelectorGroup.MODE_SINGLE_CHOICE);
        singleGroup.setStateListener(new SingleChoiceListener());
        ((Selector) findViewById(R.id.single10)).setGroup("single", singleGroup);
        ((Selector) findViewById(R.id.single20)).setGroup("single", singleGroup);
        ((Selector) findViewById(R.id.single30)).setGroup("single", singleGroup);
    }
}

activity_main.xml中布局了6个Selector,其中三个用于单选,三个用于多选。

菜单选

这一次新需求是多选和单选的组合:菜单选。这种模式将选项分成若干组,组内单选,组间多选。看下使用策略模式重构后的SelectorGroup是如何轻松应对的:

private class OderChoiceMode implements SelectorGroup.ChoiceAction {

    @Override
    public void onChoose(Selector selector, SelectorGroup selectorGroup, SelectorGroup.StateListener stateListener) {
        //'取消同组的上次选中按钮'
        cancelPreSelector(selector, selectorGroup);
        //'选中当前点击按钮'
        selector.setSelected(true);
    }

    //'取消同组上次选中按钮,同组的按钮具有相同的groupTag'
    private void cancelPreSelector(Selector selector, SelectorGroup selectorGroup) {
        Selector preSelector = selectorGroup.getPreSelector(selector.getGroupTag());
        if (preSelector!=null) {
            preSelector.setSelected(false);
        }
    }
}

然后就可以像这样动态的为SelectorGroup扩展菜单选行为了:

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        //'菜单选'
        SelectorGroup orderGroup = new SelectorGroup();
        orderGroup.setStateListener(new OrderChoiceListener());
        orderGroup.setChoiceMode(new OderChoiceMode());
        //'为同组按钮设置相同的groupTag'
        ((Selector) findViewById(R.id.selector_starters_duck)).setGroup("starters", orderGroup);
        ((Selector) findViewById(R.id.selector_starters_pork)).setGroup("starters", orderGroup);
        ((Selector) findViewById(R.id.selector_starters_springRoll)).setGroup("starters", orderGroup);
        ((Selector) findViewById(R.id.selector_main_pizza)).setGroup("main", orderGroup);
        ((Selector) findViewById(R.id.selector_main_pasta)).setGroup("main", orderGroup);
        ((Selector) findViewById(R.id.selector_soup_mushroom)).setGroup("soup", orderGroup);
        ((Selector) findViewById(R.id.selector_soup_scampi)).setGroup("soup", orderGroup);
    }
}

效果如下: order-choice.gif

其中单选按钮通过继承Selector重写onSwitchSelected(),定义了选中效果为爱心动画。

总结

至此,选项按钮这个repository已经将两种设计模式运用于实战。

  1. 运用了模版方法模式将变化的按钮布局和点击效果和按钮本身隔离。

  2. 运用了策略模式将变化的选中行为和选中组隔离。

在经历多次需求变更的突然袭击后,遍体鳞伤的我们需要找出自救的方法:

实现需求前,通过分析需求识别出“会变的”和“不变的”逻辑,增加一层抽象将“会变的”逻辑封装起来,以实现隔离和分层,将“不变的”逻辑和抽象的互动代码在上层类中固定下来。需求发生变化时,通过在下层实现抽象以多态的方式来应对。这样的代码具有弹性,就能以“不变的”上层逻辑应对变化的需求

talk is cheap, show me the code

实例代码省略了一些非关键的细节,完整代码在这里

推荐阅读

  1. Android自定义控件 | 高可扩展单选按钮(再也不和产品经理吵架了)
  2. Android自定义控件 | 运用策略模式扩展单选按钮和产品经理成为好朋友