开发需求记录:实现app任意界面弹框与app置于后台时通知

前言

在产品经理提需求时候提到,app在接收到报警信息时候能不能弹出一个弹框,告诉用户报警信息,这个弹框要在app的任意界面能够弹出,并且用户点击详情时候,会跳转到报警详情界面,查看具体信息,当用户将app至于后台的时候,接收到报警信息,app发送通知,当用户点击通知时候,跳转到报警详情界面。 功能大体总结如上,在实现弹框与通知在跳转界面时遇到一些问题,在此记录一下。效果图如下:

功能分析

弹框实现,使用DialogFragment。

前后台判断则是,创建一个继承自ActivityLifecycleCallbacks接口和Application的类,继承ActivityLifecycleCallbacks接口是为了前后台判断,继承Application则是方便在基类BaseActivity获取前后台相关数据。

项目原本采用单Activity多Fragment实现,后面因为添加了视频相关功能,改为了多Activity多Fragment。

原单Activity时候,实现比较容易。后面修改为多Activity,就有些头疼,最终用思路是创建基类BaseActivity,后面添加Activity时都要继承基类BaseActivity。使用基类原因是把相同的功能抽取出来,且若每个Activity都自己实现弹框和通知的话太容易出错,也太容易漏下代码了。

代码实现

弹框

在实现继承自DialogFragment的弹框时,需要在onCreateDialog方法内设置dialog的宽高模式以及背景,不然弹框会有默认的边距,导致显示效果与预期不符,未去边距与去掉边距的弹框效果如下: 关于onCreateDialog的代码如下:

kotlin 复制代码
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
    val dialog = Dialog(requireContext())
    dialog.setContentView(R.layout.custom_dialog_layout)
    dialog.window?.apply {
        setLayout(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT)
        setBackgroundDrawable(ColorDrawable(Color.parseColor("#88000000")))//去掉DialogFragment的边距
    }
    dialog.setCancelable(false)
    return dialog
}

此外当弹框出现的时候,弹框背景色还会闪烁。这里采用属性值动画设置弹框背景色控件的透明度变换。完整的Dialog代码如下:

kotlin 复制代码
class AlarmDialogFragment: DialogFragment() {
    private lateinit var binding:CustomDialogLayoutBinding
    private var animator:ObjectAnimator? = null

