1. 基本使用
- 关于画中画的基本使用,这里就不赘描述了,可以参考官方文档:官方文档 - 对画中画 (PiP) 的支持
- 虽然画中画的相关接口在SDK23就能使用,但实际上画中画是SDK26及以上才开始支持的,所以使用前需要特别注意下SDK的版本。
2. 仿iOS画中画
-
2.1 Android和iOS的画中画效果对比
左图为Android官方例子的效果,右图为iOS系统的效果。
两者都是系统自带的画中画效果,主要区别在于
Android
的画中画以整个Activity
为单位、iOS
的画中画以视频为单位。这就导致Android
开启画中画之后,整个页面会消失,无法继续浏览其他内容。 -
2.2 仿iOS画中画方案
目前在做课程学习的业务,产品希望做到类似
iOS
的效果,方便用户边看视频边看其他文本内容。基于Android
以整个Activity
为单位进行画中画的特点,大致方案就是:当用户点击画中画时,开启一个新的Activity来播放视频并将该Activity进行画中画处理。 -
2.3 最终模仿效果
3. 遇见的问题与解决方案
-
3.1 启动launchMode=“singleTask”的Activity会在画中画窗口中打开
问题 下面这个视频中:我们在主页面(下面简称:
MainActivity
)中,先打开一个画中画页面(下面简称:PipActivity
),再打开一个launchMode=singleTask
页面(下面简称:SingleTaskActivity
)。奇怪的事情发生了,SingleTaskActivity
居然展示在PipActivity
中。官方的demo下也有人提了这个issue,现在还是open状态。
原因分析 我们先通过扔物线老师的这个视频 ,回顾下
Activity
的四种启动方式、Task
、taskAffinity
等概念。有了相关的基础知识后,再思考背后的原因。从上面的奇怪表现看来,我一开始想到的是:
PipActivity
与MainActivity
似乎处于不同的Task
。于是尝试打印相关的Task
信息。-
打开
PipActivity
后的Task
信息。果然两个
Activity
处于不同的Task中。 -
在上一步的基础上,再打开
SingleTaskActivity
。可以看到
SingleTaskActivity
与PipActivity
处于同一个Task
中。
上面的信息解释了
SingleTaskActivity
为啥会出现在PipActivity
窗口中了,因为两者处于同一个Task
中。为啥会出现在同一个
Task
中呢?常规的standard
、singleTop
启动时,是直接在当前Task
中创建,而singleTask
的Activity
在启动时会查找taskAffinity
相同的Task
,刚好这时候应用有两个taskAffinity=11033:com.example.test
的Task
并且PipActivity
所在的Task
是最新的,所以启动SingleTaskActivity
时就被添加到PipActivity
所在的Task
了。(以上是个人猜测)解决 那么如果我们让
PipActivity
独占一个Task
,那么SingleTaskActivity
就不会出现在PipActivity
窗口中了。有了大致方向,我首先想到的是:让
PipActivity
以singleInstance
的方式启动。这样设置后,
SingleTaskActivity
能正常启动了,但是打开PipActivity
时会有转场动画,比较影响体验。于是换另外一个思路:
PipActivity
以默认的方式启动,但给它设置一个单独的taskAffinity
。搞定,这样设置后,能满足我们的需求。
设置了单独的
taskAffinity
会导致任务列表中有两个相同应用的任务,可以设置下面两个参数android:excludeFromRecents="true"
和android:autoRemoveFromRecents="true"
来保证只有一个。 -
-
3.2 无法区分画中画窗口的“最大化”和“关闭”按钮
问题 上图是画中画窗口,红色框框是两个系统自带的按钮:“最大化”按钮和“关闭”按钮。这两个按钮的作用不同,代表的用户目的不一样,需要区分处理。
解决 但可惜系统没有提供区分的方法,不过在stackoverflow上看到一个有意思的解决办法:
@Override public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) { if (!isInPictureInPictureMode) { if (getLifecycle().getCurrentState() == Lifecycle.State.CREATED) { // 用户点击“关闭”按钮 } else if (getLifecycle().getCurrentState() == Lifecycle.State.STARTED){ // 用户点击”最大化“按钮 } } }
点击“最大化”按钮或“关闭“按钮时,页面都会退出画中画并回调
onPictureInPictureModeChanged
方法,但回调时页面的生命周期不一样,可以利用这点来进行判断。自己也验证了一下,上面的代码是没有问题的。 -
3.3 自定义画中画按钮
画中画窗口最下面这行是自定义按钮区域,我们可以自定义想要的功能。
- 定义操作按钮
注意:每个private val mPlayAction get() = RemoteAction( Icon.createWithResource(this, R.drawable.edu_ic_play_video), "", "", PendingIntent.getBroadcast( this, REQUEST_CODE_PLAY, Intent(ACTION_PLAY), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) )
RemoteAction
的REQUEST_CODE
(比如上面的REQUEST_CODE_PLAY
)都应该不一样。 - 设置操作按钮 可以通过
Activity
的enterPictureInPictureMode
和setPictureInPictureParams
进行设置。 - 监听并响应操作 上面通过
PendingIntent.getBroadcast
给按钮设置了一个PendingIntent
,当按钮被点击时就会触发对应的广播,我们只要监听广播就行了。
注意:在不需要的时候,记得通过// 定义广播接收器 private val mediaActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { ACTION_PLAY -> { // 执行播放操作 } } } } // 注册广播接收器 context.registerReceiver(mediaActionReceiver, IntentFilter().apply { addAction(ACTION_PLAY) })
context.unregisterReceiver
取消监听。
- 定义操作按钮
-
3.4 开启画中画前的检查
能否开启画中画,受系统版本、画中画权限、系统可运行内存大小等影响,在进入画中画前需要挨个检查。
fun checkOpenPipMode(context: Context): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { // Android O以下不支持画中画 return false } val appOpsManager = context.getSystemService(APP_OPS_SERVICE) as AppOpsManager if (AppOpsManager.MODE_ALLOWED != appOpsManager.checkOpNoThrow( AppOpsManager.OPSTR_PICTURE_IN_PICTURE, context.applicationInfo.uid, context.packageName ) ) { // 没有画中画权限 ActivityUtils.startActivity( Intent("android.settings.PICTURE_IN_PICTURE_SETTINGS").apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } ) return false } if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { // 低 RAM 设备可能无法使用画中画模式 return false } return true }
-
3.5 开启画中画时,平板的dp发生变化
private fun calculateDp() { val density = application.resources.displayMetrics.density val screenWidth = application.resources.displayMetrics.widthPixels val dp = screenWidth / density println("Test_pip screenWidth=$screenWidth density=$density dp=$dp") }
上面这段代码涉及的
density
和dp
是常见屏幕适配方案的关键参数,对页面布局有较大影响。问题 开发过程中遇到一个情况,平板上打开画中画页面后,此时全局的
application.resources.displayMetrics.widthPixels
变成以画中画小窗口为依据,导致screenWidth
变小,从而导致dp
变小,从而导致相关适配的判断出现问题。上述情况从画中画页面打开,持续到下一个非画中画页面打开,只在部分平板出现,暂时没有找到原因。
4. 总结
上面我们分享了系统画中画的使用经验,但其实我们还可以通过自定义悬浮窗来达到画中画的效果。下面通过一个表格简单分析下两者的区别。
系统画中画 | 悬浮窗 | |
---|---|---|
版本限制 | API >= O | 任意版本 |
权限申请 | 权限默认开启 | 需要引导用户开启悬浮窗权限 |
开发体验 | 系统支持的功能,开发时不需要考虑Window设置、窗口拖拽、窗口动画等;但在个别低版本手机上,会有些奇怪的显示问题,但还能接受。 | 非系统支持的功能,不受系统及手机厂商的影响,可以定义统一的UI、动效、交互等;但各方面都需要自己开发,可能比较麻烦,可以考虑使用现有悬浮窗框架; |
使用体验 | 在低版本上,使用体验可能比较一般;但在高版本上,可以有比较好的使用体验,UI、交互等在高版本上都有不错的表现。 | 使用体验取决于开发者的设计,参考目前一些框架,个人觉得比不上高版本的系统画中画。 |
两种方案,都有优缺点。不过随着高版本手机的普及,系统画中画的低代码、较好的使用体验等能力,会被逐步放大,所以个人觉得系统画中画会比较好。