Android自定義控件 | 時(shí)隔一年,用新知識(shí)重構(gòu)一個(gè)老庫(kù)

一年前,用 Java 寫(xiě)了一個(gè)高可擴(kuò)展選擇按鈕庫(kù)挽铁。單個(gè)控件實(shí)現(xiàn)單選双戳、多選益楼、菜單選氧秘,且選擇模式可動(dòng)態(tài)擴(kuò)展。

一年后省撑,一個(gè)新的需求要用到這個(gè)庫(kù)赌蔑,項(xiàng)目代碼已經(jīng)全 Kotlin 化,強(qiáng)硬地插入一些 Java 代碼顯得格格不入竟秫,Java 冗余的語(yǔ)法也降低了代碼的可讀性娃惯,于是決定用 Kotlin 重構(gòu)一番,在重構(gòu)的時(shí)候也增加了一些新的功能肥败。這一篇分享下重構(gòu)的過(guò)程石景。

選擇按鈕的可擴(kuò)展性主要體現(xiàn)在 4 個(gè)方面:

  1. 選項(xiàng)按鈕布局可擴(kuò)展
  2. 選項(xiàng)按鈕樣式可擴(kuò)展
  3. 選中樣式可擴(kuò)展
  4. 選擇模式可擴(kuò)展

擴(kuò)展布局

原生的單選按鈕通過(guò)RadioButton+ RadioGroup實(shí)現(xiàn),他們?cè)诓季稚媳仨毷歉缸雨P(guān)系拙吉,而RadioGroup繼承自LinearLayout潮孽,遂單選按鈕只能是橫向或縱向鋪開(kāi),這限制的單選按鈕布局的多樣性筷黔,比如下面這種三角布局就難以用原生控件實(shí)現(xiàn):

selector.gif

為了突破這個(gè)限制往史,單選按鈕不再隸屬于一個(gè)父控件,它們各自獨(dú)立佛舱,可以在布局文件中任意排列椎例,圖中 Activity 的布局文件如下(偽碼):

<androidx.constraintlayout.widget.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    
    <TextView
        android:id="@+id/title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Selector age"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <test.taylor.AgeSelector
        android:id="@+id/selector_teenager"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/title"
        app:layout_constraintStart_toStartOf="parent"/>

    <test.taylor.AgeSelector
        android:id="@+id/selector_man"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toStartOf="@id/selector_old_man"
        app:layout_constraintTop_toBottomOf="@id/selector_teenager"
        app:layout_constraintStart_toStartOf="parent"/>

    <test.taylor.AgeSelector
        android:id="@+id/selector_old_man"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/selector_teenager"
        app:layout_constraintStart_toEndOf="@id/selector_man"/>
</androidx.constraintlayout.widget.ConstraintLayout>

AgeSelector表示一個(gè)具體的按鈕,本例中它是一個(gè)“上面是圖片请祖,下面是文字”的單選按鈕订歪。它繼承自抽象的Selector

擴(kuò)展樣式

從業(yè)務(wù)上講肆捕,Selector長(zhǎng)什么樣是一個(gè)頻繁的變化點(diǎn)刷晋,遂把“構(gòu)建按鈕樣式”這個(gè)行為設(shè)計(jì)成Selector的抽象函數(shù)onCreateView(),供子類(lèi)重寫(xiě)以實(shí)現(xiàn)擴(kuò)展。

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);
    }
    
    // 如何構(gòu)建按鈕視圖眼虱,延遲到子類(lèi)實(shí)現(xiàn)
    protected abstract View onCreateView();
}

Selector繼承自FrameLayout喻奥,實(shí)例化時(shí)會(huì)構(gòu)建按鈕視圖,并把該視圖作為孩子添加到自己的布局中捏悬。子類(lèi)通過(guò)重寫(xiě)onCreateView()擴(kuò)展按鈕樣式:

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 中撞蚕。

按鈕被選中之后的樣式,也是一個(gè)業(yè)務(wù)上的變化點(diǎn)过牙,用同樣的思路可以將Selector這樣設(shè)計(jì):

