横屏适配做了这么多年,踩过的坑比踩过的键盘还多。每次产品经理一句“这个页面横屏看一下”,接下来的几小时基本就是和Activity重建、布局错乱、刘海屏遮挡斗智斗勇。而屏幕适配这件事,更是从早期的dimens多套文件到现在的sw限定符,走了不少弯路。
这篇文章就把横屏适配和多屏幕适配这两件事从头到尾捋一遍。横屏适配的核心是搞清楚Activity生命周期变化、资源目录切换、布局差异处理这三件事;多屏幕适配的核心则是理解dp/dpi原理、掌握主流的sw最小宽度限定符方案、处理好刘海屏折叠屏这类特殊场景。内容既有原理讲解,也有可以直接抄作业的代码和配置,适合刚接触适配的初级开发者,也能给做了几年还在靠hack方案硬撑的朋友一些参考。
1. 先把横竖屏切换这件事拆明白
很多人一提到横屏适配,第一反应就是“给layout目录加个land后缀不就行了”。真这么简单,就不会有那么多线上事故了。横竖屏切换触发的不只是布局切换,而是整个Activity的重建流程,这里面的每一个环节都可能出问题。
1.1 系统为什么会重走一遍Activity生命周期
默认情况下,Android设备发生配置变更(Configuration Change)时,系统会销毁当前Activity并重新创建一个新的。这个过程不是只换一张布局那么简单:onPause、onStop、onDestroy依次走完,然后重新走onCreate、onStart、onResume。说白了,旧Activity整个被抛弃,新Activity从零开始。
系统这么设计不是为了折腾开发者,而是默认所有资源都是跟配置绑定的:横竖屏对应不同的layout目录、不同的dimens、不同的drawable,Android希望你能用同一套代码加载不同配置下的资源,那就只能从头走一遍生命周期,让所有资源引用重新解析。
但问题就出在这个“重新解析”上。如果你在Activity里持有了一些状态数据,比如用户输入到一半的文本、网络请求返回的结果、滚动列表的位置,这些数据存在成员变量里,Activity一重建就全丢了。常见的结果就是用户横屏之前写了一堆字,转一下屏幕,输入框空了。
解决这个问题的核心思路有两个:一个是在onSaveInstanceState里保存数据并在onCreate中恢复,另一个是让系统不重建Activity。前者是正统方案,适用于大多数场景;后者是用configChanges属性声明“这些配置变化我来自己处理”,绕开系统重建机制。哪个更合适,取决于页面复杂度。
1.2 三种处理策略的取舍
第一种策略,也是最正统的:不做任何特殊处理,依赖系统重建Activity,所有状态走onSaveInstanceState和ViewModel保存。看起来简单,但状态恢复涉及序列化、ViewModel作用域、Fragment的自动恢复,细节非常多。适合页面状态不复杂、登录页引导页这类低频交互页面。
第二种策略,用android:configChanges="orientation|screenSize|keyboardHidden"声明不给系统重建的机会,自己监听onConfigurationChanged回调去刷新布局。这个方案最大的优势是Activity不会重建,数据不丢,改动量小。但代价是你得自己在回调里手动切换布局适配逻辑,比如重新setContentView或者动态调整View参数。而且很多第三方组件库本身没有做横屏适配,它们内部可能依赖生命周期重建来刷新自己,你手动切断重建后,这些组件反而表现异常。
第三种策略,强制锁定屏幕方向,不响应横屏。通过android:screenOrientation="portrait"锁定竖屏,或者代码里调用setRequestedOrientation。这是最省事的方案,很多工具类App都这么干。但代价是牺牲用户体验,特别是视频播放、图片预览、图表展示这类横屏体验明显更好的场景。
我的建议是:视频播放、图表、分屏多任务这类核心横屏场景,老老实实用第一种方案配合状态保存;内部工具页、设置页、列表页,能锁就锁,别硬撑横屏;对于那些必须响应横屏又不能重建的复杂页面,再用configChanges方案并做好onConfigurationChanged里的布局刷新逻辑。实战中大部分适配问题,都是三种方案混在一起用,页面级差异化处理。
2. 横屏布局适配:从layout-land到尺寸微调
生命周期问题解决之后,遇到最多的就是布局错乱。横屏后屏幕宽度变大但是高度变小,竖屏时刚刚好的控件位置,横屏后要么互相遮挡,要么被挤出屏幕。这部分靠的就是资源目录和布局策略的配合。
2.1 资源限定符怎么用才不踩坑
横屏资源目录的基本规则是:在res目录下创建layout-land、values-land、dimens-land等带land限定符的目录,系统在横屏时会自动加载这些目录下的资源,不用写任何判断代码。
但这里有一个容易踩的坑:系统限定符的匹配是“最佳匹配”而不是“唯一匹配”。比如你创建了layout和layout-land两个目录,竖屏时加载layout,横屏时加载layout-land,这没问题。但如果再叠加一个layout-sw600dp目录,情况就复杂了:横屏且屏幕最小宽度大于600dp的设备,会同时匹配layout-land和layout-sw600dp,此时系统会根据限定符优先级选一个最精确的。land和sw这两类限定符没有绝对的优先级关系,匹配结果取决于设备具体配置,一旦匹配不如预期,布局错乱就很隐蔽。
横屏适配的目录规划,我建议按照页面重要程度分级处理:极少数核心页面单独做layout-land,大多数页面不做横屏专属布局,靠通用布局的自适应能力。这样既不臃肿,也不容易发生限定符打架。
如果只是尺寸微调,可以只在values-land里放一份dimens覆盖竖屏值,比如把某个margin从16dp改成24dp,无需复制整个布局文件。复制布局文件最大的问题是维护成本,后期改一个竖屏按钮的位置,漏改横屏文件,测试阶段就会漏问题。
2.2 横竖屏布局差异的常见处理套路
横屏的常见差异有几个类型。
第一个是比例型差异:竖屏时适合上下堆叠的线性布局,横屏时换成左右并排的相对布局或约束布局。我的套路是优先使用ConstraintLayout,它天然支持按比例约束和自适应间距,很多页面一套布局横竖屏通吃。比如一个竖屏时居中偏上的卡片,用ConstraintLayout加垂直偏移比例,横屏时不需要换布局,卡片位置自动合理。
第二个是尺寸型差异:竖屏时全宽的输入框,横屏如果还是全宽就太长了。此时可以把宽度约束为最大宽度,或者水平方向的margin放大。典型做法是设置layout_width和layout_height后,再用layout_constraintWidth_maxSize限制最大宽度,配合layout_constraintWidth_percent设定占据屏幕的比例。
第三个是容器型差异:ScrollView在竖屏时可能因为内容超高而需要滚动,横屏时内容可能变得很短。反过来,有的内容在竖屏刚好放下,横屏高度不够需要滚动。横屏布局里,根据内容结构决定是否包裹ScrollView,并且注意ScrollView里套RecyclerView的嵌套滚动问题。这种页面的常规做法是,横屏时把RecyclerView的高度设为wrap_content,或者干脆不让它滚动,由外层ScrollView统一负责。
还有一个隐藏比较深的问题:软键盘。横屏时软键盘几乎占据半个屏幕,底部按钮很容易被顶飞。处理方式是在AndroidManifest的windowSoftInputMode里设置adjustResize,并配合ViewCompat.setOnApplyWindowInsetsListener监听键盘弹出高度,动态调整底部按钮的margin。这个细节不处理,横屏输入场景基本必翻车。
3. 多屏幕适配的核心:dp、dpi与计算逻辑
说完了横屏,再看多屏幕适配。市面上的安卓设备屏幕尺寸从4.7英寸到12英寸以上都有,分辨率从720P到2K、4K,像素密度差异巨大。要在这么杂的环境下统一视觉表现,彻底理解dp和dpi的关系是第一位的。
3.1 px、dp、dpi的关系与换算
先厘清概念。
px是物理像素,也就是屏幕上的发光点。dpi是屏幕像素密度,表示每英寸有多少个像素点,计算公式是屏幕对角线像素数除以对角线英寸数。dp是密度无关像素,为了保证视觉尺寸在不同屏幕上表现一致。
核心换算公式是:px = dp * (dpi / 160)。这个160是基准密度,Android假设在160dpi的设备上,1dp等于1px。如果一台设备dpi是320,那么1dp就等于2px。
为什么要除以160?因为视觉尺寸的本质是物理尺寸,1dp在理论上等于1/160英寸。屏幕密度越高,同样的物理尺寸需要更多的物理像素,所以dp换算成px时需要乘上密度系数。理解了这一点,就不会再犯在代码里写死像素值的错误。
实际项目中,最常用的换算场景有两个。一个是在代码里把dp转px,用TypedValue.applyDimension或者直接用Resources.getSystem().getDisplayMetrics().density乘以dp数值。另一个是从设计稿拿到px尺寸后,除以设备密度得到dp值再写进布局。
很多新手会在这里犯一个错:以为把设计稿标注的px直接换成dp写在布局里就完事了。实际上设计稿通常基于某个固定宽度(比如375dp),你要把设计稿尺寸换算成目标设备的dp值,需要先明确设计稿的dp基准宽度。比如设计稿宽750px、按2倍图切图,那么基准密度是2,设计稿的750px对应375dp,之后的布局都应该在这个dp语义下书写。
3.2 屏幕适配方案的演进:从百分比到sw限定符
早期的屏幕适配方案是按屏幕分辨率建dimens目录,values-1280x720、values-1920x1080这样搞。这套方案文件爆炸,维护成本极高,而且系统限定符匹配时有容错机制,找不到精确匹配就看default,实际效果很不稳定。
后来出现过一个比较知名的百分比库,思路是把控件宽高按父容器的百分比动态计算。它解决了一部分问题,但侵入性太强,布局可读性差,性能上也有损耗,现在已经很少有人在用了。
当前主流方案有两个。
一个是sw最小宽度限定符,也就是values-sw480dp、values-sw600dp、values-sw720dp这种目录。sw指的是屏幕最短边对应的dp值,系统按这个值匹配最接近的资源目录。这套方案的优点是纯资源方案,无侵入,布局文件不用改,直接换dimens里的数值就能全局调整尺寸。缺点是文件量还是不小,而且sw值需要按主流设备分档,档位分得不合理就会出现尺寸跳变。
另一个是今日头条适配方案,核心思路是放弃dp语义,直接把designWidth设为目标宽度dp值(比如360dp),然后在App启动时通过修改全局DisplayMetrics的density、scaledDensity、densityDpi三个值,强制让系统按当前设备的实际宽度动态计算dp换算率,使得所有使用dp单位的布局都按照设备宽度等比缩放。
头条方案最大的优点是:布局代码完全不用动,设计稿标注px直接默认为dp写上去即可,所见即所得。缺点是它修改的是全局密度,会影响动画、文字显示、第三方控件内部布局,如果项目里混用了多种单位和库,很容易出现意外缩放。还有一点,如果你的App要支持平板,头条方案默认是按屏幕宽度缩放的,平板上会放大得非常夸张,需要额外加平板判断逻辑。
我的选择倾向是:工具类、表单类、信息列表类App,直接上头条方案,开发效率最高;游戏、视频、创意设计类对精确视觉要求高的,用sw方案或者自研适配框架更可控。另外还有一个折中方案,dimens只建标准值和常用档位,配合ConstraintLayout的百分比约束来消化中间差值,项目结构简单时这套组合很实用。
3.3 实际项目中的适配清单
落到实际项目里,多屏幕适配要检查的点大致可以整理成下面这个流程。
- 全局基础设置:确认设计稿宽度基准,统一density换算工具类,避免各处写死转换逻辑。
- 布局层面:外层优先ConstraintLayout,用比例约束、链、Guideline替代固定间距和固定宽高。
- 字体适配:字体单位尽量用sp,但也要知道sp会跟随系统字体缩放设置,如果不想让大字模式打乱布局,可以设置字体不随系统缩放,或者在特定页面关闭sp缩放。
- 图片与图标:切图使用drawable-xxhdpi等密度目录,控件尺寸用dp,Bitmap在代码中加载时避免手动解压到全尺寸。
- 列表item:item根布局高度尽量自适应内容,不要固定死高度,除非产品明确要求固定行高。
- 横竖屏切换测试:每个页面至少在竖屏和横屏下各过一遍,特别是有RecyclerView、EditText、弹窗的页面。
这个清单看着简单,实际执行起来最考验的是设计规范。如果设计稿本身没有按标准网格出图,间距随意,开发阶段再强的适配方案也救不回来。所以好的适配一定是设计、开发、测试三方对齐规范的结果。
4. Android Studio里的多屏验证与调试技巧
代码写完了,适配效果不能靠想象,要借助工具验证。Android Studio自带的工具里,有对适配帮助极大的功能,用好的话能省大量真机测试时间。
4.1 Layout Validation的使用实战
Layout Validation是Android Studio自带的多屏预览工具。入口在编辑器右上角的预览窗口,点击"View"菜单选择"Tool Windows"里的"Layout Validation",打开后可以同时预览同一个布局在多个设备上的渲染效果。
这个工具最大的价值,是能在不改动代码的情况下快速发现布局在窄屏、宽屏、平板上的表现差异。比如一个ConstraintLayout约束的卡片页面,你在Preview里选一台Pixel 2和一台Pixel Tablet,宽高比不同,布局会不会挤压、图片会不会变形、间距合不合理,一眼就能看出来。
我在实际使用中一般会把这些设备加进集合里统一预览:一台小屏旗舰(比如5.5英寸左右1080P)、一台大屏旗舰(6.7英寸左右2K)、一台老式中端机(720P)、一台平板(10英寸左右)。每次改完布局代码,先跑一遍Layout Validation再看真机。这样做最大好处是快速筛选出明显的问题布局,再集中针对性修复。
Layout Validation只能看布局渲染效果,无法验证生命周期变化和状态保存逻辑,所以它替代不了真机测试,但作为适配前期的自测工具,确实高效。
4.2 设备模拟器与云真机测试的取舍
Android Studio自带的AVD模拟器可以创建各种屏幕尺寸和密度组合的设备。但模拟器资源消耗大,启动慢,而且模拟器的传感器、真实硬件行为跟真机有差异,比如刘海屏的真实显示区域、键盘弹出行为、系统字体缩放,模拟器模拟得不够真实。
针对需要精确验证的机型,我的经验是:先本地模拟器跑主流配置,再交给云真机平台在真实设备矩阵上做自动化遍历测试。云真机平台的好处是设备覆盖型号广,可以在真实设备上自动跑Monkey和UI自动化用例,截屏对比。横竖屏切换、软键盘弹出这类场景,云真机里都有真实系统行为可以复现。
不过云真机也有局限。自动化的用例覆盖面取决于用例脚本写得多深,如果测试用例本身没有覆盖到某个横屏页面,那云真机跑再多遍也发现不了问题。所以在适配联调阶段,核心页面我建议一定要在真机上手工滑一遍,特别是那种“看着没事,点下去才发现按钮偏移”的交互层问题,自动化脚本不容易发现。
5. 进阶场景:刘海屏、挖孔屏和折叠屏
多屏幕适配做到这个阶段,基础工作已经完成,剩下的都是麻烦。这里说的麻烦是指:刘海屏、挖孔屏、瀑布屏、折叠屏这类形态差异大的设备,它们在系统层面对App的渲染区域有特殊处理,使用的就是WindowInsets和窗口布局参数。
5.1 WindowInsets与安全区域
Android从API 21开始引入WindowInsets的概念,系统把状态栏、导航栏、刘海区域、键盘等系统窗口区域,通过WindowInsets传给应用,应用按需处理。
默认情况下,Target SDK 35的App在不做任何配置时,内容是不会自动避开刘海等区域的,实际行为是系统会强制把窗口设置为不延伸到这些区域,但竖屏下底部导航栏有时会被内容覆盖,横屏时刘海区域的显示问题更明显。
处理安全区域的正统做法是监听View的setOnApplyWindowInsetsListener,拿到系统栏insets之后,给需要避让的View设置padding或者margin。示例代码大致是这样:
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) binding.contentLayout.updatePadding( left = bars.left, top = bars.top, right = bars.right, bottom = bars.bottom ) insets }这样就能保证内容始终不被系统栏遮挡。对于横屏场景,导航栏通常在屏幕底部或侧边,刘海通常在一侧,使用systemBars类型获取到的值基本覆盖了这些区域。
需要特别注意的是displayCutout这个类型。有些设备的刘海区域不在系统栏范围内,比如横屏时刘海在屏幕侧边中间位置,此时只有使用cutout类型才能拿到实际的安全距离。
val cutout = insets.getInsets(WindowInsetsCompat.Type.displayCutout())如果App的目标SDK比较高,系统默认会尽量帮你处理cutout,但有些设备厂商会提供“刘海屏显示”开关,强制App的显示区域扩展到刘海区域,这时候想不遮挡就只能自己适配。还有一个思路是,把根布局的fitsSystemWindows设为true,让系统替你处理padding,但这个方案的应用场景受限,它只解决padding问题,如果布局内部有重叠或滚动内容,依然需要手动适配。
5.2 折叠屏适配要点
折叠屏设备这两年越来越多,适配思路跟普通多屏幕稍不一样。折叠屏展开后会触发screenLayout、smallestScreenSize等配置变化,相当于一次屏幕尺寸的剧烈改变,如果Activity没有做configChanges声明,就会被系统重建。
折叠屏适配的第一个重点,是确认你的App是否要支持展开态。如果只是普通应用,默认在展开态下的效果基本是屏幕变大而已,大多数情况下布局不至于崩溃,但观感可能会比较空。想要展开后全体验升级,通常需要监听posture变化,在onConfigurationChanged里判断当前是否为展开状态,并切换布局资源目录或者调整控件比例。
第二个重点是横竖屏与折叠的交叉情况。折叠屏展开后屏幕比例接近正方形,此时竖横屏的判断跟普通手机不太一样,开发者可能会发现系统对横竖屏的判定跟预期不符。排查思路是检查smallestScreenSize的实际值,并据此配置布局资源。
第三个重点是自适应窗口。Android官方推荐使用自适应窗口库(Jetpack WindowManager)来监听窗口状态变化,它提供了windowLayoutInfo的回调,可以拿到当前窗口的显示姿态、折叠角度等信息。示例代码大致是这样:
WindowInfoRepository().windowLayoutInfo(activity) .collect { layoutInfo -> val foldingFeature = layoutInfo.displayFeatures .filterIsInstance<FoldingFeature>() .firstOrNull() // 根据foldingFeature的state和orientation做适配 }折叠屏适配不用追求一步到位,一般做到“展开不崩溃、不遮内容、关键控件可用”就算合格。过度适配反而容易引入新的兼容问题。
6. 常见问题排查与避坑记录
这一章的内容,是从实际项目中踩出来的教训。适配问题大多不会出现在代码语法层面,而是隐藏在系统的各种默认行为里,排查起来比较费劲。
6.1 横屏Activity被重建,数据丢失
这是横屏适配出现频率最高的问题。现象是横屏后Activity重新执行onCreate,界面上用户输入的内容全部丢失。
排查顺序如下:先看AndroidManifest里这个Activity是否声明了configChanges。如果没有声明,系统必然重建Activity,那就需要检查状态保存是否完整。onSaveInstanceState里是否保存了EditText的输入内容?RecyclerView的滚动位置是否保存了?如果数据存在ViewModel里,Activity重建后ViewModel是否会被清空?
如果声明了configChanges,数据还是丢了,那问题就出在声明不全。比如只写了orientation,没写screenSize,部分Android版本上仍然会重建Activity。因为从API 13开始,screenSize也是触发生命周期变化的配置项。正确写法是:
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout"这里需要补一个细节:声明了configChanges意味着所有配置变化都交给App自己处理,那么onConfigurationChanged里必须自己处理布局切换和尺寸变化,否则会出现界面不刷新、布局残留的问题。
6.2 横屏后布局错乱,但是竖屏正常
这类问题的根源通常不在屏幕方向,而在于布局本身不具备自适应能力。比如固定宽高的TextView、写死margin的ConstraintLayout、没有处理长文本换行的控件,在宽高比变化时就会露出马脚。
排查思路是用Layout Validation切到横屏设备看看实际效果。根布局如果是LinearLayout,检查权重是否分配正确;如果是RelativeLayout,检查依赖关系的控件是否因为空间不足而重叠;如果是ConstraintLayout,检查是否有控件宽度设置了固定值没有使用约束链或比例约束。
还有一种常见情况是横屏专属布局文件写好了,但没生效。检查layout-land目录是否存在、命名是否正确(必须是layout-land而不是layout_land)、Activity的setContentView是否使用了R.layout下的同一常量。系统资源目录的匹配是靠目录名实现的,目录名写错系统不会报错,只是静默加载默认布局,这个坑特别隐蔽。
还有一种情况是代码逻辑里对宽高做了预设。比如在某些麒麟芯设备上,系统汇报的屏幕尺寸跟实际显示不一致,导致dp计算偏离预期。遇到这种设备相关的问题,先打印DisplayMetrics确认实际值,再针对性处理。
6.3 我的适配排查顺序速查表
下面这个表格是我在实际项目中总结出的排查顺序,基本能覆盖90%的适配问题。
| 现象 | 排查步骤 | 常见原因 |
|---|---|---|
| 横屏后Activity重建 | 检查configChanges声明 | 声明缺失或不全 |
| 横屏后数据丢失 | 检查onSaveInstanceState和ViewModel | 状态没有保存或保存类型不可序列化 |
| 横屏布局加载不对 | 检查layout-land目录和布局文件名 | 目录名错误或文件缓存未刷新 |
| 横屏控件被遮挡 | 检查安全区域padding和刘海屏适配 | 没有处理WindowInsets和displayCutout |
| 平板界面控件拉伸变形 | 检查px/dp混用情况 | 代码中写死px像素值 |
| 字体显示异常 | 检查sp单位和系统字体缩放 | sp被全局density修改方案污染 |
| 动画或弹窗位置偏移 | 检查根布局和DecorView层级 | 弹窗的anchor位置计算用的是旧的屏幕尺寸 |
这个表里的每一项都是我真实处理过的案例。特别提醒一下字体缩放那个问题:用了全局修改density的方案之后,字体缩放会变得异常,因为scaledDensity也会被改掉。如果不想让用户设置的大字体被适配方案吞掉,可以在修改全局density时单独保存原始的scaledDensity,再根据系统字体缩放比例叠加。
6.4 适配的边界:什么时候该停手
适配工作最大的陷阱不是不会做,而是做得过火。我把适配按设备类型划分成几个边界:
- 手机和平板共用一个布局,但平板端只保证信息可读、操作可用,不追求视觉上的完全统一。
- 按分辨率建dimens目录的方案,只维护当前用户设备TOP10的分辨率,不做全量覆盖。
- 桌面级设备、电视、车载等大屏场景,只做基本不崩的标准,不专门优化。
适配的本质是取舍。不可能让每一个屏幕像素都跟设计稿一模一样,这既不可能也没必要。适配的最终标准是:内容不遮挡、操作可达、视觉无明显变形。超出这个标准的优化,大部分是自嗨。
横屏适配和多屏幕适配,说到底是一套理解资源系统、理解生命周期、理解窗口机制的工程实践。把基础概念搞清楚,把工具用好,把测试做扎实,再刁钻的屏幕变化也翻不了天。我的经验是,每次遇到适配问题,先别急着写代码,花十分钟确认系统行为到底是怎么样的,往往比埋头改布局高效得多。