【AICon】探索RAG 技术在实际应用中遇到的挑战及应对策略!AICon精华内容已上线73%>>> 了解详情
写点什么

干货 | 携程火车票 Flutter 最佳实践

  • 2021-05-13
  • 本文字数:9993 字

    阅读完需:约 33 分钟

干货 | 携程火车票Flutter最佳实践

背景

在竞争激烈的移动时代,各大互联网公司都在争相抢夺市场,如何提高研发效率,快速迭代产品成为非常重要的因素。


跨平台方案能够节约一定开发、测试、运维成本。Flutter 是由谷歌开源的跨平台框架,可以快速在 iOS 和 Android 上构建高质量的原生用户界面。

一、 为什么选择 Flutter

携程在已经引入了 React Native 的情况下,为什么还会选择 Flutter?更多是对性能的考虑。开发效率与性能体验就像天平两端,需要找到一个平衡点。RN 能够满足我们绝大部分的业务,并且热更、版本控制都很灵活。但是在复杂页面上,特别是在长列表的渲染上,还是存在一定的问题,促使我们去尝试一些新的解决方案。Flutter 官宣自绘 UI 引擎,采用原生方式做渲染,媲美原生体验。


Native 、React Native、Flutter 对比如下:


1.1 研发效率

Flutter 具有跨平台性,可以在多端上运行。同时 Dart 语言作为开发语言,本身的优势就在于它既支持 JIT,又支持 AOT,在 JIT(Just In Time)即时编译功能下,能提供 Hot Reload 功能。在开发过程中,实时地看到界面改动。生产包 AOT 编译,将代码编译成 ARM 二进制,从而既可以享受运行时又具有原生语言相近的运行效率。

1.2 扩展性好

Flutter 提供了多种不同的 Channel,用于 Dart 和平台之间相互通信。通过这些桥方法,使 Flutter 具有很好地与 Native 和 React Native 进行混合编程的能力。赋予 Flutter 一些 Native 的能力,同时也能很好地让我们在现有 Native 项目混合 Flutter 开发。 

二、 Provider 对 MVVM 架构的实践

在 Flutter 的开发过程中,特别是一些业务复杂的页面,为了代码结构清晰,模块逻辑解耦,我们一般采用的是模块化的编程思想。随之而来的问题就是,组件之间怎么相互通讯,比如变更了登录态,如何通知其他模块刷新?


推荐使用 Provider 来管理各个组件的状态,我们实践下来 ,主体布局采用 MVVM 模式是比较方便做模块化编程的。

2.1 为什么需要使用 Provider

如果状态是该组件私有的,则应该由组件自己管理;但是如果状态要跨组件共享,则该状态应该由各个组件共同的父元素来管理。对于组件私有的状态很好理解,当需要刷新当前 widget 的时候,只需要通过 setState()的方法来实现组件重绘的效果;对于跨组件共享的状态,可以使用 EventBus 来实现。


可是当事件多了的时候,难以正确管理,其次订阅者必须要显式注册状态改变回调,也必须在组件销毁的时候手动解绑以避免内存泄漏。而 Provider 就可以通过自身的原理,简单地去实现状态共享,不需要麻烦的操作。且 Provider 是官方推荐的状态管理方式,具有良好的生态环境及维护团队。

2.2 Provider 的实现原理

1)  InheritedWidget 简单介绍

Provider 是基于 InheritedWidget 的再次封装,InheritedWidget 提供了一种数据在 Widget 树中自上而下传递,共享的方式。我们在根 Widget 继承了 InheritedWidget,然后在该组件中存放一个数据 data,那么可以在任意子 Widget 中来获取该组件的数据并使用。当在任一组件中改变了共享数据 data,InheritedWidget 组件会自上而下通知所有使用过共享数据的组件并刷新组件,同时会回调 didChangeDependencies() 方法。


2)  Provider 的原理和流程