// 抽象按鈕實(shí)現(xiàn)點(diǎn)擊事件
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);
        // 設(shè)置點(diǎn)擊事件
        this.setOnClickListener(this);
    }
    
    @Override
    public void onClick(View v) {
        // 原有選中狀態(tài)
        boolean isSelect = this.isSelected();
        // 反轉(zhuǎn)選中狀態(tài)
        this.setSelected(!isSelect);
        // 展示選中狀態(tài)切換效果
        onSwitchSelected(!isSelect);
        return !isSelect;
    }
    
    // 按鈕選中狀態(tài)變化時(shí)的效果延遲到子類(lèi)實(shí)現(xiàn)
    protected abstract void onSwitchSelected(boolean isSelect);
}

將選中按鈕狀態(tài)變化的效果抽象成一個(gè)算法甥厦,延遲到子類(lèi)實(shí)現(xiàn):

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();
        }
    }
    
    // 播放取消選中動(dòng)畫(huà)
    private void playUnselectedAnimation() {
        if (ivSelector == null) {
            return;
        }
        if (valueAnimator != null) {
            valueAnimator.reverse();
        }
    }

    // 播放選中動(dòng)畫(huà)
    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在選中狀態(tài)變化時(shí)定義了一個(gè)背景色漸變動(dòng)畫(huà)。

函數(shù)類(lèi)型變量代替繼承

在抽象按鈕控件中寇钉,“按鈕樣式”和“按鈕選中狀態(tài)變換”被抽象成算法刀疙,算法的實(shí)現(xiàn)推遲到子類(lèi),用這樣的方式摧莽,擴(kuò)展按鈕的樣式和行為庙洼。

繼承的一個(gè)后果就是類(lèi)數(shù)量的膨脹顿痪,有沒(méi)有什么辦法不用繼承就能擴(kuò)展按鈕樣式和行為镊辕?

可以把構(gòu)建按鈕樣式的成員方法onCreateView()設(shè)計(jì)成一個(gè)View類(lèi)型的成員變量,通過(guò)設(shè)值函數(shù)就可以改變其值蚁袭。但按鈕選中狀態(tài)變換是一種行為征懈,在 Java 中行為的表達(dá)方式只有方法,所以只能通過(guò)繼承來(lái)改變行為揩悄。

Kotlin 中有一種類(lèi)型叫函數(shù)類(lèi)型卖哎,運(yùn)用這種類(lèi)型,可以將行為保存在變量中:

class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    FrameLayout(context, attrs, defStyleAttr) {
    
    // 選中狀態(tài)變換時(shí)的行為删性,它是一個(gè)lambda
    var onSelectChange: ((Selector, Boolean) -> Unit)? = null
    // 按鈕是否被選中
    var isSelecting: Boolean = false

    // 按鈕樣式
     var contentView: View? = null
        set(value) {
            field = value
            value?.let {
                // 當(dāng)按鈕樣式被賦值時(shí)亏娜,將其添加到 Selector,作為子視圖
                addView(it, LayoutParams(MATCH_PARENT, MATCH_PARENT))
            }
        }
    
    // 變更按鈕選中狀態(tài)
    fun setSelect(select: Boolean) {
        showSelectEffect(select)
    }
    
    // 展示選中狀態(tài)變換效果
    fun showSelectEffect(select: Boolean) {
        // 如果選中狀態(tài)發(fā)生變化蹬挺,則執(zhí)行選中狀態(tài)變換行為
        if (isSelecting != select) {
            onSelectChange?.invoke(this, select)
        }
        isSelecting = select
    }
}

選中樣式和行為都被抽象為一個(gè)成員變量维贺,只需賦值就可以動(dòng)態(tài)擴(kuò)展,不再需要繼承:

// 構(gòu)建按鈕實(shí)例
val selector = Selector {
    layout_width = 90
    layout_height = 50
    contentView = ageSelectorView
    onSelectChange = onAgeSelectStateChange
}

// 構(gòu)建按鈕樣式
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 ->
    // 根據(jù)選中狀態(tài)變換按鈕選中背景
    selector.find<ImageView>("ivSelector")?.alpha = if (select) 1f else 0f
}

