本文要点
用户界面是都反应式系统,由用户界面应用程序接收的事件与应用程序必须在接口系统上执行的动作之间的关系来确定。
函数式 UI 是用于用户界面应用程序的一组实现技术,其强调的是应用程序的效果部分和纯函数部分之间要有明确的界限。
用户界面的行为可通过状态机建模,状态机在接收事件时会在界面的不同行为模式之间转换。状态机模型可以直观且经济地可视化,吸引了各种角色的构建者(产品所有者、测试人员、开发人员),并且能让设计错误在早期开发过程中就暴露出来。
用户界面模型可以自动生成用户界面的实现和测试,从而获得更灵活,更可靠的软件。
基于属性的测试和蜕变测试利用自动生成的测试序列来查找错误,而不必定义用户界面对测试序列的完整而准确的响应。这样的测试技术在两个流行的 C 编译器(GCC 和 LLVM)中发现了 100 多个新错误。
介绍
函数式UI依赖一种显式函数关系,将用户界面接收的事件与界面应用程序必须在接口系统上执行的动作链接起来:
(1) (action_n, state_n+1) = f(state_n, event_n),其中:
-n 是应用程序处理的第 n 个事件,
-state_n 是处理第 n 个事件时反应系统的状态,
-f 称为"反应函数"
这种函数等式已经在Elm前端框架的推广下广为人知,并在受 Elm 启发的一系列语言和框架中得到使用。
本文介绍了另一种函数式 UI 技术,其依赖于一种用户界面应用程序行为的模型。该模型使用了状态机,将应用程序对事件的反应描述为机器状态之间的一种转换。
首先将等式(1)中的状态分为控制状态和扩展状态,将等式改写为状态机模型的形式。然后,我们提出一种直观而严谨的视觉形式,准确而简洁地描述应用程序的行为。本文将用一个带有具体 JavaScript 实现的示例应用程序来说明该方法。
上一篇文章还解释了函数式 UI 如何简化用户场景的单元测试来增强应用程序的可靠性。基于模型的测试则更进一步,可以允许开发人员完全或部分自动化代码生成和用户场景生成作业。面对大量测试时,基于属性状态的测试可以测试接口的特定不变项来检测出错误。用户界面模型使开发人员可以测试规范(模型),而不是测试特定的实现,从而降低了测试的脆弱程度。这里也会使用一个示例应用程序来具体解释。
当模型方法只适合部分应用程序行为时,可将其与类似 Elm 的方法(等式 1 所述)混合使用。这种灵活性是使用纯函数的直接好处,可以提供更好的可组合性。
使用模型时有多种方式可用来确保规范与实现之间保持一致,这也是在注重安全性的行业中普遍使用模型驱动软件的原因所在。
使用状态机对用户界面行为建模
在上一篇文章中,我们给出了一个简单的小猫动图搜索程序的示例:
一种反应函数映射为:
下面变复杂一点,要求应用程序在接收“More please”按钮之前等待图像链接被提取。该应用程序现在有两种模式,一种是在应用程序正在获取,另一种是未获取。这意味着我们有两种截然不同的反应函数,应用在两种程序模式中。
当应用程序准备获取时:
当应用程序忙于获取时:
可以在上述基本等式中将模式(称为控制状态)与其余状态(称为扩展状态)分离开来表示这种情况。在这里:
变成:
从中我们可以根据模式(控制状态)选择要应用哪个子反应函数:
事实上,许多用户界面都表现出以离散模式为特征的行为,其中反应函数具有更简单的形式。Web 应用程序中的路由是一个经典例子。对于应用程序的每个路由,都会有一个单独的反应函数来计算对事件的反应。对于具有两个路由(home 和 about)的应用程序,在伪代码中,我们将有以下类型的内容:
另一个经典例子是一款街机游戏,其中玩家的输入将根据玩家角色在特定时点的行为而导致不同的动作:
我们将模式称为"控制状态"(control state),因为我们根据模式的值将正在运行的应用程序的控制流从反应函数转移到某个子反应函数。我们将其余状态称为"扩展状态"(extended state),因为这种形式的反应函数描述了一种称为扩展状态转换器的状态机。扩展状态转换器是带有内存的状态机,可处理输入并产生输出。状态转换器和 f 之间的关系如下:
机器的状态是上述模式(控制状态),
机器的内存是扩展状态,
f 输出的动作(actions)是机器的输出,
由 f 计算的扩展状态和控制状态定义状态机的转换
总而言之,应用程序行为的规范由反应函数来描述。该反应函数可以写为一个状态转换器,其在接收输入(事件),和在计算模式(控制状态)之间转换时,更新其内存(扩展状态)并输出要执行的命令。这有什么用呢?
走向视觉形式
状态转换器可以通过链接机器状态的图形直观而准确地表示出来,并且用户场景就是该图中的路径。之前举例的 Elm 应用程序的用户界面行为可以总结为如下的图像:
这里使用的视觉形式非常简单。这两个节点是我们修改后的提取小猫动图的应用程序的两个控制状态。节点之间的边标记的是应用程序处理的事件。标记为 e/c 的边有事件 e 和命令 c,而将节点 A 链接到节点 B 的是以下语义的可视化表示:**假定(given)应用程序处于控件状态 A,则当(when)事件 e 发生时,那么(then)**应用程序的新控制状态为 B,而命令 c 由反应函数返回。
应用程序会接收事件,但既不会导致输出,也不会通过反应函数改变控制状态的事件是不会被表示的。例如,假设应用程序处于 Busy 控制状态,则在发生 More Please 事件时,应用程序应忽略该事件,这意味着反应函数会返回一个空输出。因此,没有哪条边具有 More Please 事件并保持 Busy 控制状态。相反,在 Ready 控制状态下就存在这样的边。
这种视觉表示有一些显而易见的重要好处:
它是应用程序行为的简洁而准确的表示:它明确回答了以下问题,那就是"事件 X 发生时会发生什么?"
只要遵循图形的给定路径,就可以轻松地可视化用户场景(前文加粗的BDD术语 given/when/then 就是为了说明这一点)
它的行为描述也可能比等效的代码更具可读性,至少对于那些可能不熟悉编程奥秘的读者而言是这样的。
此外很重要的是,我们先前是从反应函数的伪代码来实现可视化的。相反的路子往往更有价值:首先绘制可视化图形,然后编写或自动生成与之匹配的代码。为此,必须使用一种可视化语言来描述可视化图像,其中可视化语言的语义可以复制必要的代码语义。有许多这样的可视化语言,它们大多是从David Harel在Statecharts上的开创性工作中获得了启发,并做出了改进。本文使用 Kingly 状态机库中定义的可视化语言,其中 Kingly 用于实现所提供的示例。
如果能有一种可视化、像代码一样精确的规范语言,可能会极大地帮助程序员,开发出更强大、更可靠的软件。这是为什么?图灵奖的获得者Frederick Brooks在他的著名文章《没有银弹——软件工程的本质与意外》中,将与问题空间相关的基本复杂性与给定解决方案空间中的偶然复杂性区分开来。Brooks 进一步阐述了他的理念,那就是随着系统所经历的状态数量呈指数级增长,难以理解正在开发的软件应做哪些工作,以及与他人交流和软件固有的无法可视化属性是现代软件开发面临的核心难题:
开发软件产品时遇到的许多经典问题都源于这种基本的复杂性,并且其非线性会随着开发规模的增长而增加。由于这种复杂性,团队成员之间难以沟通,从而导致产品缺陷、成本超支和进度延迟。这种复杂性导致开发人员难以枚举且难以理解程序所有的可能状态,由此降低了软件的可靠性。
(……)
构建软件的困难之处在于决定该做什么,而不是具体做事的过程。
(……)
尽管行业在限制和简化软件结构方面取得了进步,但它们本质上仍然是不可见的,因此开发人员无法在头脑中使用一些最强大的概念工具。这种缺失不仅影响了人们脑中的设计过程,而且严重阻碍了人与人之间的交流。
可以解决前述痛点的可视化规范语言是降低软件本质复杂性的良好备选方案。回到前面提到的街机游戏。下图:
(来源在这里)
这里以游戏设计师、项目所有者或游戏开发人员可以快速理解的方式可视化游戏需求的特定部分,而无需编写任何代码。在讨论和探索需求时可以继续使用这种视觉形式,然后用事件和之前提到的动作来标记图的边,从而增强精度。实际上,图像可以做得足够精确,乃至可以自动从中生成代码。
对抗非线性状态增长的层次结构和历史记录
虽然上文的视觉形式解决了 Brooks 所关注一部分问题,也就是决定该做什么和人与人之间的沟通障碍,但如果不厘清快速增长的状态,可视化也是无济于事的。这可以通过以下方法实现:
仅表示以某种方式更改机器状态(控制状态,扩展状态)或产生输出的转换,
使用扩展状态来捕获控制流中不包含的状态,
将机器的控制状态描述为一种层次结构,
使用这种层次结构将多个转换解构为一个,
添加一种方法来恢复机器的过去配置(历史记录机制),
我们来详细介绍最后三个项目。
用层次结构解构行为
请注意,反应子函数的签名类似于顶级反应函数。这意味着将状态分为控制状态和扩展状态的过程可以递归应用。设想一个有多个路由的应用程序。先前的示例有两条路由:
假设 About 页面有一个 Team 子路由和一个 Main 索引路由,我们可以依次编写:
由于必要时还可以扩展 f_about_team 和任何反应子函数,因此自然会出现控制状态树。该树可以视觉化表示。来看一下带有嵌套路由的更复杂的示例:
可以看到控制状态的层次结构由包含关系反映出来。例如,About detail 控制状态包含在 About 控制状态中,而其本身包含了 Team 控制状态。
此外同样重要的是,包含关系可用于解构反应。例如在规范级别,无论应用程序处于什么状态,当更改 URL 时,应用程序都应显示与新 URL 对应的路由。在可视化级别,我们有五个控制状态(实际上是七个,没有事件触发的转换的两个 Routing 控制状态一进入就被舍弃了)。这意味着需要 5 个边来表示与路由更改对应的行为。将所有 5 个状态都包含在一个单一的包含状态(App)中后,在 App 和顶级 Routing 控制状态之间就只需要一个边了。
历史记录机制
在反应系统中,经常有必要临时中断一个行为,将其替换为另一个行为,然后恢复被中断的行为。
回想一下之前应用程序路由行为的可视化图像,当应用程序更改 url 时,状态机将转换为 Routing 控制状态并确定是否允许更改路由。如果不是这种情况,就必须回到我们原来的位置。
在退出时记住这个位置并在进入时恢复它(在 App 控制状态下转换为带圈的 H)就能做到这一点。
有了所有之前的约定,可视化图像就可以清楚地说明在应用程序的给定状态下哪些动作是可能的、禁止的或必须发生的。现在来看一个具体的 JavaScript 示例。
示例
考虑一个双人国际象棋游戏的用户界面:
其行为大致如下:
白棋和黑棋交替行动
游戏从白棋开始
白棋移动时,首先选择(单击)要移动的棋子,然后单击该棋子的目的地
如果目的地是有效的(符合国际象棋游戏规则),则该棋子将被移动,然后黑棋开始行动
相同的规则适用于黑棋(选择棋子并指示目的地)。黑棋成功行动后,轮到白棋
直到某一回合结束游戏
这样,我们就有三种表现出不同行为的模式:白棋回合(white pieces turn),黑棋回合(black pieces turn)和游戏结束(game over)。看看在这些模式下映射的事件〜动作(event - action)。在第一种模式下(白棋回合),界面不应理会用户对黑棋的点击。当用户单击一粒白棋,应高亮显示该棋。在第二种模式下反之亦然。在游戏结束模式下,不应理会任何点击:
这些模式可以进一步完善。在“白棋回合”模式下,单击一粒白棋后,我们将再次改变行为。具体而言,下一个单击的方块可能是另一粒白棋,或者是所选白棋要移动到的有效目的地,或者是无效目的地。在第一种情况下,界面应高亮显示新选择的白棋,并按照之前一样操作。在第二种情况下,界面应在其目标位置显示选定的棋子(即执行移动操作)。在第三种情况下,界面应忽略点击。
因此我们确定了另外两个属于"白棋回合"模式的子模式:一个是在选择某个块之前应用程序所处的模式,以及选择一块之后的模式。"白棋回合"详细的事件〜动作映射如下所示:
一旦我们确定了所有模式(控制状态),就可以将它们链接在一个图中,其中节点是控制状态,并且链接(也称为转换)反映事件〜动作的关系:
这里先不谈可视化图像的细节。注解 event [guard] / action 用于标记控制状态之间的转换,并且在进入分层控制状态(例如"白棋回合")后立即进行初始转换(也就是具有原始控制状态 init 的转换)。
现在添加一个撤消(Undo)功能。添加两个新的边(下面的红色转换部分)来更新对应的建模,这些新边对应于应用程序的“黑棋回合”或“白棋回合”转换状态中的“撤消”按钮点击:
最后再添加一个功能:游戏计时器,它将计算从游戏开始到现在经过的秒数。点击时,计时器还将暂停并闪烁:
建模使用了一个计时器(timer),其每秒产生一个事件。机器会记住处理计时器事件(游戏开始时的历史记录伪状态)时的位置,并在完成后恢复游戏的行为:
再次,新功能在可视化图中产生了新的边(红色),并且没有修改现有行为建模。前面的两个模型显示了层次结构和历史伪状态如何实现经济的行为表示。在设计良好的机器中,行为的增量更改应与建模中的增量更改相对应。
用 JavaScript 实现
可以利用Kingly状态机库来用状态机实现事件~动作关系。
Kingly 有一些教程,包括本文中使用的双人国际象棋游戏,以及真实世界的Conduit演示应用的实现。此外还有与React和Vue的预制集成。
下面是第一个国际象棋游戏迭代的机器代码示例。用于定义 Kingly 机器映射的转换记录正好与模型化中出现的转换相对应:
由于可执行状态机已经封装了其状态,因此它仅接收事件(在此相当于用户点击棋盘上的事件)。运行的示例可以是:
如前所述,对于每个已处理事件,机器都会在接口系统(此处为屏幕和国际象棋引擎)上生成要执行的命令(render 和 MOVE_PIECE)。
该机器仅实现应用程序的控制流程:它对国际象棋游戏一无所知,只不过是两个玩家在轮流行动而已。关注点很好地分离开来:通过React组件完成棋盘渲染;应用国际象棋规则并维护棋盘的工作是由国际象棋引擎完成的。同一个机器可以不修改就直接应用于跳棋游戏。
基于模型的测试
在之前的函数式UI文章中,出于测试目的提供了一个纯函数 h(等效于反应函数):
我们将其称为先知(oracle)函数。传递给 h 的输入事件的顺序是特定的用户场景,相应的输出是应执行的计算出的命令。因此,可以使用常规的断言检查技术对用户场景进行单元测试。此外,通过状态机建模,可以以高度的灵活性自动化生成大量测试序列。但是测试序列数量太多的话本身就有问题。
这里我们会回顾要测试的空间、如何使用模型生成测试序列、开发人员面临的两个基本测试问题,以及如何利用基于示例的测试、基于属性的测试和蜕变测试的组合。对该主题的完整讨论将需要单独成文。以下仅介绍基础知识。
呈指数增长的测试空间,面临两个挑战
对于长度为 n(即由输入[e_1,…,e_n]组成)的用户场景,输入测试空间是事件测试空间的笛卡尔积。回到前文的国际象棋游戏示例的第一个迭代,长度为 2 的用户场景就会是[{CLICKED: square1},{CLICKED: square2}]。因为用户可以点击棋盘上的任何方块,所以 CLICKED 事件的测试空间为 8x8 =64。这样,长度为 n 的用户方案的测试空间为 64 ^ n。开发人员经常要面对巨大的测试空间,其随着用户场景的长度呈指数增长。
对于测试空间中的任何测试,必须设计一种方法来验证观察到的测试结果。因此测试人员面临两个挑战,陈宗岳教授将其称为可靠测试集问题和先知问题:
先知问题是指很难或不可能验证给定测试场景的测试结果的情况(……)。
可靠测试集问题意味着,由于通常不可能穷举执行所有可能的测试场景,因此要有效地选择一部分测试场景(可靠的测试集),并获得确定程序正确性的能力是一项挑战。
让我们看看如何通过实际示例解决这两个问题。
申请表格向导
该示例包含一个多步骤工作流的实际案例(不过可视界面已改为使用开放源码设计系统,但行为完全没变)。用户正在申请一个志愿服务机会,为此必须通过一个 5 步的流程,并且每个步骤都有专用的屏幕。从一个步骤移到另一个步骤时,将验证用户输入的数据,然后异步保存。用户流程如下:
像显示的那样,用户流程通常可以用作起点,以迭代方式完善为应用程序的精确模型。
用户流以及错误和数据获取流所表示的核心行为的第一个建模如下:
实现的原型如下所示:
该模型可用于解决可靠测试集问题
尽管可以随机采样测试空间,但有了接口行为模型就可以选择一个有趣的测试序列,所谓有趣是指:
它们代表特定的用户场景,
或满足某些覆盖指标,
或满足特定属性。
为了说明第一点,在上面的可视化图中,应用程序的满意路径以粗体绿色表示。遵循错误路径(红色虚线)时,可以将该场景扩展为包括一些验证错误。
为了使人们对建模和实现产生信心,手动选择一小组涵盖规范关键部分的用户场景是非常有价值的。应提前计算反应函数的预期输出,以便随后与实际输出做对比。换句话说就是需要一个测试先知。
该模型还可用于自动生成测试序列,以满足某些结构化覆盖指标。面向数据的覆盖需要覆盖扩展状态测试空间,而基于转换的覆盖需要覆盖控制状态之间的转换。常见的基于转换的覆盖指标是(按覆盖范围顺序):
当测试集到达模型中的每个状态至少一次时,将实现全状态覆盖。这样的覆盖一般来说效率不够高,因为行为错误只是偶然发现的。
当测试执行模型中的每个转换至少一次时,就可以实现全转换覆盖。这也自动涵盖了所有状态。
全部 n-转换覆盖,意味着测试套件中包含 n 个或更多转换的所有可能转换序列。
当基础模型图的所有可能分支都被测试时(控制结构的详尽测试),即可实现全路径覆盖。这对应先前的覆盖指标,可以做到足够高的 n。
所有单循环路径和所有无循环路径都是更严格的指标,它们关注的是模型中的循环。
下面的简单模型说明了各项覆盖指标:
使用一个专用的图遍历库,可以为表格向导应用程序创建一个抽象测试套件,该套件满足了所有单循环路径指标,最终进行了大约 1500 次测试!在这些测试中,手动选择的是 4 个,总共进行了约 50 个转换(超过 26 个),满足“所有转换”的覆盖指标。这四个转换所涵盖的控制状态如下(nok 对应于 init 伪控制状态——在可视化图中为橙色):
四个测试场景的预期结果(先知)是手动计算的。但是,为成千上万个自动测试计算先知是昂贵或不可能的。这样,基于属性的测试将比基于案例的测试更有效地发现错误。
用基于属性的测试和蜕变测试解决先知问题
向导应用程序的一个属性(在这里是业务规则)可用来在第一个原型实现中发现错误:所有订阅的团队都必须对激励问题有一个非空的答案。一个失败的序列表明,当有一个订阅的团队给出了有效的答案,然后删除该答案,并且用户返回到 Teams 屏幕时,就会发生该错误。
由于该团队仍处于订阅状态,因此用户可以使用空白答案进入 Review 屏幕,这违反了该属性。进一步的调查表明,根本原因是 Back 按钮注册了空答案,而没有检查它是否确实是有效答案。
蜕变属性涉及不同测试序列的测试结果。例如,这里按顺序订阅团队 A 和 B 的用户(序列 t1)应与使用相同数据按团队 B 和 A 的顺序订阅的用户产生相同的应用程序数据(在应用程序流程的最后一步中生成) 。因此,先知函数 h 使得 last(h(t1)) = last(h(t2))。
在没有先知的情况下,蜕变属性非常强大:它们不需要测试实际输出与预期输出,而是需要测试两个(或几个)输出之间的关系。《蜕变测试:挑战与机遇》中提供了关于蜕变测试的完整而出色的评价。这篇文章提到蜕变测试的一大成果是在两个流行的 C 编译器(GCC 和 LLVM)中发现了 100 多个新的错误。
属性(不变项、蜕变性质或其他性质)与问题相关,而不是与解决方案相关。因此,可以在发现错误的同时修改实现,同时保持基于属性的测试完好无损。为了帮助识别属性,高级软件架构师Scott Wlaschin建议采用定向分类法。
总结
用户界面是通过其事件/动作接口与感兴趣的外部系统确定的反应系统,一个纯粹的反应函数可将用户在用户界面上的动作映射到接口系统上的动作。
可以使用状态机来对一大类反应系统(这些反应系统具有指示其行为的数量有限的模式)建模,可以将状态机表达为适用于特定机器状态的一系列反应子函数式。
模型驱动的开发使人们能够以一种吸引各种支持者(产品所有者、测试人员和开发人员)的方式经济地可视化行为。使用函数式 UI,可以对用户场景进行单元测试,从而避免使用复杂的自动化工具和长时间运行的不稳定测试。重要的是,对于基于模型的函数式 UI,可以根据模型中编码的规范自动生成实现和测试。大量且多样的测试以及基于属性的技术的应用导致了更高质量的软件。
不利的一面是,虽然建模是一个迭代过程,但它是一种自上而下的方法,许多开发人员可能没什么发言权。要花更多时间考虑系统的规范和属性,也就是 what 而不是 how,是问题而不是解决方案,这可能需要思维方式的转变。其次,源自状态图的视觉形式不能有效地表示状态之间的数据流动。转换显示了执行流程,但不能代表数据。在大多数情况下,数据变量在图表表示中不可见。第三,并非所有接口都具有有限(或可管理)数量的行为模式。在某些情况下,状态机建模增加的复杂性可能会比其减少的复杂性更多。
在注重安全的领域中,基于模型的函数式 UI 已被广泛用于嵌入式系统的接口原型。Esterel Technologies 的创始人 EricBantégnie 在接受The Atlantic编辑的James Somers采访时解释说(Esterel Technologies 是一家开发基于模型的设计工具的公司):
没有人会手工建造汽车。(……)在许多地方,代码仍然是手工艺品。当你手工编写 10,000 行代码时没什么问题。但是你拥有的系统,拿空客来说会有 3000 万行代码,或者特斯拉有 1 亿行代码[……]——这就会变得非常非常复杂了。
参考链接
Constructing the User Interface With Statecharts by Ian Horrocks.
Metamorphic Testing: A Review of Challenges and Opportunities
John Hughes - How to specify it! A guide to writing properties of pure functions
作者介绍:
Bruno Couriol 拥有法国格兰德高等商学院的电信理学硕士学位、数学学士学位和欧洲工商管理学院的工商管理硕士学位。他的大部分职业生涯都是作为业务顾问,帮助大型公司解决其关键的战略、组织和技术问题。在过去的几年中,他专注于业务、技术和企业家精神的融合。
原文链接:
Functional UI - a Model-Based Approach
评论