在去年12月份的Flutter Live发布会发布Flutter 1.0时,介绍了一款 HistoryOfEverything App —— 万物起源,展现了Flutter开发的灵活和渲染的高效,最近这款App已经开源。android
以前关于Flutter App设计模式,Widget组织的争论一直不绝于耳,此款App做为Google团队的做品,咱们或许能够从中学习到,Google对于Flutter App代码组织的思路。git
这个App颇有意思,讲的是人类起源的时间线,从大爆炸时期一直到互联网诞生。关于App的组成,主要分为三个页面:github
首页菜单页 | 时间线页 | 文章页 |
---|---|---|
![]() |
![]() |
![]() |
这3个页面里,有列表的展现,有自定义UI,有动画,有输入框,能够研究的内容有不少json
即便写成系列文章,也很难囊括全部细节。同时由于刚刚接触此app的源码,不免有描述错误指之处,还望指出设计模式
内容较多,部分删减:数组
├── 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文件夹里的内容性能优化
资源文件夹里,除了图标,logo,大部分都是App里关于内容的各类图片或者动画,这些文件由timeline.json和menu.json管理。bash
App的代码部分,并不关心具体显示什么内容,而是经过timeline.json和menu.json获取须要显示的列表以及具体文章,因此即便列表再长,都和app代码无关。markdown
这个app的逻辑并不复杂,因此代码部分并无使用很复杂的架构,而是经过显示内容的不一样,分红了几个文件夹,对应了显示的几个页面架构
article
: 文章页的代码bloc相关
: 状态管理方面的代码main.dart
: app入口main_menu
: 首页菜单timeline
: 时间线咱们能够看到,代码的组织基本上与页面的显示一致,并无将页面级widegt放到一个目录,而小视图级widget放到另外一个目录这种开发起来很麻烦的组织方式
同时咱们也能够看到,UI相关和逻辑相关的代码,没有放在一块儿,例如搜索框和搜索管理器,放在了不一样位置。
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);
就能够了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
首页菜单有4部分:
这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())
顶部logo很简单,一个Image,一个Text。
有意思的是,顶部log在搜索框在输入的时候,会隐藏。这个功能,是使用Collapsible widget来实现的,它是一个动画widget,其属性isCollapsed控制是否隐藏,当isCollapsed值变化的时候,就会经过200ms的补间动画,控制SizeTransition,来改变顶部logo的大小。而isCollapsed属性,由搜索框是否正在输入决定
搜索框是封装好的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)功能,也就是当用户不停输入文字的时候,不执行真正的搜索。这样作能够在有效减小没必要要搜索的同时,依然保证快速响应,提升性能。
MenuSection是万物起源的入口项,咱们叫它历史阶段,从它进入某个时间线
总的数据源模型是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.
});
复制代码
当没有在搜索的时候,历史阶段列表存放在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()))))
]),
],
))));
}
复制代码
这里有几个技术细节:
单个搜索项的代码是这样的
RepaintBoundary(
child: ThumbnailDetailWidget(_searchResults[i],hasDivider: i != 0, tapSearchResult: _tapSearchResult)
)
复制代码
RepaintBoundary根据文档来看,是用于提升渲染性能的,具体尚未研究,就不扩展来讲了,ThumbnailDetailWidget是一个有缩略图的部件,这里的缩略图也很厉害,是经过读取nma文件获取的。具体在讲解时间线时再说。
这三个按钮,就是普通FlatButton
收藏页面的显示,和搜索列表相似,不过涉及到了bloc
进入收藏页,它须要知道用户收藏了哪些历史节点,因而经过以下代码获取bloc容器里的数据
List<TimelineEntry> entries = BlocProvider.favorites(context).favorites;
复制代码
经过阅读万物起源App,遇到了不少以前没接触过的widget,也看到了一些状态管理和性能优化的代码,而首页只是其中比较简单的部分,更复杂的内容都在timeline里,下一篇将会着重分析timeline的内容。
同时此文中省略了一下知识点的分析,之后有时间也会继续分析,