Flutter 项目复盘 纯纯的实战经验(欢迎讨论)

34,833 阅读7分钟

一. 项目开始

1. 新建flutter项目时首先明确native端用的语言 java还是kotlin , objectC 还是swift ,否则选错了后期换挺麻烦的

2. 选择自己的路由管理和状态管理包,决定项目架构 以我而言 第一个项目用的 fluro 和 provider 一个路由管理一个状态管理,项目目录新建store和route文件夹,分别存放provider的model文件和fluro的配置文件,到了第二个项目,发现了Getx,一个集合了依赖注入,路由管理,状态管理的包,用起来! 项目目录结构有了很大的变化,整体条理整洁

第一个 image.png

第二个 image.png

3. 常用包配置,比如 Getx 需要把外层MaterialApp换成GetMaterialApp, flutter_screenutil 需要初始化设计图比例,provider全局导入,Dio 封装,拦截器,网络提示等等

二. 全局配置

1. 复用样式

1. 由于flutter 某些小widget复用性很高,而App 需要统一样式 ,样式颜色之类的预设文件放在command文件夹内

colours.dart ,可以预设静态class,存储常用主题色 image.png

styles.dart,可以预设 字体样式 分割线样式 各种固定值间隔

image.png

2. 建议全局管理后端接口,整洁还便于维护,舒服

3. models 文件夹

models 文件夹,可能在web端并不常用,但是在dart里我觉得很需要,后端返回的Json 字符串,一定要通过model类 格式化为一个类,可以极大地减少拼写错误或者类型错误, . 语法也比 [''] 用起来舒服的多推荐一个网站 quickType 输入json对象,一键输出model类!

4. 是否强制横竖屏?

需要在main.dart里配置好

// 强制横屏
  SystemChrome.setPreferredOrientations(
      [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);

5. 是否需要修改顶部 底部状态栏布局以及样式?

SystemUiOverlayStyleSystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle); 来配置

6. 设置字体不跟随系统

参考地址

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Container(color: Colors.white),
      builder: (context, widget) {
        return MediaQuery(
          //设置文字大小不随系统设置改变
          data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
          child: widget,
        );
      },
    );
  }
}

7. 国际化配置

使用部分widget会显示英文,比如IOS风格的dialog,显示中文这需要设置一下了, 首先需要一个包支持,

  flutter_localizations:
    sdk: flutter

引入包,然后在main.dart MetrialApp 的配置项中加入

   // 设置本地化,部分原生内容显示中文,例如长按选中文本显示复制、剪切
      localizationsDelegates: [
        GlobalMaterialLocalizations.delegate, //国际化
        GlobalWidgetsLocalizations.delegate,//国际化
        const FallbackCupertinoLocalisationsDelegate() // 这里是为了解决一个报错 看第 8 条
      ],
      //国际化
      supportedLocales: [
        const Locale('zh', 'CH'),
        const Locale('en', 'US'),
      ],

8. 使用CupertinoAlertDialog报错:The getter 'alertDialogLabel' was called on null

解决方法: 在main.dart中 加入如下类,然后在MetrialApp 的 localizationsDelegates 中实例化 见第 7 条

class FallbackCupertinoLocalisationsDelegate
    extends LocalizationsDelegate<CupertinoLocalizations> {
  const FallbackCupertinoLocalisationsDelegate();

  @override
  bool isSupported(Locale locale) => true;

  @override
  Future<CupertinoLocalizations> load(Locale locale) =>
      DefaultCupertinoLocalizations.load(locale);

  @override
  bool shouldReload(FallbackCupertinoLocalisationsDelegate old) => false;
}

9. ImageCache

最近版本的flutter更新,限制了catchedImage的上限, 100张 1000mb ,而业务需求却需要缓存更多,设置一下了这需要

    class ChangeCatchImage extends WidgetsFlutterBinding {
  @override
  createImageCache() *{*
    Global.myImageCatche = ImageCache()
      ..maximumSize = 1000
      ..maximumSizeBytes = 1000 << 20; // 1000MB
    return Global.myImageCatche;
  }
}

然后在main.dart runApp那里实例化一下ChangeCatchImage() 就可以了

三. 业务模块