在構(gòu)建Selector實(shí)例的同時(shí)巴帮,指定了它的樣式和選中變換效果(其中運(yùn)用到 DSL 簡(jiǎn)化構(gòu)建代碼溯泣,詳細(xì)介紹可以點(diǎn)擊這里

擴(kuò)展選中模式

單個(gè)Selector已經(jīng)可以很好的工作,但要讓多個(gè)Selector形成一種單選或多選的模式榕茧,還需要一個(gè)管理器來(lái)同步它們之間的選中狀態(tài)垃沦,Java 版本的管理器如下:

public class SelectorGroup {
    // 選中模式
    public interface ChoiceAction {
        void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener);
    }
    
    // 選中狀態(tài)監(jiān)聽(tīng)器
    public interface StateListener {
        void onStateChange(String groupTag, String tag, boolean isSelected);
    }
    
    // 選中模式實(shí)例
    private ChoiceAction choiceMode;
    // 選中狀態(tài)監(jiān)聽(tīng)器實(shí)例
    private StateListener onStateChangeListener;
    // 用于上一次選中的按鈕的 Map
    private HashMap<String, Selector> selectorMap = new HashMap<>();

    // 注入選中模式
    public void setChoiceMode(ChoiceAction choiceMode) {
        this.choiceMode = choiceMode;
    }

    // 設(shè)置選中狀態(tài)監(jiān)聽(tīng)器
    public void setStateListener(StateListener onStateChangeListener) {
        this.onStateChangeListener = onStateChangeListener;
    }

    // 獲取之前選中的按鈕
    public Selector getPreSelector(String groupTag) {
        return selectorMap.get(groupTag);
    }
    
    // 變更指定按鈕的選中狀態(tài)
    public void setSelected(boolean selected, Selector selector) {
        if (selector == null) {
            return;
        }
        // 記憶選中的按鈕
        if (selected) {
            selectorMap.put(selector.getGroupTag(), selector);
        }
        // 觸發(fā)按鈕選中樣式變更
        selector.setSelected(selected);
        if (onStateChangeListener != null) {
            onStateChangeListener.onStateChange(selector.getGroupTag(), selector.getSelectorTag(), selected);
        }
    }

    // 取消之前選中的按鈕
    private void cancelPreSelector(Selector selector) {
        // 每個(gè)按鈕有一個(gè)組標(biāo)識(shí),用于標(biāo)識(shí)它屬于哪個(gè)組
        String groupTag = selector.getGroupTag();
        // 獲取該組中之前選中的按鈕并將其取消選中
        Selector preSelector = getPreSelector(groupTag);
        if (preSelector != null) {
            preSelector.setSelected(false);
        }
    }

    // 當(dāng)按鈕被點(diǎn)擊時(shí),會(huì)將點(diǎn)擊事件通過(guò)該函數(shù)傳遞給 SelectorGroup
    void onSelectorClick(Selector selector) {
        // 將點(diǎn)擊事件委托給選擇模式來(lái)處理
        if (choiceMode != null) {
            choiceMode.onChoose(selector, this, onStateChangeListener);
        }
        // 將選中的按鈕記錄在 Map 中
        selectorMap.put(selector.getGroupTag(), selector);
    }
    
    // 預(yù)定的單選模式
    public class SingleAction implements ChoiceAction {
        @Override
        public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
            cancelPreSelector(selector);
            setSelected(true, selector);
        }
    }

    // 預(yù)定的多選模式
    public class MultipleAction implements ChoiceAction {
        @Override
        public void onChoose(Selector selector, SelectorGroup selectorGroup, StateListener stateListener) {
            boolean isSelected = selector.isSelected();
            setSelected(!isSelected, selector);
        }
    }
}

SelectorGroup將選中模式抽象成接口ChoiceAction用押,以便通過(guò)setChoiceMode()動(dòng)態(tài)地?cái)U(kuò)展肢簿。

