App 首页弹窗优先级编排优雅解决方案

一、背景

通过观察众多知名app我们可以发现,在app启动进入首页的时候,我们一般会遇到以下几种弹窗:app更新升级提示弹窗、青少年模式切换弹窗、某活动引导弹窗、某新功能引导弹窗、白日\黑夜模式切换弹窗......弹出一个,点击消失,又弹出另一个......针对单个弹窗而言,它既有自身弹出的条件,又有弹出时机的优先级......在开发中面对众多弹窗的时候,我们该如何实现呢?有人说这好办,在DialogA注册onDismissListener编写DialogB弹出的条件、在DialogB注册onDismissListener编写DialogC弹出的条件、以此类推实现DialogD、E、F......伪代码如下:

class DialogA extend Dialog{
protecd void onCreate(){
//...
setOnDismissListener{
   if(条件成立){
     new DialogB().show();
    }else{
     new DialogC().show();
   }
   }
    }
}


class DialogB extend Dialog{
protecd void onCreate(){
//...
setOnDismissListener{
   if(条件成立){
     new DialogC().show();
    }else{
     new DialogD().show();
   }
   }
    }
}
// ......

以上案例仅仅是要Dialog能弹出来才能走到 setOnDismissListener 里的逻辑,那要是连Dialog都没能弹出来,那情况岂不是更揪心???就算最后你能凭借超强的if/else套娃能力勉强实现了,相信我,此时工程代码已然一坨屎了!首页弹出远比想象的复杂!

二、话不多说,上方案!

我们先看首页弹出的整个业务流程,弹窗是一个接着一个出现的,这非常容易让人联想到这是一条“链”的流程,什么链?责任链嘛!呼之即出!(有对责任链不熟悉的同学我建议先学习《设计模式》,重点参透它的设计思想。)
关于责任链,就不得不提一嘴名世之作----okhttp,它的每一个节点叫做拦截器(Interceptor)。于是乎我们有样学样,将我们的每一个弹窗(Dialog)看作是责任链的一个节点(DialogInterceptor),将所有弹窗组织成一条弹窗链(DialogChain),链头的节点优先级最高,依次递减。话不多说,上代码!
1.定义DialogInterceptor:


interface DialogInterceptor {

    fun intercept(chain: DialogChain)

}

2.定义DialogChain:


class DialogChain private constructor(
    // 弹窗的时候可能需要Activity/Fragment环境。
    val activity: FragmentActivity? = null,
    val fragment: Fragment? = null,
    private var interceptors: MutableList<DialogInterceptor>?
) {
    companion object {
        @JvmStatic
        fun create(initialCapacity: Int = 0): Builder {
            return Builder(initialCapacity)
        }
        @JvmStatic
        fun openLog(isOpen:Boolean){
            isOpenLog=isOpen
        }
    }

    private var index: Int = 0

   // 执行拦截器。
    fun process() {
        interceptors ?: return
        when (index) {
            in interceptors!!.indices -> {
                val interceptor = interceptors!![index]
                index++
                interceptor.intercept(this)
            }
            // 最后一个弹窗关闭的时候,我们希望释放所有弹窗引用。
            interceptors!!.size -> {
                "===> clearAllInterceptors".logI(this)
                clearAllInterceptors()
            }
        }
    }

    private fun clearAllInterceptors() {
        interceptors?.clear()
        interceptors = null
    }
 // 构建者模式。
    open class Builder(private val initialCapacity: Int = 0) {
        private val interceptors by lazy(LazyThreadSafetyMode.NONE) {
            ArrayList<DialogInterceptor>(
                initialCapacity
            )
        }
        private var activity: FragmentActivity? = null
        private var fragment: Fragment? = null

       // 添加一个拦截器。
        fun addInterceptor(interceptor: DialogInterceptor): Builder {
            if (!interceptors.contains(interceptor)) {
                interceptors.add(interceptor)
            }
            return this
        }
       // 关联Fragment。
        fun attach(fragment: Fragment): Builder {
            this.fragment = fragment
            return this
        }
       // 关联Activity。
        fun attach(activity: FragmentActivity): Builder {
            this.activity = activity
            return this
        }


        fun build(): DialogChain {
            return DialogChain(activity, fragment, interceptors)
        }
    }


是的,就两个类,整个解决方案就俩类!下面我们先看一下用例,而后再结合用例梳理一遍框架的逻辑流程。

三、用例

step1:在app主工程新建一个BaseDialog并实现DialogInterceptor接口:

abstract class BaseDialog(context: Context):AlertDialog(context),DialogInterceptor {
  
    private var mChain: DialogChain? = null

    /*下一个拦截器*/
    fun chain(): DialogChain? = mChain
    
    @CallSuper
    override fun intercept(chain: DialogChain) {
        mChain = chain
    }



    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        window?.attributes?.width=800
        window?.attributes?.height=900

    }

}

