性能文章>Flutter 低成本屏幕适配方案探索和实践>

Flutter 低成本屏幕适配方案探索和实践转载

3周前
172102

导语

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

 

正文

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

Flutter 低成本屏幕适配方案探索和实践数据图表-heapdump性能社区


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

Flutter 低成本屏幕适配方案探索和实践数据图表-heapdump性能社区


“思考为什么在 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(https://pub.flutter-io.cn/packages/flutter_screenutil#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
)

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


更低成本方案探索

方案 1: 从 SDK 层去修改

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

引擎启动流程如下图:(参考自 Gityuan 的 深入理解 Flutter 引擎启动(http://gityuan.com/2019/06/22/flutter_booting))

Flutter 低成本屏幕适配方案探索和实践数据图表-heapdump性能社区


既然 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源码编译环境(http://gityuan.com/2019/08/03/flutter_engine_setup)。我们在 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 都会引发新的问题:

通过 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()
  );
}


  
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 下的源码和调用流程,找到合理的切入点,尝试是否有更佳的适配方案,让适配做的更加从容和优雅。

参考资料

Flutter for Android developers(https://flutter.dev/docs/get-started/flutter-for/android-devs)
flutter 屏幕适配 字体大小适配(https://blog.csdn.net/u011272795/article/details/82795477)
搭建Flutter Engine源码编译环境(http://gityuan.com/2019/08/03/flutter_engine_setup)
一种极低成本的 Android 屏幕适配方式
深入理解 Flutter 引擎启动(http://gityuan.com/2019/06/22/flutter_booting)

 

更多思考

Flutter的应用非常广泛,大家可以阅读这个专题来加深学习!

HeapDump性能社区专题系列八:中间件redis的养护手册

 

分类:
标签:
请先登录,再评论

暂无回复,快来写下第一个回复吧~

为你推荐

美团FlutterWeb性能优化探索与实践
一、背景 1.1 关于FlutterWeb 时间回拨到 2018 年,Google 首次公开 FlutterWeb Beta 版,表露出要实现一份代码、多端运行的愿景。经过无数工程师两年多的努力,在今年年初(2021 年 3 月份),Flutter 2.0 正式对外发布,
Flutter混合栈路由实践与优化
导语  在 Flutter 和原生混合开发的场景里,路由是绕不开的一个话题。但业内的方案中仍存在内存异常,对官方底层的修改也需要不断踩坑。我们在项目实践中,抽离出了一套混合栈路由框架。对内存进行了进一步优化,清晰了对底层代码的修改,同时更易于 Flutter SDK 升级。 
腾讯MOO音乐关于Flutter的内存治理(上)
导语MOO 音乐是 TME 旗下的新锐音乐服务,其团队是公司内最早实践 Flutter 的先行者之一。本系列文章将提炼 MOO APP 开发中遇到的情况,就 Flutter 内存占用治理方面,分享日常开发的一些基本认知、注意要点、排查方法和优化方案。内存治理篇文章共分上、中、下三篇,本篇为上篇。
腾讯MOO音乐关于Flutter的内存优化策略(下)
MOO 音乐是 TME 旗下的新锐音乐服务,其团队是公司内最早实践 Flutter 的先行者之一。本系列文章将提炼 MOO APP 开发中遇到的情况,就 Flutter 内存占用治理方面,分享日常开发的一些基本认知、注意要点、排查方法和优化方案。前两期为大家介绍了Flutter的内存泄漏排查及内存
腾讯MOO音乐关于Flutter的内存泄漏的排查实战(中)
导言MOO 音乐是 TME 旗下的新锐音乐服务,其团队是公司内最早实践 Flutter 的先行者之一。本系列文章将提炼 MOO APP 开发中遇到的情况,就 Flutter 内存占用治理方面,分享日常开发的一些基本认知、注意要点、排查方法和优化方案。内存治理篇文章共分上、中、下三篇,本篇为中篇。
淘宝特价版Flutter研发模式下的页面性能优化实践
导语淘宝特价版是集团内应用Flutter技术场景比较多,且用户量一亿人以上的应用了。目前我们首页、详情、店铺、我的,看看短视频,及评价,设置等二级页面都在用Flutter技术搭建。我们发现使用Flutter经常会遇到性能问题。因为Flutter严格意义上仅是一种“UI渲染框架&rdq
HeapDump性能社区专题系列七:大厂前端敲门砖——Flutter的应用实践
HeapDump性能社区内容专题,打包知识一起学:系列一:了解数据库性能优化系列二:手把手教你了解OOM系列三:过年七天,天天向上系列四:后端面试必备问题集系列五:了解前端性能优化实践系列六:手把手教你玩转JVM性能调优 Flutter是Google开源的构建用户界面(
闲鱼直播—使用Flutter实现跨平台播放器开发实践
导言直播带货已成为近年来最热的“风口”,已成为电商升级的新突破口。闲鱼作为国内最大的二手交易平台市场,直播带货也成为推动成交的强烈需求。但是闲鱼直播原先接入外部提供的直播sdk,存在以下几个痛点问题:业务定制困难。接外部sdk都存在“改不动,不敢改”