SelectorGroup還預(yù)定了兩種選中模式:?jiǎn)芜x和多選。

  1. 單選可以理解為:點(diǎn)擊按鈕時(shí),選中當(dāng)前的并取消選中之前的译仗。
  2. 多選可以理解為:點(diǎn)擊按鈕時(shí)無(wú)條件地反轉(zhuǎn)當(dāng)前選中狀態(tài)抬虽。

Selector會(huì)持有SelectorGroup實(shí)例,以便將按鈕點(diǎn)擊事件傳遞給它統(tǒng)一管理:

public abstract class Selector extends FrameLayout implements View.OnClickListener {
    // 按鈕組標(biāo)簽
    private String groupTag;
    // 按鈕管理器
    private SelectorGroup selectorGroup;
    
    // 設(shè)置組標(biāo)簽和管理器
    public Selector setGroup(String groupTag, SelectorGroup selectorGroup) {
        this.selectorGroup = selectorGroup;
        this.groupTag = groupTag;
        return this;
    }
    
    @Override
    public void onClick(View v) {
        // 將點(diǎn)擊事件傳遞給管理器
        if (selectorGroup != null) {
            selectorGroup.onSelectorClick(this);
        }
    }
}

然后就可以像這樣實(shí)現(xiàn)單選:

SelectorGroup singleGroup = new SelectorGroup();
singleGroup.setChoiceMode(SelectorGroup.SingleAction);
selector1.setGroup("single", singleGroup);
selector2.setGroup("single", singleGroup);
selector3.setGroup("single", singleGroup);

也可以像這樣實(shí)現(xiàn)菜單選:

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);

// 菜單選:組內(nèi)單選纵菌,跨組多選
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阐污,存儲(chǔ)在函數(shù)類(lèi)型的變量中,這樣可省去注入函數(shù)咱圆,Kotlin 版本的SelectorGroup如下:

class SelectorGroup {
    companion object {
        // 單選模式的靜態(tài)實(shí)現(xiàn)
        var MODE_SINGLE = { selectorGroup: SelectorGroup, selector: Selector ->
            selectorGroup.run {
                // 查找同組中之前選中的笛辟,取消其選中狀態(tài)
                findLast(selector.groupTag)?.let { setSelected(it, false) }
                // 選中當(dāng)前按鈕
                setSelected(selector, true)
            }
        }

        // 多選模式的靜態(tài)實(shí)現(xiàn)
        var MODE_MULTIPLE = { selectorGroup: SelectorGroup, selector: Selector ->
            selectorGroup.setSelected(selector, !selector.isSelecting)
        }
    }

    // 所有當(dāng)前選中按鈕的有序集合(有些場(chǎng)景需要記憶按鈕選中的順序)
    private var selectorMap = LinkedHashMap<String, MutableSet<Selector>>()

    // 當(dāng)前的選中模式(函數(shù)類(lèi)型)
    var choiceMode: ((SelectorGroup, Selector) -> Unit)? = null

    // 選中狀態(tài)變更監(jiān)聽(tīng)器, 將所有選中按鈕回調(diào)出去(函數(shù)類(lèi)型)
    var selectChangeListener: ((List<Selector>/*selected set*/) -> Unit)? = null

    // Selector 將點(diǎn)擊事件通過(guò)這個(gè)方法傳遞給 SelectorGroup 
    fun onSelectorClick(selector: Selector) {
        // 將點(diǎn)擊事件委托給選中模式
        choiceMode?.invoke(this, selector)
    }

    // 查找指定組的所有選中按鈕
    fun find(groupTag: String) = selectorMap[groupTag]

    // 根據(jù)組標(biāo)簽查找該組中上一次被選中的按鈕
    fun findLast(groupTag: String) = find(groupTag)?.takeUnless { it.isNullOrEmpty() }?.last()

    // 變更指定按鈕的選中狀態(tài)
    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)
        // 觸發(fā)選中狀態(tài)監(jiān)聽(tīng)器
        if (select) {
            selectChangeListener?.invoke(selectorMap.flatMap { it.value })
        }
    }

    // 釋放持有的選中控件
    fun clear() {
        selectorMap.clear()
    }
}

然后就可以像這樣使用SelectorGroup