注意看 intercept(chain: DialogChain) 方法,我们将其传进来的 DialogChain 对象保存起来,再提供 chain()方法供子类访问。

step2:衍生出 ADialog:


class ADialog(context: Context) : BaseDialog(context), View.OnClickListener {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.dialog_a)
        findViewById<View>(R.id.tv_confirm)?.setOnClickListener(this)
        findViewById<View>(R.id.tv_cancel)?.setOnClickListener(this)
        // 注释1:弹窗消失时把请求移交给下一个拦截器。
        setOnDismissListener {
            chain()?.process()
        }
    }

    override fun onClick(p0: View?) {
        dismiss()
    }

    override fun intercept(chain: DialogChain) {
        super.intercept(chain)
        val isShow = true // 注释2:这里可根据实际业务场景来定制dialog 显示条件。
        if (isShow) {
            this.show()
        } else { // 注释3:当自己不具备弹出条件的时候,可以立刻把请求转交给下一个拦截器。
            chain.process()
        }
    }
}

附 dialog_a.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">


    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="我是Dialog A"
        android:textSize="23sp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:textColor="@android:color/black"/>

    <TextView
        android:id="@+id/tv_cancel"
        android:layout_width="wrap_content"
        android:layout_height="40dp"
        android:text="取消"
        android:textSize="23sp"
        android:gravity="center"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/line"
        app:layout_constraintBottom_toBottomOf="parent"
        android:textColor="@android:color/holo_orange_dark"/>
    <View
        android:id="@+id/line"
        android:layout_width="1dp"
        android:layout_height="20dp"
        android:background="@android:color/darker_gray"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>
    <TextView
        android:id="@+id/tv_confirm"
        android:layout_width="wrap_content"
        android:layout_height="40dp"
        android:text="确定"
        android:textSize="23sp"
        android:gravity="center"
        app:layout_constraintStart_toEndOf="@id/line"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:textColor="@android:color/holo_orange_dark"/>

</androidx.constraintlayout.widget.ConstraintLayout>

附 dialog_a.xml界面效果图:

dialog_a.PNG

我们先看ADialog 注释1处,就是在Dialog消失的时候拿到 DialogChain 对象,调用其process() 方法,这里注意关联 BaseDialog 中的逻辑——我们是利用了 intercept(chain: DialogChain) 方法传进的DialogChain 对象做文章。此外还要注意关联 DialogChain process()方法中的逻辑:

 fun process() {
        interceptors ?: return
        when (index) {
            in interceptors!!.indices -> {

                val interceptor = interceptors!![index] // 注释1
                index++ // 注释2 
                interceptor.intercept(this) // 注释3
            }
            // 最后一个弹窗关闭的时候,我们希望释放所有弹窗引用。
            interceptors!!.size -> {
                "===> clearAllInterceptors".logI(this)
                clearAllInterceptors()
            }
        }
    }

我们先看注释1处,当 DialogChain 第一次调用 process() 时(此时 index值为0),注释1 拿到的就是 interceptors 集合中的第 一 个元素(我们假设ADialog 就是 interceptors 集合中的第 一个元素),经过注释2处之后,index值自增1,再经注释3处将DialogChain 对象传进 ADialog 去,那么此时 ADialog 中拿到的就是 index==1 的DialogChain 对象, 那么在 ADialog 中任意地方再调用 DialogChain process()方法就又拿到interceptors 集合中的第 二个元素继续做文章,以此类推......

我们继续回到ADialog 代码中,对于ADialog ,我们期望的业务逻辑是:
1.当注释2条件为true时才能弹出ADialog ,否则就走注释3处的逻辑,把“请求”交给下一个 DialogInterceptor 处理......
2.假设注释2处条件为true,ADialog 成功弹了出来,那么不管是点击了“取消”还是“确定”,我们希望在它消失的时候将“请求”转交给下一个DialogInterceptor 处理。

而实际业务场景也是常常如此,ADialog 关闭之后再弹出BDialog......

step3 衍生出 BDialog:


class BDialog(context: Context) : BaseDialog(context), View.OnClickListener {
    private var data: String? = null

    //  注释1:这里注意:intercept(chain: DialogChain)方法与 onDataCallback(data: String)方法被调用的先后顺序是不确定的
    fun onDataCallback(data: String) {
        this.data = data
        tryToShow()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.dialog_b)
        findViewById<View>(R.id.tv_confirm)?.setOnClickListener(this)
        findViewById<View>(R.id.tv_cancel)?.setOnClickListener(this)
        // 弹窗消失时把请求移交给下一个拦截器。
        setOnDismissListener {
            chain()?.process()
        }
    }

    override fun onClick(p0: View?) {
        dismiss()
    }
   // 注释2 这里注意:intercept(chain: DialogChain)方法与 onDataCallback(data: String)方法被调用的先后顺序是不确定的
    override fun intercept(chain: DialogChain) {
        super.intercept(chain)
        tryToShow()
    }

    private fun tryToShow() {
        // 只有同时满足这俩条件才能弹出来。
        if (data != null && chain() != null) {
            this.show()
        }
    }
}

