[HistoryOfEverything]Google開源Flutter App解讀(一)—— 項目結構和首頁

1. 介紹

在去年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的源碼,不免有描述錯誤指之處,還望指出設計模式

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管理。bash

App的代碼部分,並不關心具體顯示什麼內容,而是經過timeline.json和menu.json獲取須要顯示的列表以及具體文章,因此即便列表再長,都和app代碼無關。markdown

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的內容。

同時此文中省略了一下知識點的分析,之後有時間也會繼續分析,

相關文章
相關標籤/搜索