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è)訪問(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 ->
                // 訪問(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)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容