软件设计中的限制和适配
2021 年 04 月 28 日
程序的创造,也就是程序的设计和实现,跟艺术创作是很相似的,都是将存在于人类大脑中的东西具象化。但跟艺术创作相比,程序的设计和实现所需要的顾及的因素可能会更多,而逻辑要求必须更精确。人们可以接受断臂的维纳斯,但不能接受一个有明显缺陷或者功能缺失的程序。
在一个实际的项目中,软件的设计者一定会面对诸多限制,例如:
- 工具库问题
- 遗留实现
- 协议
- 用户界面
- ……
对于这些限制,有些可以简单的修改或扩展,比如工具库问题;有些需要大幅调整甚至重构,比如遗留实现;而有些可以通过设计来澄清和适配,比如协议和用户界面。
这里用一个实际的例子来说明如何通过设计来适配用户界面。例子中的软件是FOTA。
注:模型做了很多简化,仅聚焦在问题本身,避免过多涉及细枝末节。
功能要求
FOTA软件要做的事情比较简单,从Server端检查是否有更新包,下载并使用更新包,允许后台自发运行和用户操作。
界面包含多个视图,包括:
- 应用加载中
- 正在检查更新
- 没有更新包
- 有更新包
- 下载中
- 下载完成(等待安装)
从界面设计的角度来讲,需求到此基本就完成了。
架构设计
需求比较简单,因此架构上面没有太多需要设计的。
- 因为需要后台,所以需要一个后台运行的service,这个必然是全局唯一的singleton。
- 因为需要界面,所以需要一个带UI的app。
- 考虑到service的存在,所以数据和逻辑应该放在service中,事件发生时通知app去修改界面。
- app仅仅负责显示,不需要显示无关的逻辑。
所以就是这样:
graph LR
app --> service
service --> app
这样,service至少要提供以下几个接口:
- check:从server获取是否有更新包
- download:从server下载更新包
- get:获取service内部数据
- [event callback]:事件发生时通知app
注意,函数名通常应该以动词开头,这是原则问题,唯一的例外可能是onXXX这类被框架回调的函数。
service的状态机设计和实现思路
按照上面的思路继续往下进行,要考虑一下重入问题。举例来说,假如下载中,app再次调用download接口,应该发生什么?
作为service开发者,你无法拒绝外部在错误的时机调用你的接口,你只能通过设计来解决这个问题。所以这里用到了状态机来防止重入。
service的状态机也是很简单的,就这样:
stateDiagram
[*] --> Idle : start
Idle --> Checking : Check
Checking --> Idle
Idle --> Downloading : Download
Downloading --> Idle
状态机的设计上要注意几点:
- 状态应该用名词、动名词或形容词,而状态切换应该用动词。因为不管状态保持事件有多短,那时是稳定态。而状态切换是由某个动作触发的。
- 状态必然是互斥的,同一时刻不可能既在A状态又在B状态。
基于这个状态机,可以很容易的实现service,无非不过是以下几点:
- get接口被调用时,返回当前时间点的数据
- check/download接口被调用时,检查状态,不正确则返回,正确则切换状态并执行操作
- 操作完成后,保存得到的数据,切换状态
- 状态切换时,上报事件
app的状态机设计和流程图
app应该由service发生的事件驱动view(视图)切换,理论上也应该有一个状态机来跟当前的view相对应。但如果直接将service的状态机和界面需求结合起来,就会发现很多不合理的地方。比如:
- checking和downloading明显对应到界面上的 正在检查更新 和 下载中,但其他的就没有对应状态了,这样app刚打开时,不知道应该显示哪个界面
- 很多状态并非互斥,比如 有更新包 和 下载中,明显 下载中 也是 有更新包 的。
这些问题的原因,是因为界面对应的view并不是状态,不需要真正的互斥;而界面设计者通常对业务逻辑理解不够深刻。
如果界面设计者对于业务逻辑理解有问题,就需要与其沟通并推动其修改。至于view的互斥问题,需要做一些适配工作,比如:
- 重新定义view state(用不同的命名,从而与前面的状态机区分开),要跟界面对应,view state可以这样定义:
- loading
- checking
- downloading
- remote_package_not_exists
- remote_package_exists
- package_half_downloaded
- package_downloaded
- 定义一个规则保证view state的互斥,比如这样的规则按照顺序执行,可以保证view state输出是互斥的:
- 启动时直接是loading
- service state是checking/downloading时,对应checking/downloading
- 如果service中的package下载完成,则是package_downloaded
- 如果service中的package下载到中途,则是package_half_downloaded
- 如果service中的package不存在,但有之前check到的记录,则是remote_package_exists
- 如果service中的package不存在,也没有之前check到的记录,则是remote_package_not_exists
根据上面的规则,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
- app启动后,获取view state,显示初始view;
- 接收事件,事件发生时,获取view state,切换view;
- 接收用户输入,输入发生时,执行操作。
接下来最多就是把连接service的逻辑跟界面逻辑分开,然后就没有其他要设计的了。
总结
FOTA是我重构的一个软件,重构之前的版本由于设计的原因,存在很多问题。无论是在开发调试还是维护过程中,设计的混乱都导致了很多麻烦。
在重构过程中,我发现,app和service分层时,state的命名出现冲突,则很容易造成困扰。
我又进一步发现,重构之前的设计中,很多问题都来自于不规范的命名,引起了不同分层理解上的冲突和误解。然后是模块责任不清晰,某些模块做了本不属于它们的工作,导致遇到问题时,追踪起来由其复杂。
因此,在重构过程中,我通过刻意的命名限制,比如动词/名词等词性的运用,理清了逻辑。进一步明确规定了各个模块的责任。收到了不错的效果。重构后的版本,无论是跟产品经理、界面设计者、QA的沟通,都比较顺畅。而调试和维护过程也比较轻松。
对这个重构过程,我有一点思考。
本质上,在软件设计中,面对的限制来自于上下两方面。来自上方的限制是用户界面设计的要求,来自下方的限制是所使用的工具能力的限制,比如平台/工具库/协议……
软件设计的目的,不光是要给计算机一个合理的执行流程;同时,也要用简单清晰的逻辑,让自己和其他人员清楚的理解软件的运行过程。
因此,软件设计人员需要从设计的角度,尽量考虑到所有细节,达成逻辑自洽。同时,也要让用户界面设计师能够理解可能存在的问题点,并在用户界面设计时加以修改,以配合软件产品的实现。
我想,这篇文章,就是我对这个软件所能榨取的最后价值吧。