政采云Flutter低成本屏幕适配方案探索

文章顶图.png

以北1.png

前言

在移动端的开发过程中,为了解决固定的设计图尺寸在不同设备上呈现的效果不一的问题,我们经常需要进行屏幕适配。虽然屏幕适配在安卓开发中已经有了很多成熟的方案,但是在 Flutter 中好像并没有什么太好的方案,因此本文将探索一个在 Flutter 上极低成本的屏幕适配方案。

未进行适配情况下的效果:

然而对于视觉设计师而言,希望达到的效果却是下面这样的:

思考为什么在 Flutter 中同一个控件在不同设备上视觉效果差别会如此大?

Flutter 中尺寸是如何计算的?

这里介绍两个概念:物理像素逻辑像素

  • 物理像素,又称设备像素,指屏幕的基础单元,也是我们能看到的尺寸。比如 iPhone 13 的屏幕在宽度方向有 1170 个像素点,高度方向有 2532 个像素点。
  • 逻辑像素,也被称为与设备或分辨率无关的像素。Flutter 作为一个跨平台的框架,必须抽离出一个新的单位,以适配不同的平台,如果还去使用原生的单位概念,就会造成混淆。 而物理像素是逻辑像素值与设备像素比 devicePixelRatio (后面简称 dpr )的乘积。即:
物理像素 px = 逻辑像素 * devicePixelRatio

在 Flutter 中,devicePixelRatio 由 ui.Window 类提供,Window 是 Flutter Framework 连接宿主操作系统的接口。因此,dart 代码中的 devicePixelRatio 属性正是引擎层从原生平台中获取的。而这个值,在安卓中就对应着 density,在 iOS 中就对应着 [UIScreen mainScreen].scale。相同逻辑像素在不同分辨率手机的看到的物理像素不一样的原因是每个设备可能都会有不同的 dpr。

网上的主流方案

Flutter_screenutil:网上比较流行的屏幕适配方案,主要原理是等比例缩放,先获取实际设备与原型设备的尺寸比例,然后根据px来适配。

核心代码如下:

/// 获取实际尺寸与 UI 设计的比例 以宽度为例
double get scaleWidth => _screenWidth / uiSize.width;

/// 根据 UI 设计的设备宽度适配  以宽度为例
double setWidth(num width) => width * scaleWidth;  

用法代码:

/// 用法 1
Container(
	width: ScreenUtil().setWidth(50),
	height:ScreenUtil().setHeight(200),
)
/// 用法 2
Container(
	width: 50.w,
	height:200.h
)  

这种方案局限性比较大,需要每个使用的地方都加上扩展函数,侵入性过强,严重影响使用观感,而且后期不好维护。而通常这种方案也是网上使用最广的方法。那难道我们需要一个个适配过去,一个个值都使用扩展方法去更改?

参考头条安卓原生的适配方案 一种极低成本的Android屏幕适配方式,思考能否以宽维度来适配,然后在一个统一的入口中一次性完成适配的工作。

更低成本方案探索

方案 1: 从 SDK 层去修改

在查看 Flutter 引擎启动流程后发现,每次引擎启动时都会由 RuntimeController 调用 CreateRunningRootIsolate 方法返回一个 DartIsolate 对象,同时通过 FlushRuntimeStateToIsolate 方法调用到 SetViewportMetrics 调用到 Window 的 UpdateWindowMetrics 方法去更新 Window 的属性。

