Cliff的工作室
软件设计中的限制和适配

程序的创造,也就是程序的设计和实现,跟艺术创作是很相似的,都是将存在于人类大脑中的东西具象化。但跟艺术创作相比,程序的设计和实现所需要的顾及的因素可能会更多,而逻辑要求必须更精确。人们可以接受断臂的维纳斯,但不能接受一个有明显缺陷或者功能缺失的程序。

在一个实际的项目中,软件的设计者一定会面对诸多限制,例如:

对于这些限制,有些可以简单的修改或扩展,比如工具库问题;有些需要大幅调整甚至重构,比如遗留实现;而有些可以通过设计来澄清和适配,比如协议和用户界面。

这里用一个实际的例子来说明如何通过设计来适配用户界面。例子中的软件是FOTA。

注:模型做了很多简化,仅聚焦在问题本身,避免过多涉及细枝末节。

功能要求

FOTA软件要做的事情比较简单,从Server端检查是否有更新包,下载并使用更新包,允许后台自发运行和用户操作。

界面包含多个视图,包括:

从界面设计的角度来讲,需求到此基本就完成了。

架构设计

需求比较简单,因此架构上面没有太多需要设计的。

  1. 因为需要后台,所以需要一个后台运行的service,这个必然是全局唯一的singleton。
  2. 因为需要界面,所以需要一个带UI的app。
  3. 考虑到service的存在,所以数据和逻辑应该放在service中,事件发生时通知app去修改界面。
  4. app仅仅负责显示,不需要显示无关的逻辑。

所以就是这样:

graph LR app --> service service --> app

这样,service至少要提供以下几个接口:

注意,函数名通常应该以动词开头,这是原则问题,唯一的例外可能是onXXX这类被框架回调的函数。

service的状态机设计和实现思路

按照上面的思路继续往下进行,要考虑一下重入问题。举例来说,假如下载中,app再次调用download接口,应该发生什么?

作为service开发者,你无法拒绝外部在错误的时机调用你的接口,你只能通过设计来解决这个问题。所以这里用到了状态机来防止重入。

service的状态机也是很简单的,就这样:

stateDiagram [*] --> Idle : start Idle --> Checking : Check Checking --> Idle Idle --> Downloading : Download Downloading --> Idle

状态机的设计上要注意几点:

  • 状态应该用名词、动名词或形容词,而状态切换应该用动词。因为不管状态保持事件有多短,那时是稳定态。而状态切换是由某个动作触发的。
  • 状态必然是互斥的,同一时刻不可能既在A状态又在B状态。

基于这个状态机,可以很容易的实现service,无非不过是以下几点:

app的状态机设计和流程图

app应该由service发生的事件驱动view(视图)切换,理论上也应该有一个状态机来跟当前的view相对应。但如果直接将service的状态机和界面需求结合起来,就会发现很多不合理的地方。比如:

这些问题的原因,是因为界面对应的view并不是状态,不需要真正的互斥;而界面设计者通常对业务逻辑理解不够深刻。

如果界面设计者对于业务逻辑理解有问题,就需要与其沟通并推动其修改。至于view的互斥问题,需要做一些适配工作,比如:

根据上面的规则,view state跟界面就做到互斥并且一一对应了。这样app的状态机就是这样:

stateDiagram [*] --> loading : start loading --> checking loading --> downloading loading --> package_downloaded loading --> package_half_downloaded loading --> remote_package_exists loading --> remote_package_not_exists checking --> package_downloaded checking --> package_half_downloaded checking --> remote_package_exists checking --> remote_package_not_exists downloading --> package_downloaded downloading --> package_half_downloaded package_downloaded --> checking package_half_downloaded --> checking remote_package_exists --> checking remote_package_not_exists --> checking package_half_downloaded --> downloading remote_package_exists --> downloading remote_package_not_exists --> downloading

好吧,切换过程很混乱,但实际上描述这些切换过程没有太大意义。重要的是,现在我们得到了一个互斥的view state数组,并将其与view对应了起来。这样,在任一时刻,我们都可以根据service的state和内部保存的数据,得到一个唯一的view state,然后可以显示。

所以,在app状态机的帮助下,我们得到了app的流程图:

stateDiagram [*] --> DisplayView : Start EventListener --> DisplayView : Event DisplayView --> DisplayView : Switch DisplayView --> Execute : Input DisplayView --> [*] : Exit

接下来最多就是把连接service的逻辑跟界面逻辑分开,然后就没有其他要设计的了。

总结

FOTA是我重构的一个软件,重构之前的版本由于设计的原因,存在很多问题。无论是在开发调试还是维护过程中,设计的混乱都导致了很多麻烦。

在重构过程中,我发现,app和service分层时,state的命名出现冲突,则很容易造成困扰。

我又进一步发现,重构之前的设计中,很多问题都来自于不规范的命名,引起了不同分层理解上的冲突和误解。然后是模块责任不清晰,某些模块做了本不属于它们的工作,导致遇到问题时,追踪起来由其复杂。

因此,在重构过程中,我通过刻意的命名限制,比如动词/名词等词性的运用,理清了逻辑。进一步明确规定了各个模块的责任。收到了不错的效果。重构后的版本,无论是跟产品经理、界面设计者、QA的沟通,都比较顺畅。而调试和维护过程也比较轻松。

对这个重构过程,我有一点思考。

本质上,在软件设计中,面对的限制来自于上下两方面。来自上方的限制是用户界面设计的要求,来自下方的限制是所使用的工具能力的限制,比如平台/工具库/协议……

软件设计的目的,不光是要给计算机一个合理的执行流程;同时,也要用简单清晰的逻辑,让自己和其他人员清楚的理解软件的运行过程。

因此,软件设计人员需要从设计的角度,尽量考虑到所有细节,达成逻辑自洽。同时,也要让用户界面设计师能够理解可能存在的问题点,并在用户界面设计时加以修改,以配合软件产品的实现。

我想,这篇文章,就是我对这个软件所能榨取的最后价值吧。

2021 年 04 月 28 日 09 时 23 分