    override fun show(manager: FragmentManager, tag: String?) {
        try {
            super.show(manager, tag)
        }catch (e:Exception){
            e.printStackTrace()
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = CustomDialogLayoutBinding.inflate(inflater)
        return binding.root
    }

    override fun onStart() {
        super.onStart()
        binding.viewAlarmDialogBg
        startAnimation()
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = Dialog(requireContext())
        dialog.setContentView(R.layout.custom_dialog_layout)
        dialog.window?.apply {
            setLayout(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT)
            setBackgroundDrawable(ColorDrawable(Color.parseColor("#88000000")))//去掉DialogFragment的边距
        }
        dialog.setCancelable(false)
        return dialog
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initView()
    }

    override fun onDestroy() {
        super.onDestroy()
        if(animator?.isStarted == true){
            animator?.end()
        }
    }

    private fun initView() {
        binding.btnCloseDialog.setOnClickListener {
            dismiss()
        }

        binding.btnDialogNav.setOnClickListener {
            if(context is MainActivity){
                val bundle = Bundle()
                bundle.putString("alarmId","1")
                findNavController().navigate(R.id.alarmDetailFragment,bundle)
            }else{
                val intent = Intent(context,MainActivity::class.java)
                intent.putExtra("task","toAlarmDetail")
                startActivity(intent)
            }
            dismiss()
        }
    }

    private fun startAnimation() {
        animator = ObjectAnimator.ofFloat(binding.viewAlarmDialogBg, "alpha", 0f, 0.6f, 0f, 0.6f, 0f)
        animator?.duration = 1200
        animator?.interpolator = AccelerateInterpolator()
        animator?.start()
    }
}

需要注意地方是,由于弹框还负责跳转,而跳转有两种情况,一种是在ActivityA内,fragmentA与fragmentB间的跳转,这种情况使用findNavController().navigate()方法进行跳转,另一种是ActivityB到另一个ActivityA内的指定FragmentB界面。这种采用startActivity(intent)方式跳转,并且在ActivityA的onStart()的方法使用下面方法。

kotlin 复制代码
/** 跳转报警详情界面 */
private fun initToAlarmDetail() {
    val task = intent.getStringExtra("task")
    if (task == "toAlarmDetail"){
        val bundle = Bundle()
        bundle.putString("alarmId","1")
        findNavController(R.id.fragment_main_table).navigate(R.id.alarmDetailFragment,bundle)
    }
}

这样从ActivityB到另一个ActivityA时候,在onStart()方法内会触发上面的initToAlarmDetail()方法,获取跳转里面的信息,在决定具体跳转到哪个Fragment。这里解释的可能不太清楚,可以在Github下载源码看看可能更好理解些。

弹框对应的xml文件代码,可以在Github内查看,可以自己写一个,这个xml比较简单,只是xml代码比较占地方这里就不粘贴了。

前后台判断

关于前后台判断,需要创建一个继承ActivityLifecycleCallbacks和Application的类,这里命名为CustomApplication,在类里面实现ActivityLifecycleCallbacks接口相关方法,此外需要创建下面三个变量,分别表示activity数量,当前activity的名称,是否处于后台,代码如下:

typescript 复制代码
private var activityCount = 0
private var nowActivityName:String? = null
private var isInBackground = true

之后需要在onActivityStarted,onActivityResumed,onActivityStopped方法内进行前后台相关处理,代码如下:

kotlin 复制代码
override fun onActivityStarted(activity: Activity) {
    activityCount++
    if (isInBackground){
        isInBackground = false
    }
    nowActivityName = activity.javaClass.name
}

override fun onActivityStopped(activity: Activity) {
    activityCount--
    if (activityCount == 0 && !isInBackground){
        isInBackground = true
    }
}

上面代码可以看出,当触发onActivityStarted方法时候,activityCount数量加一,且app处于前台。之后记录当前activity名称,这里记录activity名称是后面有个功能是app置于后台时候弹出通知,而通知相关操作,为了每个activity都能实现就放在基类执行,而弹出通知并不需要每个继承基类的activity都执行,到时候需要根据根据nowActivityName判断哪个继承了基类的activity执行通知操作。

当触发onActivityStopped方法时候,activityCount数量减一,且当activityCount数量为零时,app置于后台。 CustomApplication完整代码如下:

kotlin 复制代码
class CustomApplication: Application(),Application.ActivityLifecycleCallbacks {
    companion object{
        const val TAG = "CustomApplication"
        @SuppressLint("CustomContext")
        lateinit var context: Context 
    }

    private var activityCount = 0
    private var nowActivityName:String? = null
    private var isInBackground = true

    fun getNowActivityName(): String? {
        return nowActivityName
    }

    fun getIsInBackground():Boolean{
        return isInBackground
    }

    override fun onCreate() {
        super.onCreate()
        context = applicationContext
        registerActivityLifecycleCallbacks(this)
    }

    override fun onActivityCreated(activity: Activity, p1: Bundle?) {

    }

    override fun onActivityStarted(activity: Activity) {
        activityCount++
        if (isInBackground){
            isInBackground = false
        }
        nowActivityName = activity.javaClass.name
    }

    override fun onActivityResumed(activity: Activity) {

    }

    override fun onActivityPaused(activity: Activity) {

    }

    override fun onActivityStopped(activity: Activity) {
        activityCount--
        if (activityCount == 0 && !isInBackground){
            isInBackground = true
        }
    }

    override fun onActivitySaveInstanceState(activity: Activity, p1: Bundle) {

    }

    override fun onActivityDestroyed(activity: Activity) {

    }
}

弹框与通知弹出

开发中弹框与通知弹出的触发条件是,监听Websocket若有信息过来,app处于前台弹框,处于后台弹通知。这里使用Handler来模拟,弹框弹出比较简单,若有继承了DialogFragment的AlarmDialogFragment类。代码如下:

scss 复制代码
val dialog = AlarmDialogFragment()
dialog.show(supportFragmentManager,"tag")

通知弹出也不难,若只是弹出通知示例代码如下:

erlang 复制代码
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    val channel =
        NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
    notificationManager?.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this.applicationContext, "normal")
    .setContentTitle("标题")
    .setContentText("通知次数:${++alarmCount}")
    .setSmallIcon(R.drawable.ic_launcher_background)
    .setTimeoutAfter(5000)
    .setAutoCancel(true)
    .build()
