彻底告别 `.w/.h/.sp`!Flutter 屏幕适配的底层玩法,一次接入全局生效

你是不是也被这些坑折磨过?

做 Flutter 的同学,屏幕适配这道坎几乎人人都绕不过去。

设计师只给一套 375×667 的稿子,产品说"要和设计稿一模一样",然后你打开代码,满屏都是这种东西:

dart 复制代码
Container(
  width: 180.w,
  height: 100.h,
  child: Text('按钮', style: TextStyle(fontSize: 16.sp)),
)

用的是 flutter_screenutil,好用是好用,但用久了你会发现几个让人抓狂的问题:

  1. const 全废了 。原本可以加 const 的 Widget,因为 .w/.h 是运行时计算,全部失去了 const 优化,Widget 树重建时白白多跑一遍。
  2. 老项目迁移是噩梦。几百个文件,每个尺寸都要手动加小尾巴,漏一个就 UI 错位,改到怀疑人生。
  3. 入侵性极强,想删删不掉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 会把 sizedevicePixelRatioviewInsetspadding 等全部按缩放比重新算一遍,覆盖掉原生的值。

这下全局 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.w180
  • 16.sp16
  • 1.swMediaQuery.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 方案的固有成本。ViewConfigurationPointerEvent 这类 API 相对稳定,但确实需要关注 Flutter 的 breaking change。项目会跟进维护。


三句话总结

  1. screen_adapt 的核心是在 Flutter 初始化链路里替换 devicePixelRatio,让逻辑坐标系直接对齐设计稿,业务层零感知。
  2. 三个关键重写点:createViewConfigurationFor(画布配置)、wrapWithDefaultView(MediaQuery 覆盖)、GestureBinding(手势坐标修正),缺一不可。
  3. 老项目不用一次性迁移,LegacyScreenUtilScope 可以让新旧方案在同一个 App 里共存,按页渐进替换。

项目地址:screen_adapt [flutter 版本 3.35.7]

灵感来源:AndroidAutoSize(今日头条屏幕适配方案)


参考资料

相关推荐
liulian09163 小时前
Flutter for OpenHarmony 跨平台开发:密码生成器功能实战指南
flutter
可有道理3 小时前
Flutter 抽象类、接口与mixin
flutter
MonkeyKing71554 小时前
Flutter路由高级管理实战:守卫、深链、多栈与Tab路由全解析
flutter
里欧跑得慢1 天前
CSS 嵌套:编写更优雅的样式代码
前端·css·flutter·web
里欧跑得慢1 天前
CSS变量与自定义属性详解
前端·css·flutter·web
xmdy58661 天前
Flutter+开源鸿蒙实战|校园易生活Day1 项目初始化搭建+开发环境校验+工程目录规范+第三方库集成+多端屏幕适配+全局底部导航
flutter·开源·harmonyos
MonkeyKing1 天前
Flutter国际化与多主题实战:多场景示例,一键适配多语言+多风格
flutter
MonkeyKing1 天前
iOS设计模式
flutter
xmdy58661 天前
Flutter+开源鸿蒙实战|校园易生活Day2 第三方库批量集成+全局Toast提示+网络状态监听+首页轮播图+资讯卡片布局
flutter·开源·harmonyos