最近生活有些变动所以断更良久,不过虽迟到但永久不会缺席。ChatGPT 浪潮还在持续扩大,各位同学一定要体验体验丫~
这篇首要介绍最近需求中遇到的问题,期望能协助后来者少踩坑。先说定论:Android 原生画中画功用并不完善,假如能够承受 APP 有两个使命栈则能够运用;不然趁早自己用浮窗自定义完成画中画的功用吧。
1. PiP 简介
Android PiP 形式也称之为画中画形式,答运用户在运用运用程序的一起,在屏幕的一角或一侧起浮显现另一个运用程序或视频。这使得用户能够一起进行多项使命,而不必切换运用程序或中断正在进行的使命。如下所示:
(注:B站的 PiP 是自定义完成的,未运用体系 PiP)
2. 准备工作,跑通 Demo
官方文档:developer.android.google.cn/guide/topic…
官方Demo:github.com/android/med…
打开官方 Demo,首要得改一下 minSdkVersion,demo 里设置的是 API 31(Android 12.0),不满足实践运用需求,这儿改为 23(Android 6.0). 但 PiP 功用只能在 Android8.0 及以上的体系上运用,所以用到一些办法时,需求注明 @RequiresApi(Build.VERSION_CODES.O)
。所以,假如需求在 Android 8.0 以下的设备支撑 PiP,只能运用自定义悬浮窗完成。
还需求注释掉 setAutoEnterEnabled(true)
、setSeamlessResizeEnabled(false)
这两个办法。因为它们只能在 Android 12.0 及以上体系运用,且对于 PiP 的主体功用没有影响。setAutoEnterEnabled
用于设置 Activity 在退到后台时是否主动进入 PiP 形式,当设置为 true,则在用户点击 Home 键回到主屏幕时,Activity 可主动进入 PiP 形式,而不必开发者手动调用 enterPictureInPictureMode
办法;setSeamlessResizeEnabled
用于设置非视频画中画时的动画效果,不影响功用。
按照上述的内容设置完后就能够将 Demo 跑通了。
3. 示例代码剖析
仅剖析检查了 Demo 中的 MovieActivity 中的 PiP 相关的代码。比较重要的代码如下:
// code 1
@RequiresApi(Build.VERSION_CODES.O)
private fun minimize() {
enterPictureInPictureMode(updatePictureInPictureParams())
}
调用 enterPictureInPictureMode(@NonNull PictureInPictureParams params)
办法就能够进入 PiP,声明如下:
// code 2
public boolean enterPictureInPictureMode(@NonNull PictureInPictureParams params) {
}
办法简介:它是 Activity 类中的办法,需求传递一个 PictureInPictureParams 类型方针。当体系成功将该 Activity 切换到 PiP 形式或现已处于 PiP,则回来值为 true;假如设备不支撑 PiP 则回来 false。
再来看下构建 PictureInPictureParams 类型方针的 updatePictureInPictureParams()
办法:
// code 3
@RequiresApi(Build.VERSION_CODES.O)
private fun updatePictureInPictureParams(): PictureInPictureParams {
// 1、核算出 PiP 小窗的宽高比,这儿直接运用播映视频的控件宽和高核算
val aspectRatio = Rational(binding.movie.width, binding.movie.height)
// 2、将播映视频的控件binding.movie设置为 PiP 中要展示的部分
val visibleRect = Rect()
binding.movie.getGlobalVisibleRect(visibleRect)
val params = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
// 3、指定进入画中画的屏幕部分。体系依据这个可完成滑润动画效果。这儿就把之前生成的 visibleRect 传值曩昔
.setSourceRectHint(visibleRect)
.build()
setPictureInPictureParams(params)
return params
}
updatePictureInPictureParams
办法效果是构建出进入 PiP 的一些参数,比方进入小窗的控件,小窗的宽高比等。注释很清楚,源码直接拿来套用就行。需求注意的点:只能指定 PiP 形式的宽高比,并不能直接设置宽和高的详细值,体系会依据设置的宽高比自己核算详细值。
假如在播映器控件上层有其他的操作按钮等,还需求在 onPictureInPictureModeChanged
回调中进行处理,即进入 PiP 后躲藏这些按钮;退出后恢复这些按钮的状况。 如下是 Demo 中的完成:
// code 4
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean, newConfig: Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (isInPictureInPictureMode) {
// Hide the controls in picture-in-picture mode.
binding.movie.hideControls()
} else {
// Show the video controls if the video is not playing
if (!binding.movie.isPlaying) {
binding.movie.showControls()
}
}
}
经过这个办法能够监听 PiP 的进入和退出。
还有一些是 PiP 形式下的播映/暂停、上一个/下一个 操作按钮,即下图红框中的这三个按钮,相关的运用方式 Demo 中已有示例,这儿不再赘述。
除此之外,还要在需求进入 PiP 的 Activity 的 AndroidManifest 中设置支撑 PiP 的特点以及处理布局配置更改。这样一来,假如在 PiP 形式转化期间呈现布局更改,该 Activity 就不会重新发动。
// code 5
<activity android:name="VideoActivity"
android:supportsPictureInPicture="true"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
...
这些官方文档中就有,这儿也不再多说。
4. 功用完成及踩坑汇总
4.1 完成点击 Back 键及 Home 键主动进入 PiP
用户在观看视频时,点击回来键或 Home 键,当时 Activity 需求进入 PiP 继续播映,这是个常见的功用,完成起来也比较简单:
// code 6
// 完成点击回来键进入 PiP
@RequiresApi(Build.VERSION_CODES.O)
override fun onBackPressed() {
enterPictureInPictureMode(updatePictureInPictureParams())
}
// 完成点击 Home 键进入 PiP
@RequiresApi(Build.VERSION_CODES.O)
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterPictureInPictureMode(updatePictureInPictureParams())
}
假如设置了之前说到的 setAutoEnterEnabled(true)
办法,则能够不必在 onUserLeaveHint()
回调里主动调用 enterPictureInPictureMode
办法进入 PiP。但主张仍是不必 setAutoEnterEnabled
,因为它只能在 Android 12 上运用。。。
onUserLeaveHint()
办法也是 Activity 中的办法,当 Activity 进入后台时就会调用它,比方用户点击 Home 键就会回调它。但有来电时,来电的 Activity 会主动带到前台,这时被退到后台的 Activity 的 onUserLeaveHint
办法并不会被调用。onUserLeaveHint
的调用机遇是在 onPause
办法之前,这点需求注意。
4.2 完成 Activity 处于 PiP 时再次进入更新视频
假设 MovieActivity 已处于 PiP 并正在播映视频,用户点击别的一个视频又要跳转到 MovieActivity 的景象。假如不进行处理就会呈现有两个 MovieActivity 一起播映视频的情况,即小窗播映的一起,还有一个另一个 MovieActivity 也在播映。如下所示,本来只要一个 PiP 在播映视频,然后点击 WATCH VIDEO TWO 按钮又进入了 MovieActivity,此刻有两个视频一起在播映:
检查堆栈信息的确有两个 MovieActivity:
这种情况下是需求将 MovieActivity 由 PiP 恢复到正常状况并播映新的视频,假如视频内容没有变则接着播映原视频。官方 Demo 也有阐明怎么处理,需求两个过程:
1)将 MovieActivity 的 launchMode
设置为 singleTask
;
2)在 MovieActivity 的 onNewIntent
办法里处理更新数据等逻辑;
比方我在打开 MovieActivity 时经过 Intent
传递不同的 video 来播映不同的视频,那么在 onNewIntent
中就需求接纳传递的参数并更新:
// code 7
// MainActivity.kt 经过 Intent 传入不同的视频
binding.btnWatchVid1.setOnClickListener {
val intent = Intent(this, MovieActivity::class.java)
intent.putExtra(MovieActivity.KEY_VIDEO_ID, R.raw.vid_bigbuckbunny)
startActivity(intent)
}
binding.btnWatchVid2.setOnClickListener {
val intent = Intent(this, MovieActivity::class.java)
intent.putExtra(MovieActivity.KEY_VIDEO_ID, R.raw.vid_dajiang)
startActivity(intent)
}
// code 8
// MovieActivity.kt onNewIntent 接纳并更新
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
val newVideoId = intent?.getIntExtra(KEY_VIDEO_ID, R.raw.vid_bigbuckbunny)
newVideoId?.let {
// 更新视频
binding.movie.setVideoResourceId(it)
}
}
在实践中或许更加复杂,但大体思路是一致的。
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 专辑
赞人玫瑰,手留余香!欢迎点赞、转发~ 转发请注明出处~
参考文献
- 官方文档:https://developer.android.google.cn/guide/topics/ui/picture-in-picture?hl=zh-cn
- 总结系列-Android画中画形式-看这篇就够啦; ZhangQiang-; https://blog.csdn.net/u011200604/article/details/104701266
- Android 面试黑洞——当我按下 Home 键再切回来,会发生什么?; 扔物线朱凯; https://www.bilibili.com/video/BV1CA41177Se/