通用应用架构——如何设计一个适用于多平台的前端通用架构

541 阅读21分钟

0. 引子

0.1 Android App架构回顾

old-android-app-arch.png

上图是在Compose出现之前Google为Android app设计的架构图。这一架构可以算得上是传播广泛且影响深远,以至于几乎每个Android开发都知道需要设计一个Repository来向上屏蔽Local/Remote两个数据源之间的细节。现在再次回顾这个架构会发现它似乎有点“头轻脚重”,对数据层的过于重视反而显得对UI部分有些忽略。当然这也是有“历史原由”的,Android最初选择Java作为开发语言,架构自然也深受服务端的影响格外重视对数据的处理。然而,在我们日常开发中可以发现,对于一个前端App来说,“数据”相关的代码占比并不大。

于是当Compose(声明式UI)横空出世后,Google在为其设计新架构时也重新调整了权重:

new-android-app-arch.png

声明式UI赋予了UI书写逻辑的能力,理所当然使得UI层占比提升。另外新出现Domain层意味着Google已经意识到APP中的逻辑并不是只有“数据相关”的,必须有一个容器来放置这些“数据无关”的逻辑。好了,对Android app架构的回顾就到此为止,毕竟本文主旨是设计一个适用于各平台的前端通用架构。

0.2 声明式UI (可略过)

当React Hooks横空出世后,业界对于声明式UI的态度可谓之狂热。面向对象不再是UI编程的最佳之选,函数式UI成为了新的最佳实践。如今几乎各个平台都可以使用函数来写UI:React / React Native, Vue 3, Flutter(with flutter_hooks), Compose, SwiftUI等等。我们所设计的架构将优先适用于这些平台,当然也可以扩展至其他命令式UI平台。
首先让我们来看看声明式UI带来了哪些新东西:

  1. 组件函数:实现UI的函数,接受props作为参数,返回UI。需要注意的是它虽然形式上是函数,但是它的执行是由框架控制的。它会在框架认为需要更新UI时被反复调用,这一过程通常称之为“重绘”。这是使得它并不能当做一个普通函数来看待。
  2. Hooks函数:基本等同于不返回UI的组件函数,它可以用来实现UI逻辑。注意它同样会参与“重绘”。Hooks里面可以定义状态和函数供组件函数使用。它的生命周期与组件函数是一致的,因此它很适合用来实现UI逻辑。
  3. Store:严格来说它并不是声明式UI的一部分。它的角色基本等同于OO(面向对象)中Class,包含了状态和方法,甚至在某些库中Store就是以Class的形式来描述。Store就是把Class中的状态封装为可以触发UI重绘的UI State。Store的生命周通常长于UI组件,因此常用于在组件间共享状态和逻辑。

以上三个组件在日常编码中非常常见,甚至可以说一个声明式UI的APP就是由三者交织组合而成。因此这三者同样会在我们的架构设计中扮演重要角色。

1. 架构设计

1.1 UI层

“天下架构始于分层”,我们的架构也不例外。分层的第一刀自然是按惯例切开UI和逻辑。声明式UI的组件函数实在是太强大太灵活了,使人下意识地想把所有代码都放进去。因此很有必要对其做些限制,确定一下哪些代码是可以放在组件函数中的,避免UI层的臃肿与混乱。
首先让我们回顾一下函数式组件的设计者在设计之初对其最朴素的设想:

UI=f(state).png

如图所示对于组件函数来说,它的输出就是UI,无论是dom、view或者其他什么东西,总之最终的效果就是把UI渲染到屏幕上。而它的输入则是UI State,每当state发生变化时重新执行一遍组件函数f就能把新的UI绘制到屏幕上了。所以根据这一定义,我们可以得出:组件函数内部的代码应仅限于根据state的值渲染相应的UI。组件函数内部应当都是一些分支逻辑,如 if(state == xxx)或switch(state)之类。而UI state当然是来自逻辑层,还有UI中的用户事件自然也是分发逻辑层来处理。

UI-Logic.png 遵守上述原则来实现的UI层逻辑非常单薄,输入输出也非常明确,因此这样的UI层是非常容易测试的。在单元测试中只需要传入不同的state来判断对应UI是否显示正确即可。而对于用户事件则需要模拟触发,然后判断逻辑层是否被调用就行。

