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
: app入口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存在業務相關的幾個屬性:
- Timeline:時間線相關的業務
- FavoritesBloc:收藏相關的bloc資料
- SearchManager:搜尋管理器,它是個單例,所以並不需要以屬性的方式存在,只需要呼叫
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;
複製程式碼
4. 總結
通過閱讀萬物起源App,遇到了很多之前沒接觸過的widget,也看到了一些狀態管理和效能優化的程式碼,而首頁只是其中比較簡單的部分,更復雜的內容都在timeline裡,下一篇將會著重分析timeline的內容。
同時此文中省略了一下知識點的分析,以後有時間也會繼續分析,