Administrator
发布于 2026-08-25 / 26 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 60 篇:屏幕适配:今日头条方案深度解析

上一篇讲了 Choreographer 卡顿监控,回到一个永恒话题——屏幕适配。Android 手机分辨率从 720p 到 4K、纵横比从 18:9 到瀑布屏,怎么做一套 UI 看起来都一样?今日头条的方案是业内最成熟的实践之一。

屏幕适配的三个维度

  • 宽度:同 dp 在不同 DPI 下物理大小不同

  • 高度:全面屏、挖孔、刘海影响高度

  • 比例:纵横比可能从 4:3 到 23:9

正确的做法是"按设计稿尺寸自动换算",而不是用一堆 dimens.xml 适配各种设备。

今日头条的方案原理

核心思路:在运行时根据设计稿宽度换算 dp。

// 假设设计稿宽度 360dp(360*1 ≈ 4.5" 屏)
const val DESIGN_WIDTH_DP = 360

fun adapt(context: Context): Context {
    val metrics = context.resources.displayMetrics
    val scale = metrics.widthPixels / (DESIGN_WIDTH_DP * metrics.density)
    metrics.density = DESIGN_WIDTH_DP * metrics.density / DESIGN_WIDTH_DP // 原值
    metrics.scaledDensity = metrics.density // 影响 sp 字体
    return context
}

更优雅的实现:

object DisplayAdapter {
    var designWidthDp = 360f

    fun adapt(context: Context, widthDp: Float = designWidthDp) {
        val displayMetrics = context.resources.displayMetrics
        val scale = displayMetrics.widthPixels / (widthDp * displayMetrics.density)

        displayMetrics.density = widthDp * displayMetrics.density / widthDp  // 不变
        displayMetrics.density = displayMetrics.density * scale
        // 真正做的事:把 density 改成 width/设计宽度
    }
}

// 在 Activity.attachBaseContext 里调用
override fun attachBaseContext(newBase: Context) {
    DisplayAdapter.adapt(newBase)
    super.attachBaseContext(newBase)
}

这样:

  • 在 360dp 设计稿上写 view.layoutParams = LayoutParams(100, 50) → 实际渲染按当前屏幕比例放大或缩小

  • 不同分辨率的设备,看到的都是"按设计稿比例"的尺寸

为什么不用 smallestWidth

Google 的 smallestWidth 限定符体系(sw360dp、sw480dp)适用于"在不同尺寸类别用不同布局",但跟尺寸调整(density 调整)不是一个维度。

smallestWidth 量化"屏幕属于哪个尺寸类"(小屏 / 中屏 / 大屏 / 平板),密度调整是为了"在同一个尺寸类里让控件大小按比例自适应"。

今日头条方案做的是后者,它只调整 density,不改变 smallestWidth。

真正的实现(AutoSize 开源版本)

GitHub 上有 AndroidAutoSize 库封装了完整方案:

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        // 注册设计稿
        AutoSizeConfig.init()
            .setBaseOnWidth(true) // 按宽度
            .setDesignWidthInDp(360) // 设计稿 360dp
            .setSupportFragments(true) // 支持 Fragment
            .setCustomAdapt(true) // 主动调用还是自动
    }
}

子模块覆盖:

// 某个 Activity 需要 480 设计稿(更宽布局)
class WideActivity : AppCompatActivity() {
    override fun getResources(): Resources {
        AutoSize.matchDesignWidthInDp(this, 480)
        return super.getResources()
    }
}

库内部原理和我上面写的一致:override Resources 的 density。

注意事项

1. Application attachBaseContext 中调用

class MyApplication : Application() {
    override fun attachBaseContext(base: Context) {
        DisplayAdapter.adapt(base)
        super.attachBaseContext(base)
    }
}

如果用 AutoSize.match() 一定要尽早调用,不然第一个 Activity 已经 layout 过了。

2. ImageView 自适应

图像资源如果按 match_parent,按宽度算不会失真;如果是固定 wrap_content,会让图片在不同屏大小不一致。建议关键图片用 match_parent 或比例约束。

3. 字体