PS:还有一点要注意的是,并不是所有UI state都要抽到逻辑层,对于一些不影响业务逻辑的state是可以放在UI层的。比如列表的展开/收起状态、按钮hover时显示tooltip等。

1.2 流程控制层

逻辑层的逻辑由UI层的生命周期用户事件来触发,相应逻辑执行完后会去修改UI State,从而触发UI层的重绘。 这符合声明式UI中的“逻辑不直接操作UI”的原则,也符合Flex中的“单向数据流”的思想。展开来说,数据流始于UI层的用户事件,自上而下流动到达最底层后再逐层向上返回,最终触发UI的更新。区别于Redux这里是用函数调用链来实现单向数据流的,因为调用链是天然的堆栈结构恰好与数据流的单向性质相符。而且调用链有语言层面的异常处理机制,任意环节出错后都可以把异常逐层传递直至UI层。完全符合“用朴实的技术实现复杂的概念”这一思想。
逻辑层的逻辑可以分为两类:UI相关的逻辑UI无关的逻辑。在我们的架构中,逻辑层将被一分为二,UI相关的逻辑通常包括一些控制UI流程的逻辑,如路由、弹窗等,因此命名为流程控制层。而非UI相关的逻辑则是一些纯粹的业务逻辑,如计算、数据处理等,因此命名为纯逻辑层

首先来聊聊流程控制层,由于它需要与UI层交互所以它必须要具备两个能力:能定义UI state能监听UI组件的生命周期。至于用户事件则是直接通过函数调用来实现的。
在声明式UI中,Hooks函数是非常适合担任流程控制层的角色的。理由如下:

  • Hooks函数可以返回一个由UI state和函数组成的对象,等同于具备了面向对象三大特性之一的“封装”。其返回的对象类型是流程控制层组件与UI层组件之间交互所约定的一个接口协议,符合依赖倒置原则(DIP),从而使得UI层与流程控制层能够解耦。
  • Hooks函数的性质与组件函数最为接近,使其可以最大程度的感知UI。例如,它可以使用useEffect来监听UI组件的生命周期,使用useState来定义UI state、它内部state的的生命周期与UI组件一致不需要手动清理、它会参与UI组件的重绘等等。
  • Hooks函数可以连接纯逻辑层,所以它很适合当一个“承上启下”的角色。他可以合并多个纯逻辑层组件,也可以包装纯逻辑层组件以更好的适配UI层。

PS:在命令式UI中,ViewModelController组件也可以担任流程控制层的角色,因为它们通常也具备上面提到的两个能力。

流程控制层的输入是生命周期和用户事件响应函数的调用,输出则是UI State。因此它的测试需要模拟生命周期或者直接调用用户事件响应函数,然后判断UI State是否符合预期。

1.3 纯逻辑层

纯逻辑层并不等同于传统意义上的数据层,而是数据层的超集。之所以这么设计主要是因为许多开发同学对“数据层”有些刻板印象,从而给其预设很多限制。例如,数据层对外暴露的类型只能是Repository、数据层不能包含业务逻辑、数据层不能拥有状态等等。曾经在一个项目中,我们对于账户登录相关的逻辑是否能放入数据层产生了争议。不得不承认在App中确实有一部分逻辑,它们与UI完全无关,与数据层具有类似的纯粹性但是不能归属到数据层中,例如音频播放器或者一些没有UI的第三方SDK。为了避免类似不必要的争论再次发生,我们将数据层扩充为纯逻辑层以容纳所有UI无关的业务逻辑。另外,所有异步操作都应该放入纯逻辑层,因为组件函数与Hooks函数性质特殊,它们并不能容纳任何耗时或者异步操作。所以如IO操作、密集计算之类的代码都应该放在纯逻辑层。

纯逻辑层中的组件根据内部是否含有状态可分为两类。无状态的纯逻辑组件通常是一些纯函数(Util Functions),它们的输入输出是确定的,不会有副作用。有状态的纯逻辑组件则是一些状态机,它们的状态会随着时间的推移而改变。这类组件通常用Store来实现,值得注意的一点是这类组件中的状态也是UI State,因为有些页面不需要流程控制层,UI组件会与纯逻辑层直接交互,关于这一点后面会详细展开。

