软件设计中的限制和适配

2021 年 04 月 28 日

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

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

  • 工具库问题
  • 遗留实现
  • 协议
  • 用户界面
  • ……

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

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

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

功能要求

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

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

  • 应用加载中
  • 正在检查更新
  • 没有更新包
  • 有更新包
  • 下载中
  • 下载完成(等待安装)

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

架构设计

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

  1. 因为需要后台,所以需要一个后台运行的service,这个必然是全局唯一的singleton。
  2. 因为需要界面,所以需要一个带UI的app。
  3. 考虑到service的存在,所以数据和逻辑应该放在service中,事件发生时通知app去修改界面。
  4. 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输出是互斥的:
    1. 启动时直接是loading
    2. service state是checking/downloading时,对应checking/downloading
    3. 如果service中的package下载完成,则是package_downloaded
    4. 如果service中的package下载到中途,则是package_half_downloaded
    5. 如果service中的package不存在,但有之前check到的记录,则是remote_package_exists
    6. 如果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的沟通,都比较顺畅。而调试和维护过程也比较轻松。

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

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

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

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

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

Top