一个全新 Flutter UI 适配方案,低入侵& 100% 还原设计稿!

Posted 刘望舒

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一个全新 Flutter UI 适配方案,低入侵& 100% 还原设计稿!相关的知识,希望对你有一定的参考价值。

  
    
    
  
微信改了推动机制,真爱请星标本公号
公众号回复 加入BATcoder技术群 BAT

一、序


今天介绍一个我最近开源的 Flutter UI 方案,可以做到在全设备上 100% 还原设计稿,其背后的思想类似 android 侧今日头条基于 density 的 UI 适配方案,接下来进入正题。

由于 Android 的碎片化,全设备的 UI 一致性,一直是开发者和设计师所追求的。后续也衍生出各种不同的适配方案以及对应的开源库,例如:AndroidAutoLayout、smallestWidth,以及最后今日头条基于修改 density 的 UI 适配方案。到后期对于 Android 的 UI 适配,基本上不存在什么新的问题了,只有方案的选择和使用问题。

而在 Flutter 中,由于要支持 Android 和 ios 多系统的设备,UI 适配遇到的场景将更加复杂,这一切又要重新来一遍。对于各个系统的屏幕适配原理都差不多,Flutter 也是如此,我们在 Flutter 中直接写在 Widget 的尺寸,会被乘以一个倍数去放大,最终对应到的是物理设备屏幕的像素尺寸。

这个倍数,在 Flutter 中就是 devicePixelRatio。它是设备上一个固定的值,不同设备的取值会不相同。

主流的 Flutter UI 适配方案,就是使用 flutter_screenutils 这个 Package,它也是基于 devicePixelRatio 对设置给 Widget 的尺寸进行调整,但入侵还是比较高的,需要遵循它的一些约束,如果想在成熟的项目上迁移使用,改动不会小。并且也有一些小问题,例如:无法适应 const Widget 的优化()。

那么针对我的一些需求,我开源了一个 Flutter package:screen_autosize,对于成熟的项目也可以做到低入侵的引入。

既然是 UI 适配,直接上效果说话。

对于一套相同的代码,使用 1080 宽度的设备,但从左到右的 dpi 分别是 375 → 392 → 440,运行后的效果如下。

引入 screen_autosize 后,运行效果如下。

一个全新 Flutter UI 适配方案,低入侵& 100% 还原设计稿!

该有的元素都有,可以看到在不同的参数设备下,可以做到 100% 的一致性。

二、screen_autosize

接下来我们正式介绍 screen_autosize。

一个全新 Flutter UI 适配方案,低入侵& 100% 还原设计稿!

2.1 优势 & 劣势

优势:

  1. 适配效果:100% 还原 UI;
  2. 稳定性高:原理简单,无任何 Framework 的 Hook;
  3. 侵入性低:修改点少且统一,日常编写 UI 无需特殊注意;
  4. 使用成本低:统一修改,全局有效;
  5. 性能损耗:几乎没有;
  6. 不破坏 const Widget 常量优化

劣势:

  1. 全局有效,无法单页面设置,需统一同项目不同模块的设计和开发标准;
  2. 编码注意点:不能使用 window 类 Api 获取尺寸信息;
  3. 需要重写 WidgetsFlutterBinding,可能与部分库存在冲突,需特殊处理;

2.2 使用方法

1. 安装依赖

安装之前,请查看最新版本(新版本如有问题,请使用上一版本)。

dependencies:
    flutter:
      sdk: flutter
    # 添加依赖
    screen_autosize: ^{latest version}

2. 使用导包

import 'package:screen_autosize/screen_autosize.dart';

3. 初始化

Step1:设定基准屏幕宽度。

需要在 runApp() 之前设置,使用 AutoSizeUtils 的 initConfig() 设置设计稿的基准宽度。

void main() {
  // 这里使用 iPhone 一倍的宽度作为基准宽度;
  AutoSizeUtils.instance.initConfig(375);
  // ...
}

Step2:替换 runApp()。

