A
第 34 章JAVA45 分钟

屏幕适配

Android 屏幕适配:dp 与 sp 原理、smallestWidth 限定符、布局别名、小屏 / 大屏 / 折叠屏适配、WindowMetrics API、ConstraintLayout 响应式布局与 Jetpack WindowManager。

学习目标

  • 理解 dp / sp 的换算原理
  • 掌握 smallestWidth 限定符与布局别名
  • 了解小屏 / 大屏 / 折叠屏适配策略
  • 掌握 WindowMetrics API 与正确获取屏幕尺寸
  • 能用 ConstraintLayout 实现响应式布局

学习目标

  • 理解 dp / sp 的换算原理;
  • 掌握 smallestWidth 限定符与布局别名;
  • 了解小屏 / 大屏 / 折叠屏适配策略;
  • 掌握 WindowMetrics API 与正确获取屏幕尺寸;
  • 能用 ConstraintLayout 实现响应式布局。

屏幕适配

dp 与 sp 原理

Android 物理屏幕千差万别:手机 5–7 英寸、平板 8–13 英寸、折叠屏 6–8 英寸展开后更大。直接写 px 会在不同密度上看起来比例失衡。

关键概念

  • px(pixel):物理像素;
  • dpi(dots per inch):每英寸像素数;
  • density:density = dpi / 160,作为 dp 到 px 的换算系数;
  • dp / dip(density-independent pixel):在 160dpi 屏幕(即 density=1)下 1dp = 1px。换算:px = dp * density;
  • sp(scale-independent pixel):类似 dp,但额外受 用户字体缩放 影响,用于文字大小。

密度桶

Android 把屏幕密度划为若干桶:

类别 dpi density 示例
ldpi 120 0.75 几乎绝迹
mdpi 160 1.0 老 320x480
hdpi 240 1.5 老 720p
xhdpi 320 2.0 1080p
xxhdpi 480 3.0 1440p
xxxhdpi 640 4.0 4K

布局文件用 dp / sp,系统按当前 density 自动换算,保证不同密度看起来“长度一致”。

字号 sp 的特殊性

sp 同时受 fontScale(用户系统字体大小)影响:

Configuration config = getResources().getConfiguration();
float fontScale = config.fontScale; // 1.0 是默认;2.0 是超大字号
// sp -> px: px = sp * density * fontScale

所以正文文字一定要用 sp;图标尺寸、固定 UI 用 dp,避免跟随用户字号变化。

资源限定符

屏幕尺寸(旧版)

限定符 含义
small ~320x422 dp
normal ~320x470 dp
large ~480x640 dp
xlarge ~720x960 dp

这是早期 API 的方案,已不推荐,因为粗略且无法表达具体宽度。

屏幕方向

-port(竖屏) / -land(横屏):

res/layout/main.xml            (默认)
res/layout-land/main.xml       (横屏替换)

smallestWidth 限定符(API 13+)

更精确的尺寸适配方式。sw<N>dp 表示 可用最小宽度(设备旋转后保持的最小边):

限定符 典型设备
sw320dp 普通手机
sw600dp 7 寸平板
sw720dp 10 寸平板
sw840dp 12 寸+ 平板 / 折叠屏展开

例:

res/layout/main.xml             (默认 sw<600)
res/layout-sw600dp/main.xml     (平板以上)
res/layout-sw840dp/main.xml     (超大屏 / 折叠展开)

注意:smallestWidth 一旦匹配,会忽略其他较宽的目录,不会重复叠加。例如 800dp 宽的平板只会匹配 sw600dp。

布局别名

适配多尺寸时通常维护 基础布局 + 别名 模式,避免重复:

res/layout/main_twopane.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <FrameLayout
        android:id="@+id/list_container"
        android:layout_width="0dp"
        android:layout_weight="1"
        android:layout_height="match_parent" />

    <FrameLayout
        android:id="@+id/detail_container"
        android:layout_width="0dp"
        android:layout_weight="2"
        android:layout_height="match_parent" />
</LinearLayout>

res/values-large.xml(旧式别名):

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <item name="main_layout" type="layout">@layout/main_twopane</item>
</resources>

res/values-sw600dp/layouts.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <item name="main_layout" type="layout">@layout/main_twopane</item>
</resources>

res/values/layouts.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <item name="main_layout" type="layout">@layout/main_singlepane</item>
</resources>

代码里统一引用别名:

setContentView(R.layout.main_layout); // 自动按 sw600 选择不同布局

小屏 / 大屏 / 折叠屏适配

