flutter_screenutil很好用,但是时候抛弃它了

1,200 阅读5分钟

万恶的UI又偷懒了,设计稿永远只有一个尺寸。产品大爷要求:App UI要和设计稿一模一样!!!

于是,我搬出了大家都在用的flutter_screenutil。 flutter_screenutil好不好用?好用!治疗了我多年的颈椎病! 但我感觉好是好,但它也有一些问题:

  1. 本来可以用 const 修饰的 widget,没办法了,性能有下降。(我就是吹毛求疵,不能忍!)
  2. 老项目迁移太麻烦了,所有描述尺寸的地方都加上小尾巴。还老漏掉。(就不能清清爽爽的吗?)
  3. 入侵性太强,无处不在的,集成容易剔除难。(复制粘贴还要处理一堆毛病?)

想来想去,flutter_screenutil做的事情很简单,跟 android 里的 density 是一个道理,既然 android 里有这个全局,flutter 里就没有?

让我们来研究一下 Flutter 框架的初始化入口

WidgetsFlutterBinding.ensureInitialized();

class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding{
...
}

从上面代码可以看出 Flutter 的初始化逻辑是分别放在几个mixin 里的,而RendererBinding实际上就是对画布的初始化,在RendererBinding里有以下方法:

  ViewConfiguration createViewConfigurationFor(RenderView renderView) {
    return ViewConfiguration.fromView(renderView.flutterView);
  }
  
  class ViewConfiguration {
      /// Creates a view configuration for the provided [FlutterView].
      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,
        );
      }
  }

这里的createViewConfigurationFor方法实际上返回画布的具体配置。之所以需要传入renderView应该是适配多窗口。 ViewConfiguration.fromView是初始化ViewConfiguration的具体实现,我们可以看到view.devicePixelRatio就是从原生获取到的像素的比例,physicalConstraints 是获取的物理窗口尺寸,logicalConstraints则是通过devicePixelRatio算出的逻辑比例。devicePixelRatio根据平台不同而不同,不同安卓手机应该是根据 density 进行配置的。我们发现不同安卓手机上UI表现不同的主要原因就是从原生获取的devicePixelRatio参数不同。那从这个逻辑出发。如果我们能够自定义devicePixelRatio,而不是从原生去获取,是不是就解决了屏幕适配的问题呢?说干就干!!

我的思路是继承WidgetsFlutterBinding,重写createViewConfigurationFor方法,传入自己的devicePixelRatio,以下具体实现代码:

class DesignSizeUtils {
  //设计稿大小
  late Size designSize;
  late MediaQueryData originData;
  late MediaQueryData data;
  double scale = 1.0;

  bool _isDesktop = false;

  factory DesignSizeUtils() => instance;
  static DesignSizeUtils get instance => _getInstance();
  static DesignSizeUtils? _instance;
  DesignSizeUtils._internal();

  //设置设计稿的大小
  void setDesignSize(Size size) {
    designSize = size;
    if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
      _isDesktop = true;
    }
    setup();
  }

  void reset() {
    final view = PlatformDispatcher.instance.implicitView!;
    originData = MediaQueryData.fromView(view);
    designSize = originData.size;
    if (designSize.width > designSize.height && !_isDesktop) {
      designSize = designSize.flipped;
    }
    scale = 1.0;
  }

  void setup() {
    final view = PlatformDispatcher.instance.implicitView!;
    originData = MediaQueryData.fromView(view);
    if (_isDesktop && scale != 1.0) {
      data = originData.design();
      return;
    }
    //横屏
    if (view.physicalSize.width > view.physicalSize.height && !_isDesktop) {
      scale = originData.size.height / designSize.width;
    } else {
      scale = originData.size.width / designSize.width;
    }
    data = originData.design();
  }

  static DesignSizeUtils _getInstance() {
    _instance ??= DesignSizeUtils._internal();
    return _instance!;
  }
}

 class DesignSizeWidgetsFlutterBinding extends WidgetsFlutterBinding {
  final Size designSize;

  DesignSizeWidgetsFlutterBinding(this.designSize);

  static WidgetsBinding ensureInitialized(Size size) {
    DesignSizeUtils.instance.setDesignSize(size);
    DesignSizeWidgetsFlutterBinding(size);
    return WidgetsBinding.instance;
  }

  @override
  ViewConfiguration createViewConfigurationFor(RenderView renderView) {
    var view = renderView.flutterView;
    DesignSizeUtils.instance.setup();
    final BoxConstraints physicalConstraints =
        BoxConstraints.fromViewConstraints(view.physicalConstraints);
    final double devicePixelRatio =
        DesignSizeUtils.instance.data.devicePixelRatio;
    return ViewConfiguration(
      physicalConstraints: physicalConstraints,
      logicalConstraints: physicalConstraints / devicePixelRatio,
      devicePixelRatio: devicePixelRatio,
    );
  } 
 }