引擎启动流程如下图:(参考自 Gityuan 的 深入理解 Flutter 引擎启动

既然 window 的属性是可以更新的,那我们在引擎调用 UpdateWindowMetrics 之后,再去更新一次 window 应该也能更新 window 的属性。 window 是一个 SingletonFlutterWindow 类型,该类是 FlutterWindow 的子类,而 FlutterWindow 又是 FlutterView 的具体实现类。

根据 FlutterView 源码里的解释,我们定位了 devicePixelRatio 取值的位置:

double get devicePixelRatio => viewConfiguration.devicePixelRatio

这里的 viewConfiguration 是在 FlutterWindow 类里获得的

class FlutterWindow extends FlutterView {
	FlutterWindow._(this._windowId, this.platformDispatcher);

  /// The opaque ID for this view.
  final Object _windowId;

  @override
  final PlatformDispatcher platformDispatcher;

  @override
  ViewConfiguration get viewConfiguration {
  	assert(platformDispatcher._viewConfigurations.containsKey(_windowId));
  	return platformDispatcher._viewConfigurations[_windowId]!;
  }
}	

ViewConfiguration 是 Platform View 的视图配置,直接影响了我们所能看到的视觉效果,主要字段如下:  

const ViewConfiguration({
  this.window,
  // 物理像素和逻辑像素的比值 这点上文中有详细说明
  this.devicePixelRatio = 1.0,
  // Flutter 渲染的 View 在Native platform 中的位置和大小
  this.geometry = Rect.zero,
  this.visible = false,
  // 各个边显示的内容和能显示内容的边距大小
  this.viewInsets = WindowPadding.zero,
  // viewInsets 和 padding 的和
  this.viewPadding = WindowPadding.zero,
  this.systemGestureInsets = WindowPadding.zero,
  // 系统UI的显示区域如状态栏,这部分区域最好不要显示内容,否则有可能被覆盖了
  this.padding = WindowPadding.zero,
});  

虽然官方的注释写了这是一个不可变的视图配置,但是我们可以通过编译源码来实现源码的修改,编译流程可以参考 搭建 Flutter Engine源码编译环境。 我们在 FlutterWindow 里面添加 set 代码,来对 ViewConfiguratiion 的值进行覆写

/// provide a method to change devicePixelRatio of the window
void setViewConfiguration(ViewConfiguration viewConfiguration) {
	assert(platformDispatcher._viewConfigurations.containsKey(_windowId));
	platformDispatcher._viewConfigurations[_windowId] = viewConfiguration;
}  

然后在 App 启动的时候调用 window.setViewConfiguration 方法,更新 devicePixelRatio 的值。

代码如下(以设计图宽度尺寸为 375 为例):

@override
  Widget build(BuildContext context2) {
    /// 375 is the number of your design size
    final modifiedViewConfiguration = window.viewConfiguration.copyWith(
      devicePixelRatio: window.physicalSize.width/375);
    window.setViewConfigureation(modifiedViewConfiguration);

    return MaterialApp(
        home: MyApp()
    );
  }  

devicePixelRatio 成功替换之后,我们发现 UI 效果达到了我们的预期。可是这在我们 sdk 升级之后会带来维护性的问题,那么有没有一种方案既不需要担心 sdk 版本的维护问题又能满足我们的需求呢。

方案 2: 从应用层去修改

我们来看一下 Flutter APP 启动的流程:

  • Flutter启动
void runApp(Widget app) {
	WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}  
  • 在启动开始,我们会对 WidgetsFlutterBinding 进行初始化操作。
class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, 	ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
   static WidgetsBinding ensureInitialized() {
      if (WidgetsBinding.instance == null)
        WidgetsFlutterBinding();
      return WidgetsBinding.instance!;
    }
  }  

WidgetsFlutterBinding 继承自 BindingBase,混入了 GestureBinding,SchedulerBinding,ServicesBinding,PaintingBinding,SemanticsBinding,RendererBinding 和WidgetsBinding 7 个mixin。 其中的 RendererBinding:渲染树与 Flutter engine 的链接,它持有了渲染树的根节点 renderView

RendererBinding 的初始化代码:

@override
   void initInstances() {
     super.initInstances();
     _instance = this;
     _pipelineOwner = PipelineOwner(
       onNeedVisualUpdate: ensureVisualUpdate,
       onSemanticsOwnerCreated: _handleSemanticsOwnerCreated,
       onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed,
     );
     window
       ..onMetricsChanged = handleMetricsChanged
       ..onTextScaleFactorChanged = handleTextScaleFactorChanged
       ..onPlatformBrightnessChanged = handlePlatformBrightnessChanged
       ..onSemanticsEnabledChanged = _handleSemanticsEnabledChanged
       ..onSemanticsAction = _handleSemanticsAction;
     initRenderView();
    _handleSemanticsEnabledChanged();
    assert(renderView != null);
    addPersistentFrameCallback(_handlePersistentFrameCallback);
    initMouseTracker();
    if (kIsWeb) {
      addPostFrameCallback(_handleWebFirstFrame);
    }
  }  

在其中的 handleMetricsChanged 方法中可以看到 renderView 的 configuration 值获取方法。

/// Called when the system metrics change.
///
/// See [dart:ui.PlatformDispatcher.onMetricsChanged].
@protected
void handleMetricsChanged() {
	assert(renderView != null);
	renderView.configuration = createViewConfiguration();
  scheduleForcedFrame();
}

那我们现在的思路也很明显了:那就是去重写 createViewConfiguration。 先去扩展一个 WidgetsFlutterBinding 的子类,在子类中重写 createViewConfiguration,然后再创造一个新的 runApp 方法来实现我们 APP 的启动。

自定义的 WidgetsFlutterBinding 子类(以设计图宽度尺寸为375为例):

class MyWidgetsFlutterBinding extends WidgetsFlutterBinding{
  @override
  ui.ViewConfiguration createViewConfiguration() {
    return ui.ViewConfiguration(
      devicePixelRatio: ui.window.physicalSize.width / 375,
    );
  }
}

然后我们再创造一个新的 runMyApp 的方法来实现我们 APP 对 MyWidgetsFlutterBinding 的调用:

