本文非常长,阅读需要勇气。
作者尝试在移动端总结出一套面向页面的架构设计,暂定命名为POA(page-oriented architecture),因为核心的关注点在于page,阅读本文更多的是了解移动端架构的方式方法。
另外,本文主要是方法论层面的阐述,具体案例因为每一种编程语言的不一样实现会有所不同,所以文中代码均为伪代码。作者已经部分实现这套方案,但具体的实现并不重要,重要的是希望这套方法论能给读者带来一些收获。
面向页面的架构的定义: 以页面为自治单元,定义页面的唯一标识,对页面进行模块化,解决页面的注册、页面栈的治理、页面间通信、页面的分层架构、页面的组件化等问题。
这里要先给出模块和组件的相关的定义,本文中所说的这些概念均基于以下的定义:
为什么要以页面为自治单元呢?
首先理解下自治单元,在 SOA 的场景下,一个 service 是一个自治单元,但是 service 的划分是无法标准化的,这增加了 SOA 架构设计的难度,直到出现 FAAS(function as a service)出现,function 是一个很好标准化的自治单元,极大降低了 SOA 架构设计的不确定性。
那么以页面为自治单元有以下一些原因:
下面附上为POA设计的模块、页面、组件的物理架构图模板,物理架构是树形结构的,从模块到页面,从页面到组件,都是如此。
以页面为自治单元是本文的 POA 架构设计的基石。
在前端领域,一个页面必然对应一个 URL,但是在移动端领域,这个标准不存在的,虽然 iOS 和 Android 系统自身会提供一些 URL 来调用系统页面的能力,但更多时候我们需要自己写一个 UIViewController或者Activity,然后直接通过依赖其类型来打开页面,这就造成了页面间更多的依赖性。
为了解耦页面间的依赖,直接采纳前端的方案,引入 URL 作为页面的唯一标识。通过这个唯一标识,我们可以打开、关闭页面,也可以跟页面进行通信。
需要注意的是,这个唯一标识只是在当前系统上下文内是唯一的,跨系统的情形不在本文的套路范畴之内,或者你可以认为我们主要讨论的是 内链 ,跨系统的页面 URL 是 外链 ,内链默认都是可信的,外链才需要关注安全性。外链可以通过转发内链来打开页面。
对 URL 的具体定义可以根据页面在树上的路径来,类似于如下的规则:
当页面更复杂的时候,可能需要增加一个 feature 路径
页面的模块化,我们需要决定哪些页面应该分在哪个模块下面,前面也提到,这是一个存在不确定性的过程。
从业务的角度,我们很多时候需要通过面向对象的思想来分析,抽象名词,选出合适的名词作为关键实体,关键实体可能就是我们需要划分出来的模块。再往深一点看,我们需要对业务进行建模,这里不展开,我了解也比较肤浅。
模块划分之后,有一个办法可以用来验证模块划分的合理性。首先模块是由页面组成的,当我们抛弃模块之间臆想出来的依赖之后,剩下的就是这些实实在在物理层面的页面依赖,根据下面的方式来验证:
模块的划分,在物理层面应该是可以独立发布的包,所以同样适用于以下设计原则, wiki :
前三个原则,较简单,不做阐述,后三个原则在后续章节会有更详细的说明。
模块化编程的具体含义可以参考 wiki 。
从我们对页面的定义看,一个页面本身就可以是一个最小的模块,页面座位自治单元,完全可以遵循模块化编程的规范,那么模块自然就很好遵循模块化编程的规范。模块的接口即是所有模块包含的页面。最小的模块可以只包含一个页面。遵循模块化编程可以让模块具有以下优点:
页面间的依赖通过路由系统可以解耦掉,但模块间的依赖是不可避免的。
因为最终整个系统要由各个模块构成,系统会直接或者间接的依赖于所有模块。模块间的依赖不能违反无环依赖原则(ADP),这个在模块划分之前就应该解决掉。
那么我们充电来看看如何减小模块之间的依赖。从前文的系统物理架构上看,整个系统的实际上是一个树形结构,我们对模块间的依赖要通过系统的罗架构图来描述,最理想的情况下,逻辑架构图跟物理架构图是一致的。但现实的业务系统中这是不可能存在的。
知道了理想情况是什么后,我们所能做的就是如何往理想的情况去设计就可以了。如何做到呢,其实也不算多深奥吧,前问已经提的稳定依赖原则和稳定抽象原则。
怎么样才能算是稳定依赖呢?一个模块如果不依赖任何模块,那就是最稳定的模块,从整个上下文看,单一页面的模块是最稳定的(页面本身依赖的外部代码不在模块化的范畴之内)。另外页面变更的可能性在整个系统的生命周期中非常少,所以可以认为单一页面的模块是稳定模块,但如果我们每个页面都搞成一个模块,这个反而可能增加了整个系统的模块间依赖复杂度。
要对模块间依赖进行量化,可以通过不稳定度和抽象度来进行。
不稳定度的计算公式: I=Ce/(Ce+Ca) Ce:代表离心耦合(Efferent Coupling)模块依赖的外部模块的数量 Ca:代表向心耦合(Afferent Coupling)模块被依赖的外部模块的数量 I:不稳定度,介于[0,1] 当Ce = 0的时候,模块被依赖的外部模块数量为0,结果是 I = 0,表示该模块最稳定。 当Ca = 0的时候,模块依赖的外部模块数量为0,结果是 I = 1,表示该模块最不稳定。
下面的图沿着箭头的方向会(依赖的方向),数字是不稳定度的值,稳定性逐渐升高。 比较简单的场景如下,依赖层级为1的情况如下图,整个App永远是最不稳定的
依赖层级为2的情况如下图,当增加依赖之后,比如 Module1、Module2 的不稳定度都增加了,依赖越多,不稳定度增加的越高,所以要尽量 最少依赖。
依赖层级为2,且产生菱形依赖的情况如下图,Module6 被 Module2 和 Module3 同时依赖,问题好像不大,因为 Module6的不稳定度未发生变化,Module3 因为要依赖 Module6,所以不稳定度从 0 变成了 0.5。
我们再看看下图中,如果 Module3 实际上只是依赖于 Module6,但通过依赖 Module2 间接的依赖于 Module6 也能达成目的。结果是,Module3的不稳定度一样从 0 变成 0.5,Module2 的不稳定度反而从 0.6 变成了 0.5,变得更稳定了。
所以,在增加依赖关系的时候,应该选择对更少的模块的不稳定度产生影响的方案。通俗一点来说,可以认为是 最小依赖 。
抽象度的计算公式: A = Na / Nc Na 表示模块中抽象类的数量 Nc 表示模块中所有类的数量 A 表示模块的抽象度 Abstractness 抽象类在很多编程语言中都会存在,只是形式不一样而已,比如:Objective C语言中的protocol,Java语言的Interface,dart语言的mixin和abstract类等。
但在本文中,需要对抽象度做更进一步的说明,我们的模块主要是由页面来组成的,如何说明抽象度是一个需要解决的问题。在前文中我们也提到通过 URL 来打开、关闭页面,或者与其它页面进行通信,那么抽象的计算可以通过 需要定义 URL 的页面数量除以 所有页面的数量来表示。这里所说的需要定义 URL 的页面,表示这些页面会由其它模块来打开、关闭、或者与其进行通信的页面。所以对抽象度的计算公式,Na 表示模块中被外部模块依赖的页面,Nc 表示模块中所有页面的数量。
通过结合抽象度和不平衡度,我们得到上图的四个极端情况:
一个合理的模块,应该处于平衡线的附近,需要注意的时候,很多人可能认为单页模块会是最好最简单的模块划分方式,理论上可以这么认为,但实际上我们一般不会这么做。开发成本过高,系统限制等等因素导致我们几乎不可能做到。
再进一步的,我可以通过计算与平衡线的距离占比来计算平衡度 B, 公式如下: B = abs(1-I-A)
虽然我们可以通过量化的数值来一定程度的表示模块的合理性,但现实中模块的依赖关系不会如此单纯,依然需要从不同测切面切入去理解各个切面下模块的合理性,所谓全局的合理性是不存在的,或者计算出来也是没有意义的。
我们在中谈到了对页面的 URL,这是页面间解耦依赖的方式,使得我们可以通过 URL 就能打开、关闭页面或者与页面进行通信。我们下面是来一步步看看这种解耦方式的细节。
那么通过 URL 我们如何才能打开一个页面呢?关闭页面和与页面进行通信会在相关的章节中有说明。
在移动端,如果是 web 的 URL,我们需要调起嵌套 webview 的页面来打开这个 URL,如果指向的是原生的页面,处理方式是找到 URL 对应的页面并打开。那这里就存在一个 URL 和 页面之间的对应关系, 页面的注册
就是将 URL 对应到页面的行为,并产生一个 URL -> Page
的注册表,在图表中名为 PageRegister。 PageRegister 可以让我们不必依赖具体页面的模块也可以间接打开页面。
如下图,PageRegister 成为一个稳定包存在,Module是模块化的需要依赖的包,Module依赖于 PageRegister 来实现注册 注册页面的切面 ,其它所有模块依赖于 Module 包,当一个模块需要注册页面的时候,我们只需要依赖于 Module 就可以了。
提供了页面注册表,我们还要解决在哪里注册页面的问题。为此,沿着模块的物理架构,增加一个模块的挂载树。
从图上看,挂载树就是模块部分的结构,沿着箭头的方向,将所有模块一一挂载到树上。父模块负责挂载子模块。
有了模块挂载树,我们可以在模块之上增加一个切面,用来注册页面。当一个模块需要注册页面的时候,可以通过继承的方式增加一个注册页面的函数入口,这个函数沿着上一节图中的箭头方向一一调用每个模块的注册页面的函数。
注册页面的函数中可将模块下面的所有页面都注册到注册表中。
有了模块挂载树,我们可以对模块增加各种切面,默认情况会提供 模块初始化切面 ,也可以增加 模块异步初始化切面 等。
如果你的编程语言限制泛型的实例化,比如 dart语言,你可能还需要增加 JSON解析器的注册切面 。
本质上是一些列的初始化行为,但通过切面的形式进行细分,职责更明确。
前面章节中,我们把页面的模块化、页面的注册都做了说明,接下来的核心关注点就是页面的栈,页面栈上都是页面的实例。
在页面结构简单的App上,一般只有一个页面栈,但很多App其实不只是存在一个页面栈,比如在iOS中,首页底部有tab菜单的,每个tab都可能有一个自己的 UINavigationController
。这个可以认为是页面的物理结构上的页面栈,因为我们无法抹平不同系统不同App页面栈设计上的差异,所以我们不去关注页面的物理页面栈,页面通常情况下以一个后进先出的方式出现在屏幕上(其他场景不在本文讨论的页面栈的范畴),所以我们这里讨论的页面栈可以认为是一个逻辑页面栈。
后续文中所说的页面栈,都是指逻辑页面栈。在图表中用词为 PageStack
。
承接上面的图,打开页面的依赖关系图如下:
每个模块必然依赖于 Module
包,因为页面都要注册到注册表中。几乎每个模块都需要打开页面,依赖于 PageStack
包来获得打开页面的能力。
当一个页面被打开多次的时候,在页面栈上会有多个页面的实例。
打开页面的接口, push
将页面推入页面栈,需传入页面 URL,可传入参数。
func push<T>(String url, T params) -> (Bool) -> Void func push<T,U>(String url, T params, (U params){}) -> (Bool) -> Void 复制代码
我们可以提供在页面栈上插入一个页面的接口, insert
将页面插入某个页面之上或之下,需传入参照页面的 URL,上面还是下面的标志位,但有的系统不支持插入一个页面到页面栈上。
func insertBefore<T>(String url, String refUrl, T params) -> (Bool) -> Void func insertAfter<T>(String url, String refUrl, T params) -> (Bool) -> Void 复制代码
页面关闭的逻辑架构图如下,只需要依赖于 PageStack
就可以了。
页面关闭的接口, pop
, remove
:
pop
是关闭当前栈顶的页面,无需传入页面 URL,可以传入回调参数给打开被关闭页面的 push
方法; remove
是关闭一个特定页面,需传入该页面的 URL func pop<T>(T params) -> (Bool) -> Void func remove(String url) -> (Bool) -> Void 复制代码
以上需要传入 URL 的场景,如果页面栈中存在多个对应于 URL 的实例,则定为最顶部找到的页面实例。
页面间通信,实际上在很多路由库上就存在,包括由编程框架本身提供的也存在页面间通信。
我们把以下情形也当成页面间通信:
push
的时候带上参数给将被打开的页面 pop
的时候带上参数回传给打开这个页面的 push
方法的回调
这两种情形适用场景有限,能满足打开与被打开页面之间的通信, pop
回传参数的优势是不需要明确在哪个页面被打开了。这里需要解释下,我们把页面当成自治单元,那默认为我们的 push
也应该发生在某个页面中。
我们还需要与任意一个页面都能通信的能力,比较常见的场景是,一个列表上,点开一项的详情页,然后进入处理页,处理完了,我需要告诉详情页和列表页发生了什么,是否需要刷新页面上的数据。这里实际上会需要同时与两个页面进行通信。
总的来说,我们还需要一个能发送参数给任意页面的一个通讯方式,包括发送端和接收端,伪代码如下:
发送端
func notify<T>(String url, String name, T params) -> (Bool) -> Void 复制代码
接收端,分不同的方式,如果是类似 Flutter 的实现方式,最好是提供一个 Widget 来接收,因为 Widget 可以插到任何一个 Widget 树的节点上,有效控制页面刷新范围,伪代码如下:
typedef WillNotify<T> = T -> void; class WillNotifyScope<T> extends StatefulWidget { const WillNotifyScope({ Key key, @required this.name, @required this.willNotify, @required this.child, }); final String name; final WillNotify<T> willNotify; final Widget child; } 复制代码
如果是原生端,实现方式需要依赖于 UIViewController
和 Activity
,通过协议的继承,让一个页面决定自己是否要接收通知,如果接收就继承协议。
interface OnNotifyListener<T> { func onNotify(String name, T params) } 复制代码
大多数场景下,我们都是在一个平台内处理页面栈,但如果我们引入了 Flutter、H5的话,这一整套关于页面栈的实现就会非常复杂了,具体的实现细节可能需要实践之后才能完整阐述 。
为了实现跨平台的页面操作和页面间通信,我们需要适配 Flutter 和 H5,并在三端提供统一的页面操作和页面间通信的API。
不遗余力的要实现这样一套框架,带来的好处是值得的:
为 web 页面注册 URL 到 PageRegister
。
在iOS下面,通过 WKWebview拦截这些 URL,然后全部使用嵌套了 WkWebview的 UIViewController 来打开,体验上基本跟原生一致,但这就要求 H5 放弃自己的那一套页面间通信的方式。
iOS下面开多个 WkWebview 的代价已经不算太高了,这种方式是可行的。
Android下面比较复杂,考虑复用 webview 的方式,以截图来展示不在屏幕上的 Activity 也是可以实现的。当然不通过这种方式实现也是可以的,我们的目的是记录一个完整的路由栈,统一三端的页面操作和页面间通信的 API。只要能达到这个目的就可以了。
关于 Flutter 的实现这里有一个可参考的样例 flutter_thrio ,不做过多展开。
下图是页面栈的依赖图,
从数据流看,页面间不管是 push
, pop
, notify
,数据流是几乎是一致的,这在实现上可以做到流程的复用。
页面栈的日志,在提高移动端App的稳定性的作用是非常大的。这么比喻吧,微服务的分布式调用链的意义有多大,页面栈的日志层面的作用就有多大。没有页面栈的日志,我们在面对很多崩溃问题时,如果堆栈信息不足的话,解决的难度是很大的。 页面栈的日志可以帮助定位发生崩溃的页面 ,就是这么简单直接。
页面栈的日志,在设计良好的情况下,完全可以用来重放页面的跳转行为。结合网络请求日志,几乎可以重放整个App的行为。这个能力可以用来 实现自动化测试 ,或者 重现用户的行为 。
有了页面栈,附加网络请求日志,等于拥有了用户的所有行为。在大数据层面的意义比各种无痕埋点更具有针对性,又比手动买点更自动化,而这仅仅是页面栈的一个副作用。
我们这里说的分层架构是逻辑上的分层架构,不是传统的物理分层架构,比如经典三层架构,领域驱动的四层架构。
逻辑分层架构包括,MVC、MVP、MVVM等,这些逻辑分层架构在各个编程框架上是不一样,比如iOS上的MVC,Android上比较灵活,MVC、MVP、MVVM都有。
在 POA 的架构设计下,这些逻辑分层架构用哪一种已经显得不是那么重要。因为页面已经足够简单,MVC很多时候也能很好的解决,MVVM反而过于复杂了。下一章节的中还会讲到如何对复杂页面进行组件化。所以即使存在一个很复杂的页面,我们依然可以通过组件化来分解成更小的组件。
在POA架构设计模式,一个页面可以使用MVC的方式来开发,也可以使用MVP或者MVVM,都是可以兼容的,因为页面本身就是最小的应用程序,开发者可以决定使用任何一种方式。所以我们不用关注在POA下使用哪一种的好与不好。
当然,像很多更新的移动端开发技术,比如 Flutter,我们在开发页面的时候,甚至不用关注这些逻辑分层架构,所以POA在很大程度上可以兼容未来出现的更多逻辑分层架构技术。
组件化是一个前端领域的概念,可以参看 wiki 。
在移动端,没有严格意义上的组件化,因为 iOS 和 Android 系统的事实标准都在苹果和谷歌公司手里,不是一个开放的标准。另外系统本身的复杂性也很难像开放的前端一样,可以有各种标准,也可以一个人开发一个编程框架。
虽然没有严格意义上的组件化,但当 UI 布局越来越复杂之后,页面的拆分是一个必须要进行的事情,组件化只是一个指导我们如何将这个拆分做得更好的一个理论支撑。
页面最终是由编程框架提供的各种控件组成的,当然自定义的自绘控件也在控件的范畴之内。控件不是组件,组件是控件的组合。组件可以有自己的逻辑分层架构,这会让组件更限制于目前业务场景。
如果我们不抽象组件的话,那么实际上, 页面 = 控件的堆积 ,所有的页面我们都需要堆积控件,即使很多时候页面中存在很多相似的块,但我们都需要从控件开始堆积页面。另外,编程框架本身提供的控件实际上是很基础的,粒度很小,这无形中给我们带来了非常多的重复的工作量。
组件化的出发点就是为了复用组件,这里我们说的复用,主要是指在当前的业务场景下的组件复用。通过组件化,我们在开发页面的时候可以更高效, 页面 = 组件的组合 。
理解了页面组件化的目的,为了达成目的,我们需要有一套针对页面组件化的方式来支撑。
一个组件被复用的次数越多,一定程度上说明组件的复用性越好。但这不是全部,因为即使一个组件只被复用两次,我们也需要拆分组件,甚至只有一次,也需要拆分。唯一的能改变的是,如何拆分组件能提高组件被复用的次数。
但组件的可复用程度,很多时候依赖于页面设计时对业务的抽象程度,依赖页面设计者的个人认知能力,无法在此展开。但我们可以判断组件封装的合理性。
组件的封装数,可以由组成组件的组件或控件个数来判断。下图中,组件左上角数字为封装数,右上角数字为组件的不稳定度。
封装数不是越高越好,看组件0,封装数为8,很明显这会是一个很复杂的组件,更像是一个页面,封装数越高,组件的不稳定度就越难以降低,如下图:
x:被复用次数,y:封装数,所以封装数过高,组件的不稳定度会难以降低。但封装度过低,组件封装的意义可能就不存在了,比如组件6,封装数为2,且有一个控件在里面,两者很可能应该封装成一个组件就可以了。
合理的封装数要在这个区间内 (1,8),结合复用次数,加快组件不稳定度的降低速率。
作者对移动端架构的设想过于理想化,现实的架构很难实现真正的POA,但理论总是要总结的,理论可以指导我们如何在混乱的移动端架构领域寻找最佳实践。
剥去太多的虚无和神秘,架构需要庖丁解牛,我们需要关注模块、页面、组件,还有代码包,需要关注如何模块化、页面栈的治理、组件化,在无法标准化的移动端架构领域寻找可以标准化的一点一滴。
参考
web components
service-oriented architecture
function as a service
bang - iOS 组件化方案探索
domain model
domain driven design
package principles
modular programming
separation of concerns
multitier architecture
Difference between MVC and 3 tier Architecture