注册

Flutter 项目复盘 纯纯的实战经验

一. 项目开始


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 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
{
const FallbackCupertinoLocalisationsDelegate();

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

@override
Future
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 datas;
final List datasrc;
final List xAxis;
final double max;
final Animation 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().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 并写入文件
    final List _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 consolidateHttpClientResponseBytes(Stream response) {
    final Completer completer = Completer.sync();
    final List> chunks = >[];
    int contentLength = 0;
    response.listen((chunk) {
    chunks.add(chunk);
    contentLength += chunk.length;
    }, onDone: () {
    final Uint8List bytes = Uint8List(contentLength);
    int offset = 0;
    for (List 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: [
    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 ,只能基于安卓的去封装,以后有机会再讲讲.


作者:零念念
链接:https://juejin.cn/post/7036636900379066376
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册