共享数据的 Model 变化后,会自动通知 ChangeNotifierProvider,ChangeNotifierProvider 内部会重新构建 InheritedWidget,而依赖该 InheritedWidget 的子 Widget 就会更新。

2.3 Provider 的使用方式

架构模式图如下:

 

1)创建业务 ViewModel,在 ViewModel 内部存放需要共享的数据。ViewModel 继承 Flutter SDK 中提供的 ChangeNotifier 类,它继承 Listenable,也实现了一个 Flutter 风格的订阅者模式,其内部实现了 addListener(),removeListener()等方法,实现对订阅者的处理。同时最好复写 dispose()和 notifyListeners()方法,防止用户在调用数据时销毁界面,而等到数据获取到以后通知界面刷新导致 Crash。


2)注册状态管理类,使用 ChangeNotifierProvider 或者 MutiProvider 将需要共享数据的 Widget 包起来,单个 NotifierProvider 时使用 ChangeNotifierProvider,多个 NotifierProvider 时使用 MutiProvider 包装,如下:



///多个NotifierProvider的时候return MultiProvider(providers: [ ChangeNotifierProvider(create: (context) => dataViewModel(mCommonAdvancedFilterRoot,query)), ChangeNotifierProvider(create: (context) => UserPreferentialViewModel(query)), ChangeNotifierProvider(create: (context) => UserPromotionViewModel())///需要调用共享数据的子Widget], child: ListResearchPageful(query));
复制代码


3)在被包起来的 Widget 中的任一子组件中获取共享数据 ViewModel,可以在 StatefulWidget 中的 builder()方法中获取,也可以使用 Builder 组件进行获取,如下:



///在StatefulWidget中的build()方法中获取ViewModelclass ListResearchPageState extends TripState<ListResearchPageful> {@override Widget build(BuildContext context) {///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在HotelListView方法下的唯一位置获取ViewModel var listViewModel = Provider.of<ListDataViewModel>(context); var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context); return MediaQuery( child: QueryListPage(widget.query, ListDataViewModel, userPromotionViewModel)); }}
复制代码



///借用Builder组件进行获取ViewModel@overrideWidget build(BuildContext context) {///使用Provider包装以后,可以在widget的任一一个子widget获取共享数据并操作数据,在这里就是可以在ListView方法下的唯一位置获取ListDataViewModel var userPromotionViewModel = Provider.of<UserPromotionViewModel>(context); return MediaQuery( child: Builder(builder: (context) {var listDataViewModel = Provider.of<ListDataViewModel>(context); return queryListPage(widget.query, listDataViewModel, userPromotionViewModel); },));}
复制代码


4)获取到 ViewModel 后,可以在子组件中直接使用 viewmodel 中的共享数据,如下:



//领券监听///此处可以直接使用viewModel调用viewmodel中的方法Event.addEventListener( "UPDATE_QUERY_RESULT_LIST",(eventName, eventData) { if (isOnPause) { listViewModel.isNeedRefresh = true; listViewModel.refreshListData(listViewModel.query); } else { listViewModel.refreshListData(listViewModel.query); }});
复制代码


2.4 Provider 的优势

1)我们的业务代码更专注数据,只要更新 Model,UI 就会自动更新,不用在状态改变后再去手动调用 setState()来显示更新页面。


2)数据改变的消息传递被屏蔽时,我们无需手动去处理状态改变事件的发布和订阅,provider 自行处理。


3)在大型复杂应用中,尤其是需要全局共享的状态非常多时,使用 Provider 将会大大简化代码逻辑,降低出错的概率,提高开发效率。

三、Flutter 性能调优

一个新技术改造完成,我们最关注的当然是性能体验有没有达到预期。那 Flutter 页面性能评判标准是什么,如何去度量,有没有可视化工具,帮我们去做一些性能调优。

3.1 Flutter 渲染原理简介

