☰
播放器全屏切换优雅实践:Android窗口机制与SurfaceView避坑指南
2026/10/15 17:14:16 网站建设 项目流程

1. 全屏切换的实质:先吃透Android窗口与系统UI机制

做了几年安卓播放器,最容易被低估的就是全屏切换。表面看,它就是用户点一下按钮,播放画面从小窗变成满屏,横过来,状态栏消失。真正上手做的时候你会发现,这一步牵扯到窗口尺寸、系统UI可见性、屏幕方向、Surface生命周期、刘海屏安全区、播放器状态保持,任何一个环节处理不当,都会变成“切换黑一下屏”“画面被拉伸”“进度丢了”“状态栏死活藏不住”之类的烂尾需求。

1.1 播放器全屏切换表面上在切什么

全屏切换本质上是三个动作的组合,而不是一句话能搞定的单一操作。

第一个动作是窗口尺寸变化。普通播放模式下,视频画面所在的那个容器只占据页面的一部分;全屏模式下,这个容器需要铺满整块屏幕。这里说的窗口,在Android里对应Activity的Window,或者更精确地说是WindowManager管理的那层布局。你需要让窗口的宽高变成填满屏幕的尺寸。

第二个动作是系统UI可见性变化。全屏模式通常要隐藏状态栏和导航栏,做成沉浸式,否则画面顶部一条黑带、底部一条导航栏,既不美观也浪费显示区域。反过来,退出全屏的时候要把这些系统栏原样恢复,颜色也要恢复。

第三个动作是屏幕方向变化。绝大多数播放器的全屏是横屏,这样视频的宽高比才协调。正常情况下调用setRequestedOrientation(SCREEN_ORIENTATION_LANDSCAPE)就能让屏幕横过来,但如果没有在清单里声明configChanges,屏幕转一下Activity就会被销毁重建,播放中的视频直接断掉重来。

这三个动作彼此之间有联动:修改窗口尺寸的时候,根布局还没完成测量,直接去拿宽高就可能拿到旧值;隐藏系统UI的时机太早,画面还没铺满就露出一个半成品的全屏态;方向切换的时机太晚,用户看到的是一小段竖屏全屏的奇怪过渡。

所以“优雅地实现全屏切换”的核心,是控制好这三个动作的顺序、时机和状态保存,从这个角度说,它其实是一个工程问题,不只是调几个API的问题。

1.2 三种常见实现路线的横向对比

我在实际项目中接触过三种主流做法,先说结论:适合大多数播放器场景的是第三种,也就是在同一个Window内动态调整布局的方案。

第一种是独立全屏Activity。播放器处于普通界面,点全屏后启动一个新的Activity,这个Activity以横屏全屏方式展示一个新的播放器实例。反向退出时finish掉全屏Activity。优点是代码隔离干净,每个界面职责单一,动画切换好像也有系统级转场样式。缺点是播放器实例需要重建,视频要重新初始化,进度、倍速、选集这些状态要手动迁移,而且启动新Activity本身有一个窗口创建过程,很容易看到黑屏闪一下,体验很难做得顺滑。

第二种是全屏Dialog方案。用一个透明主题的Dialog或者WindowManager.addView方式挂一个全屏容器在页面上方。比Activity轻量,不回退生命周期,黑屏概率低。但它仍然需要单独维护一份全屏界面的布局和状态,和普通界面之间存在两条UI分支,遇到代码复杂时会发现很多逻辑要写两份,方向变化时Dialog的行为和Activity还不完全一致。

第三种是单Activity动态布局方案。普通的播放器页面里有一个播放器容器,全屏时通过修改Window和根布局的参数,让这个容器直接铺满屏幕,同时隐藏系统UI、切横屏。退出时恢复原来的参数。它最大的价值是不重建Activity、不重建播放器、不迁移状态,所有播放进度都在同一个实例里保持,这是“优雅”体验的基础。

我推荐第三种,但也得说清楚它的代价:它要求你对View层级、LayoutParams、系统UI和生命周期有更细的控制,代码不像Activity方案那么直白。下面所有内容都围绕第三种方案展开。

