Flutter 低成本屏幕适配方案探索和实践转载
导语
在移动端的开发过程中,为了解决固定的设计图尺寸在不同设备上呈现的效果不一的问题,我们经常需要进行屏幕适配。虽然屏幕适配在安卓开发中已经有了很多成熟的方案,但是在 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(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))
既然 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的养护手册