在做性能优化之前,先让我们了解一下渲染的原理。Flutter 的一切皆为 Widget。为了性能又区分了 StatefulWidgetStatelessWidget。StatefulWidget 能通过setState()来实现刷新。这样的设计方便我们去控制局部刷新,从而提高性能。


Flutter 中的控件会历 Widget -> Element -> RenderObject -> Layer 这样的变化过程,而其中 Layer 的组成由 RenderObject 中的 isRepaintBoundary 标志位决定。


当调用 setState() 时,RenderObject 就会往上的父节点去查找,根据 isRepaintBoundary 是否为 true,会决定是否从这里开始往下去触发重绘,来确定要更新哪些区域。

3.2 构建运行 Profile 模式 

Flutter 支持三种模式编译 app,Debug 模式、Release 模式和 Profile 模式。Debug 模式 采用 JIT 编译,支持 HotReload,所以在 Debug 模式下会放大性能问题。性能分析需要确保使用真机并在 profile 模式下运行,这样拿到的数据是最接近真实性能的。


1)Debug 模式对应 Dart 的 JIT 模式,可以在真机和模拟器上运行。该模式会打开所有的断言,以及所有的调试信息、服务扩展和调试辅助。此外,该模式支持有状态的 Hot reload。


2)Release 模式对应 Dart 的 AOT 模式,只能在真机上运行,不能在模拟器上运行,其编译目标为最终的线上发布。该模式会关闭所有的断言,以及尽可能多的调试信息、服务扩展和调试辅助。此外,该模式优化了应用快速启动、代码快速执行,以及二级制包大小。


3)Profile 模式,基本与 Release 模式一致,只是多了对 Profile 模式的服务扩展的支持,包括支持跟踪,以及一些为了最低限度支持所需要的依赖。该模式用于分析真实设备实际运行性能。


  • 纯 Flutter 项目构建 Profile 模式

flutter run —profile 命令是使用 Profile 模式来编译的。IDE 也是支持这个模式的,例如 Android Studio 提供了 Run > Profile… 菜单选项。

  • Flutter 与 Native 混合项目构建 Profile 模式

a. 打包 Flutter 工程 Profile 产物



// 进入flutter项目,执行build-release,并指定输出目录 tripflutterbuild-release -o /projects/ctrip_flutter/release -i info
复制代码


b. 配置 Native 项目

打包好 flutter 产物之后,需要导入到 native 项目并打包。修改 Native 项目根目录的 gradle.properties 文件。



### 开启Profile模式 TRIP_FLUTTER_PROFILE=true ### 设置profile模式下js使用的产物目录(过程1构建的 ./profile 目录)TRIP_FLUTTER_LOCAL_OUTPUTS_PATH=/projects/ctrip_flutter/profile
复制代码


c. 构建 Native 工程

直接通过 IDE 运行到手机上。

3.3 性能分析工具及方法

1)performance overlay 

平时常用的性能分析工具有 performance overlay,通过它可以直观看到当前帧的耗时。在 Profile 模式下,通过 Android Studio 看页面的 FPS,注意需要在 HotReload 连接的情况下查看。

选中 View > Tool Windows > Flutter Performance。



点击上面图中的箭头所指的按钮,就会在手机或模拟器中打开(如下图所示)。FPS 是一个动态过程,页面滑动这个值是一直变化的,最右边的是当前帧。出现红色则表示耗时超过 16.6ms,也就是发生丢帧现象,也是我们常说的页面闪动问题。performance overlay 的主要功能如下:


  • 获取 FPS 数值来衡量页面性能,方便对比 Flutter、Native 页面帧率;

  • 直观统计页面在各个机型上面的表现;

  • 定位页面的具体哪个模块有问题;



2)Dart DevTool

另一个工具是 Dart DevTool ,在 Android studio 右侧,还可以从 Flutter inspector 里面的 more action,以及 Flutter Performance 底部的入口进入。