// 構(gòu)建管理器
val singleGroup = SelectorGroup().apply {
    choiceMode = SelectorGroup.MODE_SINGLE
    selectChangeListener = { selectors: List<Selector>->
        // 在這里可以拿到選中的所有按鈕
    }
}

// 構(gòu)建單選按鈕1
Selector {
    tag = "old-man"
    group = singleGroup
    groupTag = "age"
    layout_width = 90
    layout_height = 50
    contentView = ageSelectorView
}

// 構(gòu)建單選按鈕2
Selector {
    tag = "young-man"
    group = singleGroup
    groupTag = "age"
    layout_width = 90
    layout_height = 50
    contentView = ageSelectorView
}

構(gòu)建的兩個(gè)按鈕擁有相同的groupTagSelectorGroup,所以他們屬于同一組并且是單選模式序苏。

動(dòng)態(tài)綁定數(shù)據(jù)

項(xiàng)目中一個(gè)按鈕通常對(duì)應(yīng)于一個(gè)“數(shù)據(jù)”手幢,比如下圖這種場(chǎng)景:

image

圖中的分組數(shù)據(jù)和按鈕數(shù)據(jù)都由服務(wù)器返回。點(diǎn)擊創(chuàng)建組隊(duì)時(shí)忱详,希望在selectChangeListener中拿到每個(gè)選項(xiàng)的 ID围来。那如何為Selector綁定數(shù)據(jù)?

當(dāng)然可以通過(guò)繼承匈睁,在Selector子類(lèi)中添加一個(gè)具體的業(yè)務(wù)數(shù)據(jù)類(lèi)型來(lái)實(shí)現(xiàn)监透。但有沒(méi)有更通用的方案?

ViewModel中設(shè)計(jì)了一種為其動(dòng)態(tài)擴(kuò)展屬性的方法航唆,將它應(yīng)用在Selector中(詳情可移步讀源碼長(zhǎng)知識(shí) | 動(dòng)態(tài)擴(kuò)展類(lèi)并綁定生命周期的新方式

class Selector @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    FrameLayout(context, attrs, defStyleAttr) {

    // 存放業(yè)務(wù)數(shù)據(jù)的容器
    private var tags = HashMap<Any?, Closeable?>()
    
    // 獲取業(yè)務(wù)數(shù)據(jù)(重載取值運(yùn)算符)
    operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T

    // 添加業(yè)務(wù)數(shù)據(jù)(重載設(shè)值運(yùn)算符)
    operator fun <T : Closeable> set(key: Key<T>, closeable: Closeable) {
        tags[key] = closeable
    }
    
    // 清除所有業(yè)務(wù)數(shù)據(jù)
    private fun clear() {
        group?.clear()
        tags.forEach { entry ->
            closeWithException(entry.value)
        }
    }
    
    // 當(dāng)控件與窗口脫鉤時(shí)胀蛮,清理業(yè)務(wù)數(shù)據(jù)
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        clear()
    }

    // 清除單個(gè)業(yè)務(wù)數(shù)據(jù)
    private fun closeWithException(closable: Closeable?) {
        try {
            closable?.close()
        } catch (e: Exception) {
        }
    }
    
    // 業(yè)務(wù)數(shù)據(jù)的鍵
    interface Key<E : Closeable>
}

Selector新增一個(gè)Map類(lèi)型的成員用于存放業(yè)務(wù)數(shù)據(jù),業(yè)務(wù)數(shù)據(jù)被聲明為Closeable的子類(lèi)型糯钙,目的是將各式各樣清理資源的行為抽象為close()方法粪狼,Selector重寫(xiě)了onDetachedFromWindow()且會(huì)遍歷每個(gè)業(yè)務(wù)數(shù)據(jù)并調(diào)用它們的close(),即當(dāng)它生命周期結(jié)束時(shí)任岸,釋放業(yè)務(wù)數(shù)據(jù)資源再榄。

Selector也重載了設(shè)值和取值這兩個(gè)運(yùn)算符,以簡(jiǎn)化業(yè)訪(fǎng)問(wèn)業(yè)務(wù)數(shù)據(jù)的代碼:

