内容简介:在去年12月份的Flutter Live发布会发布Flutter 1.0时,介绍了一款 HistoryOfEverything App —— 万物起源,展示了Flutter开发的灵活和渲染的高效,最近这款App已经之前关于Flutter App设计模式,Widget组织的争论一直不绝于耳,此款App作为Google团队的作品,我们或许可以从中学习到,Google对于Flutter App代码组织的思路。这个App很有意思,讲的是人类起源的时间线,从大爆炸时期一直到互联网诞生。关于App的组成,主要分为三个页
1. 介绍
在去年12月份的Flutter Live发布会发布Flutter 1.0时,介绍了一款 HistoryOfEverything App —— 万物起源,展示了Flutter开发的灵活和渲染的高效,最近这款App已经 开源 。
之前关于Flutter App设计模式,Widget组织的争论一直不绝于耳,此款App作为Google团队的作品,我们或许可以从中学习到,Google对于Flutter App代码组织的思路。
这个App很有意思,讲的是人类起源的时间线,从大爆炸时期一直到互联网诞生。关于App的组成,主要分为三个页面:
首页菜单页 | 时间线页 | 文章页 |
---|---|---|
这3个页面里,有列表的展示,有自定义UI,有动画,有输入框,可以研究的内容有很多
即使写成系列文章,也很难囊括所有细节。同时因为刚刚接触此app的源码,难免有描述错误指之处,还望指出
2. 文件组织
内容较多,部分删减:
├── README.md ├── android │ ├── ... ├── assets │ ├── Agricultural_evolution │ │ ├── Agricultural_evolution.nma │ │ └── Agricultural_evolution.png │ ├── Alan_Turing │ │ ├── Alan_Turing.nma │ │ └── Alan_Turing.png │ ├── Amelia_Earhart │ │ └── Amelia_Earhart.flr │ ├── Animals.flr │ ├── Apes │ │ ├── Apes.nma │ │ ├── Apes0.png │ │ └── Apes1.png │ ├── App_Icons │ │ └── ... │ ├── Articles │ │ ├── agricultural_revolution.txt │ │ └── ... │ ├── Big_Bang │ │ └── Big_Bang.flr │ ├── BlackPlague │ │ ├── BlackPlague.nma │ │ └── BlackPlague.png │ ├── Broken\ Heart.flr │ ├── Cells │ │ ├── Cells.nma │ │ └── Cells.png │ ├── ... │ ├── flutter_logo.png │ ├── fonts │ │ ├── Roboto-Medium.ttf │ │ └── Roboto-Regular.ttf │ ├── heart_icon.png │ ├── heart_outline.png │ ├── heart_toolbar.flr │ ├── humans.flr │ ├── info_icon.png │ ├── little-dino.jpg │ ├── menu.json │ ├── right_arrow.png │ ├── search_icon.png │ ├── share_icon.png │ ├── sloth.jpg │ ├── timeline.json │ └── twoDimensions_logo.png ├── full_quality │ └── ... ├── lib │ ├── article │ │ ├── article_widget.dart │ │ ├── controllers │ │ │ ├── amelia_controller.dart │ │ │ ├── flare_interaction_controller.dart │ │ │ ├── newton_controller.dart │ │ │ └── nima_interaction_controller.dart │ │ └── timeline_entry_widget.dart │ ├── bloc_provider.dart │ ├── blocs │ │ └── favorites_bloc.dart │ ├── colors.dart │ ├── main.dart │ ├── main_menu │ │ ├── about_page.dart │ │ ├── collapsible.dart │ │ ├── favorites_page.dart │ │ ├── main_menu.dart │ │ ├── main_menu_section.dart │ │ ├── menu_data.dart │ │ ├── menu_vignette.dart │ │ ├── search_widget.dart │ │ ├── thumbnail.dart │ │ └── thumbnail_detail_widget.dart │ ├── search_manager.dart │ └── timeline │ ├── ticks.dart │ ├── timeline.dart │ ├── timeline_entry.dart │ ├── timeline_render_widget.dart │ ├── timeline_utils.dart │ └── timeline_widget.dart ├── pubspec.lock ├── pubspec.yaml └── test └── widget_test.dart 复制代码
可以看出,整个App需要关心的主要是assets和lib文件夹里的内容
2.1 assets
资源文件夹里,除了图标,logo,大部分都是App里关于内容的各种图片或者动画,这些文件由timeline.json和menu.json管理。
App的代码部分,并不关心具体显示什么内容,而是通过timeline.json和menu.json获取需要显示的列表以及具体文章,所以即使列表再长,都和app代码无关。
2.2 lib
这个app的逻辑并不复杂,所以代码部分并没有使用很复杂的架构,而是通过显示内容的不同,分成了几个文件夹,对应了显示的几个页面
article bloc相关 main.dart main_menu timeline
我们可以看到,代码的组织基本上与页面的显示一致,并没有将页面级widegt放到一个目录,而小视图级widget放到另一个目录这种开发起来很麻烦的组织方式
同时我们也可以看到,UI相关和逻辑相关的代码,没有放在一起,例如搜索框和搜索管理器,放在了不同位置。
3. 代码
3.1 状态管理
flutter应该使用怎样的状态管理,一直存在争论,这个app使用了简化版的bloc,之所以说是简化版,是因为没有使用bloc来实现数据驱动UI更新,原因也很简单 —— 这个App的业务不需要~
import "package:flutter/widgets.dart"; import "package:timeline/blocs/favorites_bloc.dart"; import 'package:timeline/search_manager.dart'; import 'package:timeline/timeline/timeline.dart'; import 'package:timeline/timeline/timeline_entry.dart'; /// This [InheritedWidget] wraps the whole app, and provides access /// to the user's favorites through the [FavoritesBloc] /// and the [Timeline] object. class BlocProvider extends InheritedWidget { final FavoritesBloc favoritesBloc; final Timeline timeline; /// This widget is initialized when the app boots up, and thus loads the resources. /// The timeline.json file contains all the entries' data. /// Once those entries have been loaded, load also all the favorites. /// Lastly use the entries' references to load a local dictionary for the [SearchManager]. BlocProvider( {Key key, FavoritesBloc fb, Timeline t, @required Widget child, TargetPlatform platform = TargetPlatform.iOS}) : timeline = t ?? Timeline(platform), favoritesBloc = fb ?? FavoritesBloc(), super(key: key, child: child) { timeline .loadFromBundle("assets/timeline.json") .then((List<TimelineEntry> entries) { timeline.setViewport( start: entries.first.start * 2.0, end: entries.first.start, animate: true); /// Advance the timeline to its starting position. timeline.advance(0.0, false); /// All the entries are loaded, we can fill in the [favoritesBloc]... favoritesBloc.init(entries); /// ...and initialize the [SearchManager]. SearchManager.init(entries); }); } @override updateShouldNotify(InheritedWidget oldWidget) => true; /// static accessor for the [FavoritesBloc]. /// e.g. [ArticleWidget] retrieves the favorites information using this static getter. static FavoritesBloc favorites(BuildContext context) { BlocProvider bp = (context.inheritFromWidgetOfExactType(BlocProvider) as BlocProvider); FavoritesBloc bloc = bp?.favoritesBloc; return bloc; } /// static accessor for the [Timeline]. /// e.g. [_MainMenuWidgetState.navigateToTimeline] uses this static getter to access build the [TimelineWidget]. static Timeline getTimeline(BuildContext context) { BlocProvider bp = (context.inheritFromWidgetOfExactType(BlocProvider) as BlocProvider); Timeline bloc = bp?.timeline; return bloc; } } 复制代码
BlocProvider是放在根节点中,供子节点获取bloc数据的容器,使用InheritedWidget作为其父类是方便子节点使用 context.inheritFromWidgetOfExactType()
获取到BlocProvider单例,也就是通过代码中的类方法 BlocProvider.getTimeline(context)
,即可获取到favoritesBloc或者timeline等属性
一般BlocProvider里,都会有一个Stream实例或者RxDart相关的属性,然后子节点监听它。当数据发生改变的时候,子节点就可以自动刷新。但是因为这个App,并不需要这个场景,所以这里也就没有这样的属性了。
BlocProvider存在业务相关的几个属性:
SearchManager.init(entries);
3.2 main.dart
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:timeline/bloc_provider.dart'; import 'package:timeline/colors.dart'; import 'package:timeline/main_menu/main_menu.dart'; /// The app is wrapped by a [BlocProvider]. This allows the child widgets /// to access other components throughout the hierarchy without the need /// to pass those references around. class TimelineApp extends StatelessWidget { @override Widget build(BuildContext context) { SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); return BlocProvider( child: MaterialApp( title: 'History & Future of Everything', theme: ThemeData( backgroundColor: background, scaffoldBackgroundColor: background), home: MenuPage(), ), platform: Theme.of(context).platform, ); } } class MenuPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold(appBar: null, body: MainMenuWidget()); } } void main() => runApp(TimelineApp()); 复制代码
应用入口文件main.dart很简单,设置了屏幕朝向,bloc容器,主题颜色以及首页显示MenuPage
3.3 MainMenuWidget(首页菜单)
首页菜单有4部分:
- 顶部logo
- 搜索框
- 历史入口sections(MenuSection) 或者 搜索结果
- 底下的三行按钮:收藏、分享、关于
这4部分通过SingleChildScrollView内嵌Column组织,当没有在搜索的时候,显示历史阶段(MenuSection)和底部按钮;当正在搜索的时候,顶部logo隐藏,MenuSection和底部按钮隐藏,输入框下面显示搜索结果列表
return WillPopScope( onWillPop: _popSearch, child: Container( color: background, child: Padding( padding: EdgeInsets.only(top: devicePadding.top), child: SingleChildScrollView( padding: EdgeInsets.only(top: 20.0, left: 20, right: 20, bottom: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Collapsible( isCollapsed: _isSearching, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ Padding( padding: const EdgeInsets.only( top: 20.0, bottom: 12.0), child: Opacity( opacity: 0.85, child: Image.asset( "assets/twoDimensions_logo.png", height: 10.0))), Text("The History of Everything", textAlign: TextAlign.left, style: TextStyle( color: darkText.withOpacity( darkText.opacity * 0.75), fontSize: 34.0, fontFamily: "RobotoMedium")) ])), Padding( padding: EdgeInsets.only(top: 22.0), child: SearchWidget( _searchFocusNode, _searchTextController)) ] + tail)), )), ); } 复制代码
另外从代码里可以看到,使用WillPopScope来获取搜索页面的退出事件(_popSearch())
3.3.1 顶部logo
顶部logo很简单,一个Image,一个Text。
有意思的是,顶部log在搜索框在输入的时候,会隐藏。这个功能,是使用Collapsible widget来实现的,它是一个动画widget,其属性isCollapsed控制是否隐藏,当isCollapsed值变化的时候,就会通过200ms的补间动画,控制SizeTransition,来改变顶部logo的大小。而isCollapsed属性,由搜索框是否正在输入决定
3.3.2 搜索框
搜索框是封装好的SearchWidget,其内部就是TextField外加一些样式,首页为它设定了_searchFocusNode和 _searchTextController,前者用于监听是否在焦点(是否正在输入),后者用于监听输入的内容。
当输入内容改变的时候,会调用updateSearch方法:
updateSearch() { cancelSearch(); if (!_isSearching) { setState(() { _searchResults = List<TimelineEntry>(); }); return; } String txt = _searchTextController.text.trim(); /// Perform search. /// /// A [Timer] is used to prevent unnecessary searches while the user is typing. _searchTimer = Timer(Duration(milliseconds: txt.isEmpty ? 0 : 350), () { Set<TimelineEntry> res = SearchManager.init().performSearch(txt); setState(() { _searchResults = res.toList(); }); }); } cancelSearch() { if (_searchTimer != null && _searchTimer.isActive) { /// Remove old timer. _searchTimer.cancel(); _searchTimer = null; } } 复制代码
updateSearch方法先取消之前的搜索延迟定时器,再创建350ms的新定时器,然后再使用SearchManager单例获取搜索结果。
通过350ms定时器,以及方法第一行的cancelSearch,可以实现消抖(debounce)功能,也就是当用户不停输入文字的时候,不执行真正的搜索。这样做可以在有效减少不必要搜索的同时,依然保证快速响应,提高性能。
3.3.3 MenuSection
MenuSection是万物起源的入口项,我们叫它历史阶段,从它进入某个时间线
3.3.3.1 数据源
总的数据源模型是MenuData类,里面存着3个历史阶段,使用MenuSectionData表示,而每个历史阶段,又有很多历史节点,使用MenuItemData表示。
class MenuData { List<MenuSectionData> sections = []; Future<bool> loadFromBundle(String filename) async { //... } } 复制代码
class MenuSectionData { String label; Color textColor; Color backgroundColor; String assetId; List<MenuItemData> items = List<MenuItemData>(); } 复制代码
MenuSectionData不光表示数据,也表示样式:文字颜色,背景颜色,这些都是存在menu.json里的,所以每个section都有不同的颜色,而UI代码是不需要关心具体什么颜色的
class MenuItemData { String label; double start; double end; bool pad = false; double padTop = 0.0; double padBottom = 0.0; } 复制代码
MenuItemData更是有更多的样式设置,不过首页并不关心MenuItemData的样式,等介绍时间线时我们再扩展来说
在首页的initState里,通过MenuData实例的loadFromBundle方法,在menu.json中加载数据,于是历史起源的首页菜单的数据模型就被填充好了。
_menu.loadFromBundle("assets/menu.json").then((bool success) { if (success) setState(() {}); // Load the menu. }); 复制代码
3.3.3.2 UI
当没有在搜索的时候,历史阶段列表存放在MainMenu代码的tail数组里,每个历史阶段入口是一个Stateful的MenuSection widget,它也支持动画,当点击历史阶段时,可以显示其历史节点:
@override Widget build(BuildContext context) { return GestureDetector( onTap: _toggleExpand, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(10.0), color: widget.backgroundColor), child: ClipRRect( borderRadius: BorderRadius.circular(10.0), child: Stack( children: <Widget>[ Positioned.fill( left: 0, top: 0, child: MenuVignette( gradientColor: widget.backgroundColor, isActive: widget.isActive, assetId: widget.assetId)), Column(children: <Widget>[ Container( height: 150.0, alignment: Alignment.bottomCenter, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( height: 21.0, width: 21.0, margin: EdgeInsets.all(18.0), /// Another [FlareActor] widget that /// you can experiment with here: https://www.2dimensions.com/a/pollux/files/flare/expandcollapse/preview child: flare.FlareActor( "assets/ExpandCollapse.flr", color: widget.accentColor, animation: _isExpanded ? "Collapse" : "Expand")), Text( widget.title, style: TextStyle( fontSize: 20.0, fontFamily: "RobotoMedium", color: widget.accentColor), ) ], )), SizeTransition( axisAlignment: 0.0, axis: Axis.vertical, sizeFactor: _sizeAnimation, child: Container( child: Padding( padding: EdgeInsets.only( left: 56.0, right: 20.0, top: 10.0), child: Column( children: widget.menuOptions.map((item) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => widget.navigateTo(item), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Container( margin: EdgeInsets.only( bottom: 20.0), child: Text( item.label, style: TextStyle( color: widget .accentColor, fontSize: 20.0, fontFamily: "RobotoMedium"), ))), Container( alignment: Alignment.center, child: Image.asset( "assets/right_arrow.png", color: widget.accentColor, height: 22.0, width: 22.0)) ])); }).toList())))) ]), ], )))); } 复制代码
这里有几个技术细节:
- 使用GestureDetector判断点击
- 使用Container和ClipRRect切圆角
- 使用Stack来叠放背景动画(MenuVignette)以及前景的文字,Stack里的位置,可以通过Positioned来控制
- MenuVignette是一个LeafRenderObjectWidget,可以制作绘图动画,上图的鱼(像叶子的绿色的鱼)动画,就是画出来的。关于这个技术细节,就足以写一篇文章了,所以暂且不深入
- 上图中的加减号,也具有动画,是使用发布会上介绍的flare制作的
- 视图的展开和关闭,通过SizeTransition控制,SizeTransition配合Animation在这个app中出现了很多次
- 历史节点也使用GestureDetector判断点击,同时为了防止和父widget的点击冲突,加入了behavior
3.3.4 搜索结果列表
单个搜索项的代码是这样的
RepaintBoundary( child: ThumbnailDetailWidget(_searchResults[i],hasDivider: i != 0, tapSearchResult: _tapSearchResult) ) 复制代码
RepaintBoundary根据文档来看,是用于提高渲染性能的,具体还没有研究,就不扩展来说了,ThumbnailDetailWidget是一个有缩略图的部件,这里的缩略图也很厉害,是通过读取nma文件获取的。具体在讲解时间线时再说。
3.3.5 收藏、分享、关于
这三个按钮,就是普通FlatButton
- 点击收藏,会进入收藏页面。
- 点击分享,会控制Share类,调用plugin,也就是通过MethodChannel调用原生代码显示分享
- 点击关于,就是个静态的关于页面。
3.3.6 收藏页面
收藏页面的显示,和搜索列表类似,不过涉及到了bloc
进入收藏页,它需要知道用户收藏了哪些历史节点,于是通过如下代码获取bloc容器里的数据
List<TimelineEntry> entries = BlocProvider.favorites(context).favorites; 复制代码
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 阿里 Blink 正式开源,重要优化点解读
- 阿里 Blink 正式开源,重要优化点解读
- 解读Bento Android框架的开源细节
- 匠心独运解读Mybatis源码,纯手工打造开源框架
- 从技术上解读大数据的应用现状和开源未来
- 解读 2018:13 家开源框架谁能统一流计算?
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
JS 压缩/解压工具
在线压缩/解压 JS 代码
XML 在线格式化
在线 XML 格式化压缩工具