纯逻辑层是应用业务逻辑的核心部分。一个应用被开发出来肯定有其使命,如展示类应用的核心逻辑是把服务端的数据获取到并呈现给用户,即时通讯类应用的核心逻辑是终端之间的信息交换,文件处理类应用的核心逻辑是文件的编辑等等。这些核心逻辑都是放在纯逻辑层中的。所以有一个简单的办法可以用来检验一个纯逻辑层组件的设计是否合理,就是假设当前应用需要开发一个命令行版,那么能否仅通过命令行调用纯逻辑层组件来实现应用的核心功能,如果可以那么这个纯逻辑层组件的设计就是合理的。 UI-Process_Control-Pure_Logic.png 纯逻辑层的测试是纯粹的语言层面的单元测试,不需要依赖任何额外的库。无论是有状态还是无状态的逻辑组件都它的输入都是函数调用,输出除了函数返回值外还有函数的执行状态。因为这里的函数可能是异步函数,UI层是不会阻塞的等待异步函数返回的,因此在异步函数的执行过程中UI上一版会显示loading,如果执行时机特别长时还会显示进度条。所以异步函数的执行状态本身也是一种UI State。在单元测试中倒不需要去测试loading状态,但是需要关注异常场景。另外,有状态的逻辑组件还需要测试状态的变化是否符合预期。

1.4 Vertical Slicing 纵向切分

在日常敏捷开发中我们应该都经历过从horizontal slicingvertical slicing的转变,在架构设计中同样如此。
如前文所述,以上三层组合起来可以形成一个数据流,而一个或多个数据流又可以组成业务单元,这里就可以和我们平时做的卡联系上。通常要实现一张feature卡就需要向应用中添加若干条数据流。这就是在架构中做vertical slicing的意义:敏捷开发中我们会把业务切分若干小的业务单元,而在后续迭代中往往会已业务单元为最小单位来修改,如移动或移除某个业务单元。所以在架构中同样以业务单元(数据流)为单位来组织代码,可以更加灵活的应对后续迭代中可能会发生的变化。 Uni-Arch.png 上图是加上纵向切分后的架构图。最左侧这一列是项目中的通用部分,它们不包含任何业务逻辑。项目中引入的大部分第三方库都是处于这个区域,日常开发中也可以在写业务时尝试做一些抽象,形成自己的通用库。
中间这一列是对应项目中会被多个页面使用到的公用业务逻辑,根据DRP原则,如果项目中有些UI或逻辑被不止一处使用到,那么就应该考虑把它们提取出来放到这一列中。
最右侧的这一列是项目中占比最大的一个部分,这一列的前两层在大部分项目占比都会超过50%,而其他部分也都是为了支撑这一部分而存在的。在图中我已经尽可能地画出页面、业务单元和数据流之间的关系,图中可以清晰看到App是由一条条数据流组成的,而每条数据流又都具备UI层、流程控制层和纯逻辑层(或者两者存其一)。这样的组织方式使得项目中的每个业务单元都是一个独立的整体,可以单独修改、移动、删除。这种架构很适合敏捷的开发方式,因为它可以很好的应对需求的变化。

2. 一些例子

上面已经论证了App是由数据流组成的,那么下面我们就来看看数据流是如何实现业务的。在上述横向分层的介绍中提到大部分组件都可以用函数的形式来实现,因此我们可以用函数签名来描述一个数据流。

2.1 计算器

用计算器这个简单的例子来描绘一个包含完整三层结构的数据流是怎样的。

Basic_Calculator.png

  • UI层:
fun Calculator(digitals: String, onKeyClick: (keyCode: Char) => Void)

计算器的UI包括两个部分,上部是数字区,下部是键盘区。上面函数中两个参数分别对应这两个区域。 digitals对应数字区显示的数字,onKeyClicked则是将键盘区的点击事件传递出去。由此可见计算器的组件函数里没处理任何逻辑,只是根据传入的参数渲染UI。

PS:这里Calculator函数中的入参并不是必须的,这么写只是为了表现UI层的输入输出关系。实际开发场景中完全可以在组件函数里直接调用hook函数。如:

fun Calculator() {
 val (digitals, onKeyClicked) = useCalculator()
}

只有当组件需要提供函数供外部调用时才需要把hook函数写在组件函数外面,并把hook函数的返回值以Props的形式传递给组件函数,这个场景会在后面的例子中展示。

  • 流程控制层:
fun useCalculator(): {digitals: String, onKeyClicked: (keyCode: Char) => Void}

