Gitme 是Flutter中文网flutterchina.club/ 开发的一款github客户端,本文和大家分享一下我们使用flutter从开始设计Gitme到动手开发,再到最后上线的整个过程中的一些思考、经验、以及趟过的坑。在阅读本文前,您可以先去我们的官网安装一下Gitme ,然后再对比本文中提到的点,才会有一个清晰的认识。
首先我们先来看几张gime软件截图:
目标
我们的目标是用flutter做一个高性能的,同时支持Android和iOS的github客户端。但是,Github资源、功能比较多,并非所有功能我们都要在APP支持,在支持计划中的功能也必须划出优先级,首个版本应具备一些核心功能,一些优先级不高的功能随着日后版本迭代一点一点来完善。经过整理、讨论,我们列出了1.0中要支持的功能列表:
-
支持github账号登录、注销。
-
登录后用户可以查看自己项目、动态等信息;支持编辑个人信息。
-
搜索;1.0支持搜索项目、用户、issue;支持github搜索语法。
-
项目:支持对项目进行star/unstar、watch/unwatch,可以查看项目issue列表、更新动态、分支源码等信息。
-
用户:支持查看用户详情;支持follow/unfollow用户;如果用户公开了邮箱,支持给用户发送email。
-
Issue: 支持浏览、打开、关闭、编辑、评论issue;支持给issue添加label。
-
Label: 支持浏览、创建、删除label; 支持通过label筛选issue。
-
书签:关注内容可以加入书签收藏,以便下次可以快速打开。
-
国际化:支持中文简体与美国英语。
-
个性化:提供多套APP主题;提供深、浅两种代码主题。
技术点分析
确定目标后,就要对功能可能用到的技术做一个分析整理,确定出哪些可以在flutter中完成,哪些需要插件。
UI
由于我们使用的是flutter, 那么UI自然是在flutter来实现,主要熟悉一下Flutter常用widget.
数据与内容
github中绝大多数内容是源代码文件及markdown文本,还有一些就是图片等其它元数据。
-
对于源代码文件,需要渲染为等宽字体,并且排版时不能强制换行。
-
对于markdown文本(主要是issue、评论、文档),这是大多数用户主要浏览的内容。为此必须有一个markdown解析器,这如果是在web端,没什么好担心的,成熟的轮子很多,但在flutter社区,情况却不容乐观,在pub仓库找到了一个flutter_markdown的包,经过测试发现坑很多,主要表现在markdown语法支持不足、样式自定义困难、不支持tabel、不能自动识别url等,离可用相差甚远。
-
对于github中的图片,主要是一般的图片(项目中的图片文件和网站的用户头像等)和github的私有emoji。这里主要关注一下github emoji,它们有些特别,因为这些emoji在文本中只是一些标记,所以在渲染之前必须对文档进行解析,提取出emoji标记,然后转化为对应的图片,最后再进行渲染。而emoji会出现在很多地方,比如markdown中,所以这在解析markdown时也是应该考虑的点。
网络请求
Github API是开放的,v3是restful风格的,v4是graphQL风格。我们最终选择了v3版本,因为graphQL虽然灵活,可以做到按需取数据,绿色无浪费,但在我们进行选型时,有两个因素让我们不得不放弃:
-
需要客户端开发者自己去汇总所需数据然后写出请求体;这非常耗时,刚开始时,我们根据github的API文档,在汇总时效率极低,一个小时才能完整的请求出两个业务接口。
-
返回数据嵌套层次太深;这让我们在将json数据转化成dart类(类似于java bean)时非常为难,如果把返回数据当成json数据,在开发时便不能获得ide的提示会降低开发效率;在编译时会牺牲掉静态类型检查会增加潜在出错可能性(比如字段名输错了)。
确定选用v3版本的api后我们需要一个合适的http库,我们希望http库具备:
-
良好的restful接口
-
请求响应拦截器;这很重要,这意味着我们可以在底层统一对请求/响应进行预处理。
-
灵活的请求配置;比如可以统一配置请求基地址、公共header等,还有就是github 很多API在请求时都会涉及私有的
content-type
, 这意味着不同的请求可能需要不同的请求配置。 -
支持超时; 由于重所周知的原因,在国内访问github时,有时可能需要较长的响应时间(有时甚至无法访问),所以支持超时是非常重要的。
-
最好支持请求取消;主要还是因为众所周知的原因,导致有时页面加载过慢,当用户没有耐心继续等待下去返回时,能够将之前请求取消,避免在后台占用资源、浪费流量。
当然,一个优秀的Http库可能还包括cookie管理、文件下载/上传等功能,但是这两个功能在我们的需求场景中暂未用到,所以就根据这5个指标去筛选。当时经过一圈查找,发现dart社区竟无一个同时满足这五点的(甚至同时满足前四点的也没有),这也是flutter社区刚起步生态还不好的尴尬,多希望有一个dart版的okhttp! 在这种时候,我一般都会找一个满足需求最高的开源项目,fork下来,然后定制。但是看了一些库的源码,发现实在是和需求相差较大,设计思路也相差太远,发现该轮子的成本已经大于从头造轮子的成本,没办法,历史上很多时候,就需要有一些人能够敢为人先,挺身而出,然后留下惊才绝艳的一笔.... 于是也便有了dio:
Dio is a powerful Http client for Dart, which supports Interceptors, FormData, Request Cancellation, File Downloading, Timeout etc.
值得一提的是,dio是flutter中文网开源项目之一,它主要借鉴了okhttp、axios、request、fly 四个开源库,所以无论是android开发者、还是前端、node开发者,相信都能很快上手dio。 目前dio在pub上得分是96分,github dart语言下项目排名22(正在快速上升中),在此,强烈向你推荐dio。
插件
Flutter的优势是在开发UI上,但由于Flutter使用自绘引擎,并不能无缝集成原生控件(Android 原生控件及iOS UIToolkit), 而原生控件有一个比较大的优势就是可以集成系统能力,比如可以调用相机(如surfaceView)、支持浏览网页(如webview),但在flutter中,由于绘制引擎skia只支持二维图形绘制,并不能直接结合原生功能,所以当我们要到这类原生相关的控件时,我们只能通过flutter插件来调用原生控件来实现,在gitme中,主要涉及的是如何打开github文档、issue、评论里的url链接内容。
Webview
现在我们需要一个webview控件,能在应用内显示h5网页,而要实现这个,我们只能通过flutter插件!
很多人问过我flutter中有没有类似于webview这样的widget,答案是现在没有,将来极大可能也不会有,原因很简单,如果在flutter中加一个webkit和v8你觉得flutter应用的安装包有多大?
好了,现在看看有没有现成的轮子,pub中搜到的flutter_webview倒不少,但大多数都不能直接来用,原因有两个:
-
我们需要对webview所在路由(android中的activity, iOS中的controller)的导航头进行一些自定义,比如当页内跳转过多时给导航栏右侧加一个直接关闭当前路由的button以避免要连续多次点击返回才能退出。
-
我们需要webview支持一套javascript bridge协议,已备日后方便集成h5功能。
但目前没有同时满足这两点的插件,所以,我们的webview插件还是得自己来写,最终我们通过:
Android: Webview + DSBridge-Andriod
iOS:WKWebview + DSBridge-IOS
实现了自己的webview插件。
其它插件
我们还用到了fluttertoast 和 shared_preferences 插件,前者用于一些需要提示的场景来弹toast, 后者主要用于应用配置持久化。
设计模式及架构
遵循合适的设计模式,会让我们的代码逻辑清晰且易维护,一般来说不同端上都会有一套成熟的设计模式,如iOS上的mvc、android上的mvp、前端的mvvm等,那么我们的flutter代码中应该遵循怎样的设计模式?要回答这个问题,我们得先看一下flutter官方给出的编程范式(Flutter框架编程范式)以及google团队创造flutter时的灵感起源React-Native。
React-Native和flutter
RN最大的特点就是状态驱动的响应式编程,简而言之就是应用程序维护一套状态(state),并提供一个UI模板,而模板可以绑定状态,然后当状态发生改变时框架根据状态的变化重新构建UI界面。可见,而整个过程中用户不会直接操作UI控件树,构建过程(包括底层优化逻辑,几React中的diff算法)由框架完成。
在Flutter中,和RN非常相似,用户可以创建有状态(stateful)和无状态(stateless) 的widget。 然后在build
方法中声明UI模板,当状态改变时,通过setState
方法通知flutter, flutter会在下一个frame中调用用户提供的build
方法来重建UI, 而底层的优化,如对比状态更新前后widget树的变化,只渲染变化部分的最小集,这些工作由flutter框架来完成,正如RN中的diff算法也是由框架完成一样。
所以很明显, Flutter是一个响应式框架,忘记mvxx这一套吧,如果你非要在flutter中套用mvxx这一套设计模式,很可能就会变成过度设计。
Dart语言范式
Dart语言最主要的特点就是结合了编译性语言与脚本语言之所长,特点很多,在实际动手之前,我比较关注它最受诟病的一点:在flutter中,对于复杂一点的UI,嵌套层次太深!
这一点确实无法反驳,过多的嵌套确实让代码看起来很难维护,尤其是web前端开发者,早就受够javaScript “回调地狱 ”(callback hell)之苦,没想到现在到了flutter还是逃不掉。但其实,问题并没有那么糟糕,flutter中的嵌套和javascript中的回调嵌套是不同的,javascript中的回调嵌套一般是异步任务的回调,需要在回调中处理之前回调的逻辑, 而flutter中的嵌套一般来说并不是回调,而是UI widget的声明结构,它不需要再回调中再处理逻辑,所以,flutter中也就是嵌套层次深一些,但不会发生处理逻辑混乱。目前比较好的建议就是对于复杂的ui,最好将各个部分拆分成单独函数。
架构
其实flutter本身就是响应式的框架,我们只需遵循响应式编程的规范就行,但在程序逻辑结构上,我们也要多考虑一下。由于gitme主要是通过网络从github获取数据,然后再渲染UI. 我们可以在逻辑上对业务代码简单分成两层:底层数据IO+上层UI渲染,
数据层
关于数据请求的配置、逻辑等不要在UI层去控制,而由数据层自己完成。这也就是为什么我们队http库的要求中一定要包含“支持请求/响应拦截器”,因为只有支持拦截器,我们才能将io逻辑更好分离。
UI渲染层
UI层我们主要使用的事是material组件库,但我们并没有直接使用 Scaffold
、 AppBar
这些基本每个页面都要用的组件,而是在其上包装了一层,目的是程序风格发生变化时,我们只需要在包装组件中统一修改即可所有页面生效,而避免全局去替换(也许你会说可以设置主题,但是主题的精细粒度是不够的,有些需要自定义的点主题并不支持)。除此之外,我们也封装了一些通用的自定义组件,如支持上拉加载、下拉刷新的无限列表。
编码
在想清楚上述问题后,我们对我们APP整体也就有了一个轮廓。接下来就是去逐一解决这些技术点即可。
UI布局
布局主要涉及Flutter中widget的使用,这一步可以结合google官方 Gallery 中的示例先摸索,等自己动手写上几个页面后,布局就会轻松很多,flutter组件非常多,但常用的也很固定。flutter sdk中的注释很详细,示例都在注释里(Flutter文档就是通过注释生成的), 在IDE中可以非常方便的跳转查看源码。总之,了解Flutter widget的第一资料就是源码。
Markdown支持
dart官方有一个markdown包,它可以将markdown文本解析成html。但是我们需要的是将markdown文本直接转化成flutter widget树,所以这个包是不能直接用的,但是,如果我们要自己实现一个markdown到flutter的解析器,也并非易事。于是,我们想到了markdown包,看能否把它将markdown语法转化为html这一步替换为从markdown到flutter的widget,顺着这个思路,我们实现了最终的markdown解析器,并且工作良好。但是有一个问题就是:markdown包只支持纯粹markdown语法解析,如果在markdown文本中嵌入html代码,html代码是不支持的,所以现在我们的markdown解析器只支持markdown语法,对内嵌html代码不支持。这个我们希望markdown包作者能在后续版本中支持内嵌html语法,或者等我这边腾出手再去给它提pr。
Emoji支持
Emoji支持是在markdown解析过程中完成的,将对应的emoji标记符先转换成markdown语法,然后再解析markdown。
Mock与缓存
由于gitme中使用的网络库是dio, 而dio的开发与迭代基本与gitme是同时的,我们也花了不少的时间在dio库的迭代上。
Mock
在开发测试时,我们测试数据放在了一个git项目中,让后push到github,App访问git数据时就从github上的测试项目拉取,但是有一个问题就是每次打开页面时都要等待几秒,直到数据获取完成,这极大的影响了我们的开发效率。为了解决这个问题,我们在dio请求拦截器中做了一层mock: 如果请求的是测试项目的数据,我们直接将本地工程对应的数据返回。这样一来有两个好处:
-
需要添加、改动测试数据时无需push到github远程仓库,本地该了就立即生效。
-
节省了网络请求时间。
缓存
由于github在墙外,国内访问有时可能会在速度和稳定性上存在一些问题,为了提高用户体验,我们需要一个合理的缓存策略。一般来说,http协议有一套完整的策略,需要服务器与客户端配合(通过header来传递缓存策略信息),但是我们调用的是github的接口,所以服务器对于我们来说是不可控的,所以我们不能使用http协议本身的缓存策略,这确实比较遗憾,但是现在我们又有了一种新的思路,这还是多亏dio支持拦截器,这让我们也可以在请求前/后来定制我们的缓存策略,值得一提的是,1.0中还没有加入缓存功能,这在我们后续版本迭代时会被支持。
链接拦截
如果在markdown中点击url链接时,会进行统一的预处理,比如:检查如果是github链接的话,将其转换为App内路由,这样就可以在APP内打开,避免跳到网页中去,如果是邮箱地址,则调用系统邮箱APP打开。
全局事件总线
gitme中有些场景需要全局状态共享,这和react中的redux或vue中的vux很相似,不过gitme中需要共享的状态并不多,所以我们采用了事件总线的方式来同步状态。
插件
正如上文所说,我们需要实现一个支持一种javascript bridge协议的webview插件,这个需要会原生开发,本身难度不大,就是gitme中实现了状态栏自动变色功能,会根据背景颜色自动调整前景文字、图标颜色,这使得我们的webview插件样式比较智能,并且非常容易自定义主题。同时也实现了几个API,以供javascript调用。
我们实现的另一个插件是版本更新插件,在其中我们也集成了mta统计sdk.
修轮子
在gitme中引入了一些第三方包,而其中近乎一半的第三方包无法直接使用,对于这些包,我们的做法是fork其源码,然后修复、定制,然后在gitme中依赖我们fork的repo(flutter支持直接依赖git项目)。在开发gitme的过程中,我们深深的体会到了生态的重要性。
总结
在1.0开发完成后,首先根据之前设定的目标,check一下完成度, 然后在谈谈开发过程中躺过的坑。
目标完成度
1.0的目标基本都已完成,但仍有几个已知问题:
- 不支持markdown中嵌套的html代码。
- SVG暂不支持;原因是flutter目前不支持svg,而第三方的包质量太差,所以初始版本暂不支持。
- 代码染色能力不足。
对于第一个问题,上文已经谈过了,待日后优化。而代码染色问题比较棘手,这主要是因为编程语言种类繁多,而靠谱的染色方式都是需要通过将代码转化为抽象语法树(AST,Abstract Syntax Tree),然后再进行关键字、方法名、类名等提取,然后应用不同样式渲染。如果是在web端,直接引入highlight.js,但dart中目前并没有这样的库,为此我们自己实现了一个简单的分析器,我们主要测试了Dart、Javascript、Java、php四种语言的成功率,gitme 1.0.0 结果如下:
语言 | 成功率 |
---|---|
Dart | > 95% |
Javascript | > 90% |
Java | > 90% |
php | 50% |
其它语言在1.0.0中染色成功率可能会非常低,由于良好的代码染色对gitme的用户体验非常重要,因此,我们的下个版本主要的任务就是优化代码染色,根据目前1.0.1的开发进度,我们的分析器已经足够强大,就目前的测试结果,已经支持绝大多数编程语言,并且染色成功率都在90%以上,当然,在1.0.1上线前,我们还要进行更加全面的测试,最终的结果,敬请期待!
趟过的坑
严格来说,从一开始到现在遇到的问题是挺多的,但其中大部分是由于刚接触flutter,不太熟悉,并不能说是坑,如各种widget的使用等。下面列出几个在gitme开发过程中让我们花费了较多时间的问题:
-
不要将build函数中传入的
context
保存为全局变量(可能是为了后续使用方便),build中传入的context会变,并且widget树不同部分构建时的context都不同,如果使用保存的全局context,将会出现不可预期的错误。比如无法通过context正常获取local及主题信息(偶现); -
不要将需要缓存的数据保存在widget中。
由于Flutter响应式机制,每次状态变化都会重新build widget树,一般来说应该将需要缓存的数据保存在state中,由于widget和state生命周期不同,大多数情况下重新build时,state是复用的,但是发现在TabView中切换tab时,每次tab都会完全重建(包括state), 这时缓存的数据就不能放在state中,有种做法是可以将数据保存在widget中,应为widget都是你在build方法中手动创建的,只要在创建时缓存一下widget(而不是每次build都重新
new
一个widget),这样只要widget不重建,就可以保证保存在widget中的数据不销毁,但我告诉你,千万不要这么做,因为你缓存widget的组件本身也是可能被重建的,这样就会导致你缓存的widget还是会被重建(原来保存的数据就销毁了); 如果你非要这么做,那么久必须保证从你缓存widget的组件开始到widget树根之间的所有widget都得被缓存,否则,一旦flutter调用根widget的build方法,那么整个widget树都会被重新构建,之前缓存的数据也就自然不复存在了。正确的做法是放在全局状态管理器(如redux)或全局变量中。 -
ListView
结合RefreshIndicator
实现下拉刷新时, 列表项如果不满一屏,下拉刷新无效,此时需要将ListView
的primary
属性设置true
,但设置后就不能给ListView
设置controller,这是因为primary
属性设置为true
的ListView
会从他父辈widget中的PrimaryScrollController
获取它的controller(每个Scaffold
都会默认设置一个PrimaryScrollController
) 所以此时再设置controller时,flutter会报错,解决办法是自己手动设置一个PrimaryScrollController
。 -
当自定义导航栏(
AppBar
)的返回按钮时,iOS下右滑关闭手势会失效。这和iOS原生导航栏自定义返回按钮会导致右滑手势失效是一样的。 -
Android和iOS系统支持的字体不一样,不要以为flutter会自己使用一套标准字体,flutter在绘制时也会使用系统字体,所以在Text widget指定字体时一定要看看是否两个平台都支持,gitme中在设置代码的等宽字体时发现了这个问题。
-
在替换图片、资源后或构建release包之前要先执行
flutter clean
清除缓存,否则有些时候,新的改动不会生效。
其它相关问题
除上面所述,关于Flutter, 还有一些问题可能是大家比较关心的。例如:
-
包大小; gitme 1.0.0 release版,Android: 11.7M, iOS AppStore上架后38M,可见android包比ios包小很多,当然,ios中各种尺寸的icon和launchImage确实会比android多占用些空间,但是这3倍的差距确实也大了一些。 笔者尚未研究flutter framework ios部分代码,至于优化空间,我想若能更好,谷歌是不会不采取行动的。
-
热更新; flutter release版默认是AOT,所以要实现热更新,那就只能依赖dart作为脚本语言的特性,采用JIT模式,而flutter的debug模式默认就是JIT模式,而JIT模式和AOT模式性能差距是非常大的,如果要做热更,问题瓶颈应该在性能。但是随着苹果AppStore审核策略的收紧,使用热更都会面临被拒风险,所以建议需要动态化的功能还是通过h5或rn/weex这样的框架,当然h5的风险要比rn/weex更低。
-
性能; Flutter AOT模式下比JIT性能好很多,如果你开发时在debug模式感觉性能不佳,可以切换到AOT模式(打Release包)试试,整体来说,flutter的性能还是符合预期的,如果Release模式下性能依然不佳,那么你就要考虑重构你的代码(或者换种实现方式)。
反馈和建议
我们之所以做gitme,最初是想做一个flutter范例,用户可以直接下载,能直观感受flutter。同时也是想做一款能够给开发者带来真正价值的APP。 我们(Flutter中文网)会继续迭代gitme,如果大家有什么好的建议或发现了bug,欢迎反馈,请在gitme issue中反馈。
下个版本计划
下个版本我们主要会在代码染色和缓存方面来优化用户体验。对于前者,上文已经仔细说过,不在赘述;对于后者,主要是因为github在墙外,在国内较慢,有时还会不稳定,所以我们考虑在APP中做一些适当的缓存策略。当然如果您有其它好的功能建议,欢迎反馈。
最后
我们欢迎您使用Gitme ,如果您觉得好,欢迎把它推荐给您的朋友、同事(菜单>分享), 也欢迎您的建议。最后再次贴出gitme官网flutterchina.club/app/gm.html 。
我们有一个APP体验群,您可以扫描下面二维码加入,如二维码已过期,可以添加管理员微信Demons-du(添加时请备注"gitme用户"), 他会将你拉进群。
从 0 到 1:我的 Flutter 技术实践 | 掘金技术征文,征文活动正在进行中