目前 DevTools 支持的功能有如下一些:

  • 检查和分析应用程序的 UI 布局和状态。

  • 诊断应用的 UI 性能问题。

  • 检测和分析应用程序的 CPU 使用情况。

  • 分析应用程序的网络使用情况。

  • Flutter 或 Dart 应用程序的源代码级调试。

  • 调试 Flutter 或 Dart 应用程序的内存使用情况和分析内存问题。

  • 查看运行的 Flutter 或 Dart 应用程序的一般日志和诊断信息。

3.4 实战性能技巧

1)懒加载 ListView

推荐使用 ListView.builder()构建 List,这样当 Item 滚入屏幕时才创建 Item,而不是 ListView-children,这样会立刻创建所有的 Item。



///Bad code 不推荐使用children 构建ListListView(children: getItems(mList))List<Widget> getItems(List<FilterNode> mList){ List<Widget> items=new List<Widget>(); if(null!=mList){for(Node node in mList){items.add(Text("不推荐写法"));} } return items;}
///推荐写法ListView.builder(// physics: NeverScrollableScrollPhysics(),//shrinkWrap: true,itemCount:mList.length,itemBuilder: (BuildContext context, int index) {return Text("推荐使用ListView.builder()");}))
复制代码


注意,无论是 ListView 还是 GridView,只要是设置了 shrinkWrap: true 属性,都没有了懒加载的效果了。


2)控制刷新范围与次数

  • 尽量避免在滑动监听中触发 setStat()刷新视图。


如上图所示,需要滑动的过程中,显示、隐藏标题栏,并且是一个渐变的过程,遇到这种情况,一定要尽量的控制刷新的范围和频次。控制在只在头图可见的情况下面触发 setStat(),避免不必要的页面滑动触发刷新。



scrollController.addListener(() {if (scrollController.offset > scrollHeight && titleAlpha != 255) { setState(() {titleAlpha = 255; }); }
if (scrollController.offset <= 0 && titleAlpha != 0) { setState(() {titleAlpha = 0; }); }
if (scrollController.offset > 0 && scrollController.offset < scrollHeight) { setState(() {titleAlpha = scrollController.offset * 255 ~/ scrollHeight; }); }});
复制代码


  • 尽量将 setStat()放在放置于视图树的低层级,好处是 build 时影响范围极小,简称局部刷新。


如上图所示在列表中 Item 中存在大量的倒计时。一定要控制刷新倒计时只影响控件本身,并且只有可视的区域视图是在刷新的,不可见的情况下及时销毁计时器。一直刷整个列表,性能开销是恐怖的。



Widget build(BuildContext context) {return Text(timeRemaining, style: TextStyle( color: HotelColors.hotel_list_reduction_sale_color, fontSize: 10, fontWeight: FontUtil.mediumWeight));}
复制代码


3)避免组件重复创建

能复用的组件尽量复用,特别是在组件化编程,页面级的情况下面,每次刷新页面把所有的子组件都重新渲染一遍,性能开销也是很大的。尽量复用,避免不必要的视图创建。



///存放界面所有的widgets,用以缓存List<Widget> widgets = new List<Widget>();///因为头部布局是静态的不刷新,使用变量控制是否复用以前的widgetsvar refreshPage = true;///获取界面布局所有的widgetsList<Widget> getPageWidgets(ScriptDataEntity data) {if(widgets.isNotEmpty && !refreshPage) { return widgets; }}
复制代码


四、Flutter 布局技巧

4.1 Flutter 不可见组件预加载

Flutter 一些组件基本都是有懒加载的,不可见的组件是没有渲染视图的,这样滑动过去,有用到网络图片的地方,经常会先白一下。针对这种情况我们对将要加载的图片进行预加载处理,比如列表页在分页请求数据回来的时候做图片预加载。还有,下一个页面的图片,需要一进去就有图片直接显示,就可以在当前页面做图片预加载。


代码如下所示:



///对每一页加载的数据进行做图片预加载(hotelListViewModel.currentPageHotels ?? []).forEach((element) {var logo = element?.logo ?? ""; if (StringUtil.isNotEmpty(logo)) { precacheImage(NetworkImage(logo), context); }});
复制代码


当数据出来后使用 PreChcheImage()预加载处理图片链接,以保证当用户滑动图片以后不会看到图片加载白屏这种问题。

4.2 Flutter 数据预加载

为了缩短用户的加载等待时长,我们经常需要一些预加载方法。比如在前一个页面预加载下一个页面的数据,或者在长列表的分页请求时候,可以做分页预加载。比如当你滑动到第五个可见的时候,就提前把下一页的数据加载好。

 

列表页通过桥方法获取上一个页面预加载的数据,这样就能有一个直出体验,这里要考虑数据已经加载好、加载中、加载失败的情况。同时还要考虑,缓存数据的时效性,什么情况下需要删除缓存。



///请求列表数据数据void loadListData(HotelQuery query) {///在首页提前获取列表页的数据并缓存到本地,当用户进入列表页时可以直接展示数据 if (resultModel != null) { ///判断是否需要再次请求数据 _dealWithResult(resultModel); return; } else if (isPreloading) { ///通过桥方法获取首页已经缓存的数据 HotelBridge.getListCache<Map>({'queryModel':query.toJson()}) .then((resp) { final newResultModel = QueryResultModel.fromJson(resp); ///有缓存数据直接处理使用 _dealWithResult(newResultModel); }).catchError((error) { ///没有数据采取请求列表页的数据 getHotelList(); }); }}
复制代码


4.3 布局自适应高度

如果需要根据内容填充的高度来自适应左边图片的高度,目前 Flutter 并不支持该功能,我们可以借助 IntrinsicHeight 组件来完美地解决该问题。InstrinsicHeight 可以让同一行的子 widget 都是相同的高度。


  • 可以将需要自适应高度的 Widget 使用 ConstrainedBox 进行包裹,并设置最低高度;

  • 将图片作为 Container 的背景图片,使用 DecorationImage 进行修饰当前的 Container;

  • 将图片的填充方式设置为 BoxFit.Cover 或者 fillHeight 即可;

五、Flutter 中常见问题分析及解决方案

5.1 设置 State 引起的问题

1)错误展示信息:

NoSuchMethodError: The method  markNeedsBuild  was called on null。


2)错误分析

这个错误一般情况下出现在异步任务,比如一些界面请求网络数据,异步获取本地数据等,需要根据数据的状态来改变刷新 Widget State。异步任务结束在页面被销毁之后,没有检查 State 是否还是 mounted 状态,继续 setState()就会出现这个错误。错误代码如下所示:



///从服务器端获取当前活动终止时间,当服务器返回以后,会通知刷新这里///如果用户在数据返回之前销毁该界面,等数据回来以后刷新界面就会报错final endTime = roomDetailItemEntity?.tonightEndTime ?? '';int endTimeOfNum = 0;if (endTime.isNotEmpty) { try { endTimeOfNum = int.parse(endTime) ?? 0; if(endTimeOfNum - Util.currentTimeMillis() > 0) { this.setState(() { _showCountDown = true; }); } } catch (e) {}}
复制代码


3)处理办法

在调用 setState()方法之前检查是否 mounted,mounted 是一个标示当前 Widget 树是否已经被渲染的状态值。所以 mounted 检查很重要,只要涉及到异步还有各种回调的时候,都不能忘记检查该值。如下:



final endTime = roomDetailItemEntity?.tonightEndTime ?? '';int endTimeOfNum = 0;if (endTime.isNotEmpty) { try { endTimeOfNum = int.parse(endTime) ?? 0; if(endTimeOfNum - Util.currentTimeMillis() > 0) { if(mounted) { this.setState(() { _showCountDown = true; });}}} catch (e) {}}
复制代码


5.2 使用 MediaQuery.of()动态获取屏幕属性的问题

1)错误展示信息

BoxConstraints has a negative minimum width;


