如今这个时代,与前端或移动相关的新框架层出不穷。所有从事Web开发的人都应该熟悉各种目不暇接的新方法以及针对复杂问题的轻量级解决方案。我们不再因为没有现成的技术而烦恼,相反我们常常因为不知道该选哪种技术而感到头疼。
最近,我偶然间发现了Flutter,于是兴致勃勃地决心一试,看看这种技术是否能够成为强有力的竞争对手,或者甚至作为一种解决方案,让我在进退两难的困境中看到新的希望。
Flutter简介
Flutter是Google出品的移动应用UI SDK。它使用了Dart VM(也是Google出品,专门针对UI进行了优化),帮助我们开发移动设备和台式设备。Dart本身也可用于Web开发,甚至可以与我们非常熟悉的Angular框架配合使用。
Flutter可以通过AoT(提前)编译方式编译成原生机器代码,目的是让应用的运行速度达到最高,同时又不会产生太多开销。
对于开发人员,Flutter提供了JIT(即时)编译器和热重载功能,我们能够在不丢失现有状态的情况下修改应用程序,这点非常实用,因为在复杂的功能中修改一个隐藏得很深的UI非常麻烦,所有曾经从事UI工作的人都清楚每次都要千辛万苦才能找到要修改的UI。
当然,SDK重要的部分是控制库。由于Flutter的定位是Android和iOS开发,因此我们可以选择使用Material(Google Android)或Cupertino(Apple iOS)控件集。这是否意味着当应用程序部署到Android或iOS手机上时会切换外观,让两者看起来都很像是原生的?并非如此。你可以随便使用哪个库,也可以同时使用两者,但是并没有统一的切换UI的功能。当然,你可以手动实现,我并不是说不建议这种做法。但请记住这种功能需要管理两组不同的布局控件,很快就会乱成一锅粥,因此应当谨慎地采用这种方法。
在默认情况下,Flutter中的所有内容都是小部件(widget)。如果你有使用Angular 2+的经验,那么可以认为wdiget就是更强大的组件,而且应该是一个非常熟悉的概念。在默认情况下,这种基本类型包含一个定义外观的build方法,而且还可以根据传递的参数和上下文自定义外观。小部件可以是无状态的也可以是有状态的。无状态小部件大部分都是静态的,不会在生命周期中发生任何明显的变化。另一方面,有状态的小部件在每次触发时都会被构建(例如,当监视的变量发生变化、用户执行单击等特定的操作)。
Flutter是响应式编程(类似于React),这意味着没有默认的持续刷新循环(像Angular那样)。取而代之的是,一旦执行了关键操作,UI或其一部分(比如其中一个小部件)就会根据状态的变化重新绘制。
我曾提过,Dart为处理UI进行了大幅优化以,但这意味着什么?在Flutter中,经过优化后的Dart支持丰富的集合处理、基于隔离的并发以及future的async-await。基本上这种SDK面向的应用程序都是构建业务,而不是游戏。尽管我们不能假设人们不会尝试使用Flutter制作游戏,甚至现在已经出现了2D游戏引擎。但我想说的是,这种应用程序的模式似乎非常适合这组特定的功能,而这也是我决定探索的角度。
风险
尽管上述一切听起来很美好,但也有一些弊端。
首先,Flutter仍然是一个处于起步阶段的SDK。虽然从它的年龄上来看属于正常,但应该注意的是,alpha版于2017年5月发布,而1.0版本到于2018年12月才发布。这意味着在撰写本文之际,Flutter仅有一年的历史。这有什么后果?Flutter的社区虽然已具规模,但仍不能与当前的主流技术相提并论。这会影响我们寻找一些常见问题的解决方案,而且可能会经历多次失败,需要付出额外的努力,并仔细阅读规范。但是,Flutter的文档很健全,并且社区在不断发展,因此我们可以认为Flutter在发展中,没有明显的缺陷。
其次,Flutter和Dart都来自Google(这既可以看作缺点,也可以看作是优点)。好的方面是Google是科技巨头,如果他们想维护,那么资源和人力都很充足。但缺点是,虽然众所周知Google会推出非常实用的技术和服务,但也有可能随时将其淘汰出局。Flutter也面临这样的风险,但近期内不太可能会发生,甚至在未来几年也不会。因此,虽然这是一种风险,但是再说一次,任何新技术都有同样的风险,而且每种技术都有这样的经历。
使用哪些工具?
我们可以使用最常见的编程IDE(Android Studi、IntelliJ IDEA,甚至还有支持Flutter的Visual Studio Code插件)来开发Flutter,这意味着大多数开发人员都不必离开熟知的环境。就我而言,最近我一直在从事面向Web的工作,所以我选择了VS Code,但这不会对开发造成任何影响,因为文本文件说到底仍然只是文本文件。我选择的平台是Android(选择这个平台的原因是因为我既没有iPhone、MacBook,也没有iMac),因此看起来无论如何我都会安装Android Studio,因为Android Studio提供了虚拟机。
除了IDE之外,还有Flutter/Dart DevTools,这是一个套件,用于监视应用程序的性能,此外还有一些调试工具,例如Flutter查看器,类似于WebTools。在调查应用程序的性能瓶颈时,实时资源监控器非常实用,还有层级查看器可以找出困扰着许多应用程序和网站UI的冗余嵌套。
入门:“Hello World”
下面我们来编写一个管理保单的移动应用程序,还有比这这更令人兴奋的事儿吗?请务必注意,我可能会尝试以不同的方式来开发有些功能,所以可能会导致代码不一致。这个应用程序包含了一些解决常见问题的想法和示例,孰优孰劣留给个人评判。
应用的简单概述:
-
-
-
-
-
-
-
该应用程序是一个“轻量级客户端”——所有字典、数据和操作都存储在服务器端
-
我们通过Mockoon模拟API,IDE的话我选用VS Code,设备则由Android模拟器提供(我选择了Nexus 6 API 28)。首先,我根据Flutter官方网站上提供的官方指南创建了一个空白应用,随后创建Flutter项目的准系统。就我个人而言,我创建了如图1所示的结构。完整的应用代码请点击这里(https://github.com/asc-lab/personal-insurance-flutter-poc),我建议你参照着本文一起看。
pubspec.yaml文件包含了项目的依赖项、资源文件和版本号,非常简单明了。此外,该文档还包含了许多说明,但我们不会修改这些说明,至少不会经常都修改。对我们来说最重要的是lib文件夹,因为其中保存了应用程序的起始文件main.dart文件,以及其中的main()方法。这是应用程序的入口点,任何代码都不应超出该点。好了,下面该搭脚手架了。
主页是应用程序的默认页面,也是默认的路由。我们将在主页展示一系列的保单。因此,通过我们的api获取字典肯定很合适。我构建了一个调用API服务的单例服务,并在应用程序启动之前就获取字典数据,这样在应用程序的任何位置都可以使用这些数据。这个字典名叫CommonData,而字典的API服务是DictionariesService。二者都位于lib/services文件夹中。我还添加了一个通用的帮助服务(名叫Helper),提供默认填充、常用转换等功能。
[
图2] CommonData
CommonData是一个单例,它有一个内部构造函数,该构造函数将其唯一的实例存储在自己的某个静态字段中。CommonData类定义不会在应用程序的其他任何地方使用,仅在这个文件中用于声明commonData实例。DictionariesService.get()方法会返回Future<DictionariesService>,它实质上实是一个promise。这意味着我们可以使用await等它返回结果,并在一切准备就绪后继续执行initialize(),或者使用.then(…)并尽早返回。我们希望initialize()在收到响应后完成,因此我们使用await。稍后我们将介绍DictionaryService.get()的实现。
经过一番研究后,我发现在绘制UI之前运行commonData.initialize()非常简单,因此我们将其放到main()中(如图3所示)。
[图3] 在运行应用之前初始化commonData
这样一来,无论我们在应用任何位置,都可以确信commonData已被初始化,因为应用本身都是在initialize()完成后执行的。这样的解决方案在许多情况下都很管用,例如服务器存储的应用程序配置文件或主题、数据暂存、应用程序设置等。对于异步操作,我们应该在主页画面上进行处理,因为我们可以在主页画面上显示加载进度条。这样可以避免在应用启动时用户看到空白的屏幕,然后怀疑应用程序是不是崩溃了。因此,如果我们必须在应用程序正常启动之前做点什么,那么最好是执行可预测、可忽略执行时间的操作,或者创建一个单独的“加载”画面,并显示一些动画和明确的“加载”消息,让用户放心地等待操作执行,并在完成操作后返回主页。这种“尴尬的预加载”就留在这里作为UX的反面教材吧。
下面,让我们来看看main()下方的MyApp类。它的主体主要是重写build(BuildContext)方法,该方法会在每次重绘MyApp小部件时调用。我们的应用有多个画面:主页和创建保单向导的5个步骤(保单类型、产品、覆盖范围、投保人和投保对象),因此我对相关主题进行了仔细研究(如图4所示)。
[图4] Flutter应用程序的导航研究
在Flutter中,导航称为“路由”。我已经根据教程创建了一些路由(如图5所示)。默认的初始路由(MyHomePage小部件)和五个向导步骤。我们是否需要访问构建上下文尚有待观察,但先放在这里总没有坏处。
值得一提的是,由于我们的应用使用了Material控件集,并且是MaterialApp实例,因此我们可以按照Material Design原则快速修改外观。ThemeData类包含了“material design主题的颜色和版式数据”。在应用程序中可以通过静态方法Theme.of(BuildContext)访问ThemeData,改变它的各种属性,以更改主题提供的默认值。现在,我们只需设置primarySwatch(应用程序的主色调及各种明度的颜色)和accentColor(也是各种明度的颜色组合,是应用的辅助色)。
如果我们使用主题的默认值和/或生成的值(我们应尽力做到这一点),则最终的UI看上去应该不会太差。如果我们不想使用默认的颜色,则可以自定义颜色(如图6所示)。不过,这需要进行大量的工作(除非客户提供了样式指导),而且我不想破坏审美,因此就按照简单的方法来吧。网上有无数的材质色样生成器,如果你想提供“基本”的色调,则可以生成一个。此外,还有一个errorColor设置,但是作为一个涉猎UI/UX领域多年的人,我建议你谨慎使用这个设置,因为标准的红色是错误指示的行业标准。即便颜色方案允许修改,也应该尽量避免,最多只是稍微修改一下明度即可。
这个过程也可以用来测试“热重载”:尝试更改主题颜色,保存,然后就能立即看到应用的变化。这个功能我非常满意。
主页基本上就是一个列表,里面展示了每个保单,每个列表项可以展开显示保单的详细信息,另外还有一个创建新保单的选项。因此,每个列表项应该是有状态的,因为列表项的外观会发生变化大,但是整个页面可以是无状态的。主页显示了一个可变长度的列表,但是在其生命周期内,其中的元素和值不会发生变化。请注意,如果我们没有将每个列表项分成独立的小部件(而是在一个类中处理所有内容),那么页面就必须是有状态的。
让我们从路由(图7)中的数据开始。每次导航到’/’时都会执行此处的逻辑。在这种情况下,这种做法很方便,因为每次我们显示主页画面时,都会有最新的用户账号数据和已创建的保单。这样一来,我们就解决了将来会遇到的问题:完成导向后如何刷新主页面。我们只需要导航回去即可。
在MyHomePage(homepage.dart)内,你可以看到一些UI定义。页面的根名称为Scaffold,它负责设置应用程序栏、操作按钮、文档主体和其他各种选项,实际上这就是通用的移动应用模板。未定义的部分会被省略该。这里的appBar是最小设置,有一个floatActionButton来启动新的保单向导,backgroundColor已与当前主题的背景色挂钩(如果我们想改变颜色,则需要保持一致性),当然还有最主要的主体。
如前所述,这些保单被包装在一个Future中,表示它们还不能传递给ListView。这就是FutureBuilder<>的用途:实际上,它是一个小部件,可以根据Future的内部状态返回内容。我们可以使用快照(AsyncSnapshot)变量,根据Future是否已完成或仍在进行中,或者是否包含错误等,返回不同的窗口小部件。
对于我们来说,如果已完成则返回一个ListView,否则返回一个加载指示器——非常标准的东西。最好将所有可能的错误处理包装到Helper类中的某个通用方法中,Helper类可以接受snapshot.connectionState并输出一些通用的错误信息。解决Future中的错误有很多方法,但这里为了简洁起见,我没有使用这些方法。因此,一个Future只能是已完成或正在加载两种状态之一。
[图8] FutureBuilder
再来看看HomepageTile小部件。这是我们的第一个有状态的UI。每个有状态的小部件都包含小部件声明(图9)及其状态,而状态才是最神奇的部分。
小部件的UI在状态中通过build方法定义。因此,每次调用setState(fn),框架都会重新构建,并使用新的属性值重新执行build(BuildContext)方法。此处,我使用了_expanded字段值作为条件,来决定应该返回_buildMiniTile()还是更详细的_buildMaxiTile()小部件。当然,这可能只是一个简单的条件赋值问题,但是我们可以利用AnimatedCrossFade小部件来美化。
它的功能正如其名:根据crossFadeState(图10)让两个子部件交叉淡入淡出。由于每次setState调用都会重新构建小部件,因此在两个以上的状态之间进行切换也是可能的,但这种用法非常罕见,因为通过特定数量的点击进入某个状态听起来有点像在戏弄用户或玩捉迷藏游戏。除非有强烈的视觉暗示,否则不要这样做。
现在我们知道了怎样创建主页,并利用通用的列表项来显示用户的保单。接下来演示一下怎样给应用程序提供Mockoon API中的数据。我们打开DictionariesService(图11)。
[图11] DictionariesService
可以看到,get()函数标记为async,意思是它的返回值会包裹在Future<>中,采用类似于promise的处理方式。http客户端会异步执行命令,然后提供响应结果、状态码等。紧接着我们将JSON(其类型默认为Map<String, dynamic>)映射为DTO对象。由于这些是字典,所以我为它们创建了map,这样就不需要在显示某个代码对应的名称时遍历所有元素了(只需这样写即可:commonData.maps[DictCode.PRODUCT_TYPE][_policy.type])。
接下来看看DTO。将json转成对象并没有公认的方法,但幸运的是我们可以利用很多插件。我这里使用了json_annotation(https://pub.dev/packages/json_annotation),用它来监视启动后(flutter packages pub run build_runner watch)就会寻找 @JsonSerializable标记并创建映射函数,如图12~13所示。
[图12] policy.dart - DTO类
[图13] policy.g.dart - 由json_annotation生成
这可以大幅简化工作,并提供非常方便的方式将类映射到JSON。
每个成功的商业应用必不可少的两部分就是表单和验证。我们来看看我们要做的功能,以及保单向导的代码。前两步(1_newPolicyType,2_newPolicyProduct)都是随处可见的、非常标准的东西,这里就不再赘述了。如果你想看看如何利用异步执行计算来填充表单,那么可以查看3_newPolicyCovers步骤,它包含了一个假的保费计算的实现。
表单的定义非常标准——首先定义一个Form对象,在预先生成的GlobalKey<FormState>键中进行处理,然后定义元素,如4_newPolicyYou.dart文件和图14所示。
[图14] 4_newPolicyYou.dart——非常直观的表单定义。注意这里使用了Helper来减少代码。
表单可以通过多种方式与数据交互,所以可以按照开发者的喜好来设计。如果需要伪双向绑定行为,可以将onChange处理函数中的值持久化到setState中。但是也可以仅使用onSaved,在表单完整之后再持久化数据。我决定采用后一种方法。Step4Builder类(图15)中包含了向导的序列——如果表单合法,则保存后继续。向表单中注入数据则采用了很简单的方法:由于我们从模型传递值给表单(processData)中相应控件的initialValue,因此每次setState操作的时候控件都会被更新。
这就是为何我们只需要填充模型的字段(processData.setOwnerFromAccount),然后使用this._formKey..currentState.reset重置表单即可,这样就可以重新计算字段的初始值——直接从模型中获取值。但是为什么要重置表单呢?因为这样可以保证,只要我们不持久化表单中的值,我们在setOwnerFromAccount中没有填充的字段就可以获得默认值(这些默认值也存在于模型中)。
这只是策略之一。在不同的情况下我们可能会选择其他方法,但要注意的是,我们并不需要一定采用某种方法。
[图15] Step4Builder - 如果表单合法,则保存并转向下一步。非常干净。
动态表单布局的实现跟传统的js/html没什么太大区别。在向导最后一步的5_newPolicySubject.dart中,我们应该创建保单投保对象的数据,因此需要根据数据类型(汽车、人或者蜥蜴等)来采用不同的表单。实现方法是在不同的小窗体中定义不同的字段集合,然后根据前一步的选择来显示合适的那个。应用程序中仅实现了一个类型(reptileObject.dart),但只需在build方法中检查一下就很容易实现添加其他的类型(图16)。
[图16] 5_newPolicySubject.dart:我只想为我的宠物蜥蜴投保,因此唯一的表单定义就是Reptile对象,但我们当然可以通过在子属性中插入if语句来显示正确的表单。
现在,我们有文本框和下拉菜单,下一步该编写日期控件了——其实控件并不存在。如果你开发过移动应用,也许这听上去有些奇怪,但是如果仔细考虑一下就会发现这完全合理。最好的方案永远是使用系统提供的input(例如,我们不需要定义键盘控件,只需要使用系统提供的即可),而每个移动系统都提供了自己的日期控件,一般表现为日历的形式。因此我们的“日期输入”仅仅是一个只读的TextFormField,当我们触摸该控件时,它会要求系统提供值。前面提到的reptileObject.dart文件就包含了一个例子(图17-18)。
图17-18:
reptileObject.dart - TextFormField的责任非常少,只需要告诉系统用户需要输入一个日期,然后显示该动作的结果即可。
我们定义了一个onTap处理函数,来拦截针对控件的交互,然后显示系统的datepicker。
由于这是一个异步的动作(用户可以花很长时间来选择日期),因此整个方法必须标记为异步。
现在表单已经完成了,我们需要提供一些基本的数据验证。规则非常简单:每个表单控件有一个“validator”属性,它接受一个函数,函数的输入就是值,输出是一个字符串。如果输出非空,则输出的内容就表示验证错误消息,显示在适当的区域。图19演示了一个组合验证(两个条件、两条消息)的简单示例。
图19:
简单的验证 - 如果Validations.required返回错误消息,则返回该消息。
否则检查输入是否为有效的邮件地址。
如果不是,则返回自定义的错误消息。
到这里一切都很顺利,但如果我们需要进行异步验证(比如检查用户名是否已存在)该怎么办?嗯……很难。Flutter不支持在验证中使用Future<>,而且应该永远不会支持,据说这样会破坏同步验证,而且由于这些原因(https://github.com/flutter/flutter/issues/9688)混合两种验证方式并不是很好的UI实践。
即使接受这个现实,我们也会遇到必须进行服务器端验证的情况,那么唯一的选择就是将海量的数据加载到设备上。不过幸运的是,有一个广为人知的非常简单的技巧。只需在验证器中执行调用然后切换一个局部标志。如果标志被设置,则不显示任何验证信息。当验证结束后将验证结果保存到某个局部变量中,然后切换该标志,然后手动触发表单的验证。这样,第一次验证触发时不会显示任何信息(或者可以显示“请稍候……”表示动作正在执行),第二次验证将验证信息改成动作的结果(需要覆盖“请稍候……”)。
因此,尽管我们可以这样进行异步验证,但还是希望SDK能提供支持。这样可行,但应该更干净一些。
不管如何,现在应用程序可以运行了,而且开发这个程序根本没有花太多时间。我们考虑了实现商业应用的绝大多数基本问题,而且并没有什么太难的地方。所以可以认为这个应用程序是成功的。我们现在可以去掉那个反面教材,清理下代码,与后台结合,然后在收到客户反馈后重新修改。
[图] 政策主题页面
那么,我们应该使用Flutter来开发移动应用吗?我认为需要考虑几个问题才能做出判断,不同的人可能会得出不同结果。
如果你是第一次开发此类移动应用,我会推荐你使用。Flutter的学习曲线非常平缓,也不需要任何前提知识。通过教程和各种文档可以很容易地判断哪些场景下应该使用什么,而采用的工具完全可以自行决定。在学习一个存在了许多年的框架时,一些太过明显的实践人们就不会再谈起,导致这些实践很难学到。由于Flutter相对比较新,因此没有什么显而易见的问题,因此也没有那些被埋在各种新功能下的人尽皆知的技巧。相反,对于经验丰富的移动开发者,对待Flutter的态度应该与其他新技术一样。在创建有很多功能的高级应用时,如果你对某个技术有经验,那么应用程序越大,该技术的优势就越大。但是,如果你要开发一个很小的应用,那么Flutter是快速开发中的无价之宝。
Flutter的社区依然在成长。社区还不是很大,但也不是太小。关于这一点大家的意见可能不一样,但我认为当前的社区大小已经足够支持小型到中型的开发。用户基础越大,边缘情况就被研究得越透彻,也就越容易找到帮助,所以只要社区依然在稳健成长,对大型项目的支持也会越来越完备,风险也会越来越小。
现在有许多Flutter开发的应用,因此已经不是小众框架了。从官方网站上可以看到,不仅Google在用,许多大牌公司也在用。这表明Flutter的技术支持计划在向好的方向发展,因此值得一试。考虑到该技术依然很新,因此这些公司很可能需要在推出应用程序之前进行一些研究,但研究之后依然选择了Flutter,所以证明Flutter可能已没有太大风险。只要有足够的时间,Flutter应该能够成为开发移动应用的首选。
众所周知,市场变化很快,但这并不能阻止我们探索新事物。而且毕竟看来Flutter值得我们去尝试。
https://altkomsoftware.pl/blog/flutter-dart-quickly-build-mobile-app-without-losing-much-hair/
☞
☞