// 游戲?qū)傩詫?shí)體類(lèi)
data class GameAttr( var name: String, var id: String ): Closeable {
    override fun close() {
        name = null
        id = null
    }
}

// 構(gòu)建游戲?qū)傩詫?shí)例
val attr = GameAttr("黃金", "id-298")

// 和游戲?qū)傩詫?shí)體配對(duì)的鍵
val key = object : Selector.Key<GameAttr> {}

// 構(gòu)建選項(xiàng)組
val gameSelectorGroup by lazy {
    SelectorGroup().apply {
        // 選擇模式(省略)
        choiceMode = { selectorGroup, selector -> ... }
        // 選中回調(diào)
        selectChangeListener = { selecteds ->
            // 遍歷所有選中的選項(xiàng)
            selecteds.forEach { s ->
                // 訪(fǎng)問(wèn)與每個(gè)選項(xiàng)綁定的游戲?qū)傩裕ㄓ玫饺≈颠\(yùn)算符)
                Log.v("test","${s[key].name} is selected")
            }
        }
    }
}

// 構(gòu)建選項(xiàng)
Selector {
    tag = attr.name
    groupTag = "匹配段位"
    group = gameSelectorGroup
    layout_width = 70
    layout_height = 32
    // 綁定游戲?qū)傩裕ㄓ玫皆O(shè)值運(yùn)算符)
    this[key] = attr
}

因?yàn)橹剌d了運(yùn)算符享潜,所以綁定和獲取游戲?qū)傩缘拇a都更加簡(jiǎn)短困鸥。

用泛型就一定要強(qiáng)轉(zhuǎn)?

綁定給 Selector 的數(shù)據(jù)被設(shè)計(jì)為泛型米碰,業(yè)務(wù)層只有強(qiáng)轉(zhuǎn)成具體類(lèi)型才能使用窝革,有什么辦法可以不要在業(yè)務(wù)層強(qiáng)轉(zhuǎn)?

CoroutineContext的鍵就攜帶了類(lèi)型信息:

public interface CoroutineContext {
    public interface Key<E : Element>
    public operator fun <E : Element> get(key: Key<E>): E?
}

而且每一個(gè)CoroutineContext的具體子類(lèi)型都對(duì)應(yīng)一個(gè)靜態(tài)的鍵實(shí)例:

public interface Job : CoroutineContext.Element {
    public companion object Key : CoroutineContext.Key<Job> {}
}

這樣吕座,不需要強(qiáng)轉(zhuǎn)就能獲得具體子類(lèi)型:

coroutineContext[Job]//返回值為 Job 而不是 CoroutineContext

模仿CoroutineContext虐译,業(yè)務(wù)Selector的鍵設(shè)計(jì)了一個(gè)帶泛型的接口:

interface Key<E : Closeable>

在為Selector綁定數(shù)據(jù)時(shí)需要先構(gòu)建“鍵實(shí)例”:

val key = object : Selector.Key<GameAttr> {}

傳入的鍵帶有類(lèi)型信息,可以在取值方法中提前完成強(qiáng)轉(zhuǎn)再返回給業(yè)務(wù)層使用:

// 值的具體類(lèi)型被參數(shù) key 指定吴趴,強(qiáng)轉(zhuǎn)之后再返回給業(yè)務(wù)層
operator fun <T : Closeable> get(key: Key<T>): T? = (tags.getOrElse(key, { null })) as T

借助于 DSL 根據(jù)數(shù)據(jù)動(dòng)態(tài)地構(gòu)建選擇按鈕就變得很輕松漆诽,上一幅 Gif 展示的界面代碼如下:

// 游戲?qū)傩约蠈?shí)體類(lèi)
data class GameAttrs(
    var title: String?,// 選項(xiàng)組標(biāo)題
    var attrs: List<GameAttrName>? // 選項(xiàng)組內(nèi)容
)

// 簡(jiǎn)化的單個(gè)游戲?qū)傩詫?shí)體類(lèi)(它會(huì)被綁定到Selector)
data class GameAttrName(
    var name: String?
) : Closeable {
    override fun close() {
        name = null
    }
}