2)错误分析

这种情况一般出现在需要获取屏幕宽度,根据屏幕宽度减去另外一个组件的宽度,用来设置另外一个组件的宽度导致,在一些计算速度比较低的手机,可能获取到的屏幕宽度为 0,这样就会导致你的组件的宽度为负数,报出错误异常。如下所示:



Widget hotelListDesContent(BuildContext context) {return Container(///此处想实现左边是图片,右边是相关信息的布局,如果MediaQuery.on(context).size.width获取为0时,就会报出异常 width: MediaQuery.of(context).size.width - Dimens.image_width80, ///右边内容 child: Stack(children: [ Container(child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[ hotelListDesName(), englishName(), hotelListRemarkContent(),],),), ///左边图片 Positioned(child: fullRoomItem()), ],));}
复制代码


3)处理方式

尽量使用 Expand,Flexible,Flex,Wrap,Stack 等组件配合 Column,Row 进行动态布局设置组件的宽高等。如下所示:



Widget hotelListDesContent(BuildContext context) {return Expanded( flex: 1,child: Stack( children: [Container( child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[ hotelListDesName(), englishName(), hotelListRemarkContent(),],),), Positioned(child: fullRoomItem()), ], ));}
复制代码

5.3 使用 Provider 时,未判断界面状态通知界面刷新的问题

1)错误信息展示

Null check operator used on a null value;


2)错误分析

一般情况下出现这种问题是由于界面销毁后,继续调用 notifyListeners()方法通知界面刷新引起的 bug。当用户打开一个界面,我们发送了 API 请求,此时用户销毁了界面,我们并未监听,等到数据返回以后,强行通知界面刷新,导致 Crash。如下所示:



HotelServices.getTyHotelRoomPrice(params, ApiCallBack(onSuccess: (Object obj) {this.roomPriceEntity = HotelRoomPriceEntity.fromJson(obj); this.resultCode = 1; ///如果在数据返回是,用户已经关闭当前界面,此处通知刷新界面会导致crash notifyListeners();}, onError: (int code, String message) {} notifyListeners()}));
复制代码


3)处理方式

正常情况下,我们会写一个基类继承 ChangeNotifier,在内部重新复写 dispose()方法,同时重新封装方法通知刷新界面,在每次需要通知刷新界面的时候判断当前界面是否已经被销毁。如下所示:



import 'package:flutter/cupertino.dart';/// ViewModel基类class HotelViewModel extends ChangeNotifier{ bool _disposed = false; @override void dispose() { _disposed = true; super.dispose(); } void hotelNotifyListeners() { if(!_disposed){ notifyListeners(); } }}
复制代码


5.4 使用 Text.rich 时导致的问题

1)错误信息展示:UnimplementedError

2)错误分析

出现这个问题的原因在于使用 Text.rich 来展示多个 Span 组件时,如果设置了最大行数,当组件超过最大行数,有别的组件未成功展示时,再次点击当前 widget,使它接受时间,就会导致 crash,用户的感知为操作无响应,其实已经 crash。如下所示:



///母房型名称, 当前我们Text最大显示两行,当大于两行是,出现...,可是此时第二个组件无处显示,当用户点击就会crashRow(children: <Widget>[Expanded(child: Text.rich(TextSpan( children: [TextSpan( text: itemRoomEntity.baseName ??""), WidgetSpan( child: Container( padding: EdgeInsets.only(bottom: Dimens.gap_dp3), child: Icon(HotelIcons.show_more), ), ), ]), maxLines: 2, overflow: TextOverflow.ellipsis), ),], crossAxisAlignment: CrossAxisAlignment.center,),
复制代码


3)解决办法

使用 Flexible 代替 Expanded,直接使用 Text 即可,区别在于 Flexible 不会自动填充整个剩余宽度,如下所示:



