4.3 实现跳转其他 Activity 时,当前 Activity 自动进入 PiP
场景:正在 MovieActivity 里播放视频,用户点击某个按钮跳转到其他 Activity,MovieActivity 此时需进入 PiP,用户可以在新打开的 Activity 页面进行操作。
官方文档里并没有针对这一场景进行说明和提示,所以一开始以为很简单,直接跟之前一样调用 enterPictureInPictureMode(updatePictureInPictureParams())
不就可以了么?于是就有了下面的代码:
// code 9 binding.btnJumpTestOne.setOnClickListener { enterPictureInPictureMode(updatePictureInPictureParams()) startActivity(Intent(this@MovieActivity, TestOneActivity::class.java)) }
想着先将当前的 MovieActivity 进入 PiP,再跳转到其他的 Activity,结果 MovieActivity 直接退出了,也没有错误信息。看栈信息发现其实要跳转的新 Activity —— TestOneActivity 已经打开了。。。
打开失败的动图:
在 MovieActivity 中点击 JUMP TO TESTONEACTIVITY 按钮跳转之后,堆栈信息如下,可以看到 pid = 21126 的进程就是 Demo 程序,TestOneActivity 确实打开了,MovieActivity 已退出:
加延时再试:
// code 10 binding.btnJumpTestOne.setOnClickListener { lifecycleScope.launch { enterPictureInPictureMode(updatePictureInPictureParams()) delay(1000) startActivity(Intent(this@MovieActivity, TestOneActivity::class.java)) } }
确实进入 PiP 了,但后面跳转的 TestOneActivity 也在 PiP 了。。
如果是先跳转 TestOneActivity 再进入 PiP ,经测试只会跳转并不会进入 PiP,这里就不再展示了。
经分析和实践发现,只能先进入 PiP 再进行跳转,之所以会出现在 PiP 里跳转,是因为后面跳转的 TestOneActivity 进入了 MovieActivity 所在的任务栈。Activity 在没有设置 taskAffinity
属性时,都会放在默认的同一个任务栈中。
所以想到的第一个方法就是,修改 MovieActivity 的 launchMode
,改为 singleInstance
。这样既可以保证任务栈中只有一个 MovieActivity 的实例,也可以将 MovieActivity 放在独立的任务栈中。试了下果然可以了,但会在多任务切换页里出现同一个 App 有两个任务栈的现象:
这是第一个问题,这个问题直到最后也无法解决,在 AndroidManifest 文件中添加 autoRemoveFromRecents
和 excludeFromRecents
都没用,还是会在多任务切换页出现两个栈。
还有一个问题即问题二,还是 singleInstance
引起的。当 MovieActivity 正在以非小窗模式播放视频时,先进入多任务切换页,再按 Home 键回到主屏幕,然后再点击 App 图标进入时,发现进入的不是 MovieActivity,而是 MovieActivity 的上个页面,即 MainActivity,此时再进入多任务切换页面,会发现 MovieActivity 所在那个任务栈已经消失了。这里其实有两个问题:
1)回到主屏幕后再点击 App 图标应该回到 MovieActivity;
2)用户并没有关闭 MovieActivity,但进入多任务切换页面后无法找到 MovieActivity 了。如下动图:
问题二的两个问题得先解决 2)才能解决 1)。2)之所以会出现是因为一个 App 出现了两个任务栈,这两个任务栈的 taskAffinity
参数默认是一样的,一山不容二虎,那么点击桌面图标后,就会把之前的任务栈移到前台,然后会把另一个任务栈干掉。
所以首先要保留这两个任务栈,给 MovieActivity 设置一个单独的 taskAffinity
名称,这就可以得以保留,问题 2)就解决了。只有先保留任务栈,才能解决问题 1)。
导致问题1)的原因是因为用户在点击 App 图标时,会将 MainActivity 所在的栈移到前台,那么首先可以想到的方法是,在点击 App 图标时,将含有 MovieActivity 的栈移到前台显示。所以我们可以注册一个生命周期监听,在 onResume
时,去遍历 App 的所有任务栈,找到含有 MovieActivity 的栈并将其移到前台即可:
// code 11 DemoApplication.kt onCreate方法中 registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks{ ··· override fun onActivityResumed(activity: Activity) { val appCompatActivity = if (activity is AppCompatActivity) { activity } else { return } // 限制条件:所有的 activity 必须为 AppCompatActivity 或其子类 val activityManager = appCompatActivity .getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager for (i in activityManager.appTasks.indices) { val appTask = activityManager.appTasks[i] val taskInfo = appTask.taskInfo if (taskInfo.topActivity == null) continue val topActivityName = taskInfo.topActivity?.className if ((!topActivityName.isNullOrBlank() && topActivityName.contains( MovieActivity::class.java.simpleName )) && i != 0 ) { // 如果存在视频播放页且所在的 Task 不在前台,则需要将其移到前台 activityManager.moveTaskToFront( taskInfo.id, ActivityManager.MOVE_TASK_NO_USER_ACTION ) } } } ··· })
很明显这个方法并不好,App 中每个 Activity 在调用 onResume
时都会走一遍这个逻辑;且 App 中所有的 Activity 必须为 AppCompatActivity
或它的子类。还得需要申请 REORDER_TASKS
权限:
// code 12 AndroidManifest.xml <!-- 申请可排序任务栈权限 --> <uses-permission android:name="android.permission.REORDER_TASKS" />
并且这里还遇到一个问题:当在 MovieActivity 跳转到 TestOneActivity 时,进入 PiP,此时点击 PiP 中的关闭按钮关闭 PiP,然后点击 Home 回到桌面,再点击 App 图标会发现进入的是 MovieActivity 页,而并不是 TestOneActivity:
经分析,原因是 PiP 的关闭按钮点击后,只是将 MovieActivity 退到了后台,并没有销毁。。。所以退到后台,再点击 App 图标时,会将包含 MovieActivity 的任务栈显示到前台,而不显示 TestOneActivity 所在的任务栈。那么我们就需要在关闭 PiP 按钮的回调中直接关闭 Activity,但我们开发者拿不到关闭按钮的回调,所以就有了下面的问题:
如何在用户点击 PiP 里的关闭按钮时,关闭 PiP 所在的 Activity?
经多次实验得知,PiP 虽然没有关闭小窗的回调,但会先调用 onStop
然后会调用 onPictureInPictureModeChanged
方法。所以可以根据是否回调了 onStop
来间接判断是否点击了 PiP 小窗里的关闭按钮。
// code 13 // MovieActivity 的 ViewModel class MovieViewModel: ViewModel() { //进入或退出画中画模式所在Activity的事件 true: 进入; false: 退出 val enterOrExitPiPMode = MutableLiveData<Boolean>() } // MovieActivity.kt @RequiresApi(Build.VERSION_CODES.O) override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) if (isInPictureInPictureMode) { ··· } else { ··· // PiP没有关闭小窗的回调,但会先回调 onStop 然后回调 onPictureInPictureModeChanged 方法。可以根据 // 是否回调了 onStop 来间接判断是否点击了PiP小窗里的关闭按钮,这里需要在用户主动关闭小窗后 finish 掉 // MovieActivity if (lifecycle.currentState < Lifecycle.State.STARTED) { movieViewModel.enterOrExitPiPMode.value = false } } } // MovieActivity.kt // 进入 or 退出画中画模式 movieViewModel.enterOrExitPiPMode.observe(this) { if (it) { // 这里暂没有操作 } else { // 关闭画中画模式事件,需要直接 finish 掉 MovieActivity finish() } }
这里使用 LiveData 是因为在其他的页面可能也需要关闭 PiP,所以可以先获得 ViewModel,通过更新 enterOrExitPiPMode
的值去关闭 PiP。
4.4 去掉 PiP 下方自带的三个按钮
PiP 小窗上的 6个按钮只有底部的三个按钮可自定义,另外的三个按钮无法修改,这也是为什么无法拿到关闭按钮回调的原因。如果需要针对底部的三个按钮进行自定义,通过设置 PictureInPictureParams 参数实现,但最多只能自定义 3个,我们这里不需要这三个按钮,就可以设置一个透明按钮间接去掉:
// code 14 // 第一步:新建一个 RemoteAction list @RequiresApi(Build.VERSION_CODES.O) private fun initPiPActions(): List<RemoteAction> { //去掉原生小窗中默认自带的 上一个、暂停、下一个 三个按钮 val actions = mutableListOf<RemoteAction>() val emptyIntent = PendingIntent.getBroadcast(requireContext(), 0, Intent(), PendingIntent.FLAG_IMMUTABLE) actions.add(RemoteAction(Icon.createWithResource(requireContext(), R.drawable.divider_transparent), "", "", emptyIntent)) return actions } // 第二步:设置到 PictureInPictureParams 参数中 val params = PictureInPictureParams.Builder() .setAspectRatio(aspectRatio) // Specify the portion of the screen that turns into the picture-in-picture mode. // This makes the transition animation smoother. .setSourceRectHint(visibleRect) .setActions(initPiPActions()) .build()
自定义底部三个按钮的方法有两种:一是通过实现 RemoteAction 的方法;二是官方 Demo 中的方法。关于这个内容参考文献2 更加详实,可以借鉴。
5. 难以解决的问题
以上的坑基本趟完了,但下面的坑实在是难以解决,这里也欢迎大佬们能给出建议。
5.1 App 出现两个任务栈
为了实现从 MovieActivity 跳转到其他 Activity 时,MovieActivity 自身进入 PiP,必须将 MovieActivity 放到独立的任务栈中,所以就会出现这个问题。以上文中也有说明。
5.2 PiP 模式下跳转一个 singleTask 的 Activity 会在 PiP 中跳转
官方 Demo 中将 MovieActivity 的 launchMode
设置为 singleTask
且不设置 taskAffinity
时,当 MovieActivity 正处于 PiP 模式下,跳转到另一个 Activity 时,目标 Activity 的 launchMode
不能为 singleTask
,否则目标 Activity 会在 PiP 中跳转。
这里将 TestOneActivity 的 launchMode
设置为 singleTask
,然后从 MovieActivity 跳到 TestOneActivity 时,TestOneActivity 出现在了 PiP 中。而通常项目中会有许多 Activity 的 launchMode
设置为了 singleTask
,所以原生 PiP 方案最终被否。。。
github 上也有 issue:github.com/android/med…
6. 小知识点汇总
6.1 ActivityManager.MOVE_TASK_NO_USER_ACTION 的作用
常用于 activityManager.moveTaskToFront
方法中,意思是不把当前的操作看作是用户触发的行为,即不会调用当前 Activity 的 onUserLeaveHint
方法。还有一个是 ActivityManager.MOVE_TASK_WITH_HOME
,这个就会调用当前 Activity 的 onUserLeaveHint
方法。实际应用中貌似很少用到。
6.2 autoRemoveFromRecents 和 excludeFromRecents 的用法
6.2.1 android:autoRemoveFromRecents
用法
android:autoRemoveFromRecents
是在任务栈中的最后一个 Activity 完成之前,由具有此属性的 Activity 启动的任务栈是否保留在多任务切换页面中。即 autoRemoveFromRecents
指定了当 Activity 被系统回收时,是否保留在多任务切换页面中。默认值为 false。
当设置为 true 时: 当 Activity 被系统回收时,从最近使用的多任务切换页中移除该 Activity 所在的任务栈;当设置为 false 时: 当 Activity 被系统回收时,不从最近使用的多任务切换页中移除该 Activity 所在的任务栈。
这个属性主要用于:
1)一些临时 Activity,当它们被销毁后,不希望它们出现在多任务切换页中,可以设置为 true;
2)一些没有重要数据的 Activity,如果设置为 true,当内存不足被系统回收后,由于它已经从多任务切换页移除,用户不太可能再去恢复它,及时移除有利于内存回收;
3)一些包含敏感数据的 Activity,为了安全考虑,不希望它出现在多任务切换页中,可以设置为 true。
所以,总体来说,这个属性主要是出于内存管理和安全考虑,控制 Activity 在被系统回收后是否从多任务切换页中移除。
6.2.2 android:excludeFromRecents
用法
android:excludeFromRecents
也是一个 Activity 属性,它指定了是否从多任务切换页中排除该 Activity 所在的任务栈。默认值为 false。
当设置为 true 时:该 Activity 所在的任务栈不会出现在多任务切换页中;当设置为 false 时:不从多任务切换页中排除该 Activity 所在的任务栈。
这个属性与 android:autoRemoveFromRecents
很像,它们的区别是:
android:autoRemoveFromRecents
是当 Activity 被系统回收时,所在栈是否从多任务切换页中移除;
android:excludeFromRecents
是 Activity 所在的栈从一开始就不会出现在任务列表中。
更多内容,欢迎关注公众号:修之竹 或者查看 修之竹的 Android 专辑
赞人玫瑰,手留余香!欢迎点赞、转发~ 转发请注明出处~