播放器全屏切换优雅实践:Android窗口机制与SurfaceView避坑指南
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重建要重新连接surfaceTextureView重建会清掉当前帧播放引擎要重新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声明configChangesactivity android:name.player.PlayerActivity android:configChangesorientation|screenSize|screenLayout|smallestScreenSize|uiMode /这里要特别提醒register orientation只是其一screenSize必须一起写上。从Android 3.2开始屏幕旋转同时触发的配置变化里包含了screenSize如果你只声明了orientation在某些设备上仍然会走重建流程。uiMode是为了应对Android 10以下Dark Mode切换导致的Activity重建不写的话系统在切换黑暗模式时也可能会重启页面。声明configChanges之后方向的切换不会再触发onDestroy和onCreate而是回调onConfigurationChangedOverride 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:idid/rootContainer android:layout_widthmatch_parent android:layout_heightmatch_parent FrameLayout android:idid/playerContainer android:layout_widthmatch_parent android:layout_heightwrap_content android:layout_gravitycenter android:background#000000 !-- 视频画面SurfaceView或TextureView -- androidx.media3.ui.PlayerView android:idid/playerView android:layout_widthmatch_parent android:layout_heightmatch_parent / !-- 控制层例如锁屏、进度条、设置按钮等 -- LinearLayout android:idid/controlBar android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical android:backgrounddrawable/player_scrim / /FrameLayout !-- 页面其他内容 -- /FrameLayoutplayerContainer在普通模式下我用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或者细粒度自己算MatrixRectF 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 状态保存与异常生命周期的兜底尽管声明了configChangesActivity仍然有可能因为内存不足或者系统回收而死亡。全屏状态下如果被系统杀掉恢复页面时至少要让播放器回到正常模式并恢复播放进度。我习惯在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、再转方向、最后改窗口尺寸并且动画期间统一用黑色背景过渡白屏问题才彻底消失。所以如果你也在改全屏切换建议你多花一点时间关注过渡阶段的视觉效果而不是只盯着功能完成。全屏切换这件事用户能感知的差距多半都在那一两百毫秒的过渡里。