最近后台经常有人问我折叠屏适配该怎么做,华为、小米的折叠机型一轮接一轮发布,很多应用的“大屏体验”却还停留在拉伸和黑边阶段。这个问题的本质,不是把手机界面简单放大,而是一个应用窗口要在两种甚至多种尺寸之间连续切换,并且还要处理铰链、外屏、分屏这些附加状态。我拿自己适配过的几个项目复盘了一下,把从布局、生命周期到厂商生态差异的完整思路整理出来,希望能帮到正在啃这块硬骨头的开发者。
这篇内容主要面向Android和HarmonyOS应用开发同学,也适合测试和产品同学用来理解折叠屏场景下的设计约束。你不需要懂多深的前端知识,能看懂dp、断点和回调,就能跟着把一套基础适配方案落进项目里。我会把华为和小米两条技术路线分开讲,也会把所有踩过的坑摊开来说。
1. 折叠屏形态与适配问题的全貌
1.1 折叠屏到底有哪些形态
折叠屏不是一种设备,而是好几类设备混在一起。我们先按结构分分类,因为每一类的适配重点完全不同。
书本式内折是目前大折叠的主流形态,华为Mate X系列末代机型、小米MIX Fold系列都是这类。它折叠时是一台窄长直板机,展开后是接近方形的平板,屏幕比例通常在8:7或者4:3附近,对角线大概7到8英寸。这类设备的关键切换点在于:折叠时是手机,展开时是平板,而且手机端和平板端的UI密度、栅格、信息层级差异很大。
翻盖式小折叠则是另一条路线,像华为Pocket系列、小米MIX Flip这类。折叠后外屏是一个小方窗,展开后就是一台普通直板手机。这类设备的适配压力不在“变大”,而在“变小”:你得保证折叠后外屏上的关键操作仍然看得清、点得着,同时展开后又能立刻切换到完整的信息布局。
外折和卷轴屏在不同品牌上有不同的探索路径,但核心逻辑一致:系统可用窗口的尺寸是一个连续变量,应用随时可能被塞进一个和之前完全不同的窗口里。相比之下,传统直板机的旋转只是方向变了,宽高比基本不变,这大大降低了适配难度。
1.2 连续变化的窗口才是真正的难点
很多团队在直板机上做了多套布局,但真正到了折叠屏上才发现,问题不是“布局样式不够”,而是“布局切换的时机和触发方式”没搞明白。
旋转屏幕时,系统通常会把Activity销毁重建,走一遍完整的配置变更流程。折叠屏展开时,系统的处理方式不那么统一:有些设备走配置变更,有些设备触发的却是一次窗口Resize,还有一些设备会先销毁再重建。更麻烦的是,铰链区域会把屏幕内容分成左右两块视觉区域,这已经不是传统意义的“全屏”了,普通应用的测量逻辑会在铰链区域出现错乱。
这里有个很形象的类比:传统适配就像是给一件衣服定做了M和L两个尺码,用户换一个尺码就换一套衣服,切换是完整的。折叠屏适配则更像一件可以随时拉链变形的外套,用户一边穿着一边拉拉链,衣服得在穿的瞬间跟着变形,还不能把口袋里的东西掉出来。这种“运行时连续变化”的处理,才是折叠屏适配真正的技术核心。
1.3 谁应该看这份适配经验
如果你正在维护一个日活不错的内容或工具类应用,折叠屏适配大概率已经排上日程了。产品同学需要看第1章和第2章,理解为什么不能用固定宽度;开发同学重点看第3章到第5章,这里覆盖了生命周期、厂商差异和真实问题排查;测试同学可以直接把第5章的检查清单拿去建回归用例。
前置知识不需要太多,理解dp这个单位,知道Android四大组件和Activity生命周期,就能读懂大部分内容。HarmonyOS侧的示例我会写成ArkTS,但核心思路和Android侧是相通的。
2. 先从布局层打好底座:响应式设计是折叠屏适配的前提
2.1 用窗口尺寸类替代固定断点
折叠屏适配的第一条原则,就是别再把手机和平板当成两种设备,而是把窗口宽度当成一个连续变量。官方推荐的断点体系可以解决大部分问题:
- 宽度小于600dp,属于紧凑档(Compact),适合单栏布局;
- 宽度在600dp到840dp之间,属于中等档(Medium),可以放双栏或者导航抽屉;
- 宽度大于840dp,属于扩展档(Expanded),可以放多栏、侧边导航栏、主从详情布局。
一张表看清楚档位和布局能力的关系:
| 窗口宽度 | 尺寸类 | 典型布局 | 折叠设备场景 |
|---|---|---|---|
| 小于600dp | Compact | 单栏列表、底部导航 | 大折叠折起来、小折叠展开前 |
| 600dp到840dp | Medium | 双栏列表+详情、侧栏导航 | 部分大折叠展开状态 |
| 大于840dp | Expanded | 三栏、多栏工作台 | 大折叠展开、平放悬停场景 |
实际操作时,不要写死“折叠了就切双栏,展开了就切单栏”,而应该用当前窗口宽度去映射布局档位。因为同一个展开状态可能配合不同的字体缩放、分屏比例和窗口拖拽尺寸,用档位来判断比用设备型号判断可靠得多。
2.2 约束布局、写宽不写高与动态栅格
Android侧布局我用得最多的是ConstraintLayout加动态计算的gridCount。例如RecyclerView的列表,在窄窗口里每行适合2个卡片,展开后就改成3到4个。这个值不需要硬编码,从窗口宽度dp和卡片最小宽度反推即可:
val widthDp = resources.configuration.smallestScreenWidthDp.takeIf { it > 0 } ?: 360 val cardMinWidth = 200.dp val spanCount = (widthDp / cardMinWidth.value).coerceAtLeast(1)这段代码的思路是:让卡片宽度决定每行列数,而不是让屏幕硬塞固定列数,这样在中间过渡尺寸下也不会突然出现一列或多列的跳变。
另一个容易踩的坑是高度写死。很多页面喜欢把一个可滑动区域的高度设置成某个固定值,或是写成match_parent后还在父容器里再套一层ScrollView。在折叠屏切换时,窗口高度可能突然变高或变低,固定高度会出现大面积留白或内容被裁切。正确的做法是让最外层容器自适应,内部滑动区域使用match_parent配合约束关系,不要人为设置高度。
2.3 字体、间距与触摸目标不能一视同仁
折叠屏展开之后可视面积变大,但很多应用的字体和间距还在按手机逻辑渲染,结果就是一屏能显示的内容数量没有增加,反而显得空洞。HarmonyOS和Android都有对应的统一字体缩放曲线,建议在断点切换时同步调整正文字号、标题字号和页面左右边距。
大屏上还要重新审视触摸目标。手机时代48dp的最小点击区域勉强够用,但在大屏上用户的手指移动距离更远,误触率上升。展开状态下,建议把列表项高度、按钮高度提升到56dp以上,卡片与卡片之间的间距也适当加大,让视觉层级更清晰。
提示:不要用sp写死所有文字,sp会跟随系统字体缩放,折叠屏用户常常调大字体。布局里用dp,文字用系统sp,再配合档位做缩放映射,才能兼顾可用性和无障碍需求。
3. 运行时折叠状态感知:监听、回调与铰链区域处理
3.1 别依赖简单的configChanges配置
很多Android开发者以为,在Manifest里加上超大configChanges集合,就能阻止Activity重建、让折叠屏切换变得顺滑。早期我确实这么干过,但后来发现这个方案在折叠屏上漏洞很多。
当你在Manifest里声明处理了screenSize和smallestScreenSize,系统确实不会重建Activity,但你的布局并不会自动适应新的窗口尺寸。你需要手动拦截onConfigurationChanged,在里面重新测量布局、更新资源引用、处理WebView重绘。问题是,厂商ROM里有些窗口尺寸变化不会触发onConfigurationChanged,而是走Resize流程,导致你的布局还停留在旧尺寸,界面出现黑白块或错位。
更稳妥的做法是:让Activity保持默认的可调整大小属性,允许系统在配置变化时重建,同时用ViewModel或SavedStateHandle保存关键状态。折叠屏切换是一次低概率的短暂过程,重建带来的开销完全可以接受,换来的是布局正确性。
3.2 用Jetpack WindowManager读取铰链和姿态
Android官方提供的Jetpack WindowManager库可以获取显示特征(DisplayFeature),也就是铰链的具体位置、遮挡状态和展开角度。这是做悬停模式和铰链避让的底层依赖。
val windowInfoTracker = WindowInfoTracker.getOrCreate(context) lifecycleScope.launch { windowInfoTracker.windowLayoutInfo(this@Activity, lifecycle) .collect { layoutInfo -> val foldingFeature = layoutInfo.displayFeatures .filterIsInstance<FoldingFeature>() .firstOrNull() if (foldingFeature != null) { when (foldingFeature.state) { FoldingFeature.State.HALF_OPENED -> { // 帐篷模式或悬停模式 } FoldingFeature.State.FLAT -> { // 屏幕完全平展 } FoldingFeature.State.FULLY_OPENED -> { // 完全展开 } } val isSeparating = foldingFeature.isSeparating // 铰链是否把内容分隔成两块独立区域 } } }这个API的核心价值是判断“铰链是否正在分隔应用内容”。如果isSeparating为true,说明窗口被分成了两块独立区域,此时你可以把应用的主内容和操作区分别放到两个区域里,避免内容骑在铰链上。
有一个细节容易被忽略:WindowManager库不是所有真机都返回有效数据,尤其是部分老机器和模拟器。建议设计一个fallback策略,当拿不到FoldingFeature时,用窗口宽高比的变化来推断折叠状态,或者干脆按尺寸档位重新布局。
3.3 折叠屏本质上是多窗口容器
大折叠展开后,用户很容易把应用拖进分屏,或者打开自由窗口。这意味着你的应用不能假设自己永远占据全屏。如果代码里存在大量基于全屏宽度的计算,比如弹窗居中位置、动画终点、自定义View的onDraw坐标,在分屏和折叠状态下都会出问题。
处理思路是:所有关键坐标和尺寸都从当前可用窗口尺寸获取,而不是从屏幕尺寸获取。Android侧使用windowManager.currentWindowMetrics,HarmonyOS侧使用窗口的avoidArea和栅格计算。这样即使窗口被压缩到原来的一半,布局也能自动跟着收敛。
4. 华为与小米两大生态的差异、兼容与实操要点
4.1 HarmonyOS侧的自适应用户界面
华为折叠屏上运行的HarmonyOS,在应用适配层面有一套更激进的自适应方案。ArkUI里提供了断点系统和栅格系统,推荐直接使用GridRow和GridCol做布局,而不是写死Row或者Column。
@Entry @Component struct NewsPage { @State currentBreakpoint: string = 'sm' build() { GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: { x: 12, y: 12 }, breakpoints: { value: ['600vp', '840vp'], reference: BreakpointsReference.WindowSize } }) { GridCol({ span: { sm: 4, md: 5, lg: 8 } }) { NewsList() } GridCol({ span: { sm: 4, md: 3, lg: 4 } }) { if (this.currentBreakpoint !== 'sm') { NewsDetail() } } } .onBreakpointChange((breakpoint: string) => { this.currentBreakpoint = breakpoint }) } }这套断点系统和Material的窗口宽度档位对齐,sm对应紧凑,md对应中等,lg对应扩展。在ArkUI里做适配有一个优势:系统层面的折叠屏切换事件会直接触发断点变化,应用只需要响应断点状态,不用自己处理铰链监听。
另外HarmonyOS提供的平行视界能力,本质上是系统级适配。它会自动把一个应用里的两个页面并行显示在主屏的左右两侧,让旧应用在大屏上也能利用空间。但它们只适合快速兜底,当你需要精细控制页面间距、导航结构时,还是得自己做真实的自适应布局。
4.2 小米折叠屏背后的Android大屏体系
小米折叠屏目前是基于Android系统,HyperOS在大屏能力上更多是应用多开和自由窗口的增强。适配思路跟随Android 12L之后的标准演进,核心是两件事:第一,把targetSdkVersion提升到31以上,让系统不再对你的应用启用旧版兼容策略;第二,在Manifest里显式声明resizeableActivity为true,让应用能够自由适配多窗口。
<application android:label="折叠屏适配研究" android:resizeableActivity="true"> <activity android:name=".MainActivity" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout" android:resizeableActivity="true" /> </application>不过我要提醒一点:resizeableActivity声明成true之后,系统不会再帮你做那种“拉伸填满”的兼容处理,如果你的布局本身就不合理,展开后会迅速暴露问题。我的建议是:开发期先不开这个属性,等基础布局改成自适应之后,再打开它回归一轮,这样能逼着自己把布局做干净。
厂商系统里还有一个“强制横屏”和“兼容模式”,如果你的应用在折叠屏设备上被拉成了全屏,很可能是这类系统的应用信息设置里自动开启了屏幕适配兼容。遇到这种情况,先确认应用的目标SDK版本,再确认声明,不要急着加代码适配,因为那通常只是厂商侧的显示策略。
4.3 一套代码兼容两套生态的实操原则
华为生态和Android生态虽然底层不同,但适配原则一致:以窗口尺寸为核心,不要依赖厂商专属API去做核心布局。也就是说,HarmonyOS的ArkUI页面也要支持窗口尺寸变化,Android页面也要支持断点变化,两边共用一套设计规范。
测试矩阵上,我建议至少覆盖四类设备:华为的大折叠(内折展开)、小米的大折叠、任意一款翻盖小折叠、一台可调整窗口尺寸的模拟器或平板。每一类设备跑一遍折叠状态切换、旋转、分屏三个场景,截图对比关键页面。这些测试截图是给产品和技术评审看的硬证据,比口头说“适配了”有用得多。
5. 高频问题排查与避坑实录
5.1 内容拉伸与被裁切的四类典型症状
折叠屏适配最常见的问题就三类:拉伸、裁切、黑边。我把线上反馈和内部测试里出现过的案例整理成一张表,按症状、根因、处理三步列出:
| 症状 | 根因 | 处理方案 |
|---|---|---|
| 展开后图片和卡片被横向拉伸 | 布局使用固定宽高比或固定边距 | 换成约束布局+动态宽高比,图片加载时按控件实际尺寸裁切 |
| 列表只占屏幕中间一小条 | 根布局用了固定maxWidth | 去掉最大宽度限制,或用断点动态调整平铺策略 |
| 展开后右侧出现黑边 | 布局按折叠态的宽度设计,没做扩展态适配 | 引入断点系统,让内容容器填满可用宽度 |
| 展开后底部按钮丢失 | 底部定位用了固定的bottom margin,没考虑铰链和导航栏 | 使用WindowInsets监听,处理系统栏区域和铰链区域 |
这里面最隐蔽的是第一种。图片本来用match_parent加固定aspectRatio,在普通手机上看着没问题,展开后控件的宽高比变了,而图片仍按原来的宽高比填充,结果就是拉伸或者裁切严重。建议图片加载时优先按控件的实际显示区域做缩放,而不是把资源图片直接塞进控件里。
5.2 WebView与H5的适配
很多应用是原生壳加WebView,折叠屏适配的坑会集中在H5页面上。WebView默认会把网页视口设置成和设备实际宽度一致,但折叠切换时viewport尺寸变化并不会重新加载页面,导致内容排版还是旧尺寸,左右出现大面积留白。
处理方式分为原生和前端两层。原生侧,在onConfigurationChanged和resize回调里重新设置WebView的layoutParams,并且调用webView.reload()或webView.invalidate()触发页面重绘;前端侧,页面本身要写响应式断点,尽量使用vw和百分比宽度,避免固定像素宽度。
更要留意的是状态丢失。折叠切换可能导致WebView所在的Activity重建,如果WebView没有正确处理saveState和restoreState,网页的登录状态和滚动位置都会丢。遇到这个问题,建议在Activity的onSaveInstanceState里保存WebView的Bundle状态,恢复时再还原。
5.3 折叠切换时的崩溃与状态丢失
状态丢失的问题在折叠屏上特别凸出,因为它和旋转还不一样,折叠切换发生时用户往往是操作到一半。比如填写表单时合上手机,再打开时如果Activity重建了,输入框里的内容还保留着,但列表滚动位置或者日历选择状态丢失了,这种体验非常割裂。
建议把所有UI状态迁移到ViewModel或SavedStateHandle里,尤其是列表滚动索引、表单内容、标签页当前位置。Compose项目用rememberSaveable,ArkUI用@StorageLink或者LocalStorage,都能在配置变化时保住状态。
键盘和窗口insets也是一类隐蔽崩溃源。折叠切换时软键盘一般会自动收起,但如果你的页面里有针对键盘高度的硬件测量,或者把底部栏通过固定margin顶上去,展开后会出现输入框被键盘遮住、底部栏浮空的问题。正确做法是参考WindowInsets的ime和systemBars,动态调整安全区。
5.4 测试和回归的经验技巧
我自己的实操经验是:模拟器只用来做断点和布局验证,真机用来做最终的折叠手感验证。模拟器可以方便地调整分辨率,比如用wm命令模拟平板尺寸,但它没有铰链区域,也没有外屏逻辑,因此无法验证折叠状态下的铰链避让。
adb shell wm size 700x1000 adb shell wm density 240 adb shell wm size reset这组命令可以在开发阶段快速验证不同窗口尺寸下的布局,比每次都找真机效率高很多。线上问题出现后,优先查看用户设备的窗口宽高和密度上报,很多“没有适配折叠屏”的反馈,其实只是字体和缩放档位组合导致的边缘问题。
6. 一次最小适配改造示例与长期维护思路
6.1 把新闻列表页改成双栏自适应
我拿一个典型的新闻客户端改造过程做示例,这个应用原本是标准手机布局:顶部工具栏、一个竖向列表、底部标签栏。首轮适配做了三件事。
第一,把根布局从LinearLayout改成ConstraintLayout,列表项里的文字和图片用约束关系排版,移除所有固定高度。第二,列表的列数改成动态计算,窄屏单列,宽屏双列,超宽屏三列。第三,引入WindowSizeClass之后,在宽屏下让列表和详情页左右并排,左侧是新闻列表,右侧是详情页,点击列表项直接在右侧刷新详情。
val sizeClass = currentWindowSizeClass() when (sizeClass.windowWidthSizeClass) { WindowSizeClass.WIDTH_EXPANDED -> { binding.detailPanel.visible() binding.listPanel.weight = 0.4f binding.detailPanel.weight = 0.6f } WindowSizeClass.WIDTH_COMPACT -> { binding.detailPanel.gone() binding.listPanel.weight = 1f } }这里有一个小窍门:宽屏下不要把列表和详情各自做成独立的Fragment再手动同步,而是让列表项选中后把数据直接传给同一个VM,详情页只负责呈现VM状态。这样不管窗口怎么切换,右边的详情内容不会闪失。
6.2 什么时候值得做深度适配,什么时候不用做
折叠屏用户体量相比直板机仍然有限,团队资源紧张时,不建议一开始就做精致的双栏布局。我建议按投入产出比分三个档次推进:
- 第一档:保证应用在折叠屏上不撕裂、不黑边、不裁切,核心操作可用。这一档主要靠约束布局和断点规范,改动量小,是所有折叠屏设备的基本盘。
- 第二档:在宽屏下提供双栏或三栏布局,让列表和详情可以同屏展示,提升大屏信息密度。这一档开始使用WindowSizeClass和动态栅格,适合内容型和工具型应用。
- 第三档:为特定形态做定制体验,比如悬停模式下的视频控制、帐篷模式下的拍照预览。这一档需要接入铰链姿态监听,成本和体验提升都最高。
大多数新闻、电商、社交类应用做到第二档就够了,只有围绕折叠形态设计核心功能的应用才值得做第三档。做决策时记住一句话:窗口尺寸才是变量,设备型号只是表象。
6.3 长期维护的关键:把折叠状态当成一个窗口尺寸类
折叠屏适配做得越多,我越觉得“折叠”这个词会对开发产生误导。真正需要关注的是窗口尺寸在哪些值附近发生了跳变、用户如何在分屏和自由窗口下使用你的应用。把折叠屏当作普通大屏设备的一种来源,处理起来反而简单。
团队在长期维护时,建议把折叠屏真机放进自动化回归设备池,每轮发版前至少跑一次主路径用例。另外,在CI里加一个lint检查,拦截新代码里可能出现的固定宽高比布局和硬编码像素宽度,这类问题比逻辑Bug更难发现,越早拦截成本越低。
从实际操作中积累的几个心得
我自己在适配过程中最大的体会是:折叠屏适配不是一次性功能改造,而是一个持续“去设备化”的过程。以前写代码时总默认屏幕是某个固定尺寸,后来遇到折叠屏才明白,应用要能面对任意大小的窗口存活下来。
还有一个值得分享的工具性技巧:开发期把当前窗口的dp尺寸、密度和断点档位显示在调试面板里,方便边改代码边看实时状态。折叠屏设备切换时,这个面板能第一时间告诉你逻辑是否按预期走了,比加日志高效得多。
如果你正在做折叠屏适配,希望这份记录能帮你少走一些弯路。先从布局层动手,把固定值都清掉,再接入断点和窗口状态,最后再考虑厂商生态的特殊优化——按这个顺序推进,你的折叠屏体验就不会太差。