小屏(手机)

  • 单栏布局;
  • 用 ScrollView 兜底溢出内容;
  • 避免过密的列表项。

大屏(平板)

  • 双栏(列表 + 详情);
  • 充分利用横向空间;
  • 注意 match_parent 在横屏下不会自动给两侧加 padding,需用 Guideline 或 maxWidth。

折叠屏

折叠屏的关键问题是 折叠状态变化。展开 / 折叠时 Configuration 改变,需要响应:

AndroidManifest.xml:

<activity
    android:name=".MainActivity"
    android:configChanges="screenSize|smallestScreenSize|screenLayout" />

让 Activity 自己处理旋转和折叠,避免重建:

public class MainActivity extends AppCompatActivity {
    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        updateLayoutForFold();
    }

    private void updateLayoutForFold() {
        WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
        if (wm == null) return;
        WindowMetrics metrics = wm.getCurrentWindowMetrics();
        Rect bounds = metrics.getBounds();
        boolean isFoldedExpanded = bounds.width() >= 600 * density();
        if (isFoldedExpanded) {
            showTwoPane();
        } else {
            showSinglePane();
        }
    }

    private float density() {
        return getResources().getDisplayMetrics().density;
    }

    private void showTwoPane() { /* 切换到双栏 */ }
    private void showSinglePane() { /* 切换到单栏 */ }
}

Jetpack WindowManager

Google 提供的 androidx.window 库抽象了折叠屏特性:

dependencies {
    implementation "androidx.window:window:1.3.0"
    implementation "androidx.window:window-java:1.3.0"
}

监听折叠状态:

import androidx.window.layout.WindowInfoTracker;
import androidx.window.layout.FoldingFeature;
import androidx.lifecycle.lifecycleScope;

public class MainActivity extends AppCompatActivity {
    private WindowInfoTracker tracker;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        tracker = WindowInfoTracker.getOrCreate(this);
        tracker.windowLayoutInfo(this)
                .addOnLayoutInfoChangedListener(info -> {
                    for (DisplayFeature feature : info.getDisplayFeatures()) {
                        if (feature instanceof FoldingFeature) {
                            FoldingFeature fold = (FoldingFeature) feature;
                            boolean isHalfOpen = fold.getState()
                                    == FoldingFeature.State.HALF_OPENED;
                            boolean isVertical = fold.getOrientation()
                                    == FoldingFeature.Orientation.VERTICAL;
                            if (isHalfOpen && isVertical) {
                                showBookModeLayout(fold.getBounds());
                            }
                        }
                    }
                }, ContextCompat.getMainExecutor(this));
    }

    private void showBookModeLayout(Rect foldBounds) {
        // 在 fold 两侧分别布局
    }
}

WindowMetrics API

Android 11(API 30)引入 WindowMetrics 替代已弃用的 Display.getMetrics:

@RequiresApi(Build.VERSION_CODES.R)
public class ScreenSizeHelper {
    public static int widthDp(Activity activity) {
        WindowManager wm = activity.getWindowManager();
        WindowMetrics metrics = wm.getCurrentWindowMetrics();
        float density = activity.getResources().getDisplayMetrics().density;
        return (int) (metrics.getBounds().width() / density);
    }

    public static int heightDp(Activity activity) {
        WindowManager wm = activity.getWindowManager();
        WindowMetrics metrics = wm.getCurrentWindowMetrics();
        float density = activity.getResources().getDisplayMetrics().density;
        return (int) (metrics.getBounds().height() / density);
    }
}

低版本兼容:

public class CompatScreen {
    public static int widthDp(Context context) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
            if (wm != null) {
                WindowMetrics metrics = wm.getCurrentWindowMetrics();
                float density = context.getResources().getDisplayMetrics().density;
                return (int) (metrics.getBounds().width() / density);
            }
        }
        DisplayMetrics dm = context.getResources().getDisplayMetrics();
        return (int) (dm.widthPixels / dm.density);
    }
}

getMaximumWindowMetrics 用于多窗口场景下获取最大可用尺寸:

WindowMetrics max = wm.getMaximumWindowMetrics();
// 多窗口拖拽后返回真实可见宽度

注意:不要再用 Display.getHeight / getMetrics,它们在多窗口模式下返回的是错的。

ConstraintLayout 响应式

ConstraintLayout 通过 Guideline、Barrier、DimensionRatio、ConstraintSet 让响应式布局更高效。

Guideline 分屏

