Exploring the low-cost screen adaptation solution of Zhengcaiyun Flutter

image.png

north.png

foreword

In the development process of the mobile terminal, in order to solve the problem that the fixed design drawing size presents different effects on different devices, we often need to perform screen adaptation. Although there are many mature solutions for screen adaptation in Android development, there seems to be no good solution in Flutter, so this article will explore a very low-cost screen adaptation solution on Flutter.

Effects without adaptation:

However, for visual designers, the desired effect is as follows:

Thinking about why the visual effect of the same control in Flutter is so different on different devices?

How is size calculated in Flutter?

Two concepts are introduced here: physical pixels and logical pixels .

  • Physical pixels, also known as device pixels, refer to the basic unit of the screen and the size we can see. For example, the screen of the iPhone 13 has 1170 pixels in the width direction and 2532 pixels in the height direction.
  • Logical pixels, also known as device- or resolution-independent pixels. As a cross-platform framework, Flutter must extract a new unit to adapt to different platforms. If the native unit concept is still used, it will cause confusion.

The physical pixel is the product of the logical pixel value and the device pixel ratio devicePixelRatio (hereinafter referred to as dpr)**. **which is

物理像素 px = 逻辑像素 * devicePixelRatio
复制代码

In Flutter, devicePixelRatio is provided by the ui.Window class, and Window is the interface that the Flutter Framework connects to the host operating system. Therefore, the devicePixelRatio property in the dart code is exactly what the engine layer gets from the native platform. And this value corresponds to density in Android, and corresponds to [UIScreen mainScreen].scale in iOS. The reason why the same logical pixels see different physical pixels on different resolution mobile phones is that each device may have different dpr.

Mainstream solutions online

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 = 375
  
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null) MyWidgetsFlutterBinding();
    return WidgetsBinding.instance;
  }
  
  @override
  void initInstances() {
    super.initInstances();
    window.onPointerDataPacket = _handlePointerDataPacket;
  }
  
  @override
  ViewConfiguration createViewConfiguration() {
    return ViewConfiguration(
      size: Size(
          designWidth, window.physicalSize.width / designWidth * window.physicalSize.height),
      devicePixelRatio: window.physicalSize.width / designWidth,
    );
  }
  
  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 层进行修改,没有任何代码侵入性

总结

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

参考资料

  1. Flutter for Android developers
  2. flutter 屏幕适配 字体大小适配
  3. 搭建Flutter Engine源码编译环境
  4. A very low-cost Android screen adaptation method
  5. In-depth understanding of Flutter engine startup

Recommended reading

Chapter 2 of the JVM series - class files to virtual machines

Dapr combat (1)

Dapr combat (2)

Demystifying the core principles of DS version control

API Operation Posture in DS 2.0 Era

Careers

Zhengcaiyun technical team (Zero), a team full of passion, creativity and execution, Base is located in the picturesque Hangzhou. The team currently has more than 300 R&D partners, including "veteran" soldiers from Ali, Huawei, and NetEase, as well as newcomers from Zhejiang University, University of Science and Technology of China, Hangdian University and other schools. In addition to daily business development, the team also conducts technical exploration and practice in the fields of cloud native, blockchain, artificial intelligence, low-code platform, middleware, big data, material system, engineering platform, performance experience, visualization, etc. And landed a series of internal technology products, and continued to explore new boundaries of technology. In addition, the team has also devoted themselves to community building. Currently, they are contributors to many excellent open source communities such as google flutter, scikit-learn, Apache Dubbo, Apache Rocketmq, Apache Pulsar, CNCF Dapr, Apache DolphinScheduler, alibaba Seata, etc. If you want to change, you have been tossed with things, and you want to start tossing things; if you want to change, you have been told that you need more ideas, but you can't break the game; if you want to change, you have the ability to achieve that result, but you don't need you; if you If you want to change what you want to do, you need a team to support it, but there is no place for you to lead people; if you want to change, you have a good understanding, but there is always that layer of blurry paper... If you believe in the power of belief, I believe that ordinary people can achieve extraordinary things, and I believe that they can meet a better self. If you want to participate in the process of taking off as the business takes off, and personally promote the growth of a technical team with in-depth business understanding, a sound technical system, technology creating value, and spillover influence, I think we should talk. Anytime, waiting for you to write something, send it to [email protected]

WeChat public account

The article is released simultaneously, the public account of the technical team of Zhengcaiyun, welcome to pay attention

image.png

Guess you like

Origin juejin.im/post/7078816723666731021