這是兩個(gè) Demo 中用到的數(shù)據(jù)實(shí)體類(lèi),真實(shí)項(xiàng)目中他們應(yīng)該是服務(wù)器返回的,簡(jiǎn)單起見(jiàn)厢拭,本地模擬一些數(shù)據(jù):

val gameAttrs = listOf(
    GameAttrs(
        "大區(qū)", listOf(
            GameAttrName("微信"),
            GameAttrName("QQ")
        )
    ),
    GameAttrs(
        "模式", listOf(
            GameAttrName("排位賽"),
            GameAttrName("普通模式"),
            GameAttrName("娛樂(lè)模式"),
            GameAttrName("游戲交流")
        )
    ),
    GameAttrs(
        "匹配段位", listOf(
            GameAttrName("青銅白銀"),
            GameAttrName("黃金"),
            GameAttrName("鉑金"),
            GameAttrName("鉆石"),
            GameAttrName("星耀"),
            GameAttrName("王者")
        )
    ),
    GameAttrs(
        "組隊(duì)人數(shù)", listOf(
            GameAttrName("三排"),
            GameAttrName("五排")
        )
    )
)

最后用 DSL 動(dòng)態(tài)構(gòu)建選擇按鈕:

// 縱向布局
LinearLayout {
    layout_width = match_parent
    layout_height = 573
    orientation = vertical

    // 遍歷游戲集合兰英,動(dòng)態(tài)添加選項(xiàng)組
    gameAttrs?.forEach { gameAttr ->
        // 添加選項(xiàng)組標(biāo)題
        TextView {
            layout_width = wrap_content
            layout_height = wrap_content
            textSize = 14f
            textColor = "#ff3f4658"
            textStyle = bold
            text = gameAttr.title
        }

        // 自動(dòng)換行容器控件
        LineFeedLayout {
            layout_width = match_parent
            layout_height = wrap_content
            
            // 遍歷游戲?qū)傩裕瑒?dòng)態(tài)添加選項(xiàng)按鈕
            gameAttr.attrs?.forEachIndexed { index, attr ->
                Selector {
                    layout_id = attr.name
                    tag = attr.name
                    groupTag = gameAttr.title
                    // 為按鈕設(shè)置控制器
                    group = gameSelectorGroup
                    // 為按鈕指定視圖
                    contentView = gameAttrView
                    // 為按鈕設(shè)置選中效果變換器
                    onSelectChange = onGameAttrChange
                    layout_width = 70
                    layout_height = 32
                    // 為按鈕綁定數(shù)據(jù)并更新視圖
                    bind = Binder(attr) { _, _ ->
                        this[gameAttrKey] = attr
                        find<TextView>("tvGameAttrName")?.text = attr.name
                    }
                }
            }
        }
    }
}

其中的按鈕視圖供鸠、按鈕控制器畦贸、按鈕效果變換器定義如下:

// 與游戲?qū)傩詫?duì)應(yīng)的鍵
val gameAttrKey = object : Selector.Key<GameAttrName> {}

// 構(gòu)建游戲?qū)傩砸晥D
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
        }