<androidx.constraintlayout.widget.ConstraintLayout>
    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/guide_half"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:orientation="vertical"
        app:layout_constraintGuide_percent="0.5" />

    <FrameLayout
        android:id="@+id/left_pane"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/guide_half" />

    <FrameLayout
        android:id="@+id/right_pane"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        app:layout_constraintStart_toEndOf="@id/guide_half"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Barrier 跟随最远元素

<androidx.constraintlayout.widget.Barrier
    android:id="@+id/barrier"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:barrierDirection="end"
    app:constraint_referenced_ids="tv_name,tv_desc" />

Barrier 会跟随 tv_name 和 tv_desc 中最宽的那个,让右侧按钮始终在两者右侧。

DimensionRatio 保持比例

<ImageView
    android:id="@+id/iv_avatar"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintDimensionRatio="1:1"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintEnd_toStartOf="@id/guide_half"
    app:layout_constraintBottom_toTopOf="@id/tv_name" />

Flow(Flow 实现响应式标签流)

Flow 是 ConstraintLayout 2.0 提供的虚拟布局,能像 Flexbox 一样自动换行:

<androidx.constraintlayout.helper.widget.Flow
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:flow_wrapMode="aligned"
    app:flow_horizontalGap="8dp"
    app:flow_verticalGap="8dp"
    app:constraint_referenced_ids="tag1,tag2,tag3,tag4,tag5"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

ConstraintSet 运行时切换

ConstraintLayout cl = findViewById(R.id.root);
ConstraintSet set = new ConstraintSet();
set.clone(this, R.layout.activity_main_expanded); // 不同布局文件
set.applyTo(cl);

适合折叠屏展开时切换布局形态。

字体缩放与无障碍

正文文字使用 sp 才能跟随用户字号。固定大小用 dp,可缩放用 sp。如果想给某些大字标题“留出字号变化空间但限制最大”,可用:

<style name="TextAppearance.Title" parent="TextAppearance.Material3.HeadlineSmall">
    <item name="android:textSize">20sp</item>
</style>

代码内可以监听 fontScale:

@Override
public void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    if (newConfig.fontScale > 1.3f) {
        // 用户开了超大字号,可能挤掉 UI 元素,考虑调整布局
        adjustLayoutForLargeFont();
    }
}

常见坑与最佳实践

  1. px 写死:写死的 px 在不同密度设备上看起来大小不一。一律用 dp / sp,自动换算。
  2. 使用 Display.getHeight():API 13 弃用,多窗口模式下返回错值。用 WindowMetrics。
  3. getResources().getDisplayMetrics() 在多窗口下失真:同上,应使用 WindowMetrics。
  4. smallestWidth 写成 sw600dp 写成 sw_600dp:下划线错误,正确是 sw600dp,无下划线。
  5. 布局别名忘了 type="layout":<item name="main_layout">@layout/main_twopane</item> 不会自动当作 layout,需要 type="layout"。
  6. match_parent 在 ConstraintLayout 不生效:ConstraintLayout 子 View 必须设约束,宽度 match_parent 应改用 0dp + constraintStart/End,否则约束不会算进去。
  7. 横屏用同一布局导致拥挤:横屏应单独写 layout-land 或用 ConstraintSet 动态切换。
  8. 字号不随用户缩放:正文写 dp 而非 sp,会让视障用户开大字号无效果,违反无障碍要求。
  9. 折叠屏未声明 configChanges:每次折叠都会重建 Activity,体验差。声明 configChanges="screenSize|smallestScreenSize|screenLayout" 自行处理。
  10. WindowMetrics.getBounds 与旧式 DisplayMetrics 混用:两者在多窗口下行为不一致,应统一用 WindowMetrics。

章节小结

本章覆盖了 Android 屏幕适配:dp 与 sp 的换算原理(px = dp * density)、密度桶与 smallestWidth 限定符、布局别名(<item type="layout">)、小屏 / 大屏 / 折叠屏适配策略、Android 11+ WindowMetrics API、ConstraintLayout 响应式(Guideline、Barrier、DimensionRatio、Flow、ConstraintSet)、Jetpack WindowManager 监听折叠状态、字体缩放与无障碍。核心要点:布局一律用 dp/sp;横屏与超大屏写专门资源或用 ConstraintSet 切换;折叠屏配合 androidx.window;多窗口场景用 WindowMetrics 不要用旧 DisplayMetrics。

下一章预告

下一章将进入性能优化领域:内存优化(LeakCanary、Bitmap 优化、弱引用)、启动优化(冷 / 温 / 热启动、延迟初始化)、渲染优化(过度绘制、RecyclerView 缓存)、ANR 分析与 StrictMode。