notificationManager?.notify(notificationId,notification)

弹框与通知的特殊要求是,能在界面任意地方弹出且跳转到指定界面。弹框跳转相关代码在上面'弹框'部分,下面来说下通知的跳转,点击通知跳转是通过创建PendingIntent后在设置进NotificationCompat的setContentIntent方法内,不过通知跳转与弹框跳转一样需要分两种情况考虑,第一种同一Activity内Fragment与Fragment跳转,这种情况下PendingIntent如下代码所示:

css 复制代码
var pendingIntent:PendingIntent? = null
val bundle = Bundle()
bundle.putString("alarmId","1")
pendingIntent = NavDeepLinkBuilder(this)
    .setGraph(R.navigation.main_navigation)
    .setDestination(R.id.alarmDetailFragment)
    .setArguments(bundle)
    .createPendingIntent()

上面代码中使用NavDeepLinkBuilder创建了一个PendingIntent,并且使用setGraph()指向使用的导航图,setDestination()则指向目标Fragment。 另一种情况则是ActivityB到另一个ActivityA内的指定FragmentB界面,这种情况下PendingIntent设置代码如下:

less 复制代码
val intent = Intent(this@BaseActivity,MainActivity::class.java)
intent.putExtra("task","toAlarmDetail")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
pendingIntent = TaskStackBuilder.create(this@BaseActivity)
    .addNextIntentWithParentStack(intent)
    .getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)

这种是创建一个跳转到MainActivity的Intent,并添加传递的参数task,接着设置Intent的启动方式,其中Intent.FLAG_ACTIVITY_NEW_TASK,表示启动Activity作为新任务启动,Intent.FLAG_ACTIVITY_CLEAR_TASK,表示清除任务栈中所有现有的Activity。之后调用TaskStackBuilder创建PendingIntent。 上面两种方式创建的PendingIntent可以通过NotificationCompat.setContentIntent(pendingIntent)添加进去,关于通知创建的代码如下:

kotlin 复制代码
/** 使用通知 - 通过pendingIntent实现跳转,缺点是任意界面进入报警详情界面,点击返回键只能返回MainFragment */
    private fun useNotificationPI() {
        var pendingIntent:PendingIntent? = null
        if(javaClass.simpleName == "MainActivity"){//主界面
            val bundle = Bundle()
            bundle.putString("alarmId","1")
            pendingIntent = NavDeepLinkBuilder(this)
                .setGraph(R.navigation.main_navigation)
                .setDestination(R.id.alarmDetailFragment)
                .setArguments(bundle)
                .createPendingIntent()
        }else {//其他界面时候切换后台通知
            val intent = Intent(this@BaseActivity,MainActivity::class.java)
            intent.putExtra("task","toAlarmDetail")
            intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
            pendingIntent = TaskStackBuilder.create(this@BaseActivity)
                .addNextIntentWithParentStack(intent)
                .getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel =
                NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
            notificationManager?.createNotificationChannel(channel)
        }
        val notification = NotificationCompat.Builder(this.applicationContext, "normal")
            .setContentTitle("标题")
            .setContentText("通知次数:${++alarmCount}")
            .setSmallIcon(R.drawable.ic_launcher_background)
            .setTimeoutAfter(5000)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .build()
        notificationManager?.notify(notificationId,notification)
    }

上面代码中if(javaClass.simpleName == "MainActivity"),及第四行代码,该代码用处是当app置于后台时候,pp界面是MainActivity时,pendingIntent使用NavDeepLinkBuilder生成,当是其他Activity时使用TaskStackBuilder生成。之所以这样是因为,在MainActivity的xml,使用了FragmentContainerView用于fragment间跳转,其他的Activity没有FragmentContainerView,因此在生成pendingIntent需要采用不同的方式生成。

这示例代码中,主要涉及到的Avtivity有MainActivity与VideoActivity,MainActivity使用FragmentContainerView,而VideoActivity没有。弹框与通知跳转的界面是AlarmDetailFragment,这个fragment在MainActivity通过Navigation实现导航。

因此在MainActivity界面进入后台时,pendingIntent使用NavDeepLinkBuilder生成,NavDeepLinkBuilder则可以使用导航图中fragment生成深度链接URI,这个URI则可以导航到指定的fragment(关于NavDeepLinkBuilder了解不深入,这里说的可能有错误地方,欢迎大佬指正)。

