快手客户端团队在开发一个大规模高耦合 App 的过程中,沉淀出一套名为 KwaiFlutter 的自研方案和技术工具。来自快手的客户端架构师张天宇在 QCon+ 案例研习社(北京站)2020 分享了他们的经验,本文整理自此次演讲。
我是来自快手的客户端架构师,这个架构师是一个职务,而不是一个职位,我理解的架构师的核心工作是利用技术手段降低管理成本,提高开发的质效。今天的主题也会围绕这个核心点展开。
首先我们来推导一下,什么样的 App 才算是一个大规模高耦合的好 App?这三点合在一起其实大家可能觉得并不是很合适。但对快手有所了解的同学应该知道,快手从业务和技术角度有以下几个特点:
第一,快手是几百个人在四个城市同时开发同一款 App;第二,从业务角度来看,快手的流量聚集非常明显,用户的时间主要花在少数的几个主流程业务上,公司如果想要推广和创新,一定会在这些主流业务上给其他新内容进行导流。这就导致快手在业务角度上成为了一个天然耦合很高的产品,而技术上解耦的上限通常来讲就是业务的耦合,由此快手就成了一个天然高耦合的大规模协作的 App。
如何让这个 App 变成一个好 App?如果不治理,它自然会演化成一个很差的技术产品,会出现开发效率低、信息传达困难、交付质量差、用户体验差等一系列问题。如果不处理好,各个业务方协作起来很困难就会暴雷。
通过收集整理前面提到的一系列问题,我们挖掘出大规模、高耦合协作的三个核心痛点:第一个需要大量沟通,第二个产品交付质量不稳定,第三个各种性能损耗积少成多。
首先,大量的沟通不只是人与人的沟通,还包括代码上的接口兼容,去找对应的 Owner,去找对应的底层库;其次,产品交付质量不稳定和性能损耗积少成多,其实也是类似的问题,因为以上这些因素的变差都是由一个个小业务、小失败积少成多而最终带来的产品用户体验下降。
第一个用代码说话,就是指所有的边界上尽量都不使用文档,而是用一个强类型的接口来表明我想干什么,我需要你来干什么。
第二个重视搜索,所谓搜索就是有一个核心入口能够让所有人在同一个点找到所有想要的东西,这在大规模信息传递的情况下是非常重要的一点。
第三个强调规范和检查,规范和检查是大家在各自开发过程中形成了一些非常重要的积累,只有推广这些东西才能让大家整体的效率更高。
第四个尽早暴露问题,随着协作规模的扩大,越早暴露问题实际上能影响的人会越少,也就是说越早暴露问题对整个团队、整体协作的效率影响越小。
第五个性能损耗可监控、可溯源,这里更强调可溯源,监控只是说我知道有性能的问题,但在大规模协作的情况下,只有可溯源才能找到对应的人、对应的团队,才能更好的快速解决这些性能问题。
上述这些原则都需要工具来保障落地,我们沉淀了一些我们觉得比较有效的工具,以下四个保障是我们觉得 ROI 比较高的点:
第一个是代码边界强类型化,大家的协作一定是出现在代码边界上的,这种边界不论是在同层的接口上,还是在上下层的接口上,使用代码描述接口可以大幅降低沟通成本,同时也附带省去了大家很讨厌的维护 Readme 文档的成本,强类型本身也维护了我们第三个保障,就是 Fail-Fast 原则,即一种错误检测机制。
第二个是组件库标准化和集中可搜索,标准化就是大家能用同样的思路去使用每一个组件库,这样使用者就不用切换自己的思维,尤其是当 Readme 或者接入文档没有一个标准化的时候,可能 A 库的 Readme 是这个风格,B 库是另外一个风格,大家使用起来会很懵,因为不知道该用什么样的思路去接入。同样,标准化也是一个可搜索的基础,因为我们肯定不会在公司内部做一个非常复杂的搜索引擎,所以能在组件库里面提出一些标准的源描述才是重点。
第三个是各阶段的 Fail-Fast,也就是尽早暴露问题,只有尽早暴露问题,才能尽可能影响更少的人。
我们推导了什么叫一个大规模高耦合的好 App 之后,现在开始介绍我们自己做的一些具体的工具。首先介绍一下我们对 Platform Channel 的封装,Platform Channel 作为沟通 Flutter 和 Native 的一个天然渠道,对于混合开发来讲是必不可少的,通常也是一个天然的技术分界,这个会隔开同组之间不同技术栈背景的同学,由此形成了一个沟通的需求。
第一个是代码边界的强类型,因为在 Channel 上,Channel 名、方法名、参数都是靠口头或者文档约定的,并没有一个类型的系统。一方面是不能做到单点修改,另一方面不同技术背景的同学需要针对一个方法进行频繁地细粒度地沟通。
第二个是各开发阶段的 Fail-Fast,没有强类型编译一定检查不出来到底有什么问题,所以只有在运行期才能暴露写错的问题。
我们可以看到,官方的一个 demo 上创建一个 Plugin 会有 5 个字符串匹配和 3 个类型匹配,一旦出错就是一个运行时的非阻塞异常,Google 上就有 4 亿条搜索结果,可见这实际上是我们普通开发者非常困扰的问题。
熟悉服务端或者安卓的同学肯定能发现,这其实就是一个 IDL 想解决的问题。常见的 IDL 逻辑是用一种中立的语言来定义接口,然后生成各端、各平台、各语言上不同的对应的代码,从而达到强类型。
针对不同 App 的特征我们研发了两套 IDL 体系,第一个是基于 PB 的方案,PB 方案脱胎于 gRPC,gRPC 是 Google 推出的基于 PB 的一个 IDL 方案,它本身是想运行在服务端上,沟通不同服务端之间的服务。
我们把 Flutter 和 Native 当做三个不同的服务,使用 Channel 代替原生 gRPC 中的 HTTP 信道,就能快速搭建起一个强类型的 Channel 封装。但这有几个问题,首先我们学习 PB 有学习成本,其次 PB 本身的包大小比较大,最后如果数组的数据类型并不是基于 PB,我们会有二次转化和维护的成本。所以快手会把 PB 应用在一些历史包袱比较小的 App 中,这样就能实现从服务端到客户端到 Flutter 整个链路上的强类型。
对于历史包袱比较重的 App,我们实现了一套自研的方案,这里有一个小视频,大家可以看到左侧的文件里面会多出来我们生成的 Native 和 Flutter 的代码。
我们再看生成的代码具体是什么?首先我们的起点在于 Flutter,也就是我们并没有一个中立的语言而是直接使用了 Dart,我们按照规则定义好 Flutter Channel 之后直接使用 build runner 就能生成三端的代码。
Flutter 代码其实就是一个简单的把方法调用发出去的一个过程,而 Native 代码两端是对称的,都是两个部分,第一个部分是一个 Handler,Handler 实现了 onMethodCall 这个方法,它里面会把具体的、序列化好的 MethodCall 分发给 Interface。Interface 实际上是跟 Dart 定义的接口一一对应的一个接口。整体看来,开发者用 Dart 定义了一个接口,然后用 Native 去实现这个接口,就可以解决所有的通信问题。
这里面的好处是额外的依赖会很少,包大小损耗会非常小,而且它又遵循了 Flutter 自己代码生成的规范,所以大家使用起来会比较方便。它的劣势是我们没有做很强的强类型生成,因为我们觉得通常来讲集成到一个别的 App 里面,Flutter 一定是后来者,是应该去做类型兼容的那一个,而不是 Flutter 反推 Native 去做类型的兼容。这个方案我们年前就已经做好了,在准备 PPT 的过程中发现官方也有一个叫做 Pigeon 的方案,大家也可以看下 。
自研方案相对来讲比较复杂,所以我们解释一下它的整个过程:首先从流程来看是输入 Dart 文件,Dart 文件会使用源码分析工具生成一个 IDL 定义文件,然后我们用三端的代码生成工具,根据 IDL 定义文件生成三端的代码,这三端的代码会运行在我们预定义好的基类和 Runtime 上,整个流程使用 Builder 来编排。
我们再简单介绍一下这里面涉及到的比较麻烦的,且大家不太熟悉的技能点:首先是源码分析工具,源码分析工具实际上用了一个 Analyzer 的包,而且只用了里面一个非常简单的类叫 SimpleAstVisitor,整个就是做 AST 树的解析,大家可以参考下 dartfmt 的源码,逻辑上比较简单。
其次是三端的生成工具,就是喜闻乐见的 JavaPoet,可以参考一下 ButterKnife,而 OC 和 Dart 其实是不需要处理像 import、format 这样很神奇的东西,所以这两个推荐大家直接使用文件模板。
我们有了生成和对原始 Channel 的封装就可以做很多事情,比如我们实现了带 Session 的 Channel、带 Callback 的 Channel,我们把 Channel 的信道都归一化了,我们用 FFI 替代了原来的 Messenger 等等 。除此之外最重要的是,我们通过两种 IDL 方案让 Channel 重新获得了强类型和 Fail-Fast 的两个保障,降低了同组不同技术栈的同学之间的协作成本,让迭代也更加可靠。
刚刚我们解决的是一个水平上面的接口弱类型问题,下面来介绍垂直层面上出现的接口弱类型问题。
所谓垂直层面就是页面跳转,通常涉及到跨团队协作,协作成本天然就比较高。Flutter 原生的跳转方案其实是学习了 Web 端,使用了 Path 的方式,业内主流的客户端组件化方案也使用了类似的方案。但是我们觉得 Path 在 Web 上是天然的,一方面 Web 端整体都是弱类型大家已经比较习惯了,另一方面 Web 页面间传递的参数通常比较少,因为大部分都要去服务端去获取,所以边界本身的明确性并不是很重要,而这两点在客户端上恰恰相反,所以 Path 在客户端上也不利于协作。
其实客户端使用 Path 主要想解决模块化过程中的依赖问题和动态化问题,也就是说我可以替换我的功能。但在不破坏能力的前提下,我们同样可以使用 IOC 来解决一样的问题。
第一个明确了接口就是我们把一个字符串加类型的文档变成了一个明确的方法,这个方法有参数,我们需要什么想干什么都已经明确了,同时这个东西也能编译器校验,即达成了 Fail-Fast。
第二个封装了实现,比如说我们到底是用什么 Route 来展现 Widget?我们到底用哪个 Navigator 来展现 Widget?这些都可以在 IOC 的实现层进行封装,这时如果有需要,我们可以快速地单点修改,完全不需要通知调用我的人。
IOC 模式在 Flutter 层跑得非常顺畅,开发过程中没有遇到相关问题,全局的修改也给我们带来很多方便,但是一旦涉及到 Native 去调 Flutter,就很容易又回到 Path 加弱类型参数的窘境。
于是我们又做了另外一件事,基于上文提到的一个 Channel IDL 的自研方案和自研页面栈,实现了一套 Native 启动 Flutter 的强类型工具,思路非常简单,封装了一个面向页面栈的 Runtime,把页面栈作为 Native 到 Flutter 启动的一个信道,根据 Flutter 层跳转到右边的接口,去生成对应的左边的 Native 代码,如此 Native 也成了一个强类型的调用。
Platform Channel Builder 和强类型的页面跳转是可能跨团队协作的点,而组件库是一定是跨团队且整个协作相当复杂,为了简化组件库的协作流程我们也开发了一些工具。
首先组件库一定要有标准。组件库是为了提升开发的便捷性,提升协作效率,一个没有标准化的组件库体系是不能达到这两个目标的,同时组件库的标准化也会给我们后续做集中管理、搜索打下一个非常重要的基础。
如何有效地传达组件的标准是一个非常重要也非常难的问题,我们在快手也对组件库进行了升级和内容上的要求,但如果仅仅把这些东西放到文档里,它的更迭、传递都是问题,所以我们在整个组件的开发流程的三个重要节点创建、开发和发布都提供了一些工具。
一方面用工具自动化明确组件和平台之间的边界,另一方面在每一个阶段都积极地进行检查,阻塞不合规的产物进入下一步流程,达到了 Fail-Fast,我们也会依次看各个阶段。
做组件的第一个阶段是创建,把一个规则推广给大家最好的办法就是,我根据这个规则把工程给你创建好,开发者只需要填空就行了。
Flutter Create 模板在内部实际上是使用了 Flutter tools 里面一个简单的模板渲染器,根据左边的一个文件模板生成一个完整的工程。我们在上面做了一些拓展,增加了一些删除、修改、替换这些控制类的扩展名,渲染工具会根据扩展名和文件内容这两点在 Flutter 自己的模板上进一步进行二次加工,形成一个更强大的模板工具。
这个模板工具的重点在于我们把模板本身放到了公司内部的云存储上,每一个约束都是以模板包的形式下发到每一个开发的电脑上,开发者可以直接使用最新模板,而且完全不需要关心我该做什么,因为我只需要搜一下 to do,然后把编译编译过,我的整个库就是符合规程的,这个过程没有任何一个需要沟通的地方。
下面是一个创建的全过程,首先把命令下载下来,然后使用模板去创建,创建完之后需要开发者填入 to do,同时我们整个工具也替开发者填好了一个 pubspec 文件,通常有了这些,开发者就不会写得很差。
但我们还需要在开发阶段进行进一步保障,开发阶段从单人开发到多人开发的节点是 MR 的提交,所以我们在 MR 提交的时候会继续进行一次模板的检查,这个模板延续刚刚的思路,也是一个控制类扩展名、部署在云端的文件夹,这样仍然能非常及时地部署到每一个开发者的机器上。
经过开发测试后,组件库就可以被发布到私服上了,这时组件库的协作方就从组件库的开发者变成了开发者和业务方,也就是使用者。只有在这个阶段做好我们的质量保障,才能真正保障最后整体协作的顺畅。
发布组件时我们需要使用 Pub Client,官方的 Pub Client 会进行一些固定的检查,这些检查有一些已面向开源社区,在我们公司内部没有意义,同时我们的自定义检查也没有包含在 Pub 中,所以我们仿照 lint 给每一个检查都加了一个 key,然后使用类似 Key configure 的东西把整个配置文件放到云端,同样做到了配置和逻辑的分离,最终能很快地同步检查所有的发布过程。
我们刚刚讲到的组件库相关内容都是偏管理类的经验,因为这些工具本身技术上都比较简单,但我们觉得收益非常大。对此,我们有以下几个经验,第一全流程监管,尽可能尽早地发现问题,做 Fail-Fast;第二充分利用模板,分离模数据和逻辑,所有的数据是经常变动的,逻辑是非常稳定的,所以可以使用配置把数据的变动非常有效地传递到各个开发者的机器上。云配置加自动化工具的方式,是我们发现在公司内部传递信息的所有实践中最有效的手段。
此前介绍的所有内容都是正向开发的协作和质量的提升,我们再介绍一下反向质量问题的追溯,这也是我们四条保障的最后一条,基础用户体验的持续监控和优化。
这里介绍两个工具,内存泄漏和包大小监控。这两个工具的共同点一方面监控的都是累积型的性能问题,另一方面这两个工具不仅要发现问题,更强调溯源到业务,避免在大规模协作中出现无效沟通。
首先是内容泄漏,具体分为两步:第一步是内容泄漏的发现,第二步是内容泄漏的引用链的排查,整个思路非常接近安卓的 LeakCanary。发现阶段我们虽然没有找到官方提供的 WeakReference,但结合使用 Expando 和 VM Service,我们可以达到类似的能力,因为 Expando 的 key 内部就使用了一个叫做 Weak Property 的东西来存储的。
我们把需要监控的对象放到 Expando 的 key 里面,然后在 GC 后使用 VM Service 进行类似于反射的操作,就能找到所有没有被正确使用的对象,这里找到都是 ID,在使用之前找到这些 ID,再调用 VM Service 的 get Retained Path 就能非常好的找到引用链。
VM Service 是 Flutter 官方一个非常强大的工具,我们之前使用的 DevTools 这些东西都是基于它的。它是一个可以理解为 RPC 的工具,它的服务端是我们的手机,客户端是我们的 PC,所以它整个传输效率和传输内容都非常有限。
下图的二维码是我们之前发表的一篇文章,专门详细介绍了整个泄露的工具流程,使用这个工具我们发现 191Framework 里面的 router 是会泄漏的。
这时候有同学就会问,内存泄漏本身已经是页面级别了,那是不是已经可以定位到团队了?在我们公司的情况不是这样的,很多主流程、分发类的页面实际上是由多团队开发的,所以只有精确定位到代码的行数才能找到真正造成泄露的人,所以在这里我们也更强调一个精准溯源的方式,这里仍然需要使用 VM Service。
首先我们会去找泄露的是什么类型,比如说方法、对象,然后根据类型去找到不同的 location,这里的 location 实际上是代码 Token 的 location,最后再使用 VM Service 提供的 Script 工具来解析 Token 真正对应的代码行数。通常有了库、有了类,我们就能定位到一个团队,有了行数,就可以通过 git-blame 找到对应的人,这样在完全不需要找人的前提下,就可以快速定位到谁造成了这次泄露。
另一个很容易积累问题的是包大小,我们也进行了一些基础优化,但发现基础优化并不能赶上业务方疯狂引入三方库带来的包大小增长,所以我们现在更倚重约束业务方的代码写法。
首先官方给了一个完整的包大小内容和依赖关系的工具,但这个并没有展示出哪个团队引入了什么,带来了多少的包大小增长,所以我们使用了一个脚本把两个数据合并到了一起,这个图就是一个既有包大小又有依赖关系的图。
我们可以使用这个图快速、直观地找到一些不太应该引入的库,同时我们会有一个表格,表格里面会按照安卓的内存占用算法给出 VPS、RPS、PPS、UPS 等包大小的不同的衡量维度。
我们会对每一个业务方进行这些维度的约束,有了这些约束和表格,我们不仅能发现包大小的增长,还能溯源到具体的业务,通过这个工具我们发现 CachedImage 库会带来 2M 左右的包大小增长,删除之后效果非常好,但是很多复杂的技术优化也不见得能有 2M 的收益。
最后我们总结一下,我们在天然高耦合的一个协作过程中,发现了几个比较能有效保证开发质量的手段:代码边界的强类型化、组建的标准化、集中可搜索、各种阶段的 Fail-Fast、基础用户体验的持续监控和优化工具,这些手段我们通过 IDL、IOC、组件库标准化工具和有溯源能力的性能监控工具落地了,落地之后有一些不错的收效。
整个分享过程中提到的所有工具都不需要大量的开发投入,技术上也没有很复杂,但是一方面代表了我们平台组的技术价值观,另一方面也给我们的协作过程带来一些收效,希望也能给大家的工作带来一些帮助。
张天宇:首先 Flutter 是一个新技术,通常我们应该先找到这个技术本身的特点,分析所有的业务方需要什么,然后找到一个非常契合的点去推广。
在推广过程中一定要有一个明星工程,明星工程可能是在某些情况下,比如开发效率非常高、场景非常复杂,或者跟 Native 的交互非常复杂等,这样能够帮助其他的接入方建立起对 Flutter 的信心。
第三个是强调工具化,大家面对一个新东西通常是有热情的,但只要稍微复杂一点或者不太好懂的东西,或者很长的文档,都会导致大家接入的热情被浇灭,所以一定要有一个工具帮助大家在起步阶段非常地顺畅。
第四个是重视人员的培养或者说人员的教育,对于一个新东西,如何让大家更快地学到、学好、学懂是推广一个新技术的核心点。最后一定要非常重视我们原有平台上的经验,比如架构方面的经验、一些管理方面的经验等,这样能让 Flutter 非常顺畅的起步,而不是等踩了很多过去的坑,大家对它的信心不高了之后才建立起来。
- 本文固定链接: http://www.douyinkuaishou.cc/?id=41365
- 转载请注明: admin 于 抖音快手 发表
《本文》有 0 条评论