Appearance
字体、系统边界与动态窗口:屏幕适配的上线验收层
回到总览:屏幕适配:从清晰显示到动态窗口可用相关模块:01-dp/dpi 与图片资源放置(资源与显示尺寸)· 02-断点响应式布局(布局结构)
一句话定义
真实工程里的“屏幕适配”不止是宽高和断点,还包括:字体放大后是否仍可读、内容会不会被状态栏/刘海/手势区/键盘挡住、折叠屏/分屏/多窗口变化时布局会不会崩。
开篇案例:宽度正确,为什么标题、输入框和 CTA 仍会失败
你在 02 里可能已经验证了 375 / 430 / 600dp 断点切换正常——列表变双栏、平板上了 NavigationRail。QA 在默认字体、全屏、无键盘的 390dp 手机上点过一遍,宽度矩阵过关。
但真实用户路径往往是多重系统边界叠加,而不是单一维度出问题:
| 叠加因素 | 典型触发 | 单独看「宽度」时容易漏掉的现象 |
|---|---|---|
| 大字体 1.6x~2.0x | 系统无障碍设置 | 标题行高翻倍,固定 48dp 容器裁切;按钮文字溢出点击区 |
| 仍是 compact 宽度 | 小屏手机或分屏后窗口 < 600dp | 横向 Row 里图标、尾按钮占满,标题 maxLines=1 被挤成省略号 |
| 键盘弹起 | 登录、表单、搜索框获焦 | viewInsets / IME 吃掉底部 200~300dp,未抬升的 Scaffold 把输入框和 CTA 顶进不可达区域 |
| 底部手势区 | 全面屏 + 沉浸式 / edge-to-edge | CTA 视觉上在屏幕内,但未吃 systemBars.bottom 时实际点在系统手势捕获区 |
四者同时出现时,页面「宽度断点正确」但用户仍会说:标题看不见、输入框点不到、底部按钮没了。这些都不属于 01 资源桶或 02 纯宽度断点能单独解释的——它们属于本文的系统边界验收层,必须用组合测试暴露,而不能只在默认字体、无键盘的全屏态里点一遍过关。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| Flutter 断点适配对照 | adaptive-layout-demo | adaptive_layout_demo_page.dart |
| Flutter 字体 / SafeArea / 键盘专项 | font-scale-safe-area-demo | font_scale_safe_area_demo_page.dart |
为什么需要
- 为什么“宽度适配没问题”但线上仍然有人说页面错位?
- 一句话答:因为真实用户遇到的常常不是“375 和 390 的差异”,而是大字体、底部手势区、键盘弹起、刘海遮挡、分屏压缩宽度这些系统边界问题。
- 为什么文字大小不能直接跟着布局一起缩放?
- 一句话答:因为文字优先服务“可读性”和“无障碍设置”,不是服务视觉比例;系统字体放大后,页面仍然要能读、能滚、能点。
- 为什么平板 / 折叠屏适配不能只看“设备型号”?
- 一句话答:因为分屏、多窗口、折叠展开态会让同一台设备在不同瞬间拥有完全不同的可用宽度和安全区,应看当前窗口,不看设备名。
问题地图:四类线上边界
线上适配 bug 很少只来自「宽度算错」,更多分散在四条支线——字体缩放、安全区 / Insets、键盘 IME、折叠 / 分屏 / 多窗口。前四块是 bug 来源,最下方的验证矩阵是发现手段;不要把验证理解成收尾步骤,它其实是把这些系统边界显性化的唯一稳定办法。
这张图表达的不是“知识点罗列”,而是“线上适配 bug 的来源分层”。从中心往外读:你以为自己在做宽度适配,但真实问题会分散到字体、安全区、键盘、窗口变化四条支线;再顺着虚线落到最下方,你会发现这些问题最终都要回到验证矩阵里统一暴露。读图时最该关注的是因果关系:前四块是 bug 来源,最下方的矩阵是发现手段,所以不要把验证理解成收尾步骤,它其实是把这些系统边界显性化的唯一稳定办法。
底层机制
字体适配四层:先分清你在适配什么
字体适配最容易犯的错,是把它当成“设计稿缩放”的一部分统一处理。真实工程里应该拆成四层:
- 字号语义:正文、标题、辅助文案分别是多少
sp/TextStyle。 - 容器弹性:字体变大后,容器能不能增高、换行、滚动。
- 横向分配:图标、价格、按钮、状态标签同一行时,文本有没有拿到弹性空间。
- 系统跟随:是否尊重系统字体缩放和无障碍设置。
很多线上事故不是字号本身错,而是第二、第三层没处理:字是 sp,但外层把高度和横向宽度写死了,于是系统大字体一开就炸。
这张图把“跟随系统好还是不好”摆成两端看。左端
sp = dp × fontScale:系统字调大你的字跟随,老年 / 低视力用户可用、符合无障碍规范,代价是要用弹性布局兜底(minHeight / weight / 可滚动)。右端把字号写死成dp/px:设计还原度高、排版不被打乱,代价是牺牲无障碍——系统字最大时你的字显特别小。业界共识是默认用 sp 跟随 + 弹性兜底,而不是全局禁用字体缩放。
字体适配原则与可执行规则
- 为什么字号单位必须用
sp/TextScaler语义,而不是dp?- 一句话答:因为字体不仅受屏幕密度影响,还要跟随用户的可访问性字体设置;
dp只表达物理尺寸,不表达文字可读性语义。
- 一句话答:因为字体不仅受屏幕密度影响,还要跟随用户的可访问性字体设置;
- 为什么不要写死文本容器高度?
- 一句话答:因为字体放大后,真实问题首先体现为行高增加、换行增多;高度写死会直接造成裁切、重叠和点击区错位。
- 为什么横向布局里文本要拿弹性空间?
- 一句话答:因为图标、价格、操作按钮通常是定宽项,真正需要吸收宽度波动和字体放大的只有文本;不给弹性空间,最先牺牲的就是标题和关键信息。
- 为什么不能全局禁用字体缩放?
- 一句话答:因为这会直接对抗系统无障碍设置,把“设计看起来整齐”建立在牺牲可读性之上;这不是适配,是绕过用户设置。
把它翻成可执行规则就是:
| 原则 | 推荐做法 | 不推荐做法 | 事故后果 |
|---|---|---|---|
| 字号跟系统 | Android/Compose 用 sp;Flutter 用系统 TextScaler | dp 写字号;统一乘 375 缩放系数 | 字体不跟系统设置走,可读性失真 |
| 高度留弹性 | wrap_content / intrinsic / minHeight / 可滚动 | 固定 48dp、56dp 容器硬卡文本 | 大字体下裁切、重叠、点不到 |
| 横向让文本吃剩余空间 | layout_weight / Modifier.weight(1f) / Expanded | 文本和图标都 wrap_content,末尾按钮挤压文本 | 单行信息溢出、关键文案省略 |
| 全局尊重无障碍 | 让系统字体放大自然传导 | 全局把 fontScale 钳死成 1.0 | 无障碍失效、审核和体验风险 |
Android View / Compose:正反例
Android View
反例的典型问题不是“不会写 sp”,而是写了 sp 但外层布局仍然是静态盒子。
xml
<!-- 反例:字号虽是 sp,但标题行高度和宽度分配都写死了 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="48dp"
android:orientation="horizontal">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:text="超级会员自动续费说明"
android:textSize="16sp"
android:maxLines="1" />
<Button
android:layout_width="72dp"
android:layout_height="40dp"
android:text="去处理" />
</LinearLayout>这个布局在默认字体下可能还行,但系统字体放大后会同时出现三件事:
- 行高不够,文本被截;
- 文本没拿到剩余宽度,按钮把标题挤没;
- 父容器固定 48dp,高度无法随两行标题增长。
xml
<!-- 正例:字号跟系统,文本拿弹性空间,容器只设最小高度 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="56dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:text="超级会员自动续费说明"
android:textSize="16sp"
android:maxLines="2"
android:ellipsize="end" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="40dp"
android:text="去处理" />
</LinearLayout>这个写法里真正重要的不是某个 API,而是三条约束:
textSize用sp;layout_width=0dp + layout_weight=1让文本吃剩余空间;- 父容器
wrap_content + minHeight,允许高度随文本增长。
Compose
Compose 的误区更常见在“把手机视觉稿定死成一排”。
kotlin
// 反例:行高写死,文本没权重,字体一放大就顶掉尾部按钮
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Info, contentDescription = null)
Text(
text = "超级会员自动续费说明",
fontSize = 16.sp,
maxLines = 1,
)
TextButton(onClick = {}) { Text("去处理") }
}kotlin
// 正例:文本吃剩余空间,容器最小高度而非固定高度
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Info, contentDescription = null)
Text(
text = "超级会员自动续费说明",
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
TextButton(onClick = {}) {
Text("去处理")
}
}如果是整页表单,还要再补一层:不要只让单个组件可变高,要让整页可滚动,否则大字体 + 小屏 + 键盘三者叠加后,底部 CTA 一定出问题。
Flutter:正反例
对应 Lab:font-scale-safe-area-demo · font_scale_safe_area_demo_page.dart
Flutter 里真正该跟随的是 MediaQuery.textScalerOf(context);真正该避免的是“把设计稿缩放系数直接灌给文本”。
dart
// 反例:把文本强行固定到 1.0,或跟 375 设计稿缩放绑死
final designScale = (MediaQuery.sizeOf(context).width / 375).clamp(1.0, 1.2);
Row(
children: [
const Icon(Icons.info, size: 20),
Text(
'超级会员自动续费说明',
textScaler: const TextScaler.linear(1.0),
style: TextStyle(fontSize: 16 * designScale),
maxLines: 1,
),
TextButton(onPressed: () {}, child: const Text('去处理')),
],
);这个写法的问题有两个:
- 它把“布局微调”和“无障碍字体”绑成同一个系数;
- 它显式绕过了系统字体缩放,结果是设计对了、用户看不清。
dart
// 正例:文本跟系统,横向给 Expanded,容器只设最小高度
final textScaler = MediaQuery.textScalerOf(context);
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 56),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.info, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'超级会员自动续费说明',
textScaler: textScaler,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
TextButton(
onPressed: () {},
child: const Text('去处理'),
),
],
),
),
)如果是卡片、列表项这类横向排布,Expanded/Flexible 基本是文本适配的第一原则;如果是整页详情或表单,SingleChildScrollView / CustomScrollView 才能兜住大字体和键盘叠加态。
iOS 对照:Dynamic Type 与 Auto Layout 弹性
Android / Flutter 工程师读 iOS 时,可把概念一一对照——iOS 没有 sp / TextScaler 字面 API,但语义相同:正文跟系统字号、布局给弹性、横向让文本吃剩余空间。
| Android / Flutter 概念 | iOS 对照 | 要点 |
|---|---|---|
sp / TextScaler | Dynamic Type:UIFont.preferredFont(forTextStyle:)、UIFontMetrics | 用 Text Style 语义字号,不要硬编码 CGFloat 点值当正文 |
| 系统字号档位变化 | adjustsFontForContentSizeCategory = true(UILabel 等) | 控件自动响应用户在设置里调大的字体 |
layout_weight / Expanded | Content Hugging Priority / Compression Resistance Priority | 横向一排时,让标签「不愿被压缩」、按钮「不愿被拉伸」,文本拿弹性空间 |
wrap_content / minHeight | Auto Layout:避免固定 height 约束卡死 UILabel | 用 >= 最小高度 + 顶部/底部约束,允许 label 随 Dynamic Type 增高 |
WindowInsets / SafeArea | safeAreaLayoutGuide、additionalSafeAreaInsets | 刘海、动态岛、home indicator 由系统 guide 表达,别手写固定 padding |
swift
// 正例:跟系统 Text Style,横向让标题抗压缩、按钮 wrap
let titleLabel = UILabel()
titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 2
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let actionButton = UIButton(type: .system)
actionButton.setContentHuggingPriority(.required, for: .horizontal)给 Android 同学的速记:iOS 的 Dynamic Type ≈ Android sp + 系统 fontScale;Hugging / Compression Resistance ≈ layout_weight 的「谁让谁」;safeAreaLayoutGuide ≈ WindowInsetsCompat / Flutter SafeArea。iOS 同样不要给正文 label 写死高度——大字体一开,和 Android 固定 48dp 一样会裁切。
底层机制:全局禁用、安全区、键盘与动态窗口
为什么不能全局禁用字体缩放
有些项目会尝试在应用入口统一覆写 fontScale = 1f,或者在 Flutter 里全局包一层 MediaQuery.copyWith(textScaler: TextScaler.noScaling)。这类方案看上去能“立刻消灭炸行”,但本质是在回避适配。
它的问题不是“是否优雅”,而是方向就错了:
- 它掩盖了容器高度和横向布局本来就不弹性的事实;
- 它让无障碍用户失去系统放大字体能力;
- 它会把真实 bug 从开发期藏起来,变成线上投诉。
工程上更合理的边界是:
- 默认跟随系统字体缩放;
- 极少数品牌数字、海报型标题、图文一体素材才局部限制缩放;
- 即使局部限制,也要明确说明这是视觉资产,不是正文信息。
安全区与 Insets:内容会不会被系统栏挡住
Android View
kotlin
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updatePadding(top = systemBars.top, bottom = systemBars.bottom)
insets
}Compose
kotlin
Scaffold(
contentWindowInsets = WindowInsets.systemBars,
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) { /* ... */ }
}Flutter
dart
SafeArea(
child: ListView(
children: const [/* ... */],
),
)要点是:
- 顶部看状态栏 / 刘海 / 动态岛
- 底部看手势区 / home indicator / 导航条
- 输入框页还要额外看键盘遮挡
键盘弹起:很多“底部按钮丢了”其实是这个问题
Flutter 示例
dart
Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: const TextField(),
)Compose / Android 思路
imePadding()/WindowInsets.ime- 或者让输入区处于可滚动容器中
重点不是 API 名字,而是判断:
text
输入框是否被键盘顶住?
底部提交按钮是否还能点到?
滚动容器会不会自动抬升?折叠屏、分屏与多窗口:看当前窗口而非设备型号
折叠屏和分屏场景里,真正影响页面的是:
- 当前窗口宽度是否跨过 600 / 840dp
- 铰链 / 分割区域是否把中间内容切断
- 展开态是否该从 BottomBar 切到 Rail / Drawer
所以适配判断更像这样:
text
设备只是背景信息;真正参与布局决策的是:
- 当前窗口宽度
- 当前安全区
- 当前可滚动区域
- 当前导航模式业界通用方案:原理、用法、边界
这一节不追求百科全书,而是给出工程里最常遇到的适配手段、它解决的问题,以及最容易被滥用的边界。
| 方案 | 原理 | 典型用法 | 适用边界 / 不该滥用的地方 |
|---|---|---|---|
Android View 资源限定符:layout-sw600dp / values-sw600dp / dimen 分桶 | 系统在资源解析阶段按最小可用宽度选择不同布局和尺寸资源 | 平板两套 XML、手机/平板不同 dimen、横屏额外布局 | 适合静态资源和基础布局切换;不适合处理分屏后实时回退,因为当前窗口变化还需要运行时判断 |
Android 运行时窗口宽度:WindowMetrics / WindowSizeClass | 页面启动和窗口变化时读取当前窗口宽度,按 600/840dp 分类 | 列表→列表详情双栏、BottomBar→Rail、动态列数 | 适合结构切换;不该拿来做“所有尺寸一起等比放大” |
| Compose adaptive / pane scaffold | 官方把常见列表-详情、自适应导航、pane 布局抽成可组合骨架 | ListDetailPaneScaffold、多 pane 内容骨架 | 适合主从结构页面;不代表所有复杂业务都能一键套,复杂交互仍需自定义 pane 策略 |
Flutter LayoutBuilder / MediaQuery 断点布局 | 直接读当前约束或窗口尺寸,在 widget 树内切换布局 | if (width < 600) ... else ...、动态网格列数、Rail/Drawer 切换 | 适合跨窗口结构切换;不该只看 deviceType 或只按机型判断 |
Flutter 375 设计稿缩放:flutter_screenutil 一类 | 通过 当前逻辑宽 / 设计稿宽 做尺寸缩放 | 小屏手机上微调边距、圆角、卡片尺寸 | 只适合 compact 小屏补偿;不该统治平板、桌面、分屏,更不该顺手缩放正文文字 |
安全区 / Insets / SafeArea | 把系统栏、刘海、手势区、键盘占用空间显式让出来 | 顶部标题避状态栏、底部 CTA 避 home indicator、输入框避键盘 | 适合系统边界治理;不解决大屏断点和字体弹性问题 |
| 跟随系统字体缩放 | 把字体视作可访问性内容,随系统字体设置放大 | Android sp、Compose sp、Flutter TextScaler | 适合所有正文和交互信息;不该为了“整齐”全局禁用,极少数视觉素材才局部限制 |
Android / Compose / Flutter 对照
| 维度 | Android View | Compose | Flutter |
|---|---|---|---|
| 字体缩放 | sp + wrap_content/minHeight + layout_weight | sp + Modifier.weight + 可滚动重排 | TextScaler + Expanded/Flexible + 可滚动容器 |
| 系统栏安全区 | WindowInsetsCompat | WindowInsets / Scaffold padding | SafeArea / MediaQuery.viewPadding |
| 键盘 | insets / 可滚动容器 | imePadding() / WindowInsets.ime | MediaQuery.viewInsets |
| 大屏导航切换 | BottomNav → Rail / Drawer | Scaffold / adaptive pane | BottomNav → NavigationRail / Drawer |
| 分屏 / 折叠屏 | 看当前窗口而不是设备型号 | 看当前窗口 size class | 看当前 constraints / MediaQuery |
常见场景
1. 登录页底部按钮被键盘挡住
- Android / Compose:给底部操作区补
imeinsets 或让页面可滚动。 - Flutter:
viewInsets.bottom或Scaffold的键盘处理。
2. 标题在系统大字体下炸成两行
- 不要写死按钮高度和标题容器高度。
- 给标题足够宽度,或允许换行。
- 次要文案可以降优先级,但不要截断关键信息。
- 横向排布时,优先压缩装饰元素和留白,不要先牺牲主文本。
3. 平板横屏仍然使用手机底部导航
- 视觉上会显得底部很空,主内容又不够宽。
- 更合理的做法是切
NavigationRail或 drawer,把横向空间还给内容区。
4. 折叠屏展开态中间内容被铰链“劈开”
- 核心不是“机型适配表”,而是不要把单个关键按钮、输入框、主标题跨铰链放置。
- 两栏布局一般比单大图布局更稳。
最小验证矩阵与扩展矩阵
真正做适配时,至少看这几个维度:
| 维度 | 最小矩阵 | 你要观察什么 |
|---|---|---|
| 宽度 | 375 / 430 / 600 / 768 / 1024dp | 是否正确切断点、列数、导航形态 |
| 方向 | 竖屏 / 横屏 | 横屏是否溢出,是否仍只有手机布局 |
| 字体 | 默认 / 放大 1.3x / 1.6x / 2.0x | 是否炸行、是否出现裁切、按钮还能不能点 |
| 系统边界 | 有刘海 / 手势导航 / 底部 indicator | 头部和底部是否被挡住 |
| 输入态 | 键盘关闭 / 键盘弹起 | 输入框和提交按钮是否被遮挡 |
| 多窗口 | 全屏 / 分屏 | 平板是否会从两栏退回单栏 |
| 长文本 | 中文短标题 / 英文长单词 / 多语言文案 | 横向布局是否被长标题顶爆 |
字体适配专项检查清单
- 所有正文、标题、按钮文案是否都用
sp/TextStyle/TextScaler语义,而不是dp或设计稿缩放值直算? - 文本容器是否避免固定高度,改成
wrap_content/minHeight/defaultMinSize/ConstrainedBox? - 横向布局里标题、价格、状态文案是否通过
layout_weight/Modifier.weight/Expanded拿到剩余空间? - 大字体 1.3x、1.6x、2.0x 下,是否仍可读、可点、可滚?
- 输入页在大字体 + 键盘同时出现时,底部 CTA 是否仍可达?
- 是否有人为把全局
fontScale钳死成 1.0?如果有,先当成风险排查。
组合测试优先级
单维度矩阵过关后,优先补这三组组合态——线上事故最常出现在叠加场景,而不是默认字体全屏态:
| 组合 | 怎么测 | 重点观察 |
|---|---|---|
| 大字体 + 键盘 | 系统字体 1.6x~2.0x,打开登录/表单页并聚焦输入框 | 标题是否裁切;输入框是否被 IME 顶住;底部 CTA 是否仍可达、可滚 |
| 小窗 + 长文案 | 分屏或窄窗口(约 360~480dp 逻辑宽)+ 英文长单词 / 多语言标题 | 横向 Row 是否挤爆;关键信息是否被省略号吞掉;尾按钮是否仍可见 |
| 折叠展开 + 导航切换 | 折叠屏展开/合上,或窗口宽度跨过 600dp | 是否应从 BottomBar 切到 Rail;铰链区是否切断主 CTA;窗口变化后是否错误保持双栏 |
一套可执行的验收顺序
按下面顺序跑,比随机点页面更能稳定暴露边界 bug;失败时先归因到哪一层,避免在错误文档里改代码。
| 步骤 | 检查内容 | 观察重点 | 失败时回看 |
|---|---|---|---|
| 1 | 375 / 430 手机宽度 | compact 布局是否正常、无横向溢出 | 02 断点响应式 |
| 2 | 600 / 768 / 1024 宽度 | 断点是否切换、列数与导航形态是否合理 | 02 断点响应式 |
| 3 | 系统大字体 1.3x / 1.6x / 2.0x | 标题、按钮、表单是否炸行、裁切、点不到 | 03(本文) |
| 4 | 长文案与多语言 | 横向布局是否失衡、关键信息是否被挤没 | 03(本文) · 横向分配见 02 |
| 5 | 键盘弹起 | 输入框与底部 CTA 是否被遮挡、页面是否可滚 | 03(本文) |
| 6 | 横屏 / 分屏 / 折叠态 | 窗口变化后布局是否仍合理、导航是否应切换 | 02 断点响应式 · 安全区见 03(本文) |
图示版顺序(与上表一致):
text
1. 先看 375/430:手机是否正常
2. 再看 600/768/1024:布局是否切换
3. 再开系统大字体:标题、按钮、表单是否炸
4. 再测长文案和多语言:横向布局是否失衡
5. 再测键盘:输入框和底部 CTA 是否被遮住
6. 最后测横屏 / 分屏 / 折叠态:窗口变化后是否仍合理常见误配、事故后果与排障
- 文字也按 375 等比缩放:看起来“整齐”,但无障碍字体一开就炸。修法:文字跟系统字体缩放语义走,不要把所有字号都强绑到同一缩放系数。
- 文本容器高度写死:默认字体正常,一放大就裁切。修法:父容器
wrap_content/minHeight,给文本两行和滚动余地。 - 横向布局不给文本弹性空间:图标、标签、按钮都不动,标题先被挤没。修法:让文本拿
weight/Expanded,把剩余空间交给文本。 - 页面顶到底但没吃 insets:首屏标题被状态栏遮住,底部按钮被手势区挡。修法:三端分别吃系统栏安全区。
- 输入页只在无键盘状态下看过:上线后用户一输入,按钮就被顶没了。修法:键盘弹起时做
ime/viewInsets处理。 - 只看设备型号,不看当前窗口:平板分屏后仍保持双栏,结果内容挤爆。修法:一律按当前窗口宽度重新判断断点。
- 全局禁用字体缩放:短期“视觉整齐”,长期是无障碍和真实 bug 双重风险。修法:恢复跟随系统,只对极少数视觉素材做局部限制。
排障路径:先判断问题是资源、布局、字体还是系统栏/键盘 → 再看它发生在什么窗口宽度和系统状态下 → 最后补矩阵验证,别只修当前一台机器。
与相近概念对比
| 概念 | 它解决什么 | 它不解决什么 |
|---|---|---|
密度桶 / nodpi | 图片清晰度、解码尺寸、内存 | 字体放大、系统栏遮挡 |
| 断点响应式 | 栏位、导航形态、信息密度 | 键盘和安全区问题 |
| 安全区 / Insets | 状态栏、手势区、刘海遮挡 | 图片放错桶 |
| 字体缩放 | 可读性、无障碍 | 大屏两栏布局 |
| 验证矩阵 | 发现边界 bug | 不能替代正确设计 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| adaptive-layout-demo | 当前仓库里最直接的宽度矩阵演示,可先拿它理解“断点换布局”的主逻辑,再把字体 / insets / 键盘矩阵补到真实项目里 | adaptive_layout_demo_page.dart |
| font-scale-safe-area-demo | 当前仓库里最直接的字体 / SafeArea / 键盘判断实验:一页内对照大字体、Row 挤压、固定高度按钮、安全区和底部 CTA 是否被键盘遮挡 | font_scale_safe_area_demo_page.dart |
复习检查题
为什么“屏幕适配”不只是 375 设计稿缩放?
答:因为真实用户遇到的问题还包括大字体、刘海、手势区、键盘、分屏和折叠态。只解决宽度比例,并不等于页面最终可读、可点、可操作。
Android / Compose / Flutter 三端分别用什么思路处理安全区?
答:Android View 用
WindowInsetsCompat,Compose 用WindowInsets/Scaffoldpadding,Flutter 用SafeArea/MediaQuery.viewPadding;本质都是把系统栏占掉的区域让出来。为什么横向布局里文本通常要拿
weight/Expanded?答:因为图标、状态标签、操作按钮多半是定宽项,文本才是最能吸收宽度变化和字体放大的弹性项;不给它剩余空间,首先丢失的就是关键信息。
为什么不能简单把全局字体缩放关掉?
答:因为这等于直接对抗系统无障碍设置。它只是把真实布局问题藏起来,并没有解决容器固定高度、横向空间分配错误这些根因。
你会怎么做一个最小可用的适配验证矩阵?
答:至少覆盖 375/430/600/768/1024dp、横竖屏、默认/放大字体、长文案、键盘弹起、全屏/分屏。先用宽度矩阵找布局问题,再用字体和系统边界矩阵补齐真实风险。
为什么要做「组合边界」验收测试,而不只单维度测字体或键盘?
答:因为线上事故常常是多重边界叠加——大字体 + 键盘、小窗 + 长文案、折叠展开 + 导航切换——单维度矩阵过关不代表组合态可用;组合测试才能暴露真实用户路径里的裁切、不可达和导航错位。
速记
- 字体适配四件事:字号语义、容器弹性、横向分配、系统跟随。
- 文字看
sp/TextScaler,不要跟 375 缩放绑死。 - 横向一排时:文本优先拿
weight/Expanded。 - 系统栏 / 刘海 / 手势区看 insets / safe area。
- 键盘问题优先怀疑
ime/viewInsets。 - 折叠屏 / 分屏看当前窗口,不看设备名。
- 验证矩阵至少覆盖:宽度、方向、字体、长文案、系统栏、键盘、多窗口。