Appearance
当前窗口、断点与响应式布局:从 375 小屏补偿到多栏结构
回到总览:屏幕适配:从清晰显示到动态窗口可用相关模块:01-dp/dpi 与图片资源放置(dp 换算 / 密度桶)· 03-字体 / 安全区 / 折叠屏 / 验证矩阵(系统边界)·
11-image-loading(图片加载链路)
一句话定义
用统一断点(compact < 600dp / medium 600~840 / expanded > 840)在手机、平板、横屏之间切换布局,而不是纯比例放大。三端断点对齐 Material 标准,保证心智一致、设计稿可复用。
这张图把本文档在整条适配链里的位置标出来:本文档(02)只解决中间那层“断点响应式”。它的前提是 01 已经把密度单位(dp / sp / 矢量 / 密度桶)做对,它的落点是 03 要兜住的系统边界(字体 / 安全区 / 折叠分屏)。读者先在脑里建立“三层叠加”的心智,再看下面的断点细节,就不容易把 375 放大误当成完整适配。
开篇案例:把 375 手机稿放到 1024dp 平板会怎样
设计师按 375 宽出稿,开发用 flutter_screenutil 把整页尺寸按 当前宽度 / 375 等比放大到平板:
text
s = 1024 / 375 ≈ 2.73结果:48dp 图标变成约 131dp 视觉量级、列表行高和卡片圆角一起被放大近 3 倍,信息密度几乎没变——用户看到的是“大号手机”,不是“平板应用”。正确做法是到 600/840dp 断点后换两栏/三栏结构,增长栏位和导航形态,而不是继续放大手机单列布局。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| Flutter 断点适配对照 | adaptive-layout-demo | adaptive_layout_demo_page.dart |
| Android Compose 断点对照 | screen-adaptation (Compose) | WindowSizeClassDemoActivity.kt |
| Android View/XML 资源分桶 | screen-adaptation (View/XML) | ViewXmlAdaptiveDemoActivity.kt |
为什么需要
- 为什么 375 基准在平板上会出事故?
- 一句话答:缩放系数
s = 屏幕逻辑宽 / 375,iPad 768~1024dp → s≈2~2.7,整个 UI 被放大两倍多:图标巨大、列表稀疏、横屏更糟(详见 §375 设计稿缩放)。
- 一句话答:缩放系数
- 为什么三端断点要统一 600 / 840?
- 一句话答:Material 规范定义的标准断点,保证三端心智一致、同一个设计稿能映射到同一套布局决策。
- 为什么“大屏适配”本质上是信息密度问题?
- 一句话答:因为大屏真正多出来的是可承载内容的空间,所以应该增加栏位、列表详情并排、导航形态,而不是把同一套手机 UI 等比拉大。
前置边界:dp 解决密度,不解决占屏比例和信息密度
01 已说明:dp 是逻辑密度锚点,让同一 100dp 按钮在 320dpi 和 480dpi 屏上视觉尺度近似一致(真机因厂商逻辑密度 override 会有偏差,不能承诺绝对物理尺)。
但 dp 不归一化占屏比例:360dp 逻辑宽手机上 100dp 占 27.8%,411dp 手机上只占 24.3%——大屏显空、小屏显挤。这是布局层问题,不是再调几个 dp 能解决的,需要断点换结构或(仅 compact 小屏)** 375 比例补偿**。
图下备注:dp 归一化了“密度”,但没归一化“占屏比例”。占屏比例随当前窗口逻辑宽变化——这正是 02 要解决的输入变量。
375 设计稿缩放:解决什么、不能解决什么
本节承接 01 迁出的完整论证:DP 是物理锚定,375 是比例锚定。
DP 的核心特性:在正常(未虚报)设备上,100dp 渲染出的视觉尺度在不同密度屏上近似一致。这保证了“不同密度屏显示一致”——同一套 dp 布局,在 320dpi 和 480dpi 上看起来一样大。
但 DP 不归一化“占屏比例”:两台手机逻辑宽不同(360dp vs 411dp),100dp 占屏比例就不同(27.8% vs 24.3%)——大屏上显得“小、空”,小屏上显得“大、挤”。
所以业界用“375 分份”(比例法) :设计师画 375pt 宽的设计稿(iPhone 基准稿),App 按“设备逻辑宽 ÷ 375”整体等比缩放整稿:
text
实际值 = 设计值 × (设备逻辑宽 / 375)- 设计稿上“占 26.7% 宽的按钮”,在 360 宽手机占 26.7%、在 411 宽手机也占 26.7%——比例永远一致,所以“看着像同一套 UI”。
- 巧用:
375.dp即全屏宽、375.dp/4即 25% 宽,百分比体系自洽(Flutter 工程里.dp = ScreenUtil().setWidth正是这套)。 - 375 是业界最常用基准:源于 iPhone 设计稿宽 375pt;Flutter 的
flutter_screenutil默认 / 常用designSize: Size(375, 812)。 - iOS 与 Android
dp一样“直接写设计值”,但“不满屏”问题照旧:pt就是设计单位,375pt 稿在 iOS 直接写width: 375,无需 375 分份的比例系数——这点 Androiddp、Flutter 逻辑像素也一样。但 375pt 在 410/430pt 的 iPhone 上同样不满一屏、在 iPad(768pt+)更空——iOS 靠 Auto Layout 约束自适应 + Size Class 断点换布局(见下文 iOS 对照)兜底,而不是把整稿等比放大。375 分份只是 Flutter(flutter_screenutil)这类“整稿等比缩放”路线的补偿手段,不是三端通吃的免适配方案。
适用边界(手机 compact) :
| 逻辑宽 | s = width/375 | 评价 |
|---|---|---|
| 360dp | 0.96 | 略缩,可接受 |
| 390dp | 1.04 | 微调,合理 |
| 430dp | 1.15 | 建议 clamp(1.0, 1.2) 上限 |
| 768dp | 2.05 | 失控,必须换断点布局 |
| 1024dp | 2.73 | 失控,必须换断点布局 |
dart
final s = (MediaQuery.sizeOf(context).width / 375).clamp(1.0, 1.2);
// 所有尺寸乘 s;超出 1.2 的平板/横屏必须走断点布局,而不是继续放大
// 正文文字不要跟 s 绑死——字号跟 sp / TextScaler(见 03)读法:手机宽度附近 s≈1.0~1.15 是小幅补偿;768/1024dp 时 s 冲到 2 倍以上,应优先变化栏位和导航,不是整页倍率。
一句话收口:375 法建在 dp 之上,只解决 compact 小屏的比例锚定;跨尺寸(平板 / 折叠 / 横屏)必须叠加断点响应式——否则 768 宽平板上整稿被放大到系数 ≈2.05,按钮吹成两倍大、留白巨大。
真正的输入是当前窗口,不是设备型号
布局判断的输入是当前可用窗口,不是“这是一台平板”:
| 状态 | 同一台平板的可能窗口宽 | 应采用的布局 |
|---|---|---|
| 全屏竖屏 | 768~840dp | medium,两栏 |
| 全屏横屏 | 1024dp+ | expanded,多栏 / Rail |
| 分屏 50% | 约 500dp | compact,退回单栏 |
| 折叠半折 | 可能 < 600dp | compact |
| 折叠展开 | 可能 > 840dp | expanded |
静态 vs 动态:
values-sw600dp/layout-sw600dp:资源解析阶段的静态分桶,启动时按最小宽度选 XML/dimen,适合基础平板布局差异。WindowMetrics/WindowSizeClass/LayoutBuilder:运行时读当前窗口,分屏、自由窗口、折叠态变化时必须用它重新分类。
配置变化(旋转、分屏、折叠)后应重新计算窗口宽度并刷新布局结构,不要缓存“设备是平板所以永远双栏”。
统一断点模型:compact / medium / expanded
| 宽度档位 | 宽度范围 | 常见设备 / 窗口状态 | 布局结构 | 导航形态 | 375 基准是否还能主导 |
|---|---|---|---|---|---|
| compact | < 600dp | 手机竖屏;平板分屏后被压窄的窗口 | 单列主内容;列表和详情通常分步进入 | BottomBar / 顶栏返回 | 可以,只做小范围补偿,建议 clamp(1.0, 1.2) |
| medium | 600~840dp | 平板竖屏;大手机横屏;部分折叠屏展开态 | 两栏最常见:列表 + 详情、筛选 + 结果 | BottomBar → NavigationRail | 不应继续主导,应该切到两栏结构 |
| expanded | > 840dp | 平板横屏;桌面窗口;更宽的展开态 | 多栏 / 三列 / 固定侧边栏 / 更高信息密度 | NavigationRail / PermanentDrawer | 不应该,继续放大会把手机 UI 拉成“大号手机” |
读表时先看“当前窗口宽度”落在哪一档,再看这一档要求的是“结构切换”还是“尺寸微调”。最关键的记忆点:
600 / 840dp是结构边界;375只在 compact 里做小屏补偿。
从缩放到结构:大屏增加信息密度
红色路径:增长按钮、标题、卡片的物理尺寸,信息密度几乎不变 → “看起来更大”。绿色路径:增长栏位、导航形态和同屏承载量 → “同一屏能处理更多任务”。
断点到了 medium / expanded,最该做的是主从(master-detail)结构同屏摊开:
- 窄屏(< 600dp) :单列列表,点按 push 进详情页。
- 宽屏(≥ 600dp) :左导航 / 菜单 + 列表 + 详情同屏并排。
Flutter 可直接用
adaptive_scaffold/TwoPane,或Row(children: [if (w >= 600) NavRail(), Expanded(child: ListPane()), if (w >= 600) Expanded(child: DetailPane())])。
导航形态随宽度演进:BottomBar → NavigationRail → PermanentDrawer。
Android View / XML 实现
对应 Lab:screen-adaptation (View/XML sw600dp) ——
ViewXmlAdaptiveDemoActivity演示values-sw600dp/layout-sw600dp静态分桶 +WindowMetricsCalculator运行时窗口判断协同。
- dp 自动适配:
px = dp × density,小屏缩放由系统完成。 - 资源限定符:
values-sw600dp/(平板布局)、values-land/(横屏)、layout-sw600dp/等——系统按限定符自动选资源。
xml
<!-- res/values/dimens.xml(手机) -->
<dimen name="item_padding">8dp</dimen>
<!-- res/values-sw600dp/dimens.xml(平板,宽度 ≥ 600dp 时覆盖) -->
<dimen name="item_padding">24dp</dimen>- 运行时窗口宽度分类:先用
WindowMetricsCalculator拿当前窗口宽度,再按 600 / 840 自己分类,决定是单列、双栏还是多栏。
kotlin
val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(this)
val widthDp = metrics.bounds.width() / resources.displayMetrics.density
val windowSizeClass = when {
widthDp < 600f -> "compact"
widthDp < 840f -> "medium"
else -> "expanded"
}- ConstraintLayout / RecyclerView 弹性:
Guideline百分比 /Chain分配权重 /GridLayoutManager动态列数,小屏不放大、大屏不空旷。
Jetpack Compose 实现
对应 Lab:screen-adaptation (Compose WindowSizeClass) ——
WindowSizeClassDemoActivity演示calculateWindowSizeClass+ListDetailPaneScaffold+NavigationSuiteScaffold,并模拟 375/430/600/768/1024dp 对照纯缩放。
kotlin
// 1) 断点:material3-window-size-class
val wsc = calculateWindowSizeClass(activity)
val layout = when (wsc.widthSizeClass) {
WindowWidthSizeClass.Compact -> CompactLayout()
WindowWidthSizeClass.Medium -> MediumTwoPane()
WindowWidthSizeClass.Expanded -> ExpandedMultiPane()
}
// 2) 官方两栏:material3-adaptive 1.4+
ListDetailPaneScaffold(
listPane = { ListPane() },
detailPane = { DetailPane() },
) // 手机=单栏(列表/详情切换),平板=并排两栏
// 3) 网格列数自适应
LazyVerticalGrid(GridCells.Adaptive(minSize = 120.dp)) { items(...) }- 约束驱动:
BoxWithConstraints适合局部小分支,WindowSizeClass适合页面级结构切换。 - 导航形态切换:大屏下
BottomBar → NavigationRail → PermanentDrawer。 - 系统边界:Compose 页面上线时还要配合
WindowInsets;详见 03。
Flutter 实现
对应 Lab:adaptive-layout-demo
dart
LayoutBuilder(builder: (context, constraints) {
final w = constraints.maxWidth;
if (w < 600) return const CompactList();
if (w < 840) return const TwoPaneLayout();
return const ExpandedMultiPane();
});当前仓库里的 Flutter demo 通过页面内预设宽度按钮模拟 375 / 430 / 600 / 768 / 1024dp:
- 375 基准缩放在手机宽度下是可接受的轻微补偿。
- 到了 768 / 1024dp,应该切两栏 / 三列,而不是把整套手机 UI 一起放大。
dart
final scale = (_simulatedWidth / 375).clamp(1.0, 1.2);
switch (breakpoint) {
case _Breakpoint.compact:
return _PhoneStyleDashboard(...);
case _Breakpoint.medium:
return _TwoPaneDashboard(title: 'Medium 600~840dp', columns: 2);
case _Breakpoint.expanded:
return _TwoPaneDashboard(title: 'Expanded > 840dp', columns: 3);
}- 可能执行顺序
- 选择一个模拟宽度。
- 先算断点落在哪一档。
- compact 走单列,medium 走两栏,expanded 走三列。
- 若打开“只看 375 基准缩放结果”,则故意不换布局,只放大手机面板。
- 可能输出text
模拟宽度:768dp 断点:medium 375 缩放系数:1.20x 模式:断点换布局 - 预期现象:768/1024dp 下,断点模式增长的是栏位和信息密度;纯缩放模式增长的是整体 UI 比例。
- 观察重点:这正是“响应式适配”与“等比放大”的本质差异。
iOS 对照:Auto Layout、Size Class 与实际容器宽度
对 Android / Flutter 工程师的映射:
| Android / Flutter 概念 | iOS 对照 |
|---|---|
WindowMetrics / LayoutBuilder 当前宽 | 容器实际 bounds,不是 UIDevice 型号 |
WindowSizeClass compact/medium/expanded | UITraitCollection horizontalSizeClass(.compact / .regular) |
values-sw600dp 静态 XML | Size Class + Storyboard / XIB 变体 |
| 断点换双栏 | Auto Layout 约束 + UISplitViewController / 自定义双栏 |
| 375 screenutil 小屏补偿 | 一般不整页缩放;用约束与 Size Class |
关键边界:iPad Split View、Slide Over、Stage Manager 下,horizontalSizeClass 可能仍是 .regular,但容器宽度已被压到 phone 级——必须读实际容器宽度决定单栏还是双栏,不能只看 Size Class 标签。Dynamic Type、Safe Area 见 03。
场景推演
| 窗口宽 | 档位 | 结构 | 导航 | 不要做 |
|---|---|---|---|---|
| 375dp 手机竖屏 | compact | 单列 + BottomBar | 375 微调 s≈1.0~1.15 可接受 | 不要为 375 单独写死 px |
| 430dp 大手机 | compact | 单列 | clamp(1.0, 1.2) | 不要开始双栏 |
| 768dp 平板竖屏 | medium | 列表 + 详情两栏 | NavigationRail | 不要整页 ×2 放大 |
| 1024dp 横屏 | expanded | 三栏 / 固定侧栏 | PermanentDrawer / Rail | 不要保留手机 BottomBar 占满宽 |
| 500dp 平板分屏 | compact | 退回单栏 | BottomBar | 不要因“设备是平板”强留双栏 |
社区成熟方案对照
| 端 | 方案 | 原理 | 典型用法 | 适用边界 |
|---|---|---|---|---|
| Android View | values-sw600dp / layout-sw600dp / dimen 分桶 | 资源解析阶段按最小宽度切不同布局和尺寸资源 | 手机/平板两套 XML;平板更大边距、双栏容器 | 适合静态资源切换;分屏实时变化仍要配合运行时窗口判断 |
| Android / Compose | WindowMetrics / WindowSizeClass | 运行时读取当前窗口宽度并映射到 600/840dp 档位 | 单栏→双栏→多栏;BottomBar→Rail | 适合结构切换;不适合拿来做整页等比放大 |
| Compose | material3-adaptive / pane scaffold | 官方把主从式、多 pane 页面抽成骨架 | ListDetailPaneScaffold、导航/详情并排 | 很适合列表详情页;复杂营销页、瀑布流仍需自定义布局 |
| Flutter | LayoutBuilder / MediaQuery | 在 widget 树内按当前约束切布局 | if (width < 600)、动态列数、Rail/Drawer 切换 | 适合跨尺寸结构变化;不要只按机型或平台名分类 |
| Flutter | responsive_framework / responsive_builder | 把断点、设备分组、包装器进一步封装 | 项目统一断点、统一 builder 入口 | 适合中大型项目收口;简单页面手写即可 |
| Flutter | flutter_screenutil | 用设计稿宽度推导尺寸缩放系数 | 小屏手机下微调圆角、边距、图标尺寸 | 只适合 compact 小屏补偿;不该统治平板布局,更不该缩放正文文字 |
Android / Compose / Flutter / Web 对照
| 维度 | Android | Compose | Flutter | Web |
|---|---|---|---|---|
| 断点来源 | sw600dp 限定符 / 运行时宽度判断 | calculateWindowSizeClass | LayoutBuilder / MediaQuery | CSS media queries |
| 两栏布局 | Fragment + 资源切换 | ListDetailPaneScaffold | 自定义 TwoPaneLayout / adaptive_scaffold | grid + media query |
| 大屏治理 | values-wXXXdp / 动态列数 | GridCells.Adaptive 列数自适应 | SliverGridDelegateWithMaxCrossAxisExtent | auto-fill minmax |
| 导航切换 | BottomNav → Rail / Drawer | BottomBar → Rail / Drawer | BottomNav → Rail / Drawer | 顶栏 / 侧栏切换 |
常见场景
- 手机竖屏(compact) :单列表 + 底部导航;375 基准
s≈1.0~1.15微调可用。 - 平板竖屏(medium) :列表 + 详情两栏,不放大。
- 平板横屏 / 折叠展开(expanded) :固定侧边栏 + 主内容多栏;列表网格列数随宽度增加。
- 分屏 / 多窗口:窗口宽度可能随时掉回
compact,所以不要把“设备是平板”误当成“当前一定是两栏”。
常见误配、事故后果与排障
- 纯比例放大做适配(375 法全端套用):平板 UI 放大 2~3 倍,图标巨大、布局稀疏。修法:
clamp上限 + 断点换布局。 GridView固定列数:平板中间大片留白。修法:GridCells.Adaptive(minSize)/MaxCrossAxisExtent。- 只用竖屏设计:横屏/平板未测,线上布局溢出。修法:
values-land/OrientationBuilder/ 真机矩阵测试。 - 把设备类型当断点:平板分屏后实际宽度可能只剩 500dp。修法:以当前窗口宽度为准,不要只看机型。
- 导航形态不切:大屏还保留手机底部导航,内容区浪费。修法:大屏切
NavigationRail/ drawer。
排障路径:先拿“屏幕逻辑宽(dp)”→ 判断落在哪个断点 → 再决定是布局切换还是尺寸缩放 → 看导航形态和列数是否跟着变 → 最后用模拟器/真机宽度矩阵验证。
与相近概念对比
| 方案 | 原理 | 适用 |
|---|---|---|
| 375 基准缩放 | 所有尺寸 × (逻辑宽/375),建议 clamp | 手机竖屏、UI 组件轻微补偿 |
| 断点响应式 | 按宽度切换布局结构 | 手机 / 平板 / 横屏全覆盖 |
| 自适应布局(Compose adaptive / adaptive_scaffold) | 官方可组合两栏/多栏骨架 | 列表+详情类页面 |
| 约束驱动局部适配 | 子树按当前约束做小范围变化 | 卡片、模块块级自适应 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| adaptive-layout-demo | 模拟 375/430/600/768/1024dp 宽度,对比 375 缩放与断点换布局(与文首「代码索引」一致) | adaptive_layout_demo_page.dart |
| screen-adaptation (Compose) | Android Compose:WindowSizeClass + ListDetailPaneScaffold + 导航形态切换,同设备模拟多档宽度对照纯缩放 | WindowSizeClassDemoActivity.kt |
| screen-adaptation (View/XML) | Android View/XML:values-sw600dp / layout-sw600dp 资源分桶 + WindowMetricsCalculator 运行时窗口判断协同 | ViewXmlAdaptiveDemoActivity.kt |
复习检查题
三端统一断点是多少?各自对应什么场景?
答:compact < 600dp(手机竖屏,单列);medium 600~840(平板竖屏,两栏);expanded > 840(横屏 / 平板,多栏)。
为什么 375 基准不能用于平板?
答:
s = 逻辑宽 / 375,iPad 768~1024dp 时s≈2~2.7,整个 UI 放大两倍多导致图标巨大、布局稀疏;正确做法是断点换布局。Flutter 里
flutter_screenutil与LayoutBuilder断点的分工是什么?答:前者解决“手机竖屏下按设计稿等比缩放”(375 基准 + clamp 上限);后者解决“跨尺寸换布局”(600/840 断点)。小屏用缩放、大屏换布局,两者配合。
为什么
values-sw600dp还不够,还要看WindowMetrics/WindowSizeClass?答:因为资源限定符更像“启动和资源选择时的静态分桶”,而分屏、自由窗口、折叠态变化是运行时事件。真实页面结构切换必须看当前窗口宽度。
为什么平板分屏后还可能退回单栏?
答:因为断点看的是当前窗口宽度,不是设备名。平板一旦进入分屏,窗口可能掉回 500dp 左右,这时仍应回到 compact 单栏布局。
平板全屏时你是双栏,用户一切分屏窗口变窄,你该怎么判断?
答:重新读
WindowMetrics/LayoutBuilder的当前宽度;若掉到 < 600dp,立即退回 compact 单栏,不要缓存“这是平板所以永远双栏”。铰链与安全区变化见 03。
速记
- 断点:600 / 840dp,三端统一;compact / medium / expanded。
- 375 只配小屏:
clamp(1.0, 1.2),大屏换布局不放大;正文不跟 s 绑死。 - 输入:当前窗口宽,不是设备型号。
- Compose:
calculateWindowSizeClass+ListDetailPaneScaffold+GridCells.Adaptive。 - Flutter:
LayoutBuilder断点 +SliverGridDelegateWithMaxCrossAxisExtent。 - Android View:
values-sw600dp+ 运行时窗口宽度分类。 - 大屏核心:增长信息密度、导航形态和栏位结构,不是增长整个 UI 比例。