而VideoActivity界面进入后台时,就需要使用TaskStackBuilder生成一个启动MainActivity的Intent。而在MainActivity的onStart方法内有下面initToAlarmDetail方法,判断跳转时携带参数决定是否跳转到AlarmDetailFragment界面。

kotlin 复制代码
/** 跳转报警详情界面 */
private fun initToAlarmDetail() {
    val task = intent.getStringExtra("task")
    if (task == "toAlarmDetail"){
        val bundle = Bundle()
        bundle.putString("alarmId","1")
        findNavController(R.id.fragment_main_table).navigate(R.id.alarmDetailFragment,bundle)
    }
}

至此弹框与通知的功能基本实现,完整的BaseActivity代码如下:

kotlin 复制代码
open class BaseActivity: AppCompatActivity() {
    companion object{
        const val TAG = "BaseActivity"
    }
    private var alarmCount = 0
    private val handler = Handler(Looper.myLooper()!!)
    //为了关闭通知,manager放在外面
    private val notificationId = 1
    private var alarmDialogFragment: AlarmDialogFragment? = null
    private var notificationManager:NotificationManager? = null
    private var bgServiceIntent:Intent? = null//前台服务

    private var nowClassName = ""

    /** 弹框定时任务 */
    private val dialogRunnable = object : Runnable {
        override fun run() {
            //在定时方法里面 javaClass.simpleName 不能获取当前所处Activity的名称
            if (nowClassName == "VideoActivity"){ //视频界面不弹弹框
                CustomLog.d(TAG,"不使用弹框 ${nowClassName}")
            }else{
                CustomLog.d(TAG,"使用弹框 ${nowClassName}")
                useDialog()
                handler.postDelayed(this, 10000)
            }
        }
    }

    /** 通知定时任务 */
    private val notificationRunnable = object :Runnable{
        override fun run() {
            useNotificationPI()
            handler.postDelayed(this,10000)
        }
    }

