Android播放器全屏切换优雅实践:同Activity尺寸调整方案

📅 发布时间:2026/10/15 17:14:17
Android播放器全屏切换优雅实践:同Activity尺寸调整方案
做播放器的开发者大概率都有过这种经历产品提了一个需求“小窗播放时点全屏按钮全屏播放时再点一次还原要转得顺滑、不丢进度、不能闪黑屏”。听起来很简单真正动手了才发现全屏切换背后藏着状态栏、方向、SurfaceView、刘海屏这一堆烂摊子。这篇文章想聊的就是怎么用一套“同一个Activity 动态尺寸调整 窗口装饰监听”的组合优雅地实现播放器全屏切换。我自己在多个真实项目里反复试过几种方案踩过不少坑下面会把选型逻辑、核心细节、可复用的代码和问题排查都摊开讲希望能帮你少走弯路。1. 全屏切换方案选型别拍脑袋先看清楚三条路很多同学拿到全屏需求的第一反应是“跳转一个新Activity”。这种方案确实存在而且网上教程最多但它是不是最优解要看你手头的播放器是怎么实现的。我建议动手前先花半小时把方案路线理清楚不然写到一半发现走不通返工成本很高。1.1 三条技术路线对比新Activity、尺寸调整、窗口装饰我把常见的实现方式归为三类分别是跳转全屏Activity、同容器内动态尺寸调整、以及通过WindowManager添加系统级窗口。三条路线各有适用场景我列了一张对比表实现方式状态保持过渡动画适配成本适用场景跳转新Activity需要手动传递进度与播放状态受限跨页面无法平滑衔接中高需处理两个页面生命周期简单需求、播放器与业务页面解耦强的场景同容器动态尺寸调整天然保持解码器不重建可控可以做转场动画中低核心是布局与DecorView协作绝大多数视频App、直播类场景WindowManager系统级窗口需要维护窗口生命周期依赖系统窗口动画偏高焦点、触摸事件都要处理悬浮窗、小窗拖拽大多数情况没必要我个人强烈建议优先考虑第二条路。原因很简单播放器实例无论用的是系统MediaPlayer还是第三方播放器SDK和承载画面的View是深度绑定的一旦把View从原来的ViewGroup里移除再添加到另一个Activity播放器底层的解码器、渲染表面、音频焦点都会面临重建或丢失。进度条从头再来还在其次黑屏、卡顿、声音断掉才是最头疼的。1.2 为什么“同一个Activity”是更优解少动播放器让它待在自己熟悉的地方在一个Activity内做全屏切换核心思路是把播放器的容器View“临时搬家”全屏时把它从原来的父容器中摘出来添加到顶层一般是DecorView或者根FrameLayout并让它尺寸铺满全屏退出全屏时再放回原位恢复原来的尺寸。这里有个关键认知View是可以临时从一个ViewGroup移动到另一个ViewGroup的只要保证“父容器变化不触发播放器内部状态变化”就行。TextureView相对友好因为它内部维护独立的Surface从一层View树挪到另一层View树表面纹理依然有效画面上不会出现黑屏。SurfaceView在这里就很吃亏它有自己的独立窗口层级变化容易出问题除非配合setZOrderOnTop之类的手段否则在复杂布局里很容易被其他View盖住。所以如果你的播放器渲染层用的还是老式SurfaceView我建议全屏切换之前先评估一下要不要迁移到TextureView。很多第三方播放器SDK其实已经开放了渲染View的设置这个替换成本没有想象中高但收益非常明显。2. 核心细节尺寸、系统栏、生命周期一个都不能少全屏切换的“优雅”很大程度体现在这些细节上。用户感知到的不是你的代码有多漂亮而是切换过程是否顺滑、画面比例是否正常、状态栏和刘海屏是否处理得当。这些细节我用三个小节拆开讲。2.1 两种窗口装饰策略沉浸式全屏还是避让状态栏全屏切换的本质不只是“尺寸变大”还包括窗口装饰状态栏、导航栏的隐藏和恢复。这里有两种做法沉浸式全屏隐藏系统状态栏和导航栏画面延伸到整个屏幕。这是大多数视频播放器的选择观影沉浸感强。非沉浸式全屏保留状态栏尺寸只扩展到状态栏下方。这种方式很少用通常只在一些特殊业务页面里出现。实现沉浸式全屏新版系统推荐用WindowInsetsController旧版本则用systemUiVisibility。我做了个兼容封装在后面的代码里会贴出来。这里想强调的是切换时机必须和布局动画同步进行如果先隐藏状态栏再放大布局中间会露出一个空窗期能明显看到画面跳一下。2.2 尺寸计算的关键参数屏幕宽高、安全区域与画面比例全屏时播放器容器要铺满的区域并不是简单的“屏幕宽度 x 屏幕高度”。竖屏状态切成横屏全屏时要考虑到当前窗口的实际可用区域排除状态栏和导航栏占用的空间横屏时刘海屏设备的安全区域否则画面会被挖孔遮挡导航栏若是手势模式底部避让区域可能很小甚至为零。我的做法是定义一个全屏布局参数计算函数。完整代码在下一章会给出来核心点在于不要直接用getDefaultDisplay这种旧API拿尺寸而是通过WindowManager.getCurrentWindowMetrics获取实时窗口大小再配合WindowInsets获取系统栏和安全区域偏移量。这样在折叠屏、平板这种窗口大小不固定的设备上也能适配正确。另外画面比例保持在播放器内部处理。全屏容器铺满后渲染画面的宽高比是否要裁切取决于播放器的显示模式。常见的模式有FIT_XY拉伸填满、CENTER_CROP居中裁剪铺满、FIT_CENTER等比缩放留黑边。视频App默认一般是CENTER_CROP因为用户希望全屏后画面铺满留黑边体验反而不好。2.3 生命周期与方向切换避开Activity重建的连环坑竖屏播着视频一点全屏按钮画面旋转成横屏。这里最大的坑是Activity重建。系统在屏幕方向变化时默认会销毁并重建Activity一旦重建播放器状态就全乱了。怎么避开有两种思路在AndroidManifest中声明configChanges让Activity自己消费方向变化事件不触发重建不改变屏幕方向而是通过自定义旋转让画面在视觉上横向显示。这种适合“假全屏”的业务场景。我推荐的做法是声明configChanges然后通过setRequestedOrientation到横屏方向。在onConfigurationChanged回调中重新计算布局参数。注意一点如果目标设备支持传感器方向比如平板进入全屏时应该请求SENSOR_LANDSCAPE而不是LOCKED这样用户左右旋转都能保持横向退出全屏时再恢复到当前界面原本的方向模式。还有一个容易忽略的点从全屏退出时不要盲目恢复Activity之前的方向因为用户可能希望回到竖屏。这时需要你把进入全屏前的方向模式保存成变量退出时setRequestedOrientation(savedPortraitOrientation)才能保证行为符合直觉。3. 实操落地一个可复用的优雅切换实现下面这部分我会给出一个可直接复制到项目里的封装方案。它不是简单的demo而是我在实际项目里打磨过几轮的版本基本覆盖了常见的适配点。3.1 布局设计用FrameLayout包住播放器容器先来看布局。假设你的播放器View叫PlayerView外层用一个FrameLayout作为容器放在原始页面的普通位置。这个容器既是小窗时的父容器也是全屏时“临时拿出来搬家”的对象。FrameLayout android:idid/playerContainer android:layout_widthmatch_parent android:layout_height240dp android:background#000000 com.example.PlayerView android:idid/playerView android:layout_widthmatch_parent android:layout_heightmatch_parent / ImageButton android:idid/btnFullscreen android:layout_widthwrap_content android:layout_heightwrap_content android:layout_gravityend|bottom android:layout_margin12dp android:srcdrawable/ic_fullscreen / /FrameLayout这里的关键是playerContainer这个FrameLayout它必须被放进Activity的根布局中而不是Fragment自己的根布局。因为全屏时需要把它往上加到DecorView或根FrameLayout如果它嵌套在Fragment的层级里addView到DecorView时会先触发一次removeView又可能触发移除动画导致视觉上闪一下。最好把Activity布局设计成单层结构根FrameLayout包含主要页面playerContainer直接挂在根布局下。3.2 核心切换逻辑一个方法打通全屏与还原接下来写一个FullScreenController。这个类负责两件事计算全屏尺寸、执行View切换。class FullScreenController( private val activity: Activity, private val container: FrameLayout ) { private var rootView: FrameLayout? null private var originParent: ViewGroup? null private var originIndex -1 private var originLayoutParams: ViewGroup.LayoutParams? null private var isFullScreen false private val profileOnApplyWindowInsets WindowInsetsCompat.Callback( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() ) {} fun toggle() { if (!isFullScreen) { enterFullScreen() } else { exitFullScreen() } } fun enterFullScreen() { isFullScreen true hideSystemBars() saveOriginState() moveContainerToRoot() applyFullScreenSize() // 可选触发一个位移动画 container.animate().alpha(1f).setDuration(200L).start() } fun exitFullScreen() { isFullScreen false showSystemBars() restoreOriginState() applyOriginSize() } private fun saveOriginState() { originParent container.parent as? ViewGroup originIndex originParent?.indexOfChild(container) ?: -1 originLayoutParams container.layoutParams } private fun moveContainerToRoot() { val parent container.parent as? ViewGroup ?: return parent.removeView(container) val activityRoot activity.findViewByIdFrameLayout(android.R.id.content) rootView activityRoot rootView?.addView(container) } private fun restoreOriginState() { rootView?.removeView(container) val parent originParent if (parent ! null) { val params originLayoutParams?.clone() as ViewGroup.LayoutParams parent.addView(container, originIndex, params) } } private fun applyFullScreenSize() { val metrics WindowManagerCompat.getCurrentWindowMetrics(activity) container.layoutParams FrameLayout.LayoutParams( metrics.bounds.width(), metrics.bounds.height() ) container.requestLayout() } private fun applyOriginSize() { container.layoutParams originLayoutParams container.requestLayout() } private fun hideSystemBars() { WindowCompat.getInsetsController(activity.window, container).apply { systemBarsBehavior WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE hide(WindowInsetsCompat.Type.systemBars()) } } private fun showSystemBars() { WindowCompat.getInsetsController(activity.window, container).apply { show(WindowInsetsCompat.Type.systemBars()) } } }这个类的设计有几个细节要说明首先是saveOriginState退出全屏时必须恢复原来的父容器和索引否则播放器会跑到页面里错误的位置比如从另一个Fragment底下冒出来。其次是clone()布局参数不能直接复用原来的LayoutParams实例因为View在切换过程中可能已经编译过它直接赋值容易出隐藏bug。3.3 系统栏与刘海屏适配代码里不能少的那几行很多新手在隐藏状态栏时只调了一次hide结果全屏切回来之后状态栏还藏着。原因是没有在退出时show回来。上面的代码已经把hide和show放进开关了但你还需要处理一个边界情况用户在全屏状态下下拉手势临时唤出了状态栏。这时如果直接退全屏状态栏会依然显示界面会闪一下。建议在onWindowFocusChanged或配套的OnApplyWindowInsets监听中检测系统栏可见性如果检测到用户在退出全屏前状态栏已经可见就同步更新内部的isFullScreen状态。下面是一个简单的监听写法ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets - val systemBars insets.getInsets(WindowInsetsCompat.Type.systemBars()) // systemBars.top 和 systemBars.bottom 即状态栏与导航栏高度 // 这里可以更新全屏状态或者记录避让区域 insets }刘海屏适配同样不能忽略。我用的是WindowCompat.setDecorFitsSystemWindows(window, false)让窗口内容延伸到刘海区域然后利用DisplayCutout的safeInset值判断安全区。简单来说就是全屏下画面默认延伸到状态栏但如果设备有刘海把顶部安全区域的值设为播放器控制栏的避让量。3.4 动画与还原怎样让切换过程不“干跳”没有动画的切换是很生硬的但动画用不好又会让人觉得卡顿。我的做法是分两步第一步改变尺寸前先用alpha动画做一次60~100毫秒的淡入淡出过渡很快几乎察觉不到但能掩盖Surface内容更新的瞬间空白第二步尺寸变化由ViewGroup的LayoutTransition来处理或者自定义一个平移缩放动画让画面从小窗位置“滑”到全屏位置。这里不建议用属性动画缩放整个容器。因为TextureView的内容是独立的Surface整体缩放会让画面拉伸变形尤其是视频画面里人物面部会扭曲。正确做法是让容器的边界直接变化画面内部的宽高比由播放器的显示模式自己去适应。退出全屏时可以先恢复系统栏显示再调整尺寸。顺序反了的话全屏布局会先闪一下再让出系统栏区域视觉上会有一次明显的跳动。4. 常见问题与排查速查这些坑我替你踩过了全屏切换做多了你会发现绝大多数问题都是相似的。下面这张速查表基本覆盖了我在项目里处理过的高频问题具体排查思路放在表格后面。4.1 高频问题速查表问题现象原因解决方案切换后黑屏全屏一瞬间画面消失SurfaceView离开窗口后Surface被销毁改用TextureView作为渲染层画面变形人物被拉伸播放器显示模式为FIT_XY改为CENTER_CROP或FIT_CENTER退出全屏后布局错乱View位置跑偏没有恢复原始父容器索引或LayoutParams在还原逻辑中恢复parent和index状态栏不会恢复小窗下状态栏依然隐藏没有在退出全屏时主动show系统栏调用InsetsController.show刘海屏遮挡画面被挖孔区域盖住没有处理DisplayCutout监听cutout安全区域动态调整容器inset旋转时画面重载屏幕旋转后黑屏重连Activity重建或Surface重建声明configChanges旋转时只改尺寸全屏画面闪烁进入全屏时白/黑闪动画与系统栏隐藏时机不同步先隐藏窗口装饰再触发尺寸动画4.2 三个高频坑的深入排查黑屏、变形、位置错乱先说黑屏。SurfaceView是这类问题的重灾区它有自己的独立SurfaceView树变化时Surface的时序不在你的控制范围内就会产生黑帧。我的经验是新项目一律用TextureView老项目如果SDK不支持换渲染View可以考虑在进入全屏时短暂冻结当前帧做一个占位图至少能缓解黑屏带来的糟糕体验。再说画面变形。这个问题常见于全屏后直接修改了TextureView的ScaleType或者调节了宽高。TextureView本身没有ScaleType概念播放器SDK一般会提供显示模式接口。如果你用的是MediaPlayerTextureView自己做矩阵变换那么需要根据容器尺寸重新计算矩阵公式是视频宽高比和目标区域宽高比对比如果目标区域更“宽”则裁剪上下如果更“窄”则裁剪左右得到的是CENTER_CROP的视觉结果。最后说位置错乱。这个bug通常藏得很深表象是退出全屏后播放器出现在错误的位置甚至覆盖在其他页面之上。最常见的原因是还原时直接把View加到了原点但原本的父容器是个LinearLayout索引没记录准确最终位置变化。所以嵌套布局层级不建议过深简单的FrameLayout直接挂载播放器容器能省掉八成这类问题。4.3 一个容易被忽略的副坑软键盘与窗口焦点全屏期间如果有评论输入框或者搜索框弹出WindowInsets会再次变化容器尺寸可能被压缩。播放器全屏通常伴随着控制栏、弹幕输入等交互软键盘弹出时如果不限制方向布局会被顶上去。我的解法是做“全屏期间禁止软键盘自动弹出”和“弹幕输入状态与全屏状态解耦”。具体来说进入全屏时把输入法模式调整为SOFT_INPUT_STATE_ALWAYS_HIDDEN退出全屏时恢复即可。5. 效果打磨与后续扩展真正“优雅”的最后一步全屏切换基础功能跑通后真正拉开体验差距的是细节打磨。这一节聊几个我比较看重的优化方向也包括一些可以继续扩展的场景。5.1 过渡体验优化先隐藏装饰再动布局这个顺序问题我在前面提过一次但值得单独拎出来强调进入全屏时一定要先在系统栏隐藏动画完成后再执行View的add和尺寸变化。实践上可以先调hideSystemBars()并等待一帧让系统栏动画结束后再scheduleLayoutChange。不然你会看到画面在系统栏隐藏前就撑满了整个区域然后被状态栏“顶”一下观感极差。退出全屏时反过来先恢复系统栏再缩小容器。如果容器尺寸变化太快系统栏动画没跟上同样会看到一段错位画面。一个小技巧是把还原动画的插值器改成DecelerateInterpolator让尺寸变化前期快、后期慢配合系统栏动画视觉上就很自然了。5.2 性能优化过渡动画与播放性能如何共存全屏切换瞬间Activity里可能有其他View动画在运行比如广告位倒计时、弹幕飘屏。建议在切换过程中暂时stop掉无关的动画或者用一个顶层ViewGroup.animate().alpha(0f)做整个页面的淡出给播放器画面让出渲染通道。这套做法在低端机上尤其有效能减少GPU负载。硬件的另一个注意点是不要在全屏动画期间频繁触发TextureView的requestLayout这会强制它重新测量子View很伤性能。正确做法是动画前一次性设置好目标尺寸动画期间只改translationX/Y或者scale动画结束后再修正布局参数。5.3 扩展多窗口、平板与手势方向的适配现在的设备形态越来越多样全屏切换不能只盯着手机竖屏到横屏这一条路径。折叠屏打开后可能是接近方形的窗口平板横屏下“全屏”就不是旋转90度这么简单了。我的经验是用WindowManager.getCurrentWindowMetrics获取实时窗口边界而不是写死横屏。这样在自由窗口、多窗口模式下依然能正确铺满。手势旋转这个扩展场景也很有意思。很多视频播放器支持全屏后通过手势旋转画面角度0/90/180/270。因为采用的是同容器动态尺寸调整方案画面旋转只需要对TextureView做矩阵变换不用跨页面传状态。如果是跳转新Activity的方案这种交互就非常难实现了。写在最后全屏切换的“优雅”来自对细节的较真做播放器这些年我的体会是全屏切换这种看起来很小的功能点恰恰最能拉开播放器体验的差距。用户不会去读你的代码但他们能敏锐地感觉到“这个视频App切全屏很顺”和“那个App全屏会黑一下”之间的区别。而这份“顺”来源于对系统栏时序的把控、对View生命周期的理解以及一个足够简洁的架构方案。最后分享一个实用判断标准如果你的全屏切换代码里需要你自己去管理进度保存、播放器暂停恢复这些状态那一定不是最优解。优雅的方案应该是播放器本身无感知只是容器换了个更大的舞台而已。下次再接到全屏需求别急着写代码先想想舞台是谁来提供主角安不安全。想清楚了实现起来也就顺手了。