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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容