[HistoryOfEverything]Google开源Flutter App解读(一)—— 项目结构和首页

栏目: 软件资讯 · 发布时间: 5年前

内容简介:在去年12月份的Flutter Live发布会发布Flutter 1.0时,介绍了一款 HistoryOfEverything App —— 万物起源,展示了Flutter开发的灵活和渲染的高效,最近这款App已经之前关于Flutter App设计模式,Widget组织的争论一直不绝于耳,此款App作为Google团队的作品,我们或许可以从中学习到,Google对于Flutter App代码组织的思路。这个App很有意思,讲的是人类起源的时间线,从大爆炸时期一直到互联网诞生。关于App的组成,主要分为三个页
[HistoryOfEverything]Google开源Flutter App解读(一)—— 项目结构和首页

1. 介绍

在去年12月份的Flutter Live发布会发布Flutter 1.0时,介绍了一款 HistoryOfEverything App —— 万物起源,展示了Flutter开发的灵活和渲染的高效,最近这款App已经 开源

之前关于Flutter App设计模式,Widget组织的争论一直不绝于耳,此款App作为Google团队的作品,我们或许可以从中学习到,Google对于Flutter App代码组织的思路。

这个App很有意思,讲的是人类起源的时间线,从大爆炸时期一直到互联网诞生。关于App的组成,主要分为三个页面:

首页菜单页 时间线页 文章页
[HistoryOfEverything]Google开源Flutter App解读(一)—— 项目结构和首页
[HistoryOfEverything]Google开源Flutter App解读(一)—— 项目结构和首页
[HistoryOfEverything]Google开源Flutter 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是万物起源的入口项,我们叫它历史阶段,从它进入某个时间线

[HistoryOfEverything]Google开源Flutter App解读(一)—— 项目结构和首页

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,它也支持动画,当点击历史阶段时,可以显示其历史节点:

[HistoryOfEverything]Google开源Flutter App解读(一)—— 项目结构和首页
@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 搜索结果列表

[HistoryOfEverything]Google开源Flutter App解读(一)—— 项目结构和首页

单个搜索项的代码是这样的

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;
复制代码

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

A Philosophy of Software Design

A Philosophy of Software Design

John Ousterhout / Yaknyam Press / 2018-4-6 / GBP 14.21

This book addresses the topic of software design: how to decompose complex software systems into modules (such as classes and methods) that can be implemented relatively independently. The book first ......一起来看看 《A Philosophy of Software Design》 这本书的介绍吧!

随机密码生成器
随机密码生成器

多种字符组合密码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具