void runMyApp(Widget app) {
  MyWidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

void main() {
  runMyApp(MyApp());
}

更改之后测试发现 dpr 成功改变,UI 效果也达到了我们的需求。

引发的问题及修改

当我们在项目中实践后,发现无论是方案 1 还是方案 2 都会引发新的问题:

  1. 通过 MediaQuery 获取到的屏幕尺寸未适配。 当我们使用 MediaQuery.of(context).size 获取屏幕尺寸时,实际上 MediaQuery.of(context) 返回的是一个 MediaQueryData 类型。

MediaQueryData 主要属性如下

const MediaQueryData({
  this.size = Size.zero,
  this.devicePixelRatio = 1.0,
  ..
})

发现此处也有用到 devicePixelRatio 这个属性,那我们同样可以在 MaterialApp 的根结点去改变 MediaQueryData 的值来使这个 Size 满足我们的需求。 改造代码如下(以设计图宽度尺寸为 375 为例):

@override
Widget build(BuildContext ctx) {
  return MaterialApp(
      builder: (context, widget) {
        return MediaQuery(
            child: widget,
            data: MediaQuery.of(context).copyWith(
              size: Size(375, window.physicalSize.height / (window.physicalSize.width / 375)),
              devicePixelRatio: window.physicalSize.width / 375,
              /// 设置文字大小不随系统设置改变
              textScaleFactor: 1.0
            ));
      },
      home: Home()
  );
}
  1. Widget 点击事件的区域发生了错乱。 我们来看 WidgetsFlutterBinding 的代码,发现他混入的 mixin 类中与手势相关的有一个 GestureBinding 。

GestureBinding 的初始化相关代码如下:

@override
void initInstances() {
  super.initInstances();
  _instance = this;
  ui.window.onPointerDataPacket = _handlePointerDataPacket;
}

代码非常的简洁,其中 onPointerDataPacket 是系统定义的回调函数:

/// Signature for [PlatformDispatcher.onPointerDataPacket].
typedef PointerDataPacketCallback = void Function(PointerDataPacket packet);

所以此处代码功能应该就是将 ui.window 获取到 PointerDataPacket 时候的处理方法指向了 GestureBinding 的 _handlePointerDataPacket 方法。

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.
  _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, ui.window.devicePixelRatio));
  if (!locked)
		_flushPointerEventQueue();
}

可以看到,此处也有用到 window 的 devicePixelRatio 属性,那我们也按照上面的方法来在我们实现的子类中更改 window 的 onPointerDataPacket 获得的值。 更改后的 WidgetsFlutterBinding 子类完整代码(以设计图宽度尺寸为 375 为例):

import 'dart:collection';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

/// 自定义的 WidgetsFlutterBinding 子类
class MyWidgetsFlutterBinding extends WidgetsFlutterBinding {
  
  final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
  
  /// 设计图宽度尺寸
  final int designWidth = 375static MyWidgetsFlutterBinding ensureInitialized() {
    MyWidgetsFlutterBinding();
    return WidgetsBinding.instance as MyWidgetsFlutterBinding;
  }

  @override
  void scheduleAttachRootWidget(Widget rootWidget) {
    super.scheduleAttachRootWidget(rootWidget);
  }
  
  @override
  void initInstances() {
    super.initInstances();
    window.onPointerDataPacket = _handlePointerDataPacket;
  }
  
  @override
    ViewConfiguration createViewConfiguration() {
      return ViewConfiguration(
        size: Size(
            375, window.physicalSize.height / (window.physicalSize.width / 375)),
        devicePixelRatio: window.physicalSize.width / 375,
      );
    }
  
  void _handlePointerDataPacket(PointerDataPacket packet) {
    // We convert pointer data to logical pixels so that e.g. the touch slop can be
    // defined in a device-independent manner.
    _pendingPointerEvents.addAll(PointerEventConverter.expand(
        packet.data, window.physicalSize.width / designWidth));
    if (!locked) _flushPointerEventQueue();
  }
  
  void _flushPointerEventQueue() {
    assert(!locked);
    while (_pendingPointerEvents.isNotEmpty)
      handlePointerEvent(_pendingPointerEvents.removeFirst());
  }
}

main.dart 中调用完整代码(以设计图宽度尺寸为 375 为例):

void runMyApp(Widget app) {
  MyWidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

void main() {
  runMyApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext ctx) {
    return MaterialApp(
        builder: (context, widget) {
          return MediaQuery(
              child: widget,
              data: MediaQuery.of(context).copyWith(
                size: Size(375, window.physicalSize.height / (window.physicalSize.width / 375)),
                devicePixelRatio: window.physicalSize.width / 375,
                /// 设置文字大小不随系统设置改变
                textScaleFactor: 1.0
              ));
        },
        home: Home()
    );
  }
}

代码的改动相对比较小,几乎不涉及任何业务代码的改动,也没有对 SDK 层进行修改,没有任何代码侵入性

项目demo: github.com/YiBei1223/e…

总结

虽然现在方案可能还会有新的问题, 但是目前相对来说还是最简单合理的方案。之后的话,还需要继续深入研究 FlutterWindow 下的源码和调用流程,找到合理的切入点,尝试是否有更佳的适配方案,让适配做的更加从容和优雅。

参考资料

  1. Flutter for Android developers
  2. flutter 屏幕适配 字体大小适配
  3. 搭建Flutter Engine源码编译环境
  4. 一种极低成本的Android屏幕适配方式
  5. 深入理解Flutter引擎启动

推荐阅读

JVM系列文章第二章-类文件到虚拟机

Dapr 实战(一)

Dapr 实战(二)

DS 版本控制核心原理揭秘

DS 2.0 时代 API 操作姿势

招贤纳士

政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有300多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

image.png