上一篇讲了 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 更标准。
灰度方案
头条做法不一定适合所有产品。渐进式灰度:
新建项目用头条方案,方便
存量项目:先在某个产品灰度跑一周,看崩溃率、看线上 UI 反馈
灰度 OK 后全量
其他屏幕适配方案对比
实战建议
新项目直接头条方案:尤其是 Compose 项目
存量项目灰度迁移:先在某个不重要的产品试
关注 Activity attach 顺序:在
Application.attachBaseContext调用而不是 Activity监控 UI 异常率:头条方案有改变 density,可能引起旧版字体大小感知不一致
设计稿统一 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 越小越好。