最近生活有些变动所以断更良久,不过虽迟到但永久不会缺席。ChatGPT 浪潮还在持续扩大,各位同学一定要体验体验丫~

这篇首要介绍最近需求中遇到的问题,期望能协助后来者少踩坑。先说定论:Android 原生画中画功用并不完善,假如能够承受 APP 有两个使命栈则能够运用;不然趁早自己用浮窗自定义完成画中画的功用吧。

1. PiP 简介

Android PiP 形式也称之为画中画形式,答运用户在运用运用程序的一起,在屏幕的一角或一侧起浮显现另一个运用程序或视频。这使得用户能够一起进行多项使命,而不必切换运用程序或中断正在进行的使命。如下所示:

Android 原生 Picture in Picture 画中画功能避坑指南

(注: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 中已有示例,这儿不再赘述。

Android 原生 Picture in Picture 画中画功能避坑指南

除此之外,还要在需求进入 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,此刻有两个视频一起在播映:

Android 原生 Picture in Picture 画中画功能避坑指南

检查堆栈信息的确有两个 MovieActivity:

Android 原生 Picture in Picture 画中画功能避坑指南

这种情况下是需求将 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 现已打开了。。。

打开失利的动图:

Android 原生 Picture in Picture 画中画功能避坑指南

在 MovieActivity 中点击 JUMP TO TESTONEACTIVITY 按钮跳转之后,堆栈信息如下,能够看到 pid = 21126 的进程就是 Demo 程序,TestOneActivity 的确打开了,MovieActivity 已退出:
Android 原生 Picture in Picture 画中画功能避坑指南

加延时再试:

// code 10
        binding.btnJumpTestOne.setOnClickListener {
            lifecycleScope.launch {
                enterPictureInPictureMode(updatePictureInPictureParams())
                delay(1000)
                startActivity(Intent(this@MovieActivity, TestOneActivity::class.java))
            }
        }

的确进入 PiP 了,但后边跳转的 TestOneActivity 也在 PiP 了。。

Android 原生 Picture in Picture 画中画功能避坑指南

假如是先跳转 TestOneActivity 再进入 PiP ,经测验只会跳转并不会进入 PiP,这儿就不再展示了。

经剖析和实践发现,只能先进入 PiP 再进行跳转,之所以会呈现在 PiP 里跳转,是因为后边跳转的 TestOneActivity 进入了 MovieActivity 地点的使命栈。Activity 在没有设置 taskAffinity 特点时,都会放在默许的同一个使命栈中。

所以想到的第一个办法就是,修正 MovieActivity 的 launchMode,改为 singleInstance。这样既能够确保使命栈中只要一个 MovieActivity 的实例,也能够将 MovieActivity 放在独立的使命栈中。试了下果然能够了,但会在多使命切换页里呈现同一个 App 有两个使命栈的现象:

Android 原生 Picture in Picture 画中画功能避坑指南

这是第一个问题,这个问题直到最终也无法处理,在 AndroidManifest 文件中增加 autoRemoveFromRecentsexcludeFromRecents 都没用,仍是会在多使命切换页呈现两个栈。

还有一个问题即问题二,仍是 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:

Android 原生 Picture in Picture 画中画功能避坑指南

经剖析,原因是 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 中跳转。

Android 原生 Picture in Picture 画中画功能避坑指南

这儿将 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 专辑

赞人玫瑰,手留余香!欢迎点赞、转发~ 转发请注明出处~

参考文献

  1. 官方文档:https://developer.android.google.cn/guide/topics/ui/picture-in-picture?hl=zh-cn
  2. 总结系列-Android画中画形式-看这篇就够啦; ZhangQiang-; https://blog.csdn.net/u011200604/article/details/104701266
  3. Android 面试黑洞——当我按下 Home 键再切回来,会发生什么?; 扔物线朱凯; https://www.bilibili.com/video/BV1CA41177Se/