2. 动态布局方案的整体设计与尺寸计算

2.1 为什么选择“不重建Activity”的路线

直接回答一个问题:为什么非要在同一个Activity里切?

播放器最怕的是重建。SurfaceView重建要重新连接surface,TextureView重建会清掉当前帧,播放引擎要重新prepare,视频源可能要从网络重新拉,用户看到的现象就是切个全屏,视频卡住转圈,甚至从头加载。即便你把进度、视频地址这些状态都塞到Bundle里,仍然很难保证播放器内部的缓冲位置、解码器状态、音画同步计时器能无缝接上。

因此“优雅”的第一原则就是让播放器实例全程存活,不销毁、不重建。动态布局方案把全屏切换的重心移到外部容器的LayoutParams上,播放器本身一直挂在窗口里,切换前后都是同一个surface、同一个解码链路,这就从根上规避了大多数黑屏和卡顿问题。

第二个原因是状态保存。进度条时间、倍速、字幕开关、音量参数、播放/暂停状态,这些东西如果通过Activity重建来回传递,迟早会漏。同一个Activity里切全屏,所有状态天然存活,写代码的人也省心。

第三点是动画过渡。不同的方案做过渡动画的成本差很多。独立Activity要处理两个窗口之间的衔接,Dialog方案要处理层级遮挡。单Activity方案可以直接拿到播放器容器的坐标,在原来的位置和全屏位置之间做一个平滑的缩放动画,视觉上就像画面自然放大到满屏,这个效果是其他方案很难做出来的。

2.2 关键尺寸计算与窗口信息的获取

全屏切换的第一步是拿到一个可靠的屏幕尺寸。很多新手在这里直接用DisplayMetrics的widthPixels和heightPixels,但要注意一个问题:如果当前窗口还处于非全屏状态,且系统UI占用了空间,getMetrics拿到的可能是减去系统栏后的可用区域尺寸,而不是屏幕真实尺寸。用这个数据做全屏,画面边缘可能漏掉一部分,或者底部多出一条空白。

更稳的方法是使用getRealMetrics,它返回的是物理屏幕的真实尺寸,包含了系统栏区域:

DisplayMetrics dm = new DisplayMetrics(); getWindowManager().getDefaultDisplay().getRealMetrics(dm); int screenWidth = dm.widthPixels; int screenHeight = dm.heightPixels;

注意,这里的getRealMetrics需要API 17及以上,现在基本可以放心用。如果你的工程里还有老设备兼容要求,也可以用getWindowManager().getCurrentWindowMetrics(),但要多处理一步,因为API 30下这个方法返回的是窗口指标,不一定等于屏幕指标。

还有一点容易被忽略:全屏尺寸应该在方向切换之后去重新获取。竖屏和横屏的宽高是互换的,如果你先取竖屏尺寸,然后setRequestedOrientation转横屏,横过来之后屏幕宽是原来那个高、屏幕高是原来那个宽,这是很多实现里画面尺寸不对的原因。

我建议的做法是:进入全屏时,先保存普通模式下播放器容器的坐标和宽高,然后隐藏系统UI,再setRequestedOrientation横屏,等到onConfigurationChanged回调里再去获取真实的横屏屏幕尺寸,最后修改窗口和容器的LayoutParams。这个顺序在后面第4章会给出完整代码。

3. 核心实现细节与关键代码

3.1 全屏切换的尺寸调整与窗口配置

在同一个Activity内做全屏切换,核心操作是修改Window的LayoutParams以及播放器容器的LayoutParams。

先看进入全屏的操作。这一步要做的关键事情是:记录当前状态,把Window铺满,把播放器容器铺满,记录当前窗口参数以便退出时恢复。

