Explorando a solução de adaptação de tela de baixo custo do Zhengcaiyun Flutter

imagem.png

norte.png

prefácio

No processo de desenvolvimento do terminal móvel, para resolver o problema de que o tamanho fixo do desenho do desenho apresenta efeitos diferentes em diferentes dispositivos, muitas vezes precisamos realizar a adaptação da tela. Embora existam muitas soluções maduras para adaptação de tela no desenvolvimento Android, parece não haver uma boa solução no Flutter, portanto, este artigo explorará uma solução de adaptação de tela de custo muito baixo no Flutter.

Efeitos sem adaptação:

No entanto, para designers visuais, o efeito desejado é o seguinte:

Pensando em por que o efeito visual do mesmo controle no Flutter é tão diferente em dispositivos diferentes?

Como o tamanho é calculado no Flutter?

Dois conceitos são introduzidos aqui: pixels físicos e pixels lógicos .

  • Os pixels físicos, também conhecidos como pixels do dispositivo, referem-se à unidade básica da tela e ao tamanho que podemos ver. Por exemplo, a tela do iPhone 13 tem 1170 pixels na direção da largura e 2532 pixels na direção da altura.
  • Pixels lógicos, também conhecidos como pixels independentes de dispositivo ou resolução. Como um framework multiplataforma, o Flutter deve extrair uma nova unidade para se adaptar a diferentes plataformas.Se o conceito de unidade nativa ainda for usado, causará confusão.

O pixel físico é o produto do valor do pixel lógico e da proporção de pixel do dispositivo devicePixelRatio (doravante referido como dpr)**. **qual é

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

No Flutter, devicePixelRatio é fornecido pela classe ui.Window e Window é a interface que o Flutter Framework conecta ao sistema operacional do host. Portanto, a propriedade devicePixelRatio no código do dart é exatamente o que a camada do mecanismo obtém da plataforma nativa. E esse valor corresponde à densidade no Android e corresponde a [UIScreen mainScreen].scale no iOS. A razão pela qual os mesmos pixels lógicos veem pixels físicos diferentes em telefones celulares de resolução diferente é que cada dispositivo pode ter dpr diferente.

Soluções convencionais 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 = 375static 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. Um método de adaptação de tela Android de muito baixo custo
  5. Compreensão aprofundada da inicialização do motor Flutter

Leitura recomendada

Capítulo 2 da série JVM - arquivos de classe para máquinas virtuais

Combate Dapr (1)

Combate Dapr (2)

Desmistificando os princípios fundamentais do controle de versão do DS

Postura de operação da API na era DS 2.0

Carreiras

Equipe técnica Zhengcaiyun (Zero), uma equipe cheia de paixão, criatividade e execução, a Base está localizada na pitoresca Hangzhou. Atualmente, a equipe tem mais de 300 parceiros de P&D, incluindo soldados "veteranos" de Ali, Huawei e NetEase, bem como recém-chegados da Universidade de Zhejiang, Universidade de Ciência e Tecnologia da China, Universidade Hangdian e outras escolas. Além do desenvolvimento diário de negócios, a equipe também realiza exploração e prática técnica nas áreas de nuvem nativa, blockchain, inteligência artificial, plataforma low-code, middleware, big data, sistema de materiais, plataforma de engenharia, experiência de desempenho, visualização, etc. E desembarcou uma série de produtos de tecnologia interna e continuou a explorar novos limites da tecnologia. Além disso, a equipe também se dedicou à construção de comunidades. Atualmente, eles contribuem para muitas comunidades excelentes de código aberto, como google flutter, scikit-learn, Apache Dubbo, Apache Rocketmq, Apache Pulsar, CNCF Dapr, Apache DolphinScheduler, alibaba Seata , etc Se você quer mudar, você foi jogado com coisas, e você quer começar a jogar coisas; se você quer mudar, lhe disseram que você precisa de mais ideias, mas você não pode quebrar o jogo; se você quer mudar mudar, você tem a capacidade de fazer isso, mas você não precisa de você, se você quer mudar o que você quer fazer, você precisa de uma equipe para apoiá-lo, mas não há lugar para você liderar as pessoas; se você quer mudar, você tem um bom entendimento, mas sempre fica aquela camada de papel embaçado... um eu melhor. Se você quiser participar do processo de decolar à medida que o negócio decola e promover pessoalmente o crescimento de uma equipe técnica com profundo conhecimento do negócio, um sistema técnico sólido, tecnologia que cria valor e influência de transbordamento, acho que devemos palestra. A qualquer momento, esperando você escrever algo, envie para [email protected]

Conta pública do WeChat

O artigo é lançado simultaneamente, a conta pública da equipe técnica de Zhengcaiyun, bem-vindo a prestar atenção

image.png

Acho que você gosta

Origin juejin.im/post/7078816723666731021
Recomendado
Clasificación