Android -- 系统画中画实践指南

1,713 阅读7分钟

1. 基本使用

  • 关于画中画的基本使用,这里就不赘描述了,可以参考官方文档:官方文档 - 对画中画 (PiP) 的支持
  • 虽然画中画的相关接口在SDK23就能使用,但实际上画中画是SDK26及以上才开始支持的,所以使用前需要特别注意下SDK的版本。

2. 仿iOS画中画

  • 2.1 Android和iOS的画中画效果对比

    image description image description

    左图为Android官方例子的效果,右图为iOS系统的效果。

    两者都是系统自带的画中画效果,主要区别在于Android的画中画以整个Activity为单位、iOS的画中画以视频为单位。这就导致Android开启画中画之后,整个页面会消失,无法继续浏览其他内容。

  • 2.2 仿iOS画中画方案

    目前在做课程学习的业务,产品希望做到类似iOS的效果,方便用户边看视频边看其他文本内容。基于Android以整个Activity为单位进行画中画的特点,大致方案就是:当用户点击画中画时,开启一个新的Activity来播放视频并将该Activity进行画中画处理

  • 2.3 最终模仿效果

    image description

3. 遇见的问题与解决方案

  • 3.1 启动launchMode=“singleTask”的Activity会在画中画窗口中打开

    问题 下面这个视频中:我们在主页面(下面简称:MainActivity)中,先打开一个画中画页面(下面简称:PipActivity),再打开一个launchMode=singleTask页面(下面简称:SingleTaskActivity)。奇怪的事情发生了,SingleTaskActivity居然展示在PipActivity中。

    image description

    官方的demo下也有人提了这个issue,现在还是open状态。

    原因分析 我们先通过扔物线老师的这个视频 ,回顾下Activity的四种启动方式、TasktaskAffinity等概念。有了相关的基础知识后,再思考背后的原因。

    从上面的奇怪表现看来,我一开始想到的是:PipActivityMainActivity似乎处于不同的Task。于是尝试打印相关的Task信息。

    • 打开PipActivity后的Task信息。

      截屏2023-09-10 15.45.31.png 果然两个Activity处于不同的Task中。

    • 在上一步的基础上,再打开SingleTaskActivity

      截屏2023-09-10 15.45.42.png 可以看到SingleTaskActivityPipActivity处于同一个Task中。

    上面的信息解释了SingleTaskActivity为啥会出现在PipActivity窗口中了,因为两者处于同一个Task中。

    为啥会出现在同一个Task中呢?常规的standardsingleTop启动时,是直接在当前Task中创建,而singleTaskActivity在启动时会查找taskAffinity相同的Task,刚好这时候应用有两个taskAffinity=11033:com.example.testTask并且PipActivity所在的Task是最新的,所以启动SingleTaskActivity时就被添加到PipActivity所在的Task了。(以上是个人猜测)

    解决 那么如果我们让PipActivity独占一个Task,那么SingleTaskActivity就不会出现在PipActivity窗口中了。

    有了大致方向,我首先想到的是:让PipActivitysingleInstance的方式启动。

    image description

    这样设置后,SingleTaskActivity能正常启动了,但是打开PipActivity时会有转场动画,比较影响体验。

    于是换另外一个思路: PipActivity以默认的方式启动,但给它设置一个单独的taskAffinity

    output.gif

    搞定,这样设置后,能满足我们的需求。

    设置了单独的taskAffinity会导致任务列表中有两个相同应用的任务,可以设置下面两个参数android:excludeFromRecents="true"android:autoRemoveFromRecents="true"来保证只有一个。

  • 3.2 无法区分画中画窗口的“最大化”和“关闭”按钮

    image description

    问题 上图是画中画窗口,红色框框是两个系统自带的按钮:“最大化”按钮和“关闭”按钮。这两个按钮的作用不同,代表的用户目的不一样,需要区分处理。

    解决 但可惜系统没有提供区分的方法,不过在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 自定义画中画按钮

    image description

    画中画窗口最下面这行是自定义按钮区域,我们可以自定义想要的功能。

    • 定义操作按钮
      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  
              )  
          )  
      
      注意:每个RemoteActionREQUEST_CODE(比如上面的REQUEST_CODE_PLAY)都应该不一样。
    • 设置操作按钮 可以通过ActivityenterPictureInPictureModesetPictureInPictureParams进行设置。
    • 监听并响应操作 上面通过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")  
    }
    

    上面这段代码涉及的densitydp是常见屏幕适配方案的关键参数,对页面布局有较大影响。

    问题 开发过程中遇到一个情况,平板上打开画中画页面后,此时全局的application.resources.displayMetrics.widthPixels变成以画中画小窗口为依据,导致screenWidth变小,从而导致dp变小,从而导致相关适配的判断出现问题。

    上述情况从画中画页面打开,持续到下一个非画中画页面打开,只在部分平板出现,暂时没有找到原因。

4. 总结

上面我们分享了系统画中画的使用经验,但其实我们还可以通过自定义悬浮窗来达到画中画的效果。下面通过一个表格简单分析下两者的区别。

系统画中画悬浮窗
版本限制API >= O任意版本
权限申请权限默认开启需要引导用户开启悬浮窗权限
开发体验系统支持的功能,开发时不需要考虑Window设置、窗口拖拽、窗口动画等;但在个别低版本手机上,会有些奇怪的显示问题,但还能接受。非系统支持的功能,不受系统及手机厂商的影响,可以定义统一的UI、动效、交互等;但各方面都需要自己开发,可能比较麻烦,可以考虑使用现有悬浮窗框架;
使用体验在低版本上,使用体验可能比较一般;但在高版本上,可以有比较好的使用体验,UI、交互等在高版本上都有不错的表现。使用体验取决于开发者的设计,参考目前一些框架,个人觉得比不上高版本的系统画中画。

两种方案,都有优缺点。不过随着高版本手机的普及,系统画中画的低代码、较好的使用体验等能力,会被逐步放大,所以个人觉得系统画中画会比较好。

参考文章

官方文档 - 对画中画 (PiP) 的支持