万恶的UI又偷懒了,设计稿永远只有一个尺寸。产品大爷要求:App UI要和设计稿一模一样!!!
于是,我搬出了大家都在用的flutter_screenutil。 flutter_screenutil好不好用?好用!治疗了我多年的颈椎病! 但我感觉好是好,但它也有一些问题:
- 本来可以用 const 修饰的 widget,没办法了,性能有下降。(我就是吹毛求疵,不能忍!)
- 老项目迁移太麻烦了,所有描述尺寸的地方都加上小尾巴。还老漏掉。(就不能清清爽爽的吗?)
- 入侵性太强,无处不在的,集成容易剔除难。(复制粘贴还要处理一堆毛病?)
想来想去,flutter_screenutil做的事情很简单,跟 android 里的 density 是一个道理,既然 android 里有这个全局,flutter 里就没有?
让我们来研究一下 Flutter 框架的初始化入口
WidgetsFlutterBinding.ensureInitialized();
class WidgetsFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding{
...
}
从上面代码可以看出 Flutter 的初始化逻辑是分别放在几个mixin 里的,而RendererBinding实际上就是对画布的初始化,在RendererBinding里有以下方法:
ViewConfiguration createViewConfigurationFor(RenderView renderView) {
return ViewConfiguration.fromView(renderView.flutterView);
}
class ViewConfiguration {
/// Creates a view configuration for the provided [FlutterView].
factory ViewConfiguration.fromView(ui.FlutterView view) {
final BoxConstraints physicalConstraints = BoxConstraints.fromViewConstraints(view.physicalConstraints);
final double devicePixelRatio = view.devicePixelRatio;
return ViewConfiguration(
physicalConstraints: physicalConstraints,
logicalConstraints: physicalConstraints / devicePixelRatio,
devicePixelRatio: devicePixelRatio,
);
}
}
这里的createViewConfigurationFor方法实际上返回画布的具体配置。之所以需要传入renderView应该是适配多窗口。 ViewConfiguration.fromView是初始化ViewConfiguration的具体实现,我们可以看到view.devicePixelRatio就是从原生获取到的像素的比例,physicalConstraints 是获取的物理窗口尺寸,logicalConstraints则是通过devicePixelRatio算出的逻辑比例。devicePixelRatio根据平台不同而不同,不同安卓手机应该是根据 density 进行配置的。我们发现不同安卓手机上UI表现不同的主要原因就是从原生获取的devicePixelRatio参数不同。那从这个逻辑出发。如果我们能够自定义devicePixelRatio,而不是从原生去获取,是不是就解决了屏幕适配的问题呢?说干就干!!
我的思路是继承WidgetsFlutterBinding,重写createViewConfigurationFor方法,传入自己的devicePixelRatio,以下具体实现代码:
class DesignSizeUtils {
//设计稿大小
late Size designSize;
late MediaQueryData originData;
late MediaQueryData data;
double scale = 1.0;
bool _isDesktop = false;
factory DesignSizeUtils() => instance;
static DesignSizeUtils get instance => _getInstance();
static DesignSizeUtils? _instance;
DesignSizeUtils._internal();
//设置设计稿的大小
void setDesignSize(Size size) {
designSize = size;
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
_isDesktop = true;
}
setup();
}
void reset() {
final view = PlatformDispatcher.instance.implicitView!;
originData = MediaQueryData.fromView(view);
designSize = originData.size;
if (designSize.width > designSize.height && !_isDesktop) {
designSize = designSize.flipped;
}
scale = 1.0;
}
void setup() {
final view = PlatformDispatcher.instance.implicitView!;
originData = MediaQueryData.fromView(view);
if (_isDesktop && scale != 1.0) {
data = originData.design();
return;
}
//横屏
if (view.physicalSize.width > view.physicalSize.height && !_isDesktop) {
scale = originData.size.height / designSize.width;
} else {
scale = originData.size.width / designSize.width;
}
data = originData.design();
}
static DesignSizeUtils _getInstance() {
_instance ??= DesignSizeUtils._internal();
return _instance!;
}
}
class DesignSizeWidgetsFlutterBinding extends WidgetsFlutterBinding {
final Size designSize;
DesignSizeWidgetsFlutterBinding(this.designSize);
static WidgetsBinding ensureInitialized(Size size) {
DesignSizeUtils.instance.setDesignSize(size);
DesignSizeWidgetsFlutterBinding(size);
return WidgetsBinding.instance;
}
@override
ViewConfiguration createViewConfigurationFor(RenderView renderView) {
var view = renderView.flutterView;
DesignSizeUtils.instance.setup();
final BoxConstraints physicalConstraints =
BoxConstraints.fromViewConstraints(view.physicalConstraints);
final double devicePixelRatio =
DesignSizeUtils.instance.data.devicePixelRatio;
return ViewConfiguration(
physicalConstraints: physicalConstraints,
logicalConstraints: physicalConstraints / devicePixelRatio,
devicePixelRatio: devicePixelRatio,
);
}
}
再写个 Demo 测试一下,看能不能生效:
void main() {
DesignSizeWidgetsFlutterBinding.ensureInitialized(const Size(375, 667));
if (Platform.isAndroid) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(statusBarColor: Colors.transparent));
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
appBarTheme: const AppBarTheme(centerTitle: false),
),
initialRoute: "/page1",
routes: {
"/page1": (context) => const Page1(),
"/page2": (context) => const Page2()
},
);
}
}
发现这样配置好像有些 UI 没效果?到底哪里出了问题?继续研究!
void runApp(Widget app) {
final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized();
_runWidget(binding.wrapWithDefaultView(app), binding, 'runApp');
}
mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, RendererBinding, SemanticsBinding{
...
Widget wrapWithDefaultView(Widget rootWidget) {
return View(
view: platformDispatcher.implicitView!,
deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: pipelineOwner,
deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: renderView,
child: rootWidget,
);
}
}
class _ViewState extends State<View> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
return RawView(
view: widget.view,
deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: widget._deprecatedPipelineOwner,
deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: widget._deprecatedRenderView,
child: MediaQuery.fromView(
view: widget.view,
child: FocusTraversalGroup(
policy: _policy,
child: FocusScope.withExternalFocusNode(
includeSemantics: false,
focusScopeNode: _scopeNode,
child: widget.child,
),
),
),
);
}
}
在这里我查看了runApp的具体代码实现,发现它会调用WidgetsBinding中的wrapWithDefaultView方法,而View的初始化过程最终在 rootWidget 的上层包裹了一层 MediaQuery.fromView,又将devicePixelRatio重置回去了。看来决定 UI 布局MediaQuery也是关键,OK那就再重写一下 wrapWithDefaultView方法:
extension MediaQueryDataExt on MediaQueryData {
MediaQueryData design() {
final scale = DesignSizeUtils.instance.scale;
return copyWith(
size: size / scale,
devicePixelRatio: devicePixelRatio * scale,
viewInsets: viewInsets / scale,
viewPadding: viewPadding / scale,
padding: padding / scale,
);
}
}
class DesignSizeWidgetsFlutterBinding extends WidgetsFlutterBinding {
final Size designSize;
DesignSizeWidgetsFlutterBinding(this.designSize);
static WidgetsBinding ensureInitialized(Size size) {
DesignSizeUtils.instance.setDesignSize(size);
DesignSizeWidgetsFlutterBinding(size);
return WidgetsBinding.instance;
}
@override
ViewConfiguration createViewConfigurationFor(RenderView renderView) {
var view = renderView.flutterView;
DesignSizeUtils.instance.setup();
final BoxConstraints physicalConstraints =
BoxConstraints.fromViewConstraints(view.physicalConstraints);
final double devicePixelRatio =
DesignSizeUtils.instance.data.devicePixelRatio;
return ViewConfiguration(
physicalConstraints: physicalConstraints,
logicalConstraints: physicalConstraints / devicePixelRatio,
devicePixelRatio: devicePixelRatio,
);
}
@override
Widget wrapWithDefaultView(Widget rootWidget) {
final view = platformDispatcher.implicitView!;
final mediaQueryData = MediaQuery.of(context).design();
rootWidget = MediaQuery(
data: mediaQueryData,
child: rootWidget,
);
return View(view: view, child: rootWidget);
}
}
再测试一下,果然,全局都改了! 但是手势的坐标判断好像都不准了。没事,继续研究。GestureBinding是对所有手势的初始化组件,看看源码:
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
@override
void initInstances() {
super.initInstances();
_instance = this;
platformDispatcher.onPointerDataPacket = _handlePointerDataPacket;
}
/// The singleton instance of this object.
///
/// Provides access to the features exposed by this mixin. The binding must
/// be initialized before using this getter; this is typically done by calling
/// [runApp] or [WidgetsFlutterBinding.ensureInitialized].
static GestureBinding get instance => BindingBase.checkInstance(_instance);
static GestureBinding? _instance;
@override
void unlocked() {
super.unlocked();
_flushPointerEventQueue();
}
final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
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.
try {
_pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, _devicePixelRatioForView));
if (!locked) {
_flushPointerEventQueue();
}
} catch (error, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: error,
stack: stack,
library: 'gestures library',
context: ErrorDescription('while handling a pointer data packet'),
));
}
}
double? _devicePixelRatioForView(int viewId) {
return platformDispatcher.view(id: viewId)?.devicePixelRatio;
}
/// Dispatch a [PointerCancelEvent] for the given pointer soon.
///
/// The pointer event will be dispatched before the next pointer event and
/// before the end of the microtask but not within this function call.
void cancelPointer(int pointer) {
if (_pendingPointerEvents.isEmpty && !locked) {
scheduleMicrotask(_flushPointerEventQueue);
}
_pendingPointerEvents.addFirst(PointerCancelEvent(pointer: pointer));
}
void _flushPointerEventQueue() {
assert(!locked);
while (_pendingPointerEvents.isNotEmpty) {
handlePointerEvent(_pendingPointerEvents.removeFirst());
}
}
....
从上面代码不难发现,所有的手势操作都是在initInstances方法里添加监听的,_devicePixelRatioForView这个方法传入了devicePixelRatio,如果重写这一段代码应该就能解决手势问题!说干就干!
class DesignSizeWidgetsFlutterBinding extends WidgetsFlutterBinding {
final Size designSize;
DesignSizeWidgetsFlutterBinding(this.designSize);
static WidgetsBinding ensureInitialized(Size size) {
DesignSizeUtils.instance.setDesignSize(size);
DesignSizeWidgetsFlutterBinding(size);
return WidgetsBinding.instance;
}
@override
ViewConfiguration createViewConfigurationFor(RenderView renderView) {
var view = renderView.flutterView;
DesignSizeUtils.instance.setup();
final BoxConstraints physicalConstraints =
BoxConstraints.fromViewConstraints(view.physicalConstraints);
final double devicePixelRatio =
DesignSizeUtils.instance.data.devicePixelRatio;
return ViewConfiguration(
physicalConstraints: physicalConstraints,
logicalConstraints: physicalConstraints / devicePixelRatio,
devicePixelRatio: devicePixelRatio,
);
}
@override
Widget wrapWithDefaultView(Widget rootWidget) {
final view = platformDispatcher.implicitView!;
final mediaQueryData = MediaQuery.of(context).design();
rootWidget = MediaQuery(
data: mediaQueryData,
child: rootWidget,
);
return View(view: view, child: DesignSizeWidget(child: rootWidget));
}
@override
void initInstances() {
super.initInstances();
//hooks GestureBinding
PlatformDispatcher.instance.onPointerDataPacket = _handlePointerDataPacket;
}
@override
void unlocked() {
super.unlocked();
_flushPointerEventQueue();
}
final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
try {
_pendingPointerEvents.addAll(
PointerEventConverter.expand(packet.data, _devicePixelRatioForView));
if (!locked) {
_flushPointerEventQueue();
}
} catch (error, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: error,
stack: stack,
library: 'gestures library',
context: ErrorDescription('while handling a pointer data packet'),
));
}
}
double? _devicePixelRatioForView(int viewId) {
if (viewId == 0) {
return DesignSizeUtils.instance.data.devicePixelRatio;
}
return platformDispatcher.view(id: viewId)?.devicePixelRatio;
}
@override
void cancelPointer(int pointer) {
if (_pendingPointerEvents.isEmpty && !locked) {
scheduleMicrotask(_flushPointerEventQueue);
}
_pendingPointerEvents.addFirst(PointerCancelEvent(pointer: pointer));
}
void _flushPointerEventQueue() {
assert(!locked);
while (_pendingPointerEvents.isNotEmpty) {
handlePointerEvent(_pendingPointerEvents.removeFirst());
}
}
}
最后再测试一手,终于达到了理想的效果!!!
但是转念一想,这样做个全局,省事是省事了,但是某些布局如果就需要原生的devicePixelRatio咋办?现在想的办法是在需要还原widget 外层再包裹一层MediaQuery,传入原生的devicePixelRatio。
最后附上开源地址: github.com/lancexin/me…