private void enterFullScreen() { if (isFullScreen) { return; } // 记录当前非全屏状态 savedPlayerContainerInfo = new Rect(); playerContainer.getGlobalVisibleRect(savedPlayerContainerInfo); savedWindowWidth = playerContainer.getLayoutParams().width; savedWindowHeight = playerContainer.getLayoutParams().height; // 窗口尺寸改为全屏 WindowManager.LayoutParams attrs = getWindow().getAttributes(); attrs.width = WindowManager.LayoutParams.MATCH_PARENT; attrs.height = WindowManager.LayoutParams.MATCH_PARENT; getWindow().setAttributes(attrs); // 播放器容器改为全屏 ViewGroup.LayoutParams layoutParams = playerContainer.getLayoutParams(); layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT; playerContainer.setLayoutParams(layoutParams); isFullScreen = true; }

这里的第一个坑是:备份的要修改的是播放器自身的LayoutParams,而不是从window.getAttributes()里读到的宽高。普通模式下播放器容器可能是一个具体的像素宽度,比如屏幕宽度的60%,高度按视频比例计算的wrap_content或者固定值。全屏切换时直接把它的LayoutParams改成MATCH_PARENT即可,退出时恢复成原来的具体值。

第二个坑是:修改window attributes之后,再去修改播放器容器,会触发一次relayout。如果方向还没变成横屏,这个过程可能让容器先竖屏铺满一次,然后又横屏铺满一次,视觉上会看到两次跳动。所以更稳的顺序是先切方向,再等布局稳定后改尺寸,或者用动画遮住这种中间状态。

3.2 系统UI隐藏与沉浸模式的正确用法

隐藏系统UI的方式在不同版本上有明显差异。老的方案是使用SYSTEM_UI_FLAG_FULLSCREEN、SYSTEM_UI_FLAG_HIDE_NAVIGATION、SYSTEM_UI_FLAG_IMMERSIVE_STICKY组合。新版本推荐使用WindowInsetsController。

如果你还在用旧Flag,代码大致是这样的:

private void hideSystemUi() { View decorView = getWindow().getDecorView(); int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; decorView.setSystemUiVisibility(flags); } private void showSystemUi() { View decorView = getWindow().getDecorView(); int flags = View.SYSTEM_UI_FLAG_VISIBLE; decorView.setSystemUiVisibility(flags); }

如果新项目或者targetSdk已经比较高,建议用WindowInsetsController,统一适配:

private fun hideSystemUi() { WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, window.decorView).apply { hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } private fun showSystemUi() { WindowCompat.setDecorFitsSystemWindows(window, true) WindowInsetsControllerCompat(window, window.decorView).show( WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars() ) }

说几个常见问题。

第一,只调用hide()不一定能真正隐藏,可能只是变成了透明或者被用户滑动手势拉出来。所以要配套设置systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE,这样用户从边缘滑动时,系统栏只是半透明地短暂出现,操作完自动缩回去,不会永久占用屏幕空间。

第二,WindowCompat.setDecorFitsSystemWindows(window, false)这行不能漏。它告诉系统,窗口内容不要自动避让系统栏,而是扩展到状态栏和导航栏后面。如果漏掉这行,即使窗口尺寸是全屏,内容区仍然被系统栏压缩,画面会露馅。

第三,退出全屏时要恢复状态栏颜色。如果你在普通模式下为状态栏设置过颜色或者浅色模式图标,全屏时把它设置成了透明,退出时必须把它恢复回去,否则页面上方会一直是一条奇怪的透明区域。恢复方式是在showSystemUi的同时,重新设置statusBarColor以及浅色图标配置。

3.3 屏幕方向切换的配置与生命周期拦截

方向切换在Android里有一个经典坑:如果不做任何配置,Activity会因configChanges被系统重建。你明明只是想在视频播放中横过来,结果整个页面重启,播放器咔地一下停住了。

解决办法是在AndroidManifest.xml中,为承载播放器的Activity声明configChanges:

<activity android:name=".player.PlayerActivity" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode" />

这里要特别提醒:register orientation只是其一,screenSize必须一起写上。从Android 3.2开始,屏幕旋转同时触发的配置变化里包含了screenSize,如果你只声明了orientation,在某些设备上仍然会走重建流程。uiMode是为了应对Android 10以下Dark Mode切换导致的Activity重建,不写的话,系统在切换黑暗模式时也可能会重启页面。

声明configChanges之后,方向的切换不会再触发onDestroy和onCreate,而是回调onConfigurationChanged:

@Override public void onConfigurationChanged(@NotNull Configuration newConfig) { super.onConfigurationChanged(newConfig); if (isFullScreen) { // 方向已经变了,但当前View可能还没有完成测量,需要延迟处理 playerContainer.post(() -> { resetVideoContainerToFullSize(); }); } }

这里有一个容易踩的坑:onConfigurationChanged被调用的时候,configuration确实已经更新,但View的尺寸不一定是新屏幕的宽高,因为relayout还没完成。直接在里面拿宽度去设置布局,很可能拿到的是旧值。所以要用post,让布局流程跑完后再设置尺寸,或者用ViewTreeObserver的OnGlobalLayoutListener确保拿到的是新尺寸。

4. 实操复盘:一个播放器从普通播放到全屏播放的完整改造

4.1 布局结构与播放器初始化

先说布局。播放器页面建议这样组织:外层是一个FrameLayout,里面有一个承载视频画面的播放器容器,还有一个控制条区域。我习惯把控制条和视频画面放成兄弟节点,而不是父子节点,这样全屏时控制条跟着一起放大,不用单独调整。

<FrameLayout android:id="@+id/rootContainer" android:layout_width="match_parent" android:layout_height="match_parent"> <FrameLayout android:id="@+id/playerContainer" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center" android:background="#000000"> <!-- 视频画面SurfaceView或TextureView --> <androidx.media3.ui.PlayerView android:id="@+id/playerView" android:layout_width="match_parent" android:layout_height="match_parent" /> <!-- 控制层,例如锁屏、进度条、设置按钮等 --> <LinearLayout android:id="@+id/controlBar" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:background="@drawable/player_scrim" /> </FrameLayout> <!-- 页面其他内容 --> </FrameLayout>

playerContainer在普通模式下我用wrap_content居中放置,高度由视频比例决定,这样小屏播放不会有大黑边。全屏时把它改成MATCH_PARENT,作为视频画面和控制条的父容器,它会跟着窗口一起铺满。

播放器初始化时,需要监听视频尺寸变化,用来调整普通模式下的容器宽高。我这里以Media3的PlayerView为例,具体引擎换成其他播放器也同理:

playerView = findViewById(R.id.playerView); player = new ExoPlayer.Builder(this).build(); playerView.setPlayer(player); player.setVideoListener(new VideoListener() { @Override public void onVideoSizeChanged(int width, int height, int unAppliedRotationDegrees, float pixelWidthHeightRatio) { // 在这里根据视频宽高和屏幕宽度计算普通模式下的playerContainer高度 } });

4.2 进入全屏的完整代码

进入全屏时的完整操作,我按顺序整理:

private void enterFullScreen() { if (isFullScreen) return; // 1. 记录普通状态 int[] location = new int[2]; playerContainer.getLocationOnScreen(location); savedRect = new Rect(location[0], location[1], location[0] + playerContainer.getWidth(), location[1] + playerContainer.getHeight()); // 2. 记录普通模式下playerContainer的LayoutParams savedContainerLp = new FrameLayout.LayoutParams( playerContainer.getLayoutParams().width, playerContainer.getLayoutParams().height); // 3. 先隐藏系统UI,再请求方向切换 hideSystemUi(); // 4. 请求横屏,onConfigurationChanged会再次调整布局 setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); // 5. 标记为全屏 isFullScreen = true; // 6. 播放控制器切到全屏布局模式 controlBar.switchToFullScreenMode(); }

这里解释一下第4步用SENSOR_LANDSCAPE而不是LANDSCAPE的原因。在某些游戏类或视频类应用里,用户可能希望在横屏下同时支持左右旋转,而不是固定锁死一个方向。如果产品不要求感应旋转,直接用LANDSCAPE也可以。我建议在切换全屏时用SENSOR_LANDSCAPE,让用户在横放手机时左右都能看,体验更自然。

真正的全屏尺寸调整,我放在onConfigurationChanged里做,使用post确保布局完成后再设置全屏宽高:

@Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); if (isFullScreen) { playerContainer.post(() -> { ViewGroup.LayoutParams lp = playerContainer.getLayoutParams(); lp.width = ViewGroup.LayoutParams.MATCH_PARENT; lp.height = ViewGroup.LayoutParams.MATCH_PARENT; playerContainer.setLayoutParams(lp); WindowManager.LayoutParams attrs = getWindow().getAttributes(); attrs.width = WindowManager.LayoutParams.MATCH_PARENT; attrs.height = WindowManager.LayoutParams.MATCH_PARENT; getWindow().setAttributes(attrs); }); } }

4.3 退出全屏的恢复逻辑

退出全屏的代码,同样要维护好几个恢复点。

private void exitFullScreen() { if (!isFullScreen) return; // 1. 先退出全屏标志 isFullScreen = false; // 2. 恢复系统UI showSystemUi(); // 3. 请求竖屏 setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); // 4. 恢复playerContainer尺寸 playerContainer.post(() -> { ViewGroup.LayoutParams lp = playerContainer.getLayoutParams(); lp.width = savedContainerLp.width; lp.height = savedContainerLp.height; playerContainer.setLayoutParams(lp); }); // 5. 控制条恢复普通模式 controlBar.restoreFromFullScreenMode(); }

这里有一个细节:退出全屏时,如果当前Activity只支持竖屏,setRequestedOrientation(SCREEN_ORIENTATION_PORTRAIT)是必要的。但如果你配置了SENSOR_LANDSCAPE进入全屏,退出时也应该恢复为原来的方向策略,不能写死为PORTRAIT,否则用户本来在支持横竖屏的页面里使用,退出全屏后被强制锁回竖屏,后续又得手动处理。

4.4 过渡动画与视觉优化

优雅和生硬的分水岭,就在过渡动画上。仅仅修改LayoutParams,画面会瞬间跳变,像幻灯片一样。好的体验是让播放器容器从小位置平滑放大到全屏,退出时再从小位置缩回去。

我的做法是用ValueAnimator插值playerContainer的LayoutParams宽度和高度,同时把它移动到屏幕目标位置。以进入全屏为例:

private void animateToFullScreen(Rect startRect, int targetWidth, int targetHeight) { ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); int startWidth = startRect.width(); int startHeight = startRect.height(); animator.addUpdateListener(animation -> { float fraction = animation.getAnimatedFraction(); ViewGroup.LayoutParams lp = playerContainer.getLayoutParams(); lp.width = (int) (startWidth + (targetWidth - startWidth) * fraction); lp.height = (int) (startHeight + (targetHeight - startHeight) * fraction); if (lp instanceof FrameLayout.LayoutParams) { FrameLayout.LayoutParams fllp = (FrameLayout.LayoutParams) lp; fllp.leftMargin = (int) ((0 - startRect.left) * fraction); fllp.topMargin = (int) ((0 - startRect.top) * fraction); fllp.width = lp.width; fllp.height = lp.height; fllp.gravity = Gravity.TOP | Gravity.START; } playerContainer.setLayoutParams(lp); }); animator.setDuration(300); animator.setInterpolator(new AccelerateDecelerateInterpolator()); animator.start(); }

使用动画时有几个限制要提前说清楚。

第一,SurfaceView在LayoutParams被频繁修改的动画过程中会非常不稳定。SurfaceView的surface是独立于View层级绘制在系统窗口上的,动画过程中频繁改变位置和大小,surface会不断重建,表现为黑色闪烁或者明显卡顿。如果你用的是SurfaceView,建议动画结束之后再做最终的全屏尺寸设置,或者考虑把SurfaceView换成TextureView。TextureView作为普通View,可以在ViewGroup内部直接被Canvas处理,位置和尺寸变化时不会产生独立的surface重建,动画体验顺滑很多。

第二,如果坚持用SurfaceView,可以在动画期间先把surface转为fixed模式,或者暂停视频帧的显示,甚至直接在当前帧上盖一个黑色块,动画结束后再恢复。这样能遮住闪帧,但需要额外代码,不如TextureView直接省心。

第三,动画插值期间,视频的渲染比例也会被拉伸。你从一个小窗口放大到全屏,如果视频的宽高比和屏幕比例不一致,动画过程中画面会变形。这个可以通过在动画期间保持playerContainer的背景为黑色,等全屏尺寸落定后再让视频层重新计算Matrix,视觉上会好很多。

5. 常见问题与避坑实录

5.1 切换黑屏与画面闪烁怎么根治

黑屏和闪烁是播放器全屏切换里最容易遇到的问题,尤其是使用SurfaceView时。

原因在于SurfaceView的工作方式:它拥有一个独立的surface,放在Window的底层。surface在View布局变化、尺寸变化、以及容器位置变化时会被销毁重建。重建期间旧画面消失,新画面尚未输出,表现出来就是黑屏。

根治方案按照有效性排序是这样的:

方案一,换成TextureView。TextureView没有独立surface渲染层,它作为普通View直接由View系统合成。尺寸和位置变化不会引起surface重建,黑屏问题基本消失。但代价是性能略低于SurfaceView,对高分辨率视频或者对性能敏感的场景,需要多评测。

方案二,全屏动画期间把SurfaceView固定为同一尺寸,等动画完成后一次性调整到全屏。虽然仍有瞬间变化,但只发生一次,而且配合系统UI隐藏和横屏切换,用户感知不明显。我之前在某播放器项目里试过,先用动画把控制条和背景放大,SurfaceView保持原尺寸居中,动画结束后再让SurfaceView放大,体感比边动画边改SurfaceView好很多。

方案三,在动画前先截取当前视频帧,用一张Bitmap放在SurfaceView上层盖住整个容器,动画结束后移除。这样即使SurfaceView底层重建,用户看到的始终是那一帧画面,不会黑。缺点是帧率损失,动画期间画面是静止的,如果播放的是视频且动画时间较长,会显得奇怪。

5.2 全屏后画面变形与黑边问题

全屏后画面变形,几乎都是没有重新计算视频矩阵导致的。普通模式下,播放器容器的高度是根据视频宽高比计算出来的,视频画面和容器比例一致,不需要额外处理。全屏模式下,如果屏幕比例和视频比例不一致,你直接让PlayerView铺满,视频画面会被拉伸填满,人物脸拉宽。

正确的做法是拿到视频的宽高比,然后计算全屏画面应该使用哪一种缩放策略。一般视频播放器都提供两种策略:CENTER_CROP和FIT_CENTER。CENTER_CROP会等比放大画面,裁剪掉多余部分,填满整个屏幕,适合竖屏全屏看横屏视频的短视频场景。FIT_CENTER则是保持完整画面基础上缩放,让视频完整呈现,可能会有左右或者上下黑边,适合电影类内容。

实现上,直接设置PlayerView的resizeMode即可:

playerView.setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_ZOOM); // CENTER_CROP

或者细粒度自己算Matrix:

RectF videoRect = new RectF(0, 0, videoWidth, videoHeight); RectF viewRect = new RectF(0, 0, playerView.getWidth(), playerView.getHeight()); Matrix matrix = new Matrix(); matrix.setRectToRect(videoRect, viewRect, Matrix.ScaleToFit.CENTER); playerView.setTransform(matrix);

这里注意:playerView.getWidth()在全屏切换完成的瞬间可能还是旧值,所以要在播放器容器的尺寸稳定之后再执行矩阵计算,否则会得到错误的比例。

5.3 刘海屏、导航栏与安全区适配

现在的全面屏手机大多有刘海或者挖孔,全屏播放时不处理好这些区域,视频画面会被刘海挡住一块。

全屏模式下,尽量让画面铺满整个屏幕,包括刘海区域,而不是避让。视频内容是动态的,被刘海挡住一块通常可以接受,至少不会有白边或者黑条。但如果你硬要避让,就得把画面缩小,效果反而更差。

实现上,需要把你的窗口布局标志设置为允许内容延伸进Display Cutout区域:

WindowManager.LayoutParams lp = getWindow().getAttributes(); lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; getWindow().setAttributes(lp);

LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES表示只允许内容延伸到屏幕短边的挖孔区域,比如刘海区域。比较新的全面屏手机屏幕都是多挖孔,这个模式可以覆盖绝大多数场景。如果你想要完全无视挖孔,可以使用LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS。

导航栏适配很常见的一个坑是:退出全屏时,导航栏是回来了,但底部区域短暂地空出一块,或者内容被系统栏盖住。原因是在隐藏系统UI之后,View的布局参数没有同步更新。不要求你立刻解决所有insets问题,但至少要做到:

  • 进入全屏前保存WindowInsetsController的状态。
  • 退出全屏时,重新设置WindowCompat.setDecorFitsSystemWindows(window, true),然后再恢复状态栏颜色。
  • 如果你用CoordinatorLayout或者EdgeToEdge风格,还要检查根布局是否使用了fitsSystemWindows,如果开了,全屏时要记得动态关掉。

5.4 状态保存与异常生命周期的兜底

尽管声明了configChanges,Activity仍然有可能因为内存不足或者系统回收而死亡。全屏状态下如果被系统杀掉,恢复页面时至少要让播放器回到正常模式并恢复播放进度。

我习惯在onSaveInstanceState里记录这几个字段:

@Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); outState.putLong(KEY_POSITION, player.getCurrentPosition()); outState.putInt(KEY_PLAY_STATE, isPlaying ? STATE_PLAYING : STATE_PAUSED); outState.putBoolean(KEY_IS_FULL_SCREEN, isFullScreen); outState.putInt(KEY_VIDEO_WIDTH, videoWidth); outState.putInt(KEY_VIDEO_HEIGHT, videoHeight); }

恢复时不要直接在onCreate里尝试恢复全屏状态。页面重建过程本身就一步一个变化,如果在这里就切横屏、改窗口,顺序很容易乱。更稳妥的做法是等页面完全创建完,播放器初始化好,用户看到普通播放界面并恢复了进度,然后再通过一个延迟任务去恢复全屏。甚至可以在恢复到全屏之前提示一下用户当前画面需要旋转,比如直接调用之前的enterFullScreen()。

另外一个容易被忽略的点:低内存回收时,相机、传感器等资源全都会释放,SurfaceView恢复时要重建;TextureView则需要重新绑定纹理。无论哪种,你都需要在页面重建时重新初始化播放器,并把之前保存的进度seek过去。

5.5 调试全屏切换的一线经验

调试全屏切换时,要善用系统级的UI边界显示功能。在开发者选项里开启“显示布局边界”和“显示Surface更新”,一眼就能看出SurfaceView的绘制范围是否在动画过程中频繁跳动。这里提两个日常调试非常有价值的技巧。

第一个技巧:全屏切换时,务必使用adb shell dumpsys window来确认窗口状态。如果全屏后窗口没有真正铺满,你会看到窗口参数里的width和height仍然是旧的像素值。这个命令也是排查“为什么画面没有全屏”最直接的证据:

adb shell dumpsys window windows | grep -E "mCurrentFocus|mFrame|mAttrs"

第二个技巧:遇到闪黑问题时,把视频播放器的surface设置成动态获取。通过设置zOrderOnTop为true或者false,观察黑色闪烁是否随zOrder变化而变化。这能帮助你快速判断是SurfaceView重建导致的黑屏,还是窗口切换渲染导致的黑屏,定位到真正的方向再去改代码,别盲目地换TextureView。

最后说一个我个人的经验。做播放器全屏切换,代码并不是最复杂的地方,真正难的是把“用户感知”调到最顺。我在维护某播放器模块的时候,前两版都是功能实现了,但用户反馈“全屏有白屏一闪”,问题就是系统UI隐藏和窗口尺寸调整的顺序没有做好。后来我把顺序固定成先隐藏系统UI、再转方向、最后改窗口尺寸,并且动画期间统一用黑色背景过渡,白屏问题才彻底消失。所以如果你也在改全屏切换,建议你多花一点时间关注过渡阶段的视觉效果,而不是只盯着功能完成。全屏切换这件事,用户能感知的差距多半都在那一两百毫秒的过渡里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询