在流程控制层的hook函数中,首先会定义名为digitals的UI State,然后定义handleKeyClick函数来处理键盘点击事件。在函数中会根据点击的键值来更新digitals。最后返回digitalshandleKeyClick组成的对象。这部分逻辑是与UI层相呼应的。除此之外在该hook函数中还需要记录键值形成的待计算数与运算符号,当需要计算时这些值将传入到纯逻辑层。

  • 纯逻辑层:
fun calculate(num1:String, operator: Charnum2: String): String

纯逻辑层的calculate函数负责运算,之所以数字是字符串是因为计算的数字可能很大。如上面所说,纯逻辑层包含业务的核心逻辑,如这里的“大数计算”逻辑。而且这部分是可以对接命令行独立运行的,也符合上述纯逻辑层的最佳实践。

可以看到在这里计算器的实现大体上均匀分为三个部分,每个部分都有自己的职责,各司其职。这样的设计使得计算器中每个部分都可以独立测试,也可以独立修改。假如后续要新增一个科学计算模式,那么只需要新增一个数据流即可,不会对原有的计算器造成任何影响,符合开放封闭原则(OCP)。新增的数据流中包含科学计算的键盘区、处理科学计算式的hook函数和处理科学计算的纯逻辑函数库即可。

Scientific_Calculator.png

2.2 音频播放器

音频播放器是一个仅包含UI层和纯逻辑层的例子。

  • UI层:
fun AudioPlayer(audioUrl: String)

播放器的UI包括一个播放/暂停按钮、一个进度条、一个显示当前时间的文本和一个显示总时长的文本。当传入新的audioUrl时播放器会自动播放新的音频。

  • 纯逻辑层:
interface PlayerStore {
    Boolean isPlaying
    Float progress
    String currentTime
    String duration

    fun setAudioUrl(url: String)
    fun pause()
    fun stop()
    fun seekTo(progress: Float)
}

可以看到播放器的UI与纯逻辑层提供的接口是完全对应的,因此不需要额外添加一个流程控制层了。而如果需要添加播放列表的功能,而纯逻辑层里又没有此能力的话,那么就需要添加一个流程控制层来管理播放列表、播放顺序、切歌等逻辑。
由此我们可以推断出哪些业务单元需要流程控制层:

  1. 业务单元的UI与纯逻辑层的接口不对应,需要一个中间层来适配。
  2. 需要融合多个纯逻辑层组件对UI层提供统一的接口。
  3. 没有纯逻辑层的业务单元,需要把逻辑部分放到流程控制层。

2.3 一个普通的取数据展示页面

这个例子将会介绍如何通过通用的流程控制层组件来简化实现。

  • UI层:
fun DataPage() {
    val (status, data) = useQuery(apiRequestFunc)
    
    when(status) {
        loading -> LoadingView()
        error -> ErrorView()
        success -> DataView(data)
    }
}

在UI组件被挂载时,触发网络请求函数来加载数据,然后根据函数执行状态和返回结果来渲染LoadingViewErrorView或者DataView

  • 流程控制层:
fun <T>useQuery(asyncFunc: () => T): {
    status: Status,
    data: T,
    error: Error,
    isLoading: Boolean,
    isError: Boolean,
    isSuccess: Boolean,
    ...
}

useQuery是这个条数据流的重点,它是由著名的异步状态管理库TanStack Query所提供的。它所做的事情其实比较简单,就是在组件挂载时去执行指定的异步函数,然后返回一系列关于函数执行状态的UI State以及函数执行结果。同样的,TanStack Query中还有一个useMutation函数,它不会在挂载时去执行异步函数,而是在UI层主动调用时才会执行,返回值则是和useQuery相同。我觉得这个库最大的意义在于它为声明式UI中的异步操作做了一个标准化的抽象,使得UI层只需要按照它约定的协议来进行编程,就可以对接任意异步函数。实现了UI层与纯逻辑层的解耦。如果我们更进一步,实现一个QueryView专门把useQuery的返回值转换为UI,那么我就可以实现全局统一的loading动画和错误提示,这样就可以大大减少UI层的工作量。

  • 纯逻辑层:
async fun apiRequestFunc(): Data

根据上面的描述,纯逻辑层可以是任意一个异步函数,网络请求、IO操作、密集计算等等。