常见的业务模块代码分析,比如登录页,闪屏页,首页,退出登录等

1. 首先安利一下Getx

一个文件夹就是一个业务模块,独自管理数据,通过依赖注入数据共享, 非舒服

image.png

包括 logic 逻辑控制层 state 数据管理层 view 视图组件层 ,当前业务的复用widget写在文件夹下

2. 登录模块

作为app的入口门户,炫酷美观是少不了的,这就需要关注性能优化,而输入的地方,验证的逻辑要有安全设计

  • 首先关于动画性能优化,最关键的一点是精准的更新需要变化的组件,我们可以通过devtool的工具查看更新范围

image.png

  • 其次时安全设计,简单的来看,限制登录次数,禁止简易密码,加密传输,验证token等,进阶版的比如,防止参数注入,过滤敏感字符等
  • 登录之前的账户验证,密码验证,必填项等,然后登录请求,需要加loading,按钮禁用,就不需要防抖了
  • 登录之后保存到本地用户基本信息(可能存在安全问题,暂未深究),然后下次登陆默认检测是否存在基本信息,并验证过期时间,和token,之后隐式登录到首页

3. splash闪屏模块

app登陆首页的准备页面,可以嵌入广告,或者定制软件宣传动画,提示三秒后跳过 如何优雅的加入app闪屏页? 其实就是在main.dart里把初始化页面设置为splash页面,之后通过跳转逻辑 判断去首页还是登录注册页面 比如这里我用了Getx 就简单配置一下

image.png

4. 操作引导模块

第一次使用app,或者重大更新之后往往会有操作引导 我的项目里用到了两种类型的操作引导

成果图 第一种

1.jpg

第二种

image.png image.png

二者都是基于overlayEntry()Overlay.of(context).insert(overlayEntry)实现的 第二种用了一个包 操作引导 flutter_intro: ^2.2.1,绑定Widget的GlobalKey,来获取Element信息,拿到位置大小,确保框选的位置正确,外层遮罩与第一种一样都是用overlayEntry()创建的

k.png

创建之后,展示出来 Overlay.of(context).insert(your_overlayEntry) 在某个按钮处切换下一个 比如点击我知道了,下一页之类的

     onPressed: () {
                  // 执行 remove 方法销毁 第一个overlayEntry 实例
                  overlayEntryTap.remove();
                  // 第二个
                  Overlay.of(context).insert(overlayEntryScale);
                },

关于第二个实现涉及的flutter_intro包,粘一下我的代码,详细的可以参照pub食用

final intro = Intro(
  // 一共有几步,这里就会创建2个GlobalKey,一会用到
  stepCount: 2,
  // 点击遮罩下一个
  maskClosable: true,
  // 高亮区域与 widget 的内边距
  padding: EdgeInsets.all(0),
  // 高亮区域的圆角半径
  borderRadius: BorderRadius.all(Radius.circular(4)),
  // use defaultTheme
  widgetBuilder: StepWidgetBuilder.useDefaultTheme(
    texts: ["点击添加收藏", "下拉添加书签"],
    buttonTextBuilder: (currPage, totalPage) {
      return currPage < totalPage - 1
          ? '我知道了 ${currPage + 3}/${totalPage + 2}'
          : '完成 ${currPage + 3}/${totalPage + 2}';
    },
  ),
);

......
// 这里用到key来绑定任意Widget
  Positioned(
              key: intro.keys[1],
              top: 0,
              right: 20,
        ...
  )
......

5. CustomPaint 绘图画板模块

成果图

image.png

当初选择flutter就是因为,有大量的绘制需求,看中了自带skia,绘制效率高且流畅而且具备平台一致性 结果坑也不少

首先来讲一下 猪脚 CustomPaint