scaledDensity 也改了,所以 sp 字体也会适配。某些场景可能不希望字体跟着缩放(用户系统设置已经放大过字体了),可以用 setIncludeFontScale(false) 关闭。

4. WebView

WebView 内部的 density 不会跟着改。需要另外适配:

webView.settings.textZoom = 100 // WebView 固定字体缩放

否则文字偏大偏小。

平板适配

头条方案对手机完美,但平板(设计稿一般是 1280dp)还需要切换设计稿宽度:

val isTablet = (resources.configuration.smallestScreenWidthDp >= 600)
if (isTablet) {
    AutoSize.matchDesignWidthInDp(this, 720) // 平板用 720dp 设计稿
} else {
    AutoSize.matchDesignWidthInDp(this, 360)
}

或者跑两套 UI dimens.xml(/res/values-sw600dp/)。

异形屏适配

刘海屏、挖孔屏、瀑布屏的高度处理:

ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
    val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
    WindowInsetsCompat.CONSUMED
}

但 Compose 时代的处理更简单:

Scaffold(
    contentWindowInsets = WindowInsets.systemBars
) { padding ->
    Box(modifier = Modifier.padding(padding)) {
        // 内容
    }
}

刘海屏的官方支持

Android 9+ 提供了 LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:

<activity
    android:name=".MyActivity"
    android:configChanges="uiMode"
    android:windowLayoutInDisplayCutoutMode="shortEdges">
</activity>

或代码:

window.attributes.layoutInDisplayCutoutMode = 
    WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES

Android 14+ 有 displayCutout API 直接读写 cutout 信息,比之前通过 DisplayCutoutCompat 更标准。

灰度方案

头条做法不一定适合所有产品。渐进式灰度:

  1. 新建项目用头条方案,方便

  2. 存量项目:先在某个产品灰度跑一周,看崩溃率、看线上 UI 反馈

  3. 灰度 OK 后全量

其他屏幕适配方案对比

方案

优点

缺点

dimens + 限定符

Google 官方

文件多、维护难

头条 density 适配

一份 dimens 解决所有

需要侵入 BaseContext

ConstraintLayout 比例

无侵入

复杂布局表达难

Compose 比例布局

Compose 推荐

老 View 体系需迁移

FlexboxLayout

类似 CSS Flexbox

学习成本

实战建议

  1. 新项目直接头条方案:尤其是 Compose 项目

  2. 存量项目灰度迁移:先在某个不重要的产品试

  3. 关注 Activity attach 顺序:在 Application.attachBaseContext 调用而不是 Activity

  4. 监控 UI 异常率:头条方案有改变 density,可能引起旧版字体大小感知不一致

  5. 设计稿统一 dp 单位:360dp 是当下约定俗成的"标准设计宽度"

真实场景的坑

案例 1:图片失真

设计稿有一个图标 48dp x 48dp,但设计师给的 @3x 资源只有 144x144 pixel。在 3x 屏完美,在 2x 屏被拉伸。

解决:所有图标放到 res/drawable/ 用 vector 或 webp,不要用 PNG。

案例 2:不同语言 UI 变形

中文:"搜索" 2 个字符
英文:"Search" 6 个字符

按钮宽度按内容算没问题,按固定 dp 算会有麻烦。约束布局:

Box(
    modifier = Modifier
        .widthIn(min = 80.dp) // 最小宽度
        .height(40.dp)
        .padding(horizontal = 16.dp)
) {
    Text(stringResource(R.string.search))
}

案例 3:动态字体大小

用户在系统设置里调字体,density 会变。头条方案会同时改 scaledDensity,导致 App 字体也跟着变。这跟用户的"加大字体"诉求冲突。

解决:

AutoSizeConfig.getInstance().setExcludeFontScaleFromDp(true)
// 不让字体跟着 dp 缩放

小结

屏幕适配没有银弹。头条方案解决"按设计稿比例缩放",最成熟、覆盖面广。复杂项目还要结合 ConstraintLayout/Compose 比例、限定符、字体策略、新 API。建议每个项目都跑一次"所有机型截图对比",找问题点单独处理。

下一篇聊 APK 体积优化——每个开发都希望自己的 App 越小越好。


评论