附 dialog_b.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/darker_gray"
    xmlns:app="http://schemas.android.com/apk/res-auto">


    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="我是Dialog B"
        android:textSize="23sp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:textColor="@android:color/black"/>

    <TextView
        android:id="@+id/tv_cancel"
        android:layout_width="wrap_content"
        android:layout_height="40dp"
        android:text="取消"
        android:textSize="23sp"
        android:gravity="center"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/line"
        app:layout_constraintBottom_toBottomOf="parent"
        android:textColor="@android:color/holo_orange_dark"/>
    <View
        android:id="@+id/line"
        android:layout_width="1dp"
        android:layout_height="20dp"
        android:background="@android:color/darker_gray"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>
    <TextView
        android:id="@+id/tv_confirm"
        android:layout_width="wrap_content"
        android:layout_height="40dp"
        android:text="确定"
        android:textSize="23sp"
        android:gravity="center"
        app:layout_constraintStart_toEndOf="@id/line"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:textColor="@android:color/holo_orange_dark"/>

</androidx.constraintlayout.widget.ConstraintLayout>

附效果图:

image.png

对于BDialog 的业务场景就比较复杂一点,当弹窗请求到达的时候(即 intercept(chain: DialogChain) 被调用),可能由于网络数据没回来或者其他一些异步原因导致自己不能立刻弹出来,而是需要“等一会儿”才能弹出来,又或者网络数据已经回来,但弹窗请求又没达到(即intercept(chain: DialogChain) 尚未被调用),总而言之就是注释2处和注释2处被调用的顺序是不确定的,但可以确定的是,假设注释1先被调用,则data字段必不为null,假设注释2先被调用,则chain()也必不为null,这时候就需要这两处都要触发一次 tryToShow() 方法,从而完成弹窗。

从这可以看到,我们设计的框架就很好的满足了我们的需求,代码也很优雅,很内聚!

step 4 衍生出 CDialog 作为 链的最后一个节点:

class CDialog(context: Context) : BaseDialog(context), View.OnClickListener {


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.dialog_c)
        findViewById<View>(R.id.tv_confirm)?.setOnClickListener(this)
        findViewById<View>(R.id.tv_cancel)?.setOnClickListener(this)
        // 弹窗消失时把请求移交给下一个拦截器。
        setOnDismissListener {
            chain()?.process()
        }
    }

    override fun onClick(p0: View?) {
        dismiss()
    }

    override fun intercept(chain: DialogChain) {
        super.intercept(chain)
        val isShow = true // 这里可根据实际业务场景来定制dialog 显示条件。
        if (isShow) {
            this.show()
        } else { // 当自己不具备弹出条件的时候,可以立刻把请求转交给下一个拦截器。
            chain.process()
        }
    }

}

附近效果图:

dialog_c

对于CDialog 就没啥复杂业务场景了,如同ADialog。不过值得一提的是,由于CDialog 作为 DialogChain 的最后一个节点,那么当它调用 chain()?.process() 方法时,将走到如下代码注释4处的逻辑:

 fun process() {
        interceptors ?: return
        when (index) {
            in interceptors!!.indices -> {

                val interceptor = interceptors!![index] // 注释1
                index++ // 注释2 
                interceptor.intercept(this) // 注释3
            }
            // 注释4 最后一个弹窗关闭的时候,我们希望释放所有弹窗引用。
            interceptors!!.size -> {
                "===> clearAllInterceptors".logI(this)
                clearAllInterceptors()
            }
        }
    }

** 附 clearAllInterceptors() 方法代码:**

 private fun clearAllInterceptors() {
        interceptors?.clear()
        interceptors = null
    }

很显然,当最后一个Dialog关闭时,我们释放了整条链的内存。

step5 在MainActivity 里最终完成用法示例:

class MainActivity : AppCompatActivity() {

    private lateinit var dialogChain: DialogChain

    private val bDialog by lazy { BDialog(this) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        DialogChain.openLog(true)
        createDialogChain() //创建 DialogChain
        // 模拟延迟数据回调。
        Handler().postDelayed({
            bDialog.onDataCallback("延迟数据回来了!!")
        },10000)
    }

   //创建 DialogChain
    private fun createDialogChain() {
        dialogChain = DialogChain.create(3)
            .attach(this)
            .addInterceptor(ADialog(this))
            .addInterceptor(bDialog)
            .addInterceptor(CDialog(this))
            .build()

    }

    override fun onStart() {
        super.onStart()
        // 开始从链头弹窗。
        dialogChain.process()
    }
}

点击运行之,如图:


附 gitee 地址:https://gitee.com/cen-shengde/dialog-chain

推荐阅读更多精彩内容