顾名思义,这是一个个性化绘制组件,他的工作就是给你创建一个画布,你想怎么画怎么画,我们直接看怎么用 首先格式化写法

  • 首先 需要写在widget 树里吧
    Container( 
    child: CustomPaint(
    painter: myPainter(),
    ),
    
    看一下参数列表,发现painter 接收一个CustomePainter对象,这里可以注意一下child参数,很奇怪明明绘制界面都放在painter里了,留一个child干啥用??? 其实有大用,这里面放是他的子widget,但是不参与绘制更新的,通俗一点就是我绘制一片流动的云彩,但是有个静止的太阳,云彩的位置是实时repaint的,这时就可以把太阳widget放在child中,优化性能

image.png

  • 接下来我们创建myPainter()
    class myPainter extends CustomPainter { 
    @override 
    void paint(Canvas canvas, Size size) { 
    // 创建画笔 
    final Paint paint = Paint(); 
    // 绘制一个圆 
    canvas.drawCircle(Offset(50, 50), 5, paint); 
    } 
    @override 
    bool shouldRepaint(CustomPainter oldDelegate) => false;
    }
  • 到这里 我们需要实现两个重要的函数(如上代码) 第一个paint()函数,自带了画布对象 canvas,和画布尺寸 size,这样我们就可以使用Canvas的内置绘制函数了! 而绘制函数,都需要接收一个Paint 画笔对象

image.png

这个画笔对象,就是用来设置画笔颜色,粗细,样式,接头样式等等

Paint paint = Paint(); 
//设置画笔
paint ..style = PaintingStyle.stroke 
      ..color = Colors.red 
      ..strokeWidth = 10;
      

第二个函数shouldRepaint() 顾名思义判断是否需要重绘,如果返回false就是不需要重绘,只执行一次paine(),返回true就是总是重绘,依据实际需求设置 如果需要绘制类似于 根据数值不断变高的柱状图动画

代码如下(搬走就能用哦)

class BarChartPainter extends CustomPainter {
  final List<double> datas;
  final List<double> datasrc;
  final List<String> xAxis;
  final double max;
  final Animation<double> animation;

  BarChartPainter(
      {@required this.xAxis,
      @required this.datas,
      this.max,
      this.datasrc,
      this.animation})
      : super(repaint: animation);

  @override
  void paint(Canvas canvas, Size size) {
    _darwBars(canvas, size);
    _drawAxis(canvas, size);
  }

  @override
  bool shouldRepaint(BarChartPainter oldDelegate) => true;

  // 绘制坐标轴
  void _drawAxis(Canvas canvas, Size size) {
    final double sw = size.width;
    final double sh = size.height;

    // 使用 Paint 定义路径的样式
    final Paint paint = Paint()
      ..color = Colors.grey
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1
      ..strokeCap = StrokeCap.round;

    // 使用 Path 定义绘制的路径,从画布的左上角到左下角在到右下角
    final Path path = Path()
      ..moveTo(40, sh)
      ..lineTo(sw - 20, sh);

    // 使用 drawPath 方法绘制路径
    canvas.drawPath(path, paint);
  }

  // 绘制柱形
  void _darwBars(Canvas canvas, Size size) {
    final sh = size.height;
    final paint = Paint()..style = PaintingStyle.fill;
    final double _barWidth = size.width / 20;
    final double _barGap = size.width / 25 * 2 + 18;
    final double textFontSize = 14.0;

    for (int i = 0; i < datas.length; i++) {
      final double data = datas[i] * ((size.height - 15) / max);
      final top = sh - data;
      // 矩形的左边缘为当前索引值乘以矩形宽度加上矩形之间的间距
      final double left = i * _barWidth + (i * _barGap) + _barGap;
      // 使用 Rect.fromLTWH 方法创建要绘制的矩形
      final rect = RRect.fromLTRBAndCorners(
          left, top, left + _barWidth, top + data,
          topLeft: Radius.circular(5), topRight: Radius.circular(3));
      // 使用 drawRect 方法绘制矩形

      final offset = Offset(
        left + _barWidth / 2 - textFontSize / 2 - 8,
        top - textFontSize - 5,
      );
      paint.color = Color(0xFF59C8FD);

      //绘制bar
      canvas.drawRRect(rect, paint);

      // 使用 TextPainter 绘制矩形上放的数值
      TextPainter(
        text: TextSpan(
          text: datas[i] == 0.0 ? '' : datas[i].toStringAsFixed(0) + " %",
          style: TextStyle(
            fontSize: textFontSize,
            color: paint.color,
            // color: Colours.gray_33,
          ),
        ),
        textAlign: TextAlign.center,
        textDirection: TextDirection.ltr,
      )
        ..layout(
          minWidth: 0,
          maxWidth: textFontSize * data.toString().length,
        )
        ..paint(canvas, offset);

      final xData = xAxis[i];
      final xOffset = Offset(left, sh + 6);
      // 绘制横轴标识
      TextPainter(
        textAlign: TextAlign.center,
        text: TextSpan(
          text: '$xData' != ''
              ? '$xData'.substring(0, 4) + '-' + '$xData'.substring(4, 6)
              : '',
          style: TextStyle(
            fontSize: 12,
            color: Colors.black,
          ),
        ),
        textDirection: TextDirection.ltr,
      )
        ..layout(
          minWidth: 0,
          maxWidth: size.width,
        )
        ..paint(canvas, xOffset);
    }
  }
}

好了,customPainter,大体就这么用,下面回归话题,绘制画板 其实整体任务相当复杂,这里刨析一处,其他的融会贯通

拿最经典的铅笔画图来说 其实单纯的实现铅笔画图,甚至带笔锋,类似于签名,都很简单,网上教程一堆

大体思路就是 加一个GestureDetector ,主要用 onPanUpdate事件实时触发绘制动作,用canvas绘制出来 绘制简单,但是性能优化复杂

这里直接给出我测试的最优解 先把新的坐标点与之前的点连成线,可以一次多连接几个,也就是类似于节流的处理手法, 比如等panUpate触发了五次回调,先都把这五个点连接成线,第六次再统一绘制一条线(要是还有啥好办法,希望不吝赐教!) 详细的以后单独整理出来一个项目

6. websocket 即时通讯模块

成果图

Inked2_LI.jpg 只做了最基本的文字 图片 文件功能

简单把各项功能实现说一下,以后会详细整理,并加入音视频
  • 关于websocket

    首先肯定是连接websocket,用到一个包 web_socket_channel

    然后初始化websocket

    // 初始化websocket
    initWebsocket(){
     Global.channel = IOWebSocketChannel.connect(
       WebsocketUrl, // websocket地址
       //这个参数注意一下, 这里是每隔10000毫秒发送ping,如果间隔10000ms没收到pong,就默认断开连接
       //所以收网速等影响,这个参数如果太小,比如100ms就会,出现过一阵子自己断开连接的问题,参考实际设置
       pingInterval: Duration(milliseconds: 10000),
     );
     // 监听服务端消息
     Global.channel.stream.listen(
         (mes) => onMessage(mes),// 处理消息
         onError: (error) => {onError(error)}, // 连接错误
         onDone: () => {onDone()}, // 断开连接
         cancelOnError: true //设置错误时取消订阅
     );
    }
    
  • 处理消息

    进入页面加载聊天消息,长列表还是得用ListView.build(),消息多的时候体验好很多

    每次监听到新消息,加入到数组中,并更新视图,这一步不同的状态管理方法不同.

    加入消息这里就有难点了

    首先分四种情况 a. 自己发的并且在ListView底部,b. 自己发的但是不在ListView底部, c. 别人发的消息并且在底部,d. 别人发的不在底部.

    a 和 b,c: 只要是自己发得就滚动到底部,在底部时就滚动的慢点,有种消息上拉的感觉

    // 这里要确保在LIstView中已经加入并渲染完成新消息
    // 我的处理就是加了一个延迟,再滚动
    // 直接滚动到ListView底部
    scrollController.jumpTo(scrollController.position.maxScrollExtent);
    // 滚动到某个确定的元素
    Scrollable.ensureVisible(
         // 给每一条消息对象加GlobalKey,获取到当前上下文
         state.messageList[index].key.currentContext,
         duration: Duration(milliseconds: 100),
         curve: Curves.easeInOut,
         // 控制对齐方式
         alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
    

    d : 这种情况,做了个类似于微信的提示

image.png

但是点击定位到消息有坑了,因为用的listView.build,当你在翻阅上边的消息,下面的消息并没有加载,因此获取不到currentContext,因为元素并没有渲染,也就定位错乱了,目前最理想的解决办法就是,往上翻的时候,之下的记录全部渲染,往下滑时再依次清.

  • 文件和图片

    用到了几个包 file_picker, open_file, path_provider

    file_picker ,用来选择文件和图片,可以配置单选多选,需要在安卓的配置文件里加权限

    open_file , 类似于微信点击文件,先下载,然后调用本地默认程序打开文件

    path_provider,提供系统可用路径,用于创建文件目录

    具体使用如下

     // 访问不到app私有目录 导致我卡了很久...
     // Directory dirloc = await getTemporaryDirectory();
     // 访问外置存储目录
     final dirPath = await getExternalStorageDirectory();
     Directory file = Directory(dirPath.path + "/" + "temFile");
     // 不存在就创建目录
      try {
       bool exists = await file.exists();
       if (!exists) {
         await file.create(); // 创建了temFile 目录 用于缓存文件
       }
     } catch (e) {
       print(e);
     }
     // 下边就很关键了 可能不同的后端数据不同实现
      // 请求存储权限 需要一个包 permission_handler: ^6.1.1
       Permission.storage.request().then((value) async {
       //如果许可
       if (value.isGranted) {
         // 判断文件是否存在 wjmc 就是一个变量存储着文件名
         File _tempFile = File(file.path + '/' + wjmc);
         if (!await _tempFile.exists()) {
           try {
             //1、创建文件地址 带扩展 我用了getx cstate 
             //  final ChatState cState = Get.find<ChatLogic>().state;
             // 这是一个通用组件 不管理数据 从chatState里注入
             cState.path = file.path + '/' + wjmc;
             //2、下载文件到本地
             cState.downloading.value = nbbh; 
             var response = await dio.get(fileUrl);
             Stream resp = response.data.stream;
             //4. 转为uint8类型
             final Uint8List bytes =
                 await consolidateHttpClientResponseBytes(resp);
             //5. 转为List<int> 并写入文件
             final List<int> _filelist = List.from(bytes);
             final filePath = File(cState.path);
             await filePath.writeAsBytes(_filelist,
                 mode: FileMode.append, flush: true);
           } catch (e) {
             print(e);
           }
         }
         cState.downloading.value = '';
        // 6.这里可以记录位置,保存path到一个数组里,退出软件之后清除缓存 我没做
         open(cState.path);
       }
     });
    // 读取Stream 文件流 处理为Uint8List
    Future<Uint8List> consolidateHttpClientResponseBytes(Stream response) {
     final Completer<Uint8List> completer = Completer<Uint8List>.sync();
     final List<List<int>> chunks = <List<int>>[];
     int contentLength = 0;
     response.listen((chunk) {
       chunks.add(chunk);
       contentLength += chunk.length;
     }, onDone: () {
       final Uint8List bytes = Uint8List(contentLength);
       int offset = 0;
       for (List<int> chunk in chunks) {
         bytes.setRange(offset, offset + chunk.length, chunk);
         offset += chunk.length;
       }
       completer.complete(bytes);
     }, onError: completer.completeError, cancelOnError: true);
    
     return completer.future;
    }
    void open(path) {
    // 下载完成 准备打开文件
     showCupertinoDialog(
       context: Get.context,// 舒服
       builder: (context) {
         return Material(
           color: Colors.transparent,
           child: CupertinoAlertDialog(
               title: Padding(
                 padding: EdgeInsets.only(bottom: 10),
                 child: Text("提示"),
               ),
               content: Padding(
                 padding: EdgeInsets.only(left: 5),
                 child: Text("是否打开文件?"),
               ),
               actions: <Widget>[
                 CupertinoButton(
                   child: Text(
                     "取消",
                     style: TextStyle(color: Colours.gray_88),
                   ),
                   onPressed: () {
                     Get.back();
                   },
                 ),
                 CupertinoButton(
                     child: Text("确定"),
                     onPressed: () async {
                       Get.back();
                       // 直接调用就能打开,会通过系统默认程序打开 比如.doc 默认用office等.
                       await OpenFile.open(
                         cState.path,
                       );
                     }),
               ]),
         );
       },
     );
    }
    

    音视频用的小鱼易连,但是木有Flutter SDK ,只能基于安卓的去封装,以后有机会再讲讲.