///母房型名称Row( mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[Flexible(child: Text((childCount > 1)?itemRoomEntity.baseName ?? "":"", maxLines: 2, overflow: TextOverflow.ellipsis,),),Container(child: Icon(childCount ==1?HotelIcons.show_more:null), margin: EdgeInsets.only(top: Dimens.gap_dp2),), ], crossAxisAlignment: CrossAxisAlignment.center,)
复制代码

六、总结与展望

总结一下,本文我们介绍了选择 Flutter 的初衷,Provider 状态管理的实际使用,建议 Flutter 主体的构架采用 MVVM 模式,还介绍了一些 Flutter 性能检测、量化工具和一些性能优化点供大家参考。收集了 Flutter 开发过程中常见并且大量发生的问题,并提供了相应的解决方案。


在复杂业务和长列表上面体验,确实 Flutter 优于 React Native。但是 React Native 也有它的优势,比如灵活的版本迭代。没有最好的跨平台方案,只有最合适业务的。目前来说,Flutter 还处于早期阶段,随着 Flutter2.0 的重大升级,其跨平台能力、性能、生态系统将会蓬勃发展,还是很值得尝试的。后续我们也将有更多的业务接入 Flutter。


【参考文档】

[1] Flutter 开发文档

https://flutter.cn/docs/perf/metrics

[2] Tripflutter 开发文档

http://pages.release.ctripcorp.com/trip-flutter/docs/

[3] 咸鱼技术

https://developer.aliyun.com/group/idlefish?spm=a2c6h.12873639.0.0.2c9618dd4mdBAQ#/?_k=khoksz

[4] Flutter 实战

https://flutter.cn/docs/perf/metrics

[5] 美团技术

https://tech.meituan.com/


作者简介:

本文为联合撰稿,作者为携程火车票 Flutter 团队,致力于跨端快速、高性能开发。


本文转载自:携程技术中心(ID:ctriptech)

原文链接:干货 | 携程火车票Flutter最佳实践

2021-05-13 13:003219

评论

发布
暂无评论
发现更多内容

架构师训练营第一期——第一周总结

tao

课程总结

架构师训练营第一周学习感悟

吴传禹

极客大学架构师训练营

UML学习笔记

胡家鹏

学习 极客大学架构师训练营

重新理解“软件工程”

Bruce Talk

软件工程

Architecture Phase I-Week1 Homework UML Diagram

phylony-lu

极客大学架构师训练营

架构师训练营Week1作业1

lucian

极客大学架构师训练营

架构师训练营第 1 期第 1 周作业

好吃不贵

极客大学架构师训练营

大作业2

zongbin

架构师训练营第 1 期 - 第一周学习总结

Anyou Liu

第一周作业

第一周学习总结

训练营第一周总结

大脸猫

第一周作业1——食堂就餐卡系统设计

dll

极客大学架构师训练营

第一周学习总结

熊桂平

学习 极客大学架构师训练营

大作业二

week01系统设计

xxx

架构师训练营 01 周 -- 学习总结

骏马

极客大学架构师训练营

UML练习1

文智

极客大学架构师训练营

架构师训练营:第一周学习总结

xs-geek

第一周 架构方法 学习笔记

应鹏

学习 极客大学架构师训练营

架构师训练营第1期第1周学习总结

好吃不贵

极客大学架构师训练营

架构师训练营:第一周作业

xs-geek

第一周作业 2——设计文档总结

dll

极客大学架构师训练营

架构师训练营第一期——第一周作业

tao

UML

架构师一期二班-吴水金-第一课作业

吴水金

架构师技能

Dart Isolate双向通讯

Daniel

架构师训练营学习总结——第一周

文智

极客大学架构师训练营

作业二-软件架构的简单思考

泡泡

架构师训练营第一周作业

吴传禹

极客大学架构师训练营

第一周学习总结

Geek_ac4080

架构师训练营第1期 - 第一周课后练习

Anyou Liu

干货 | 携程火车票Flutter最佳实践_大前端_携程技术_InfoQ精选文章