现在的应用都是越来越大,越来越复杂,更加需要模块的精细化切分,以及支持多人共同合作开发。
目前大量应用单纯一个页面的业务量级已经非常庞大,这里就来再次从页面级别讨论一下页面组件化。
前言
我们可以将任何一个页面都视作一个列表,每类元素都可以看做列表中的一个cell。之前,在美学新项目启动的时候,按照页面组件化的思路做了一个组件化拆分方案(DDComponent),在当时的业务场景来说,已经基本足够,但是在不停的使用过程中,也逐渐发现了一些问题,比如
- 数据源不匹配。这是一个非常常见的问题,异步、没有正确reloadData都是引起这类问题的原因,目前我还没有看见过有哪个开源方案能够彻底解决这个问题的。
- reloadData效率问题。当一个列表足够长的时候,每次reloadData都将会非常消耗性能。
- size计算。在布局和构建列表的时候,最让人麻烦的就是size计算,这会让我们的布局工作量加倍,而系统提供的方法实际效果并不好,虽然有FD的自动计算方案,但依然存在很多问题。
- 通信问题。组件间应该是相互独立的,如何解决相互间的通信问题也是一个重点。
这次重新设计的这套数据驱动系统主要考虑了如下几个目标:
- 开发效率。在目前的业务开发过程中,效率永远是第一的,决定业务快速迭代的基础。
- 并行开发。在这个多人合作的场景中,如何减少沟通成本也是非常关键的,而从架构层面解决这个问题是最有效的。
- 复用。对于一些信息展示类为主的应用,到处存在着复用问题,而以前的复用层面仅仅是view、model,而无法把这个模块的MVC进行复用。
- 标准化。提供一套完全标准化的页面组件方案。
- 扩展性。能够应对平时的大部分场景以及不同类型布局系统。
- 执行效率。从架构的层面考虑性能问题,并为性能优化提供一系列方案,并且可以进行逐步优化。
数据源不匹配问题
数据源问题是我们开发列表中遇到的最大的一个问题,也是非常难以解决的问题。
场景
当一个列表比较简单的时候,我们很少会有这样的问题。但是随着业务发展,列表内容越来越复杂,那么就非常容易出现这类问题,主要可能表现为以下几个场景:
- cell 为空
- 数组越界
- 非法调用(类型不匹配)
原因
在我们处理数据的时候,如果在if-else的条件里面遗漏了某种数据,可能会导致该数据返回空cell。这种情况在迭代和重构的时候,由于对现有业务没有透彻的理解,就很容易发生。
而数组越界、非法调用,往往是异步和时序问题。在我们更新数据源之后,如果没有及时、同步、正确的更新列表,就会发生这样的问题。这类问题一般是偶现的问题,难以排查,也难以修改。而且如果该页面是拆分后的结构,那么每个独立模块内可能是正常的,但是多个模块间相互影响可能就是有问题的。
如何解决这个问题?
我们很难去保证每个人的开发素质,也很难去保证多人并行开发过程中的沟通和一致性。所以我们需要从架构层面去解决这个问题。
解决这个问题的关键在于数据源,我们列表中所展示的数据,和我们正在处理的数据并不能保证一致性,那么最好的解决办法就是保存两份数据源,一份是开发自己维护的,一份是用于页面展示的。
目前列表所有接口返回的都是位置信息,而不是我们展示的数据,我们需要得到数据就需要从数组中的位置去取,这也无法保证展示的数据源index和真实的数据源index的一致性。
那么这里需要解决的问题总结为:
- 展示数据源和当前数据源的分离
- 抹除位置信息,直接通过数据来表示
数据源分离实现
其实做数据源分离的思路很简单,系统的很多设计思路也是类似的。在我们提交数据源变化的时候,我们直接创建一个只读的数据源快照,用于展示使用。当我们再次刷新的时候,同样需要生成一个新的快照用于刷新列表。大致的流程如下:
1 | --> update data ---> original data source --> update data --> original data source --> ... |
绑定数据,而不是index
我们开发过程中,一直以来都是以index为核心进行布局数据,而没有从数据进行布局列表,这是导致这类问题最根本的思路上的问题,如果我们所有接口都是直接得到相应的数据,我们不需要操作index,那么我们就绝对不会出现越界等问题了。
那么我们可以把接口设计成这样:
1 | - (NSArray *)viewModelsForComponent:(NELayoutEntityComponent *)component; |
所有行为都必须通过数据来执行,让真实的数据作为模块的核心。那么这里数据就必须具备唯一性这个条件。
可能你要说,并不是每一个模块都是有这么明确的数据类型来表示,那么这时候可以让该组件自己作为数据来表示。因为这里每一个组件的数据可以认为是每个组件的全局唯一id,只要保证其唯一性即可。
reload 效率问题
列表刷新的效率问题主要集中在两个地方:
- 刷新导致重新计算布局
- 刷新导致大量更新视图数据
要解决这两个问题,可以增加size缓存、减少计算量以及视图更新的频次。为了减少计算量,这里引入增量系统。
对应于上面所述,我们将数据作为列表的核心,所以我们可以对数据源进行增量计算,对比得到变化的部分进行更新。而这也是很多系统所采用的方式,比如IGList。
这里为了一些异步能力考虑,并没有采用IGList这些成熟的开源框架,而是重新设计了更新流程。
1 | main thread: new data source -+ +-> force update -> reload collection view |
在触发更新并且重新生成快照之后,会进入两个异步线程,分别进行计算数据源增量和异步size计算(在需要极限优化性能的时候可以采用,在下个章节中讨论)。计算完增量后回到主线程,然后进行列表的更新。在这个过程中间如果触发了新reload,那么直接取消这一次的数据刷新操作,忽略该次结果。
这样,我们尽可能的减少列表的刷新和数据源的频繁计算,同时也在异步进行数据源增量计算(目前来看这个时间占总刷新时间的比例并不高)。
效率追踪
在以前我们写一个大页面的时候,如果出现性能问题,我们需要去查找问题的原因往往靠经验,也就是猜测,然后一个个排查。这样大大降低了我们排查问题的效率和准确性。
而且以前的问题都是后知的,也就是发生严重影响的时候,才会反过来修改。
而我们进行组件拆分后,每个类型的组件都是独立的,我们可以在任意我们所需要的地方,比如:
- size计算
- diff耗时
- cell渲染
进行插桩、统计与分析。那么我们可以在投入生产之前就能得到一手数据,甚至在这些地方加入限制与报警,从而进行精确的优化。
数据源differ协议
数据需要能够比对增量,就必须满足一定的规则,这里采用的和IGList一样的协议。但是这样会带来一些问题:
- 对每一个数据需要重写diffable协议,增加了开发工作量。
- 每一个数据都需要做copy,才能进行对比前后变化,这对工作量和性能都会有一定的影响。
- 并不能保证团队所有人都能够正确的理解和设计diffable内容,不良的设计可能直接导致增量系统的未知行为(比如不能保证数据的唯一性)。
- 在迭代过程中,数据diffable并不是一成不变的,可能会根据需求来改变其等价的条件。
- 在同一个应用中,可能在不同场景中的等价条件是互斥的,这样我们就无法复用该数据模型。
基于这些问题,我决定完全放弃基于diffable协议去做更新操作,而是基于数据去强制刷新。
1 | - (void)reloadDataWithForce:(BOOL)force animated:(BOOL)animated; |
在很多场景下,我们仅需要更新某个cell的数据,就可以直接采用该能力,而默认我们则采用数据指针这一个唯一值作为参照条件。这样可以让我们减少需要小心维护数据协议的大量工作量。
size问题
size计算一直是iOS开发过程中一个非常机械且重复的工作,而且特别容易出bug。
- 如果一个cell的布局非常复杂,那么这个计算过程也会显得难以阅读。
- 如果经过很长时间的迭代,也会让计算的逻辑变得复杂无比。
- 如果cell中有多行文字,那么计算文字既耗时,又会让逻辑变得混乱,同时一旦修改一个字体等属性,很容易遗漏了size计算中的属性。
- 如果cell中的元素比较动态,会根据不同数据做不同展示。
- size计算如果由每个人去写,会产生各种各样的写法,甚至隐含了很多的问题(目前为止见过太多这样的问题了)。
自动计算
其实cell最终展示的时候,我们是可以确认size的,比如安卓的listView,以及tableView的自动适应size。
既然我们可以根据cell的数据排版可以知道size,那么我们其实是可以自动计算出size的。这样不仅减少了一定的工作量,而且也避免了很多bug。
到这里,肯定很多人跳出来说,你这样做性能肯定不好。当然这么做性能可能会变差,但是以目前设备性能和未来的增长速度,大部分场景下性能是过剩的,那么我们为什么不去好好利用这一优势。就像有人说的,未来移动端肯定会被前端所统治一样。
要做到可以自动计算size,那么布局系统就不能使用手写frame的方式了。目前苹果提供给我们的AutoLayout布局就能很好的完成这个任务。
另外第三方的flexbox也是非常好的一种布局方式,关于flexbox布局之前我也介绍过,但是官方的YogaKit并不好用,所以我重新设计了一套与JSX、SwiftUI类似思想的方案,同时做了一些优化。这个之后可以专门介绍一下。
缓存
size计算另一个非常重要的点就是缓存。我们的列表往往是增量加载的,所以每次的数据和展示并不会发生变化,所以也就没有必要每次都计算一次size。
目前有一些第三方库可以做到这一点,但是很多都是基于indexPath的,而且需要自己手动进行缓存与查找。而在这里,我们的架构有着天然的优势,那就是我们是完全基于数据的,所以这里可以做到完全自动的缓存能力。只需要开启该能力即可:
1 | self.cacheEnable = YES; |
当然我们也会有手动清除缓存的接口和能力。
size性能优化
当然,我们也不排除一些页面存在性能问题,比如一个页面加载了非常大量的数据,或者页面布局过于复杂,或者数据解析比较耗时等等,都可能导致我们的自动计算耗时增加。
一种方式是对解析完的数据和布局进行缓存,这样能够非常好的解决刷新与复用的问题。
如果随着业务迭代,我们发现了性能出现瓶颈,那么我们可以精确的对一类数据进行优化。如下表所示:
1 | 自动计算 自动计算+缓存 手动计算 手动计算+缓存 异步计算 异步计算+缓存 |
但我们一般不推荐一开始就考虑性能问题,我们还是需要以质量和开发效率为主。
性能优化补充
一般我们反对一开始就考虑性能优化,因为可能这里根本不会存在性能问题,没有必要浪费大量的时间在这,而且会把简单的东西变复杂了。
但是从架构上,我们还是需要考虑到需要优化的这种场景与需求,在架构层面我们就需要能够支持一步步的优化与数据验证,这样才能够看到量化的结果和形成闭环。
1 | 开发 --> 迭代 --> 性能报告 ------> 迭代 --> 性能报告 |
通信问题
一个页面是有关联性的,当我们把一个页面拆分为多个组件的时候,就必然存在组件间通信的问题。
我这里的解决方案比较简单,做一个局部通知系统的机制,使用消息转发,只要组件实现了协议A,那么该组件就会自动接收到协议A的消息,从而达到了通信的目的。而这个通知系统只作用于当前vc和当前组件树中的组件,不会逃逸到其他地方。
这里为什么要使用通知这种方案呢?
- 消息传递的层次性。我们要通过delegate从组件A上传递消息到组件B上,那么整个链路可能是:
组件A
->section A
->viewController
->section B
->组件B
,整个链路非常长,而且难以管理。 - 消息并不一定是一对一的,存在一对多的情况。比如viewController的appearence事件,可能任意组件都需要知道这个事件。
优点
- 开发效率高
- 灵活且适用性高
- 解决了链路长的问题
- 在不同组件组合的场景下,具有更高的兼容性与解耦性
- 避免了消息逃逸到其他页面
缺点
- 和常见的回调、通知机制不同,需要一定的了解学习
- 需要在组件树中的组件才会收到消息,会在某些时候产生一定的误解
其他方案
这里并不限制和约束其他的通信方案,比如delegate,notification等。而该方案从总体来说,解耦的优势还是更为突出。
曝光问题
对于cell的display事件来说,在某些情况下可能是不准确的:
- reloadData触发复用的时候
- 组件移除组件树的时候,或者组件发生替换的时候
这和CollectionView与拆分组件的机制有关,所以这里设计了一套全新的曝光方案,使用数据,而不是cell作为曝光源,来做到更为精确的曝光。
下面简单的说明下思路。
曝光检查
此次采用的是主动检测方式,在列表发生滚动的时候,会触发一次曝光检查。检查的目的是为了找到:
- 哪些数据由
不可见
->可见
- 哪些数据由
可见
->不可见
这个可以利用CollectionView的visibleIndexPaths来查找,如果需要更为精确的数据,还可以自己再根据inset再校验一遍。
检查时间点
那么那些场景可能会需要检查?
- 列表滚动
- reloadData
- viewControllerAppearance
以及其他根据业务需求可以增加的检查。
效率
目前还未发现这种方案的效率问题,一般来说可见的组件也不会有很多,不会产生什么验证的效率问题。
并行开发与组件原子性
每个组件我们希望承担的功能尽可能的单一,只负责一类数据与一类视觉展示,这样我们就能保证组件的最小粒度,以及其原子性。
这样,我们就可以很容易的将一个大型的复杂页面进行拆分,分配给多人开发,而不会产生很大的冲突和依赖。
同时这也非常有利于我们快速的开发一个功能,以适应市场变化。
复用性
虽然组件化的一个很重要的特点是复用性,但是在实际情况下,可复用的组件依然占少数,而想要设计出一个能够比较好复用的组件是相当困难的,对人员的能力要求,以及对组件化的理解都很高。
所以这里我不想特意去要求高复用性,而更希望将组件做的更小,更简单,粒度更细。
最后
这是从个人经验上,从最早的DDComponent拆分思想,以及后来出现的IGList的diff思路,经过自身的大量业务实践,最终总结所得,同时兼顾简单、易学的一套方案。
其实这里可以做的更激进一些,以满足一些比如动态化、DSL的能力,但考虑到受众应该是所有层次的开发,所以不便做的太过复杂。
由于项目原因,暂不便公开源码。