    override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
        initWindow()
        return super.onCreateView(name, context, attrs)
    }

    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        super.onCreate(savedInstanceState, persistentState)
        CustomLog.d(TAG,"onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) 当前类:${javaClass.simpleName}")
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        CustomLog.d(TAG,"onCreate(savedInstanceState: Bundle?) 当前类:${javaClass.simpleName}")
        initData()
    }

    override fun onStart() {
        super.onStart()
        CustomLog.d(TAG,"onStart 当前类:${javaClass.simpleName}")
        nowClassName = javaClass.simpleName
        handler.postDelayed(dialogRunnable, 3000)
        initService()
    }

    override fun onResume() {
        super.onResume()
        CustomLog.d(TAG,"onResume 当前类:${javaClass.simpleName}")
    }

    override fun onRestart() {
        super.onRestart()
        CustomLog.d(TAG,"onRestart 当前类:${javaClass.simpleName}")
    }

    override fun onPause() {
        super.onPause()
        CustomLog.d(TAG,"onPause 当前类:${javaClass.simpleName}")
    }

    override fun onStop() {
        super.onStop()
        CustomLog.d(TAG,"onStop 当前类:${javaClass.simpleName}")
        val customApplication = applicationContext as CustomApplication
        val nowActivityName = customApplication.getNowActivityName()
        val activitySimpleName = nowActivityName?.substringAfterLast(".")
        CustomLog.d(TAG,"activitySimpleName:$activitySimpleName")
        val isInBackground = (this@BaseActivity.applicationContext as CustomApplication).getIsInBackground()
        if (isInBackground && activitySimpleName.equals(javaClass.simpleName)){// 处于后台 且 切换至后台app的activity页面名称等于当前基类里面获取activity类名
            handler.postDelayed(notificationRunnable,3000)
            CustomLog.d(TAG,"使用通知 $nowClassName")
        }else{
            CustomLog.d(TAG,"关闭所有定时任务 $nowClassName")
            closeAllTask()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        CustomLog.d(TAG,"onDestroy 当前类:${javaClass.simpleName}")
        closeAllTask()
        this.stopService(bgServiceIntent)
    }

    /** 关闭所有定时任务 */
    private fun closeAllTask() {
        handler.removeCallbacks(dialogRunnable)
        handler.removeCallbacks(notificationRunnable)
    }

    /** 初始化数据 - 关于弹框*/
    private fun initData() {
        notificationManager = notificationManager ?: this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        alarmDialogFragment = alarmDialogFragment ?: AlarmDialogFragment()
    }

    /** 使用通知 - 通过pendingIntent实现跳转,缺点是任意界面进入报警详情界面,点击返回键只能返回MainFragment */
    private fun useNotificationPI() {
        var pendingIntent:PendingIntent? = null
        if(javaClass.simpleName == "MainActivity"){//主界面
            CustomLog.d(TAG,">>>通知:MainActivity")
            val bundle = Bundle()
            bundle.putString("alarmId","1")
            pendingIntent = NavDeepLinkBuilder(this)
                .setGraph(R.navigation.main_navigation)
                .setDestination(R.id.alarmDetailFragment)
                .setArguments(bundle)
                .createPendingIntent()
        }else {//其他界面时候切换后台通知
            CustomLog.d(TAG,">>>通知:else")
            val intent = Intent(this@BaseActivity,MainActivity::class.java)
            intent.putExtra("task","toAlarmDetail")
            intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
            pendingIntent = TaskStackBuilder.create(this@BaseActivity)
                .addNextIntentWithParentStack(intent)
                .getPendingIntent(0,PendingIntent.FLAG_UPDATE_CURRENT)
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel =
                NotificationChannel("normal", "Normal", NotificationManager.IMPORTANCE_DEFAULT)
            notificationManager?.createNotificationChannel(channel)
        }
        val notification = NotificationCompat.Builder(this.applicationContext, "normal")
            .setContentTitle("标题")
            .setContentText("通知次数:${++alarmCount}")
            .setSmallIcon(R.drawable.ic_launcher_background)
            .setTimeoutAfter(5000)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .build()
        notificationManager?.notify(notificationId,notification)
    }

    /** 弹框使用 - 因为此处涉及到fragment等生命周期,进入其他activity内时候,在前的activity使用useDialog会因为生命周期问题闪退*/
    private fun useDialog() {
        //弹出多个同种弹框
//        alarmDialogFragment = AlarmDialogFragment()
//        alarmDialogFragment?.show(supportFragmentManager,"testDialog")

        //不弹出多个同种弹框,一次只弹一个,若弹框存在不弹新框
        if (alarmDialogFragment?.isVisible == false){//如果不加这一句,当弹框存在时候在调用alarmDialogFragment.show的时候会报错,因为alarmDialogFragment已经存在
            alarmDialogFragment?.show(supportFragmentManager,"testDialog")
        }else{
            //更新弹框内信息
        }
    }

    /** 关闭报警弹框 */
    private fun closeAlarmDialog() {
        if (alarmDialogFragment?.isVisible == true) {
            alarmDialogFragment?.dismiss()//要关闭的弹框
        }
    }

    //状态栏透明,且组件占据了状态栏
    private fun initWindow() {
        window.statusBarColor = Color.TRANSPARENT
        window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
    }

    /** 初始化服务 */
    private fun initService() {
        CustomLog.d(TAG,"开启前台服务")
        bgServiceIntent = bgServiceIntent ?: Intent(this, BackgroundService::class.java)
        this.startService(bgServiceIntent)
    }
}

总结

只是弹出弹框和通知的话,实现很好实现,中间麻烦地方在于当app使用多个Activity,该怎么实现跳转到指定的界面。当然这里麻烦是,从ActivityB跳转到ActivityA的Fragment,如果是只有一个Activity应该会好办些。个人感觉fragment跳转应该有更好的方式实现希望能和大佬们交流下这种情况下,用什么技术实现。

PS:感觉原生Android在写界面和跳转方面写起来不太方便。不知道大家有便捷的方式吗。

代码地址

GitHub:github.com/SmallCrispy...

相关推荐
Kapaseker3 小时前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴3 小时前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭13 小时前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab14 小时前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe19 小时前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农1 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos
鹏程十八少1 天前
4.Android 30分钟手写一个简单版shadow, 从零理解shadow插件化零反射插件化原理
android·前端·面试
Kapaseker1 天前
一杯美式搞定 Kotlin 空安全
android·kotlin
三少爷的鞋1 天前
Android 协程时代,Handler 应该退休了吗?
android
火柴就是我2 天前
让我们实现一个更好看的内部阴影按钮
android·flutter