将原本的 runApp()runAutoSizeApp(MyApp()) 替换。

void main() {
  // 这里使用 iPhone 一倍的宽度作为基准宽度;
  AutoSizeUtils.instance.initConfig(375);
  // runApp(MyApp());
  runAutoSizeApp(MyApp());
}

Step3: 替换 MaterialApp 生成的 MediaQuery

MaterialApp 内部会生成 MediaQuery,需要将其通过 MediaQueryWrapper 替换。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      builder: (context, widget) {
        // 替换根的 MediaQuery
        return MediaQueryWrapper(builder: (BuildContext context){
          return widget;
        },);
      },
      home: HomePage(title: 'Flutter ScreenAutoSize示例'),
    );
  }
}
// ...

4. 开始写 UI

编码时,无任何注意点,直接按照设计图的尺寸写就行。

eg. 在 375 宽度(iPhone 1 倍尺寸)的设计稿下,一个 100x100 黄色区域,直接写参数就行,无需任何注意点。

class ColorsWidget extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.all(10), // 不需要转换,设计稿是多少,就写多少
      width: 100,  // 不需要转换,设计稿是多少,就写多少
      height: 100// 不需要转换,设计稿是多少,就写多少
      color: Colors.orange,
      child: Text(
        '我是正方形,边长是100',
        style: TextStyle(
          color: Colors.white,
          fontSize: 12,
        ),
      ),
    );
  }
}

如果要写满屏的尺寸,例如某个 Widget 宽度需要撑满屏幕,可以使用 2 种方法。

  1. 利用 MediaQuery 获取: MediaQuery.of(context).size.width;
  2. 利用 AutoSizeUtils 获取: AutoSizeUtils.instance.mediaWidth;

与宽度(width)对应的还有高度(height)的尺寸。

使用上有 2 点需要注意:

  1. 不能从 window 获取屏幕尺寸。因为库里改写了 devicePixelRatio,所以不能直接从 ui.window 里获取对应参数;
  2. 如果有多个 MaterialApp,也需要同步使用 MediaQueryWrapper 包装。 MaterialApp 内部会从 window 中获取参数生成 MediaQuery,所以需要特殊处理;

除了这 2 点,其他暂时未发现其他需要特殊注意的。

2.3 示例 & 效果

示例代码:

void main() {
  AutoSizeUtils.instance.initConfig(baseWidth: 375);
  runAutoSizeApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      builder: (context, widget) {
        return MediaQueryWrapper(builder: (BuildContext context){
          return widget;
        },);
      },
      // ...
      home: HomePage(title: 'Flutter ScreenAutoSize示例'),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({Key key, this.title}) : super(key: key);
  final String title;
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage{
  @override
  Widget build(BuildContext context) {
    printScreenInformation();
    var sysMediaData = MediaQueryData.fromWindow(ui.window);
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: SingleChildScrollView(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: <Widget>[
            Container(
              padding: EdgeInsets.only(left: 10),
              width: MediaQuery.of(context).size.width,
              height: AutoSizeUtils.instance.statusBarHeight,
              color: Colors.orange,
              alignment: Alignment.centerLeft,
              child: Text(
                '我和 StatusBar 的高度一致,高度是:${AutoSizeUtils.instance.statusBarHeight} dp',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 12,
                ),
              ),
            ),
            Row(
              children: <Widget>[
                Container(
                  padding: EdgeInsets.all(10),
                  width: 180,
                  height: 80,
                  color: Colors.red,
                  child: Text(
                    '实际宽度:${180}dp \n'
                        '实际高度:${80}dp',
                    style: TextStyle(color: Colors.white, fontSize: 12),
                  ),
                ),
                Container(
                  padding: EdgeInsets.all(10),
                  width: 180,
                  height: 80,
                  color: Colors.blue,
                  child: Text(
                      '设计稿宽度: 180dp \n'
                          '设计稿高度: 80dp',
                      style: TextStyle(
                          color: Colors.white,
                          fontSize: 12)),
                ),
              ],
            ),
            Container(
              padding: EdgeInsets.all(10),
              width: 375,
              height: 80,
              color: Colors.blueGrey,
              child: Text(
                  '设计稿宽度: 375dp \n'
                      '设计稿高度: 80dp',
                  style: TextStyle(
                      color: Colors.white,
                      fontSize: 12)),
            ),
            Container(
              padding: EdgeInsets.all(10),
              width: MediaQuery.of(context).size.width,
              height: 80,
              color: Colors.green,
              child: Text(
                  '设计稿要求宽度撑满屏幕,使用 MediaQuery.of().size.width 设置 \n'
                      '设计稿高度: 80dp',
                  style: TextStyle(
                      color: Colors.white,
                      fontSize: 12)),
            ),
            Container(
              padding: EdgeInsets.all(10),
              width: 100,
              height: 100,
              color: Colors.orange,
              child: Text(
                '我是正方形,边长是100',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 12,
                ),
              ),
            ),

            Text('设备原始像素尺寸: ${ui.window.physicalSize.width} x ${ui.window.physicalSize.height} (px)'),
            Text('设备原始 density: ${sysMediaData.devicePixelRatio}dp'),
            Text('设备原始宽度: ${sysMediaData.size.width} dp'),
            Text('设备原始高度: ${sysMediaData.size.height} dp'),
            Text("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="),
            Text('调整后的的像素密度: ${AutoSizeUtils.instance.devicePixelRatio}'),
            Text('调整后的宽度: ${AutoSizeUtils.instance.mediaWidth} dp'),
            Text('调整后的高度: ${AutoSizeUtils.instance.mediaHeight} dp'),
            Text('状态栏高度: ${AutoSizeUtils.instance.statusBarHeight} dp'),
            Text('底部安全区距离: ${AutoSizeUtils.instance.bottomBarHeight} dp'),
            Visibility(
              visible: AutoSizeUtils.instance.bottomBarHeight > 0,
              child: Container(
                padding: EdgeInsets.only(left: 10),
                width: MediaQuery.of(context).size.width,
                height: AutoSizeUtils.instance.bottomBarHeight,
                color: Colors.orange,
                alignment: Alignment.centerLeft,
                child: Text(
                  '我和 BottomBar 的高度一致,高度是:${AutoSizeUtils.instance.bottomBarHeight} dp',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 12,
                  ),
                ),
              ),
            ),

          ],
        ),
      ),
    );
  }
}

3.2 适配效果

未适配前:从左到右的 dpi 分别是 375 → 392 → 440。

一个全新 Flutter UI 适配方案,低入侵& 100% 还原设计稿!

使用 screen_autosize 适配后:从左到右的 dpi 分别是 375 → 392 → 440。

三、小结

今天的分享就到这里。

对于 screen_autosize 来说,收敛了配置,只需要在固定位置进行简单的配置,之后在项目中写 Flutter 代码,就无需额外的任何代码,做到低入侵,对一些已经开发成熟,比较友好。

这次 Package 开源,有一些细节还没准备好,例如还不支持 2.0 的空安全,后面有需要会继续迭代。不过基本功能都包含了,原则上可以直接使用。

设计思想和原理讲解的文章,后续会补上,涉及的代码比较简单,有兴趣也可以直接看源码

要是有任何不对的地方,各位老铁轻喷。有任何问题欢迎在留言区讨论,或提 issues。

文末「阅读原文」可直接跳转到 pub.dev (screen_autosize)。


·················END·················

推荐阅读

BATcoder技术群,让一部分人先进大厂

大家,我是刘望舒,腾讯TVP,著有三本技术畅销书,连续四年蝉联电子工业出版社年度优秀作者,谷歌开发者社区特邀讲师。

前华为技术专家,现大厂技术负责人。

想要加入 BATcoder技术群,公号回复BAT 即可。

为了防止失联,欢迎关注我的小号