你是不是也被这些坑折磨过?
做 Flutter 的同学,屏幕适配这道坎几乎人人都绕不过去。
设计师只给一套 375×667 的稿子,产品说"要和设计稿一模一样",然后你打开代码,满屏都是这种东西:
dart
Container(
width: 180.w,
height: 100.h,
child: Text('按钮', style: TextStyle(fontSize: 16.sp)),
)
用的是 flutter_screenutil,好用是好用,但用久了你会发现几个让人抓狂的问题:
const全废了 。原本可以加const的 Widget,因为.w/.h是运行时计算,全部失去了 const 优化,Widget 树重建时白白多跑一遍。- 老项目迁移是噩梦。几百个文件,每个尺寸都要手动加小尾巴,漏一个就 UI 错位,改到怀疑人生。
- 入侵性极强,想删删不掉 。
screenutil渗透到每一行布局代码里,哪天想换方案,等于重写。
有没有一种方案,接入一次,全局生效,业务代码一行不改?
有。而且原理并不复杂,今天就把这套底层玩法讲清楚。
先搞懂:Flutter 的尺寸到底从哪来?
在讲方案之前,先用大白话说清楚一个概念:devicePixelRatio(设备像素比)。
简单理解:手机屏幕的物理像素很密,但 Flutter 布局用的是"逻辑像素"。devicePixelRatio 就是这两者的换算比例。比如 iPhone 上是 3.0,意味着 1 个逻辑像素 = 3×3 个物理像素。
不同安卓手机的 devicePixelRatio 各不相同,这就是为什么同一套代码在不同手机上 UI 大小不一样的根本原因。
Flutter 在初始化时,通过 RendererBinding 里的 createViewConfigurationFor 方法,从原生拿到这个值,然后算出整个画布的逻辑尺寸:
dart
// Flutter 源码:ViewConfiguration.fromView
factory ViewConfiguration.fromView(ui.FlutterView view) {
final BoxConstraints physicalConstraints =
BoxConstraints.fromViewConstraints(view.physicalConstraints);
final double devicePixelRatio = view.devicePixelRatio; // 从原生获取
return ViewConfiguration(
physicalConstraints: physicalConstraints,
logicalConstraints: physicalConstraints / devicePixelRatio, // 算出逻辑尺寸
devicePixelRatio: devicePixelRatio,
);
}
关键洞察来了 :如果我们能在这里把 devicePixelRatio 换成自己算的值,让 Flutter 的逻辑坐标系直接对齐设计稿,是不是就不需要在每个 Widget 上手动换算了?
答案是:可以,而且这正是 screen_adapt 的核心思路。
三步走:从源头改掉 devicePixelRatio
Step 1:重写 createViewConfigurationFor,改掉画布配置
继承 WidgetsFlutterBinding,重写这个方法,把 devicePixelRatio 换成按设计稿算出来的值:
dart
class DesignSizeWidgetsFlutterBinding extends WidgetsFlutterBinding {
static WidgetsBinding ensureInitialized(Size designSize) {
ScreenSizeUtils.instance.setDesignSize(designSize);
DesignSizeWidgetsFlutterBinding(designSize);
return WidgetsBinding.instance;
}
@override
ViewConfiguration createViewConfigurationFor(RenderView renderView) {
final view = renderView.flutterView;
ScreenSizeUtils.instance.setup(); // 计算缩放比
final physicalConstraints =
BoxConstraints.fromViewConstraints(view.physicalConstraints);
// 用我们自己算的 devicePixelRatio,而不是原生的
final devicePixelRatio = ScreenSizeUtils.instance.data.devicePixelRatio;
return ViewConfiguration(
physicalConstraints: physicalConstraints,
logicalConstraints: physicalConstraints / devicePixelRatio,
devicePixelRatio: devicePixelRatio,
);
}
}
缩放比的计算逻辑很直接:scale = 屏幕实际宽度 / 设计稿宽度,然后把这个 scale 乘进 devicePixelRatio 里。
但这样改完,你会发现有些 UI 还是没变。 为什么?
Step 2:重写 wrapWithDefaultView,修掉被 MediaQuery 重置的问题
翻 Flutter 源码会发现,runApp 内部调用了 wrapWithDefaultView,它在根 Widget 外面套了一层 MediaQuery.fromView,这一层会把 devicePixelRatio 重新从原生读一遍,把我们 Step 1 的修改覆盖掉。
解决方案:重写 wrapWithDefaultView,在根 Widget 外层包一个我们自己的 MediaQuery:
dart
@override
Widget wrapWithDefaultView(Widget rootWidget) {
final view = platformDispatcher.implicitView!;
// DesignSizeWidget 内部会注入适配后的 MediaQuery
return View(view: view, child: DesignSizeWidget(child: rootWidget));
}
DesignSizeWidget 会把 size、devicePixelRatio、viewInsets、padding 等全部按缩放比重新算一遍,覆盖掉原生的值。
这下全局 UI 都对了,但手势点击开始偏了。 继续。
Step 3:Hook GestureBinding,修掉手势坐标
手势事件的坐标转换在 GestureBinding 里,它用的是原生的 devicePixelRatio 来把物理坐标转成逻辑坐标。我们改了逻辑坐标系,但手势坐标还在用旧的换算,自然就偏了。
重写 initInstances,接管指针事件处理,换上我们自己的 devicePixelRatio:
dart
@override
void initInstances() {
super.initInstances();
// 接管指针事件,用适配后的 devicePixelRatio 做坐标转换
PlatformDispatcher.instance.onPointerDataPacket = _handlePointerDataPacket;
}
double? _devicePixelRatioForView(int viewId) {
if (viewId == 0) {
return ScreenSizeUtils.instance.data.devicePixelRatio; // 用适配值
}
return platformDispatcher.view(id: viewId)?.devicePixelRatio;
}
三步走完,全局适配、MediaQuery 对齐、手势坐标全部修正,一次接入,全局生效。
实际接入:三行代码搞定
dart
import 'package:screen_adapt/screen_adapt.dart';
void main() {
DesignSizeWidgetsFlutterBinding.ensureInitialized(
const Size(375, 667), // 设计稿尺寸
type: ScreenAdaptType.width, // 按宽度适配,最常用
scaleText: true, // 字体跟随适配
supportSystemTextScale: true, // 保留系统大字体设置
);
runApp(const MyApp());
}
接入后,业务代码直接按设计稿写,不需要任何后缀:
dart
// 接入前(flutter_screenutil 写法)
Container(
width: 180.w,
height: 100.h,
child: Text('按钮', style: TextStyle(fontSize: 16.sp)),
)
// 接入后(screen_adapt 写法)
const Container(
width: 180, // 直接写设计稿尺寸
height: 100,
child: Text('按钮', style: TextStyle(fontSize: 16)),
)
注意:const 回来了。
进阶:局部区域不想适配怎么办?
全局适配是好事,但有些场景确实需要"局部退出":
- 地图、WebView、原生视图(PlatformView)
- 第三方图表库,它有自己的尺寸逻辑
- 某个区域就是要按物理像素精确绘制
screen_adapt 提供了几个工具来处理这些场景。
UnscaledZone:局部反适配
dart
Column(
children: [
// 这个 Container 参与全局适配
Container(width: 180, height: 100, color: Colors.blue),
// 这个区域退出适配,按原始尺寸渲染
UnscaledZone(
child: Container(width: 180, height: 100, color: Colors.green),
),
// full 模式:连布局占位也一起退出适配
UnscaledZone(
mode: UnscaledZoneMode.full,
child: Container(width: 180, height: 100, color: Colors.orange),
),
],
)
两种模式的区别:
contextFallback(默认):子树视觉回到原始尺寸,但父布局里的占位槽还是适配态的大小。适合局部图表、画布。full:视觉和占位都一起退出适配。适合 legacy 模块、第三方组件。
AdaptedPlatformView:原生视图补偿
PlatformView(比如地图 SDK、WebView)在全局适配下容易出现尺寸错位、点击区域偏移。用 AdaptedPlatformView 包一层,自动补偿差异。
PhysicalPixelZone:1px 线条不模糊
需要画精确 1px 线条或像素级绘制时,用 PhysicalPixelZone 包住绘制区域,内部切换到物理像素语义,外层布局流不受影响。
从 flutter_screenutil 迁移:不用一次性改完
老项目不用一口气把所有 .w/.h 全删掉,可以分批迁移:
第一步:把根部初始化换掉
dart
// 迁移前
runApp(
ScreenUtilInit(
designSize: const Size(375, 812),
builder: (_, __) => const MyApp(),
),
);
// 迁移后
void main() {
DesignSizeWidgetsFlutterBinding.ensureInitialized(
const Size(375, 812),
type: ScreenAdaptType.width,
scaleText: true,
supportSystemTextScale: true,
);
runApp(const MyApp());
}
第二步 :还没迁移的旧页面,套一层 LegacyScreenUtilScope
dart
class LegacyEntryPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LegacyScreenUtilScope(
child: ScreenUtilInit(
designSize: const Size(375, 812),
builder: (_, __) => const LegacyOrderPage(), // 旧页面继续用 .w/.h
),
);
}
}
第三步 :按页迁移,把 .w/.h/.sp 逐步改回裸数字,迁完一页删一个 LegacyScreenUtilScope。
迁移规则很简单:
180.w→18016.sp→161.sw→MediaQuery.sizeOf(context).width
方案对比:什么时候该用哪个?
| 对比维度 | screen_adapt |
flutter_screenutil |
|---|---|---|
| 代码入侵性 | 无,业务代码零修改 | 高,所有尺寸加后缀 |
const 优化 |
完整保留 | 全部失效 |
| 老项目迁移 | 只改初始化,渐进迁移 | 需改所有尺寸代码 |
| 手势坐标 | 自动修正 | 无此问题(不改底层) |
| 局部退出适配 | UnscaledZone |
手动包 MediaQuery |
| 上手门槛 | 稍高(需理解 Binding) | 低,开箱即用 |
| 维护成本 | 需跟进 Flutter 底层变更 | 依赖库作者维护 |
结论:
- 新项目、或者老项目想彻底减少代码入侵 → 用
screen_adapt - 临时项目、团队对 Flutter 底层不熟悉、快速交付优先 →
flutter_screenutil也够用
FAQ:常见问题避坑
Q:横屏怎么处理? ScreenAdaptType.width 按宽度适配,横屏时宽高互换,框架内部会自动判断物理尺寸方向,不需要额外处理。
Q:字体大小跟着适配,但用户开了系统大字体,会不会冲突? supportSystemTextScale: true 时,系统大字体设置会叠加在适配比例上,两者都生效。如果不想受系统字体影响,传 false。
Q:某个第三方库内部用了 MediaQuery,适配后行为异常怎么办? 用 UnscaledZone(mode: UnscaledZoneMode.full) 把这个库的根 Widget 包起来,让它在原始坐标系里运行。
Q:运行时能动态切换设计稿尺寸吗? 可以,树里有 DesignSizeWidget 时直接调用:
dart
DesignSize.of(context).setDesignSize(const Size(390, 844));
DesignSize.of(context).reset(); // 恢复原始
Q:Flutter 版本升级后,底层 API 变了怎么办? 这是自定义 Binding 方案的固有成本。ViewConfiguration、PointerEvent 这类 API 相对稳定,但确实需要关注 Flutter 的 breaking change。项目会跟进维护。
三句话总结
screen_adapt的核心是在 Flutter 初始化链路里替换devicePixelRatio,让逻辑坐标系直接对齐设计稿,业务层零感知。- 三个关键重写点:
createViewConfigurationFor(画布配置)、wrapWithDefaultView(MediaQuery 覆盖)、GestureBinding(手势坐标修正),缺一不可。 - 老项目不用一次性迁移,
LegacyScreenUtilScope可以让新旧方案在同一个 App 里共存,按页渐进替换。
项目地址:screen_adapt [flutter 版本 3.35.7]
灵感来源:AndroidAutoSize(今日头条屏幕适配方案)