// 按鈕選中狀態(tài)變化時(shí),變更背景色及按鈕字體顏色
private val onGameAttrChange = { selector: Selector, select: Boolean ->
    selector.find<TextView>("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
}

// 構(gòu)建按鈕控制器
private val gameSelectorGroup by lazy {
    SelectorGroup().apply {
        choiceMode = { selectorGroup, selector ->
            // 設(shè)置除“匹配段位選項(xiàng)組”之外的其他組為單選
            if (selector.groupTag != "匹配段位") {
                selectorGroup.apply {
                    findLast(selector.groupTag)?.let { setSelected(it, false) }
                }
                selectorGroup.setSelected(selector, true)
            }
            // 設(shè)置“匹配段位選項(xiàng)組”為多選
            else {
                selectorGroup.setSelected(selector, !selector.isSelecting)
            }
        }
        
        // 選中按鈕發(fā)生變化時(shí)楞捂,都會(huì)在這里回調(diào)
        selectChangeListener = { selecteds ->
            selecteds.forEach { s->
                Log.v("test","${s[gameAttrKey]?.name} is selected")
            }
        }
    }
}

talk is cheap, show me the code

完整代碼可以點(diǎn)擊這里

推薦閱讀

文中有一些未展開(kāi)的細(xì)節(jié)薄坏,比如“構(gòu)建布局的 DSL”、“ViewModel 動(dòng)態(tài)擴(kuò)展屬性原理”寨闹、“在 DSL 中運(yùn)用數(shù)據(jù)綁定”胶坠,“重載運(yùn)算符”。它們的詳細(xì)講解可以點(diǎn)擊如下鏈接:

  1. Android自定義控件 | 高可擴(kuò)展單選按鈕(再也不和產(chǎn)品經(jīng)理吵架了)
  2. Android自定義控件 | 運(yùn)用策略模式擴(kuò)展單選按鈕和產(chǎn)品經(jīng)理成為好朋友
  3. Android自定義控件 | 源碼里有寶藏之自動(dòng)換行控件
  4. Android性能優(yōu)化 | 把構(gòu)建布局用時(shí)縮短 20 倍(下)
  5. 讀源碼長(zhǎng)知識(shí) | 動(dòng)態(tài)擴(kuò)展類(lèi)并綁定生命周期的新方式
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末繁堡,一起剝皮案震驚了整個(gè)濱河市沈善,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌椭蹄,老刑警劉巖闻牡,帶你破解...
    沈念sama閱讀 216,997評(píng)論 6 502
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異塑娇,居然都是意外死亡澈侠,警方通過(guò)查閱死者的電腦和手機(jī)劫侧,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,603評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門(mén)埋酬,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人烧栋,你說(shuō)我怎么就攤上這事写妥。” “怎么了审姓?”我有些...
    開(kāi)封第一講書(shū)人閱讀 163,359評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵珍特,是天一觀(guān)的道長(zhǎng)。 經(jīng)常有香客問(wèn)我魔吐,道長(zhǎng)扎筒,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,309評(píng)論 1 292
  • 正文 為了忘掉前任酬姆,我火速辦了婚禮嗜桌,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘辞色。我一直安慰自己骨宠,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,346評(píng)論 6 390
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著层亿,像睡著了一般桦卒。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上匿又,一...
    開(kāi)封第一講書(shū)人閱讀 51,258評(píng)論 1 300
  • 那天方灾,我揣著相機(jī)與錄音,去河邊找鬼碌更。 笑死迎吵,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的针贬。 我是一名探鬼主播击费,決...
    沈念sama閱讀 40,122評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼桦他!你這毒婦竟也來(lái)了蔫巩?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 38,970評(píng)論 0 275
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤快压,失蹤者是張志新(化名)和其女友劉穎圆仔,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體蔫劣,經(jīng)...
    沈念sama閱讀 45,403評(píng)論 1 313
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡坪郭,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,596評(píng)論 3 334
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了脉幢。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片歪沃。...
    茶點(diǎn)故事閱讀 39,769評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖嫌松,靈堂內(nèi)的尸體忽然破棺而出沪曙,到底是詐尸還是另有隱情,我是刑警寧澤萎羔,帶...
    沈念sama閱讀 35,464評(píng)論 5 344
  • 正文 年R本政府宣布液走,位于F島的核電站,受9級(jí)特大地震影響贾陷,放射性物質(zhì)發(fā)生泄漏缘眶。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,075評(píng)論 3 327
  • 文/蒙蒙 一髓废、第九天 我趴在偏房一處隱蔽的房頂上張望巷懈。 院中可真熱鬧,春花似錦瓦哎、人聲如沸砸喻。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,705評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)割岛。三九已至愉适,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間癣漆,已是汗流浹背维咸。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,848評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留惠爽,地道東北人癌蓖。 一個(gè)月前我還...
    沈念sama閱讀 47,831評(píng)論 2 370
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像婚肆,于是被迫代替她去往敵國(guó)和親租副。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,678評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容