再写个 Demo 测试一下,看能不能生效:

void main() {
  DesignSizeWidgetsFlutterBinding.ensureInitialized(const Size(375, 667));
  if (Platform.isAndroid) {
    SystemChrome.setSystemUIOverlayStyle(
        const SystemUiOverlayStyle(statusBarColor: Colors.transparent));
  }
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        useMaterial3: true,
        appBarTheme: const AppBarTheme(centerTitle: false),
      ),
      initialRoute: "/page1",
      routes: {
        "/page1": (context) => const Page1(),
        "/page2": (context) => const Page2()
      },
    );
  }
}

发现这样配置好像有些 UI 没效果?到底哪里出了问题?继续研究!


void runApp(Widget app) {
  final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized();
  _runWidget(binding.wrapWithDefaultView(app), binding, 'runApp');
}

mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, RendererBinding, SemanticsBinding{
...
  Widget wrapWithDefaultView(Widget rootWidget) {
    return View(
      view: platformDispatcher.implicitView!,
      deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: pipelineOwner,
      deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: renderView,
      child: rootWidget,
    );
  }
}

class _ViewState extends State<View> with WidgetsBindingObserver {
  @override
  Widget build(BuildContext context) {
    return RawView(
      view: widget.view,
      deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: widget._deprecatedPipelineOwner,
      deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: widget._deprecatedRenderView,
      child: MediaQuery.fromView(
        view: widget.view,
        child: FocusTraversalGroup(
          policy: _policy,
          child: FocusScope.withExternalFocusNode(
            includeSemantics: false,
            focusScopeNode: _scopeNode,
            child: widget.child,
          ),
        ),
      ),
    );
  }
}

在这里我查看了runApp的具体代码实现,发现它会调用WidgetsBinding中的wrapWithDefaultView方法,而View的初始化过程最终在 rootWidget 的上层包裹了一层 MediaQuery.fromView,又将devicePixelRatio重置回去了。看来决定 UI 布局MediaQuery也是关键,OK那就再重写一下 wrapWithDefaultView方法:

extension MediaQueryDataExt on MediaQueryData {
  MediaQueryData design() {
    final scale = DesignSizeUtils.instance.scale;
    return copyWith(
      size: size / scale,
      devicePixelRatio: devicePixelRatio * scale,
      viewInsets: viewInsets / scale,
      viewPadding: viewPadding / scale,
      padding: padding / scale,
    );
  }
}

 class DesignSizeWidgetsFlutterBinding extends WidgetsFlutterBinding {
  final Size designSize;

  DesignSizeWidgetsFlutterBinding(this.designSize);

  static WidgetsBinding ensureInitialized(Size size) {
    DesignSizeUtils.instance.setDesignSize(size);
    DesignSizeWidgetsFlutterBinding(size);
    return WidgetsBinding.instance;
  }

  @override
  ViewConfiguration createViewConfigurationFor(RenderView renderView) {
    var view = renderView.flutterView;
    DesignSizeUtils.instance.setup();
    final BoxConstraints physicalConstraints =
        BoxConstraints.fromViewConstraints(view.physicalConstraints);
    final double devicePixelRatio =
        DesignSizeUtils.instance.data.devicePixelRatio;
    return ViewConfiguration(
      physicalConstraints: physicalConstraints,
      logicalConstraints: physicalConstraints / devicePixelRatio,
      devicePixelRatio: devicePixelRatio,
    );
  } 
  
  @override
  Widget wrapWithDefaultView(Widget rootWidget) {
    final view = platformDispatcher.implicitView!;
    final mediaQueryData = MediaQuery.of(context).design();
    rootWidget =  MediaQuery(
      data: mediaQueryData,
      child: rootWidget,
    );
    return View(view: view, child: rootWidget);
  }
 }

再测试一下,果然,全局都改了! 但是手势的坐标判断好像都不准了。没事,继续研究。GestureBinding是对所有手势的初始化组件,看看源码:

mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
    platformDispatcher.onPointerDataPacket = _handlePointerDataPacket;
  }

  /// The singleton instance of this object.
  ///
  /// Provides access to the features exposed by this mixin. The binding must
  /// be initialized before using this getter; this is typically done by calling
  /// [runApp] or [WidgetsFlutterBinding.ensureInitialized].
  static GestureBinding get instance => BindingBase.checkInstance(_instance);
  static GestureBinding? _instance;

  @override
  void unlocked() {
    super.unlocked();
    _flushPointerEventQueue();
  }

  final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();

  void _handlePointerDataPacket(ui.PointerDataPacket packet) {
    // We convert pointer data to logical pixels so that e.g. the touch slop can be
    // defined in a device-independent manner.
    try {
      _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, _devicePixelRatioForView));
      if (!locked) {
        _flushPointerEventQueue();
      }
    } catch (error, stack) {
      FlutterError.reportError(FlutterErrorDetails(
        exception: error,
        stack: stack,
        library: 'gestures library',
        context: ErrorDescription('while handling a pointer data packet'),
      ));
    }
  }

  double? _devicePixelRatioForView(int viewId) {
    return platformDispatcher.view(id: viewId)?.devicePixelRatio;
  }

  /// Dispatch a [PointerCancelEvent] for the given pointer soon.
  ///
  /// The pointer event will be dispatched before the next pointer event and
  /// before the end of the microtask but not within this function call.
  void cancelPointer(int pointer) {
    if (_pendingPointerEvents.isEmpty && !locked) {
      scheduleMicrotask(_flushPointerEventQueue);
    }
    _pendingPointerEvents.addFirst(PointerCancelEvent(pointer: pointer));
  }

  void _flushPointerEventQueue() {
    assert(!locked);

    while (_pendingPointerEvents.isNotEmpty) {
      handlePointerEvent(_pendingPointerEvents.removeFirst());
    }
  }
  ....

从上面代码不难发现,所有的手势操作都是在initInstances方法里添加监听的,_devicePixelRatioForView这个方法传入了devicePixelRatio,如果重写这一段代码应该就能解决手势问题!说干就干!

 class DesignSizeWidgetsFlutterBinding extends WidgetsFlutterBinding {
  final Size designSize;

  DesignSizeWidgetsFlutterBinding(this.designSize);

  static WidgetsBinding ensureInitialized(Size size) {
    DesignSizeUtils.instance.setDesignSize(size);
    DesignSizeWidgetsFlutterBinding(size);
    return WidgetsBinding.instance;
  }

  @override
  ViewConfiguration createViewConfigurationFor(RenderView renderView) {
    var view = renderView.flutterView;
    DesignSizeUtils.instance.setup();
    final BoxConstraints physicalConstraints =
        BoxConstraints.fromViewConstraints(view.physicalConstraints);
    final double devicePixelRatio =
        DesignSizeUtils.instance.data.devicePixelRatio;
    return ViewConfiguration(
      physicalConstraints: physicalConstraints,
      logicalConstraints: physicalConstraints / devicePixelRatio,
      devicePixelRatio: devicePixelRatio,
    );
  } 
  
  @override
  Widget wrapWithDefaultView(Widget rootWidget) {
    final view = platformDispatcher.implicitView!;
    final mediaQueryData = MediaQuery.of(context).design();
    rootWidget =  MediaQuery(
      data: mediaQueryData,
      child: rootWidget,
    );
    return View(view: view, child: DesignSizeWidget(child: rootWidget));
  }
  
  @override
  void initInstances() {
    super.initInstances();
    //hooks GestureBinding
    PlatformDispatcher.instance.onPointerDataPacket = _handlePointerDataPacket;
  }

  @override
  void unlocked() {
    super.unlocked();
    _flushPointerEventQueue();
  }

  final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();

  void _handlePointerDataPacket(ui.PointerDataPacket packet) {
    try {
      _pendingPointerEvents.addAll(
          PointerEventConverter.expand(packet.data, _devicePixelRatioForView));
      if (!locked) {
        _flushPointerEventQueue();
      }
    } catch (error, stack) {
      FlutterError.reportError(FlutterErrorDetails(
        exception: error,
        stack: stack,
        library: 'gestures library',
        context: ErrorDescription('while handling a pointer data packet'),
      ));
    }
  }

  double? _devicePixelRatioForView(int viewId) {
    if (viewId == 0) {
      return DesignSizeUtils.instance.data.devicePixelRatio;
    }
    return platformDispatcher.view(id: viewId)?.devicePixelRatio;
  }

  @override
  void cancelPointer(int pointer) {
    if (_pendingPointerEvents.isEmpty && !locked) {
      scheduleMicrotask(_flushPointerEventQueue);
    }
    _pendingPointerEvents.addFirst(PointerCancelEvent(pointer: pointer));
  }

  void _flushPointerEventQueue() {
    assert(!locked);

    while (_pendingPointerEvents.isNotEmpty) {
      handlePointerEvent(_pendingPointerEvents.removeFirst());
    }
  }
 }

最后再测试一手,终于达到了理想的效果!!!

但是转念一想,这样做个全局,省事是省事了,但是某些布局如果就需要原生的devicePixelRatio咋办?现在想的办法是在需要还原widget 外层再包裹一层MediaQuery,传入原生的devicePixelRatio。

最后附上开源地址: github.com/lancexin/me…