希望能通过这个例子管中窥豹,看到一个通用的流程控制层组件的强大之处。个人觉得这块还有很大的挖掘空间,一般大家考虑开发一个公共库的时候都会考虑UI组件库或者通用的纯逻辑组件库,而通用的流程控制层组件库是一个被大多数人忽略的宝藏。当然开发这类库需要有一定的抽象能力,能在千变万化的业务中抽象出一套通用的逻辑,这是一项非常有挑战性的工作。但是一旦做出来,它将会减少大量的重复工作,提高开发效率。

2.4 登录模块

这个例子将会介绍如何把跨页面公用的逻辑封装为公用业务组件。

  • UI层:
fun LoginDialog(isOpen: Boolean, login: (username: String, password: String) => Promise<Void>)
fun LogoutDialog(isOpen: Boolean, logout: () => Promise<Void>)

登录模块的UI层主要是两个对话框。LoginDialog用来接收用户输入的账号密码,然后调用login函数来登录,登录时显示loading,完成后显示登录成功提示或者在失败时显示错误提示。LogoutDialog用首先会展示一个二次确认,当用户确认退出时调用logout函数来退出登录。同样的,退出登录时也会显示loading,完成后显示退出成功提示或者在失败时显示错误提示。

  • 流程控制层:
fun useAuth(): {
    isLogin: Boolean,
    login: () => Promise<Void>,
    logout: () => Promise<Void>,
    loginDialogProps: {isOpen: Boolean, login: (username: String, password: String) => Promise<Void>},
    logoutDialogProps: {isOpen: Boolean, logout: () => Promise<Void>},
}

可以看到useAuth函数返回值可以分为两个部分,isLoginloginlogout是供外部调用的,也就是给集成了这个登录组件的页面使用的。而loginDialogPropslogoutDialogProps则是给登录组件的UI层使用的。所以这个函数需要在UI组件外部调用,然后把返回值传递给UI组件。即:

fun SomePage() {
    val {isLogin, login, logout, loginDialogProps, logoutDialogProps} = useAuth()
    
    LoginDialog(loginDialogProps)
    LogoutDialog(logoutDialogProps)
}

当外部页面调用login时,useAuth函数首先会让LoginDialog显示来引导用户登录,然后当UI层调用Props里的login函数时,它会去调用纯逻辑层的登录函数。函数执行完后,如果登录成功则会延时数秒用于展示成功提示然后自动隐藏;如果登录失败则会一直展示错误提示不会自动隐藏。退出登录也是类似的逻辑。

  • 纯逻辑层:
interface AuthStore {
    Boolean isLogin
    fun login(username: String, password: String): Promise<Void>
    fun logout(): Promise<Void>
    fun getUserInfo(): Promise<UserInfo>
    fun getToken(): Promise<String>
}

登录组件的纯逻辑层是一个Store,它包含了登录、退出登录、获取用户信息和获取token等方法。这里的getUserInfogetToken方法是为了给其他业务单元使用的,比如在请求接口时需要带上token,或者在页面上展示用户信息。这个Store其实是一个小型的状态机,它维护了登录状态,而loginlogout函数则是其状态流转函数。所以它里面的函数在执行前要先检查一下当前状态是否满足条件,否则直接抛出异常。

这样就完成了一个公共业务组件,它可以被任意需要登录/登出功能的页面引入。同时它也可以把登录认证相关的逻辑聚合到一起。这就是以业务单元为单位的架构的好处,一个业务单元它可以是一个页面专用的,也随时可以转换成可以跨页面使用的公共组件。另外,如果一个业务组件的抽象程度做得足够高,它也可以成为一个通用业务组件给其他项目使用。

3. 总结

本文主要介绍了一个适用于多平台(声明式UI)的前端通用应用架构。该架构由组件函数实现的UI层、Hooks函数实现的流程控制层和纯函数实现的纯逻辑层组成。从纵向的角度来看,整个应用是由数据流组锁构成的业务单元组合而成的。因此这样一横一纵的切分把应用切成了网状,而数据流就是贯穿其中的线,所以前端应用的结构可以看做是一张由数据流交织的网。
这样的架构设计有以下几个优点:

  1. 权责清晰:每条数据流由UI层、流程控制层和纯逻辑层组成,各司其职,职责明确。
  2. 易于实现:每层之间的交互都是通过函数调用来实现,适用于所有编程语言。
  3. 面向变化:以业务单元为单位的架构设计使得应用可以很好的应对需求的变化。

希望本文能对大家有所启发,谢谢!